mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 00:01:49 +00:00
179 lines
7.0 KiB
Go
179 lines
7.0 KiB
Go
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(`<!doctype html><html lang="en"><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">Unsubscribe</button></form>{{end}}
|
|
{{if .Resubscribe}}<form method="post" action="{{.Resubscribe}}"><button type="submit" class="secondary">Unsubscribed by mistake? Resubscribe</button></form>{{end}}
|
|
</body></html>`))
|
|
|
|
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)
|
|
}
|