# SMTP / Email Delivery — Architecture

Written before implementation, per the standing rule that any new
subsystem this size gets a design doc first. This documents the plan as
implemented, not aspirational — see `CHANGELOG.md`'s entry for this
feature for what actually shipped vs. what's noted as deferred below.

## Update: production-hardening pass

A second pass turned the initial implementation into a
production-quality feature without rewriting this architecture — see
`docs/SMTP_PRODUCTION_AUDIT.md` (broader findings: races, UX,
accessibility, responsive) and `docs/SMTP_SECURITY_AUDIT.md` (the
20-point security checklist, each verified with real evidence — live
requests including a genuinely created, under-privileged Subscriber
account, or a passing automated test). Changes that affect the design as
originally documented below:

- **§7 Provider registry**: Postmark added as a 9th preset (data-only,
  same as the other 8 — no logic change to this section's own reasoning).
- **§8 Logging**: `Email_Log` now only records a send when there's an
  actual active (enabled + default) connection, OR the send is an
  explicit test — not unconditionally for every `wp_mail()` call on the
  site from the moment the plugin activates. This was a real bug in the
  original pass (`SMTP_PRODUCTION_AUDIT.md` finding #1), not a design
  change; §8's data model and retention behavior are otherwise unchanged.
- **§9 Failure handling**: the classifier's pattern-matching order was
  corrected — more specific categories (`tls_failure`, `timeout`, etc.)
  are now checked before the broad `connection_refused` catch-all, which
  previously shadowed `tls_failure` for a common real message shape
  (`SMTP_PRODUCTION_AUDIT.md` finding #2).
- **§4/§6 Free/Pro enforcement**: `Smtp_Connection::create()`'s gate is
  now re-checked immediately before the write, narrowing (not
  eliminating — see the audit for why full elimination would need a
  different storage architecture) the TOCTOU race window between the
  initial check and the actual write.
- **Admin UI** (not covered by name in the original doc, which focused on
  backend architecture): the connection form's labels are now
  programmatically associated with their inputs, the Pro-upgrade modal
  has a real focus trap/Escape handling/ARIA attributes, and a
  pre-existing layout bug in the *shared* Global Settings sidebar
  (`.chargeforms-settings-nav`, used by every settings section, not
  built for this feature) that broke at 390px viewport width was found
  and fixed.

Everything else below reflects the original, still-accurate design.

## 1. Existing email flow (as found)

Every outbound ChargeForms email already funnels through **one choke
point**: WordPress's own `wp_mail()`. Confirmed by grep — every call site:

- `frontend/class-submission-handler.php` — owner notifications, visitor
  confirmations, double opt-in
- `includes/class-notifications.php` — the retry-wrapped owner-notification
  sender (`Notifications::send()` → `wp_mail()`, logs to
  `Notification_Log`, WP-Cron retry up to 3 attempts)
- `includes/class-email-summaries.php` — weekly digest
- `includes/class-draft-service.php` — Save & Resume resume-link email
- `includes/integrations/class-integration-manager.php` — integration-failure
  admin alert

**No plugin code calls PHPMailer directly, and no `phpmailer_init` hook
exists anywhere in the codebase today.** This is the ideal state the spec
asked to confirm before building anything — it means SMTP can be added as
a pure transport-configuration layer with zero changes to any of the five
call sites above.

**No existing encryption/secrets facility exists.** Checked: `mailchimp_api_key`,
`telegram_default_bot_token`, `stripe_secret_key`, and every other stored
API credential in `includes/class-settings.php` are stored **as plaintext**
in the single `chargeforms_settings` option row. This is a real, pre-existing
gap the SMTP feature does not need to fix (out of scope — those are separate
settings, changing them is a separate decision), but it means SMTP needs its
**own** narrowly-scoped encryption service — there is nothing existing to
extend.

## 2. Where SMTP hooks in

```
ChargeForms notification / draft / summary / integration-alert code
                    ↓ (unchanged)
                wp_mail()
                    ↓
        WordPress's own PHPMailer instance
                    ↓
     `phpmailer_init` action  ←── ChargeForms\Includes\Email\Smtp_Transport
                    ↓
  PHPMailer configured for SMTP using the active connection's settings
                    ↓
              remote SMTP server
```

`Smtp_Transport::configure( PHPMailer $phpmailer )` is hooked onto the
core `phpmailer_init` action (WordPress's own supported integration point
— every real SMTP plugin, including FluentSMTP, hooks the same one). It
is a no-op — `wp_mail()` behaves exactly as it always has, PHP `mail()`
or whatever the host already does — whenever no connection is configured
and enabled. **Nothing about this feature is mandatory**; an install that
never opens Email Delivery settings sees zero behavior change.

## 3. What changes / what doesn't

**Changes:**
- One new `phpmailer_init` hook (additive, conditional on a connection existing)
- New REST routes under the existing `chargeforms/v1` namespace
- New admin screen (`ChargeForms > Email Delivery`)
- New `chargeforms_email_log` table (see §8)
- New `chargeforms_smtp_connections` option (see §6)

**Does NOT change:**
- Four of the five existing `wp_mail()` call sites listed in §1 — zero
  code changes. The fifth, `includes/class-notifications.php`'s
  `attempt()`, gets exactly one additive line immediately before its
  existing `wp_mail()` call
  (`Email_Log::set_next_context( [ 'email_type' => 'notification',
  'form_id' => ..., 'entry_id' => ... ] )`) — this is what lets the SMTP
  log tag owner-notification rows with their real form/entry id (spec
  §13) instead of falling back to the generic 'other' type every other
  call site gets. It does not touch that method's own logic, return
  value, or `Notification_Log` behavior in any way — it only enriches
  the separate `Email_Log` row `wp_mail_succeeded`/`wp_mail_failed`
  would have written anyway.
- `Notifications`' own retry/logging (`Notification_Log`) — stays exactly
  as-is; SMTP logging (`Email_Log`) is a *separate*, broader log (every
  `wp_mail()` call site, not just owner notifications) built on the
  `wp_mail_succeeded`/`wp_mail_failed` hooks, not a replacement
- Email content, subject, headers, attachments, HTML rendering — untouched;
  SMTP is transport only, per the spec's own explicit instruction
- WordPress core emails (password resets, etc.), WooCommerce, and any
  other plugin's `wp_mail()` calls — they go through the same
  `phpmailer_init` hook automatically once a connection is active, which
  is the intended, documented behavior of hooking this specific filter
  (not scoped to ChargeForms-originated mail — there is no reliable way
  to distinguish "whose" `wp_mail()` call it is from inside
  `phpmailer_init`, and doing so wouldn't be desirable anyway: a site
  owner who configures SMTP wants *all* their site's mail to go through
  it, exactly like every other SMTP plugin)

## 4. Free / Pro architecture

**One data model, from day one, for both tiers** — Pro is not a different
schema. `Smtp_Connection` is always a list of 0-N connection records;
Free enforcement is a **count check** at the write boundary (`create()`),
not a structurally different Free-only code path. This satisfies the
spec's "don't build Free in a way that requires rewriting later" directly:
adding a second connection for a Pro site is just... not being blocked
by the count check.

```
Smtp_Connection::create( $data )
  → count existing enabled connections for this site
  → if count >= 1 AND ! Settings::has_pro(): reject (Free limit)
  → else: create
```

Enforced in **two places**, matching the spec's "hiding the button is not
enough" requirement:
1. `Smtp_Connection::create()` itself — the actual data-layer guard,
   so nothing that calls it (REST, a future CLI command, a future
   importer) can bypass it.
2. The REST `POST /smtp/connections` callback checks the same condition
   before calling `create()` and returns a structured `403` with an
   `upgrade_required` error code — the direct-API-bypass test the spec
   asks for.

`Settings::has_pro()` is reused as-is — no second license check, per the
spec's explicit instruction.

## 5. Security model

- **Capability**: every SMTP REST route requires
  `current_user_can( 'chargeforms_manage_forms' )` — the same capability
  every other ChargeForms admin REST route already requires (`can_manage()`
  in `class-rest-api.php`). SMTP host/port config is *administrator-only*
  functionality per the spec's SSRF note — there is no code path where a
  form submission, a REST request from an unauthenticated visitor, or a
  webhook payload can influence SMTP host/port/credentials. Only the
  authenticated admin-settings UI writes to `Smtp_Connection`.
- **Nonce**: standard WP REST nonce (`X-WP-Nonce`, `wp_rest` action) —
  the same mechanism `class-rest-api.php`'s existing routes rely on via
  `rest_cookie_check_errors`. No separate nonce scheme invented.
- **Credential encryption at rest**: `Credential_Cipher` — AES-256-GCM via
  PHP's `openssl_encrypt`/`openssl_decrypt`, key derived from
  `wp_salt( 'auth' )` (already unique per install, already treated as a
  secret by WordPress core itself for auth cookies). Ciphertext + nonce +
  auth tag are base64-encoded and stored together; nothing about the key
  is stored in the database.
- **Never returned**: `Smtp_Connection::to_public_array()` is the *only*
  shape a connection is ever serialized to for a REST response — it
  never includes the password field, only a computed
  `password_configured: bool`. Verified by test (`credential-masking`, see
  §11) that the raw `password_encrypted` column-equivalent key genuinely
  never appears in the serialized output, not just "isn't rendered by the
  current UI."
- **Never logged**: `Email_Log` writes structured metadata only (see §8) —
  no code path passes the password/connection secret into
  `Email_Log::record()`. `Smtp_Test`'s exception handling explicitly
  strips the connection's own password value out of any raw PHPMailer
  error string before it's stored or displayed (PHPMailer error strings
  can occasionally echo back connection parameters).
- **CRLF / header injection**: `Smtp_Connection::sanitize()` rejects any
  `from_email`/`reply_to` containing `\r` or `\n` before it's ever handed
  to PHPMailer, in addition to `sanitize_email()`. `from_name` goes through
  `sanitize_text_field()` (strips control characters) then an explicit
  CRLF check as defense in depth.
- **SSRF posture**: the spec is right that an admin-configurable SMTP host
  is inherently privileged — this plugin's answer is *not* to sandbox or
  block internal-looking hostnames (which would break real legitimate use
  — an internal relay, `localhost` for a local mail-catcher during
  development, etc.) but to restrict *who can set the host at all* to the
  same capability gate as every other admin-only ChargeForms setting.
  This matches the spec's own §23 instruction not to over-restrict
  legitimate hosts.
- **No connect-and-scan validation**: `Smtp_Connection::sanitize()`
  validates *shape* (hostname format, port range 1-65535, encryption enum)
  synchronously on save. It never opens a socket. The only code path that
  ever actually connects to the configured host is the explicit,
  rate-limited "Send Test Email" action.
- **Rate limiting**: `Email_Test::RATE_LIMIT` = 5 attempts per connection
  per 10 minutes, enforced via a WordPress transient
  (`chargeforms_smtp_test_{connection_id}`), the same pattern
  `SubmissionHandler::is_rate_limited()` already uses for submission
  throttling — not a new mechanism.

## 6. Data model

**Decision: a dedicated `wp_options` row, not a database table, for the
connection records themselves.** Per the spec's own §29 instruction
("only create a table if the existing settings architecture cannot safely
support it"): the number of SMTP connections a single WordPress site will
ever have is small (1 for every Free site, realistically single digits
even on Pro), never queried by anything except "give me all of them" or
"give me one by id", and never grows unbounded. A dedicated option
(`chargeforms_smtp_connections`, *not* folded into the giant
`chargeforms_settings` blob, so a future Pro-only bulk-connection-import
tool or a debugging `wp option get` doesn't have to wade through unrelated
settings) is the honestly-simpler choice, matching how `Settings` itself
already works. **The email *log*, by contrast, does need a real table**
(§8) — that data is genuinely unbounded and needs indexed pagination,
exactly the case the spec's §29 carve-out describes.

```php
// One element of the chargeforms_smtp_connections option's array value:
[
    'id'                 => 'a1b2c3d4',     // 8-char random id, Smtp_Connection::generate_id()
    'name'               => 'Microsoft 365',
    'provider'           => 'microsoft365', // key into Smtp_Provider_Registry, or 'custom'
    'host'               => 'smtp.office365.com',
    'port'               => 587,
    'encryption'         => 'tls',          // none | tls | ssl
    'auth_enabled'       => true,
    'username'           => 'admin@example.com',
    'password_encrypted' => 'base64:...',   // Credential_Cipher output, or '' if auth disabled
    'from_email'         => 'admin@example.com',
    'from_name'          => 'My Company',
    'reply_to'           => 'support@example.com',
    'enabled'            => true,
    'is_default'         => true,
    'created_at'         => '2026-08-26 10:00:00', // GMT, matches project-wide convention
    'updated_at'         => '2026-08-26 10:00:00',
    'last_tested_at'     => null,
    'last_test_status'   => null,          // success | failed | null (never tested)
    'last_error'         => null,          // sanitized, user-facing string only
]
```

Invalid-state prevention (spec §17): `Smtp_Connection::set_default()` is
the *only* code path that ever writes `is_default => true` — it always
clears the flag on every other connection in the same transaction-shaped
option write first. Deleting the default connection promotes the next
enabled connection (oldest by `created_at`) to default, or leaves the
option with zero connections and zero defaults if none remain — a
documented, valid empty state (falls back to plain `wp_mail()` behavior,
per §3).

## 7. Provider registry

`Smtp_Provider_Registry` is a static, data-only array — no
provider-specific *logic* anywhere, per the spec's explicit instruction.
Each entry supplies only non-secret convenience defaults (host, common
port, encryption, a documentation URL, a short authentication note) that
pre-fill the manual-entry form; nothing about OAuth is implemented or
implied. Covers: Other (blank/manual), Gmail/Google Workspace, Microsoft
365, Amazon SES, SendGrid, Mailgun, Brevo, Postmark, Zoho (9 total —
Postmark added in the production-hardening pass, see the Update note
above).

## 8. Logging + retention

New table `{$wpdb->prefix}chargeforms_email_log`, created via the existing
`Install::activate()` / `dbDelta()` mechanism (added to the same `$sql`
block every other ChargeForms table is created from — no parallel
migration system):

```sql
CREATE TABLE {$prefix}chargeforms_email_log (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    connection_id VARCHAR(20) NULL,
    connection_name VARCHAR(191) NULL,
    email_type VARCHAR(30) NOT NULL,     -- notification | test | summary | other
    form_id BIGINT UNSIGNED NULL,
    entry_id BIGINT UNSIGNED NULL,
    recipient VARCHAR(255) NOT NULL,
    subject VARCHAR(255) NULL,
    status VARCHAR(10) NOT NULL,          -- sent | failed
    error_category VARCHAR(40) NULL,
    error_message VARCHAR(500) NULL,
    duration_ms INT UNSIGNED NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    KEY created_at (created_at),
    KEY connection_id (connection_id),
    KEY form_id (form_id)
) {$charset_collate};
```

Populated from `wp_mail_succeeded` / `wp_mail_failed` (both core hooks —
`wp_mail_succeeded` fires with the same `$mail_data` shape `wp_mail_failed`
already does), **not** by threading logging calls through every call
site — this is what makes it a genuinely universal log (every plugin's
mail, not just ChargeForms') without touching those five files. Never
stores the message body (spec §13/34 — metadata only) and never stores a
recipient list longer than what's needed to identify the send (multiple
recipients are joined, truncated at the column width — not a privacy
concern at that length, but bounded regardless).

**Retention**: `Email_Log::PRUNE_FREQUENCY`-gated prune on insert (same
pattern `Api_Log`'s `PRUNE_FREQUENCY` constant already uses — prune
runs on every 50th insert, not every single one) plus a configurable
retention window (`Settings::get( 'email_log_retention_days', 30 )`).
Rows older than the window are deleted in one indexed `DELETE ... WHERE
created_at < %s` query using the `created_at` key above — bounded by
construction, not by a separate cron sweep needing its own scheduling
(consistent with how `Api_Log` already self-prunes).

## 9. Failure handling / error classification

`Email_Test::classify_exception( \Throwable $e, string $raw_message )`
maps PHPMailer/`fsockopen`-level failures to one of a fixed set of
categories (`dns_failure`, `connection_refused`, `timeout`,
`tls_failure`, `cert_failure`, `auth_failure`, `invalid_recipient`,
`rejected`, `partial_failure`, `unknown`) via pattern-matching against
PHPMailer's own known error-string prefixes (`SMTP -> ERROR:`,
`SMTP connect() failed`, `Could not authenticate`, etc. — these are
PHPMailer's actual, stable exception message formats). Each category has
a fixed, honest user-facing explanation (§7 of the spec's examples) —
**never a claimed cause the code can't actually distinguish**; anything
that doesn't match a known pattern falls into `unknown` with the raw
(credential-stripped) PHPMailer message shown in a collapsed "technical
details" section, never a bare "SMTP Error."

An SMTP failure during a real `wp_mail()` call (not a test) never throws
past `Smtp_Transport::configure()` — PHPMailer's own `Exception` class is
caught, the failure is logged, `wp_mail_failed` still fires normally (core
WordPress behavior, unmodified), and the calling code sees exactly the
same `false` return it always has. No fatal error can propagate from this
subsystem into a form submission, an admin page load, or WP-Cron.

## 10. No duplicate sends

`Smtp_Transport::configure()` only ever *configures* the single PHPMailer
instance WordPress already constructed for this one `wp_mail()` call — it
does not send anything itself, does not construct a second PHPMailer, and
does not call `->send()`. There is exactly one PHPMailer instance per
`wp_mail()` invocation, exactly as before this feature existed; SMTP
changes *how* it sends, never *how many times*. `Email_Test::send()`
(the "Send Test Email" button) is the one path that calls `wp_mail()`
itself, and it does so exactly once per click, gated by the rate limiter
in §5 — there is no automatic retry/failover logic added anywhere near
production notification sending by this feature (see §11: failover is
explicitly out of scope for this pass, by the spec's own instruction).

## 11. Explicitly out of scope for this pass

Per the spec's own repeated instruction not to build ahead of what's
required: no routing UI (form-specific / conditional SMTP selection), no
automatic failover between connections, no OAuth for Gmail/Microsoft, no
DNS/SPF/DKIM verification (informational guidance only, per §37), no
multi-step "setup wizard" screen (the single-page Email Delivery screen
covers the same configure→save→test flow without a separate guided-mode
UI — noted as a real, honest gap below, not silently dropped). The data
model (`Smtp_Connection` as a list, `Email_Manager` as the dispatch point)
is deliberately shaped so a Pro add-on can add per-form/conditional
routing later by reading `Form::settings.smtp_connection_id` and passing
it through to `Smtp_Transport` — that seam exists, but nothing reads it
yet.

## 12. Test strategy

Following this project's existing convention exactly (`tests/*.test.php`,
plain PHP scripts with narrow WP-function stubs, run via `php tests/foo.test.php`,
no PHPUnit/WP test suite dependency — see any existing test file). New
test files, each self-contained:

- `tests/smtp-credential-cipher.test.php` — encrypt/decrypt round-trip,
  ciphertext is never the plaintext substring, decrypting garbage fails
  closed (returns empty, never throws into caller code)
- `tests/smtp-connection-crud.test.php` — create/update/delete, Free
  one-connection limit, Pro multiple-connection allowance, default-flag
  invariants (exactly one default, promotion on delete), `to_public_array()`
  never contains the password key
- `tests/smtp-connection-validation.test.php` — malformed host, invalid
  port range, invalid encryption enum, invalid email, CRLF-injection
  attempts in from_email/from_name/reply_to all rejected
- `tests/smtp-rest-permissions.test.php` — REST callbacks reject a
  non-`can_manage` request; Free-tier direct second-`POST` bypass attempt
  returns the structured 403
- `tests/smtp-test-throttle.test.php` — 6th test-email attempt inside the
  window is rejected, matching `RATE_LIMIT`
- `tests/smtp-error-classification.test.php` — known PHPMailer error
  strings map to the correct category; an unrecognized string falls back
  to `unknown` rather than a fabricated guess
- `tests/smtp-email-log.test.php` — a log row never contains a password
  substring even when constructed from a raw exception message that
  happened to include one; retention prune deletes only rows past the
  window

**Real external SMTP**: this project has no live SMTP server available in
this environment (confirmed the same way the Google Sheets test's OpenSSL
gap already documents a real, unrelated environment limitation — see
`CHANGELOG.md`). `Email_Test`'s actual network path (`fsockopen`/PHPMailer
`SMTP` class talking to a real server) is therefore **not exercised by an
automated test** — it's manually verifiable end-to-end once a real
mailbox is available, and the unit tests above cover everything
upstream/downstream of that one network call (classification, logging,
throttling, masking). This is stated plainly rather than claimed as
covered, per the spec's own anti-overclaiming instruction.

## 13. Compatibility

- Fresh install: `chargeforms_smtp_connections` option simply doesn't
  exist yet — `Smtp_Connection::all()` returns `[]`, `Smtp_Transport`
  no-ops, behavior is identical to a pre-SMTP install.
- Upgrade from an existing install: `Install::maybe_upgrade()`'s existing
  `dbDelta()` call picks up the new `chargeforms_email_log` table
  automatically (dbDelta is additive/idempotent, same mechanism every
  other table added since v1 already relies on).
- Uninstall: table drop + option delete added to `uninstall.php`,
  consistent with the existing opt-in "Delete all data" gate — never
  destructive unless the site owner already checked that box.
