Skip to content

Commit 35a37d8

Browse files
authored
fix(acs): added FROM_EMAIL_ADDRESS envvar for ACS (#1081)
* fix: clear Docker build cache to use correct Next.js version * fix(mailer): add FROM_EMAIL_ADDRESS envvar for ACS * bun.lock * added tests
1 parent 2b52d88 commit 35a37d8

File tree

11 files changed

+180
-23
lines changed

11 files changed

+180
-23
lines changed

apps/sim/app/api/help/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { z } from 'zod'
33
import { renderHelpConfirmationEmail } from '@/components/emails'
44
import { getSession } from '@/lib/auth'
55
import { sendEmail } from '@/lib/email/mailer'
6+
import { getFromEmailAddress } from '@/lib/email/utils'
67
import { env } from '@/lib/env'
78
import { createLogger } from '@/lib/logs/console/logger'
89
import { getEmailDomain } from '@/lib/urls/utils'
@@ -95,7 +96,7 @@ ${message}
9596
to: [`help@${env.EMAIL_DOMAIN || getEmailDomain()}`],
9697
subject: `[${type.toUpperCase()}] ${subject}`,
9798
text: emailText,
98-
from: `${env.SENDER_NAME || 'Sim'} <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
99+
from: getFromEmailAddress(),
99100
replyTo: email,
100101
emailType: 'transactional',
101102
attachments: images.map((image) => ({
@@ -125,7 +126,7 @@ ${message}
125126
to: [email],
126127
subject: `Your ${type} request has been received: ${subject}`,
127128
html: confirmationHtml,
128-
from: `${env.SENDER_NAME || 'Sim'} <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
129+
from: getFromEmailAddress(),
129130
replyTo: `help@${env.EMAIL_DOMAIN || getEmailDomain()}`,
130131
emailType: 'transactional',
131132
})

apps/sim/app/api/workspaces/invitations/route.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ describe('Workspace Invitations API Route', () => {
9191
env: {
9292
RESEND_API_KEY: 'test-resend-key',
9393
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
94+
FROM_EMAIL_ADDRESS: 'Sim <[email protected]>',
9495
EMAIL_DOMAIN: 'test.sim.ai',
9596
},
9697
}))

apps/sim/app/api/workspaces/invitations/route.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import { type NextRequest, NextResponse } from 'next/server'
55
import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
66
import { getSession } from '@/lib/auth'
77
import { sendEmail } from '@/lib/email/mailer'
8+
import { getFromEmailAddress } from '@/lib/email/utils'
89
import { env } from '@/lib/env'
910
import { createLogger } from '@/lib/logs/console/logger'
10-
import { getEmailDomain } from '@/lib/urls/utils'
1111
import { db } from '@/db'
1212
import {
1313
permissions,
@@ -240,8 +240,7 @@ async function sendInvitationEmail({
240240
})
241241
)
242242

243-
const emailDomain = env.EMAIL_DOMAIN || getEmailDomain()
244-
const fromAddress = `${env.SENDER_NAME || 'Sim'} <noreply@${emailDomain}>`
243+
const fromAddress = getFromEmailAddress()
245244

246245
logger.info(`Attempting to send email from ${fromAddress} to ${to}`)
247246

