Compare commits

...

8 Commits

Author SHA1 Message Date
Waleed Latif
e107363ea7 v0.3.35: migrations, custom email address support 2025-08-21 12:36:51 -07:00
Waleed Latif
7e364a7977 fix(emails): remove unused useCustomFromFormat param (#1082)
* fix(mailer): remove unused useCustomFormat

* bun.lock changes
2025-08-21 12:09:03 -07:00
Waleed Latif
35a37d8b45 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
2025-08-21 11:57:44 -07:00
Vikhyath Mondreti
2b52d88cee fix(migrations): add missing migration for document table (#1080)
* fix(migrations): add missing migration for document table

* add newline at end of file
2025-08-21 11:48:54 -07:00
Waleed Latif
abad3620a3 fix(build): clear docker build cache to use correct Next.js version 2025-08-21 01:43:45 -07:00
Waleed Latif
a37c6bc812 fix(build): clear docker build cache to use correct Next.js version (#1075)
* fix: clear Docker build cache to use correct Next.js version

- Changed GitHub Actions cache scope from build-v2 to build-v3
- This should force a fresh build without cached Next.js 15.5.0 layers
- Reverted to ^15.3.2 version format that worked on main branch

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* run install

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-21 01:38:47 -07:00
Waleed Latif
cd1bd95952 fix(nextjs): downgrade nextjs due to known issue with bun commonjs module bundling (#1073) 2025-08-21 01:24:06 -07:00
Waleed Latif
4c9fdbe7fb fix(nextjs): downgrade nextjs due to known issue with bun commonjs module bundling (#1073) 2025-08-21 01:23:10 -07:00
16 changed files with 579 additions and 417 deletions

View File

@@ -85,8 +85,8 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=build-v2
cache-to: type=gha,mode=max,scope=build-v2
cache-from: type=gha,scope=build-v3
cache-to: type=gha,mode=max,scope=build-v3
provenance: false
sbom: false

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',
})

View File

@@ -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',
},
}))

View File

@@ -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}`)
@@ -251,7 +250,6 @@ async function sendInvitationEmail({
html: emailHtml,
from: fromAddress,
emailType: 'transactional',
useCustomFromFormat: true,
})
if (result.success) {

View File

@@ -0,0 +1,5 @@
ALTER TABLE "document" ADD COLUMN IF NOT EXISTS "processing_status" text DEFAULT 'pending' NOT NULL;
ALTER TABLE "document" ADD COLUMN IF NOT EXISTS "processing_started_at" timestamp;
ALTER TABLE "document" ADD COLUMN IF NOT EXISTS "processing_completed_at" timestamp;
ALTER TABLE "document" ADD COLUMN IF NOT EXISTS "processing_error" text;
CREATE INDEX IF NOT EXISTS "doc_processing_status_idx" ON "document" USING btree ("knowledge_base_id","processing_status");

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',
})

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',
})
)
})
@@ -218,23 +218,6 @@ describe('mailer', () => {
)
})
it('should use custom from format when useCustomFromFormat is true', async () => {
const result = await sendEmail({
...testEmailOptions,
from: 'Sim <noreply@sim.ai>',
useCustomFromFormat: true,
})
expect(result.success).toBe(true)
// Should call Resend with the exact from address provided (no modification)
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
from: 'Sim <noreply@sim.ai>', // Uses custom format as-is
})
)
})
it.concurrent('should replace unsubscribe token placeholders in HTML', async () => {
const htmlWithPlaceholder = '<p>Content</p><a href="{{UNSUBSCRIBE_TOKEN}}">Unsubscribe</a>'

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,6 @@ export interface EmailOptions {
includeUnsubscribe?: boolean
attachments?: EmailAttachment[]
replyTo?: string
useCustomFromFormat?: boolean // If true, uses "from" as-is; if false, uses "SENDER_NAME <from>" format
}
export interface BatchEmailOptions {
@@ -55,7 +54,6 @@ interface ProcessedEmailData {
headers: Record<string, string>
attachments?: EmailAttachment[]
replyTo?: string
useCustomFromFormat: boolean
}
const resendApiKey = env.RESEND_API_KEY
@@ -149,10 +147,9 @@ async function processEmailData(options: EmailOptions): Promise<ProcessedEmailDa
includeUnsubscribe = true,
attachments,
replyTo,
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
@@ -186,16 +183,13 @@ async function processEmailData(options: EmailOptions): Promise<ProcessedEmailDa
headers,
attachments,
replyTo,
useCustomFromFormat,
}
}
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.senderEmail
const emailData: any = {
from: fromAddress,
@@ -327,9 +321,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,
}

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')
})
})

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()}`
}

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

View File

@@ -7,7 +7,6 @@ import { getMainCSPPolicy, getWorkflowExecutionCSPPolicy } from './lib/security/
const nextConfig: NextConfig = {
devIndicators: false,
productionBrowserSourceMaps: false,
images: {
remotePatterns: [
{
@@ -59,6 +58,7 @@ const nextConfig: NextConfig = {
},
experimental: {
optimizeCss: true,
turbopackSourceMaps: false,
},
...(isDev && {
allowedDevOrigins: [

View File

@@ -96,7 +96,7 @@
"lenis": "^1.2.3",
"lucide-react": "^0.479.0",
"mammoth": "^1.9.0",
"next": "15.5.0",
"next": "^15.3.2",
"next-runtime-env": "3.3.0",
"next-themes": "^0.4.6",
"openai": "^4.91.1",

760
bun.lock

File diff suppressed because it is too large Load Diff

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",

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