diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 93a86d77..d140389c 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -5,7 +5,7 @@ > before assuming something isn't built. `@api` = stable to build on; `@internal` = may change. > Grouped by **capability** (across layers), not by directory. -**244 classes** across **33 capabilities** · **26 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). +**245 classes** across **33 capabilities** · **27 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). ## Capabilities (`library/Tiger`) @@ -308,6 +308,7 @@ - **Tiger_Model_AgentRun** `@api` — one turn's execution + control record (see migration 0036). · `library/Tiger/Model/AgentRun.php` - **Tiger_Model_Comment** `@api` — the one comment/review store (migration 0045). · `library/Tiger/Model/Comment.php` - **Tiger_Model_CommentAggregate** `@api` — the denormalized per-subject rollup (migration 0046). · `library/Tiger/Model/CommentAggregate.php` +- **Tiger_Model_EmailTemplate** `@api` — EmailTemplate — the DB-override tier for Tiger_Mail's transactional templates (see migration 0054). · `library/Tiger/Model/EmailTemplate.php` - **Tiger_Model_GitCredential** `@api` — a least-privilege, encrypted-at-rest credential (TIGER-334, migration 0054). · `library/Tiger/Model/GitCredential.php` - **Tiger_Model_GitOperationLog** `@api` — the append-only git audit trail (TIGER-334, migration 0054). · `library/Tiger/Model/GitOperationLog.php` - **Tiger_Model_GitRepoConnection** `@api` — a module's connection to its GitHub repo (TIGER-334, migration 0054). · `library/Tiger/Model/GitRepoConnection.php` @@ -363,6 +364,7 @@ - **CMS** (`cms`, app) · services: Menu, Page, Paths, Settings · `modules/cms` - **Code** (`code`, developer) · services: Code · `modules/code` - **Comments** (`comment`, app) · services: Comment, Render, Settings, Subjects · `modules/comment` +- **Email Templates** (`email`, app) · services: Template · `modules/email` - **TigerGit** (`git`, app) · services: Base, Release, Repo, Update · `modules/git` - **Identity** (`identity`, plugin) · services: Identity · `modules/identity` - **Matomo** (`matomo`, app) · services: Matomo · `modules/matomo` diff --git a/CHANGELOG.md b/CHANGELOG.md index 180874ad..653400d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows ## [Unreleased] +## [1.39.0] — 2026-10-23 + +### Added +- **Authorable transactional email templates (`modules/email` + `Tiger_Model_EmailTemplate`)** (TIGER-267). + A DB-override tier over `Tiger_Mail`'s file-based template engine — the same live-override pattern as + Config/Translation (file = base, an active DB row = the override, last wins), scoped by `org_id` + (`''` = global default, else per-tenant). Owners/agents author a subject + HTML body with `{{var}}` + placeholders from an admin screen; no code change, no deploy. + - **Engine** — migration `0054` (`email_template`, plain `UNIQUE(org_id, template_key)` because + `store()` revives soft-deleted rows rather than freeing the key); `Tiger_Model_EmailTemplate` + (`resolve` org-wins-over-global, `byKey`, upsert-with-revive `store`, soft-delete `reset`); + `Tiger_Mail` gains a registered-template registry (`registerTemplate`/`registeredTemplates`), + `forOrg()`, `{{var}}` substitution, a `template()` that prefers an authored row over the `.phtml` + (fail-safe fallback to the file on any error), and a `renderTemplate()` preview (incl. unsaved drafts). + - **Admin** — `/email/admin`: a DataTables list (Default vs Customized) + an editor with a variable + palette and a live iframe preview. Every `/api` method `_isAllowed`-guarded; admin-gated ACL + deny-by-default; house primitives only (no browser dialogs); full `en` i18n. The 5 core emails + (`reset`, `otp`, `verify`, `register-verify`, `backup`) are registered with their real variables. + ## [1.38.0] — 2026-10-21 ### Added diff --git a/library/Tiger/Mail.php b/library/Tiger/Mail.php index 6c162d7a..867a9d93 100644 --- a/library/Tiger/Mail.php +++ b/library/Tiger/Mail.php @@ -36,6 +36,8 @@ class Tiger_Mail protected $_text; /** @var Zend_Config|null */ protected $_config; + /** The org whose authored template override template() prefers; '' = the global/platform default. */ + protected $_orgId = ''; /** * A process-wide transport override. When set, it wins over the config-resolved transport for @@ -59,6 +61,62 @@ public static function setDefaultTransport($transport = null) self::$_defaultTransport = $transport; } + /** + * The registry of known transactional-email events, keyed by template key. + * + * A module declares each email it sends — the key (which matches its `emails/.phtml` default), + * a human label + description, and the `{{var}}` placeholders the template may use — so the + * authoring UI (TIGER-267) can list every editable email and show its variable palette, without + * scanning the filesystem. Registration is how a module OPTS its mail into being owner/agent- + * authorable; an unregistered `template()` call still works (file default + any override), it just + * won't appear in the management screen. + * + * @var array}> + */ + protected static $_templates = []; + + /** + * Register a transactional-email event so it's authorable in the admin (and documents its vars). + * + * @param array $def {key, label, description?, group?, vars?: map of placeholder => description} + * @return void + */ + public static function registerTemplate(array $def) + { + $key = isset($def['key']) ? trim((string) $def['key']) : ''; + if ($key === '') { return; } + self::$_templates[$key] = [ + 'key' => $key, + 'label' => (string) ($def['label'] ?? $key), + 'description' => (string) ($def['description'] ?? ''), + 'group' => (string) ($def['group'] ?? 'general'), + 'vars' => is_array($def['vars'] ?? null) ? $def['vars'] : [], + ]; + } + + /** + * Every registered email event (for the management screen), keyed by template key. + * + * @return array}> + */ + public static function registeredTemplates() + { + ksort(self::$_templates); + return self::$_templates; + } + + /** + * Scope which org's authored override template() prefers (chainable). '' keeps the global default. + * + * @param string $orgId the org id, or '' for the global/platform override + * @return self this instance, for chaining + */ + public function forOrg($orgId) + { + $this->_orgId = (string) $orgId; + return $this; + } + /** * Capture the resolved config for later transport resolution. * @@ -323,11 +381,23 @@ protected static function _templatePaths() */ public function template($name, array $vars = []) { + $vars += ['siteName' => $this->_siteName(), 'siteUrl' => $this->_siteUrl()]; + + // DB override tier (TIGER-267): an authored row for this key wins over the file default, exactly + // as a `config`/`translation` row overrides its ini/file base. Its subject + body carry `{{var}}` + // placeholders substituted here; the body is author HTML (raw), only the VALUES are escaped. + $override = $this->_override($name); + if ($override !== null) { + if ($this->_subject === '' && (string) $override->subject !== '') { + $this->_subject = self::_substitute((string) $override->subject, $vars); + } + $this->_html = $this->_wrapLayout(self::_substitute((string) $override->body, $vars), $vars); + return $this; + } + $view = new Zend_View(); $view->setEncoding('UTF-8'); foreach (self::_templatePaths() as $path) { $view->addScriptPath($path); } - - $vars += ['siteName' => $this->_siteName(), 'siteUrl' => $this->_siteUrl()]; $view->assign($vars); $content = $view->render($name . '.phtml'); @@ -338,6 +408,90 @@ public function template($name, array $vars = []) return $this; } + /** + * Resolve this message's authored template override (org-scoped, global fallback), or null. + * + * Fail-safe: any lookup error (table absent on a pre-0054 install, no DB) yields null, so a send + * silently falls back to the file default rather than throwing. + * + * @param string $name the template key + * @return Zend_Db_Table_Row_Abstract|null the override row, or null when none / unavailable + */ + protected function _override($name) + { + if (!class_exists('Tiger_Model_EmailTemplate')) { return null; } + try { + return (new Tiger_Model_EmailTemplate())->resolve((string) $name, $this->_orgId); + } catch (Throwable $e) { + return null; + } + } + + /** + * Wrap already-rendered body HTML in the shared branded layout (the same shell file templates use). + * + * @param string $contentHtml the message body HTML + * @param array $vars layout variables (siteName/siteUrl/preheader honored) + * @return string the full HTML email + */ + protected function _wrapLayout($contentHtml, array $vars = []) + { + $view = new Zend_View(); + $view->setEncoding('UTF-8'); + foreach (self::_templatePaths() as $path) { $view->addScriptPath($path); } + $view->assign($vars); + $view->assign('content', $contentHtml); + return $view->render('layout.phtml'); + } + + /** + * Substitute `{{var}}` placeholders with HTML-escaped values; strip any that go unmatched. + * + * The body/subject are authored (trusted) text, so they're not escaped — only the substituted + * VALUES are (`htmlspecialchars`), which keeps a value safe inside markup or an href. A leftover + * `{{unknown}}` is removed rather than mailed verbatim. + * + * @param string $text the authored text with placeholders + * @param array $vars placeholder name => value + * @return string the substituted text + */ + protected static function _substitute($text, array $vars) + { + return preg_replace_callback('/\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/', static function ($m) use ($vars) { + $key = $m[1]; + if (!array_key_exists($key, $vars)) { return ''; } + return htmlspecialchars((string) $vars[$key], ENT_QUOTES, 'UTF-8'); + }, (string) $text); + } + + /** + * Render a template to {subject, html} WITHOUT sending — for the authoring screen's live preview. + * + * Resolves the same override → file cascade template() uses, so a preview is exactly what a send + * would produce. When $subject/$body are passed (the editor's unsaved draft), they're previewed + * directly instead of the stored/file version. + * + * @param string $name the template key + * @param array $vars sample placeholder values + * @param string $orgId the scope to resolve a stored override in ('' = global) + * @param string|null $subject an unsaved subject draft to preview (null = use stored/file) + * @param string|null $body an unsaved body draft to preview (null = use stored/file) + * @return array{subject:string,html:string} the rendered subject + full HTML + */ + public static function renderTemplate($name, array $vars = [], $orgId = '', $subject = null, $body = null) + { + $mail = (new self())->forOrg((string) $orgId); + $vars += ['siteName' => $mail->_siteName(), 'siteUrl' => $mail->_siteUrl()]; + + if ($body !== null) { + $sub = self::_substitute((string) ($subject ?? ''), $vars); + return ['subject' => $sub, 'html' => $mail->_wrapLayout(self::_substitute((string) $body, $vars), $vars)]; + } + if ($subject !== null) { $mail->subject((string) $subject); } + $mail->template((string) $name, $vars); + return ['subject' => $mail->_subject, 'html' => (string) $mail->_html]; + } + /** The install's site name, for the email header/footer. */ protected function _siteName() { diff --git a/library/Tiger/Model/EmailTemplate.php b/library/Tiger/Model/EmailTemplate.php new file mode 100644 index 00000000..e5fe8bff --- /dev/null +++ b/library/Tiger/Model/EmailTemplate.php @@ -0,0 +1,116 @@ +.phtml` default: an owner- or + * agent-authored `subject` + HTML `body` carrying `{{var}}` placeholders, which Tiger_Mail::template() + * substitutes and wraps in the shared branded layout. This is the live-override pattern applied to mail + * (file = base, this table = the override, last wins), the sibling of Tiger_Model_Config / + * Tiger_Model_Translation. Scoped by `org_id` ('' = the global/platform default, else per-tenant), so + * resolve() prefers an org row over the global one, exactly like the config cascade. + * + * @api + */ +class Tiger_Model_EmailTemplate extends Tiger_Model_Table +{ + protected $_name = 'email_template'; + protected $_primary = 'email_template_id'; + + /** The global/platform scope (every install's baseline override, no tenant). */ + const SCOPE_GLOBAL = ''; + + /** + * Resolve the active override for a key, org row winning over the global one (the config cascade). + * + * @param string $key the template key (matches the file template name, e.g. `reset`) + * @param string $orgId the requesting org ('' to consider only the global override) + * @return Zend_Db_Table_Row_Abstract|null the winning override row, or null when none is authored + */ + public function resolve($key, $orgId = '') + { + $orgId = (string) $orgId; + if ($orgId !== '') { + $row = $this->fetchRow( + $this->activeSelect() + ->where('template_key = ?', (string) $key) + ->where('org_id = ?', $orgId) + ); + if ($row) { return $row; } + } + return $this->fetchRow( + $this->activeSelect() + ->where('template_key = ?', (string) $key) + ->where('org_id = ?', self::SCOPE_GLOBAL) + ); + } + + /** + * The active override for an exact (key, scope), without the global fallback — the editor's own row. + * + * @param string $key the template key + * @param string $orgId the scope ('' = global) + * @return Zend_Db_Table_Row_Abstract|null the row, or null when that scope has no override + */ + public function byKey($key, $orgId = '') + { + return $this->fetchRow( + $this->activeSelect() + ->where('template_key = ?', (string) $key) + ->where('org_id = ?', (string) $orgId) + ); + } + + /** + * Upsert an override (revives a soft-deleted row for the same key+scope so the unique index holds). + * + * @param string $key the template key + * @param string $orgId the scope ('' = global) + * @param string $subject the subject line (may carry `{{var}}` placeholders) + * @param string $body the HTML body (may carry `{{var}}` placeholders) + * @return string the email_template_id + */ + public function store($key, $orgId, $subject, $body) + { + // Match ANY row (incl. soft-deleted) for this key+scope so a prior reset doesn't collide on the + // (org_id, template_key) unique index — revive + overwrite it instead of inserting a duplicate. + $row = $this->fetchRow( + $this->select() + ->where('template_key = ?', (string) $key) + ->where('org_id = ?', (string) $orgId) + ->order('created_at DESC') + ); + $data = [ + 'template_key' => (string) $key, + 'org_id' => (string) $orgId, + 'subject' => (string) $subject, + 'body' => (string) $body, + 'status' => 'active', + 'deleted' => 0, + ]; + if ($row) { + // Revive + overwrite the existing row (its key is reused, not freed — see migration 0054); + // the model's update() re-stamps updated_at/updated_by. softDelete/update take a WHERE clause. + $this->update($data, $this->getAdapter()->quoteInto('email_template_id = ?', $row->email_template_id)); + return (string) $row->email_template_id; + } + return (string) $this->insert($data); + } + + /** + * Remove the override for a key+scope (soft-delete) so the file default renders again. + * + * @param string $key the template key + * @param string $orgId the scope ('' = global) + * @return bool true when an override was present and removed + */ + public function reset($key, $orgId = '') + { + $row = $this->byKey($key, $orgId); + if (!$row) { return false; } + // softDelete() takes a WHERE clause, not an id — a bare UUID would be nonsense SQL. + $this->softDelete($this->getAdapter()->quoteInto('email_template_id = ?', $row->email_template_id)); + return true; + } +} diff --git a/library/Tiger/Version.php b/library/Tiger/Version.php index 56f141fc..8706682d 100644 --- a/library/Tiger/Version.php +++ b/library/Tiger/Version.php @@ -9,5 +9,5 @@ class Tiger_Version { /** Current Tiger Core version. Keep in lockstep with the git tag cut for a release. */ - const VERSION = '1.38.0'; + const VERSION = '1.39.0'; } diff --git a/migrations/0054_create_email_template.php b/migrations/0054_create_email_template.php new file mode 100644 index 00000000..7810a6e9 --- /dev/null +++ b/migrations/0054_create_email_template.php @@ -0,0 +1,43 @@ +.phtml` is the branded default, and an active row + * here OVERRIDES it (last wins) — an authored subject + HTML body (with `{{var}}` placeholders) that + * Tiger_Mail::template() substitutes and wraps in the shared layout. No row → the file default renders + * unchanged. Scoped `org_id` (''=global/platform default, else per-tenant), so one install's orgs can + * each brand their own mail. See Tiger_Model_EmailTemplate + Tiger_Mail::template(). + * + * `(org_id, template_key)` is unique across ALL rows (not active-only): unlike `user.email` — where a + * soft-delete must FREE the value for a re-signup (TIGER-274) — an override's key is deliberately REUSED. + * Tiger_Model_EmailTemplate::store() matches any row (soft-deleted included) and revives + overwrites it, + * so "reset to default" then re-author updates the one row rather than inserting a second. A plain unique + * is therefore the right integrity guard here and sidesteps a generated-column expression some engines + * refuse (MySQL/MariaDB err 1901 on a multi-column CONCAT in GENERATED ALWAYS AS). + */ +return [ + 'up' => [ + "CREATE TABLE IF NOT EXISTS `email_template` ( + `email_template_id` CHAR(36) NOT NULL, + `org_id` CHAR(36) NOT NULL DEFAULT '', + `template_key` VARCHAR(191) NOT NULL, + `subject` VARCHAR(255) NOT NULL DEFAULT '', + `body` LONGTEXT NOT NULL, + `status` VARCHAR(16) NOT NULL DEFAULT 'active', + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + `created_by` CHAR(36) NULL, + `updated_by` CHAR(36) NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NULL, + PRIMARY KEY (`email_template_id`), + UNIQUE KEY `uq_email_template_key` (`org_id`, `template_key`), + KEY `idx_email_template_lookup` (`template_key`, `org_id`, `deleted`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + ], + 'down' => [ + "DROP TABLE IF EXISTS `email_template`", + ], +]; diff --git a/modules/email/Bootstrap.php b/modules/email/Bootstrap.php new file mode 100644 index 00000000..7fee884e --- /dev/null +++ b/modules/email/Bootstrap.php @@ -0,0 +1,78 @@ + 'email', + 'label' => 'email.nav.settings', + 'icon' => 'fa-envelope', + 'href' => '/email/admin', + 'resource' => 'Email_AdminController', + 'order' => 68, + ]); + } + + /** Register the platform's shipped transactional emails as authorable events (idempotent). */ + protected function _initEmailTemplates() + { + if (!class_exists('Tiger_Mail')) { return; } + + Tiger_Mail::registerTemplate([ + 'key' => 'reset', + 'label' => 'email.tpl.reset.label', + 'description' => 'email.tpl.reset.desc', + 'group' => 'auth', + 'vars' => ['url' => 'email.var.reset_url'], + ]); + Tiger_Mail::registerTemplate([ + 'key' => 'otp', + 'label' => 'email.tpl.otp.label', + 'description' => 'email.tpl.otp.desc', + 'group' => 'auth', + 'vars' => ['code' => 'email.var.code'], + ]); + Tiger_Mail::registerTemplate([ + 'key' => 'verify', + 'label' => 'email.tpl.verify.label', + 'description' => 'email.tpl.verify.desc', + 'group' => 'auth', + 'vars' => ['url' => 'email.var.verify_url'], + ]); + Tiger_Mail::registerTemplate([ + 'key' => 'register-verify', + 'label' => 'email.tpl.register_verify.label', + 'description' => 'email.tpl.register_verify.desc', + 'group' => 'auth', + 'vars' => ['url' => 'email.var.verify_url', 'domain' => 'email.var.domain'], + ]); + Tiger_Mail::registerTemplate([ + 'key' => 'backup', + 'label' => 'email.tpl.backup.label', + 'description' => 'email.tpl.backup.desc', + 'group' => 'system', + 'vars' => [ + 'filename' => 'email.var.filename', + 'size' => 'email.var.size', + 'components' => 'email.var.components', + 'error' => 'email.var.error', + ], + ]); + } +} diff --git a/modules/email/assets/js/email.templates.js b/modules/email/assets/js/email.templates.js new file mode 100644 index 00000000..a1fb4d91 --- /dev/null +++ b/modules/email/assets/js/email.templates.js @@ -0,0 +1,156 @@ +/* Email Templates — the authoring screens (TIGER-267). + * Two surfaces share this file: the LIST (#email-templates-table) and the EDITOR (#email-tpl-form). + * All data/mutations are /api calls through the house primitives; never a page-POST, never a browser + * confirm()/alert() (TigerModal only). */ +(function () { + 'use strict'; + + function T(k) { return (window.Tiger && Tiger.t) ? Tiger.t(k) : k; } + function post(fd) { + return fetch('/api', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: fd }) + .then(function (r) { return r.json().catch(function () { return {}; }); }); + } + function notify(fb, msg, type) { if (window.TigerDOM && fb) { TigerDOM.notify(fb, msg, { type: type || 'info' }); } } + function msgs(res, fb, fallback) { + (res && res.messages && res.messages.length ? res.messages : [{ message: fallback, class: 'error' }]) + .forEach(function (m) { notify(fb, m.message, m.class); }); + } + + /* ---------------------------------------------------------------- LIST */ + function initList() { + var tableEl = document.getElementById('email-templates-table'); + if (!tableEl || typeof tigerDataTable !== 'function') { return; } + var fb = document.getElementById('email-feedback'); + var esc = tigerDataTable.esc; + + var table = tigerDataTable('#email-templates-table', { + service: { module: 'email', service: 'template', action: 'datatable' }, + order: [[0, 'asc']], + columns: [ + { data: 'label', render: function (t, type, row) { + return type !== 'display' ? t : + '' + esc(t) + ''; + } }, + { data: 'key', render: function (t, type) { + return type !== 'display' ? t : '' + esc(t) + ''; + } }, + { data: 'group', render: function (t, type) { + return type !== 'display' ? t : '' + esc(t) + ''; + } }, + { data: 'customized', render: function (t, type) { + if (type !== 'display') { return t ? 1 : 0; } + return t + ? '' + esc(T('statusCustomized')) + '' + : '' + esc(T('statusDefault')) + ''; + } }, + { data: null, orderable: false, searchable: false, className: 'text-end text-nowrap', render: function (t, type, row) { + if (type !== 'display') { return ''; } + var h = ''; + if (row.can_edit) { + h += ' '; + } + if (row.can_reset) { + h += ''; + } + return h || '—'; + } } + ] + }); + + tableEl.addEventListener('click', function (e) { + var btn = e.target.closest('[data-email-reset]'); + if (!btn) { return; } + var key = btn.getAttribute('data-email-reset'); + TigerModal.confirm({ title: T('confirmResetTitle'), body: T('confirmResetBody'), confirmLabel: T('actionReset'), variant: 'danger' }) + .then(function (ok) { + if (!ok) { return; } + var fd = new URLSearchParams({ module: 'email', service: 'template', method: 'reset', key: key }); + TigerButton.run(btn, function () { return post(fd); }).then(function (res) { + if (res && res.result === 1) { notify(fb, T('resetDone'), 'success'); table.ajax.reload(null, false); return; } + msgs(res, fb, T('general')); + }).catch(function () { notify(fb, T('networkError'), 'error'); }); + }); + }); + } + + /* -------------------------------------------------------------- EDITOR */ + function initEditor() { + var form = document.getElementById('email-tpl-form'); + if (!form) { return; } + var wrap = document.querySelector('[data-email-key]'); + var key = wrap ? wrap.getAttribute('data-email-key') : ''; + var fb = document.getElementById('email-feedback'); + var bodyEl = document.getElementById('email-tpl-body'); + var subjEl = document.getElementById('email-tpl-subject'); + var frame = document.getElementById('email-preview-frame'); + var subjOut = document.getElementById('email-preview-subject'); + + /* Insert a {{var}} at the cursor in the body textarea (the variable palette). */ + document.querySelectorAll('[data-email-var]').forEach(function (chip) { + chip.addEventListener('click', function () { + var token = '{{' + chip.getAttribute('data-email-var') + '}}'; + var start = bodyEl.selectionStart || 0; + var end = bodyEl.selectionEnd || 0; + bodyEl.value = bodyEl.value.slice(0, start) + token + bodyEl.value.slice(end); + bodyEl.focus(); + bodyEl.selectionStart = bodyEl.selectionEnd = start + token.length; + }); + }); + + function doPreview(btn) { + var fd = new URLSearchParams({ module: 'email', service: 'template', method: 'preview', + key: key, subject: subjEl ? subjEl.value : '', body: bodyEl ? bodyEl.value : '' }); + var run = function () { return post(fd); }; + var done = function (res) { + if (res && res.result === 1 && res.data) { + if (subjOut) { subjOut.textContent = res.data.subject || ''; } + if (frame) { frame.srcdoc = res.data.html || ''; } + return; + } + msgs(res, fb, T('general')); + }; + if (btn && window.TigerButton) { TigerButton.run(btn, run).then(done).catch(function () { notify(fb, T('networkError'), 'error'); }); } + else { run().then(done).catch(function () {}); } + } + + var prevBtn = document.getElementById('email-tpl-preview'); + if (prevBtn) { prevBtn.addEventListener('click', function () { doPreview(this); }); } + doPreview(null); // first paint + + document.getElementById('email-tpl-save').addEventListener('click', function () { + var fd = new URLSearchParams(new FormData(form)); + fd.set('module', 'email'); fd.set('service', 'template'); fd.set('method', 'save'); + TigerButton.run(this, function () { return post(fd); }).then(function (res) { + if (res && res.result === 1) { notify(fb, T('saved'), 'success'); doPreview(null); return; } + if (res && res.form) { + Object.keys(res.form).forEach(function (f) { + var el = form.querySelector('[name="' + f + '"]'); if (el) { el.classList.add('is-invalid'); } + }); + notify(fb, T('fixFields'), 'error'); + } + msgs(res, fb, T('general')); + }).catch(function () { notify(fb, T('networkError'), 'error'); }); + }); + + var resetBtn = document.getElementById('email-tpl-reset'); + if (resetBtn) { + resetBtn.addEventListener('click', function () { + var btn = this; + TigerModal.confirm({ title: T('confirmResetTitle'), body: T('confirmResetBody'), confirmLabel: T('actionReset'), variant: 'danger' }) + .then(function (ok) { + if (!ok) { return; } + var fd = new URLSearchParams({ module: 'email', service: 'template', method: 'reset', key: key }); + TigerButton.run(btn, function () { return post(fd); }).then(function (res) { + if (res && res.result === 1) { window.location = '/email/admin'; return; } + msgs(res, fb, T('general')); + }).catch(function () { notify(fb, T('networkError'), 'error'); }); + }); + }); + } + } + + document.addEventListener('DOMContentLoaded', function () { + initList(); + initEditor(); + }); +})(); diff --git a/modules/email/configs/acl.ini b/modules/email/configs/acl.ini new file mode 100644 index 00000000..d0413160 --- /dev/null +++ b/modules/email/configs/acl.ini @@ -0,0 +1,25 @@ +; SPDX-License-Identifier: BSD-3-Clause +; Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers. +; +; Email Templates module ACL — the authoring screen + its /api service, admin+ only. The deny-by-default +; spine in core acl.ini governs everyone else (guest/user/manager see nothing here). Resource names +; follow the framework convention: a controller resolves to "_Controller", a service +; to "_Service_". +[production] + +acl.resources.email_admin_ctrl.resource = "Email_AdminController" +acl.resources.email_template_svc.resource = "Email_Service_Template" + +acl.rules.email_admin_ctrl.role = "admin" +acl.rules.email_admin_ctrl.resource = "Email_AdminController" +acl.rules.email_admin_ctrl.permission = "allow" + +acl.rules.email_template_svc.role = "admin" +acl.rules.email_template_svc.resource = "Email_Service_Template" +acl.rules.email_template_svc.permission = "allow" + +[staging : production] + +[testing : production] + +[development : production] diff --git a/modules/email/controllers/AdminController.php b/modules/email/controllers/AdminController.php new file mode 100644 index 00000000..200f662c --- /dev/null +++ b/modules/email/controllers/AdminController.php @@ -0,0 +1,65 @@ +view->title = 'Email Templates — Tiger Admin'; + $this->view->useDataTables = true; // the layout loads jQuery + DataTables only when this is set + } + + /** + * The editor for one template key — seeds the view with the registry def + the stored override. + * + * @return void + */ + public function editAction() + { + $key = (string) $this->getParam('key', ''); + $defs = Tiger_Mail::registeredTemplates(); + if ($key === '' || !isset($defs[$key])) { + $this->_helper->redirector->gotoUrl('/email/admin'); + return; + } + + $def = $defs[$key]; + $row = (new Tiger_Model_EmailTemplate())->byKey($key, Tiger_Model_EmailTemplate::SCOPE_GLOBAL); + + $form = new Email_Form_Template(); + $form->populate([ + 'template_key' => $key, + 'subject' => $row ? (string) $row->subject : '', + 'body' => $row ? (string) $row->body : '', + 'scope' => Tiger_Model_EmailTemplate::SCOPE_GLOBAL, + ]); + + $this->view->title = 'Edit Template — Tiger Admin'; + $this->view->form = $form; + $this->view->def = $def; + $this->view->templateKey = $key; + $this->view->customized = (bool) $row; + } +} diff --git a/modules/email/forms/Template.php b/modules/email/forms/Template.php new file mode 100644 index 00000000..16d06a4d --- /dev/null +++ b/modules/email/forms/Template.php @@ -0,0 +1,40 @@ + true, + 'filters' => ['StringTrim'], + ]], + // The scope the override is stored at ('' = the global/platform default). Hidden; '' in v1. + ['hidden', 'scope', [ + 'required' => false, + 'filters' => ['StringTrim'], + ]], + ['text', 'subject', [ + 'required' => true, + 'filters' => ['StringTrim'], + 'attribs' => ['class' => 'form-control', 'id' => 'email-tpl-subject', 'maxlength' => 255], + ]], + ['textarea', 'body', [ + 'required' => true, + 'attribs' => ['class' => 'form-control font-monospace', 'id' => 'email-tpl-body', 'rows' => 18], + ]], + ]; + } +} diff --git a/modules/email/languages/en/email.php b/modules/email/languages/en/email.php new file mode 100644 index 00000000..8f2606f9 --- /dev/null +++ b/modules/email/languages/en/email.php @@ -0,0 +1,74 @@ + 'Email Templates', + + // List screen + 'email.list.heading' => 'Email Templates', + 'email.list.subtitle' => 'Customize the transactional emails Tiger sends. An edited template overrides the branded default — no deploy.', + 'email.col.template' => 'Email', + 'email.col.key' => 'Key', + 'email.col.group' => 'Group', + 'email.col.status' => 'Status', + 'email.col.actions' => 'Actions', + 'email.status.customized' => 'Customized', + 'email.status.default' => 'Default', + 'email.list.confirm_reset_title' => 'Reset to default?', + 'email.list.confirm_reset_body' => 'This removes your customized version and restores the shipped default. This cannot be undone.', + + // Editor screen + 'email.edit.card_content' => 'Content', + 'email.edit.label_subject' => 'Subject', + 'email.edit.label_body' => 'HTML body', + 'email.edit.help_body' => 'HTML is wrapped in the branded email layout. Use {{variable}} placeholders — insert them from the panel on the right.', + 'email.edit.card_variables' => 'Variables', + 'email.edit.variables_help' => 'Click to insert a placeholder at the cursor. It is replaced with the real value when the email is sent.', + 'email.edit.card_preview' => 'Preview', + 'email.edit.preview_subject' => 'Subject:', + + // Actions + 'email.action.save' => 'Save', + 'email.action.reset' => 'Reset to default', + 'email.action.edit' => 'Edit', + 'email.action.back' => 'Back', + 'email.action.refresh' => 'Refresh', + + // Service results + 'email.saved' => 'Template saved.', + 'email.reset' => 'Template reset to its default.', + 'email.error.unknown_template' => 'That email template does not exist.', + + // JS feedback + 'email.js.network_error' => 'Network error — please try again.', + 'email.js.fix_fields' => 'Please correct the highlighted fields.', + + // Shipped template events (labels + descriptions) + 'email.tpl.reset.label' => 'Password reset', + 'email.tpl.reset.desc' => 'Sent when someone requests a password reset link.', + 'email.tpl.otp.label' => 'Sign-in code', + 'email.tpl.otp.desc' => 'The one-time code for passwordless sign-in.', + 'email.tpl.verify.label' => 'Email verification', + 'email.tpl.verify.desc' => 'Confirms a user\'s email address.', + 'email.tpl.register_verify.label' => 'Signup verification', + 'email.tpl.register_verify.desc' => 'Verifies the email address given at signup.', + 'email.tpl.backup.label' => 'Backup report', + 'email.tpl.backup.desc' => 'The result of a scheduled or manual backup.', + + // Template variable descriptions + 'email.var.reset_url' => 'The password-reset link.', + 'email.var.verify_url' => 'The verification link.', + 'email.var.code' => 'The one-time sign-in code.', + 'email.var.domain' => 'The site domain being verified.', + 'email.var.filename' => 'The backup archive filename.', + 'email.var.size' => 'The backup archive size.', + 'email.var.components' => 'What the backup included.', + 'email.var.error' => 'The failure reason (on a failed backup).', + 'email.var.site_name' => 'The site name.', + 'email.var.site_url' => 'The site URL.', +]; diff --git a/modules/email/module.json b/modules/email/module.json new file mode 100644 index 00000000..2da22d3b --- /dev/null +++ b/modules/email/module.json @@ -0,0 +1,5 @@ +{ + "name": "Email Templates", + "type": "app", + "category": ["messaging"] +} diff --git a/modules/email/services/Template.php b/modules/email/services/Template.php new file mode 100644 index 00000000..35368b4d --- /dev/null +++ b/modules/email/services/Template.php @@ -0,0 +1,201 @@ +_isAllowed('datatable')) { $this->_error('core.api.error.not_allowed'); return; } + + $dt = $this->_dtParams($params); + $t = Zend_Registry::isRegistered('Zend_Translate') ? Zend_Registry::get('Zend_Translate') : null; + $model = new Tiger_Model_EmailTemplate(); + + $canEdit = $this->_isAllowed('save'); + $rows = []; + foreach (Tiger_Mail::registeredTemplates() as $def) { + $custom = (bool) $model->byKey($def['key'], self::SCOPE); + $rows[] = [ + 'key' => $def['key'], + 'label' => $t ? (string) $t->translate($def['label']) : $def['label'], + 'group' => $def['group'], + 'customized' => $custom, + 'can_edit' => $canEdit, + 'can_reset' => $canEdit && $custom, + ]; + } + + $total = count($rows); + + // Search spans the visible text (label/key/group); filter before sort+paginate. + $search = trim((string) ($dt['search'] ?? '')); + if ($search !== '') { + $needle = mb_strtolower($search); + $rows = array_values(array_filter($rows, static function ($r) use ($needle) { + return mb_strpos(mb_strtolower($r['label'] . ' ' . $r['key'] . ' ' . $r['group']), $needle) !== false; + })); + } + $filtered = count($rows); + + // Sort by the requested column (0=label,1=key,2=group,3=status), default label asc. + $cols = ['label', 'key', 'group', 'customized']; + $ci = isset($dt['order'][0]['column']) ? (int) $dt['order'][0]['column'] : 0; + $dir = (isset($dt['order'][0]['dir']) && $dt['order'][0]['dir'] === 'desc') ? -1 : 1; + $col = $cols[$ci] ?? 'label'; + usort($rows, static function ($a, $b) use ($col, $dir) { + return $dir * strcmp((string) $a[$col], (string) $b[$col]); + }); + + $rows = array_slice($rows, (int) $dt['start'], (int) $dt['length'] ?: $filtered); + + $this->_dtResponse($dt['draw'], $total, $filtered, $rows); + } + + /** + * The registry def (label/description/vars) + the stored override (or empty) for a key. + * + * @param array $params {key} + * @return void + */ + public function get(array $params): void + { + if (!$this->_isAllowed('get')) { $this->_error('core.api.error.not_allowed'); return; } + + $key = (string) ($params['key'] ?? ''); + $defs = Tiger_Mail::registeredTemplates(); + if ($key === '' || !isset($defs[$key])) { $this->_error('email.error.unknown_template'); return; } + + $row = (new Tiger_Model_EmailTemplate())->byKey($key, self::SCOPE); + $this->_success([ + 'key' => $key, + 'def' => $defs[$key], + 'subject' => $row ? (string) $row->subject : '', + 'body' => $row ? (string) $row->body : '', + 'customized' => (bool) $row, + ]); + } + + /** + * Save (upsert) an authored override. + * + * @param array $params {template_key, subject, body, scope?} + * @return void + */ + public function save(array $params): void + { + if (!$this->_isAllowed('save')) { $this->_error('core.api.error.not_allowed'); return; } + + $form = new Email_Form_Template(); + if (!$form->isValid($params)) { $this->_formErrors($form); return; } + $v = $form->getValues(); + + $key = (string) $v['template_key']; + if (!isset(Tiger_Mail::registeredTemplates()[$key])) { $this->_error('email.error.unknown_template'); return; } + + try { + $this->_transaction(function () use ($key, $v) { + (new Tiger_Model_EmailTemplate())->store( + $key, self::SCOPE, (string) $v['subject'], (string) $v['body'] + ); + }); + $this->_success(['key' => $key, 'customized' => true], 'email.saved'); + } catch (Throwable $e) { + $this->_error(APPLICATION_ENV !== 'production' ? $e->getMessage() : 'core.api.error.general'); + } + } + + /** + * Remove an override so the code-shipped file default renders again. + * + * @param array $params {key} + * @return void + */ + public function reset(array $params): void + { + if (!$this->_isAllowed('reset')) { $this->_error('core.api.error.not_allowed'); return; } + + $key = (string) ($params['key'] ?? ''); + if (!isset(Tiger_Mail::registeredTemplates()[$key])) { $this->_error('email.error.unknown_template'); return; } + + try { + $this->_transaction(function () use ($key) { + (new Tiger_Model_EmailTemplate())->reset($key, self::SCOPE); + }); + $this->_success(['key' => $key, 'customized' => false], 'email.reset'); + } catch (Throwable $e) { + $this->_error(APPLICATION_ENV !== 'production' ? $e->getMessage() : 'core.api.error.general'); + } + } + + /** + * Render a preview of the current draft (subject + body) with sample variable values. + * + * @param array $params {key, subject, body} + * @return void + */ + public function preview(array $params): void + { + if (!$this->_isAllowed('preview')) { $this->_error('core.api.error.not_allowed'); return; } + + $key = (string) ($params['key'] ?? ''); + $defs = Tiger_Mail::registeredTemplates(); + if ($key === '' || !isset($defs[$key])) { $this->_error('email.error.unknown_template'); return; } + + $subject = (string) ($params['subject'] ?? ''); + $body = (string) ($params['body'] ?? ''); + $vars = $this->_sampleVars($defs[$key]); + + try { + $out = Tiger_Mail::renderTemplate($key, $vars, self::SCOPE, $subject, $body); + $this->_success(['subject' => $out['subject'], 'html' => $out['html']]); + } catch (Throwable $e) { + $this->_error(APPLICATION_ENV !== 'production' ? $e->getMessage() : 'core.api.error.general'); + } + } + + /** + * Illustrative sample values for a template's declared placeholders, for the preview. + * + * @param array $def the registry def (its `vars` names the placeholders) + * @return array placeholder name => sample value + */ + protected function _sampleVars(array $def): array + { + $out = []; + foreach (array_keys($def['vars'] ?? []) as $name) { + $lower = strtolower((string) $name); + if ($lower === 'code') { + $out[$name] = '123456'; + } elseif (strpos($lower, 'url') !== false) { + $out[$name] = 'https://example.com/' . $lower . '/abc123'; + } elseif ($lower === 'domain') { + $out[$name] = 'example.com'; + } elseif ($lower === 'username') { + $out[$name] = 'sampleuser'; + } else { + $out[$name] = '[' . $name . ']'; + } + } + return $out; + } +} diff --git a/modules/email/views/scripts/admin/edit.phtml b/modules/email/views/scripts/admin/edit.phtml new file mode 100644 index 00000000..6448eb16 --- /dev/null +++ b/modules/email/views/scripts/admin/edit.phtml @@ -0,0 +1,95 @@ +form; +$el = function ($name) use ($form) { return $form->getElement($name); }; +$def = $this->def; +?> +
+
+