apps/sim/lib/auth.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ import {
2121
import { getBaseURL } from '@/lib/auth-client'
2222
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
2323
import { sendEmail } from '@/lib/email/mailer'
24+
import { getFromEmailAddress } from '@/lib/email/utils'
2425
import { quickValidateEmail } from '@/lib/email/validation'
2526
import { env, isTruthy } from '@/lib/env'
2627
import { isBillingEnabled, isProd } from '@/lib/environment'
2728
import { createLogger } from '@/lib/logs/console/logger'
28-
import { getEmailDomain } from '@/lib/urls/utils'
2929
import { db } from '@/db'
3030
import * as schema from '@/db/schema'
3131

@@ -153,7 +153,7 @@ export const auth = betterAuth({
153153
to: user.email,
154154
subject: getEmailSubject('reset-password'),
155155
html,
156-
from: `noreply@${env.EMAIL_DOMAIN || getEmailDomain()}`,
156+
from: getFromEmailAddress(),
157157
emailType: 'transactional',
158158
})
159159

@@ -244,7 +244,7 @@ export const auth = betterAuth({
244244
to: data.email,
245245
subject: getEmailSubject(data.type),
246246
html,
247-
from: `onboarding@${env.EMAIL_DOMAIN || getEmailDomain()}`,
247+
from: getFromEmailAddress(),
248248
emailType: 'transactional',
249249
})
250250

@@ -1446,7 +1446,7 @@ export const auth = betterAuth({
14461446
to: invitation.email,
14471447
subject: `${inviterName} has invited you to join ${organization.name} on Sim`,
14481448
html,
1449-
from: `noreply@${env.EMAIL_DOMAIN || getEmailDomain()}`,
1449+
from: getFromEmailAddress(),
14501450
emailType: 'transactional',
14511451
})
14521452

apps/sim/lib/email/mailer.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ vi.mock('@/lib/env', () => ({
3737
AZURE_ACS_CONNECTION_STRING: 'test-azure-connection-string',
3838
AZURE_COMMUNICATION_EMAIL_DOMAIN: 'test.azurecomm.net',
3939
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
40-
SENDER_NAME: 'Sim',
40+
FROM_EMAIL_ADDRESS: 'Sim <[email protected]>',
4141
},
4242
}))
4343

@@ -198,7 +198,7 @@ describe('mailer', () => {
198198

199199
expect(mockSend).toHaveBeenCalledWith(
200200
expect.objectContaining({
201-
from: 'Sim <[email protected]>',
201+
202202
})
203203
)
204204
})

apps/sim/lib/email/mailer.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { EmailClient, type EmailMessage } from '@azure/communication-email'
22
import { Resend } from 'resend'
33
import { generateUnsubscribeToken, isUnsubscribed } from '@/lib/email/unsubscribe'
4+
import { getFromEmailAddress } from '@/lib/email/utils'
45
import { env } from '@/lib/env'
56
import { createLogger } from '@/lib/logs/console/logger'
6-
import { getEmailDomain } from '@/lib/urls/utils'
77

88
const logger = createLogger('Mailer')
99

@@ -26,7 +26,7 @@ export interface EmailOptions {
2626
includeUnsubscribe?: boolean
2727
attachments?: EmailAttachment[]
2828
replyTo?: string
29-
useCustomFromFormat?: boolean // If true, uses "from" as-is; if false, uses "SENDER_NAME <from>" format
29+
useCustomFromFormat?: boolean // If true, uses "from" as-is; if false, uses default FROM_EMAIL_ADDRESS format
3030
}
3131

3232
export interface BatchEmailOptions {
@@ -152,7 +152,7 @@ async function processEmailData(options: EmailOptions): Promise<ProcessedEmailDa
152152
useCustomFromFormat = false,
153153
} = options
154154

155-
const senderEmail = from || `noreply@${env.EMAIL_DOMAIN || getEmailDomain()}`
155+
const senderEmail = from || getFromEmailAddress()
156156

