Skip to content

Commit e107363

Browse files
authored
v0.3.35: migrations, custom email address support
2 parents abad362 + 7e364a7 commit e107363

File tree

12 files changed

+184
-44
lines changed

12 files changed

+184
-44
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 & 4 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

@@ -251,7 +250,6 @@ async function sendInvitationEmail({
251250
html: emailHtml,
252251
from: fromAddress,
253252
emailType: 'transactional',
254-
useCustomFromFormat: true,
255253
})
256254

257255
if (result.success) {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
ALTER TABLE "document" ADD COLUMN IF NOT EXISTS "processing_status" text DEFAULT 'pending' NOT NULL;
2+
ALTER TABLE "document" ADD COLUMN IF NOT EXISTS "processing_started_at" timestamp;
3+
ALTER TABLE "document" ADD COLUMN IF NOT EXISTS "processing_completed_at" timestamp;
4+
ALTER TABLE "document" ADD COLUMN IF NOT EXISTS "processing_error" text;
5+
CREATE INDEX IF NOT EXISTS "doc_processing_status_idx" ON "document" USING btree ("knowledge_base_id","processing_status");

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 & 19 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
})
@@ -218,23 +218,6 @@ describe('mailer', () => {
218218
)
219219
})
220220

221-
it('should use custom from format when useCustomFromFormat is true', async () => {
222-
const result = await sendEmail({
223-
...testEmailOptions,
224-
from: 'Sim <[email protected]>',
225-
useCustomFromFormat: true,
226-
})
227-
228-
expect(result.success).toBe(true)
229-
230-
// Should call Resend with the exact from address provided (no modification)
231-
expect(mockSend).toHaveBeenCalledWith(
232-
expect.objectContaining({
233-
from: 'Sim <[email protected]>', // Uses custom format as-is
234-
})
235-
)
236-
})
237-
238221
it.concurrent('should replace unsubscribe token placeholders in HTML', async () => {
239222
const htmlWithPlaceholder = '<p>Content</p><a href="{{UNSUBSCRIBE_TOKEN}}">Unsubscribe</a>'
240223

apps/sim/lib/email/mailer.ts

Lines changed: 5 additions & 11 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,6 @@ 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
3029
}
3130

3231
export interface BatchEmailOptions {
@@ -55,7 +54,6 @@ interface ProcessedEmailData {
5554
headers: Record<string, string>
5655
attachments?: EmailAttachment[]
5756
replyTo?: string
58-
useCustomFromFormat: boolean
5957
}
6058

6159
const resendApiKey = env.RESEND_API_KEY
@@ -149,10 +147,9 @@ async function processEmailData(options: EmailOptions): Promise<ProcessedEmailDa
149147
includeUnsubscribe = true,
150148
attachments,
151149
replyTo,
152-
useCustomFromFormat = false,
153150
} = options
154151

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

157154
// Generate unsubscribe token and add to content
158155
let finalHtml = html
@@ -186,16 +183,13 @@ async function processEmailData(options: EmailOptions): Promise<ProcessedEmailDa
186183
headers,
187184
attachments,
188185
replyTo,
189-
useCustomFromFormat,
190186
}
191187
}
192188

193189
async function sendWithResend(data: ProcessedEmailData): Promise<SendEmailResult> {
194190
if (!resend) throw new Error('Resend not configured')
195191

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

200194
const emailData: any = {
201195
from: fromAddress,
@@ -327,9 +321,9 @@ async function sendBatchWithResend(emails: EmailOptions[]): Promise<BatchSendEma
327321

328322
const results: SendEmailResult[] = []
329323
const batchEmails = emails.map((email) => {
330-
const senderEmail = email.from || `noreply@${env.EMAIL_DOMAIN || getEmailDomain()}`
324+
const senderEmail = email.from || getFromEmailAddress()
331325
const emailData: any = {
332-
from: `${env.SENDER_NAME || 'Sim'} <${senderEmail}>`,
326+
from: senderEmail,
333327
to: email.to,
334328
subject: email.subject,
335329
}

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

0 commit comments

Comments
 (0)