improvement(email): reply-to help@sim.ai for lifecycle and billing emails (#5487)

* 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

* 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
This commit is contained in:
Waleed
2026-07-07 12:01:36 -07:00
committed by GitHub
parent a48ecd2538
commit f36461465b
6 changed files with 66 additions and 12 deletions
+3 -5
View File
@@ -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) {
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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<void
const alreadySubscribed = await isProPlan(userData.id)
if (alreadySubscribed) return
const { from, replyTo } = getPersonalEmailFrom()
const { from } = getPersonalEmailFrom()
const replyTo = getHelpEmailAddress()
const html = await renderAbandonedCheckoutEmail(userData.name || undefined)
await sendEmail({
@@ -94,6 +94,7 @@ vi.mock('@/lib/messaging/email/utils', () => ({
from: 'billing@sim.test',
replyTo: 'support@sim.test',
})),
getHelpEmailAddress: vi.fn(() => 'help@sim.test'),
}))
vi.mock('@/lib/messaging/email/validation', () => ({
@@ -109,6 +110,7 @@ import {
handleInvoicePaymentSucceeded,
resetUsageForSubscription,
} from '@/lib/billing/webhooks/invoices'
import { sendEmail } from '@/lib/messaging/email/mailer'
interface SelectResponse {
limitResult?: unknown
@@ -194,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: [
+3 -2
View File
@@ -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',
+9 -1
View File
@@ -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 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
@@ -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()}`
}