Files
notify/internal/marketing/category.go
zeekayandGitHub d2c09e545d notify: SES SendRawEmail + List-Unsubscribe injection on marketing class (#8)
* kmsbridge: drop legacy-Infisical workaround framing in doc comments

kmsbridge has been the canonical path to Hanzo KMS since the canonical
migration landed. Update doc comments to reflect that — the package is the
direct line to /v1/kms/orgs/{org}/secrets, not a stopgap around a base/plugins
version pin. The base.KMSClient surface compatibility note stays since callers
do still swap import paths to migrate.

* notify: SES SendRawEmail + List-Unsubscribe injection on marketing class

Per RFC 8058 + Gmail/Outlook bulk-sender policy (2024+), marketing email
must carry both List-Unsubscribe and List-Unsubscribe-Post to surface
the native one-click unsubscribe button. The SES SDK's SendEmail cannot
inject custom headers; SendRawEmail takes raw MIME instead.

Substrates:

- service/amazonses/raw.go: AmazonSES.SendRaw method, builds the MIME
  envelope (From / To / Subject / Date / MIME-Version / Content-Type +
  sorted extra headers + blank line + body) and ships via SendRawEmail.
  Header smuggling (CR/LF in extra header values) is rejected at
  compose time. SendEmail path is untouched.

- internal/marketing/category.go: IsMarketing predicate matching the
  five §3.2 buckets (promotional, newsletter, product_update,
  partner_offer, research) + HTML / plain footer injection helpers.

- internal/tasks/activities.go: marketing branch on Activities.send.
  When (a) email channel, (b) marketing category, (c) marketing.Config
  wired: mint unsubscribe token, persist row into
  notification_unsubscribe_tokens BEFORE send, append visible footer
  to the body, route through chain.RunMarketing.

- internal/tenant/chain.go: ChainProvider gains an optional RawSender
  extension. RunMarketing prefers SendRaw when implemented (today: SES
  API only), falls back to plain Send otherwise. Per-attempt + whole-
  chain deadlines and the retry classifier are unchanged.

- pkg/types + internal/tasks/workflow: SendRequest / SendInput gain
  Category, UserID, IsHTML fields.

- cmd/notifyd/main.go: wires marketing.Config (NOTIFY_ENV + unsubSigner)
  onto Activities at boot. NOTIFY_UNSUBSCRIBE_SECRET also gates this
  path (same fail-closed rule that already gates /unsubscribe routes).

Tests (37 added across 4 files):
- service/amazonses/raw_test.go — MIME envelope shape; sorted header
  ordering; HTML vs plain Content-Type; multi-recipient To header;
  header-smuggling rejection; SDK error propagation.
- internal/marketing/category_test.go — IsMarketing buckets + edges;
  HTML + plain footer injection; empty URL no-op.
- internal/tasks/marketing_test.go — token persistence;
  three-gate shouldUseMarketing decision matrix; idempotent re-persist;
  extractToken URL parsing.
- internal/tenant/chain_marketing_test.go — RunMarketing prefers
  RawSender path; falls back to Send when RawSender absent; multi-
  provider fallthrough on retryable failure.

Completes the PR #7 follow-up: the header builder shipped in #7 is now
wired into the send path; one-click unsubscribe works end-to-end for
marketing-class email.
2026-06-03 21:32:58 -07:00

80 lines
2.8 KiB
Go

// category.go — the marketing-category predicate + body-footer
// injection helpers. The category set is the union of the five
// marketing buckets defined in §3.2 of the notification-preferences
// paper. Keeping IsMarketing and footer-injection together with the
// header builder makes "what does this package own?" one answer:
// everything about composing a marketing-class email.
package marketing
import "strings"
// Category is the canonical marketing-class identifier as it appears
// on SendRequest.Category. Non-marketing categories (transactional,
// regulatory, alerts) do not get headers or footers — the router emits
// them as plain transactional sends through the existing path.
//
// Source of truth: internal/preferences.knownMarketingCategory matches
// the same five values. Any addition there MUST land here too.
const (
CategoryPromotional = "promotional"
CategoryNewsletter = "newsletter"
CategoryProductUpdate = "product_update"
CategoryPartnerOffer = "partner_offer"
CategoryResearch = "research"
)
// IsMarketing reports whether the given category is one of the
// marketing buckets. The check is case-insensitive + trim-tolerant so
// callers can pass values straight off the wire without normalising.
func IsMarketing(category string) bool {
switch strings.ToLower(strings.TrimSpace(category)) {
case CategoryPromotional, CategoryNewsletter, CategoryProductUpdate, CategoryPartnerOffer, CategoryResearch:
return true
}
return false
}
// HTMLFooter is the unsubscribe block appended to marketing-class HTML
// emails. Inline styles keep the rendering predictable in clients that
// strip <style> blocks; the link target is the same HTTPS URL as the
// List-Unsubscribe header so a click in the body and a click in the
// native unsubscribe UI both reach the same endpoint.
func HTMLFooter(url string) string {
return `<hr>
<p style="color: #666; font-size: 12px;">
Unsubscribe: <a href="` + url + `">click here</a>
</p>`
}
// PlainFooter is the equivalent for text/plain bodies. The "--" prefix
// is the conventional signature delimiter (RFC 3676) so mail clients
// that show signatures collapsed still render the link.
func PlainFooter(url string) string {
return "--\nUnsubscribe: " + url
}
// AppendHTMLFooter returns body + "\n" + HTMLFooter(url). When url is
// empty we return body unchanged — callers that already injected the
// footer don't double-up.
func AppendHTMLFooter(body, url string) string {
if strings.TrimSpace(url) == "" {
return body
}
if body == "" {
return HTMLFooter(url)
}
return body + "\n" + HTMLFooter(url)
}
// AppendPlainFooter is the text/plain counterpart of AppendHTMLFooter.
func AppendPlainFooter(body, url string) string {
if strings.TrimSpace(url) == "" {
return body
}
if body == "" {
return PlainFooter(url)
}
return body + "\n\n" + PlainFooter(url)
}