mirror of
https://github.com/hanzoai/notify.git
synced 2026-10-30 15:04:32 +00:00
* 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.
70 lines
2.6 KiB
Go
70 lines
2.6 KiB
Go
package tasks
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/hanzoai/tasks/pkg/sdk/workflow"
|
|
)
|
|
|
|
// SendInput is the workflow + activity input. One pair per recipient.
|
|
// MessageID is the notifyd-generated message row id the worker updates
|
|
// as the send progresses (queued → sending → sent/failed).
|
|
type SendInput struct {
|
|
MessageID string `json:"message_id"`
|
|
TenantSlug string `json:"tenant_slug"`
|
|
Channel string `json:"channel"`
|
|
Provider string `json:"provider,omitempty"`
|
|
To string `json:"to"`
|
|
Subject string `json:"subject,omitempty"`
|
|
Body string `json:"body"`
|
|
TemplateID string `json:"template_id,omitempty"`
|
|
Event string `json:"event,omitempty"`
|
|
Vars map[string]any `json:"vars,omitempty"`
|
|
|
|
// Category is one of marketing.Category* on the marketing path or
|
|
// any transactional/regulatory tag (empty = transactional). Drives
|
|
// the List-Unsubscribe injection branch in Activities.Deliver.
|
|
Category string `json:"category,omitempty"`
|
|
|
|
// UserID is the destination user's IAM id. Required for marketing-
|
|
// class sends so the unsubscribe token signs on the right
|
|
// principal. Ignored on the transactional path.
|
|
UserID string `json:"user_id,omitempty"`
|
|
|
|
// IsHTML is the body's content-type hint. True → text/html in the
|
|
// MIME envelope; false → text/plain. Defaults to plain when the
|
|
// caller omits it; templates that produce HTML must set it.
|
|
IsHTML bool `json:"is_html,omitempty"`
|
|
}
|
|
|
|
// SendResult is the workflow return shape.
|
|
type SendResult struct {
|
|
MessageID string `json:"message_id"`
|
|
Status string `json:"status"`
|
|
Provider string `json:"provider"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// NotifySendWorkflow is the durable-execution entry point for async
|
|
// sends. The workflow body must be deterministic on its inputs — all
|
|
// I/O happens inside the Deliver activity.
|
|
//
|
|
// Single activity: this is intentional. A "send" is a unit of work,
|
|
// not a DAG; if a provider fails, retry the whole thing rather than
|
|
// trying to split rendering from delivery (rendering against the same
|
|
// vars yields the same body, so re-rendering on retry is free).
|
|
func NotifySendWorkflow(ctx workflow.Context, in SendInput) (SendResult, error) {
|
|
actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
|
|
StartToCloseTimeout: 60 * time.Second,
|
|
})
|
|
var res SendResult
|
|
if err := workflow.ExecuteActivity(actCtx, "Deliver", in).Get(actCtx, &res); err != nil {
|
|
return SendResult{
|
|
MessageID: in.MessageID,
|
|
Status: "failed",
|
|
Error: err.Error(),
|
|
}, err
|
|
}
|
|
return res, nil
|
|
}
|