From 35a37d8b455d044d7fcb2c795465e563d1ea4172 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 21 Aug 2025 11:57:44 -0700 Subject: [PATCH] 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 --- apps/sim/app/api/help/route.ts | 5 +- .../api/workspaces/invitations/route.test.ts | 1 + .../app/api/workspaces/invitations/route.ts | 5 +- apps/sim/lib/auth.ts | 8 +- apps/sim/lib/email/mailer.test.ts | 4 +- apps/sim/lib/email/mailer.ts | 14 +- apps/sim/lib/email/utils.test.ts | 140 ++++++++++++++++++ apps/sim/lib/email/utils.ts | 13 ++ apps/sim/lib/env.ts | 4 +- helm/sim/values.schema.json | 6 +- helm/sim/values.yaml | 3 +- 11 files changed, 180 insertions(+), 23 deletions(-) create mode 100644 apps/sim/lib/email/utils.test.ts create mode 100644 apps/sim/lib/email/utils.ts diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts index 23d4ca4a0..1ed594fbb 100644 --- a/apps/sim/app/api/help/route.ts +++ b/apps/sim/app/api/help/route.ts @@ -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'} `, + 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'} `, + from: getFromEmailAddress(), replyTo: `help@${env.EMAIL_DOMAIN || getEmailDomain()}`, emailType: 'transactional', }) diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index 334733d42..e4eb4b030 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -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 ', EMAIL_DOMAIN: 'test.sim.ai', }, })) diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index aacd9a716..c13b80ee2 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -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'} ` + const fromAddress = getFromEmailAddress() logger.info(`Attempting to send email from ${fromAddress} to ${to}`) diff --git a/apps/sim/lib/auth.ts b/apps/sim/lib/auth.ts index 5bd24192a..1ec93b583 100644 --- a/apps/sim/lib/auth.ts +++ b/apps/sim/lib/auth.ts @@ -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', }) diff --git a/apps/sim/lib/email/mailer.test.ts b/apps/sim/lib/email/mailer.test.ts index b527910bc..44e0af837 100644 --- a/apps/sim/lib/email/mailer.test.ts +++ b/apps/sim/lib/email/mailer.test.ts @@ -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 ', }, })) @@ -198,7 +198,7 @@ describe('mailer', () => { expect(mockSend).toHaveBeenCalledWith( expect.objectContaining({ - from: 'Sim ', + from: 'custom@example.com', }) ) }) diff --git a/apps/sim/lib/email/mailer.ts b/apps/sim/lib/email/mailer.ts index 99ed58470..7917b381c 100644 --- a/apps/sim/lib/email/mailer.ts +++ b/apps/sim/lib/email/mailer.ts @@ -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 " 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 { 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 { - 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, } diff --git a/apps/sim/lib/email/utils.test.ts b/apps/sim/lib/email/utils.test.ts new file mode 100644 index 000000000..4937c4227 --- /dev/null +++ b/apps/sim/lib/email/utils.test.ts @@ -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 ', + EMAIL_DOMAIN: 'example.com', + }, + })) + + const { getFromEmailAddress } = await import('./utils') + const result = getFromEmailAddress() + + expect(result).toBe('Sim ') + }) + + 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 ', + EMAIL_DOMAIN: 'ignored.com', + }, + })) + + const { getFromEmailAddress } = await import('./utils') + const result = getFromEmailAddress() + + expect(result).toBe('Custom ') + }) + + 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') + }) +}) diff --git a/apps/sim/lib/email/utils.ts b/apps/sim/lib/email/utils.ts new file mode 100644 index 000000000..27f19c492 --- /dev/null +++ b/apps/sim/lib/email/utils.ts @@ -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()}` +} diff --git a/apps/sim/lib/env.ts b/apps/sim/lib/env.ts index 7cb8a7cb7..5128a8bd5 100644 --- a/apps/sim/lib/env.ts +++ b/apps/sim/lib/env.ts @@ -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 ") + FROM_EMAIL_ADDRESS: z.string().min(1).optional(), // Complete from address (e.g., "Sim " 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 diff --git a/helm/sim/values.schema.json b/helm/sim/values.schema.json index 7fd38d689..9ba6c1d40 100644 --- a/helm/sim/values.schema.json +++ b/helm/sim/values.schema.json @@ -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 \" 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", diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 494498397..680270a91 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -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 " 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