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.
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 claim
Evidence
Status
A failure inside one recipient attempt is caught before the loop advances
Trigger-hook test starts with invalid shared CRM configuration, repairs it after the first logged failure, and confirms the later recipient is attempted and sent
Covered
CRM email routes preserve configured prefixes, base query and tenant routing
crmAppUrl unit matrix plus invite, password-reset, sign-in, support-ticket and worker route tests
Covered
A deferred review link suppresses a generic account CTA in both MIME parts
action-link tests cover HTML and plain text independently, including an HTML-only review link; worker tests separately verify their own CRM action URLs
Covered
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 component
Observed candidate behavior
Coverage
Path prefix /deploy/crm
Action route is appended under /deploy/crm
crm-app-url.test.ts and route integrations
Base query tenant=alpha
Tenant query remains on every generated action
crm-app-url.test.ts and invite/reset route tests
Route-specific query values
Parameters such as shift date are merged without losing base query values
crm-app-url.test.ts
Configured fragment
Cleared before a route-owned one-time token is added
crm-app-url.test.ts plus invite/reset/sign-in integrations
Invalid or insecure production configuration
Missing URL, non-HTTP(S), credentials, and production HTTP fail closed
crm-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.
Scenario
Expected result
Observed result
First recipient attempt sees invalid shared CRM configuration
Record one failure and continue
First failure logged; test repairs configuration; second recipient sent
One provider submission fails
Do not abort unrelated recipients
Existing provider isolation suite remains green
No recipients accept the notification
Return the typed skipped result
Typed 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.
Flow
Route owner
Verification
Admin invite
crmAppUrl
Real database invite acceptance flow
Admin password reset
crmAppUrl
Real database reset request and completion flow
Admin sign-in
crmAppUrl
Real database sign-in email flow
Support ticket
crmAppUrl
Focused route suite
Shift notification
crmAppUrl with route-specific date
Caller inspection, helper date-merge test and focused email rendering test
Trial expiry and franchise reconciliation
crmAppUrl
Focused worker suites
General trigger-hook open action
crmAppUrl
Focused 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.
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.
/**
* 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.
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.