From 69b91570bbcfa7bd658b94619d10dc6a6b087362 Mon Sep 17 00:00:00 2001 From: claudio Date: Fri, 15 May 2026 00:29:35 +0100 Subject: [PATCH 1/4] feat(trust-portal): dedicated FROM env var for trust portal emails (#2851) Add RESEND_FROM_TRUST_PORTAL so Trust Portal access and NDA emails can ship from a brand-specific sender (e.g., noreply@mail.trust.inc), distinct from the generic system sender. triggerEmail() gets a new `trustPortal: true` flag that resolves the new env var, with graceful fallback to RESEND_FROM_SYSTEM so existing deploys keep working until the env var is set in Trigger.dev / Render. TrustEmailService (NDA signing, access granted, access reclaim, access request notification) now uses `trustPortal: true`. Co-authored-by: Claude Opus 4.7 (1M context) --- .env.example | 1 + apps/api/.env.example | 1 + apps/api/src/email/trigger-email.ts | 14 +++++++++----- apps/api/src/trust-portal/email.service.ts | 8 ++++---- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 227d7d00b2..773b5d9959 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ RESEND_API_KEY="" # API key from Resend for email authentication / invites RESEND_FROM_MARKETING="" RESEND_FROM_SYSTEM="" RESEND_FROM_DEFAULT="" +RESEND_FROM_TRUST_PORTAL="" # Sender for Trust Portal access/NDA emails (falls back to RESEND_FROM_SYSTEM) RESEND_TO_TEST="" RESEND_REPLY_TO_MARKETING="" REVALIDATION_SECRET="" # openssl rand -base64 32 diff --git a/apps/api/.env.example b/apps/api/.env.example index e2fea0ab10..9e8714d2b7 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -44,6 +44,7 @@ GROQ_API_KEY= RESEND_API_KEY= RESEND_FROM_SYSTEM= # e.g., noreply@mail.trycomp.ai RESEND_FROM_DEFAULT= # e.g., hello@mail.trycomp.ai +RESEND_FROM_TRUST_PORTAL= # e.g., Comp AI — sender for Trust Portal access/NDA emails (falls back to RESEND_FROM_SYSTEM) # Background checks BACKGROUND_CHECK_API_BASE_URL=https://glad-sturgeon-729.convex.site diff --git a/apps/api/src/email/trigger-email.ts b/apps/api/src/email/trigger-email.ts index 735c891825..ad9770e0ba 100644 --- a/apps/api/src/email/trigger-email.ts +++ b/apps/api/src/email/trigger-email.ts @@ -10,6 +10,7 @@ export async function triggerEmail(params: { react: ReactElement; marketing?: boolean; system?: boolean; + trustPortal?: boolean; cc?: string | string[]; scheduledAt?: string; attachments?: EmailAttachment[]; @@ -20,12 +21,15 @@ export async function triggerEmail(params: { const fromMarketing = process.env.RESEND_FROM_MARKETING; const fromSystem = process.env.RESEND_FROM_SYSTEM; const fromDefault = process.env.RESEND_FROM_DEFAULT; + const fromTrustPortal = process.env.RESEND_FROM_TRUST_PORTAL; - const fromAddress = params.marketing - ? fromMarketing - : params.system - ? fromSystem - : fromDefault; + const fromAddress = params.trustPortal + ? (fromTrustPortal ?? fromSystem) + : params.marketing + ? fromMarketing + : params.system + ? fromSystem + : fromDefault; const handle = await tasks.trigger('send-email', { to: params.to, diff --git a/apps/api/src/trust-portal/email.service.ts b/apps/api/src/trust-portal/email.service.ts index 89d6809ad0..123ac32b60 100644 --- a/apps/api/src/trust-portal/email.service.ts +++ b/apps/api/src/trust-portal/email.service.ts @@ -25,7 +25,7 @@ export class TrustEmailService { organizationName, ndaSigningLink, }), - system: true, + trustPortal: true, }); this.logger.log(`NDA signing email sent to ${toEmail} (ID: ${id})`); @@ -49,7 +49,7 @@ export class TrustEmailService { expiresAt, portalUrl, }), - system: true, + trustPortal: true, }); this.logger.log(`Access granted email sent to ${toEmail} (ID: ${id})`); @@ -73,7 +73,7 @@ export class TrustEmailService { accessLink, expiresAt, }), - system: true, + trustPortal: true, }); this.logger.log(`Access reclaim email sent to ${toEmail} (ID: ${id})`); @@ -115,7 +115,7 @@ export class TrustEmailService { requestedDurationDays, reviewUrl, }), - system: true, + trustPortal: true, }); this.logger.log( From d9cfd4a37b66beed3171338fd8f02c31fea0334c Mon Sep 17 00:00:00 2001 From: claudio Date: Fri, 15 May 2026 00:43:25 +0100 Subject: [PATCH 2/4] refactor(email): move FROM resolution into Trigger.dev task (#2853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously triggerEmail() resolved the FROM address on the API host (Render) by reading RESEND_FROM_* env vars and passing the literal address to the Trigger.dev task. This forced sender env vars to be duplicated across Render and Trigger.dev environments. Move resolution into the send-email task: - triggerEmail() now passes a channel ('marketing' | 'system' | 'trustPortal' | 'default') instead of a resolved address - send-email.ts reads RESEND_FROM_* on Trigger.dev's side and picks the right sender via a switch on channel - params.from is still honored as an explicit override - Unknown/missing channel falls through to the outer chain, preserving prior behavior for direct task callers (e.g., EmailController) Public API of triggerEmail() is unchanged — callers still pass the same marketing/system/trustPortal boolean flags. No call sites need to change. Net effect: Render no longer needs RESEND_FROM_* vars to be set — Trigger.dev becomes the single source of truth for email sender configuration. Co-authored-by: Claude Opus 4.7 (1M context) --- apps/api/src/email/trigger-email.ts | 30 +++++++++--------- apps/api/src/trigger/email/send-email.ts | 39 ++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/apps/api/src/email/trigger-email.ts b/apps/api/src/email/trigger-email.ts index ad9770e0ba..79866968e2 100644 --- a/apps/api/src/email/trigger-email.ts +++ b/apps/api/src/email/trigger-email.ts @@ -1,9 +1,22 @@ import { render } from '@react-email/render'; import { tasks } from '@trigger.dev/sdk'; import type { ReactElement } from 'react'; -import type { sendEmailTask } from '../trigger/email/send-email'; +import type { EmailChannel, sendEmailTask } from '../trigger/email/send-email'; import type { EmailAttachment } from './resend'; +type TriggerEmailFlags = { + marketing?: boolean; + system?: boolean; + trustPortal?: boolean; +}; + +function resolveChannel(flags: TriggerEmailFlags): EmailChannel { + if (flags.trustPortal) return 'trustPortal'; + if (flags.marketing) return 'marketing'; + if (flags.system) return 'system'; + return 'default'; +} + export async function triggerEmail(params: { to: string; subject: string; @@ -18,24 +31,13 @@ export async function triggerEmail(params: { try { const html = await render(params.react); - const fromMarketing = process.env.RESEND_FROM_MARKETING; - const fromSystem = process.env.RESEND_FROM_SYSTEM; - const fromDefault = process.env.RESEND_FROM_DEFAULT; - const fromTrustPortal = process.env.RESEND_FROM_TRUST_PORTAL; - - const fromAddress = params.trustPortal - ? (fromTrustPortal ?? fromSystem) - : params.marketing - ? fromMarketing - : params.system - ? fromSystem - : fromDefault; + const channel = resolveChannel(params); const handle = await tasks.trigger('send-email', { to: params.to, subject: params.subject, html, - from: fromAddress ?? undefined, + channel, cc: params.cc, scheduledAt: params.scheduledAt, attachments: params.attachments?.map((att) => ({ diff --git a/apps/api/src/trigger/email/send-email.ts b/apps/api/src/trigger/email/send-email.ts index 212e87f1d4..d01181cee2 100644 --- a/apps/api/src/trigger/email/send-email.ts +++ b/apps/api/src/trigger/email/send-email.ts @@ -8,6 +8,36 @@ const emailQueue = queue({ concurrencyLimit: 10, }); +export const emailChannelSchema = z.enum([ + 'marketing', + 'system', + 'trustPortal', + 'default', +]); +export type EmailChannel = z.infer; + +function resolveFromAddressForChannel( + channel: EmailChannel | undefined, +): string | undefined { + const fromMarketing = process.env.RESEND_FROM_MARKETING; + const fromSystem = process.env.RESEND_FROM_SYSTEM; + const fromDefault = process.env.RESEND_FROM_DEFAULT; + const fromTrustPortal = process.env.RESEND_FROM_TRUST_PORTAL; + + switch (channel) { + case 'trustPortal': + return fromTrustPortal ?? fromSystem; + case 'marketing': + return fromMarketing; + case 'system': + return fromSystem; + case 'default': + return fromDefault; + default: + return undefined; + } +} + export const sendEmailTask = schemaTask({ id: 'send-email', queue: emailQueue, @@ -18,6 +48,7 @@ export const sendEmailTask = schemaTask({ to: z.string(), subject: z.string(), html: z.string(), + channel: emailChannelSchema.optional(), from: z.string().optional(), cc: z.union([z.string(), z.array(z.string())]).optional(), scheduledAt: z.string().optional(), @@ -40,11 +71,15 @@ export const sendEmailTask = schemaTask({ throw new Error('Resend not initialized - missing API key'); } + const toTest = process.env.RESEND_TO_TEST; const fromSystem = process.env.RESEND_FROM_SYSTEM; const fromDefault = process.env.RESEND_FROM_DEFAULT; - const toTest = process.env.RESEND_TO_TEST; - const fromAddress = params.from ?? fromSystem ?? fromDefault; + const fromAddress = + params.from ?? + resolveFromAddressForChannel(params.channel) ?? + fromSystem ?? + fromDefault; const toAddress = toTest ?? params.to; if (!fromAddress) { From 9eb00a2bd9bcaaf815a0a349e02978c23e36319a Mon Sep 17 00:00:00 2001 From: claudio Date: Fri, 15 May 2026 00:47:14 +0100 Subject: [PATCH 3/4] refactor(email): emailcontroller send uses channel not env reads (#2855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the channel refactor — the internal /v1/internal/email/send endpoint was still reading RESEND_FROM_SYSTEM/RESEND_FROM_DEFAULT on the API host to compute the from address. With the rest of the pipeline now resolving on Trigger.dev, this controller is the last Render-side reader of those env vars in the email flow. Pass channel ('system' | 'default') based on dto.system instead. The Trigger.dev task resolves the env var on its side. dto.from is still honored as an explicit override via params.from. After this lands, Render genuinely doesn't need any RESEND_FROM_* var for the single-email path. send-batch-email still reads them on Render and is out of scope here. Co-authored-by: Claude Opus 4.7 (1M context) --- apps/api/src/email/email.controller.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/api/src/email/email.controller.ts b/apps/api/src/email/email.controller.ts index 7acb18aa71..2b15718d12 100644 --- a/apps/api/src/email/email.controller.ts +++ b/apps/api/src/email/email.controller.ts @@ -29,15 +29,12 @@ export class EmailController { }) @ApiResponse({ status: 200, description: 'Email task triggered' }) async sendEmail(@Body() dto: SendEmailDto) { - const fromAddress = dto.system - ? (process.env.RESEND_FROM_SYSTEM ?? process.env.RESEND_FROM_DEFAULT) - : (dto.from ?? process.env.RESEND_FROM_DEFAULT); - const handle = await tasks.trigger('send-email', { to: dto.to, subject: dto.subject, html: dto.html, - from: fromAddress, + from: dto.from, + channel: dto.system ? 'system' : 'default', cc: dto.cc, scheduledAt: dto.scheduledAt, attachments: dto.attachments, From d02874761a19bb430dc9c979fa803a7b2070603a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 19:49:36 -0400 Subject: [PATCH 4/4] [dev] [claudfuen] feat/trust-portal-email-from-resolution (#2854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(email): move FROM resolution into Trigger.dev task Previously triggerEmail() resolved the FROM address on the API host (Render) by reading RESEND_FROM_* env vars and passing the literal address to the Trigger.dev task. This forced sender env vars to be duplicated across Render and Trigger.dev environments. Move resolution into the send-email task: - triggerEmail() now passes a channel ('marketing' | 'system' | 'trustPortal' | 'default') instead of a resolved address - send-email.ts reads RESEND_FROM_* on Trigger.dev's side and picks the right sender via a switch on channel - params.from is still honored as an explicit override - Unknown/missing channel falls through to the outer chain, preserving prior behavior for direct task callers (e.g., EmailController) Public API of triggerEmail() is unchanged — callers still pass the same marketing/system/trustPortal boolean flags. No call sites need to change. Net effect: Render no longer needs RESEND_FROM_* vars to be set — Trigger.dev becomes the single source of truth for email sender configuration. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(email): emailcontroller send uses channel not env reads Follow-up to the channel refactor — the internal /v1/internal/email/send endpoint was still reading RESEND_FROM_SYSTEM/RESEND_FROM_DEFAULT on the API host to compute the from address. With the rest of the pipeline now resolving on Trigger.dev, this controller is the last Render-side reader of those env vars in the email flow. Pass channel ('system' | 'default') based on dto.system instead. The Trigger.dev task resolves the env var on its side. dto.from is still honored as an explicit override via params.from. After this lands, Render genuinely doesn't need any RESEND_FROM_* var for the single-email path. send-batch-email still reads them on Render and is out of scope here. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claudio Fuentes Co-authored-by: Claude Opus 4.7 (1M context)