Skip to content

feat(advisor-branding P2): wire email from-name + header to resolveBrand (compliance footer pinned)#1477

Merged
Systemsaholic merged 2 commits into
mainfrom
feature/advisor-branding-p2-email
Jul 5, 2026
Merged

feat(advisor-branding P2): wire email from-name + header to resolveBrand (compliance footer pinned)#1477
Systemsaholic merged 2 commits into
mainfrom
feature/advisor-branding-p2-email

Conversation

@Systemsaholic

@Systemsaholic Systemsaholic commented Jul 5, 2026

Copy link
Copy Markdown
Owner

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).

  • 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 path → brand.fromName else this.fromName; SMTP path → brand.fromName else 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-gated resolveBrandedFromName() helper; brandingService is @Optional; the resolve call is isolated in try/catch so a branding hiccup can never fail a send.
  • Email header (template-context-builder.service.ts + trip-order email template): loadBusinessConfig(agencyId, advisorId?) adds brand_* to the context; buildContext exposes a brand object; 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.
  • Compliance footer (agencySettings.emailComplianceFooter + tico_registration) is already agency-scoped and untouched. Hardcoded auth/invite/password-reset templates + inline fallbacks left alone (per dispatch).
  • Tests: from-name gate (unbranded → today's value; personal → "X by Phoenix Voyages"; dba → bare DBA; fail-open); brand context (unbranded → brand_headline === company_name; branded header; registrant unchanged; resolveBrand called 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

    • Email and document templates now support advisor-specific branding, including branded headers and branded “From” display names when available.
    • Branded emails can now show advisor naming while still preserving compliance details like registration information.
  • Bug Fixes

    • Improved fallback behavior so emails and templates continue to render normally if branding information can’t be resolved.
    • Prevents blank or whitespace-only display names from appearing in outgoing emails.

Systemsaholic and others added 2 commits July 5, 2026 17:12
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>
@vercel

vercel Bot commented Jul 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tailfire-client Ready Ready Preview, Comment Jul 5, 2026 9:20pm
tailfire-ota Ready Ready Preview, Comment Jul 5, 2026 9:20pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This 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.

Changes

Advisor self-branding integration

Layer / File(s) Summary
Module wiring and test scaffolding
apps/api/src/document-templates/document-templates.module.ts, apps/api/src/email/email.module.ts, apps/api/src/document-templates/__tests__/template-context-builder.service.spec.ts, apps/api/src/document-templates/template-context-builder.spec.ts, apps/api/src/document-templates/template-context-builder.brand.spec.ts
DocumentTemplatesModule and EmailModule import BrandingModule; existing and new test suites mock/stub BrandingService.resolveBrand to supply agency/advisor brand data.
TemplateContextBuilderService brand resolution
apps/api/src/document-templates/template-context-builder.service.ts
Service injects BrandingService, passes advisorId into loadBusinessConfig, resolves brand with a fail-open try/catch, and returns brand_headline/logo/color/tier plus a top-level ctx.brand object.
Trip-order template headline binding
apps/api/src/document-templates/system-templates/trip-order.template.ts, apps/api/src/document-templates/system-templates/trip-order.branding.render.spec.ts
emailHtml header now falls back to brand.headline instead of agency.name; new render tests validate unbranded, personal, and DBA tier headline/footer rendering.
EmailService branded from-name resolution
apps/api/src/email/email.service.ts, apps/api/src/email/email.service.spec.ts
EmailService optionally injects BrandingService, resolves advisor Brand during sendEmail, derives brandFromName via new resolveBrandedFromName helper, and applies it to SMTP/platform from headers and persisted syncedEmails.fromName.

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
Loading
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)
Loading

Related PRs: None identified.

Suggested labels: enhancement, email, document-templates

Suggested reviewers: None identified.

🐰 A brand new headline, a name reborn,
From agency defaults, advisors adorn,
Emails now whisper the tier that they wear,
Fail-open and gentle, with fallback to spare.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the feature but omits the required Summary heading, TripsService surface checkbox, and checklist items from the template. Add the template sections: a ## Summary, the TripsService surface checkbox for no trips.service.ts changes, and the checklist items for tests and secrets.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the advisor-branding email from-name/header change and the compliance-footer constraint.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/advisor-branding-p2-email

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

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

apps/api/src/document-templates/__tests__/template-context-builder.service.spec.ts

