From 6c5e70f1c95896f6409a132ff17c186349298998 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 7 Jul 2026 11:49:20 -0700 Subject: [PATCH 1/2] improvement(email): reply-to help@sim.ai for lifecycle and billing emails - onboarding follow-up (5-day), payment-failed, and abandoned-checkout emails now reply-to the shared help inbox instead of a personal address - added getHelpEmailAddress() and reused it in the help route to remove the duplicated inline expression --- apps/sim/app/api/help/route.ts | 8 +++----- apps/sim/background/lifecycle-email.ts | 5 +++-- apps/sim/lib/billing/webhooks/checkout.ts | 5 +++-- apps/sim/lib/billing/webhooks/invoices.test.ts | 1 + apps/sim/lib/billing/webhooks/invoices.ts | 5 +++-- apps/sim/lib/messaging/email/utils.ts | 10 +++++++++- 6 files changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts index 7e21fb2ee62..6fe478fe4b4 100644 --- a/apps/sim/app/api/help/route.ts +++ b/apps/sim/app/api/help/route.ts @@ -4,12 +4,10 @@ import { renderHelpConfirmationEmail } from '@/components/emails' import { helpFormBodySchema } from '@/lib/api/contracts/common' import { validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' -import { getEmailDomain } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' -import { getFromEmailAddress } from '@/lib/messaging/email/utils' +import { getFromEmailAddress, getHelpEmailAddress } from '@/lib/messaging/email/utils' const logger = createLogger('HelpAPI') @@ -86,7 +84,7 @@ ${message} } const emailResult = await sendEmail({ - to: [`help@${env.EMAIL_DOMAIN || getEmailDomain()}`], + to: [getHelpEmailAddress()], subject: `[${type.toUpperCase()}] ${subject}`, text: emailText, from: getFromEmailAddress(), @@ -118,7 +116,7 @@ ${message} subject: `Your ${type} request has been received: ${subject}`, html: confirmationHtml, from: getFromEmailAddress(), - replyTo: `help@${env.EMAIL_DOMAIN || getEmailDomain()}`, + replyTo: getHelpEmailAddress(), emailType: 'transactional', }) } catch (err) { diff --git a/apps/sim/background/lifecycle-email.ts b/apps/sim/background/lifecycle-email.ts index 37dff97f8d3..7179ff398d5 100644 --- a/apps/sim/background/lifecycle-email.ts +++ b/apps/sim/background/lifecycle-email.ts @@ -7,7 +7,7 @@ import { getEmailSubject, renderOnboardingFollowupEmail } from '@/components/ema import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils' import { sendEmail } from '@/lib/messaging/email/mailer' -import { getPersonalEmailFrom } from '@/lib/messaging/email/utils' +import { getHelpEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { LIFECYCLE_EMAIL_TASK_ID, type LifecycleEmailType } from '@/lib/messaging/lifecycle' const logger = createLogger('LifecycleEmail') @@ -31,7 +31,8 @@ async function sendLifecycleEmail({ userId, type }: LifecycleEmailParams): Promi return } - const { from, replyTo } = getPersonalEmailFrom() + const { from } = getPersonalEmailFrom() + const replyTo = getHelpEmailAddress() let html: string diff --git a/apps/sim/lib/billing/webhooks/checkout.ts b/apps/sim/lib/billing/webhooks/checkout.ts index 21f5a6a6e4b..a77f1689543 100644 --- a/apps/sim/lib/billing/webhooks/checkout.ts +++ b/apps/sim/lib/billing/webhooks/checkout.ts @@ -6,7 +6,7 @@ import type Stripe from 'stripe' import { getEmailSubject, renderAbandonedCheckoutEmail } from '@/components/emails' import { isProPlan } from '@/lib/billing/core/subscription' import { sendEmail } from '@/lib/messaging/email/mailer' -import { getPersonalEmailFrom } from '@/lib/messaging/email/utils' +import { getHelpEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' const logger = createLogger('CheckoutWebhooks') @@ -42,7 +42,8 @@ export async function handleAbandonedCheckout(event: Stripe.Event): Promise ({ from: 'billing@sim.test', replyTo: 'support@sim.test', })), + getHelpEmailAddress: vi.fn(() => 'help@sim.test'), })) vi.mock('@/lib/messaging/email/validation', () => ({ diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index a3e026f2006..846c9aec758 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -29,7 +29,7 @@ import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' import { stripeWebhookIdempotency } from '@/lib/billing/webhooks/idempotency' import { getBaseUrl } from '@/lib/core/utils/urls' import { sendEmail } from '@/lib/messaging/email/mailer' -import { getPersonalEmailFrom } from '@/lib/messaging/email/utils' +import { getHelpEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' const logger = createLogger('StripeInvoiceWebhooks') @@ -337,7 +337,8 @@ async function sendPaymentFailureEmails( }) ) - const { from, replyTo } = getPersonalEmailFrom() + const { from } = getPersonalEmailFrom() + const replyTo = getHelpEmailAddress() await sendEmail({ to: userToNotify.email, subject: 'Payment Failed - Action Required', diff --git a/apps/sim/lib/messaging/email/utils.ts b/apps/sim/lib/messaging/email/utils.ts index b7fa5060ab7..7fd935f4fc0 100644 --- a/apps/sim/lib/messaging/email/utils.ts +++ b/apps/sim/lib/messaging/email/utils.ts @@ -35,7 +35,8 @@ export function extractEmailFromAddress(fromAddress: string): string | undefined } /** - * Get the personal email from address and reply-to + * Get the personal email from address, for the "from" header only. + * Reply-to for these emails should come from `getHelpEmailAddress()`, not this value. */ export function getPersonalEmailFrom(): { from: string; replyTo: string | undefined } { const personalFrom = env.PERSONAL_EMAIL_FROM @@ -50,3 +51,10 @@ export function getPersonalEmailFrom(): { from: string; replyTo: string | undefi replyTo: undefined, } } + +/** + * Get the shared help inbox address, used as reply-to so replies reach the team rather than an individual + */ +export function getHelpEmailAddress(): string { + return `help@${env.EMAIL_DOMAIN || getEmailDomain()}` +} From de04785a8d575bc546e242fff05f79e8154fd0b3 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 7 Jul 2026 11:57:24 -0700 Subject: [PATCH 2/2] fix(email): address Greptile review feedback - add a sendEmail assertion locking in the payment-failure email's replyTo - clarify getPersonalEmailFrom() JSDoc so it doesn't overstate replyTo's scope --- .../sim/lib/billing/webhooks/invoices.test.ts | 44 +++++++++++++++++++ apps/sim/lib/messaging/email/utils.ts | 4 +- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/billing/webhooks/invoices.test.ts b/apps/sim/lib/billing/webhooks/invoices.test.ts index 0f4a319daa4..6dae02abb64 100644 --- a/apps/sim/lib/billing/webhooks/invoices.test.ts +++ b/apps/sim/lib/billing/webhooks/invoices.test.ts @@ -110,6 +110,7 @@ import { handleInvoicePaymentSucceeded, resetUsageForSubscription, } from '@/lib/billing/webhooks/invoices' +import { sendEmail } from '@/lib/messaging/email/mailer' interface SelectResponse { limitResult?: unknown @@ -195,6 +196,49 @@ describe('invoice billing recovery', () => { expect(mockUnblockOrgMembers).not.toHaveBeenCalled() }) + it('sends the payment-failure email with the shared help inbox as reply-to', async () => { + queueSelectResponse({ + limitResult: [ + { + id: 'sub-db-1', + plan: 'team_8000', + referenceId: 'org-1', + stripeSubscriptionId: 'sub_stripe_1', + }, + ], + }) + queueSelectResponse({ + whereResult: [{ userId: 'owner-1', role: 'owner' }], + }) + queueSelectResponse({ + whereResult: [{ email: 'owner@sim.test', name: 'Owner' }], + }) + + await handleInvoicePaymentFailed( + createInvoiceEvent('invoice.payment_failed', { + amount_due: 3582, + attempt_count: 1, + customer: 'cus_123', + customer_email: 'owner@sim.test', + hosted_invoice_url: 'https://stripe.test/invoices/in_123', + id: 'in_123', + metadata: { + billingPeriod: '2026-04', + subscriptionId: 'sub_stripe_1', + type: 'overage_threshold_billing_org', + }, + }) + ) + + expect(sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'owner@sim.test', + from: 'billing@sim.test', + replyTo: 'help@sim.test', + }) + ) + }) + it('unblocks org members when the matching metadata-backed invoice payment succeeds', async () => { queueSelectResponse({ limitResult: [ diff --git a/apps/sim/lib/messaging/email/utils.ts b/apps/sim/lib/messaging/email/utils.ts index 7fd935f4fc0..7c170e3f191 100644 --- a/apps/sim/lib/messaging/email/utils.ts +++ b/apps/sim/lib/messaging/email/utils.ts @@ -35,8 +35,8 @@ export function extractEmailFromAddress(fromAddress: string): string | undefined } /** - * Get the personal email from address, for the "from" header only. - * Reply-to for these emails should come from `getHelpEmailAddress()`, not this value. + * Get the personal email from address and reply-to. Lifecycle and billing notification + * emails should use `getHelpEmailAddress()` for reply-to instead of the value returned here. */ export function getPersonalEmailFrom(): { from: string; replyTo: string | undefined } { const personalFrom = env.PERSONAL_EMAIL_FROM