oskarholm4968A health marketplace has two clocks running at once: a seller should learn about a new order...
A health marketplace has two clocks running at once: a seller should learn about a new order promptly, while a one-time code used to enter the seller console must remain short-lived and bound to one authentication attempt. The integration choice should preserve that separation. Use an email API or an SMTP relay as a replaceable delivery adapter, but keep code generation, expiration, attempt limits, order state, and audit history inside the application boundary.
TL;DR: an email API can replace an SMTP login flow for delivering 2FA codes. It cannot replace the login flow itself. The smallest safe integration exposes one internal notification command, assigns it a stable idempotency key, records each handoff, and treats provider acceptance as a delivery milestone rather than proof that a seller read the message. The same adapter may carry a new-order alert, but that alert must never contain or imply an authentication credential.
SMTP and an email API solve the transport handoff in different ways. An SMTP client authenticates to a relay and submits a message; an API client usually authenticates an HTTPS request carrying equivalent message data. From the marketplace application's perspective, both belong behind a narrow interface. Neither should decide whether a 2FA challenge is valid, consume a code, mutate an order, or authorize a seller session.
That boundary matters because transport retries and authentication retries mean different things. Repeating a transport handoff with the same logical message may be correct after a timeout. Reusing or regenerating a code without consulting challenge state may extend its useful lifetime or create two valid values. Keep those clocks apart.
No transport fixes this.
For a new order, persist the order and an outbox record in the same database transaction. A worker later turns that outbox record into a notification command. For a login challenge, persist only what the verifier needs, apply an explicit expiration and attempt policy, and enqueue a separate notification command whose template receives the code as transient input. The delivery receipt belongs to the notification audit trail; successful verification belongs to the authentication audit trail.
A compact interface keeps the integration effort visible:
package notify
import (
"context"
"time"
)
type Message struct {
ID string
Purpose string
Recipient string
Template string
Variables map[string]string
NotAfter time.Time
}
type Receipt struct {
MessageID string
AcceptedAt time.Time
TransportID string
}
type Sender interface {
Send(ctx context.Context, message Message) (Receipt, error)
}
Message.ID is the application's idempotency key. NotAfter lets a worker refuse stale 2FA work before making a network call; it is not evidence that a recipient's mailbox will enforce the same deadline. TransportID supports reconciliation, but it must not become the primary key for business state because it is assigned outside the application.
The critical states are local: queued, handed off, retryable, terminally failed, and suppressed because the message became stale. Provider-specific statuses can be retained as evidence, then mapped into that smaller vocabulary. This gives operations one stable model when transports change and prevents a remote label from silently changing authentication behavior.
Exactly-once delivery is the wrong promise across a database and an external mail system. The defensible target is an exactly-once business transition with at-least-once processing around it. Insert the outbox row atomically with the order or challenge record, claim work with a lease, and make every retry carry the same message ID. If a network timeout leaves acceptance unknown, reconciliation should search by that ID before another send where the transport permits it; otherwise the system must tolerate a duplicate notification.
Duplicates are annoying for order alerts. For 2FA they are confusing, especially if two messages expose different codes. Generate one challenge value for one challenge ID and do not rotate it merely because delivery is retried. The verifier should atomically move the challenge from active to consumed, so two concurrent correct submissions cannot create two sessions. Audit the transition, not the secret.
Do not store a reusable plaintext code in general application logs. Keep recipient data and credentials out of error strings, restrict retention to the operational and legal purpose, and make access to notification records auditable. Concrete retention periods and attempt limits are policy decisions that depend on the marketplace's threat model and regulatory obligations; a transport library cannot choose them responsibly.
The first request is a small fraction of the work. A realistic estimate includes credential rotation, domain authentication, templates, bounce and complaint processing, webhook authentication, retry classification, rate controls, redaction, dashboards, reconciliation, and a transport migration test. SMTP may fit an existing mail abstraction with little application code. An API may expose structured errors and metadata more directly. Either can be the lower-effort choice depending on the team's current operational surface.
Email domain authentication remains independent of the client protocol. SPF publishes which systems may send for a domain, DKIM provides a domain-associated signature, and DMARC defines alignment and policy using those mechanisms. Moving from SMTP submission to an HTTPS API does not remove that work. RFC 7489 also describes aggregate and failure reporting, which can inform domain-level investigation without becoming an application delivery ledger.
SMS is another transport, not an automatic fallback. GSM-7 messages have a 160-character limit when sent as one segment, while UCS-2 messages have a 70-character single-segment limit; concatenation reduces the per-segment payload because headers consume space. A localized seller alert can therefore change segmentation after a single non-GSM character. More importantly, switching a 2FA challenge from email to SMS changes threat assumptions, consent and opt-out handling, data flows, and operational dependencies. Model that as an explicit policy decision.
Use a comparison worksheet based on owned work rather than nominal feature count:
| Concern | SMTP relay | Email API | Application must still own |
|---|---|---|---|
| Submission | SMTP session and envelope | Structured HTTPS request | Stable message identity |
| Retry signal | SMTP reply and connection outcome | HTTP outcome and response body | Backoff, retry budget, stale-work check |
| Authentication | Relay credentials | API credentials | Secret rotation and least privilege |
| Domain trust | SPF, DKIM, DMARC configuration | SPF, DKIM, DMARC configuration | Alignment monitoring and policy |
| Audit | Relay response plus local logs | API response plus local logs | Business correlation and retention |
| Migration | Swap SMTP implementation | Swap API implementation | Contract tests and reconciliation |
This table does not yield a universal winner. It exposes where integration effort moves. A team with a mature SMTP client and established bounce ingestion may gain little from rewriting submission alone, while a team that needs structured per-message metadata may accept an API integration because it reduces parsing and correlation work. The decision should be reversible.
The limitation is operational, not syntactic. An API is unsuitable when the organization cannot operate another credential type, authenticate callbacks, or reconcile a second status vocabulary; retaining the established SMTP relay is then the smaller and more auditable change. SMTP is a poor fit when the application needs structured message metadata but the relay exposes only coarse responses and mailbox-level reports. The trade-off is explicit: an API can reduce application-side parsing while adding an HTTP contract and callback surface, whereas SMTP can reuse mature infrastructure while leaving correlation work to local conventions. Neither choice removes sender-domain configuration, secret rotation, queue ownership, or incident response.
Classify failures before retrying. Invalid recipients and rejected content are generally terminal for that message; timeouts, connection loss, and explicit transient responses may be retryable within a bounded budget. An authentication failure should page the owning team rather than hammer the endpoint. A stale 2FA message should be suppressed even if the transport has recovered.
Acceptance is not delivery. Delivery is not reading. Reading is not authentication.
Four different facts.
Record timestamps for creation, first attempt, each classified result, acceptance, and terminal disposition. Attach the order ID or challenge ID as an internal correlation value, but do not put sensitive business details into transport-visible metadata without a documented need. Reconciliation then becomes a ledger exercise: compare locally expected handoffs with externally reported outcomes, flag gaps, and preserve the evidence used to resolve them.
Testing should cover the uncomfortable edges: a timeout after remote acceptance, two workers claiming the same row, a callback arriving before the request response is committed, duplicate callbacks, an expired challenge still in the queue, and an order transaction that rolls back. Contract tests can run against a local fake implementing Sender; staging tests should verify authenticated sending domains and callback validation without using production recipients. Deployment should begin with shadow accounting, comparing the new adapter's intended actions with the current path before it is allowed to send.
Observability needs low-cardinality metrics such as attempts, accepted handoffs, retryable failures, terminal failures, stale suppressions, and queue age, partitioned only by purpose and transport class. Message IDs belong in trace fields and logs, not metric labels. Alert on sustained divergence between created commands and terminal outcomes, because a healthy HTTP success rate can coexist with a growing queue.
Start by placing the current sender behind the interface and assigning stable message IDs. Next, add the outbox, state transitions, redaction rules, and reconciliation report while behavior remains unchanged. Only then introduce a second adapter behind a purpose-scoped feature flag, beginning with non-credential order alerts and a small seller cohort.
Before moving 2FA traffic, verify stale-message suppression, duplicate handling, callback authentication, domain alignment, and rollback behavior. During migration, keep one system of record for notification state and compare outcome distributions by purpose; do not let both adapters race to send the same command. Remove the old path only after queued work has drained and audit queries produce consistent answers.
The durable conclusion is narrow: replace the submission mechanism when its total integration and operating burden is lower, but preserve the application's ownership of authentication state, idempotency, and evidence. That boundary makes a seller's new-order alert useful without allowing a transport decision to redefine account security.