Oops! Something went wrong! :(

ESLint: 8.57.1

Error: ESLint configuration in --config is invalid:

  • Property "" is the wrong type (expected object but got "import config from \"@tailfire/config/eslint/flat-node\";\nexport default config;").

    at ConfigValidator.validateConfigSchema (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2177:19)
    at ConfigArrayFactory._normalizeConfigData (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3019:19)
    at ConfigArrayFactory._loadConfigData (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2984:21)
    at ConfigArrayFactory.loadFile (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2850:40)
    at createCLIConfigArray (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3660:35)
    at new CascadingConfigArrayFactory (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3735:29)
    at new CLIEngine (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/cli-engine/cli-engine.js:617:36)
    at new ESLint (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/eslint/eslint.js:430:27)
    at Object.execute (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/cli.js:410:24)
    at async main (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/bin/eslint.js:152:22)

apps/api/src/document-templates/document-templates.module.ts

Oops! Something went wrong! :(

ESLint: 8.57.1

Error: ESLint configuration in --config is invalid:

  • Property "" is the wrong type (expected object but got "import config from \"@tailfire/config/eslint/flat-node\";\nexport default config;").

    at ConfigValidator.validateConfigSchema (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2177:19)
    at ConfigArrayFactory._normalizeConfigData (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3019:19)
    at ConfigArrayFactory._loadConfigData (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2984:21)
    at ConfigArrayFactory.loadFile (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2850:40)
    at createCLIConfigArray (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3660:35)
    at new CascadingConfigArrayFactory (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3735:29)
    at new CLIEngine (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/cli-engine/cli-engine.js:617:36)
    at new ESLint (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/eslint/eslint.js:430:27)
    at Object.execute (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/cli.js:410:24)
    at async main (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/bin/eslint.js:152:22)

apps/api/src/document-templates/system-templates/trip-order.branding.render.spec.ts

Oops! Something went wrong! :(

ESLint: 8.57.1

Error: ESLint configuration in --config is invalid:

  • Property "" is the wrong type (expected object but got "import config from \"@tailfire/config/eslint/flat-node\";\nexport default config;").

    at ConfigValidator.validateConfigSchema (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2177:19)
    at ConfigArrayFactory._normalizeConfigData (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3019:19)
    at ConfigArrayFactory._loadConfigData (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2984:21)
    at ConfigArrayFactory.loadFile (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2850:40)
    at createCLIConfigArray (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3660:35)
    at new CascadingConfigArrayFactory (/node_modules/.pnpm/@eslint+eslintrc@2.1.4/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3735:29)
    at new CLIEngine (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/cli-engine/cli-engine.js:617:36)
    at new ESLint (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/eslint/eslint.js:430:27)
    at Object.execute (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/lib/cli.js:410:24)
    at async main (/node_modules/.pnpm/eslint@8.57.1/node_modules/eslint/bin/eslint.js:152:22)

  • 7 others

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Systemsaholic Systemsaholic merged commit 4c88c06 into main Jul 5, 2026
11 of 18 checks passed
@Systemsaholic Systemsaholic deleted the feature/advisor-branding-p2-email branch July 5, 2026 21:22

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
apps/api/src/document-templates/template-context-builder.service.ts (1)

430-478: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Parallelize the three independent lookups in loadBusinessConfig.

settings, agency, and brand are fetched with three sequential awaits even though none depends on the others' results (all only need agencyId/advisorId). Since loadBusinessConfig itself already runs concurrently with 8 other loaders inside buildContext's Promise.all, this sequential chain sets the latency floor for the whole buildContext call. 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

📥 Commits

Reviewing files that changed from the base of the PR and between 64d3a99 and 388437c.

📒 Files selected for processing (10)
  • apps/api/src/document-templates/__tests__/template-context-builder.service.spec.ts
  • apps/api/src/document-templates/document-templates.module.ts
  • apps/api/src/document-templates/system-templates/trip-order.branding.render.spec.ts
  • apps/api/src/document-templates/system-templates/trip-order.template.ts
  • apps/api/src/document-templates/template-context-builder.brand.spec.ts
  • apps/api/src/document-templates/template-context-builder.service.ts
  • apps/api/src/document-templates/template-context-builder.spec.ts
  • apps/api/src/email/email.module.ts
  • apps/api/src/email/email.service.spec.ts
  • apps/api/src/email/email.service.ts

Comment on lines +543 to 548
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!,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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:


🏁 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.ts

Repository: 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:


🏁 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.

Comment on lines +1123 to +1136
/**
* 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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant