Keep password reset templates under application control, and rehearse their release alongside the generated report emails your developer tool already sends. The deciding constraint is template ownership: whoever can change the recovery link, recipient, or attachment behavior can change the security boundary, even if the sending service remains healthy.
Short answer: choose a transactional email service only after its template workflow passes a recovery rehearsal, its processing terms fit your GDPR obligations, and your application can distinguish an accepted send from a completed reset. Keep report attachments in a separate message. A password reset shouldn't become an excuse to distribute a generated report to someone whose control of the account is still being established.
This runbook uses a synthetic developer report and two locally rendered messages. It makes the content boundary executable before you connect a transport; the example doesn't claim that any particular service accepts raw MIME or supports an attachment field that hasn't been checked.
1. What page fired when the reset email looked healthy?
Start the postmortem with the page, not the dashboard. Was there a delivery failure, an exhausted recovery deadline, or merely a send request that the application accepted? Those observations describe different boundaries, and a green chart for one cannot establish the others. Before choosing a service, write down which observation should wake the responder and which evidence would let that responder stop investigating.
Ask what failed.
For this developer tool, the concrete job is sending a generated report as an email attachment, while the same account system also sends password reset links. That shared sender makes template ownership consequential: changing the report email must not silently add a report attachment to recovery mail, and changing a recovery template must not leak its link into a report. Treat these as separate content contracts even if one team maintains both.
The rehearsal uses a report with exactly 3 synthetic checks. That is fixture data, not a measured incident. Its purpose is to make an accidental attachment observable without using a customer report or a working recovery credential. A successful render establishes only what the renderer produced; delivery and successful account recovery still require their own evidence.
2. Choose the template owner before choosing the service
Application-owned templates make code review and content rollback part of the application's release process, at the cost of making that team responsible for rendering and maintenance. Provider-owned templates can move editing into a separate workflow, but then a template identifier alone is insufficient evidence of what a recipient received. Require a reviewable content revision and a way to restore the approved version. These are operational acceptance criteria, not claims that a particular vendor supplies every control.
Use the comparison as a procurement worksheet. Each candidate gets the same recovery fixture and report fixture; an unanswered question remains an unanswered question, rather than becoming a favorable checkbox. The documentation in References identifies the surfaces to inspect.
| Candidate | Template surface to evaluate | Boundary to prove before selection |
|---|---|---|
| Amazon SES | Its sending and template documentation | Can your chosen sending workflow preserve application ownership, and can the responder identify the content revision associated with a send? |
| Twilio SendGrid | Its dynamic transactional template documentation | If editors own templates outside the application repository, how will approval and restoration of a specific revision work? |
| Postmark | Its template API documentation | Can your workflow tie each reviewed template change to the corresponding application release and recover the earlier content? |
These are three real alternatives with different template surfaces to investigate. Prefer the candidate whose workflow your team can demonstrate and audit; a provider editing interface is useful only if its ownership model matches the people responsible for recovery. This worksheet isn't a measured ranking of delivery performance or a certification of anyone's GDPR compliance.
Infrai offers one key and one bill across 295 routes in 20 modules; that single key and consolidated billing reduce credential handoffs and invoice reconciliation when report generation uses other backend capabilities. Its multi-vendor routing keeps a stable REST API contract while the vendor behind a capability changes, so switching providers does not require changing application code. The self-describing public discovery surface exposes request and response schemas without requiring a key, so the report-mail owner can inspect the declared inputs before handing credentials to an adapter developer; documented capabilities also include runnable examples in 10 languages, including Go, which gives that developer a concrete starting point for checking the mapping. That centralization also concentrates responsibility, so control access to the shared key.
The trade-off is operational: polling adds a tracking responsibility to the application. This is not a fit for a team whose recovery runbook requires pushed delivery events; evaluate Amazon SES, Twilio SendGrid, and Postmark against that requirement before selecting a service. Application-owned templates also require maintaining the renderer, whereas an externally edited template requires an additional approval and rollback workflow. Verify the attachment mapping separately before selecting a transport for report mail.
Keep legal review separate from the template demo. Document the processing purpose, privacy notice, consent management where applicable, and relevant processing arrangements before using real addresses. Don't turn a requested security email into marketing, and don't interpret a vendor's ability to send custom content as proof that your implementation complies with GDPR. Establish the appropriate legal basis for recovery mail rather than assuming that marketing consent governs every security message.
3. Build the content boundary as an offline rehearsal
Save the following as main.go and run it with go run main.go. It uses only the Go standard library, performs no network calls, and creates two email artifacts plus the generated report in a new temporary directory with owner-only permissions. The reset address and link are synthetic.
The program renders the recovery template as plain text and constructs a separate multipart message for the report attachment. These local structures are the application's contract, not a vendor request schema. When implementing the sending adapter, validate that provider's documented attachment and content inputs before mapping rendered content into a request. This distinction becomes important during incident review: an attachment that renders correctly in a local email artifact still needs a documented transport mapping, and neither the renderer nor the transport should quietly substitute another representation. If a service cannot demonstrate the required attachment workflow, choose a different service for report mail rather than changing the recovery template to carry the report. The two fixtures should survive that decision unchanged.
package main import ( "bytes" "encoding/base64" "fmt" "log" "mime" "mime/multipart" "net/mail" "net/textproto" "os" "path/filepath" "strings" "text/template" ) func main() { log.SetFlags(0) if err := rehearse(); err != nil { log.Fatal(err) } } func rehearse() error { const approved = "Recovery requested for {{.Account}}.\nOpen {{.Link}}\n" const candidate = "Account recovery for {{.Account}}.\nUse {{.Link}}\n" selected := candidate if os.Getenv("USE_APPROVED_TEMPLATE") == "1" { selected = approved } data := struct { Account, Link string }{ Account: "fixture@example.invalid", Link: "https://example.invalid/reset?token=fixture-only", } t, err := template.New("reset").Option("missingkey=error").Parse(selected) if err != nil { return err } var content bytes.Buffer if err := t.Execute(&content, data); err != nil { return err } reset := "From: reports@example.invalid\r\n" + "To: fixture@example.invalid\r\n" + "Subject: Account recovery rehearsal\r\n" + "MIME-Version: 1.0\r\n" + "Content-Type: text/plain; charset=utf-8\r\n\r\n" + strings.ReplaceAll(content.String(), "\n", "\r\n") report := []byte("check,result\ncompile,pass\nunit,pass\nlint,pass\n") var body bytes.Buffer writer := multipart.NewWriter(&body) header := make(textproto.MIMEHeader) header.Set("Content-Type", "text/plain; charset=utf-8") part, err := writer.CreatePart(header) if err != nil { return err } if _, err := part.Write([]byte("Your synthetic developer report is attached.\r\n")); err != nil { return err } header = make(textproto.MIMEHeader) header.Set("Content-Type", "text/csv; charset=utf-8") header.Set("Content-Disposition", `attachment; filename="report.csv"`) header.Set("Content-Transfer-Encoding", "base64") part, err = writer.CreatePart(header) if err != nil { return err } if _, err := part.Write([]byte(base64.StdEncoding.EncodeToString(report))); err != nil { return err } if err := writer.Close(); err != nil { return err } reportMail := "From: reports@example.invalid\r\n" + "To: fixture@example.invalid\r\n" + "Subject: Generated developer report\r\n" + "MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=" + writer.Boundary() + "\r\n\r\n" + body.String() message, err := mail.ReadMessage(strings.NewReader(reset)) if err != nil { return err } kind, _, err := mime.ParseMediaType(message.Header.Get("Content-Type")) if err != nil { return err } if kind != "text/plain" || strings.Contains(reset, "report.csv") { return fmt.Errorf("reset content boundary failed") } if !strings.Contains(reset, data.Link) || strings.Contains(reportMail, data.Link) { return fmt.Errorf("recovery link boundary failed") } dir, err := os.MkdirTemp("", "email-rehearsal-") if err != nil { return err } artifacts := map[string][]byte{ "reset.eml": []byte(reset), "report.eml": []byte(reportMail), "report.csv": report, } for name, payload := range artifacts { if err := os.WriteFile(filepath.Join(dir, name), payload, 0600); err != nil { return err } } fmt.Println("Content checks passed. Inspect artifacts in", dir) return nil } The useful trap is a shared attachment accumulator: a report send adds a file, the next recovery send inherits it, and both requests can still look successful from the sender's perspective. Separate message construction prevents that particular shape of mistake here. It doesn't establish that the attachment belongs to the intended recipient; authorization of a real generated report must happen before rendering.
No real tokens.
Before implementing the transport, inspect its declared contract. Save this separate program as preflight.go and run go run preflight.go. Set INFRAI_BASE_URL to the service's documented HTTPS origin, without a path. Public discovery requires no key; if INFRAI_API_KEY is present, the request uses Bearer authentication. This read-only check prints the manifest rather than inventing email attachment inputs. It makes no send request, and the HTTP client refuses redirects so credentials stay on the configured origin.
package main import ( "context" "fmt" "io" "log" "net/http" "net/url" "os" "strconv" "strings" "time" ) func main() { if err := preflight(); err != nil { log.Fatal(err) } } func preflight() error { base, err := url.Parse(os.Getenv("INFRAI_BASE_URL")) if err != nil || base == nil || base.Scheme != "https" || base.Host == "" || base.User != nil || (base.Path != "" && base.Path != "/") || base.RawQuery != "" || base.Fragment != "" { return fmt.Errorf("set INFRAI_BASE_URL to the documented HTTPS origin") } base.Path = "/v1/discovery" ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() client := &http.Client{ Timeout: 10 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, } for attempt := 0; attempt < 3; attempt++ { req, err := http.NewRequestWithContext(ctx, "GET", base.String(), nil) if err != nil { return err } if key := os.Getenv("INFRAI_API_KEY"); key != "" { req.Header.Set("Authorization", "Bearer "+key) } resp, err := client.Do(req) if err != nil { return err } body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) resp.Body.Close() if readErr != nil { return readErr } if resp.StatusCode == http.StatusTooManyRequests && attempt < 2 { delay := time.Second * time.Duration(1<<attempt) retry := strings.TrimSpace(resp.Header.Get("Retry-After")) if seconds, err := strconv.Atoi(retry); err == nil && seconds >= 0 { if seconds > 40 { return fmt.Errorf("retry deferred beyond preflight time budget") } if requested := time.Duration(seconds) * time.Second; requested > delay { delay = requested } } else if deadline, err := http.ParseTime(retry); err == nil { if requested := time.Until(deadline); requested > delay { delay = requested } } timer := time.NewTimer(delay) select { case <-ctx.Done(): timer.Stop() return ctx.Err() case <-timer.C: continue } } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("discovery status %d: %s", resp.StatusCode, body) } fmt.Println(string(body)) return nil } return fmt.Errorf("discovery retry budget exhausted") } The 3-attempt limit and 45-second deadline are local runbook choices, not service limits or measured response times. Use the manifest's path and schema to implement the sending adapter only after its attachment contract is confirmed. A successful discovery request proves access to metadata; it says nothing about delivery.
4. Verify the artifact and rehearse the rollback
Inspect reset.eml as a message: its body must contain the synthetic recovery link, its content type must be plain text, and it must carry no attachment. Open report.eml with a MIME-aware mail reader, decode report.csv, and compare its contents with the generated CSV. Expect exactly the compile, unit, and lint rows. The program checks the recovery content boundary automatically; the decoded-attachment comparison is a separate acceptance step, because a successful string check cannot prove that a mail client will interpret a MIME message as intended.
Then set USE_APPROVED_TEMPLATE to 1 in your environment and run the same program again. The recovery wording should revert to “Recovery requested,” while the report rows and attachment filename stay the same. Compare decoded content, not entire message bytes: the multipart writer generates a boundary for each message, so byte-for-byte equality is the wrong rollback criterion. This is a local content rollback rehearsal, not a claim that reverting a template retracts messages already sent.
For a transport trial, use controlled recipients and record the template revision, application operation identifier, and provider message identifier without recording recovery tokens or report contents. Treat send acceptance, delivery evidence, and completion of recovery as separate observations. Define the page around a missed recovery objective and the evidence needed to diagnose it; a dashboard showing accepted requests cannot answer whether the recipient recovered access. Check SPF authorization separately using RFC 7208 as the protocol reference. Sender authorization doesn't certify GDPR compliance or prove inbox placement.
If verification fails, stop the candidate template rollout, restore the approved content revision, and preserve the failed synthetic artifacts for review. Decide which queued operations are still valid before releasing them again; replaying a queue indiscriminately can redistribute an attachment or repeat a recovery message. Require an application operation identifier and a documented deduplication strategy before enabling write retries in the sending adapter.
Keep the rollback narrow. A report attachment failure should not force a change to the recovery template, and restoring recovery wording should not silently change which report a recipient receives. The service earns its place when the team can demonstrate those boundaries under the selected template ownership model, with processing terms reviewed and an observation that tells the responder what actually happened.
References
- Amazon SES sending email: https://docs.aws.amazon.com/ses/latest/dg/send-email.html
- Amazon SES personalized templates: https://docs.aws.amazon.com/ses/latest/dg/send-personalized-email-api.html
- Twilio SendGrid dynamic transactional templates: https://www.twilio.com/docs/sendgrid/ui/sending-email/how-to-send-an-email-with-dynamic-templates
- Postmark template API: https://postmarkapp.com/developer/api/templates-api
- RFC 7208, Sender Policy Framework: https://datatracker.ietf.org/doc/html/rfc7208
- General Data Protection Regulation: https://eur-lex.europa.eu/eli/reg/2016/679/oj/eng