157157
// Generate unsubscribe token and add to content
158158
let finalHtml = html
@@ -193,9 +193,7 @@ async function processEmailData(options: EmailOptions): Promise<ProcessedEmailDa
193193
async function sendWithResend(data: ProcessedEmailData): Promise<SendEmailResult> {
194194
if (!resend) throw new Error('Resend not configured')
195195

196-
const fromAddress = data.useCustomFromFormat
197-
? data.senderEmail
198-
: `${env.SENDER_NAME || 'Sim'} <${data.senderEmail}>`
196+
const fromAddress = data.useCustomFromFormat ? data.senderEmail : data.senderEmail
199197

200198
const emailData: any = {
201199
from: fromAddress,
@@ -327,9 +325,9 @@ async function sendBatchWithResend(emails: EmailOptions[]): Promise<BatchSendEma
327325

328326
const results: SendEmailResult[] = []
329327
const batchEmails = emails.map((email) => {
330-
const senderEmail = email.from || `noreply@${env.EMAIL_DOMAIN || getEmailDomain()}`
328+
const senderEmail = email.from || getFromEmailAddress()
331329
const emailData: any = {
332-
from: `${env.SENDER_NAME || 'Sim'} <${senderEmail}>`,
330+
from: senderEmail,
333331
to: email.to,
334332
subject: email.subject,
335333
}

apps/sim/lib/email/utils.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
// Mock the env module
4+
vi.mock('@/lib/env', () => ({
5+
env: {
6+
FROM_EMAIL_ADDRESS: undefined,
7+
EMAIL_DOMAIN: undefined,
8+
},
9+
}))
10+
11+
// Mock the getEmailDomain function
12+
vi.mock('@/lib/urls/utils', () => ({
13+
getEmailDomain: vi.fn().mockReturnValue('fallback.com'),
14+
}))
15+
16+
describe('getFromEmailAddress', () => {
17+
beforeEach(() => {
18+
// Reset mocks before each test
19+
vi.resetModules()
20+
})
21+
22+
it('should return FROM_EMAIL_ADDRESS when set', async () => {
23+
// Mock env with FROM_EMAIL_ADDRESS
24+
vi.doMock('@/lib/env', () => ({
25+
env: {
26+
FROM_EMAIL_ADDRESS: 'Sim <[email protected]>',
27+
EMAIL_DOMAIN: 'example.com',
28+
},
29+
}))
30+
31+
const { getFromEmailAddress } = await import('./utils')
32+
const result = getFromEmailAddress()
33+
34+
expect(result).toBe('Sim <[email protected]>')
35+
})
36+
37+
it('should return simple email format when FROM_EMAIL_ADDRESS is set without display name', async () => {
38+
vi.doMock('@/lib/env', () => ({
39+
env: {
40+
FROM_EMAIL_ADDRESS: '[email protected]',
41+
EMAIL_DOMAIN: 'example.com',
42+
},
43+
}))
44+
45+
const { getFromEmailAddress } = await import('./utils')
46+
const result = getFromEmailAddress()
47+
48+
expect(result).toBe('[email protected]')
49+
})
50+
51+
it('should return Azure ACS format when FROM_EMAIL_ADDRESS is set', async () => {
52+
vi.doMock('@/lib/env', () => ({
53+
env: {
54+
FROM_EMAIL_ADDRESS: '[email protected]',
55+
EMAIL_DOMAIN: 'example.com',
56+
},
57+
}))
58+
59+
const { getFromEmailAddress } = await import('./utils')
60+
const result = getFromEmailAddress()
61+
62+
expect(result).toBe('[email protected]')
63+
})
64+
65+
it('should construct from EMAIL_DOMAIN when FROM_EMAIL_ADDRESS is not set', async () => {
66+
vi.doMock('@/lib/env', () => ({
67+
env: {
68+
FROM_EMAIL_ADDRESS: undefined,
69+
EMAIL_DOMAIN: 'example.com',
70+
},
71+
}))
72+
73+
const { getFromEmailAddress } = await import('./utils')
74+
const result = getFromEmailAddress()
75+
76+
expect(result).toBe('[email protected]')
77+
})
78+
79+
it('should use getEmailDomain fallback when both FROM_EMAIL_ADDRESS and EMAIL_DOMAIN are not set', async () => {
80+
vi.doMock('@/lib/env', () => ({
81+
env: {
82+
FROM_EMAIL_ADDRESS: undefined,
83+
EMAIL_DOMAIN: undefined,
84+
},
85+
}))
86+
87+
const mockGetEmailDomain = vi.fn().mockReturnValue('fallback.com')
88+
vi.doMock('@/lib/urls/utils', () => ({
89+
getEmailDomain: mockGetEmailDomain,
90+
}))
91+
92+
const { getFromEmailAddress } = await import('./utils')
93+
const result = getFromEmailAddress()
94+
95+
expect(result).toBe('[email protected]')
96+
expect(mockGetEmailDomain).toHaveBeenCalled()
97+
})
98+
99+
it('should prioritize FROM_EMAIL_ADDRESS over EMAIL_DOMAIN when both are set', async () => {
100+
vi.doMock('@/lib/env', () => ({
101+
env: {
102+
FROM_EMAIL_ADDRESS: 'Custom <[email protected]>',
103+
EMAIL_DOMAIN: 'ignored.com',
104+
},
105+
}))
106+
107+
const { getFromEmailAddress } = await import('./utils')
108+
const result = getFromEmailAddress()
109+
110+
expect(result).toBe('Custom <[email protected]>')
111+
})
112+
113+
it('should handle empty string FROM_EMAIL_ADDRESS by falling back to EMAIL_DOMAIN', async () => {
114+
vi.doMock('@/lib/env', () => ({
115+
env: {
116+
FROM_EMAIL_ADDRESS: '',
117+
EMAIL_DOMAIN: 'fallback.com',
118+
},
119+
}))
120+
121+
const { getFromEmailAddress } = await import('./utils')
122+
const result = getFromEmailAddress()
123+
124+
expect(result).toBe('[email protected]')
125+
})
126+
127+
it('should handle whitespace-only FROM_EMAIL_ADDRESS by falling back to EMAIL_DOMAIN', async () => {
128+
vi.doMock('@/lib/env', () => ({
129+
env: {
130+
FROM_EMAIL_ADDRESS: ' ',
131+
EMAIL_DOMAIN: 'fallback.com',
132+
},
133+
}))
134+
135+
const { getFromEmailAddress } = await import('./utils')
136+
const result = getFromEmailAddress()
137+
138+
expect(result).toBe('[email protected]')
139+
})
140+
})

apps/sim/lib/email/utils.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { env } from '@/lib/env'
2+
import { getEmailDomain } from '@/lib/urls/utils'
3+
4+
/**
5+
* Get the from email address, preferring FROM_EMAIL_ADDRESS over EMAIL_DOMAIN
6+
*/
7+
export function getFromEmailAddress(): string {
8+
if (env.FROM_EMAIL_ADDRESS?.trim()) {
9+
return env.FROM_EMAIL_ADDRESS
10+
}
11+
// Fallback to constructing from EMAIL_DOMAIN
12+
return `noreply@${env.EMAIL_DOMAIN || getEmailDomain()}`
13+
}

apps/sim/lib/env.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ export const env = createEnv({
4949

5050
// Email & Communication
5151
RESEND_API_KEY: z.string().min(1).optional(), // Resend API key for transactional emails
52-
EMAIL_DOMAIN: z.string().min(1).optional(), // Domain for sending emails
53-
SENDER_NAME: z.string().optional(), // Name to use as email sender (e.g., "Sim" in "Sim <[email protected]>")
52+
FROM_EMAIL_ADDRESS: z.string().min(1).optional(), // Complete from address (e.g., "Sim <[email protected]>" or "[email protected]")
53+
EMAIL_DOMAIN: z.string().min(1).optional(), // Domain for sending emails (fallback when FROM_EMAIL_ADDRESS not set)
5454
AZURE_ACS_CONNECTION_STRING: z.string().optional(), // Azure Communication Services connection string
5555

5656
// AI/LLM Provider API Keys

helm/sim/values.schema.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,13 @@
127127
"type": "string",
128128
"description": "Resend API key for transactional emails"
129129
},
130+
"FROM_EMAIL_ADDRESS": {
131+
"type": "string",
132+
"description": "Complete from address (e.g., \"Sim <[email protected]>\" or \"[email protected]\")"
133+
},
130134
"EMAIL_DOMAIN": {
131135
"type": "string",
132-
"description": "Domain for sending emails"
136+
"description": "Domain for sending emails (fallback when FROM_EMAIL_ADDRESS not set)"
133137
},
134138
"GOOGLE_CLIENT_ID": {
135139
"type": "string",

0 commit comments

Comments
 (0)