TL;DR: An Express transactional email service wrapper should keep an edtech password-reset template under application ownership, enqueue delivery through an API rather than an SMTP relay, retry only transient failures within the link's useful lifetime, and log state transitions without logging the token. The delivery system may own channel rendering and transport details, but it must not quietly rewrite purpose, variables, expiry wording, or security-sensitive links. The page should fire on an expired or stuck reset, not on every failed HTTP attempt.
This is less convenient than letting whichever system sends the email become the source of truth. It is also much easier to reason about during an incident. A password reset can be delivered perfectly and still be wrong: the template can promise 30 minutes while the application rejects the link after 10, a retry can arrive after expiry, or a template edit can omit the school name that tells a parent which account is being changed. A green delivery dashboard does not settle any of those questions.
How should an Express transactional email service wrapper retry expired jobs?
Start the postmortem timeline at the reset request, not at the provider response. The useful event sequence is requested, queued, accepted for delivery, and either completed before expiry or terminal before expiry. Transport attempts belong beneath that sequence. They are evidence, not the user outcome.
The operational invariant is blunt: never begin or repeat a delivery attempt when the reset link no longer has enough remaining lifetime to be useful. That rule requires the job to carry an absolute expiration time created by the application. A worker that reconstructs expiry from queue age, retry count, or template copy is making a security decision from secondary data.
Consider a bounded incident: a learner requests a reset just before class, the first delivery call times out, and the queue retries later. There are several possible truths hidden behind that timeout. The service may have accepted the first request, may have rejected it, or may never have seen it. Retrying with a stable idempotency key contains the duplicate risk, but time still wins. Once the application deadline is reached, mark the job expired and stop.
Time wins.
Do not celebrate a late acceptance as recovery.
The alert should represent consumed user opportunity. Page when a sustained share of reset jobs reaches expiry without a recorded acceptance, or when queue delay consumes the configured delivery budget. Create a ticket for isolated retryable attempts that recover. Exact thresholds belong to the service objective and traffic shape; inventing a universal percentage would make the alert look precise while teaching the on-call nothing.
Template ownership is an incident boundary
A template is executable behavior wearing prose. The application knows why the message exists and what the token means. A delivery team knows message construction, text and HTML alternatives, suppression handling, and channel constraints. Giving either side unrestricted control creates a gap where expiry semantics can drift.
That is the boundary.
Use a versioned contract. The application repository should define a template key, contract version, required variables, absolute expiry, locale, and a reviewed revision. The rendering layer should reject unknown versions or missing variables instead of producing a plausible partial message. Store the approved subject and expiry phrasing with the contract, while allowing the delivery layer to implement encoding and transport-safe rendering.
| Concern | Accountable owner | Failure behavior |
|---|---|---|
| Token creation and absolute expiry | Application | Do not enqueue an invalid request |
| Meaning of variables and security copy | Application/content review | Reject incompatible template versions |
| HTML, plain text, encoding, and transport | Delivery system | Fail the attempt without changing semantics |
| Retry budget and queue policy | Messaging platform | Stop before the usefulness deadline |
| User-outcome alert | Service owner | Page on expired or stuck workflows |
This division costs coordination. A small team with one repository and one deployable may reasonably keep rendering beside the application, provided the queue payload still names a version and tests pin the rendered result. The stronger boundary matters when templates can be edited independently, several applications share delivery infrastructure, or review must identify exactly which words accompanied a security action.
The same wrapper may send a welcome email, but that longer-lived job should have its own retry budget and alert. Reusing code does not make the two user outcomes equivalent.
A preventative worker path
The HTTP handler should authenticate and rate-limit the reset request, create the reset record, and enqueue a message command. It should not wait through delivery retries. The queue consumer below focuses on the dangerous boundary: expiry, idempotency, classified errors, and redacted logs. It models the transport as an interface because an SDK type leaking through this layer would make ownership ambiguous.
package resetmail
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"log/slog"
"time"
)
type Job struct {
ID, AccountID, TemplateVersion string
ExpiresAt time.Time
Attempt int
}
type Receipt struct{ MessageID string }
type Sender interface {
SendReset(context.Context, Job, string) (Receipt, error)
}
type Retryable interface {
error
Temporary() bool
}
var ErrExpired = errors.New("reset message expired")
func Deliver(ctx context.Context, now time.Time, job Job, sender Sender, log *slog.Logger) (Receipt, error) {
if !now.Before(job.ExpiresAt) {
log.WarnContext(ctx, "reset delivery expired", "job_id", job.ID,
"account_id", job.AccountID, "template_version", job.TemplateVersion)
return Receipt{}, ErrExpired
}
sum := sha256.Sum256([]byte(job.ID + "|" + job.TemplateVersion))
key := hex.EncodeToString(sum[:])
receipt, err := sender.SendReset(ctx, job, key)
if err == nil {
log.InfoContext(ctx, "reset delivery accepted", "job_id", job.ID,
"message_id", receipt.MessageID, "template_version", job.TemplateVersion)
return receipt, nil
}
var temporary Retryable
if errors.As(err, &temporary) && temporary.Temporary() && now.Before(job.ExpiresAt) {
log.WarnContext(ctx, "reset delivery retryable", "job_id", job.ID, "attempt", job.Attempt)
return Receipt{}, err
}
log.ErrorContext(ctx, "reset delivery terminal", "job_id", job.ID, "attempt", job.Attempt)
return Receipt{}, errors.New("terminal delivery failure")
}
Production code should return a typed disposition such as retry, dead-letter, or expired so the queue does not infer policy from strings. The raw recipient and reset URL never enter the log fields. Backoff needs a cap derived from ExpiresAt, plus random jitter to avoid synchronized retries. Retry timeouts and explicitly classified transient responses; do not retry permanent address or contract-validation failures.
If the transport cannot provide idempotent acceptance, keep a local attempt ledger and accept that an ambiguous timeout may still produce duplicates. Exactly-once email is not a credible incident promise.
Test the timeline, not the happy-path response
A unit test that receives a successful mock receipt proves almost nothing. Freeze the clock, then test one second before expiry, exactly at expiry, and after expiry. Verify that every retry uses the same key, a permanent error goes terminal immediately, an unknown template version never reaches the sender, and logs contain neither the URL nor the recipient.
Then exercise the queue boundary. Kill a worker after the sender accepts but before acknowledgement and confirm that redelivery does not create a new logical send request. Delay a job until its deadline and confirm that it is recorded as expired without touching the transport. Deploy a new template contract while old jobs remain queued; the old version must still render or fail explicitly.
Watch the user path with counters for requested, accepted, terminal, and expired jobs, histograms for queue age and remaining lifetime at acceptance, and correlation by job ID. Do not make open pixels the success signal. Apple documents that Mail Privacy Protection can prevent senders from learning whether a recipient opened an email, so an open-rate graph cannot establish that the reset workflow worked. The stronger completion event is the application's successful token redemption, joined through an internal workflow identifier rather than tracking content embedded for marketing.
SMS fallback changes the abuse model, not the ownership rule. A fallback message still needs the same expiry semantics and versioned contract, while the request edge also needs controls against automated abuse. Twilio's guidance on SMS pumping describes attackers generating high volumes of messages to profit from artificial traffic; the general operational lesson is to rate-limit and monitor destination patterns before a fallback turns a mail incident into an SMS cost and abuse incident. This is a risk boundary, not an argument for any service.
Where this boundary does not apply
This architecture has a real trade-off: it adds a queue, a contract version, and another deployment boundary. It is not appropriate for a local development sink or a one-off internal notification whose lateness has no security or user consequence; a synchronous adapter can be clearer there. Direct SMTP relay code can also be adequate inside a controlled test environment, although it gives production application code too much transport responsibility for this short-lived security workflow. The advice changes again for legally prescribed content: legal or compliance may own the exact copy, while engineering still owns version compatibility, expiry enforcement, and observable delivery states.
For the short-lived reset, the decision rule remains narrow: the team that defines token validity owns message semantics; the delivery layer owns faithful rendering and transport. Review those two contracts together, alert on lost usefulness, and ask which page fires before trusting the dashboard.
Top comments (0)