DEV Community

BenedictVance6863
BenedictVance6863

Posted on

4 Compliance Controls When Transactional Email APIs Replace SMTP Relay for 2FA Codes

TL;DR: An email API can replace an SMTP login flow for 2FA codes and marketplace order notices. The protocol swap is the easy part. The application still needs four evidence controls: a stable notification ID, a recorded handoff result, authenticated delivery events, and a retention policy that does not preserve the secret itself. Treat an API's accepted response as evidence of handoff, not proof that a seller received the message.

For a logistics marketplace, the least complex useful design is one notification service between order events and the outbound channel. It creates the seller-facing message, calls a replaceable transport adapter, and writes evidence to a small ledger. The same boundary can send a short-lived login code before the seller opens the new order, but code generation, expiry, attempt limits, and redemption stay in the identity system. Email only carries the code.

This distinction matters during a compliance review. A team should be able to reconstruct why order ord_7F31 caused one notification, when the transport accepted it, and which later event changed its state. It should not need a mailbox password, a copy of the OTP, or a screenshot from an operator console to do that.

That is the bar.

Can an API really replace the SMTP login flow?

Yes, at the application boundary. Both approaches can submit a transactional message, and neither submission method proves inbox placement or human receipt. SMTP authentication establishes access to a relay; an HTTP API commonly establishes access with a scoped credential. The meaningful architectural question is what evidence your application receives and can retain after the handoff.

Do not let the transport own authentication policy. A 2FA code must remain one-time and short-lived even if delivery is delayed, retried, or duplicated. Likewise, the order database remains authoritative for the order. The notification says that a new order exists and links the seller back to the marketplace; it should not become a second copy of mutable fulfillment data.

The four useful states are deliberately small:

State What it proves What it does not prove
prepared The application created one notification for a known business event The transport saw it
accepted The transport returned a successful handoff result and an external ID Delivery or reading
delivered An authenticated delivery event matched that external ID Human receipt or action
failed A handoff or later delivery event failed That a retry is safe

Keep “opened” out of the compliance claim. Even where an implementation can observe it, an open signal is not reliable proof that the intended seller read or acted on the order. The business action is better evidence: the authenticated seller viewed or acknowledged the order inside the marketplace.

The trade-off is extra state. Four states, two identifiers, and a retention job take more engineering than calling send() and forgetting the result. For low-risk mail with no audit requirement, that machinery may be unnecessary. For 2FA and order access, a thin ledger earns its keep because it separates an application decision from a transport claim.

Put the ledger before the transport

The data flow is compact. An order-created event enters a notification worker. The worker derives a deterministic notification ID from the event and purpose, stores a redacted record, renders the message, and invokes either an API adapter or an SMTP adapter. The adapter returns a normalized receipt. Later delivery events update the same record only after their authenticity and correlation data have been checked.

Here is a runnable, provider-neutral slice. The in-memory transport stands in for an API client, so the example exposes the contract without inventing a commercial endpoint. Swap it for an SMTP adapter and the ledger does not change.

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone
from hashlib import sha256
from typing import Protocol
from uuid import uuid4


def now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


@dataclass(frozen=True)
class Message:
    notification_id: str
    recipient: str
    subject: str
    body: str


@dataclass(frozen=True)
class Receipt:
    accepted: bool
    external_id: str | None
    detail: str


class Transport(Protocol):
    def send(self, message: Message) -> Receipt: ...


class MemoryApiTransport:
    def send(self, message: Message) -> Receipt:
        return Receipt(True, f"msg_{uuid4().hex}", "accepted")


class EvidenceLedger:
    def __init__(self) -> None:
        self.rows: dict[str, dict[str, str | None]] = {}

    def prepare(self, message: Message, event_id: str) -> None:
        self.rows[message.notification_id] = {
            "event_id": event_id,
            "recipient_hash": sha256(message.recipient.encode()).hexdigest(),
            "purpose": "seller_new_order",
            "state": "prepared",
            "external_id": None,
            "updated_at": now_iso(),
        }

    def record_handoff(self, notification_id: str, receipt: Receipt) -> None:
        row = self.rows[notification_id]
        row.update(
            state="accepted" if receipt.accepted else "failed",
            external_id=receipt.external_id,
            updated_at=now_iso(),
        )


def notification_id(event_id: str, purpose: str) -> str:
    material = f"{event_id}:{purpose}".encode()
    return sha256(material).hexdigest()[:24]


def notify_new_order(
    event_id: str,
    order_id: str,
    seller_email: str,
    transport: Transport,
    ledger: EvidenceLedger,
) -> str:
    notice_id = notification_id(event_id, "seller_new_order")
    message = Message(
        notification_id=notice_id,
        recipient=seller_email,
        subject=f"New marketplace order {order_id}",
        body=f"Sign in to review and acknowledge order {order_id}.",
    )
    ledger.prepare(message, event_id)
    ledger.record_handoff(notice_id, transport.send(message))
    return notice_id


ledger = EvidenceLedger()
notice_id = notify_new_order(
    event_id="evt_01JORDER7F31",
    order_id="ord_7F31",
    seller_email="dispatch@example.test",
    transport=MemoryApiTransport(),
    ledger=ledger,
)
assert ledger.rows[notice_id]["state"] == "accepted"
print(ledger.rows[notice_id])
Enter fullscreen mode Exit fullscreen mode

The example stores a recipient hash to reduce casual exposure in the audit table, but a plain hash of an email address is still guessable. In production, use a keyed digest when the audit use case does not require recovering the address, keep the key outside the ledger, and document who can perform a lookup. Do not place a 2FA code in this record at all.

