Short answer: an SMTP relay can deliver an OTP, but it is a poor place to keep the compliance story when one login flow uses mixed providers. Put a provider-neutral delivery record in your application first, attach a redacted decision trace, and treat SMTP or an HTTPS sender as a replaceable transport.
I reached this conclusion while designing a customer-support contact form that routes each request to a queue. The form is not an OTP flow, yet the same evidence problem appears: six months later, an auditor asks why a message went to the billing queue and whether the notification was sent. A relay transcript alone cannot answer the routing question. For OTP and 2FA, the stakes are higher because the record must show which challenge was issued, which channel was selected, and when the attempt expired without storing the code itself.
The failure is missing evidence, not SMTP
SMTP is a protocol for transferring a message. It does not define your application’s identity for a challenge, its risk decision, or its retention policy. A relay may return a message identifier and a handoff status, but your audit system still needs a stable event ID, tenant, policy version, destination domain, and outcome. Mixing providers makes that gap visible: a timeout from one sender and an accepted response from another can look like one attempt unless your own event ledger ties them together.
That distinction is easy to miss.
The simple implementation is tempting. I initially treated the relay response as the event, then noticed that a process can exit between message handoff and the later log write. That sequence loses useful facts. It also encourages logging the code, which turns a diagnostic artifact into a credential. A safer order is to create an immutable intent record, hash or omit secrets, and then append transport observations; the extra record is small, but it gives a reviewer a complete timeline even when the worker disappears at the worst possible moment.
Keep the secret out.
Here is the small event shape I use for a notebook-to-prod prototype. It is deliberately boring; boring records are easy to inspect.
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
import hashlib
import json
@dataclass
class DeliveryIntent:
event_id: str
challenge_id: str
queue: str
destination_domain: str
policy_version: str
created_at: str
def fingerprint(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
intent = DeliveryIntent(
event_id="evt_01JOTP7Q83",
challenge_id=fingerprint("challenge-7f2c"),
queue="account-recovery",
destination_domain="example.net",
policy_version="otp-policy-3",
created_at=datetime.now(timezone.utc).isoformat(),
)
print(json.dumps(asdict(intent), sort_keys=True))
The short fingerprint is a correlation handle, not proof that a particular code was correct. Keep the challenge outcome, attempt count, and expiry in a separate access-controlled store.
What should an OTP email audit trail record across SMTP relay and mixed providers?
Start with the facts that remain meaningful after a sender changes. Record event_id, challenge_id, and a policy version generated by your service. Add the selected transport class, provider request ID if one exists, enqueue time, acceptance time, final delivery signal, and a reason category for fallback. Never make a provider’s message ID your primary key.
For a support contact form, the equivalent record includes the classifier version, queue decision, confidence band, and the human override. That parallel is useful in reviews: the OTP policy and the support router both need a reproducible explanation, not a screenshot of a vendor dashboard.
I keep the event schema append-only and emit one record per state transition: created, submitted, accepted, delivered, expired, or blocked. A retry is a new transition with a parent event ID, not an overwrite. This lets an evaluator ask whether a fallback increased duplicate sends or merely improved observability.
A compact policy function can make the decision testable before any network call:
def choose_transport(*, domain: str, risk: str, relay_allowed: bool) -> str:
if risk == "high":
return "https-api"
if relay_allowed and domain not in {"disposable.test"}:
return "smtp-relay"
return "https-api"
assert choose_transport(domain="example.net", risk="high", relay_allowed=True) == "https-api"
assert choose_transport(domain="example.net", risk="normal", relay_allowed=True) == "smtp-relay"
The point is not that HTTPS always wins. The point is that the policy can be replayed in an eval harness with synthetic domains and risk labels.
Where mixed providers create operational surprises
A fallback can create two valid deliveries for one challenge. If the first sender accepted the message and your timeout triggered a second attempt, the user may receive two codes. Bind the challenge to a single active attempt, and make later messages explain that only the newest code is valid. Rate-limit by account, destination, and source network; count failures separately from sends. This is where a long-running test pays off: replaying the same event through a delayed response, a worker restart, and a provider switch exposes duplicate paths that a happy-path unit test will never see.
Mailbox-provider requirements matter too. Yahoo’s sender guidance calls for authentication, low complaint rates, and unsubscribe handling for applicable mail. Those are sender-level controls, so moving OTP traffic between relays does not remove the need to monitor them. Your evidence record should retain the domain and authentication configuration revision used for each route.
The other surprise is retention. Delivery telemetry often contains a full address, subject, or message body by default. For compliance evidence, store a domain, a stable recipient fingerprint, and a template revision unless an investigator has a documented reason to access more. Set deletion jobs and test them; a retention policy that exists only in a document is not an operational control.
A decision table I can defend in a review
| Condition | Prefer | Evidence to require |
|---|---|---|
| One sender, low-risk internal pilot | SMTP relay | Relay response, event ID, expiry test |
| High-risk login or regulated tenant | Direct HTTPS sender | Request ID, policy trace, delivery signal |
| Mixed providers with fallback | Either transport behind one adapter | Parent event, idempotency key, duplicate-send metric |
| Provider contract cannot expose useful status | A different sender or a queue you operate | Synthetic probe and documented status mapping |
The catch is that a mixed setup is not suitable when your team cannot operate an event ledger, replay tests, and alerting. In that case, stick with one well-understood sender until those controls exist. A single relay is easier to reason about, even if it gives you fewer routing options.
I am not sure any sender’s “delivered” signal proves that a human saw the code; mailbox acceptance is not user verification. Your test plan should say what each status means and what it cannot prove.
Measure before changing the transport
Before copying this pattern, run a 14-day synthetic test across the domains your customers actually use. Measure time from intent to acceptance, time to first visible delivery where you can ethically observe it, duplicate challenge rate, fallback rate, and the percentage of events with complete policy metadata. Add a query that reconstructs one challenge from creation through expiry in under a minute.
Then run failure drills: kill the worker after intent creation, delay a provider response, and replay the same idempotency key. The expected result is one active challenge, a clear state transition, and no secret in logs. A green dashboard without those drills is decoration.
The practical answer to “why not use SMTP relay?” is therefore conditional. SMTP is fine as a transport when its handoff semantics and authentication controls fit your risk model. It becomes a liability when the relay is also your audit database, retry policy, and explanation for a routing decision. Keep those responsibilities in your application, test them with real failure modes, and let the transport be the part you can swap.
References
- https://resend.com/docs/introduction
- https://senders.yahooinc.com/best-practices/
- https://www.rfc-editor.org/rfc/rfc5321
Top comments (0)