mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user