Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions apps/sim/app/api/help/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 3 additions & 2 deletions apps/sim/background/lifecycle-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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

Expand Down
5 changes: 3 additions & 2 deletions apps/sim/lib/billing/webhooks/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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({
Expand Down
45 changes: 45 additions & 0 deletions apps/sim/lib/billing/webhooks/invoices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -109,6 +110,7 @@ import {
handleInvoicePaymentSucceeded,
resetUsageForSubscription,
} from '@/lib/billing/webhooks/invoices'
import { sendEmail } from '@/lib/messaging/email/mailer'

interface SelectResponse {
limitResult?: unknown
Expand Down Expand Up @@ -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: [
Expand Down
5 changes: 3 additions & 2 deletions apps/sim/lib/billing/webhooks/invoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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',
Expand Down
10 changes: 9 additions & 1 deletion apps/sim/lib/messaging/email/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Comment thread
greptile-apps[bot] marked this conversation as resolved.
export function getPersonalEmailFrom(): { from: string; replyTo: string | undefined } {
const personalFrom = env.PERSONAL_EMAIL_FROM
Expand All @@ -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()}`
}
Loading