Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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`
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
158 changes: 156 additions & 2 deletions library/Tiger/Mail.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/<key>.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<string,array{key:string,label:string,description:string,group:string,vars:array<string,string>}>
*/
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<string,array{key:string,label:string,description:string,group:string,vars:array<string,string>}>
*/
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.
*
Expand Down Expand Up @@ -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');
Expand All @@ -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()
{
Expand Down
116 changes: 116 additions & 0 deletions library/Tiger/Model/EmailTemplate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* EmailTemplate — the DB-override tier for Tiger_Mail's transactional templates (see migration 0054).
*
* An active row OVERRIDES the code-shipped `emails/<template_key>.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;
}
}
2 changes: 1 addition & 1 deletion library/Tiger/Version.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Loading
Loading