escape($this->t($def['label'])) ?>

+

escape($this->t($def['description'])) ?>

+
+
+ t('email.action.back') ?> + customized): ?> + + + +
+
+ +
+ +
+
+
+ + + +
+
t('email.edit.card_content') ?>
+
+
+ + +
+
+ + +
t('email.edit.help_body') ?>
+
+
+
+
+
+ +
+
+
t('email.edit.card_variables') ?>
+
+

t('email.edit.variables_help') ?>

+
+ $descKey): ?> + + + + +
+
+
+ +
+
+ t('email.edit.card_preview') ?> + +
+
+

t('email.edit.preview_subject') ?>

+ +
+
+
+
+ +i18n([ + 'saved' => 'email.saved', + 'resetDone' => 'email.reset', + 'confirmResetTitle' => 'email.list.confirm_reset_title', + 'confirmResetBody' => 'email.list.confirm_reset_body', + 'actionReset' => 'email.action.reset', + 'networkError' => 'email.js.network_error', + 'fixFields' => 'email.js.fix_fields', + 'general' => 'core.api.error.general', +]); +$this->pageScript($this->asset('js/email.templates.js', 'email')); +?> diff --git a/modules/email/views/scripts/admin/index.phtml b/modules/email/views/scripts/admin/index.phtml new file mode 100644 index 00000000..c10d10d5 --- /dev/null +++ b/modules/email/views/scripts/admin/index.phtml @@ -0,0 +1,51 @@ + +
+
+

