package handler import ( "context" "errors" "html/template" "net/http" "sort" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/app/unsublink" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/observability/errs" ) // The recipient-facing unsubscribe endpoints. PUBLIC and unauthenticated by // design: the only credential is the opaque token in the path, minted per // recipient when the email was sent. // // Two token generations share the route. A short stored ticket is what is // minted today, because the opt-out address is the one URL a recipient reads // in full (issue #498); a self-contained signed token is what links already // in inboxes carry, and what still ships when a ticket cannot be stored. // unsublink.IsTicket decides which, on shape, so neither costs the other a // lookup. // // GET /unsubscribe/:token a click on the link: a confirm page // POST /unsubscribe/:token the confirm button, or the mail // client's RFC 8058 one-click POST // (body List-Unsubscribe=One-Click), // which suppresses with no page // POST /unsubscribe/:token/resubscribe the "unsubscribed by mistake" button // // A GET never changes anything, because link scanners and preview fetchers // follow every link in an email; only a POST suppresses. func (h *Handler) UnsubscribePage(c *gin.Context) { claims, ok := h.unsubscribeClaims(c) if !ok { return } t := unsubCopyFor(c.GetHeader("Accept-Language")) if claims.ContactID == uuid.Nil { renderUnsubPage(c, http.StatusOK, unsubView{Title: t.TestTitle, Body: t.TestBody}) return } renderUnsubPage(c, http.StatusOK, unsubView{Title: t.ConfirmTitle, Body: t.ConfirmBody, Confirm: c.Request.URL.Path}) } // unsubscribeBodyLimit caps the public POST bodies. The engine-wide limit is // registered after these routes, so it does not cover them; a one-click or // confirm body is a few bytes. const unsubscribeBodyLimit = 16 << 10 func (h *Handler) UnsubscribeSubmit(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, unsubscribeBodyLimit) oneClick := strings.EqualFold(strings.TrimSpace(c.PostForm("List-Unsubscribe")), "One-Click") confirmed := c.PostForm("confirm") == "1" t := unsubCopyFor(c.GetHeader("Accept-Language")) claims, err := h.verifyUnsubscribeToken(c.Request.Context(), c.Param("token")) if err != nil { if oneClick { // RFC 8058: a bad or expired link is terminal, so 200 stops the // provider retrying; only a genuine server failure gets a 5xx, // which is what a lookup that could not run is. if err == errUnsubUnavailable { c.Status(http.StatusBadGateway) return } c.Status(http.StatusOK) return } renderUnsubPage(c, unsubStatus(err), unsubInvalid(t, err)) return } if claims.ContactID == uuid.Nil { if oneClick { c.Status(http.StatusOK) return } renderUnsubPage(c, http.StatusOK, unsubView{Title: t.TestTitle, Body: t.TestBody}) return } // A browser POST without the confirm field is not the button: show the // confirm page again rather than act on it. if !oneClick && !confirmed { renderUnsubPage(c, http.StatusOK, unsubView{Title: t.ConfirmTitle, Body: t.ConfirmBody, Confirm: c.Request.URL.Path}) return } via := "link" if oneClick { via = "one_click" } xerr := h.AdvancedService.UnsubscribeFromLink(c.Request.Context(), claims.OrgID, claims.CampaignID, claims.ContactID, via) if oneClick { if xerr != nil && xerr.Code != errx.BadRequest { c.Status(http.StatusBadGateway) return } c.Status(http.StatusOK) return } if xerr != nil { renderUnsubPage(c, http.StatusOK, unsubView{Title: t.FailedTitle, Body: t.FailedBody}) return } renderUnsubPage(c, http.StatusOK, unsubView{Title: t.DoneTitle, Body: t.DoneBody, Resubscribe: c.Request.URL.Path + "/resubscribe"}) } func (h *Handler) UnsubscribeUndo(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, unsubscribeBodyLimit) claims, ok := h.unsubscribeClaims(c) if !ok { return } t := unsubCopyFor(c.GetHeader("Accept-Language")) if claims.ContactID == uuid.Nil { renderUnsubPage(c, http.StatusBadRequest, unsubInvalid(t, unsublink.ErrInvalid)) return } if xerr := h.AdvancedService.Resubscribe(c.Request.Context(), claims.OrgID, claims.ContactID); xerr != nil { renderUnsubPage(c, http.StatusOK, unsubView{Title: t.FailedTitle, Body: t.FailedResubscribeBody}) return } renderUnsubPage(c, http.StatusOK, unsubView{Title: t.ResubscribedTitle, Body: t.ResubscribedBody}) } // errUnsubUnavailable is a ticket lookup that failed rather than a link that // is not real. Told apart because the answers differ in both directions: the // recipient is asked to try again instead of told their link is invalid, and // a one-click POST gets a retryable status instead of a terminal one. var errUnsubUnavailable = errors.New("unsubscribe link store unavailable") func (h *Handler) verifyUnsubscribeToken(ctx context.Context, token string) (unsublink.Claims, error) { if unsublink.IsTicket(token) { if h.UnsubscribeTickets == nil { return unsublink.Claims{}, unsublink.ErrInvalid } t, err := h.UnsubscribeTickets.Resolve(ctx, token) if err != nil { errs.CaptureException(err) return unsublink.Claims{}, errUnsubUnavailable } if t == nil { return unsublink.Claims{}, unsublink.ErrInvalid } claims := unsublink.Claims{ OrgID: t.OrganizationID, CampaignID: t.CampaignID, ContactID: t.ContactID, ExpiresAt: t.ExpiresAt, } if !time.Now().Before(t.ExpiresAt) { return claims, unsublink.ErrExpired } return claims, nil } if h.UnsubscribeLinks == nil { return unsublink.Claims{}, unsublink.ErrInvalid } return h.UnsubscribeLinks.Verify(token, time.Now()) } func (h *Handler) unsubscribeClaims(c *gin.Context) (unsublink.Claims, bool) { claims, err := h.verifyUnsubscribeToken(c.Request.Context(), c.Param("token")) if err != nil { renderUnsubPage(c, unsubStatus(err), unsubInvalid(unsubCopyFor(c.GetHeader("Accept-Language")), err)) return claims, false } return claims, true } // unsubStatus is 503 for a lookup that failed, so a recipient reloading gets // the page rather than a cached refusal, and 400 for a link that is not real. func unsubStatus(err error) int { if err == errUnsubUnavailable { return http.StatusServiceUnavailable } return http.StatusBadRequest } func unsubInvalid(t unsubCopy, err error) unsubView { switch err { case errUnsubUnavailable: return unsubView{Title: t.RetryTitle, Body: t.RetryBody} case unsublink.ErrExpired: return unsubView{Title: t.ExpiredTitle, Body: t.ReplyBody} } return unsubView{Title: t.InvalidTitle, Body: t.ReplyBody} } type unsubView struct { Title string Body string Confirm string // POST target of the confirm button, when shown Resubscribe string // POST target of the resubscribe button, when shown // Set by renderUnsubPage from the request's language. Lang, ConfirmButton, ResubscribeButton string RTL bool } // unsubLanguage picks the page's language from the browser's // Accept-Language: the most preferred one the page is written in, English // when none is. func unsubLanguage(acceptLanguage string) string { type pref struct { code string q float64 } var prefs []pref for _, part := range strings.Split(acceptLanguage, ",") { tag, params, _ := strings.Cut(strings.TrimSpace(part), ";") q := 1.0 if v, ok := strings.CutPrefix(strings.TrimSpace(params), "q="); ok { f, err := strconv.ParseFloat(v, 64) // A qvalue is 0 to 1; NaN fails both comparisons. if err != nil || !(f >= 0 && f <= 1) { continue } q = f } if code := unsubLanguageCode(tag); code != "" && q > 0 { prefs = append(prefs, pref{code, q}) } } sort.SliceStable(prefs, func(i, j int) bool { return prefs[i].q > prefs[j].q }) if len(prefs) > 0 { return prefs[0].code } return "en" } // unsubLanguageCode is the page language a tag asks for, or "" for one it // has none of. Norwegian and Tagalog tags name the language written as nb and // fil, and Taiwan, Hong Kong and Macau read Traditional Chinese. func unsubLanguageCode(tag string) string { parts := strings.Split(strings.ToLower(strings.ReplaceAll(tag, "_", "-")), "-") code := parts[0] switch code { case "no", "nn": code = "nb" case "tl": code = "fil" case "zh": for _, p := range parts[1:] { if p == "hant" || p == "tw" || p == "hk" || p == "mo" { code = "zh-Hant" } } } if _, ok := unsubCopies[code]; !ok { return "" } return code } func unsubCopyFor(acceptLanguage string) unsubCopy { return unsubCopies[unsubLanguage(acceptLanguage)] } // A neutral page: the email came from the customer's mailbox, so the page // names no brand and carries no scripts or external assets. var unsubTemplate = template.Must(template.New("unsubscribe").Parse(`
{{.Body}}
{{if .Confirm}}{{end}} {{if .Resubscribe}}{{end}} `)) func renderUnsubPage(c *gin.Context, status int, v unsubView) { v.Lang = unsubLanguage(c.GetHeader("Accept-Language")) t := unsubCopies[v.Lang] v.ConfirmButton, v.ResubscribeButton, v.RTL = t.ConfirmButton, t.ResubscribeButton, t.RTL c.Header("Vary", "Accept-Language") c.Header("Cache-Control", "no-store") c.Header("X-Robots-Tag", "noindex") c.Status(status) // A static page with one form that posts back to this origin. No script, // no images, nothing embedded, and it must not be framed: the whole page // is a one-click state change. c.Header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") c.Header("Content-Type", "text/html; charset=utf-8") _ = unsubTemplate.Execute(c.Writer, v) }