mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 16:02:02 +00:00
299 lines
11 KiB
Go
299 lines
11 KiB
Go
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(`<!doctype html><html lang="{{.Lang}}"{{if .RTL}} dir="rtl"{{end}}><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>{{.Title}}</title>
|
|
<style>body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;max-width:32rem;margin:4rem auto;padding:0 1.25rem;color:#0f172a;line-height:1.5}
|
|
h1{font-size:1.25rem;margin:0 0 .5rem}p{color:#475569;margin:0 0 1.25rem}
|
|
button{font:inherit;padding:.55rem 1rem;border-radius:.375rem;border:1px solid #0284c7;background:#0284c7;color:#fff;cursor:pointer}
|
|
button.secondary{background:#fff;color:#0f172a;border-color:#cbd5e1}</style></head>
|
|
<body><h1>{{.Title}}</h1><p>{{.Body}}</p>
|
|
{{if .Confirm}}<form method="post" action="{{.Confirm}}"><input type="hidden" name="confirm" value="1"><button type="submit">{{.ConfirmButton}}</button></form>{{end}}
|
|
{{if .Resubscribe}}<form method="post" action="{{.Resubscribe}}"><button type="submit" class="secondary">{{.ResubscribeButton}}</button></form>{{end}}
|
|
</body></html>`))
|
|
|
|
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)
|
|
}
|