diff --git a/cmd/handlers.go b/cmd/handlers.go index 040b94a69..cd2c0028d 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -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")) diff --git a/cmd/public.go b/cmd/public.go index b81e51ba7..5859023ad 100644 --- a/cmd/public.go +++ b/cmd/public.go @@ -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 } diff --git a/cmd/subscribers.go b/cmd/subscribers.go index a0b8a6f88..cc81aff07 100644 --- a/cmd/subscribers.go +++ b/cmd/subscribers.go @@ -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. diff --git a/docs/docs/content/apis/subscribers.md b/docs/docs/content/apis/subscribers.md index 6b99210f8..bce3afb58 100644 --- a/docs/docs/content/apis/subscribers.md +++ b/docs/docs/content/apis/subscribers.md @@ -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. | @@ -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. diff --git a/docs/swagger/collections.yaml b/docs/swagger/collections.yaml index f12732ee9..08094032f 100644 --- a/docs/swagger/collections.yaml +++ b/docs/swagger/collections.yaml @@ -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. @@ -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: diff --git a/i18n/en.json b/i18n/en.json index f39a035ff..5c2711639 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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.", diff --git a/internal/core/subscribers.go b/internal/core/subscribers.go index 8a764e8a9..dbbc2b0ab 100644 --- a/internal/core/subscribers.go +++ b/internal/core/subscribers.go @@ -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. diff --git a/models/subscribers.go b/models/subscribers.go index 0ee42d920..5c107e9e6 100644 --- a/models/subscribers.go +++ b/models/subscribers.go @@ -19,6 +19,10 @@ const ( SubscriptionStatusUnconfirmed = "unconfirmed" SubscriptionStatusConfirmed = "confirmed" SubscriptionStatusUnsubscribed = "unsubscribed" + + AttribsConflictIncoming = "incoming" + AttribsConflictExisting = "existing" + AttribsConflictReplace = "replace" ) // Subscribers represents a slice of Subscriber.