Start with a message-path matrix
Write one row for every business action that should send mail: first version, revised version, accepted version, tender, reminder, receipt, cancellation, or other follow-up. For each row, identify the UI or API action, database commit, queue or outbox record, customer-send function, provider request, attachment source, and final event handler.
If the first version calls the sender from a background workflow but later versions only save a database row and download a file in the browser, the later path has no delivery failure yet. It has no send attempt. Internal staff notifications also do not prove that a customer email was created.
Do not let a generic status such as completed hide a failed notification step. Business completion and customer communication need separate, observable states.
Find the first boundary without evidence
- No send-attempt event
- Stay in the application path. Check the exact message-type branch, transaction boundary, feature flag, recipient resolution, and job enqueue.
- Attempt, but no provider ID
- Inspect request construction, authentication, timeout handling, attachment encoding, and the provider response. Do not mark the message sent.
- Provider accepted
- Record the provider message ID and await a terminal event. For Microsoft Graph, HTTP 202 means accepted for processing, not completed delivery.
- Recipient server accepted
- Separate receiver acceptance from inbox, spam, quarantine, rule processing, and user-visible completion.
Use one customer-send contract
Route every customer-facing message type through one typed service contract, even when templates and attachments differ. The caller should provide a message type, business object ID, object version, template revision, recipient reference, locale, and attachment metadata. The service should return a stable internal attempt ID and later associate it with the provider ID.
Use an idempotency key such as message_type:business_id:version:recipient_reference. Enqueue only after the business transaction commits, preferably through an outbox or equivalent durable handoff. A retry may resume the same attempt, but it must not silently create a second customer message.
Log bounded metadata, not private content: message type, internal IDs, timestamps, template revision, attachment count, byte size, checksum, provider ID, and normalized status. Avoid customer addresses, message bodies, signed document links, credentials, and tokens.
Prove the attachment crossed the provider boundary
A generated PDF in object storage or a local browser download is not an email attachment. Verify that the final provider request contains the attachment bytes or an intentional secure link, the correct MIME type and filename, and a size the selected provider path accepts.
- Record the source object version and checksum before enqueueing.
- Assert that the email adapter accepts attachment metadata and actually maps it into the provider request.
- Test zero, one, and multiple attachments, plus a file above the supported limit.
- Choose an explicit fallback: reject before sending, or send a time-bounded authorized link. Never silently omit the file.
- Keep attachment-fetch and email-send failures separate so retries resume the correct step.
Microsoft Graph supports file attachments in the JSON sendMail action. If an application wrapper exposes an attachments property but drops it before constructing the Graph message, the API cannot infer or retrieve the file.
Do not compress the delivery state
Use evidence-backed states such as requested, committed, queued, provider_accepted, receiver_accepted, and failed. Treat visible as controlled-mailbox or user evidence, not as a guess derived from API success.
- Store provider events idempotently and preserve their original timestamp and event ID.
- Allow later asynchronous bounces or suppressions to supersede an earlier acceptance state.
- Alert on attempts that remain queued or provider-accepted beyond a bounded service window.
- Keep the business object's state independent from the communication attempt state.
Regression-test every business action
A single adapter unit test is not enough. Exercise the real action that creates each version and message type, with the queue worker and provider adapter replaced by inspectable test doubles. The assertions should prove exactly one committed job, the expected customer recipient reference, the expected template revision, and the expected attachment checksum.
- First version and every later version use the same observable customer-send boundary.
- Internal assignee notifications cannot satisfy a customer-email assertion.
- A database rollback creates no queued email; a committed transaction creates exactly one.
- Retries preserve idempotency and do not duplicate the attachment or recipient.
- Provider acceptance, asynchronous failure, and receiver acceptance update the same attempt record.
Only then inspect deliverability
Once the exact later-message path has a provider ID, continue with provider events, suppressions, SMTP responses, recipient-server evidence, and original headers from controlled test mailboxes. Compare the visible From domain, envelope domain, DKIM signing domain, sending IP, content, and attachment behavior with the successful message.
Use the local incident triage tool to identify the first unproved boundary. If the provider accepted the message but a later bounce appeared, follow the asynchronous delivery state model. For controlled headers you are allowed to inspect, use the browser-local header analyzer.
Evidence required before closing the incident
- Every affected business action creates one durable customer-send attempt after commit.
- The provider request contains the expected recipient, template revision, and attachment metadata.
- A provider ID and terminal event are correlated to the same internal attempt.
- Controlled receiver evidence is recorded separately from provider acceptance.
- Regression tests cover first and later versions, every affected message class, rollback, retry, and attachment failure.