docs(website): the engagement engine as built (Phase 4a)
Companion to website#170. Section 6.0b's assignment for Phase 4 - ENGAGEMENT.md's
as-built and BACKEND_DESIGN.md's table inventory - plus the two corrections
building it forced on this document's own design sections.
ENGAGEMENT.md
- Phase 4 is split 4a / 4b, with what each owes.
- Section 7.1 Q2 and Q4 answered, so seven of eight are settled and only Q8
(android CI) is open.
- The as-built: the gate order and why two of its placements are load-bearing,
the segment rule the design never stated (not is legal only inside an and,
and contributes no ceiling), dormancy three ways and why audience_segment_id
has no foreign key, the conditions grammar's two fail-closed properties, and
why emit does not await the engine.
- Section 4.1's cooldown statement and section 4.2a's dedupe index are
corrected in place, so the design sections stop teaching the two defects.
BACKEND_DESIGN.md
- Five new tables in the schema inventory, each with the reasoning a reader
would otherwise have to reconstruct: why subject_key is in the primary key,
why the dedupe index is scoped, why the send log survives an account
deletion and is not a second address book, and why a rule points at a
segment without a foreign key doing it.
No route table changes - Phase 4a adds no routes.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
# The Engagement System — findings and plan
|
||||
|
||||
**Status:** design of record. **Phases 1, 1a, 1b, 2 and 3 are built** (Phase 1: website#165 +
|
||||
**Status:** design of record. **Phases 1, 1a, 1b, 2, 3 and 4a are built** (Phase 1: website#165 +
|
||||
docs#178, with website#164 as its prerequisite; Phase 1a: website#166 + docs#179; Phase 1b:
|
||||
website#167 + docs#180; Phase 2: website#168 + docs#181); everything from Phase 4 on is still design.
|
||||
The scope decisions below are settled; **six of the eight questions in §7.1 are answered** — Q1, Q3,
|
||||
Q5 and Q7 on 2026-08-28, and Q6 on 2026-08-29 at the start of Phase 2, which also settled §7.2's
|
||||
namespace question. Q1's answer added a whole phase (**Phase 1b**, unique email addresses). **Q2, Q4
|
||||
and Q8** remain open and block Phases 4, 5b and 8 respectively. Per CLAUDE.md § Conventions, no
|
||||
website#167 + docs#180; Phase 2: website#168 + docs#181; Phase 3: website#169 + docs#182; Phase 4a:
|
||||
website#170 + docs#183); everything from Phase 4b on is still design.
|
||||
The scope decisions below are settled; **seven of the eight questions in §7.1 are answered** — Q1, Q3,
|
||||
Q5 and Q7 on 2026-08-28, Q6 on 2026-08-29 at the start of Phase 2 (which also settled §7.2's
|
||||
namespace question), and **Q2 and Q4 on 2026-08-29 at the start of Phase 4**. Q1's answer added a
|
||||
whole phase (**Phase 1b**, unique email addresses); Q4's answer and the phase's size split **Phase 4
|
||||
into 4a and 4b**. **Q8** remains open and blocks Phase 8. Per CLAUDE.md § Conventions, no
|
||||
implementation starts without the org lead's approval of the phase it belongs to.
|
||||
|
||||
**Branching:** every phase lands on **`edge`** in its repo; `main` is touched once, by the cutover
|
||||
@@ -651,8 +653,13 @@ drops three of them. The module supplies `subject` on emit; core stores it opaqu
|
||||
configured cooldown — otherwise this table grows without bound, which is the failure mode
|
||||
`teamActivityPrune` was written for.
|
||||
|
||||
**The check is `INSERT … ON DUPLICATE KEY UPDATE` guarded on the interval**, in one statement, so two
|
||||
concurrent emits cannot both pass a read-then-write check.
|
||||
**The check must not be a read-then-write**, or two concurrent emits both see an expired cooldown and
|
||||
both send. The obvious single statement — `INSERT … ON DUPLICATE KEY UPDATE` with the interval guard in
|
||||
the assignments, reading the answer out of `affectedRows` — **does not work against this codebase's
|
||||
pool**, and Phase 4a is where that was found: the mariadb connector defaults `foundRows: true`, so a
|
||||
no-op update reports 1 rather than 0 and every cooldown passes. What ships instead is a guarded UPDATE
|
||||
(the interval in a WHERE clause, where a row either matches or does not) falling back to an
|
||||
`INSERT IGNORE` for the first fire. See Phase 4a's as-built for the statements and the races.
|
||||
|
||||
### 4.2 Scheduling — a queue for delay, and deliberately no queue for digest
|
||||
|
||||
@@ -679,7 +686,10 @@ CREATE TABLE IF NOT EXISTS engagement_outbox (
|
||||
sent_at DATETIME NULL,
|
||||
CONSTRAINT fk_engo_rule FOREIGN KEY (rule_id) REFERENCES engagement_rules(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_engo_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uq_engo_dedupe (dedupe_key),
|
||||
-- SCOPED, not global. One event legitimately becomes one row per (rule, user,
|
||||
-- channel); a global unique index would admit the first recipient's row and
|
||||
-- silently ignore every other. Corrected in Phase 4a — see its as-built.
|
||||
UNIQUE KEY uq_engo_dedupe (rule_id, user_id, channel, dedupe_key),
|
||||
INDEX idx_engo_due (status, due_at),
|
||||
INDEX idx_engo_cancel (rule_id, user_id, subject_key, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -691,8 +701,10 @@ Three things this buys that a straight send does not:
|
||||
- **`status='cancelled'` is the actual point of that window.** `idx_engo_cancel` is what a *resolving*
|
||||
event queries: a `house.decay` back up to `LikeNew` cancels every scheduled row for that
|
||||
(rule, user, house). Without cancellation, a delay is just a late mail.
|
||||
- **`dedupe_key UNIQUE` makes replay safe.** The sidecar has no schema-migration mechanism and a
|
||||
reconnect backfills; an at-least-once feed must not become an at-least-once mailer.
|
||||
- **`dedupe_key` UNIQUE per (rule, user, channel) makes replay safe.** The sidecar has no
|
||||
schema-migration mechanism and a reconnect backfills; an at-least-once feed must not become an
|
||||
at-least-once mailer. The scope matters as much as the constraint: a dedupe key names the *event*,
|
||||
and the event fans out to every recipient of every channel of every matching rule.
|
||||
|
||||
`channel` is `VARCHAR(32)` and not an `ENUM` for exactly the reason `announce_job_legs.leg` is —
|
||||
the channel set is data, and a module (or a later core channel) must not require an `ALTER`.
|
||||
@@ -1281,7 +1293,8 @@ change is not complete until `docs/` reflects it" — is the floor; this table i
|
||||
| **1b** Unique email | `website/BACKEND_DESIGN.md` — the `users` table (the "not unique" note is now false), the new change/verify routes, and the de-dupe migration as an operator-visible upgrade step | `website/README.md` upgrade notes · a release note naming the admin report and the verification-gate default |
|
||||
| **2** Trigger registry | `website/MODULE_API.md` §1.1 (**1.7.0** + correct the stale "1.6.0 has only ever been on `edge`" paragraph), §2.3 (`ctx.events`, `ctx.inbox`), §2.4 (`registerEventTriggers`, `registerAudiences`), the dormant-rule note (landed as §6.8) · `website/ENGAGEMENT.md` §4.3 and §5.1a kept true · `BACKEND_DESIGN.md` route table | **Both deferred to the Phase 13 cutover window, deliberately — see Phase 2's as-built.** `Integration-kit`'s `ci/core-ref.json` pins a **`main`** sha, so the equality check stays green (and must stay green) for the whole `edge` period; `runicgateway.com`'s `checkFacts.mjs` *fetches* from `main`, so setting `platform.json.moduleApi` → 1.7.0 now would turn that repo red immediately |
|
||||
| **3** Channel preferences | `website/BACKEND_DESIGN.md` route table · `android/PLAN.md` §11 | — |
|
||||
| **4** Engine | `website/ENGAGEMENT.md` (rules/cooldown/outbox as built) · `BACKEND_DESIGN.md` table inventory | — |
|
||||
| **4a** Engine | `website/ENGAGEMENT.md` (rules/cooldown/outbox as built, and the two §4 defects it corrects) · `BACKEND_DESIGN.md` table inventory | — |
|
||||
| **4b** Rules screen | `website/BACKEND_DESIGN.md` route table · `website/ENGAGEMENT.md` §5.1a composition UI | — |
|
||||
| **5a/5b** Templates + editor | `website/ENGAGEMENT.md` §4.6 · a template-authoring section in `BACKEND_DESIGN.md` or its own doc | **`runicgateway.com`**: a new admin docs page for the template editor |
|
||||
| **6** Email channel + Teams migration | `website/TEAMS.md` §6.3/§6.4 **rewritten** — the Team pipeline it describes no longer exists as its own thing | **`runicgateway.com`**: `administration/teams.mdx` notification section |
|
||||
| **7** In-app channel (core+web) | `website/BACKEND_DESIGN.md` routes + tables · `website/ENGAGEMENT.md` | **`runicgateway.com`**: `notifications-and-email.mdx` gains the in-app channel |
|
||||
@@ -1742,15 +1755,163 @@ see something a preference actually governs.
|
||||
|
||||
### Phase 4 — The engine: rules, cooldowns, outbox
|
||||
|
||||
`engagement_rules`, `engagement_cooldowns`, `engagement_outbox`, `engagement_sends`, the sweep worker
|
||||
(`setInterval` + `unref` + `stop`, wired into `server.js` like its five siblings), audience resolution,
|
||||
condition evaluation, delay and cancellation. Admin → Engagement → Rules.
|
||||
**Split into 4a and 4b** at the start of the phase, on the same argument that split Phase 5: the half
|
||||
that first makes this system capable of sending is worth reviewing without a React screen in the same
|
||||
diff, and the ceiling arithmetic that decides who a rule may reach is worth reading on its own.
|
||||
|
||||
#### 4a — the engine (server only) ✅
|
||||
|
||||
`engagement_rules`, `engagement_audience_segments`, `engagement_cooldowns`, `engagement_outbox`,
|
||||
`engagement_sends`, the sweep worker (`setInterval` + `unref` + `stop`, wired into `server.js` like
|
||||
its five siblings), audience resolution, condition evaluation, delay and cancellation, and the
|
||||
save-path validation the admin surface will call. **No HTTP surface at all** — provably done when a
|
||||
fired trigger produces an outbox row and a send-log entry with no UI in the picture.
|
||||
|
||||
#### 4b — the admin surface
|
||||
|
||||
Admin → Engagement → Rules, the §5.1a segment composition UI, and the routes underneath them. Q4's
|
||||
answer places it in **its own top-level nav group** (below), so 4b also creates the group that
|
||||
Triggers, Templates and the send log join in Phase 5.
|
||||
|
||||
**Acceptance:** a trigger fired twice inside `cooldown_seconds` for the same (rule, user, subject) sends
|
||||
once; the same trigger for a *different* subject sends again; a scheduled row is cancelled by a
|
||||
`cancel_on` trigger and never sends; a restart mid-window still sends exactly once; a duplicate
|
||||
`dedupe_key` is a successful no-op.
|
||||
**Guardrails:** swagger + route manifest; a named test for the multi-house cooldown case (§4.1).
|
||||
**Guardrails:** swagger + route manifest (4b — 4a adds no routes); a named test for the multi-house
|
||||
cooldown case (§4.1).
|
||||
|
||||
---
|
||||
|
||||
#### As built — 4a (2026-08-29)
|
||||
|
||||
**Three decisions were settled by the org lead before any code, and two of them are §7.1 questions
|
||||
this phase was blocked on.**
|
||||
|
||||
| | Question | Decision |
|
||||
|---|---|---|
|
||||
| **Q2** | multi-instance: `SKIP LOCKED`, or document single-instance | **Neither, exactly**: a compare-and-set claim — `UPDATE … SET status='sending' WHERE id=? AND status='scheduled'`, the winner being whoever the server reports `affectedRows = 1` to. It is what §4.2a's ENUM was already shaped for (nothing else needs a `sending` state), it needs no open transaction and no MariaDB version floor, and it delivers Q2's intent |
|
||||
| **Q4** | where the engagement admin surface lives | **its own top-level nav group**, "Engagement", beside Content / Moderation / System — Rules now, Triggers / Templates / Send Log in Phase 5. Email Delivery stays a section of Settings for now |
|
||||
| — | one PR or two | **4a / 4b**, as above |
|
||||
|
||||
**What Q2's answer does and does not buy.** It makes the *outbox* safe for two app instances. It does
|
||||
not make the deployment multi-instance: `announceWorker`, `teamDigestWorker`, `teamForumUploadSweep`
|
||||
and `teamActivityPrune` are all still written for one, and widening them is not this phase's scope.
|
||||
What it buys is that the one table that will carry mail is ready for the day it is, which is cheap now
|
||||
and expensive after mail has doubled once.
|
||||
|
||||
**Two defects in this document's own §4, both found by building it.**
|
||||
|
||||
1. **§4.2a's `UNIQUE KEY uq_engo_dedupe (dedupe_key)` was a data-loss bug, not a style question.** A
|
||||
dedupe key names the EVENT — "house 0x4001 entered IDOC" — and one event legitimately becomes many
|
||||
outbox rows: an audience of fifty users is fifty rows, a rule spanning email and in-app doubles
|
||||
that, and a second rule on the same trigger doubles it again. Under a *global* unique index the
|
||||
first of those inserts wins and every other one is silently ignored, so ninety-nine recipients are
|
||||
dropped by the mechanism that exists to stop a replayed event becoming a second mail. Shipped as
|
||||
**`UNIQUE (rule_id, user_id, channel, dedupe_key)`**, which keeps exactly the replay guarantee and
|
||||
nothing more. A test asserts one key fans out to six rows.
|
||||
|
||||
2. **§4.1's single `INSERT … ON DUPLICATE KEY UPDATE` cooldown claim does not work against this
|
||||
codebase's pool**, and the way it fails is silent. Its answer is read out of `affectedRows` on the
|
||||
usual contract — 1 inserted, 2 updated-and-changed, **0 for a duplicate key whose update changed
|
||||
nothing**, that 0 being "still cooling". **The mariadb Node connector defaults `foundRows: true`**,
|
||||
which makes `affectedRows` report rows *matched* rather than rows *changed*, and `utils/db.js` does
|
||||
not override it. Under that pool the no-op returns 1 and is indistinguishable from a fresh insert:
|
||||
**every cooldown passes, always.** Shipped as two statements instead, each of which is its own
|
||||
atomic decision and neither of which asks `affectedRows` to mean two things:
|
||||
|
||||
```sql
|
||||
-- 1. claim by moving the row, guarded in a WHERE clause where a row either matches or does not
|
||||
UPDATE engagement_cooldowns SET last_fired_at = ?, fire_count = fire_count + 1
|
||||
WHERE rule_id = ? AND user_id = ? AND subject_key = ?
|
||||
AND last_fired_at <= ? - INTERVAL ? SECOND;
|
||||
-- 2. matched nothing? then the row is absent or cooling; INSERT IGNORE separates the two
|
||||
INSERT IGNORE INTO engagement_cooldowns (rule_id, user_id, subject_key, last_fired_at, fire_count)
|
||||
VALUES (?, ?, ?, ?, 1);
|
||||
```
|
||||
|
||||
Still race-free, and each race resolves the right way: two concurrent first fires both fall to the
|
||||
INSERT and the primary key picks one; two concurrent fires after expiry serialise on the row lock
|
||||
and the second re-evaluates its guard against the committed `last_fired_at`.
|
||||
|
||||
**The second defect is the reason this phase has a second test file.** `engagementEngine.test.js`
|
||||
stubs the five tables and runs the engine's logic against in-memory stand-ins, which is right for
|
||||
everything the engine *decides* — and it was **green against the broken cooldown claim**, because a
|
||||
stub can only agree with whoever wrote it, and the same misreading produced both. The statements whose
|
||||
correctness is a *server* contract now run against a real MariaDB in `engagementEngineSql.test.js`,
|
||||
which creates a throwaway database, drops it, and **skips when there is none** so CI stays green
|
||||
without one. The general lesson: a stub is a fine stand-in for a table and a poor one for a protocol.
|
||||
|
||||
**The gate order is the design.** Enabled rules → conditions → audience → **ceiling re-check** →
|
||||
per-channel preference → per-rule hourly ceiling → cooldown → deduped enqueue. Two of those placements
|
||||
are load-bearing:
|
||||
|
||||
- **The G24 ceiling is re-checked at SEND time, not only at save.** The save path already ran the same
|
||||
`ceilings.permits`, so the only way this can fail is the case it exists for — a module upgrade that
|
||||
*narrows* its trigger's declaration underneath a rule saved when it was wider. Without it, a rule
|
||||
written against yesterday's declaration keeps reaching yesterday's population forever. This is the
|
||||
second call site §5.1a promised, not a second implementation.
|
||||
- **The hourly ceiling is checked before the cooldown**, because the ceiling is about the rule and the
|
||||
cooldown is about one recipient. A rule that has hit its ceiling should not also burn every
|
||||
recipient's cooldown slot on sends that never happen.
|
||||
|
||||
**Segments (§5.1a) as built, with one rule the design did not state.** `not` is legal **only as a
|
||||
child of `and`**. A complement needs a universe, and the only one available that does not widen is
|
||||
the set its siblings produced: `A AND NOT B` is "A, less B", which is what an operator wants and
|
||||
cannot be composed into a broadcast. A bare `NOT B` — or `A OR NOT B` — would have to mean "everyone
|
||||
except…", which is a way to build the whole deployment out of one narrow audience and is precisely the
|
||||
widening rule 3 forbids. It is refused at save with that sentence.
|
||||
|
||||
The other half of that: **a `not` contributes no ceiling to the meet.** Excluding people cannot widen
|
||||
who an expression reaches, so folding the excluded audience's ceiling in would refuse safe segments —
|
||||
`members AND NOT staff` would hit `meet('members','staff') = null` and be rejected even though it
|
||||
reaches strictly fewer people than `members` alone.
|
||||
|
||||
**Dormancy, three ways, and none of them deletes anything** (§7.3, §5.1a rule 4). A rule naming an
|
||||
unregistered trigger, a rule whose channel is gone, and a rule whose segment was deleted are all
|
||||
*listed, flagged and left alone*. In particular `engagement_rules.audience_segment_id` deliberately
|
||||
carries **no foreign key**: `ON DELETE CASCADE` would delete an operator's rules and `ON DELETE SET
|
||||
NULL` would silently fall the rule back to its plain `audience` column — and that fallback reaches a
|
||||
*different set of people*, which is the exact failure §5.1a rule 4 exists to prevent. Deleting a
|
||||
segment that a rule still uses is refused in the model, with the count.
|
||||
|
||||
**Conditions are a small closed grammar**, not an expression language: and/or/not over comparisons of
|
||||
one *declared* variable against a literal, every operator renderable as a dropdown, bounded in list
|
||||
length and nesting depth because the tree comes out of a JSON column an admin can write and is walked
|
||||
on the emit path. Two properties worth keeping:
|
||||
|
||||
- **An absent variable makes every comparison false, including `ne`.** "Not equal to IDOC" reads as
|
||||
satisfied by nothing at all, and treating it that way would fire a rule on every event that omits an
|
||||
optional variable. `present` / `absent` are the honest way to ask.
|
||||
- **A tree that no longer parses evaluates false**, never "no conditions". Failing closed stops the
|
||||
mail; failing open mails everyone the rule could ever reach.
|
||||
|
||||
**`ctx.events.emit` does not await the engine.** It is called from inside a game-event handler, and
|
||||
the caller's job is to say the event happened — not to wait on rule lookups, audience resolution and a
|
||||
dozen inserts to find out whether it is allowed to carry on. That is the same argument the C# side's
|
||||
`Emit()` makes about the Core thread. `dispatch` catches everything internally and never rejects. The
|
||||
consequence a caller must know: **`emit` returns before the outbox rows exist**, so a test that wants
|
||||
the delivery decision calls `engine.dispatch` directly.
|
||||
|
||||
**Nothing is delivered, and that is visible rather than pretended.** A channel's `deliver` arrives with
|
||||
email in Phase 6 and the in-app inbox in Phase 7. Until then the worker claims the row, finds no
|
||||
`deliver`, finishes it `failed`, and writes a send-log row saying so in as many words. Recording
|
||||
`sent` would be a lie in the one table whose entire purpose is answering "did they get it"; leaving the
|
||||
row `scheduled` would mean an IDOC warning queued today arriving three weeks later on the deploy that
|
||||
first shipped a mailer. On a real deployment the path is unreachable anyway — core seeds no rules and
|
||||
`enabled` defaults to 0, so nothing enqueues until 4b's screen exists and an operator uses it.
|
||||
|
||||
**Three smaller things the phase settled:**
|
||||
|
||||
- **`everyone` and `authenticated` resolve identically.** A signed-out visitor has no address, no
|
||||
device and no inbox, so the widest set the engine can deliver to is the active user table. The
|
||||
lattice still distinguishes them — a trigger ceilinged `everyone` permits an `authenticated` rule
|
||||
and not the reverse — and only the *resolution* coincides.
|
||||
- **A plain `members` audience with no segment reaches nobody.** `members` is the ceiling for "a
|
||||
module-declared list"; without a segment there is no list, and core knows no game vocabulary with
|
||||
which to guess. Inert and visible, rather than quietly falling back to something wider.
|
||||
- **Every audience is filtered through `users.status = 'active'`, including a module's.** A module's
|
||||
resolver returns ids over its own store and has no notion of account status; a banned account must
|
||||
not be mailable by a module returning its id.
|
||||
|
||||
---
|
||||
|
||||
@@ -2009,7 +2170,7 @@ day it ships.
|
||||
|
||||
## Part 7 — Open questions and forward-compat notes
|
||||
|
||||
### 7.1 Questions for the org lead — six answered, three still open
|
||||
### 7.1 Questions for the org lead — seven answered, one still open
|
||||
|
||||
1. ✅ **ANSWERED — may unverified addresses receive engagement mail?** *"Emails need to be unique and
|
||||
verification blocking sending is an admin setting."* Combined with the opt-in answer, this settles
|
||||
@@ -2025,15 +2186,23 @@ day it ships.
|
||||
|
||||
*Consequence for ordering:* Phase 9 no longer blocks Phase 11 — the verification *mechanism* moves
|
||||
forward into 1b, and Phase 9 keeps only bounces and suppression.
|
||||
2. **Multi-instance.** Do we commit to single-instance (status quo) and document it, or add
|
||||
`SELECT … FOR UPDATE SKIP LOCKED` to the outbox sweep in Phase 4? Recommendation: add it in Phase 4 —
|
||||
it is cheap there and expensive to retrofit after mail has doubled once.
|
||||
2. ✅ **ANSWERED — multi-instance.** Neither of the two the question offered, and the third is
|
||||
better than both: the outbox sweep **claims each row with a compare-and-set** —
|
||||
`UPDATE … SET status='sending' WHERE id=? AND status='scheduled'` — and the instance the server
|
||||
reports `affectedRows = 1` to owns it. §4.2a's status ENUM already carried a `sending` state that
|
||||
nothing else needed, so this is what the schema was shaped for; it needs no open transaction (which
|
||||
nothing else in this codebase's workers does) and no MariaDB version floor. It makes the **outbox**
|
||||
safe for two app instances and does not, on its own, make the deployment multi-instance — the four
|
||||
existing workers are still written for one. Built in Phase 4a.
|
||||
3. ✅ **ANSWERED — rules as data vs. rules as code.** Data, as recommended: rules are operator-editable
|
||||
rows, `enabled` defaults to `0`, and every rule carries a **hard per-rule hourly send ceiling**. The
|
||||
ceiling is not a nicety — it is the thing that keeps a misconfigured rule from becoming a mail storm,
|
||||
and it is what makes "data" safe enough to choose over "code". Phase 4 builds both.
|
||||
4. **Does the engagement admin surface belong under Admin → Settings, or its own top-level section?**
|
||||
It is three screens (Triggers, Rules, Templates) plus a send log.
|
||||
4. ✅ **ANSWERED — the engagement admin surface gets its own top-level nav group**, "Engagement",
|
||||
beside Content / Moderation / System. Four screens is too much to bury: Settings is already one long
|
||||
page of sections, and a send log is a paged table rather than a settings section. Rules lands with
|
||||
Phase 4b; Triggers, Templates and the send log join it in Phase 5. **Email Delivery stays a section
|
||||
of Settings** for now — moving it is not part of either phase.
|
||||
5. ✅ **ANSWERED — which SMTP posture is the documented default?** Document all three; **lead with a
|
||||
relay** (Mailgun/SES/Postmark); name Gmail-with-an-app-password (`smtp.gmail.com:587`) explicitly as
|
||||
the migration path off OAuth2 for the existing deployment (§1.2a). Phase 1 owes all three in
|
||||
|
||||
Reference in New Issue
Block a user