Invoice email delivery
The local invoice composer first queues an email with ToBeSent and LocalQueueOnly. Queueing alone does not send it. The delivery worker is disabled by default. An opt-in SMTP adapter is available; see /docs/smtp-email-adapter for mandatory TLS, sender restrictions and operator configuration.
State meanings
| State | Meaning |
|---|---|
| Pending | Immutable message and attachment intent saved; awaiting a worker. |
| Dispatching | Attempt committed before invoking the adapter. |
| Sent | Adapter proved transport acceptance; recipient delivery is not proved. |
| Rejected | Adapter reported rejection. |
| Unknown | Acceptance could not be determined, including interrupted dispatches. |
| Blocked | Local validation failed before adapter invocation. |
The Admin email log displays these distinctions without exposing provider response bodies. Unknown results must be reconciled before another attempt. Neither a timeout nor a process restart is evidence that the message was not sent.
Adapter integration
The internal Billing::InvoiceEmailDelivery service accepts an explicitly injected adapter. InvoiceEmailDeliveryJob obtains its adapter and global enable decision from the central system configuration at /admin/system_settings. The merchant’s InvoiceEmailDeliveryEnabled flag is also required. Global and merchant settings are checked again before each subsequent dispatch; an already active SMTP connection is not interrupted. Configuration loading opens no connection.
When no system configuration exists, the job falls back to BILLING_INVOICE_EMAIL_DELIVERY_ENABLED=true and BILLING_INVOICE_EMAIL_ADAPTER=smtp with a complete SMTP environment configuration. A stored configuration, even when disabled, overrides that fallback.
The service verifies the frozen message, tenant references and exact immutable PDF bytes before invoking the adapter. It commits a dispatch marker and attempt first, and rejects invocation from inside an existing database transaction. Worker retries cannot claim the same active attempt twice.
Reconciliation accepts only authoritative confirmed-sent, confirmed-rejected or confirmed-not-sent evidence from the adapter. Only confirmed-not-sent permits another attempt. Repeating the same reconciliation request does not contact the adapter again. Adapter errors are reduced to fixed local codes, not copied into public error messages.
REST compatibility boundary
The existing POST /api/v1/emails/{id}/resend operation may requeue an email that has no durable delivery intent. Once an intent exists, it returns 409 EmailDeliveryResendBlocked before changing the email or customer. An explicit new-message/resend-generation workflow is still required for that case.
The delivery worker and reconciliation service are internal integration interfaces; this change adds no new public REST endpoint. Dunning emails remain in their separate local queue workflow. Production SMTP configuration, scheduling, delivery receipts, bounce handling and a controlled staging test remain outstanding.