mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e107363ea7 | ||
|
|
7e364a7977 | ||
|
|
35a37d8b45 | ||
|
|
2b52d88cee | ||
|
|
abad3620a3 | ||
|
|
a37c6bc812 | ||
|
|
cd1bd95952 | ||
|
|
4c9fdbe7fb |
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -251,7 +250,6 @@ async function sendInvitationEmail({
|
||||
html: emailHtml,
|
||||
from: fromAddress,
|
||||
emailType: 'transactional',
|
||||
useCustomFromFormat: true,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
|
||||
5
apps/sim/db/migrations/0077_missing_doc_processing.sql
Normal file
5
apps/sim/db/migrations/0077_missing_doc_processing.sql
Normal 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");
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
|
||||
@@ -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>'
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
140
apps/sim/lib/email/utils.test.ts
Normal file
140
apps/sim/lib/email/utils.test.ts
Normal 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
apps/sim/lib/email/utils.ts
Normal file
13
apps/sim/lib/email/utils.ts
Normal 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()}`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user