Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ func initHTTPHandlers(e *echo.Echo, a *App) {
g.GET("/api/subscribers/:id/bounces", pm(hasID(a.GetSubscriberBounces), "bounces:get"))
g.DELETE("/api/subscribers/:id/bounces", pm(hasID(a.DeleteSubscriberBounces), "bounces:manage"))
g.POST("/api/subscribers", pm(a.CreateSubscriber, "subscribers:manage"))
g.POST("/api/subscribers/subscription", pm(a.SubscribeSubscriber, "subscribers:manage"))
g.PUT("/api/subscribers/:id", pm(hasID(a.UpdateSubscriber), "subscribers:manage"))
g.PATCH("/api/subscribers/:id", pm(hasID(a.PatchSubscriber), "subscribers:manage"))
g.POST("/api/subscribers/:id/optin", pm(hasID(a.SubscriberSendOptin), "subscribers:manage"))
Expand Down
36 changes: 8 additions & 28 deletions cmd/public.go
Original file line number Diff line number Diff line change
Expand Up @@ -769,38 +769,18 @@ func (a *App) processSubForm(c echo.Context) (bool, error) {
}
}

// Insert the subscriber into the DB.
_, hasOptin, err := a.core.InsertSubscriber(models.Subscriber{
// Subscribe the subscriber (create or update on conflict).
_, _, hasOptin, err := a.core.SubscribeSubscriber(models.Subscriber{
Name: req.Name,
Email: req.Email,
Status: models.SubscriberStatusEnabled,
}, nil, listUUIDs, false, true)
if err == nil {
return hasOptin, nil
}

// Insert returned an error. Examine it.
var lastErr = err

// Subscriber already exists. Update subscriptions in the DB.
if e, ok := err.(*echo.HTTPError); ok && e.Code == http.StatusConflict {
// Get the subscriber from the DB by their email.
sub, err := a.core.GetSubscriber(0, "", req.Email)
if err != nil {
return false, err
}

// Update the subscriber's subscriptions in the DB.
_, hasOptin, err := a.core.UpdateSubscriberWithLists(sub.ID, sub, nil, listUUIDs, false, false, true, nil, true)
if err == nil {
return hasOptin, nil
}, nil, listUUIDs, false, models.AttribsConflictIncoming)
if err != nil {
if e, ok := err.(*echo.HTTPError); ok {
return false, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("%s", e.Message))
}
lastErr = err
return false, echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("public.errorProcessingRequest"))
}

// Something else went wrong.
if e, ok := lastErr.(*echo.HTTPError); ok {
return false, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("%s", e.Message))
}
return false, echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("public.errorProcessingRequest"))
return hasOptin, nil
}
65 changes: 65 additions & 0 deletions cmd/subscribers.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,71 @@ func (a *App) CreateSubscriber(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{sub})
}

type subscribeReq struct {
models.Subscriber
Lists []int `json:"lists"`
PreconfirmSubs bool `json:"preconfirm_subscriptions"`
AttribsConflict string `json:"attribs_conflict"`
}

type subscribeResp struct {
Subscriber models.Subscriber `json:"subscriber"`
Created bool `json:"created"`
HasOptin bool `json:"has_optin"`
}

// SubscribeSubscriber handles create-or-update subscription requests with optional attribs.
func (a *App) SubscribeSubscriber(c echo.Context) error {
user := auth.GetUser(c)

var req subscribeReq
if err := c.Bind(&req); err != nil {
return err
}

validated, err := a.importer.ValidateFields(subimporter.SubReq{
Subscriber: req.Subscriber,
Lists: req.Lists,
PreconfirmSubs: req.PreconfirmSubs,
})
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}

listIDs := user.FilterListsByPerm(auth.PermTypeManage, req.Lists)
if len(req.Lists) > 0 && len(listIDs) == 0 {
return echo.NewHTTPError(http.StatusForbidden, a.i18n.Ts("globals.messages.permissionDenied", "name", "lists"))
}

attribsConflict := strings.TrimSpace(req.AttribsConflict)
if attribsConflict == "" {
attribsConflict = models.AttribsConflictIncoming
}
switch attribsConflict {
case models.AttribsConflictIncoming, models.AttribsConflictExisting, models.AttribsConflictReplace:
default:
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("subscribers.invalidAttribsConflict"))
}

sub := validated.Subscriber
if sub.Status == "" {
sub.Status = models.SubscriberStatusEnabled
}

out, created, hasOptin, err := a.core.SubscribeSubscriber(sub, listIDs, nil, req.PreconfirmSubs, attribsConflict)
if err != nil {
return err
}

maskRestrictedSubLists(user, &out)

return c.JSON(http.StatusOK, okResp{subscribeResp{
Subscriber: out,
Created: created,
HasOptin: hasOptin,
}})
}

