Short answer: build generated-report alert emails around a small, immutable evidence envelope, then make domain authorization, template setup, dispatch, retries, and deletion prove claims against that envelope instead of treating a provider's “delivered” label as the audit record.
For an edtech SaaS, the first calculation is bytes retained, not messages sent. Take a hypothetical load of 12,000 reports per day, a 1.5 MB PDF at the 95th percentile, and 90 days of attachment retention. That policy holds about 1,620 GB at steady state before replicas or backups. A 30-day attachment policy holds about 540 GB. Changing the email library moves neither number.
from dataclasses import dataclass
@dataclass(frozen=True)
class RetentionEstimate:
reports_per_day: int
attachment_mb: float
attachment_days: int
evidence_kb: float
evidence_days: int
def attachment_gb(self) -> float:
return self.reports_per_day * self.attachment_mb * self.attachment_days / 1000
def evidence_gb(self) -> float:
return self.reports_per_day * self.evidence_kb * self.evidence_days / 1_000_000
estimate = RetentionEstimate(12_000, 1.5, 30, 4, 365)
print(estimate.attachment_gb()) # 540.0 with the stated decimal-unit assumptions
print(estimate.evidence_gb()) # 17.52 with the stated decimal-unit assumptions
Those inputs are an example, not a benchmark. Use production size percentiles and account for replicas, backup behavior, rendering, and transfer in the real model. The change that moves the dominant storage term is shortening full-artifact retention, so this design deliberately stops keeping the PDF after 30 days and keeps a digest plus narrowly scoped dispatch evidence longer. There is a real loss: after deletion, an auditor can compare an externally supplied copy with the digest, but the mail system cannot retrieve the original. Reproduction requires a retained source snapshot and renderer version, which adds storage and personal-data exposure again.
Put the evidence policy at the worker boundary
Start from a question an investigator might ask: “Which report bytes did this tenant authorize the system to send to this learner?” A useful answer needs the event and tenant IDs, approved sender domain, released template identifier, attachment digest, attempt number, timestamp, and the transport disposition actually observed. It does not automatically need a permanent PDF or an indefinitely retained recipient address.
Keep the envelope separate from the mutable job record. One report-ready event can have multiple transport attempts, but it should have one terminal business outcome; each attempt gets a unique number and append-only evidence, while a claim or lease prevents two workers from dispatching the same event concurrently. This arrangement catches two opposite failures: overwriting an earlier rejection with a later result, and counting retries as independent learner notices.
Evidence has limits.
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from hashlib import sha256
import json
@dataclass(frozen=True)
class AttemptEvidence:
event_id: str
tenant_id: str
approved_domain: str
template_version: str
attachment_sha256: str
attempt: int
observed_disposition: str
recorded_at: str
def encode_evidence(event: dict, pdf_bytes: bytes, attempt: int, disposition: str) -> str:
evidence = AttemptEvidence(
event_id=event["event_id"],
tenant_id=event["tenant_id"],
approved_domain=event["approved_domain"],
template_version=event["template_version"],
attachment_sha256=sha256(pdf_bytes).hexdigest(),
attempt=attempt,
observed_disposition=disposition,
recorded_at=datetime.now(timezone.utc).isoformat(),
)
return json.dumps(asdict(evidence), sort_keys=True, separators=(",", ":"))
Call the field observed_disposition, not delivered. Submission, relay acceptance, a later receiving-system response, and human reading are distinct claims. Apple's Mail Privacy Protection downloads remote content in the background and prevents senders from accurately learning Mail activity, which makes tracking-pixel opens unsuitable as compliance proof that a learner read a report.
Store the recipient identity only to the precision required by the governing question. A recipient domain may support aggregate deliverability analysis but cannot resolve a dispute about one account. I'm not sure which precision is justified without the retention policy and audit request; those are the missing inputs, and adding fields “just in case” increases exposure without improving a defined answer.
How can SaaS event alert emails enforce custom-domain DKIM verification?
Model domain setup as an authorization gate. A tenant requests a sender domain, receives the DNS records required by the selected signing arrangement, publishes them, and waits while the control plane checks what DNS exposes. Only a verified and enabled tenant-domain record may supply the From identity to the worker. Never accept a sender, selector, or signing configuration directly from the notification event.
Google's current sender guidelines require all senders to personal Gmail accounts to use SPF or DKIM, with additional SPF, DKIM, and DMARC requirements for bulk senders. The published guideline should remain the authority rather than a copied threshold embedded in code. DKIM establishes domain responsibility for a signed message; it does not decide which SaaS tenant is authorized to use that domain. The application's tenant mapping must answer that separate question.
The verification test is therefore more than “DNS returned something.” Exercise the state transitions and tenant boundary: pending domains cannot send, verified domains can send only for their owner, disabled domains stop supplying a From identity, and a later configuration check produces a dated observation. Revalidation frequency depends on DNS behavior, tenant risk, and the revocation objective; there isn't an honest universal interval.
| Test | Expected control | Failure mode named |
|---|---|---|
| Pending domain reaches dispatch | Block before MIME construction | unverified identity used |
| Tenant B references tenant A's domain | Reject the event | cross-tenant impersonation |
| Verified domain becomes disabled | Block new attempts | abandoned identity remains active |
| Event supplies its own sender | Ignore or reject that field | policy bypass through payload data |
Stick with one centrally managed organizational sender domain when tenants cannot operate DNS reliably or when the compliance team requires a single tightly controlled identity. Custom domains are not suitable by default: delegated administration, rechecks, revocation, and support become part of the service.
Test deterministic artifacts through the whole worker
The Node.js application may publish the report-ready event, but the queue contract should contain stable references and typed template data rather than caller-built HTML. Inside one controlled worker boundary, resolve the authorized sender and released template, escape learner-controlled values, render plain text and HTML alternatives, generate the PDF once, hash those exact bytes, and then submit them. A retry reuses the artifact and template version.
No regeneration.
Otherwise, two attempts can share an event ID while carrying different report bytes, leaving the audit record unable to say which artifact went where. Use a readable template release ID for operators and a content digest to detect edits under that ID. Keep legal text in the released template instead of adding a mutable footer at transport time.
The following Python worker function is deliberately transport-neutral. It constructs the complete message and returns the attachment digest for the evidence envelope; the mail system associated with the approved domain applies DKIM signing after submission.
from email.message import EmailMessage
from email.utils import make_msgid
from hashlib import sha256
from html import escape
def build_report_email(event: dict, pdf_bytes: bytes) -> tuple[EmailMessage, str]:
learner_html = escape(event["learner_name"])
period_html = escape(event["report_period"])
message = EmailMessage()
message["Subject"] = f"Learning report for {event['report_period']}"
message["From"] = event["approved_sender"]
message["To"] = event["recipient"]
message["Message-ID"] = make_msgid(domain=event["message_id_domain"])
message.set_content(
f"Hello {event['learner_name']},\n\n"
f"Your learning report for {event['report_period']} is attached.\n"
)
message.add_alternative(
f"<p>Hello {learner_html},</p>"
f"<p>Your learning report for {period_html} is attached.</p>",
subtype="html",
)
message.add_attachment(
pdf_bytes,
maintype="application",
subtype="pdf",
filename=f"learning-report-{event['report_id']}.pdf",
)
return message, sha256(pdf_bytes).hexdigest()
The language boundary matters less than policy ownership. The producer authorizes the request; the worker owns sender resolution, template release selection, MIME construction, submission, and evidence recording. Don't duplicate those rules in the Node.js API handler and the consumer, because separate implementations can interpret the same domain state differently.
Govern evidence retention and deletion together
Unit tests should cover escaping, text and HTML parity, deterministic hashing, tenant-domain isolation, and rejection of unverified senders. Contract tests feed a fixed event into the worker and compare the MIME structure and evidence fields. Integration tests use domains and mailboxes controlled for testing, then inspect authentication results instead of interpreting transport acceptance as inbox placement. During deployment, canary a limited tenant set and watch queue age, attempt count, domain-state failures, and rejection categories separately; one blended delivery rate can hide a tenant configuration failure behind healthy traffic. Retry only a result classified as temporary by the chosen transport contract, preserve every attempt under the original event, and route exhausted work to operational review. The catch is that byte reuse needs protected artifact storage for the retry window. If policy forbids that, the choices narrow to regeneration from a frozen source snapshot and renderer version, or dropping the promise of byte-for-byte evidence. Deletion deserves the same test discipline: a scheduled job should remove expired PDFs, record the policy and deletion time, and leave only the approved evidence fields, while backups and replicas follow their declared lifecycle rather than silently extending availability. Test that an expired artifact is no longer retrievable and that its digest remains queryable for the longer evidence window. Also test the uncomfortable recovery path: after deletion, operators should see an explicit “artifact expired” state, not an empty result that can be mistaken for a generation failure.
Deletion is behavior.
The trade-off is intentional. Full artifacts provide better reconstruction but retain more sensitive learner data and consume far more storage; digests reduce both burdens but prove equality only when someone already has a candidate file. A defensible system chooses between those properties through policy, names the loss, and makes its email deliverability and compliance claims no broader than the evidence it actually observes.
Top comments (0)