feat(engagement): templates — the email block family, renderer and seeded set (engagement Phase 5a)
Every subject and body moves out of `mailer.js` into `engagement_templates` rows an operator can edit. A relocation, not a regression: nothing that sends mail today starts depending on an operator authoring something first. - `email.*` block family in its own registry, sharing the page family's envelope walk and validate-then-sanitize order by binding rather than by copy. - A server-side renderer producing both parts of a multipart message; the text part is byte-identical to the literals this commit deletes. - Nine seeded templates, six of them wired now; the seeder's `customized = 0` guard lives in the UPDATE's own WHERE. - `renderByKey` falls back to the shipped seed when a row is missing or unusable, so no failure of the table can stop a password reset. Also fixes `check:hosts` reading the template key `auth.email-verify` as the hostname `auth.email`. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1847,3 +1847,47 @@ CREATE TABLE IF NOT EXISTS engagement_sends (
|
||||
-- an index range scan rather than a table scan: it runs once per rule per event.
|
||||
INDEX idx_engs_rule_window (rule_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- §4.4. The mail (and, from Phase 7, in-app) bodies an operator can edit, stored
|
||||
-- as a validated block array rather than as raw HTML: `blocks` goes through the
|
||||
-- same validate-then-sanitize gate the CMS pages do, against the `email.*`
|
||||
-- registry (src/emailBlocks/). Storing operator HTML would hand the renderer an
|
||||
-- injection surface and give up the prop schemas.
|
||||
--
|
||||
-- Three columns carry the whole "ship a better default without stealing an
|
||||
-- operator's work" mechanism (§4.6.1 property 3). `seed_key` says which shipped
|
||||
-- template a row came from, `seed_version` which revision of it, and `customized`
|
||||
-- whether a person has since edited it. The seeder updates a row whose version is
|
||||
-- behind ONLY while `customized = 0`; a customized row is left exactly as it is
|
||||
-- and the newer default is surfaced in the admin list instead. Same posture
|
||||
-- `settingsJson` takes: never overwrite what someone chose.
|
||||
--
|
||||
-- `trigger_id` has no foreign key for the reason `engagement_rules.trigger_id`
|
||||
-- has none -- a trigger is declared in code, so the set of them is whatever
|
||||
-- registered on this boot. NULL means a reusable template not tied to one
|
||||
-- trigger, which is what every transactional seed is: `mailer` renders them by
|
||||
-- key, no rule involved.
|
||||
CREATE TABLE IF NOT EXISTS engagement_templates (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`key` VARCHAR(96) NOT NULL UNIQUE,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
trigger_id VARCHAR(96) NULL,
|
||||
trigger_version INT NULL,
|
||||
channel VARCHAR(32) NOT NULL,
|
||||
subject VARCHAR(300) NULL,
|
||||
blocks MEDIUMTEXT NOT NULL,
|
||||
text_body MEDIUMTEXT NULL,
|
||||
status ENUM('draft','published') NOT NULL DEFAULT 'draft',
|
||||
-- Editable, NOT deletable -- the pages.protected flag, for the same reason:
|
||||
-- the system breaks without a password-reset body.
|
||||
protected TINYINT(1) NOT NULL DEFAULT 0,
|
||||
seed_key VARCHAR(96) NULL,
|
||||
seed_version INT NULL,
|
||||
customized TINYINT(1) NOT NULL DEFAULT 0,
|
||||
updated_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_engt_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_engt_trigger (trigger_id, channel, status),
|
||||
INDEX idx_engt_seed (seed_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
Reference in New Issue
Block a user