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
This commit is contained in:
Waleed Latif
2025-08-21 11:57:44 -07:00
committed by GitHub
parent 2b52d88cee
commit 35a37d8b45
11 changed files with 180 additions and 23 deletions
+3 -2
View File
@@ -3,6 +3,7 @@ import { z } from 'zod'
import { renderHelpConfirmationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { sendEmail } from '@/lib/email/mailer'
import { getFromEmailAddress } from '@/lib/email/utils'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getEmailDomain } from '@/lib/urls/utils'
@@ -95,7 +96,7 @@ ${message}
to: [`help@${env.EMAIL_DOMAIN || getEmailDomain()}`],
subject: `[${type.toUpperCase()}] ${subject}`,
text: emailText,
from: `${env.SENDER_NAME || 'Sim'} <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
from: getFromEmailAddress(),
replyTo: email,
emailType: 'transactional',
attachments: images.map((image) => ({
@@ -125,7 +126,7 @@ ${message}
to: [email],
subject: `Your ${type} request has been received: ${subject}`,
html: confirmationHtml,
from: `${env.SENDER_NAME || 'Sim'} <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
from: getFromEmailAddress(),
replyTo: `help@${env.EMAIL_DOMAIN || getEmailDomain()}`,
emailType: 'transactional',
})
@@ -91,6 +91,7 @@ describe('Workspace Invitations API Route', () => {
env: {
RESEND_API_KEY: 'test-resend-key',
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
FROM_EMAIL_ADDRESS: 'Sim <noreply@test.sim.ai>',
EMAIL_DOMAIN: 'test.sim.ai',
},
}))
@@ -5,9 +5,9 @@ import { type NextRequest, NextResponse } from 'next/server'
import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
import { getSession } from '@/lib/auth'
import { sendEmail } from '@/lib/email/mailer'
import { getFromEmailAddress } from '@/lib/email/utils'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getEmailDomain } from '@/lib/urls/utils'
import { db } from '@/db'
import {
permissions,
@@ -240,8 +240,7 @@ async function sendInvitationEmail({
})
)
const emailDomain = env.EMAIL_DOMAIN || getEmailDomain()
const fromAddress = `${env.SENDER_NAME || 'Sim'} <noreply@${emailDomain}>`
const fromAddress = getFromEmailAddress()
logger.info(`Attempting to send email from ${fromAddress} to ${to}`)
+4 -4
View File
@@ -21,11 +21,11 @@ import {
import { getBaseURL } from '@/lib/auth-client'
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
import { sendEmail } from '@/lib/email/mailer'
import { getFromEmailAddress } from '@/lib/email/utils'
import { quickValidateEmail } from '@/lib/email/validation'
import { env, isTruthy } from '@/lib/env'
import { isBillingEnabled, isProd } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { getEmailDomain } from '@/lib/urls/utils'
import { db } from '@/db'
import * as schema from '@/db/schema'
@@ -153,7 +153,7 @@ export const auth = betterAuth({
to: user.email,
subject: getEmailSubject('reset-password'),
html,
from: `noreply@${env.EMAIL_DOMAIN || getEmailDomain()}`,
from: getFromEmailAddress(),
emailType: 'transactional',
})
@@ -244,7 +244,7 @@ export const auth = betterAuth({
to: data.email,
subject: getEmailSubject(data.type),
html,
from: `onboarding@${env.EMAIL_DOMAIN || getEmailDomain()}`,
from: getFromEmailAddress(),
emailType: 'transactional',
})
@@ -1446,7 +1446,7 @@ export const auth = betterAuth({
to: invitation.email,
subject: `${inviterName} has invited you to join ${organization.name} on Sim`,
html,
from: `noreply@${env.EMAIL_DOMAIN || getEmailDomain()}`,
from: getFromEmailAddress(),
emailType: 'transactional',
})
+2 -2
View File
@@ -37,7 +37,7 @@ vi.mock('@/lib/env', () => ({
AZURE_ACS_CONNECTION_STRING: 'test-azure-connection-string',
AZURE_COMMUNICATION_EMAIL_DOMAIN: 'test.azurecomm.net',
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
SENDER_NAME: 'Sim',
FROM_EMAIL_ADDRESS: 'Sim <noreply@sim.ai>',
},
}))
@@ -198,7 +198,7 @@ describe('mailer', () => {
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
from: 'Sim <custom@example.com>',
from: 'custom@example.com',
})
)
})
+6 -8
View File
@@ -1,9 +1,9 @@
import { EmailClient, type EmailMessage } from '@azure/communication-email'
import { Resend } from 'resend'
import { generateUnsubscribeToken, isUnsubscribed } from '@/lib/email/unsubscribe'
import { getFromEmailAddress } from '@/lib/email/utils'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getEmailDomain } from '@/lib/urls/utils'
const logger = createLogger('Mailer')
@@ -26,7 +26,7 @@ export interface EmailOptions {
includeUnsubscribe?: boolean
attachments?: EmailAttachment[]
replyTo?: string
useCustomFromFormat?: boolean // If true, uses "from" as-is; if false, uses "SENDER_NAME <from>" format
useCustomFromFormat?: boolean // If true, uses "from" as-is; if false, uses default FROM_EMAIL_ADDRESS format
}
export interface BatchEmailOptions {
@@ -152,7 +152,7 @@ async function processEmailData(options: EmailOptions): Promise<ProcessedEmailDa
useCustomFromFormat = false,
} = options
const senderEmail = from || `noreply@${env.EMAIL_DOMAIN || getEmailDomain()}`
const senderEmail = from || getFromEmailAddress()
// Generate unsubscribe token and add to content
let finalHtml = html
@@ -193,9 +193,7 @@ async function processEmailData(options: EmailOptions): Promise<ProcessedEmailDa
async function sendWithResend(data: ProcessedEmailData): Promise<SendEmailResult> {
if (!resend) throw new Error('Resend not configured')
const fromAddress = data.useCustomFromFormat
? data.senderEmail
: `${env.SENDER_NAME || 'Sim'} <${data.senderEmail}>`
const fromAddress = data.useCustomFromFormat ? data.senderEmail : data.senderEmail
const emailData: any = {
from: fromAddress,
@@ -327,9 +325,9 @@ async function sendBatchWithResend(emails: EmailOptions[]): Promise<BatchSendEma
const results: SendEmailResult[] = []
const batchEmails = emails.map((email) => {
const senderEmail = email.from || `noreply@${env.EMAIL_DOMAIN || getEmailDomain()}`
const senderEmail = email.from || getFromEmailAddress()
const emailData: any = {
from: `${env.SENDER_NAME || 'Sim'} <${senderEmail}>`,
from: senderEmail,
to: email.to,
subject: email.subject,
}
+140
View File
@@ -0,0 +1,140 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Mock the env module
vi.mock('@/lib/env', () => ({
env: {
FROM_EMAIL_ADDRESS: undefined,
EMAIL_DOMAIN: undefined,
},
}))
// Mock the getEmailDomain function
vi.mock('@/lib/urls/utils', () => ({
getEmailDomain: vi.fn().mockReturnValue('fallback.com'),
}))
describe('getFromEmailAddress', () => {
beforeEach(() => {
// Reset mocks before each test
vi.resetModules()
})
it('should return FROM_EMAIL_ADDRESS when set', async () => {
// Mock env with FROM_EMAIL_ADDRESS
vi.doMock('@/lib/env', () => ({
env: {
FROM_EMAIL_ADDRESS: 'Sim <noreply@sim.ai>',
EMAIL_DOMAIN: 'example.com',
},
}))
const { getFromEmailAddress } = await import('./utils')
const result = getFromEmailAddress()
expect(result).toBe('Sim <noreply@sim.ai>')
})
it('should return simple email format when FROM_EMAIL_ADDRESS is set without display name', async () => {
vi.doMock('@/lib/env', () => ({
env: {
FROM_EMAIL_ADDRESS: 'noreply@sim.ai',
EMAIL_DOMAIN: 'example.com',
},
}))
const { getFromEmailAddress } = await import('./utils')
const result = getFromEmailAddress()
expect(result).toBe('noreply@sim.ai')
})
it('should return Azure ACS format when FROM_EMAIL_ADDRESS is set', async () => {
vi.doMock('@/lib/env', () => ({
env: {
FROM_EMAIL_ADDRESS: 'DoNotReply@customer.azurecomm.net',
EMAIL_DOMAIN: 'example.com',
},
}))
const { getFromEmailAddress } = await import('./utils')
const result = getFromEmailAddress()
expect(result).toBe('DoNotReply@customer.azurecomm.net')
})
it('should construct from EMAIL_DOMAIN when FROM_EMAIL_ADDRESS is not set', async () => {
vi.doMock('@/lib/env', () => ({
env: {
FROM_EMAIL_ADDRESS: undefined,
EMAIL_DOMAIN: 'example.com',
},
}))
const { getFromEmailAddress } = await import('./utils')
const result = getFromEmailAddress()
expect(result).toBe('noreply@example.com')
})
it('should use getEmailDomain fallback when both FROM_EMAIL_ADDRESS and EMAIL_DOMAIN are not set', async () => {
vi.doMock('@/lib/env', () => ({
env: {
FROM_EMAIL_ADDRESS: undefined,
EMAIL_DOMAIN: undefined,
},
}))
const mockGetEmailDomain = vi.fn().mockReturnValue('fallback.com')
vi.doMock('@/lib/urls/utils', () => ({
getEmailDomain: mockGetEmailDomain,
}))
const { getFromEmailAddress } = await import('./utils')
const result = getFromEmailAddress()
expect(result).toBe('noreply@fallback.com')
expect(mockGetEmailDomain).toHaveBeenCalled()
})
it('should prioritize FROM_EMAIL_ADDRESS over EMAIL_DOMAIN when both are set', async () => {
vi.doMock('@/lib/env', () => ({
env: {
FROM_EMAIL_ADDRESS: 'Custom <custom@custom.com>',
EMAIL_DOMAIN: 'ignored.com',
},
}))
const { getFromEmailAddress } = await import('./utils')
const result = getFromEmailAddress()
expect(result).toBe('Custom <custom@custom.com>')
})
it('should handle empty string FROM_EMAIL_ADDRESS by falling back to EMAIL_DOMAIN', async () => {
vi.doMock('@/lib/env', () => ({
env: {
FROM_EMAIL_ADDRESS: '',
EMAIL_DOMAIN: 'fallback.com',
},
}))
const { getFromEmailAddress } = await import('./utils')
const result = getFromEmailAddress()
expect(result).toBe('noreply@fallback.com')
})
it('should handle whitespace-only FROM_EMAIL_ADDRESS by falling back to EMAIL_DOMAIN', async () => {
vi.doMock('@/lib/env', () => ({
env: {
FROM_EMAIL_ADDRESS: ' ',
EMAIL_DOMAIN: 'fallback.com',
},
}))
const { getFromEmailAddress } = await import('./utils')
const result = getFromEmailAddress()
expect(result).toBe('noreply@fallback.com')
})
})
+13
View File
@@ -0,0 +1,13 @@
import { env } from '@/lib/env'
import { getEmailDomain } from '@/lib/urls/utils'
/**
* Get the from email address, preferring FROM_EMAIL_ADDRESS over EMAIL_DOMAIN
*/
export function getFromEmailAddress(): string {
if (env.FROM_EMAIL_ADDRESS?.trim()) {
return env.FROM_EMAIL_ADDRESS
}
// Fallback to constructing from EMAIL_DOMAIN
return `noreply@${env.EMAIL_DOMAIN || getEmailDomain()}`
}
+2 -2
View File
@@ -49,8 +49,8 @@ export const env = createEnv({
// Email & Communication
RESEND_API_KEY: z.string().min(1).optional(), // Resend API key for transactional emails
EMAIL_DOMAIN: z.string().min(1).optional(), // Domain for sending emails
SENDER_NAME: z.string().optional(), // Name to use as email sender (e.g., "Sim" in "Sim <noreply@domain.com>")
FROM_EMAIL_ADDRESS: z.string().min(1).optional(), // Complete from address (e.g., "Sim <noreply@domain.com>" or "noreply@domain.com")
EMAIL_DOMAIN: z.string().min(1).optional(), // Domain for sending emails (fallback when FROM_EMAIL_ADDRESS not set)
AZURE_ACS_CONNECTION_STRING: z.string().optional(), // Azure Communication Services connection string
// AI/LLM Provider API Keys
+5 -1
View File
@@ -127,9 +127,13 @@
"type": "string",
"description": "Resend API key for transactional emails"
},
"FROM_EMAIL_ADDRESS": {
"type": "string",
"description": "Complete from address (e.g., \"Sim <noreply@domain.com>\" or \"DoNotReply@domain.com\")"
},
"EMAIL_DOMAIN": {
"type": "string",
"description": "Domain for sending emails"
"description": "Domain for sending emails (fallback when FROM_EMAIL_ADDRESS not set)"
},
"GOOGLE_CLIENT_ID": {
"type": "string",
+2 -1
View File
@@ -66,7 +66,8 @@ app:
# Email & Communication
RESEND_API_KEY: "" # Resend API key for transactional emails
EMAIL_DOMAIN: "" # Domain for sending emails
FROM_EMAIL_ADDRESS: "" # Complete from address (e.g., "Sim <noreply@domain.com>" or "DoNotReply@domain.com")
EMAIL_DOMAIN: "" # Domain for sending emails (fallback when FROM_EMAIL_ADDRESS not set)
# OAuth Integration Credentials (leave empty if not using)
GOOGLE_CLIENT_ID: "" # Google OAuth client ID