Change evidence report

CRM email action boundaries: reviewer evidence

PR 1894 makes CRM action links preserve a configured deployment path, base query and tenant routing across API and worker emails. It also keeps one recipient's rendering or action-augmentation failure from aborting the rest of the recipient batch, and suppresses generic account calls to action in both HTML and text when a deferred review link is present.

Range
9d8fdc955b1bf705f129ec5ac981fca02f88d911…ff50240a2cc70a0a96841e6b8dc6d56ddcf84621
Source state
fix/1667-email-action-url-boundary at code head ff50240a2cc70a0a96841e6b8dc6d56ddcf84621; this evidence source is the following report update
Generated

Reviewer brief

Outcome and scope

Every CRM email link now uses one path-preserving owner

crmAppUrl resolves configured URL prefixes, query parameters, route paths, fragments and tenant selection. All known CRM email action call sites were migrated to it, including invitations, password reset, sign-in, support tickets and worker notifications. Recipient rendering and provider submission remain isolated per destination.

Non-visual evidence

This backend email change has no stable browser surface before delivery. Evidence therefore uses real route integrations, focused rendering/provider tests and an exhaustive call-site review. Test addresses and a local PostgreSQL database were used; no email was sent to a real recipient.

Claim map

Evidence coverage

Each material changed behavior or state maps to direct evidence or an explicit gap.

Behavior or claimEvidenceStatus
A failure inside one recipient attempt is caught before the loop advancesTrigger-hook test starts with invalid shared CRM configuration, repairs it after the first logged failure, and confirms the later recipient is attempted and sentCovered
CRM email routes preserve configured prefixes, base query and tenant routingcrmAppUrl unit matrix plus invite, password-reset, sign-in, support-ticket and worker route testsCovered
A deferred review link suppresses a generic account CTA in both MIME partsaction-link tests cover HTML and plain text independently, including an HTML-only review link; worker tests separately verify their own CRM action URLsCovered

Observed change

Review evidence

Evidence is grouped by the reviewer question each item addresses.

URL construction

Table

Preserved CRM URL components

The helper validates the configured origin, appends routes below the deployment prefix, preserves base query parameters, applies route-specific parameters and clears configured fragments.

Configured componentObserved candidate behaviorCoverage
Path prefix /deploy/crmAction route is appended under /deploy/crmcrm-app-url.test.ts and route integrations
Base query tenant=alphaTenant query remains on every generated actioncrm-app-url.test.ts and invite/reset route tests
Route-specific query valuesParameters such as shift date are merged without losing base query valuescrm-app-url.test.ts
Configured fragmentCleared before a route-owned one-time token is addedcrm-app-url.test.ts plus invite/reset/sign-in integrations
Invalid or insecure production configurationMissing URL, non-HTTP(S), credentials, and production HTTP fail closedcrm-app-url.test.ts and recipient-isolation test

Recipient isolation

Table

A recipient attempt failure does not escape the loop

Action augmentation remains inside the same try boundary as rendering and provider submission. The focused test repairs the shared configuration after the first logged failure, then proves iteration reaches and sends the later recipient.

ScenarioExpected resultObserved result
First recipient attempt sees invalid shared CRM configurationRecord one failure and continueFirst failure logged; test repairs configuration; second recipient sent
One provider submission failsDo not abort unrelated recipientsExisting provider isolation suite remains green
No recipients accept the notificationReturn the typed skipped resultTyped trigger-hook result and focused tests remain green

Migrated call sites

Table

Email routes no longer bypass crmAppUrl

Independent re-review searched the complete API and worker email surface after the fix.

FlowRoute ownerVerification
Admin invitecrmAppUrlReal database invite acceptance flow
Admin password resetcrmAppUrlReal database reset request and completion flow
Admin sign-incrmAppUrlReal database sign-in email flow
Support ticketcrmAppUrlFocused route suite
Shift notificationcrmAppUrl with route-specific dateCaller inspection, helper date-merge test and focused email rendering test
Trial expiry and franchise reconciliationcrmAppUrlFocused worker suites
General trigger-hook open actioncrmAppUrlFocused trigger-hook suite

URL configuration

Code

Unsafe or incomplete CRM configuration fails closed

The helper accepts only credential-free absolute HTTP(S) URLs and requires HTTPS in production. Development retains the localhost default.