The deterministic ID is also a decision, not magic deduplication. A unique constraint on that ID prevents two workers from creating two logical notifications for the same event and purpose. It does not make every network retry safe. Consider a worker that submits the ord_7F31 notice, loses the connection before reading the response, and wakes up with the ledger still at prepared. Sending again immediately may create two messages; refusing to retry may create none. The correct branch depends on evidence available from the transport: use its documented idempotency facility if one exists, or reconcile the notification ID against its message records before another attempt. If neither operation is available, record the attempt as unresolved and apply a purpose-specific manual or delayed policy. Do not quietly relabel uncertainty as failure. That uncomfortable state is more truthful, and it gives an auditor a reason for the eventual decision instead of a fabricated certainty.

Evidence changes the retry design

A tempting implementation marks an email “sent” as soon as a request returns success. That label overstates the evidence. Call it accepted, retain the external message ID, and move to delivered only when a verified asynchronous event says so. A rejection can move directly to failed; a timeout is unresolved until reconciliation or a controlled retry settles it.

Accepted is not delivered.

This is where API and SMTP integrations often feel different operationally. An API may return structured response data that is convenient to normalize. An SMTP client may expose a relay response through a different shape. The ledger contract should absorb that difference. Transport-specific response bodies do not belong in order-domain code.

Retries need two clocks. The first is technical: backoff prevents a transient transport problem from becoming a request storm. The second is business time: a login code that expires before the next attempt should not be sent, while an order notice may still be useful later. Therefore the worker checks purpose and validity before each attempt. It never extends an OTP's validity because the channel was slow.

Keep code verification independent too. Store a verifier suitable for the authentication design, compare attempts in the identity service, invalidate the code after successful use, and rate-limit attempts. The email body is a delivery artifact, not the system of record for authentication.

DMARC adds another evidence boundary. RFC 7489 describes domain owners publishing policy through DNS and receivers feeding authentication results into message handling. It also defines aggregate and failure reporting. Those reports can support domain-level monitoring, but they do not prove that one specific seller saw one specific order notice. Preserve that distinction in dashboards and audit language.

An API is not suitable merely because it is newer than an established SMTP relay. Keep the relay when it already satisfies credential isolation, observable handoff, correlation, event verification, regional handling, and retention requirements, especially if replacing it would add an unsupported operational dependency. Choose an API when its documented contract makes those controls clearer or easier to automate. This is a transport choice, not a security upgrade by definition.

Test the behavior, not the client library

An eval-driven workflow starts in a notebook or a small test harness with recorded cases, then promotes the same cases into CI. The useful cases are business failures: a duplicate order event, a transport rejection, an ambiguous timeout, an event with an unknown external ID, an expired login code, and a delivery event replay. Mocking one send() call is too shallow because it cannot tell you whether evidence remains coherent across retries.

class RejectingTransport:
    def send(self, message: Message) -> Receipt:
        return Receipt(False, None, "policy_rejection")


def test_rejection_is_auditable() -> None:
    ledger = EvidenceLedger()
    notice_id = notify_new_order(
        event_id="evt_rejected_002",
        order_id="ord_8820",
        seller_email="seller@example.test",
        transport=RejectingTransport(),
        ledger=ledger,
    )

    row = ledger.rows[notice_id]
    assert row["state"] == "failed"
    assert row["event_id"] == "evt_rejected_002"
    assert row["external_id"] is None
    assert "seller@example.test" not in str(row)


test_rejection_is_auditable()
Enter fullscreen mode Exit fullscreen mode

Add contract tests for each real adapter. Given the same normalized message, each adapter must return the same receipt shape, classify permanent and retryable failures consistently, and avoid logging message bodies or credentials. Delivery-event tests should use signed fixtures and include stale timestamps, bad signatures, duplicate events, and out-of-order transitions. The exact authentication mechanism belongs to the adapter because it depends on the transport's documented contract.

Prompt cost still deserves attention even in an email pipeline. If a model drafts explanatory text, keep the 2FA code, order authority, routing decision, and compliance fields outside the prompt. Pin the prompt and model configuration, evaluate output against a fixed corpus, and cap generated text to the smallest surface that benefits from generation. A deterministic template is the right default for a login code. It is easier to test, cheaper to operate, and less likely to alter mandatory wording.

No model is needed for the code itself.

Make operations answerable

Deployment is ready when an operator can answer a narrow set of questions from retained evidence: Which business event created this notification? Which template version rendered it? Which transport accepted it, under which credential identity? What external ID came back? Which authenticated event caused the latest state? When will the record be deleted?

Alert on unresolved states, not raw traffic alone. A growing set of old prepared records points toward a worker or handoff problem. Long-lived accepted records indicate that delivery evidence has not arrived or cannot be correlated. A jump in failed records should be split by stable failure class, while message bodies and OTP values stay out of metrics and logs.

Channel fallback deserves an explicit product rule. SMS has different message-length and encoding constraints: the referenced character-limit guidance distinguishes GSM-7 from UCS-2 and explains segmentation. That makes “send the same body by SMS” a poor fallback design. Use a channel-specific, tested template, and do not create a second active OTP unless the identity policy intentionally supersedes the first one.

Before release, walk one synthetic seller and order through the full path in a non-production environment. Confirm one logical notification, inspect the redacted ledger, replay the same event, inject a rejection, verify an authenticated delivery event, and exercise retention deletion. Then rehearse credential rotation and transport replacement without changing order-domain code. This is a short checklist in prose because the sequence matters: create, correlate, challenge, remove.

An API is a sound replacement for an SMTP login flow when it improves the handoff contract and fits this evidence model. The durable design choice is the boundary around it. Keep identity rules in identity, order truth in the marketplace, and delivery claims no stronger than the recorded evidence.

Further reading

Top comments (0)