package handler import ( "html/template" "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/app/unsublink" "github.com/warmbly/warmbly/internal/errx" ) // The recipient-facing unsubscribe endpoints. PUBLIC and unauthenticated by // design: the only credential is the signed token in the path, minted per // recipient when the email was sent. // // 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 } if claims.ContactID == uuid.Nil { renderUnsubPage(c, http.StatusOK, unsubView{Title: "This was a test email", Body: "Test sends carry a link that is not tied to anyone, so there is nothing to unsubscribe."}) return } renderUnsubPage(c, http.StatusOK, unsubView{ Title: "Unsubscribe from these emails?", Body: "Confirm and you will not receive further emails from this sender.", 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" claims, err := h.verifyUnsubscribeToken(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. c.Status(http.StatusOK) return } renderUnsubPage(c, http.StatusBadRequest, unsubInvalid(err)) return } if claims.ContactID == uuid.Nil { if oneClick { c.Status(http.StatusOK) return } renderUnsubPage(c, http.StatusOK, unsubView{Title: "This was a test email", Body: "Test sends carry a link that is not tied to anyone, so there is nothing to unsubscribe."}) 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: "Unsubscribe from these emails?", Body: "Confirm and you will not receive further emails from this sender.", 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: "We couldn't process that link", Body: "The link is no longer valid. Reply to the email instead and the sender will stop."}) return } renderUnsubPage(c, http.StatusOK, unsubView{ Title: "You've been unsubscribed", Body: "You will not receive further emails from this sender.", 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 } if claims.ContactID == uuid.Nil { renderUnsubPage(c, http.StatusBadRequest, unsubInvalid(unsublink.ErrInvalid)) return } if xerr := h.AdvancedService.Resubscribe(c.Request.Context(), claims.OrgID, claims.ContactID); xerr != nil { renderUnsubPage(c, http.StatusOK, unsubView{Title: "We couldn't process that link", Body: "The link is no longer valid. Reply to the email and the sender can add you back."}) return } renderUnsubPage(c, http.StatusOK, unsubView{Title: "You're subscribed again", Body: "The sender can email you as before. You can unsubscribe from any later email."}) } func (h *Handler) verifyUnsubscribeToken(token string) (unsublink.Claims, error) { 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.Param("token")) if err != nil { renderUnsubPage(c, http.StatusBadRequest, unsubInvalid(err)) return claims, false } return claims, true } func unsubInvalid(err error) unsubView { if err == unsublink.ErrExpired { return unsubView{Title: "This link has expired", Body: "Reply to the email instead and the sender will stop."} } return unsubView{Title: "This unsubscribe link is invalid", Body: "Reply to the email instead and the sender will stop."} } 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 } // 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(` {{.Title}}

{{.Title}}

{{.Body}}

{{if .Confirm}}
{{end}} {{if .Resubscribe}}
{{end}} `)) func renderUnsubPage(c *gin.Context, status int, v unsubView) { c.Header("Cache-Control", "no-store") c.Header("X-Robots-Tag", "noindex") c.Status(status) c.Header("Content-Type", "text/html; charset=utf-8") _ = unsubTemplate.Execute(c.Writer, v) }