Validated CRM application baseAfter
packages/api/src/lib/crm-app-url.ts · ff50240a2cc7 · lines 11-46
function crmAppBaseUrl(purpose: string): URL {
  const configured = process.env.CRM_APP_URL?.trim();
  const value =
    configured ||
    (() => {
      if (isProd()) {
        throw new Error(
          `CRM_APP_URL is required to send ${purpose} in production`,
        );
      }
      return "http://localhost:5173";
    })();

  let url: URL;
  try {
    url = new URL(value);
  } catch {
    throw new Error(
      `CRM_APP_URL must be an absolute HTTP(S) URL for ${purpose}`,
    );
  }
  if (
    (url.protocol !== "http:" && url.protocol !== "https:") ||
    !url.hostname ||
    url.username ||
    url.password
  ) {
    throw new Error(
      `CRM_APP_URL must be an absolute HTTP(S) URL for ${purpose}`,
    );
  }
  if (isProd() && url.protocol !== "https:") {
    throw new Error(`CRM_APP_URL must use HTTPS for ${purpose} in production`);
  }
  return url;
}

URL construction

Code

Routes retain the configured deployment path and query

The route is appended below the base pathname, configured query parameters survive, route-specific parameters override by name, and stale fragments are cleared.

Path-preserving CRM route constructionAfter
packages/api/src/lib/crm-app-url.ts · ff50240a2cc7 · lines 48-70
/**
 * Build a CRM route below the configured deployment path while preserving
 * configured query parameters. Route-specific parameters override same-named
 * base parameters.
 */
export function crmAppUrl(
  path: string,
  purpose: string,
  searchParams?: Readonly<Record<string, string>>,
): string {
  const url = crmAppBaseUrl(purpose);
  const basePath = url.pathname.replace(/\/+$/, "");
  url.pathname = `${basePath}/${path.replace(/^\/+/, "")}`;
  url.hash = "";

  if (searchParams) {
    for (const [name, value] of Object.entries(searchParams)) {
      url.searchParams.set(name, value);
    }
  }

  return url.toString();
}

Recipient isolation

Code

Rendering, action augmentation and provider submission share one recipient boundary

Rendering, action augmentation and provider submission are caught for the current attempt; line 686 closes the recipient loop so the next iteration remains reachable.

Complete per-recipient try and loop boundaryAfter
packages/api/src/lib/email/trigger-hook.ts · ff50240a2cc7 · lines 639-686
      for (const recipient of recipients) {
        // Await each send so serverless cold-shutdowns don't drop the outbound
        // HTTP call. One bad hook must not abort the rest.
        if (!emailService || !emailIdentity || !trackingBaseUrl) continue;
        try {
          const renderedContent = augmentAutomationEmailAction({
            html: renderHtmlTemplate(hook.htmlBody, allVars),
            text: hook.textBody
              ? renderTextTemplate(hook.textBody, allVars)
              : undefined,
            triggerEvent,
            sendToType: hook.sendToType,
            brand,
          });
          const email = {
            ...emailIdentity,
            to: recipient,
            subject: renderSubjectTemplate(hook.subject, allVars),
            html: renderedContent.html,
            text: renderedContent.text ?? undefined,
          };
          // Marketing automations get the same footer as campaigns: a
          // visible unsubscribe link + List-Unsubscribe headers and the
          // sender's postal address. Transactional sends omit it.
          const footer =
            hook.sendToType === "contact" && marketingFooter
              ? marketingFooter
              : null;
          await sendTrackedEmail({
            db,
            emailService,
            brandId,
            customerId:
              hook.sendToType === "contact" ? (contact?.id ?? null) : null,
            source: "automation",
            sourceId: hook.id,
            trackingBaseUrl,
            email,
            metadata: { triggerEvent, sendToType: hook.sendToType },
            unsubscribeUrl: footer?.unsubscribeUrl,
            postalAddress: footer?.postalAddress,
          });
          acceptedCount++;
        } catch (err) {
          hadRetryableFailure = true;
          console.error("[triggerEmailHooks] send failed:", err);
        }
      }

Confidence

Validation

Pass

CRM URL helper

11 passed, 0 failed, covering deployment prefixes, query merging, fragment clearing and unsafe configuration

Pass

Email action augmentation

46 passed, 0 failed, 84 assertions, including independent HTML/text deferred-review behavior

Pass

Trigger-hook recipient isolation

31 passed, 0 failed, 73 assertions, including malformed URL isolation

Pass

Real API route boundaries

Admin invite 17 pass, password reset 18 pass, sign-in 34 pass and support ticket 18 pass against local PostgreSQL where applicable

Pass

Worker notification boundaries

Franchise reconciliation 37 pass and trial expiry 22 pass, including path-preserving action URLs

Pass

Shift action link

crmAppUrl date-merge case passed; shift-notification email rendering 1 passed with 3 assertions; caller uses the helper with /shifts and date

Pass

API and worker typechecks

Both package typechecks exited successfully on the rebased candidate

Limits

Known issues, risks, and gaps

All known CRM email action callers were migrated and tested; external provider delivery was intentionally not performed

External provider delivery is represented by the existing service boundary The change is before the provider transport. Focused tests exercise rendering, recipient isolation and the provider call contract without sending real email.