feat(advisor-branding P2): wire email from-name + header to resolveBrand (compliance footer pinned)#1477
Conversation
Advisor self-branding P2 (final v1 slice) — the email FROM-NAME and the one
branded email HEADER (trip-order cover email) now render the acting advisor's
brand; the compliance FOOTER + TICO stay pinned to the agency registrant. Ships
LIVE but inert until an advisor has an active brand row (tier='none' → agency =
byte-identical to today). No migration; money boundary untouched.
FROM-NAME (email.service.ts)
- Resolve the acting advisor's brand once (from resolveAgent's userId) and,
ONLY when tier != 'none', override the from-NAME:
* platform-fallback (Postmark/Resend): brand.fromName else this.fromName
* SMTP (advisor mailbox): brand.fromName else the account display name
The from-ADDRESS never changes (platform address / the advisor's own mailbox —
the DBA-domain case works because they send from their own domain). Extracted
resolveBrandedFromName() as a tier-gated pure helper. brandingService is an
@optional dep (tests without it → unbranded).
EMAIL HEADER (template-context-builder.service.ts + trip-order email template)
- loadBusinessConfig(agencyId, advisorId?) resolves the brand and adds
brand_headline/logo/color to the context's businessConfig; buildContext
exposes a `brand` object (HEADER). Registrant fields (company_name,
tico_registration, phone/email/address) are LEFT UNTOUCHED — the footer stays
the agency. ISOLATED try/catch: a brand-resolution failure degrades the header
to the agency name but never drops the businessConfig (footer TICO survives).
- trip-order emailHtml header h1 → {{brand.headline}} (byte-identical unbranded).
- Compliance footer (agencySettings.emailComplianceFooter + tico_registration)
is already agency-scoped and untouched.
- LEFT ALONE (per dispatch): hardcoded "Phoenix Voyages" auth/invite/
password-reset templates + inline TS fallbacks; sign-off/footer company names.
Tests (foreground)
- email from-name gate: unbranded → null (keeps today's from-name); personal →
"X by Phoenix Voyages"; dba → bare DBA; blank → null.
- brand context: unbranded → brand_headline === company_name; personal/dba →
branded header; registrant (company_name/tico) unchanged; resolveBrand called
with (agencyId, agentId); fail-open keeps the footer TICO.
- trip-order EMAIL render: unbranded/personal/dba header + pinned Phoenix+TICO
footer.
- Updated 2 context-builder specs for the new brandingService constructor dep.
Config UI (Branding tab + admin verify toggle) is a SEPARATE P2b slice — not here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…/catch (Codex nit) Codex gpt-5.5 xhigh non-blocking nit — mirror the TemplateContextBuilderService defensive pattern: wrap the from-name resolveBrand call so a branding failure degrades to the unbranded from-name and NEVER fails an email send. P0 resolveBrand already fails open; this is defense-in-depth for consistency within the PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis PR introduces advisor self-branding by injecting BrandingService into TemplateContextBuilderService and EmailService. TemplateContextBuilderService resolves advisor brand data to populate a new ctx.brand object and brand_* fields with fail-open fallback; EmailService uses resolved brand to override from-name in SMTP and platform email sends. Modules and templates are updated accordingly, with new/updated tests. ChangesAdvisor self-branding integration
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Caller
participant TemplateContextBuilderService
participant BrandingService
participant DatabaseService
Caller->>TemplateContextBuilderService: buildContext(agencyId, agentId)
TemplateContextBuilderService->>DatabaseService: loadBusinessConfig(agencyId, advisorId)
TemplateContextBuilderService->>BrandingService: resolveBrand(agencyId, advisorId)
alt resolveBrand succeeds
BrandingService-->>TemplateContextBuilderService: Brand
else resolveBrand fails
BrandingService-->>TemplateContextBuilderService: error caught, fallback to agency defaults
end
TemplateContextBuilderService-->>Caller: ctx with businessConfig and brand
sequenceDiagram
participant Caller
participant EmailService
participant BrandingService
Caller->>EmailService: sendEmail(...)
EmailService->>BrandingService: resolveBrand(agent.userId)
alt resolveBrand succeeds
BrandingService-->>EmailService: Brand
EmailService->>EmailService: resolveBrandedFromName(brand)
else resolveBrand fails
BrandingService-->>EmailService: error caught, logged
EmailService->>EmailService: brandFromName = null
end
EmailService->>EmailService: build from header (SMTP or platform provider)
Related PRs: None identified. Suggested labels: enhancement, email, document-templates Suggested reviewers: None identified. 🐰 A brand new headline, a name reborn, 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
apps/api/src/document-templates/__tests__/template-context-builder.service.spec.tsOops! Something went wrong! :( ESLint: 8.57.1 Error: ESLint configuration in --config is invalid:
apps/api/src/document-templates/document-templates.module.tsOops! Something went wrong! :( ESLint: 8.57.1 Error: ESLint configuration in --config is invalid:
apps/api/src/document-templates/system-templates/trip-order.branding.render.spec.tsOops! Something went wrong! :( ESLint: 8.57.1 Error: ESLint configuration in --config is invalid:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
apps/api/src/document-templates/template-context-builder.service.ts (1)
430-478: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winParallelize the three independent lookups in
loadBusinessConfig.
settings,agency, andbrandare fetched with three sequentialawaits even though none depends on the others' results (all only needagencyId/advisorId). SinceloadBusinessConfigitself already runs concurrently with 8 other loaders insidebuildContext'sPromise.all, this sequential chain sets the latency floor for the wholebuildContextcall. Running these three concurrently would cut this branch's latency roughly by a third.♻️ Proposed refactor
- const [settings] = await this.db.client - .select({ - logoUrl: agencySettings.logoUrl, - primaryColor: agencySettings.primaryColor, - ticoRegistration: agencySettings.ticoRegistration, - companyPhone: agencySettings.companyPhone, - companyEmail: agencySettings.companyEmail, - companyAddress: agencySettings.companyAddress, - }) - .from(agencySettings) - .where(eq(agencySettings.agencyId, agencyId)) - .limit(1) - - const [agency] = await this.db.client - .select({ name: agencies.name }) - .from(agencies) - .where(eq(agencies.id, agencyId)) - .limit(1) - - const companyName = agency?.name || 'Phoenix Voyages' - let brand - try { - brand = await this.brandingService.resolveBrand(agencyId, advisorId) - } catch (brandErr) { - this.logger.warn(`Brand resolution failed for agency ${agencyId}; using agency header: ${brandErr}`) - brand = null - } + const [[settings], [agency], brand] = await Promise.all([ + this.db.client + .select({ + logoUrl: agencySettings.logoUrl, + primaryColor: agencySettings.primaryColor, + ticoRegistration: agencySettings.ticoRegistration, + companyPhone: agencySettings.companyPhone, + companyEmail: agencySettings.companyEmail, + companyAddress: agencySettings.companyAddress, + }) + .from(agencySettings) + .where(eq(agencySettings.agencyId, agencyId)) + .limit(1), + this.db.client + .select({ name: agencies.name }) + .from(agencies) + .where(eq(agencies.id, agencyId)) + .limit(1), + this.brandingService.resolveBrand(agencyId, advisorId).catch((brandErr) => { + this.logger.warn(`Brand resolution failed for agency ${agencyId}; using agency header: ${brandErr}`) + return null + }), + ]) + + const companyName = agency?.name || 'Phoenix Voyages'🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/api/src/document-templates/template-context-builder.service.ts` around lines 430 - 478, loadBusinessConfig currently does three independent lookups sequentially for agencySettings, agencies, and brandingService.resolveBrand even though they only depend on agencyId/advisorId. Refactor loadBusinessConfig to start all three operations together and await them as a group, while keeping the existing isolated brand failure fallback to agency-only and preserving the current return shape. Use the loadBusinessConfig method, the db.client queries for settings/agency, and the brandingService.resolveBrand call as the key locations to update.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/api/src/email/email.service.ts`:
- Around line 1123-1136: The new resolveBrandedFromName method is inserted
between the resolveAgent docstring and the resolveAgent method, which makes the
existing waterfall comment appear to document the wrong function. Move
resolveBrandedFromName so it follows resolveAgent, or add a separate doc section
for it, and keep the resolveAgent JSDoc immediately above resolveAgent to
preserve the intended association.
- Around line 543-548: The SMTP sender construction in the email service still
interpolates an unsanitized display name into the From header, which can lead to
malformed or injected headers. Update the logic around the
smtpSendService.sendRaw call in EmailService to sanitize brandFromName and
agent.displayName by stripping CR/LF and escaping problematic characters like
quotes and angle brackets, or avoid string concatenation entirely by passing a
structured sender object with separate name and address fields. Apply the same
sanitization approach used by resolveBrand() to this SMTP account path as well.
---
Nitpick comments:
In `@apps/api/src/document-templates/template-context-builder.service.ts`:
- Around line 430-478: loadBusinessConfig currently does three independent
lookups sequentially for agencySettings, agencies, and
brandingService.resolveBrand even though they only depend on agencyId/advisorId.
Refactor loadBusinessConfig to start all three operations together and await
them as a group, while keeping the existing isolated brand failure fallback to
agency-only and preserving the current return shape. Use the loadBusinessConfig
method, the db.client queries for settings/agency, and the
brandingService.resolveBrand call as the key locations to update.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 28ab947c-4887-468c-9177-c5b864084e34
📒 Files selected for processing (10)
apps/api/src/document-templates/__tests__/template-context-builder.service.spec.tsapps/api/src/document-templates/document-templates.module.tsapps/api/src/document-templates/system-templates/trip-order.branding.render.spec.tsapps/api/src/document-templates/system-templates/trip-order.template.tsapps/api/src/document-templates/template-context-builder.brand.spec.tsapps/api/src/document-templates/template-context-builder.service.tsapps/api/src/document-templates/template-context-builder.spec.tsapps/api/src/email/email.module.tsapps/api/src/email/email.service.spec.tsapps/api/src/email/email.service.ts
| const smtpDisplayName = brandFromName ?? agent.displayName | ||
| const smtpResult = await this.smtpSendService.sendRaw({ | ||
| accountId: agent.emailAccountId, | ||
| from: agent.displayName | ||
| ? `"${agent.displayName}" <${agent.emailAddress}>` | ||
| from: smtpDisplayName | ||
| ? `"${smtpDisplayName}" <${agent.emailAddress}>` | ||
| : agent.emailAddress!, |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
RFC 5322 From header display-name sanitization CRLF injection best practice
💡 Result:
To prevent CRLF (Carriage Return Line Feed) injection in the From header display name (and other email headers) as defined by RFC 5322, you must treat all user-supplied data as untrusted and sanitize it before interpolation [1][2][3][4]. Best practices include: 1. Strip Newline Characters: Before any header construction, explicitly remove all carriage return (\r, 0x0D), line feed (\n, 0x0A), and null (\0) bytes from user-supplied inputs [1][5][4][6]. Even if an email library is used, sanitizing at the application layer provides essential defense-in-depth [5][6]. 2. Use Modern, Robust Email Libraries: Prefer using established, well-maintained email sending libraries or transactional email APIs (e.g., SendGrid, Postmark, Resend) that are designed to handle header encoding and boundary separation automatically [3][4][6]. These libraries typically manage header construction safely, treating inputs as data rather than permitting raw injection [6]. 3. Validate and Enforce Constraints: - Validate input format (e.g., using schema validation like Zod or regex) to ensure data conforms to expected patterns [2][3]. - Enforce reasonable maximum lengths for all header fields (e.g., 256 characters) to prevent buffer-related issues or malformed header blocks [5]. 4. Avoid Manual String Concatenation: Never build raw email MIME strings using template literals or string concatenation that includes unsanitized user variables [2][5][3]. For example, do not build a header like From: ${userName} <${email}> without ensuring userName has been completely stripped of control characters [2][3]. 5. Implement Server-Side Validation: Never rely solely on client-side validation, as it can be easily bypassed by attackers [6]. All sanitization and validation must occur on the server side before the message is passed to the mail transfer agent (MTA) [3][6]. Failure to sanitize these inputs allows an attacker to inject arbitrary headers—such as Bcc: or Cc:—enabling them to redirect emails, perform phishing, or turn your application into a spam relay [1][7][2][3].
Citations:
- 1: https://auditbuffet.com/patterns/ab-001252
- 2: https://github.com/romain-deperne/CVE-2026-34975
- 3: https://www.datahogo.com/en/learn/email-header-injection
- 4: https://www.acunetix.com/vulnerabilities/web/email-header-injection-invicti-iast/
- 5: https://auditbuffet.com/patterns/ab-002469
- 6: https://www.scanvigil.com/email-injection-attacks-detection-and-prevention/
- 7: https://www.wordfence.com/threat-intel/vulnerabilities/wordpress-plugins/wpforms-lite/wpforms-1102-improper-neutralization-of-crlf-sequences-to-unauthenticated-email-header-injection-via-reply-to-display-name
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate the relevant files and inspect the surrounding code.
git ls-files 'apps/api/src/email/email.service.ts' 'apps/api/src/**/branding*.ts' 'apps/api/src/**/brand*.ts' 'apps/api/src/**/smtp*.ts' | sed -n '1,120p'
echo '--- email.service.ts (around the referenced lines) ---'
sed -n '500,700p' apps/api/src/email/email.service.ts
echo '--- search for fromName handling ---'
rg -n 'fromName|sanitize.*from|BrandingService|resolveBrand|displayName|emailAddress' apps/api/src/email apps/api/src -g '!**/*.map'Repository: Systemsaholic/tailfire
Length of output: 48661
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- branding.service.ts (resolveBrand / fromName composition) ---'
sed -n '1,180p' apps/api/src/branding/branding.service.ts
echo
sed -n '240,330p' apps/api/src/branding/branding.service.ts
echo '--- email.service.ts (resolveBrandedFromName helper) ---'
sed -n '1110,1165p' apps/api/src/email/email.service.ts
echo '--- smtp-send.service.ts (header construction) ---'
sed -n '150,240p' apps/api/src/email-accounts/smtp-send.service.tsRepository: Systemsaholic/tailfire
Length of output: 15165
🌐 Web query:
Nodemailer from header display name quote escaping CRLF injection sendMail string address parsing
💡 Result:
When using Nodemailer, handling user-supplied input in email header fields requires caution to avoid security issues such as CRLF injection, header injection, and denial-of-service (DoS) vulnerabilities. Address Parsing and Header Injection Nodemailer supports passing addresses as formatted strings (e.g., '"Name" user@example.com') or as JavaScript objects (e.g., { name: 'Name', address: 'user@example.com' }) [1][2]. Historically, vulnerabilities existed where providing an address object with maliciously crafted strings could allow for arbitrary header injection if the input contained carriage return (CR) or line feed (LF) characters [3]. While Nodemailer has implemented fixes for these issues [3], it is best practice to treat all user-provided data as untrusted. If you must use a string for the 'from' or other address fields, ensure that any display name containing special characters—particularly commas—is enclosed in double quotes [1][2]. Failure to do so can lead to improper parsing of the address list [2][4]. However, the most robust way to handle potentially complex or untrusted inputs is to use the address object format, as Nodemailer is designed to automatically handle necessary escaping when inputs are passed this way [1][2]. Known Security Considerations Several vulnerabilities have affected Nodemailer's handling of headers and parsing: 1. Header Injection via CRLF: Vulnerabilities have been identified (such as GHSA-268h-hp4c-crq3) where CRLF sequences in specific fields (like list comments) were not properly sanitized, allowing attackers to inject arbitrary headers into the generated email [5][6][7]. 2. Denial of Service (DoS): The address parser has been vulnerable to infinite recursion when processing maliciously crafted address headers containing deeply nested group structures or repetitive colon tokens, which can lead to a process crash (e.g., CVE-2025-14874 / GHSA-rcmh-qjqh-p98v) [8][9][10]. 3. Parsing Misinterpretation: Issues have been reported regarding the incorrect handling of quoted local-parts containing '@' characters, which can lead to misrouting or filter evasion (e.g., GHSA-mm7p-fcc7-pg87) [11]. Recommendations To maintain security: - Keep Nodemailer updated to the latest version to benefit from patches for known vulnerabilities [3][8]. - Prefer the object format ({ name, address }) over string concatenation for address fields to allow the library to handle escaping correctly [1][2]. - Sanitize all user-provided input by removing or rejecting CR and LF characters before passing them to any Nodemailer options, as a defense-in-depth measure [6][7]. - Avoid using complex or deeply nested address structures if the input is user-controlled to prevent DoS-related parsing recursion [8].
Citations:
- 1: https://nodemailer.com/message/addresses
- 2: https://community.nodemailer.com/address-formatting/
- 3: Header injection vulnerability in address object nodemailer/nodemailer#1289
- 4: From address / sender name - quotes vs commas nodemailer/nodemailer#377
- 5: GHSA-268h-hp4c-crq3
- 6: https://osv.dev/vulnerability/GHSA-268h-hp4c-crq3
- 7: GHSA-268h-hp4c-crq3
- 8: GHSA-rcmh-qjqh-p98v
- 9: https://nvd.nist.gov/vuln/detail/CVE-2025-14874
- 10: https://osv.dev/vulnerability/CVE-2025-14874
- 11: https://osv.dev/vulnerability/GHSA-mm7p-fcc7-pg87
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n 'sanitizeFromName|escape.*fromName|header injection|CRLF|quote.*display name|encode.*display name' apps/api/src packages -g '!**/*.map'Repository: Systemsaholic/tailfire
Length of output: 469
Sanitize display names before building From headers.
resolveBrand() only trims fromName; it doesn’t strip CR/LF or escape ", <, >, so the raw interpolation here can still produce malformed or injected headers. Apply the same fix in the SMTP account path as well, or pass { name, address } to Nodemailer instead of concatenating strings.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/api/src/email/email.service.ts` around lines 543 - 548, The SMTP sender
construction in the email service still interpolates an unsanitized display name
into the From header, which can lead to malformed or injected headers. Update
the logic around the smtpSendService.sendRaw call in EmailService to sanitize
brandFromName and agent.displayName by stripping CR/LF and escaping problematic
characters like quotes and angle brackets, or avoid string concatenation
entirely by passing a structured sender object with separate name and address
fields. Apply the same sanitization approach used by resolveBrand() to this SMTP
account path as well.
| /** | ||
| * Advisor self-branding P2 — the from-NAME override, or null to keep today's | ||
| * from-name. Only an ACTIVE brand (tier 'personal' | 'dba') changes the | ||
| * from-name; tier 'none' / no brand returns null so the caller falls back to | ||
| * the platform from-name (or the SMTP account display name). resolveBrand | ||
| * already composes fromName ("X by Phoenix Voyages" for personal, the bare | ||
| * DBA name for dba). | ||
| */ | ||
| private resolveBrandedFromName(brand: Brand | null): string | null { | ||
| if (!brand || brand.tier === 'none') return null | ||
| const name = brand.fromName?.trim() | ||
| return name ? name : null | ||
| } | ||
|
|
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Docstring misplacement: resolveBrandedFromName orphans the resolveAgent docstring.
The new resolveBrandedFromName block (lines 1123-1136) is inserted between the existing resolveAgent docstring (lines 1115-1122, unchanged, describing the contactId→tripId→createdBy waterfall) and the resolveAgent method itself (line 1137). The waterfall docstring now visually documents resolveBrandedFromName instead of resolveAgent, and resolveAgent is left without an adjacent doc comment.
♻️ Move the new method after `resolveAgent` (or add its own section header)
- /**
- * Advisor self-branding P2 — the from-NAME override, or null to keep today's
- * from-name. Only an ACTIVE brand (tier 'personal' | 'dba') changes the
- * from-name; tier 'none' / no brand returns null so the caller falls back to
- * the platform from-name (or the SMTP account display name). resolveBrand
- * already composes fromName ("X by Phoenix Voyages" for personal, the bare
- * DBA name for dba).
- */
- private resolveBrandedFromName(brand: Brand | null): string | null {
- if (!brand || brand.tier === 'none') return null
- const name = brand.fromName?.trim()
- return name ? name : null
- }
-
private async resolveAgent(
...
): Promise<...> {
...
}
+
+ /**
+ * Advisor self-branding P2 — the from-NAME override, or null to keep today's
+ * from-name. Only an ACTIVE brand (tier 'personal' | 'dba') changes the
+ * from-name; tier 'none' / no brand returns null so the caller falls back to
+ * the platform from-name (or the SMTP account display name). resolveBrand
+ * already composes fromName ("X by Phoenix Voyages" for personal, the bare
+ * DBA name for dba).
+ */
+ private resolveBrandedFromName(brand: Brand | null): string | null {
+ if (!brand || brand.tier === 'none') return null
+ const name = brand.fromName?.trim()
+ return name ? name : null
+ }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/api/src/email/email.service.ts` around lines 1123 - 1136, The new
resolveBrandedFromName method is inserted between the resolveAgent docstring and
the resolveAgent method, which makes the existing waterfall comment appear to
document the wrong function. Move resolveBrandedFromName so it follows
resolveAgent, or add a separate doc section for it, and keep the resolveAgent
JSDoc immediately above resolveAgent to preserve the intended association.
Advisor Self-Branding P2 — email (final v1 slice). Codex gpt-5.5 xhigh: APPROVE on 388437c (clean r1 + defensive-nit r2). Builds on P0 (#1475) + P1 (#1476), both merged.
The email from-name and the one branded email header (the trip-order cover email) now render the acting advisor's brand; the compliance footer + TICO stay pinned to the agency registrant. Ships LIVE but inert until an advisor has an active brand row — default
tier='none'→ agency = byte-identical to today (proven by test). No migration; money boundary untouched (Codex-confirmed).email.service.ts): resolve the acting advisor's brand once (fromresolveAgent's userId) and, only whentier != 'none', override the from-NAME — platform path →brand.fromNameelsethis.fromName; SMTP path →brand.fromNameelse the account display name. The from-ADDRESS never changes (platform noreply / the advisor's own mailbox — the DBA-domain case works because they send from their own domain). Tier-gatedresolveBrandedFromName()helper;brandingServiceis@Optional; the resolve call is isolated in try/catch so a branding hiccup can never fail a send.template-context-builder.service.ts+ trip-order email template):loadBusinessConfig(agencyId, advisorId?)addsbrand_*to the context;buildContextexposes abrandobject; the trip-order email<h1>reads{{brand.headline}}. Registrant fields (company_name,tico_registration, contact) are left untouched — the footer stays the agency. Brand resolution is isolated so a failure degrades the header to the agency name but never drops the footer TICO.agencySettings.emailComplianceFooter+tico_registration) is already agency-scoped and untouched. Hardcoded auth/invite/password-reset templates + inline fallbacks left alone (per dispatch).brand_headline === company_name; branded header; registrant unchanged;resolveBrandcalled with the acting advisor; fail-open keeps footer TICO); trip-order email render for all three tiers. 75 unit specs green.Config UI (Branding tab + admin verify toggle) is a separate P2b slice — not in this PR.
Summary by CodeRabbit
New Features
Bug Fixes