t('email.list.heading') ?>

+

t('email.list.subtitle') ?>

+
+
+ +
+ +
+
+
+ + + + + + + + + + +
t('email.col.template') ?>t('email.col.key') ?>t('email.col.group') ?>t('email.col.status') ?>t('email.col.actions') ?>
+
+
+
+ +i18n([ + 'confirmResetTitle' => 'email.list.confirm_reset_title', + 'confirmResetBody' => 'email.list.confirm_reset_body', + 'statusCustomized' => 'email.status.customized', + 'statusDefault' => 'email.status.default', + 'actionEdit' => 'email.action.edit', + 'actionReset' => 'email.action.reset', + 'resetDone' => 'email.reset', + 'networkError' => 'email.js.network_error', + 'general' => 'core.api.error.general', +]); +$this->pageScript($this->asset('js/email.templates.js', 'email')); +?> diff --git a/tests/Integration/Mail/EmailTemplateOverrideTest.php b/tests/Integration/Mail/EmailTemplateOverrideTest.php new file mode 100644 index 00000000..5edff5d7 --- /dev/null +++ b/tests/Integration/Mail/EmailTemplateOverrideTest.php @@ -0,0 +1,113 @@ +.phtml` default: a tenant row wins over the + * global one (the config cascade), a send renders the authored body (with `{{var}}` substitution) when a + * row exists and falls back to the file when it doesn't, and reset → re-author works despite the + * active-only unique index (the revive path). + */ +#[CoversClass(Tiger_Model_EmailTemplate::class)] +#[CoversClass(Tiger_Mail::class)] +final class EmailTemplateOverrideTest extends IntegrationTestCase +{ + protected function setUp(): void + { + parent::setUp(); + Zend_Registry::set('Zend_Config', new Zend_Config([ + 'tiger' => ['site' => ['name' => 'Acme Corp', 'url' => 'https://acme.example.com']], + 'mail' => ['from' => ['email' => 'no-reply@acme.example.com', 'name' => 'Acme']], + ], true)); + } + + /** The HTML body Tiger_Mail would send, without sending it. */ + private function render(string $template, array $vars, string $orgId = ''): string + { + $mail = new Tiger_Mail(); + $mail->forOrg($orgId)->to('x@example.com')->subject('')->template($template, $vars); + return (string) (new ReflectionProperty(Tiger_Mail::class, '_html'))->getValue($mail); + } + + #[Test] + public function an_org_override_wins_over_the_global_one(): void + { + $m = new Tiger_Model_EmailTemplate(); + $m->store('reset', '', 'Global subject', '

global body

'); + $m->store('reset', 'org-A', 'Org subject', '

org body

'); + + $this->assertSame('

global body

', (string) $m->resolve('reset', '')->body); + $this->assertSame('

org body

', (string) $m->resolve('reset', 'org-A')->body); + // An org with no override of its own falls back to the global row. + $this->assertSame('

global body

', (string) $m->resolve('reset', 'org-B')->body); + } + + #[Test] + public function template_renders_the_override_with_substitution_else_the_file_default(): void + { + (new Tiger_Model_EmailTemplate())->store( + 'reset', '', 'Reset', '

Authored reset — open {{url}}

' + ); + + $overridden = $this->render('reset', ['url' => 'https://acme.example.com/r/abc']); + $this->assertStringContainsString('Authored reset — open https://acme.example.com/r/abc', $overridden); + $this->assertStringContainsString('Acme Corp', $overridden, 'wrapped in the branded layout'); + + // A key with no override falls back to the shipped file template. + $fallback = $this->render('otp', ['code' => '424242']); + $this->assertStringContainsString('424242', $fallback, 'the file default still renders'); + $this->assertStringNotContainsString('Authored reset', $fallback); + } + + #[Test] + public function reset_removes_the_override_and_the_file_default_returns(): void + { + $m = new Tiger_Model_EmailTemplate(); + $m->store('reset', '', 'Reset', '

Authored {{url}}

'); + $this->assertStringContainsString('Authored', $this->render('reset', ['url' => 'u'])); + + $this->assertTrue($m->reset('reset', '')); + $this->assertNull($m->resolve('reset', ''), 'the override is gone'); + $this->assertStringNotContainsString('Authored', $this->render('reset', ['url' => 'u']), + 'the file default renders once the override is reset'); + } + + #[Test] + public function re_authoring_after_a_reset_revives_the_row_without_a_unique_collision(): void + { + $m = new Tiger_Model_EmailTemplate(); + $id1 = $m->store('reset', '', 'First', '

first

'); + $m->reset('reset', ''); // soft-delete + $id2 = $m->store('reset', '', 'Second', '

second

'); // must NOT hit the active-only unique index + + $this->assertSame($id1, $id2, 'the soft-deleted row is revived, not duplicated'); + $row = $m->resolve('reset', ''); + $this->assertNotNull($row); + $this->assertSame('

second

', (string) $row->body); + } + + #[Test] + public function render_template_preview_uses_the_stored_override(): void + { + (new Tiger_Model_EmailTemplate())->store( + 'reset', '', 'Reset {{url}}', '

body {{url}}

' + ); + + $out = Tiger_Mail::renderTemplate('reset', ['url' => 'https://acme.example.com/r/z']); + $this->assertSame('Reset https://acme.example.com/r/z', $out['subject']); + $this->assertStringContainsString('body https://acme.example.com/r/z', $out['html']); + } +} diff --git a/tests/Unit/Mail/MailSubstituteTest.php b/tests/Unit/Mail/MailSubstituteTest.php new file mode 100644 index 00000000..3f90945f --- /dev/null +++ b/tests/Unit/Mail/MailSubstituteTest.php @@ -0,0 +1,74 @@ + ['site' => ['name' => 'Acme Corp', 'url' => 'https://acme.example.com']], + 'mail' => ['from' => ['email' => 'no-reply@acme.example.com', 'name' => 'Acme']], + ], true)); + } + + #[Test] + public function a_draft_body_substitutes_escapes_and_strips(): void + { + $out = Tiger_Mail::renderTemplate( + 'reset', + ['username' => 'Ada'], + '', + 'Welcome {{username}}', + '

Hi {{username}} — {{unknown}}

' + ); + + // The subject's value is substituted AND escaped. + $this->assertSame('Welcome <b>Ada</b>', $out['subject']); + + // The body's value is escaped... + $this->assertStringContainsString('Hi <b>Ada</b>', $out['html']); + $this->assertStringNotContainsString('Ada', $out['html']); + // ...the authored markup is preserved raw (only VALUES are escaped, not the template)... + $this->assertStringContainsString('

', $out['html']); + // ...an unmatched placeholder is stripped, never mailed verbatim... + $this->assertStringNotContainsString('{{unknown}}', $out['html']); + // ...and the whole thing is wrapped in the shared branded layout. + $this->assertStringContainsString('Acme Corp', $out['html']); + $this->assertStringContainsString(' 'zz-unit-probe', + 'label' => 'Probe', + 'vars' => ['x' => 'an x'], + ]); + $all = Tiger_Mail::registeredTemplates(); + $this->assertArrayHasKey('zz-unit-probe', $all); + $this->assertSame('Probe', $all['zz-unit-probe']['label']); + $this->assertSame(['x' => 'an x'], $all['zz-unit-probe']['vars']); + $this->assertSame('general', $all['zz-unit-probe']['group'], 'group defaults to general'); + } +}