Skip to content

Apply tenant brand colors to all transactional emails - #88

Merged
keithfawcett merged 3 commits into
getcoherence:mainfrom
TylonHH:codex/upstream-mail-branding
Oct 13, 2026
Merged

keithfawcett merged 3 commits into
getcoherence:mainfrom
TylonHH:codex/upstream-mail-branding

Conversation

@TylonHH

@TylonHH TylonHH commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

What

Apply the tenant's configured brandColor to all OpenPartner transactional emails, including admin/partner magic links, invitations, campaign notices, account suspension, brand-review and platform-ops/billing notifications. Previously the email templates always used the platform teal even when the portal used a custom brand color.

Why

The existing brand setting should carry through to email. This also gives messages without a CTA and the mail-transport test a branded HTML card; the plain-text alternative remains intact.

How

  • Resolve the sending context's tenant color on each send, before either SMTP or Postmark dispatch, so settings changes take effect immediately.
  • Apply colors only to marked template styles: card border, button background and inline links. Unmarked custom HTML, message content and URLs remain untouched.
  • Accept the existing settings formats (#RGB, #RRGGBB, #RRGGBBAA); strip alpha like the portal and fall back to teal for missing/invalid values. Choose black/white button text and darken light link colors to maintain at least 4.5:1 contrast on white.
  • Keep platform-ops messages in their existing platform/default-tenant context. External Network-service emails are outside this repository.

How to verify

Use Node 22, pnpm 9 and a disposable PostgreSQL 16 database, then run:

pnpm install --frozen-lockfile
pnpm --filter @openpartner/db build
pnpm migrate
pnpm lint
pnpm typecheck
pnpm test
pnpm --filter @openpartner/api build

The new Vitest file covers every template, safe fallback, shorthand/alpha colors, contrast, unchanged custom HTML and fresh tenant-scoped branding through mocked SMTP/Postmark transports. Both transport regressions fail with the original mailer because the delivered HTML still contains the fixed teal button.

Local validation used Node 22.20.0 / pnpm 9.15.4 / PostgreSQL 16.15 and the upstream frozen lockfile:

  • Passed: dependency installation, shared DB build, migrations, lint (five existing warnings), workspace typecheck, API production build and git diff --check.
  • Passed: final complete rerun, 571 tests in 54 files, no reported skips: API 545/49, SDK 11/2, router 3/1, portal 12/2. This includes all 28 new email tests and nine multi-tenant cases with the non-superuser, non-BYPASSRLS app role present.
  • Intermittent existing-suite failures: a preceding complete run had two funding-races.test.ts sweep-limit assertions fail (empty skipped list / five checked charges instead of two). The focused funding suite and final complete rerun passed without funding-code changes; root cause remains unconfirmed.
  • Existing upstream baseline failures: production audit reports 3 low / 23 moderate / 3 high / 1 critical advisories (dependency remediation remains separate in Fix high and critical production dependency vulnerabilities #85). Full workspace build fails in the unchanged SDK declaration build with TS5101 (baseUrl deprecation under TypeScript 6); the affected API build passes independently.

No live email or deployment was performed; Gmail/Outlook rendering remains unverified.

Checklist

  • Tests added / updated
  • pnpm typecheck + pnpm test green locally (final complete rerun)
  • No edits to already-applied migrations
  • No new dependencies
  • Architectural principles in CLAUDE.md preserved

@TylonHH
TylonHH requested a review from keithfawcett as a code owner October 11, 2026 18:58
@semgrep-code-getcoherence

Copy link
Copy Markdown

Semgrep found 1 ssc-7d20d71b-c449-4d63-8acf-839c670aaa0f finding:

🟠 High severity issue identified in your code:

Risk: Affected versions of nodemailer are vulnerable to Inefficient Algorithmic Complexity / Inefficient Regular Expression Complexity. When strict parsing of an address token fails, Nodemailer's addressparser falls back to extracting an address from free text with the unanchored regex /\s*\b[^@\s]+@[^\s]+\b\s*/, which backtracks quadratically on long whitespace-free runs that contain no usable '@'. An attacker who can get such a string into any value that reaches the parser — a From/To/Cc/Bcc/Sender/Reply-To value passed to sendMail, an address header on a MimeNode that is later built or turned into an envelope, or a string handed directly to the exported addressparser function — can block the Node.js event loop for tens of seconds (about 273 KB causes roughly 43 seconds of blocking), denying service to the whole application.

Fix: Upgrade this library to at least version 10.0.6 at openpartner/pnpm-lock.yaml:2361.

Reference(s): GHSA-v53p-9fqp-m79j

View Dataflow Graph
flowchart LR
    classDef invis fill:white, stroke: none
    classDef default fill:#e7f5ff, color:#1c7fd6, stroke: none

    subgraph File0["<b>apps/api/src/mailer.ts</b>"]
        direction LR
        %% Source

        subgraph Source
            direction LR

            v0["<a href=https://github.com/getcoherence/openpartner/blob/44065cbe5efff444064c469b1be8f6c9cd44835a/apps/api/src/mailer.ts#L119 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 119] nodemailer.createTransport({<br>        host: cfg.smtp.host,<br>        port: cfg.smtp.port,<br>        secure: cfg.smtp.secure,<br>        auth:<br>          cfg.smtp.user && cfg.smtp.password<br>            ? { user: cfg.smtp.user, pass: cfg.smtp.password }<br>            : undefined,<br>        // Nodemailer&apos;s default connection timeout is 2 MINUTES — longer<br>        // than the platform gateway&apos;s ~60s, so a misconfigured host (wrong<br>        // hostname, filtered port) surfaced as an opaque 504 instead of<br>        // our clean ETIMEDOUT/ECONNREFUSED error, and a real send (partner<br>        // invite, signup) could pin its request just as long. Fail fast:<br>        // a healthy SMTP handshake completes in single-digit seconds.<br>        connectionTimeout: 10_000,<br>        greetingTimeout: 10_000,<br>        socketTimeout: 30_000,<br>      })</a>"]
        end
        %% Intermediate

        subgraph Traces0[Traces]
            direction TB

            v2["<a href=https://github.com/getcoherence/openpartner/blob/44065cbe5efff444064c469b1be8f6c9cd44835a/apps/api/src/mailer.ts#L119 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 119] transporter</a>"]
        end
        %% Sink

        subgraph Sink
            direction LR

            v1["<a href=https://github.com/getcoherence/openpartner/blob/44065cbe5efff444064c469b1be8f6c9cd44835a/apps/api/src/mailer.ts#L137 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 137] transporter.sendMail({<br>        from,<br>        to: msg.to,<br>        replyTo,<br>        subject: msg.subject,<br>        text: msg.text,<br>        html,<br>        headers: msg.tag ? { &apos;X-Tag&apos;: msg.tag } : undefined,<br>      })</a>"]
        end
    end
    %% Class Assignment
    Source:::invis
    Sink:::invis

    Traces0:::invis
    File0:::invis

    %% Connections

    Source --> Traces0
    Traces0 --> Sink

Loading

Semgrep found 1 ssc-3547ef8a-8b04-4bef-9de4-e72d4fd883e9 finding:

🟠 High severity issue identified in your code:

Risk: Affected versions of nodemailer are vulnerable to Inefficient Algorithmic Complexity / Uncontrolled Resource Consumption. Nodemailer's RFC 5322 address parser accumulates parsed addresses with Array.prototype.concat(), splices display-name fragments out of the accumulator, and deduplicates recipients with a linear scan per address, all of which run in quadratic O(n^2) time. By supplying a large comma-separated address list to any field that reaches the parser — a recipient/Reply-To/From value passed to sendMail, an address header set on a MimeNode and then encoded when the message is built, or a string handed directly to the exported addressparser function — an attacker can pin the CPU at 100% and block the Node.js event loop for tens of seconds (a ~1.5 MB list stalls the process for 25-30 seconds), denying service to the whole application.

Fix: Upgrade this library to at least version 9.1.0 at openpartner/pnpm-lock.yaml:2361.

Reference(s): GHSA-2x7j-588g-ccc2

@TylonHH

TylonHH commented Oct 11, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for flagging these findings. Both advisories affect the existing upstream dependency, nodemailer@9.0.3; this branding PR changes neither the dependency manifests nor the lockfile. They are real dependency vulnerabilities, but were not introduced by this change.

Upgrading Nodemailer to at least 10.0.6 addresses both reported address-parser denial-of-service issues. Existing security PR #85 includes Nodemailer remediation, and Dependabot PR #82 proposes 10.0.9.

I recommend merging the dependency remediation separately, then updating this branding branch against the patched main branch and rerunning the checks. The existing audit failure is also disclosed in this PR's validation notes.

keithfawcett and others added 2 commits October 13, 2026 00:09
The workspace sign-in (/signin) runs under whichever Admin row matched
first, so it was painted in an arbitrary brand's color; ops alerts and the
ops-console sign-in went out in the default tenant's color. A Message can
now be marked `platform`, which skips tenant branding, and those three
sends set it. The marker rewrite is also anchored to our own <table>/<a>
tags, so text that merely contains `data-op-email=` (an escaped partner
name) is left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@keithfawcett keithfawcett left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks! Pushed a small follow-up: platform-voice emails (sign-in, ops alerts) are never brand-colored, and the marker rewrite is anchored to our own tags.

@keithfawcett
keithfawcett merged commit e5e755d into getcoherence:main Oct 13, 2026
5 checks passed
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.

2 participants