|
| 1 | +--- |
| 2 | +"@objectstack/plugin-email": patch |
| 3 | +--- |
| 4 | + |
| 5 | +fix(plugin-email): judge the default sender where it is configured, and record a send rejected before delivery (#14318) |
| 6 | + |
| 7 | +Measured on a local rig with `OS_EMAIL_FROM="ObjectOS Local <noreply@localhost>"`, |
| 8 | +`OS_EMAIL_PROVIDER=log` and `OS_AUTH_REQUIRE_EMAIL_VERIFICATION=true`: the server |
| 9 | +booted clean, the first sign-up answered `200`, the UI said a verification email |
| 10 | +had been sent, and nothing had been. `EMAIL_REGEX` requires a dotted domain and |
| 11 | +correctly refuses `noreply@localhost` — but it refused it inside |
| 12 | +`normalizeMessage`, on the **first send**, which for a fresh deployment is the |
| 13 | +first user's sign-up. better-auth runs `sendVerificationEmail` through |
| 14 | +`runInBackgroundOrAwait`, which logs `Failed to run background task` and returns |
| 15 | +normally, so the account was created and the user was parked on a verify screen |
| 16 | +whose Resend repeated the whole sequence. `sys_email` held no row for any of it: |
| 17 | +the throw happened *before* the row insert, and every other failure shape in the |
| 18 | +service is a row at `status:'failed'`. |
| 19 | + |
| 20 | +Two changes, one per half. |
| 21 | + |
| 22 | +**The address is judged where it is configured.** `EmailServicePlugin.init()` |
| 23 | +now refuses a declared `defaultFrom` that no message could ever be sent from, so |
| 24 | +a deployment that names an unsendable sender fails its boot instead of failing |
| 25 | +every send — the same trade `resolveTransport` already makes for an SMTP |
| 26 | +provider with no host, and the error names the consequence and the fix |
| 27 | +(`OS_EMAIL_FROM` / `config.email.defaultFrom`). An **absent** sender is still |
| 28 | +accepted: callers that always pass `input.from` are a complete configuration, |
| 29 | +and `normalizeMessage` already refuses a send that has neither. |
| 30 | + |
| 31 | +The `mail` settings channel takes that method's opposite, stated trade — a save |
| 32 | +must not kill a running server — so an unsendable saved From address is |
| 33 | +**refused, the previous sender kept**, and the consequence stated at `error`. |
| 34 | +`error` and not `warn` because nothing looks broken afterwards: the save |
| 35 | +succeeds and the settings page shows the address the operator typed. |
| 36 | + |
| 37 | +**A send rejected before delivery now leaves a `sys_email` row.** The |
| 38 | +`normalizeMessage` window was the one path on which a send produced no record at |
| 39 | +all. It now writes `status:'failed'` with the reason, prefixed |
| 40 | +`rejected before delivery:` so the column distinguishes a message that never |
| 41 | +reached a transport from one an SMTP host refused. The envelope columns carry |
| 42 | +what the caller actually passed (never re-canonicalised — canonicalisation is |
| 43 | +what threw), `(none)` where the input named nothing, since `from_address` / |
| 44 | +`to_addresses` / `subject` are required. The row is safe by construction: both |
| 45 | +re-delivery paths — the `afterInsert` outbox drain hook and the boot outbox |
| 46 | +sweep — gate on `status === 'queued'`, so a rejection record can never be |
| 47 | +mistaken for an outbox entry. Persisting it is best-effort and never replaces |
| 48 | +the caller's error. |
| 49 | + |
| 50 | +Unchanged: `formatAddress`, `EMAIL_REGEX` and `normalizeMessage` keep their |
| 51 | +exact verdicts (the new `isSendableAddress` predicate shares the one regex, so a |
| 52 | +boot cannot pass a check the send path then fails), `send()` still throws on |
| 53 | +validation failure rather than answering `failed`, and the auth layer's |
| 54 | +propagation is as it was — `sendVerificationEmail` already rejects on both a |
| 55 | +throw and a returned `status:'failed'`, which is now pinned by a test. |
0 commit comments