Short answer: for a generated customer-support report, keep the message template and attachment contract in your repository, verify the sending domain before launch, and put any transactional email API behind a small delivery interface.
The operational constraint changes the choice: support staff need the exact report that was generated, while recipients need a legible message that explains what is attached. A provider-hosted template can make copy edits convenient, but it also puts the message body and the application release on separate change paths. I would accept that split for marketing-managed copy; I would not accept it by default for an operational report whose filename, content type, case reference, and retention policy move together.
This is a bounded incident scenario, not a story about a particular outage. A case closes, a worker generates case-18427-summary.pdf, and the email job carries template version support-report-v3. Its durable record should bind the case ID, recipient, artifact digest, filename, media type, template version, and attempt number before any network call begins. Now replay the sequence from the record: the renderer produces the subject and both message bodies; validation rejects an empty report; the adapter submits the composed message; the workflow stores the returned provider identifier; and a later delivery event reconciles against that identifier. The dangerous state is easy to picture without inventing a production failure: the worker uploads the correct PDF while a separately edited template tells the customer to expect a CSV, or the rendered subject omits the case reference used by support. A dashboard may show an accepted request and the queue may drain, yet the customer and support agent now have conflicting descriptions of the same artifact. No delivery provider can reconcile two owners that disagree about the contract, and another retry cannot repair the meaning of content that was inconsistent at composition time.
Own the contract first.
For a welcome email, provider-side template ownership is often reasonable because copy changes more frequently than application behavior and the payload is small. A generated support report has a tighter coupling. The code knows the report type, attachment bytes, filename, recipient, case identifier, and template version at one point in time; keeping the template beside that code makes review and rollback one release operation.
"Own" does not have to mean hand-writing HTML inside a Go function. It means one team controls the canonical source, its variables, the rendering test, and the release decision. The compiled artifact may still be uploaded to a delivery service. The distinction matters during an incident because an on-call engineer should be able to answer, from the job record alone, which content was intended and which attachment contract was applied.
I use three failure boundaries:
- Composition decides the subject, text and HTML bodies, attachment metadata, and template version.
- Delivery accepts an already composed message and returns a provider message identifier.
- Workflow records the business outcome and decides whether another attempt is allowed.
That separation keeps a retry from silently selecting newer copy. It also prevents a template editor from changing attachment semantics without the same review applied to the report generator. The catch is that repository ownership puts copy deployment on the engineering path. If legal or lifecycle teams must publish copy independently several times a day, a hosted template with an explicit immutable version is the better fit.
The API comes later.
Price the capacity you own before the API bill
Attachments alter the capacity model. The worker holds generated bytes, composes a message, calls a remote API, and retains enough state to determine the outcome. Queue depth therefore depends on report generation time and delivery latency, while memory pressure depends on attachment size and concurrency. I am not sure what safe concurrency is for your workload; a load test with representative PDFs, the real worker memory limit, and the intended retry policy resolves that question.
Start with an explicit budget. If the worker has a 512 MiB memory limit, don't infer that hundreds of parallel 2 MiB reports are safe merely because their raw sizes multiply to less than the limit. Encoding, message construction, the PDF generator, buffers, and the runtime all consume memory. Measure resident memory at increasing concurrency, set a ceiling below the observed failure point, and let queue age expose demand that exceeds it. Capacity planning here is less glamorous than switching APIs, but it protects the user-visible SLO.
The SLO also needs the right boundary. "API request accepted" is a useful delivery-stage event; it is not the same event as "recipient received the report." Track composition failures, accepted submissions, subsequent delivery outcomes available from the chosen service, and age of the oldest queued report separately. Otherwise a healthy request-success graph can hide a growing generation backlog or messages that never reach the recipient.
Keep the fallback plain. A support agent should still be able to retrieve the report through an authenticated support workflow when email is not an appropriate channel for its sensitivity or size. SMS can notify a customer that an update is available, but it cannot preserve the same attachment contract; treat it as a different workflow with its own consent, content, and delivery rules, not as an automatic substitute for email. Your mileage may vary on the exact escalation path because support hours, consent records, and data classification differ; those three inputs should be written down before anyone calls the fallback automatic.
Retry the artifact, not a fresh rendering
The preventative code path is a narrow interface plus validation at the composition boundary. This example deliberately has no vendor URL. An adapter can translate the validated message into whichever API has passed the team's acceptance tests, while the support workflow remains unaware of provider-specific request shapes.
package reportmail
import (
"context"
"errors"
"fmt"
)
type Attachment struct {
Filename string
ContentType string
Data []byte
}
type Message struct {
To string
Subject string
TextBody string
HTMLBody string
TemplateVersion string
CaseID string
Attachment Attachment
}
type Receipt struct {
ProviderMessageID string
}
type Deliverer interface {
Send(ctx context.Context, message Message) (Receipt, error)
}
func NewSupportReportMessage(
to string,
caseID string,
templateVersion string,
filename string,
pdf []byte,
) (Message, error) {
if to == "" || caseID == "" || templateVersion == "" {
return Message{}, errors.New("recipient, case ID, and template version are required")
}
if filename == "" || len(pdf) == 0 {
return Message{}, errors.New("a named, non-empty report is required")
}
return Message{
To: to,
Subject: fmt.Sprintf("Support report for case %s", caseID),
TextBody: fmt.Sprintf("Your report for case %s is attached.", caseID),
HTMLBody: fmt.Sprintf("<p>Your report for case %s is attached.</p>", caseID),
TemplateVersion: templateVersion,
CaseID: caseID,
Attachment: Attachment{
Filename: filename,
ContentType: "application/pdf",
Data: pdf,
},
}, nil
}
Validation is intentionally boring. That's useful. In production I would also make the job identifier stable across attempts and store a digest of the generated artifact so the workflow can recognize what it is retrying; the exact deduplication mechanism depends on the queue and provider contract, so it belongs in the adapter and workflow design rather than in a supposedly universal snippet.
Test the composer without a network call. Assert that a given case produces both text and HTML bodies, the expected subject, one non-empty PDF, and the requested template version. Then contract-test each adapter against a controlled recipient and record the provider identifier. A deployment should fail before traffic if a required template version or verified sending identity is absent.
Domain verification belongs in that deployment gate, not in a launch-day checklist. Google's email sender guidelines are the primary reference supplied for sender requirements; use them as the baseline and re-check them as the sending profile changes. The application should select only a pre-approved sending identity. It should never try to create or repair DNS records while processing a customer report.
No guesswork.
Can welcome email API templates and domain verification share one release owner?
SendGrid, Resend, and Postmark are reasonable names to include in an email API evaluation because they appear in the actual shortlist, but the product labels do not answer the ownership question. Run the same evidence-producing exercise for all three. This table is a buy-versus-build review sheet, not a ranking.
| Decision | Keep in the application | Put in a managed service | Evidence required before launch |
|---|---|---|---|
| Template source | One reviewed release contains message and report contract | Non-engineers publish copy independently | Rendered fixtures, immutable version selection, rollback drill |
| Domain verification | Deployment checks an approved identity | Service guides and records verification state | DNS review, authenticated test message, documented owner |
| Attachment assembly | Worker binds exact bytes to the case job | Adapter transforms provider-specific payloads | Filename, content type, digest, and size tests |
| Delivery state | Workflow owns retries and business status | Service exposes submission and later delivery events | Duplicate test, delayed-event test, event reconciliation |
| On-call surface | Team maintains renderer and adapters | Team depends on service control plane and event contract | Runbook, alert ownership, exit test |
For each candidate, ask the same concrete questions: Can the application select an immutable template version? Can a test environment verify the exact sender identity without sharing production credentials? Is the attachment represented directly in the API contract? Can later delivery events be reconciled to the case and the original submission? What evidence survives if the team changes providers?
Do not score a polished editor as an availability feature. It may reduce copy turnaround while increasing the number of independent changes that on-call must reconstruct. Conversely, don't treat repository templates as automatically safer: without preview tooling and accountable copy review, engineering ownership just turns every typo into a code deployment. The correct choice follows the owner who can review, publish, roll back, and answer for the content during the report-delivery SLO window.
A small internal renderer and one adapter can be sensible when attachment semantics are specialized and change with application code. Stick with a managed template editor when independent publishing is the governing requirement. Consider a self-hosted mail stack only when the organization is prepared to own sending infrastructure, sender reputation work, security updates, and continuous operations; avoiding API lock-in does not erase that on-call load.
Before enabling report mail, I would require one successful controlled delivery from the exact sending domain, one rendered fixture reviewed by support, and one retry exercise that proves the workflow does not create a second business action. The gate should also show that the report can be retrieved through the authenticated support path, because some attachments should not travel by email at all.
That is the gate.
Then watch four signals: oldest queued-job age, composition error rate, submission acceptance rate, and reconciled delivery outcomes. Page on user impact and exhausted retry capacity, not on every individual transient response. Preserve the case ID, template version, artifact digest, attempt number, and provider message identifier in structured records, but keep report contents and recipient data out of routine log messages.
This advice is not suitable for bulk campaigns, where segmentation, experimentation, suppression management, and frequent copy changes shift ownership toward specialized tooling. It is also a poor fit for reports whose sensitivity policy forbids email attachments; use authenticated retrieval and a separately governed notification instead. For a transactional customer-support report, however, the clean default remains stable: own the content-to-attachment contract, enforce the domain gate before deployment, and make delivery replaceable.
Top comments (0)