Short answer: keep password-reset template source in the application repository, render an immutable message before the API call, authenticate a dedicated custom-domain mail stream with DKIM and SPF, and treat the reset token's short expiry as a security boundary rather than text that an email provider may change.
For an edtech platform, a reset message sits on an awkward boundary. The identity service knows the student, the token, and the expiry; the mail system knows delivery. Giving either side ownership of both concerns creates hidden state. Four boundaries keep the design review honest: source ownership, rendering, authentication, and delivery evidence.
This is an architecture decision, not a vendor selection.
The decision is to make the application repository the canonical source for the subject and body, while a narrow mail adapter owns transport. A reviewed template version travels with every request. The adapter accepts already-rendered content, a recipient, and an idempotency key; it must not fetch a mutable remote template during the password-reset critical path.
That division makes the expiry claim testable. If the identity service issues a token that expires at 14:35:00Z, it can render “This link expires in 10 minutes” from the same policy and reject a delayed job before sending it. The mail transport cannot silently turn ten minutes into an hour. I don't need the provider to understand account recovery, and I don't want the identity service to know provider-specific payload fields.
The custom domain is a separate boundary. DKIM signs selected message content and associates that signature with a domain through DNS-published key material; the signing domain is carried in the signature's d= tag. SPF concerns whether the sending infrastructure is authorized for the envelope domain. They answer different questions, so a green check beside one is not evidence that the other is configured. The first production send should wait for an automated preflight that inspects both DNS records and a received test message.
Migration-safe invariants and failure boundaries
The first invariant is temporal: the reset token expires on the server, regardless of what the message says. Email copy is explanatory, never enforcement. The second is referential: a message records the template version and token identifier, but logs must not retain the raw reset URL or token. The third is operational: retrying the same job must not mint a second token or produce an unbounded series of messages. The fourth is organizational: changing security copy requires the same review path as changing the issuer.
Failure modes need names because “email failed” is useless. render_rejected means required data was absent before transport. expired_before_send means queue delay consumed the useful lifetime. transport_rejected means the mail API declined the request. delivery_unknown means acceptance occurred but no terminal event has arrived. auth_mismatch means the received test message does not show the intended authentication result for the intended domain. These states should be mutually understandable across identity, communications, and support teams, even if their underlying systems use different labels.
Be strict here.
An API acceptance response is evidence of handoff, not proof that the learner received or read the message. Open tracking is especially weak evidence: Apple Mail Privacy Protection can download remote content privately and prevent a sender from learning whether a recipient opened a message. For a password-reset flow, the defensible product metric is completion of the reset, joined to a non-secret token identifier, while delivery events remain operational signals.
Coordination cost across ownership options
| Canonical owner | Change path | Failure boundary | Best fit | Limitation |
|---|---|---|---|---|
| Application repository | Code review and deployment | Rendering fails before the API call | Security-sensitive, short-expiry messages | Copy-only edits follow an engineering release path |
| Mail platform | Provider editor or template API | Runtime lookup and remote version selection | Campaign-like content changed frequently by specialists | Security policy and copy can drift unless versions are pinned |
| Dedicated template service | Separate review and deployment | Network lookup plus cache/version behavior | Many applications sharing governed templates | Adds another runtime dependency and ownership surface |
Repository ownership wins for this reset message because the token policy, wording, tests, and rollback unit stay together. It isn't universally better. If a communications team must alter localized copy several times a day without an application release, a governed template service or a mail-platform template can be the right owner; pin a version in the send request, prohibit the remote layer from constructing reset URLs, and test each published version against the issuer's expiry policy.
The catch is translation. Keeping templates beside the identity service can make linguists wait on engineers, and a deployment may be disproportionate for punctuation. That cost is real. The boundary is still appropriate when an inaccurate duration or link target creates a security or support incident; for lower-risk welcome content, shift ownership closer to the team doing the editing.
Rollout contract in Python
The transport contract below uses a configured API URL instead of inventing a provider route. It renders first, refuses stale work, sends a stable idempotency key, and records only identifiers safe for an operational log. The exact authorization header and response schema belong in the adapter for the selected service.
from dataclasses import dataclass
from datetime import datetime, timezone
from html import escape
import json
import os
from urllib.request import Request, urlopen
TEMPLATE_VERSION = "password-reset-v4"
@dataclass(frozen=True)
class ResetMessage:
recipient: str
display_name: str
reset_url: str
token_id: str
expires_at: datetime
def render(message: ResetMessage, now: datetime) -> dict[str, object]:
seconds_left = int((message.expires_at - now).total_seconds())
if seconds_left <= 0:
raise ValueError("expired_before_send")
minutes_left = max(1, seconds_left // 60)
name = escape(message.display_name)
link = escape(message.reset_url, quote=True)
return {
"to": [{"email": message.recipient}],
"from": {"email": "account@notify.school.example"},
"subject": "Reset your learning account password",
"html": (
f"<p>Hello {name},</p>"
f"<p><a href=\"{link}\">Reset your password</a>. "
f"This link expires in {minutes_left} minutes.</p>"
"<p>If you did not request this, you can ignore this email.</p>"
),
"metadata": {
"template_version": TEMPLATE_VERSION,
"token_id": message.token_id,
},
}
def send(message: ResetMessage) -> str:
now = datetime.now(timezone.utc)
payload = render(message, now)
request = Request(
os.environ["EMAIL_API_URL"],
data=json.dumps(payload).encode("utf-8"),
method="POST",
headers={
"Authorization": f"Bearer {os.environ['EMAIL_API_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": f"reset:{message.token_id}:{TEMPLATE_VERSION}",
},
)
with urlopen(request, timeout=5) as response:
result = json.load(response)
return str(result["message_id"])
There is a deliberately large gap between the example and production code. Validate recipient addresses at account creation, do not put secrets in metadata, cap transport retries inside the token lifetime, and route terminal delivery events through an authenticated event consumer. Store message_id, token_id, template_version, timestamps, and normalized state transitions. Keep the raw body only if a documented retention requirement justifies the exposure. I'm not sure one retention period fits every school or jurisdiction; privacy counsel and incident-response requirements should settle that policy, not a copied default.
Consider a job created at 14:25:00Z for a token expiring at 14:35:00Z. A worker that claims it at 14:34:58Z can still pass a naive expires_at > now check, spend two seconds rendering and waiting for a connection, then hand off a message whose link is already dead. The policy should reserve a minimum useful-delivery window before transport, not merely test for a positive remainder. The correct margin depends on the queue and the user promise, so derive it from an explicit service objective and observe queue age; don't invent a universal number. Test the exact boundary with a fixed clock, including the equality case, and record expired_before_send without sending. This one scenario also verifies that retries reuse the same token identifier and template version instead of restarting the security clock.
Before enabling the flow, run a fixture through every locale and assert that the body contains one expected HTTPS origin, the configured duration, and no unresolved placeholder. Then send to controlled inboxes, inspect the received authentication results, and exercise a delayed queue item. A useful deployment gate checks the behavior at one second before expiry and at expiry. Exact edges matter.
What should a transactional email API with custom domain, DKIM, and SPF reject?
The rejected design performs a template lookup by a mutable name such as password-reset-current during each send. It appears convenient, but it splits a single security statement across the token issuer, a remote editor, DNS configuration, and transport. A rollback of application code does not necessarily roll back copy. A late edit can describe an expiry the server never granted. Caches can also make “current” mean different versions at different workers even when every component behaves according to its contract.
Remote ownership remains valid for a welcome email with no secret and no short-lived authorization decision. In that case editorial autonomy may outweigh atomic deployment, provided the application supplies only approved data, the remote system exposes immutable versions, and release evidence identifies exactly which version was sent. Use the same adapter boundary; change the owner deliberately.
For the password-reset decision, review after any change to token lifetime, sending domain, DNS keys, template ownership, queue retry policy, or mail transport. The conclusion is narrow: keep this security-sensitive template with the issuer, keep transport replaceable, and measure reset completion rather than opens.
Top comments (0)