// UpdateSubscriber handles modification of a subscriber.
func (a *App) UpdateSubscriber(c echo.Context) error {
// Get the authenticated user.
Expand Down
66 changes: 66 additions & 0 deletions docs/docs/content/apis/subscribers.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
| GET | [/api/subscribers/{subscriber_id}/export](#get-apisubscriberssubscriber_idexport) | Export a specific subscriber. |
| GET | [/api/subscribers/{subscriber_id}/bounces](#get-apisubscriberssubscriber_idbounces) | Retrieve a subscriber bounce records. |
| POST | [/api/subscribers](#post-apisubscribers) | Create a new subscriber. |
| POST | [/api/subscribers/subscription](#post-apisubscriberssubscription) | Create or update a subscriber subscription. |
| POST | [/api/subscribers/{subscriber_id}/optin](#post-apisubscriberssubscriber_idoptin) | Sends optin confirmation email to subscribers. |
| POST | [/api/public/subscription](#post-apipublicsubscription) | Create a public subscription. |
| PUT | [/api/subscribers/lists](#put-apisubscriberslists) | Modify subscriber list memberships. |
Expand Down Expand Up @@ -342,6 +343,71 @@ curl -u 'api_username:access_token' 'http://localhost:9000/api/subscribers' -H '

______________________________________________________________________

#### POST /api/subscribers/subscription

Create or update a subscriber subscription. If the e-mail address is new, a subscriber is created. If the e-mail already exists, the subscriber's list memberships are updated (including resubscribing unsubscribed users) and attributes are updated. Opt-in confirmation e-mails are sent automatically for double opt-in lists when applicable.

This endpoint is intended for backend integrations that need a single call to handle signup flows with attributes, replacing the multi-step create / search / opt-in pattern.

##### Parameters

| Name | Type | Required | Description |
|:-------------------------|:-----------|:---------|:------------------------------------------------------------------------------------------------------------------------------|
| email | string | Yes | Subscriber's email address. |
| name | string | | Subscriber's name. Defaults to the e-mail local-part if omitted. |
| status | string | | Subscriber's status: `enabled`, `blocklisted`. Defaults to `enabled`. |
| lists | number\[\] | | List of list IDs to subscribe to. If omitted, existing list memberships are unchanged on update. |
| attribs | JSON | | Optional JSON object attributes for the subscriber. |
| attribs_conflict | string | | How to apply `attribs` when the subscriber already exists: `incoming` (default), `existing`, or `replace`. See below. |
| preconfirm_subscriptions | bool | | If true, subscriptions are marked as confirmed and no opt-in emails are sent for double opt-in lists. |

When the e-mail address already exists, `attribs_conflict` controls how incoming attributes are combined with existing ones:

| Value | Behavior |
|-------|----------|
| `incoming` | For each key in `attribs`, incoming values replace existing values. Keys not in the request are unchanged. Nested objects are merged recursively with incoming values winning on conflict. |
| `existing` | For each key in `attribs`, existing values are kept when already set. Incoming values are applied only for keys that are missing or null. Nested objects are merged recursively with existing values winning on conflict. |
| `replace` | The entire `attribs` object is replaced with the incoming value. |

##### Example Request

```shell
curl -u 'api_username:access_token' 'http://localhost:9000/api/subscribers/subscription' -H 'Content-Type: application/json' \
--data '{"email":"subscriber@domain.com","name":"The Subscriber","lists":[1],"attribs":{"signup_location":"ios"}}'
```

##### Example Response

```json
{
"data": {
"subscriber": {
"id": 3,
"created_at": "2019-07-03T12:17:29.735507+05:30",
"updated_at": "2019-07-03T12:17:29.735507+05:30",
"uuid": "eb420c55-4cfb-4972-92ba-c93c34ba475d",
"email": "subscriber@domain.com",
"name": "The Subscriber",
"attribs": {
"signup_location": "ios"
},
"status": "enabled",
"lists": [
{
"subscription_status": "unconfirmed",
"id": 1,
"name": "Default list"
}
]
},
"created": true,
"has_optin": true
}
}
```

______________________________________________________________________

#### POST /api/subscribers/{subscribers_id}/optin

Sends opt-in confirmation email to subscribers.
Expand Down
58 changes: 58 additions & 0 deletions docs/swagger/collections.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,29 @@ paths:
data:
type: boolean

/subscribers/subscription:
post:
description: creates or updates a subscriber subscription with optional attributes.
operationId: subscribeSubscriber
tags:
- Subscribers
requestBody:
description: subscription request
content:
application/json:
schema:
$ref: "#/components/schemas/SubscribeSubscriber"
responses:
"200":
description: subscription result
content:
application/json:
schema:
type: object
properties:
data:
$ref: "#/components/schemas/SubscribeSubscriberResponse"

"/subscribers/{id}":
get:
description: handles the retrieval of a single subscriber by ID.
Expand Down Expand Up @@ -3718,6 +3741,41 @@ components:
type: object
additionalProperties: true

SubscribeSubscriber:
type: object
required:
- email
properties:
email:
type: string
name:
type: string
status:
type: string
lists:
type: array
items:
type: integer
attribs:
type: object
additionalProperties: true
attribs_conflict:
type: string
enum: ["incoming", "existing", "replace"]
default: incoming
preconfirm_subscriptions:
type: boolean

SubscribeSubscriberResponse:
type: object
properties:
subscriber:
$ref: "#/components/schemas/Subscriber"
created:
type: boolean
has_optin:
type: boolean

UpdateSubscriber:
type: object
properties:
Expand Down
1 change: 1 addition & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,7 @@
"subscribers.errorSendingOptin": "Error sending opt-in e-mail.",
"subscribers.export": "Export",
"subscribers.invalidAction": "Invalid action.",
"subscribers.invalidAttribsConflict": "Invalid attribs_conflict. Use incoming, existing, or replace.",
"subscribers.invalidEmail": "Invalid email.",
"subscribers.invalidJSON": "Invalid JSON in attributes.",
"subscribers.invalidName": "Invalid name.",
Expand Down
90 changes: 90 additions & 0 deletions internal/core/subscribers.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,96 @@ func (c *Core) InsertSubscriber(sub models.Subscriber, listIDs []int, listUUIDs
return out, hasOptin, nil
}

// SubscribeSubscriber creates a subscriber or, if the e-mail already exists, updates their
// subscriptions and attributes. The second return value indicates whether a new subscriber
// was created; the third indicates whether an opt-in confirmation e-mail was sent.
func (c *Core) SubscribeSubscriber(sub models.Subscriber, listIDs []int, listUUIDs []string, preconfirm bool, attribsConflict string) (models.Subscriber, bool, bool, error) {
incomingAttribs := sub.Attribs

out, hasOptin, err := c.InsertSubscriber(sub, listIDs, listUUIDs, preconfirm, true)
if err == nil {
return out, true, hasOptin, nil
}

e, ok := err.(*echo.HTTPError)
if !ok || e.Code != http.StatusConflict {
return models.Subscriber{}, false, false, err
}

existing, err := c.GetSubscriber(0, "", sub.Email)
if err != nil {
return models.Subscriber{}, false, false, err
}

updateSub := existing
if strings.TrimSpace(sub.Name) != "" {
updateSub.Name = sub.Name
}
if sub.Status != "" {
updateSub.Status = sub.Status
}
if len(incomingAttribs) > 0 {
updateSub.Attribs = applyAttribsConflict(existing.Attribs, incomingAttribs, attribsConflict)
}

out, hasOptin, err = c.UpdateSubscriberWithLists(existing.ID, updateSub, listIDs, listUUIDs, preconfirm, false, true, nil, true)
if err != nil {
return models.Subscriber{}, false, false, err
}

return out, false, hasOptin, nil
}

func applyAttribsConflict(existing, incoming models.JSON, conflict string) models.JSON {
switch conflict {
case models.AttribsConflictReplace:
return incoming
case models.AttribsConflictExisting:
return fillJSON(existing, incoming)
default:
return mergeJSON(existing, incoming)
}
}

// mergeJSON deep-merges src into dst. Nested maps are merged recursively; incoming values replace existing.
func mergeJSON(dst, src models.JSON) models.JSON {
if dst == nil {
dst = models.JSON{}
}
for k, v := range src {
if dstVal, ok := dst[k]; ok {
if dstMap, ok := dstVal.(map[string]any); ok {
if srcMap, ok := v.(map[string]any); ok {
dst[k] = mergeJSON(models.JSON(dstMap), models.JSON(srcMap))
continue
}
}
}
dst[k] = v
}
return dst
}

// fillJSON deep-merges src into dst, keeping existing values on conflict. Only missing or null keys are set.
func fillJSON(dst, src models.JSON) models.JSON {
if dst == nil {
dst = models.JSON{}
}
for k, v := range src {
dstVal, ok := dst[k]
if !ok || dstVal == nil {
dst[k] = v
continue
}
if dstMap, ok := dstVal.(map[string]any); ok {
if srcMap, ok := v.(map[string]any); ok {
dst[k] = fillJSON(models.JSON(dstMap), models.JSON(srcMap))
}
}
}
return dst
}

// UpdateSubscriber updates a subscriber's properties.
func (c *Core) UpdateSubscriber(id int, sub models.Subscriber) (models.Subscriber, error) {
// Format raw JSON attributes.
Expand Down
4 changes: 4 additions & 0 deletions models/subscribers.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const (
SubscriptionStatusUnconfirmed = "unconfirmed"
SubscriptionStatusConfirmed = "confirmed"
SubscriptionStatusUnsubscribed = "unsubscribed"

AttribsConflictIncoming = "incoming"
AttribsConflictExisting = "existing"
AttribsConflictReplace = "replace"
)

// Subscribers represents a slice of Subscriber.
Expand Down