diff --git a/.github/workflows/build-ecr.yml b/.github/workflows/build-ecr.yml new file mode 100644 index 0000000000..59c9d60222 --- /dev/null +++ b/.github/workflows/build-ecr.yml @@ -0,0 +1,40 @@ +name: Build and Push to ECR + +on: + push: + branches: [main] + workflow_dispatch: + +env: + AWS_REGION: us-west-2 + ECR_REGISTRY: 310455165573.dkr.ecr.us-west-2.amazonaws.com + ECR_REPOSITORY: sim-app + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + uses: aws-actions/amazon-ecr-login@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: docker/app.Dockerfile + push: true + tags: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/apps/docs/content/docs/en/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/self-hosting/environment-variables.mdx index 327b0657a1..bd4c2f64e2 100644 --- a/apps/docs/content/docs/en/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/en/self-hosting/environment-variables.mdx @@ -67,6 +67,11 @@ import { Callout } from 'fumadocs-ui/components/callout' | `COPILOT_API_KEY` | API key for copilot features | | `ADMIN_API_KEY` | Admin API key for GitOps operations | | `RESEND_API_KEY` | Email service for notifications | +| `SMTP_HOST` | SMTP hostname for outgoing email | +| `SMTP_PORT` | SMTP port, usually `587` or `465` | +| `SMTP_SECURE` | SMTP security mode: `TLS`, `SSL`, or `None` | +| `SMTP_USERNAME` | SMTP username for authenticated relays | +| `SMTP_PASSWORD` | SMTP password for authenticated relays | | `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) | | `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) | | `DISABLE_REGISTRATION` | Set to `true` to disable new user signups | @@ -81,7 +86,14 @@ ENCRYPTION_KEY= INTERNAL_API_SECRET= NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com NEXT_PUBLIC_SOCKET_URL=https://sim.yourdomain.com +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_SECURE=TLS +SMTP_USERNAME=mailer +SMTP_PASSWORD=super-secret-password OPENAI_API_KEY=sk-... ``` See `apps/sim/.env.example` for all options. + +If you configure multiple email providers, Sim uses `Resend -> Azure Communication Services -> SMTP` in that order. diff --git a/apps/sim/.env.example b/apps/sim/.env.example index f8e926f885..4abb818e29 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -22,6 +22,16 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener # Email Provider (Optional) # RESEND_API_KEY= # Uncomment and add your key from https://resend.com to send actual emails # If left commented out, emails will be logged to console instead +# FROM_EMAIL_ADDRESS="Sim " +# EMAIL_DOMAIN=example.com + +# SMTP (Optional - alternative to Resend/Azure for self-hosting) +# If multiple providers are configured, Sim tries Resend first, then Azure, then SMTP. +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_SECURE=TLS # TLS (STARTTLS), SSL (implicit TLS, usually 465), or None +# SMTP_USERNAME=your_smtp_username +# SMTP_PASSWORD=your_smtp_password # Local AI Models (Optional) # OLLAMA_URL=http://localhost:11434 # URL for local Ollama server - uncomment if using local models diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index 8c003e0ada..9e4ca66003 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -1,4 +1,4 @@ -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted, isServerKeysEnabled } from '@/lib/core/config/feature-flags' import type { BlockOutput, OutputFieldDefinition, SubBlockConfig } from '@/blocks/types' import { getHostedModels, getProviderFromModel, providers } from '@/providers/utils' import { useProvidersStore } from '@/stores/providers/store' @@ -65,6 +65,14 @@ function shouldRequireApiKeyForModel(model: string): boolean { (hostedModel) => hostedModel.toLowerCase() === normalizedModel ) if (isHosted && isHostedModel) return false + if (isServerKeysEnabled && isHostedModel) { + try { + const providerId = getProviderFromModel(model) + if (providerId === 'anthropic') return false + } catch { + // fall through + } + } if (normalizedModel.startsWith('vertex/') || normalizedModel.startsWith('bedrock/')) { return false diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index 04a35adb42..54824b59f8 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -3,7 +3,7 @@ import { workspaceBYOKKeys } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { getRotatingApiKey } from '@/lib/core/config/api-keys' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted, isServerKeysEnabled } from '@/lib/core/config/feature-flags' import { decryptSecret } from '@/lib/core/security/encryption' import { getHostedModels } from '@/providers/models' import { useProvidersStore } from '@/stores/providers/store' @@ -80,9 +80,9 @@ export async function getApiKeyWithBYOK( const byokProviderId = isGeminiModel ? 'google' : (provider as BYOKProviderId) if ( - isHosted && - workspaceId && - (isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel) + ((isHosted && (isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel)) || + (isServerKeysEnabled && isClaudeModel)) && + workspaceId ) { const hostedModels = getHostedModels() const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase()) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 685cf0e9da..19bead5567 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -64,6 +64,11 @@ export const env = createEnv({ PERSONAL_EMAIL_FROM: z.string().min(1).optional(), // From address for personalized emails 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 + SMTP_HOST: z.string().min(1).optional(), // SMTP server hostname for self-hosted email delivery + SMTP_PORT: z.string().regex(/^\d+$/).optional(), // SMTP server port (e.g. 587, 465) + SMTP_SECURE: z.enum(['TLS', 'SSL', 'None']).optional(), // SMTP security mode (STARTTLS, implicit TLS, or plain) + SMTP_USERNAME: z.string().min(1).optional(), // SMTP username for authenticated relays + SMTP_PASSWORD: z.string().min(1).optional(), // SMTP password for authenticated relays // SMS & Messaging TWILIO_ACCOUNT_SID: z.string().min(1).optional(), // Twilio Account SID for SMS sending @@ -83,6 +88,7 @@ export const env = createEnv({ GEMINI_API_KEY_2: z.string().min(1).optional(), // Additional Gemini API key for load balancing GEMINI_API_KEY_3: z.string().min(1).optional(), // Additional Gemini API key for load balancing OLLAMA_URL: z.string().url().optional(), // Ollama local LLM server URL + ANTHROPIC_BASE_URL: z.string().url().optional(), // Custom Anthropic API base URL (e.g., for proxy endpoints) VLLM_BASE_URL: z.string().url().optional(), // vLLM self-hosted base URL (OpenAI-compatible) VLLM_API_KEY: z.string().optional(), // Optional bearer token for vLLM ELEVENLABS_API_KEY: z.string().min(1).optional(), // ElevenLabs API key for text-to-speech in deployed chat @@ -292,6 +298,9 @@ export const env = createEnv({ // Invitations - for self-hosted deployments DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments) + // Server-managed API Keys (for self-hosted deployments) + USE_SERVER_KEYS: z.boolean().optional(), // Use server-configured rotating API keys for all users (self-hosted) + // Development Tools REACT_GRAB_ENABLED: z.boolean().optional(), // Enable React Grab for UI element debugging in Cursor/AI agents (dev only) REACT_SCAN_ENABLED: z.boolean().optional(), // Enable React Scan for performance debugging (dev only) @@ -356,6 +365,7 @@ export const env = createEnv({ NEXT_PUBLIC_CUSTOM_CSS_URL: z.string().url().optional(), // Custom CSS stylesheet URL NEXT_PUBLIC_SUPPORT_EMAIL: z.string().email().optional(), // Custom support email + NEXT_PUBLIC_USE_SERVER_KEYS: z.boolean().optional(), // Client-side flag to hide API key fields when server keys are enabled NEXT_PUBLIC_E2B_ENABLED: z.string().optional(), NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: z.string().optional(), NEXT_PUBLIC_ENABLE_PLAYGROUND: z.string().optional(), // Enable component playground at /playground @@ -408,6 +418,7 @@ export const env = createEnv({ NEXT_PUBLIC_ORGANIZATIONS_ENABLED: process.env.NEXT_PUBLIC_ORGANIZATIONS_ENABLED, NEXT_PUBLIC_DISABLE_INVITATIONS: process.env.NEXT_PUBLIC_DISABLE_INVITATIONS, NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED, + NEXT_PUBLIC_USE_SERVER_KEYS: process.env.NEXT_PUBLIC_USE_SERVER_KEYS, NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED, NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: process.env.NEXT_PUBLIC_COPILOT_TRAINING_ENABLED, NEXT_PUBLIC_ENABLE_PLAYGROUND: process.env.NEXT_PUBLIC_ENABLE_PLAYGROUND, diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 9f746c5b12..a9657d0f20 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -25,6 +25,15 @@ export const isHosted = getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' || getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai' +/** + * Are server-managed API keys enabled for self-hosted deployments. + * When true, server-configured rotating API keys (e.g., ANTHROPIC_API_KEY_1) + * are used for all users without requiring UI input. + * This flag is blocked when isHosted is true (hosted environment manages its own keys). + */ +export const isServerKeysEnabled = + (isTruthy(env.USE_SERVER_KEYS) || isTruthy(getEnv('NEXT_PUBLIC_USE_SERVER_KEYS'))) && !isHosted + /** * Is billing enforcement enabled */ diff --git a/apps/sim/lib/messaging/email/mailer.test.ts b/apps/sim/lib/messaging/email/mailer.test.ts index c78855e6e9..26fed7b517 100644 --- a/apps/sim/lib/messaging/email/mailer.test.ts +++ b/apps/sim/lib/messaging/email/mailer.test.ts @@ -18,6 +18,7 @@ const mockSend = vi.fn() const mockBatchSend = vi.fn() const mockAzureBeginSend = vi.fn() const mockAzurePollUntilDone = vi.fn() +const mockSmtpSend = vi.fn() // Mock the Resend module - returns an object with emails.send vi.mock('resend', () => { @@ -42,6 +43,16 @@ vi.mock('@azure/communication-email', () => { } }) +vi.mock('nodemailer', () => { + return { + default: { + createTransport: vi.fn().mockImplementation(() => ({ + sendMail: (...args: any[]) => mockSmtpSend(...args), + })), + }, + } +}) + // Mock unsubscribe module vi.mock('@/lib/messaging/email/unsubscribe', () => ({ isUnsubscribed: vi.fn(), @@ -56,6 +67,11 @@ vi.mock('@/lib/core/config/env', () => AZURE_COMMUNICATION_EMAIL_DOMAIN: 'test.azurecomm.net', NEXT_PUBLIC_APP_URL: 'https://test.sim.ai', FROM_EMAIL_ADDRESS: 'Sim ', + SMTP_HOST: 'smtp.test.sim.ai', + SMTP_PORT: '587', + SMTP_SECURE: 'TLS', + SMTP_USERNAME: 'smtp-user', + SMTP_PASSWORD: 'smtp-password', }) ) @@ -82,6 +98,38 @@ import { } from '@/lib/messaging/email/mailer' import { generateUnsubscribeToken, isUnsubscribed } from '@/lib/messaging/email/unsubscribe' +async function loadMailerWithEnv(overrides: Record = {}) { + vi.resetModules() + + const dynamicLogger = createMockLogger() + + vi.doMock('@/lib/core/config/env', () => + createEnvMock({ + RESEND_API_KEY: 'test-api-key', + AZURE_ACS_CONNECTION_STRING: 'test-azure-connection-string', + NEXT_PUBLIC_APP_URL: 'https://test.sim.ai', + FROM_EMAIL_ADDRESS: 'Sim ', + SMTP_HOST: 'smtp.test.sim.ai', + SMTP_PORT: '587', + SMTP_SECURE: 'TLS', + SMTP_USERNAME: 'smtp-user', + SMTP_PASSWORD: 'smtp-password', + ...overrides, + }) + ) + + vi.doMock('@sim/logger', () => ({ + createLogger: () => dynamicLogger, + })) + + const mailerModule = await import('@/lib/messaging/email/mailer') + + return { + dynamicLogger, + ...mailerModule, + } +} + describe('mailer', () => { const testEmailOptions = { to: 'test@example.com', @@ -105,6 +153,10 @@ describe('mailer', () => { error: null, }) + mockSmtpSend.mockResolvedValue({ + messageId: 'smtp-message-id', + }) + // Mock successful Azure response mockAzurePollUntilDone.mockResolvedValue({ status: 'Succeeded', @@ -204,6 +256,29 @@ describe('mailer', () => { expect(result.success).toBe(false) expect(result.message).toBe('Failed to send email') }) + + it('should fall back to SMTP when Resend and Azure fail', async () => { + mockSend.mockRejectedValue(new Error('Resend unavailable')) + mockAzureBeginSend.mockImplementation(() => { + throw new Error('Azure unavailable') + }) + + const result = await sendEmail({ + ...testEmailOptions, + emailType: 'transactional', + }) + + expect(result.success).toBe(true) + expect(result.message).toBe('Email sent successfully via SMTP') + expect(mockSmtpSend).toHaveBeenCalledWith( + expect.objectContaining({ + from: 'Sim ', + to: 'test@example.com', + subject: 'Test Subject', + html: '

Test email content

', + }) + ) + }) }) describe('sendBatchEmails', () => { @@ -237,5 +312,72 @@ describe('mailer', () => { // Should not check unsubscribe for transactional emails expect(isUnsubscribed).not.toHaveBeenCalled() }) + + it('should fall back to SMTP during batch sends when Resend batch and Azure fail', async () => { + mockBatchSend.mockRejectedValue(new Error('Resend batch unavailable')) + mockSend.mockRejectedValue(new Error('Resend unavailable')) + mockAzureBeginSend.mockImplementation(() => { + throw new Error('Azure unavailable') + }) + + const result = await sendBatchEmails({ emails: testBatchEmails }) + + expect(result.success).toBe(true) + expect(mockSmtpSend).toHaveBeenCalledTimes(2) + }) + }) + + describe('provider configuration', () => { + it('should send with SMTP when SMTP is the only configured provider', async () => { + const { sendEmail: sendEmailWithSmtpOnly } = await loadMailerWithEnv({ + RESEND_API_KEY: undefined, + AZURE_ACS_CONNECTION_STRING: undefined, + }) + + const result = await sendEmailWithSmtpOnly({ + ...testEmailOptions, + emailType: 'transactional', + }) + + expect(result.success).toBe(true) + expect(result.message).toBe('Email sent successfully via SMTP') + expect(mockSend).not.toHaveBeenCalled() + expect(mockAzureBeginSend).not.toHaveBeenCalled() + expect(mockSmtpSend).toHaveBeenCalledTimes(1) + }) + + it('should ignore invalid SMTP ports', async () => { + const { dynamicLogger, hasEmailService: hasEmailServiceWithInvalidSmtp } = await loadMailerWithEnv( + { + RESEND_API_KEY: undefined, + AZURE_ACS_CONNECTION_STRING: undefined, + SMTP_PORT: '587tls', + } + ) + + expect(hasEmailServiceWithInvalidSmtp()).toBe(false) + expect(dynamicLogger.warn).toHaveBeenCalledWith( + 'SMTP configuration ignored because port is invalid', + { port: '587tls' } + ) + }) + + it('should warn when multiple providers are configured and prefer Resend first', async () => { + const { dynamicLogger, sendEmail: sendEmailWithMultipleProviders } = await loadMailerWithEnv() + + const result = await sendEmailWithMultipleProviders({ + ...testEmailOptions, + emailType: 'transactional', + }) + + expect(result.success).toBe(true) + expect(result.message).toBe('Email sent successfully via Resend') + expect(dynamicLogger.warn).toHaveBeenCalledWith( + 'Multiple email providers configured; earlier providers take precedence', + { providerOrder: ['Resend', 'Azure Communication Services', 'SMTP'] } + ) + expect(mockSend).toHaveBeenCalledTimes(1) + expect(mockSmtpSend).not.toHaveBeenCalled() + }) }) }) diff --git a/apps/sim/lib/messaging/email/mailer.ts b/apps/sim/lib/messaging/email/mailer.ts index 9d3c7b3337..054ce3961f 100644 --- a/apps/sim/lib/messaging/email/mailer.ts +++ b/apps/sim/lib/messaging/email/mailer.ts @@ -1,5 +1,6 @@ import { EmailClient, type EmailMessage } from '@azure/communication-email' import { createLogger } from '@sim/logger' +import nodemailer from 'nodemailer' import { Resend } from 'resend' import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -57,6 +58,21 @@ interface ProcessedEmailData { replyTo?: string } +type SmtpSecureMode = 'TLS' | 'SSL' | 'None' + +interface SmtpConfig { + host: string + port: number + secureMode: SmtpSecureMode + username?: string + password?: string +} + +interface EmailProvider { + name: string + send: (data: ProcessedEmailData) => Promise +} + const resendApiKey = env.RESEND_API_KEY const azureConnectionString = env.AZURE_ACS_CONNECTION_STRING @@ -70,11 +86,19 @@ const azureEmailClient = ? new EmailClient(azureConnectionString) : null +const smtpConfig = getSmtpConfig() + +const smtpTransporter = smtpConfig ? createSmtpTransporter(smtpConfig) : null + +const emailProviders = getEmailProviders() + +warnOnMultipleProviders(emailProviders) + /** * Check if any email service is configured and available */ export function hasEmailService(): boolean { - return !!(resend || azureEmailClient) + return emailProviders.length > 0 } export async function sendEmail(options: EmailOptions): Promise { @@ -99,35 +123,34 @@ export async function sendEmail(options: EmailOptions): Promise const processedData = await processEmailData(options) - if (resend) { - try { - return await sendWithResend(processedData) - } catch (error) { - logger.warn('Resend failed, attempting Azure Communication Services fallback:', error) + if (emailProviders.length === 0) { + logger.info('Email not sent (no email service configured):', { + to: options.to, + subject: options.subject, + from: processedData.senderEmail, + }) + return { + success: true, + message: 'Email logging successful (no email service configured)', + data: { id: 'mock-email-id' }, } } - if (azureEmailClient) { + const failedProviders: string[] = [] + + for (const provider of emailProviders) { try { - return await sendWithAzure(processedData) + return await provider.send(processedData) } catch (error) { - logger.error('Azure Communication Services also failed:', error) - return { - success: false, - message: 'Both Resend and Azure Communication Services failed', - } + failedProviders.push(provider.name) + logger.warn(`${provider.name} failed, attempting next email provider:`, error) } } - logger.info('Email not sent (no email service configured):', { - to: options.to, - subject: options.subject, - from: processedData.senderEmail, - }) + logger.error('All configured email providers failed:', { failedProviders }) return { - success: true, - message: 'Email logging successful (no email service configured)', - data: { id: 'mock-email-id' }, + success: false, + message: `${failedProviders.join(', ')} failed`, } } catch (error) { logger.error('Error sending email:', error) @@ -138,6 +161,133 @@ export async function sendEmail(options: EmailOptions): Promise } } +function getEmailProviders(): EmailProvider[] { + const providers: EmailProvider[] = [] + + if (resend) { + providers.push({ + name: 'Resend', + send: sendWithResend, + }) + } + + if (azureEmailClient) { + providers.push({ + name: 'Azure Communication Services', + send: sendWithAzure, + }) + } + + if (smtpTransporter) { + providers.push({ + name: 'SMTP', + send: sendWithSmtp, + }) + } + + return providers +} + +function warnOnMultipleProviders(providers: EmailProvider[]): void { + if (providers.length <= 1) { + return + } + + logger.warn('Multiple email providers configured; earlier providers take precedence', { + providerOrder: providers.map((provider) => provider.name), + }) +} + +function getSmtpConfig(): SmtpConfig | null { + const host = env.SMTP_HOST?.trim() + const portValue = env.SMTP_PORT?.trim() + const username = env.SMTP_USERNAME?.trim() + const password = env.SMTP_PASSWORD?.trim() + + if (!host && !portValue && !username && !password) { + return null + } + + if (!host || !portValue) { + logger.warn('SMTP configuration ignored because host or port is missing') + return null + } + + if (!/^\d+$/.test(portValue)) { + logger.warn('SMTP configuration ignored because port is invalid', { port: portValue }) + return null + } + + const port = Number(portValue) + if (port < 1 || port > 65535) { + logger.warn('SMTP configuration ignored because port is invalid', { port: portValue }) + return null + } + + if ((username && !password) || (!username && password)) { + logger.warn('SMTP configuration ignored because username/password are incomplete') + return null + } + + return { + host, + port, + secureMode: normalizeSmtpSecureMode(env.SMTP_SECURE, port), + username, + password, + } +} + +function normalizeSmtpSecureMode( + secureMode: string | undefined, + port: number +): SmtpSecureMode { + const normalized = secureMode?.trim().toUpperCase() + + if (normalized === 'TLS' || normalized === 'SSL') { + return normalized + } + + if (normalized === 'NONE') { + return 'None' + } + + if (port === 465) { + return 'SSL' + } + + if (port === 587) { + return 'TLS' + } + + return 'None' +} + +function createSmtpTransporter(config: SmtpConfig) { + const baseTransport = { + host: config.host, + port: config.port, + secure: config.secureMode === 'SSL', + requireTLS: config.secureMode === 'TLS', + ignoreTLS: config.secureMode === 'None', + tls: { + rejectUnauthorized: config.secureMode !== 'None', + }, + } + + if (config.username && config.password) { + return nodemailer.createTransport({ + ...baseTransport, + auth: { + user: config.username, + pass: config.password, + }, + }) + } + + return nodemailer.createTransport(baseTransport) +} + interface UnsubscribeData { headers: Record html?: string @@ -288,6 +438,37 @@ async function sendWithAzure(data: ProcessedEmailData): Promise throw new Error(`Azure Communication Services failed with status: ${result.status}`) } +async function sendWithSmtp(data: ProcessedEmailData): Promise { + if (!smtpTransporter) throw new Error('SMTP not configured') + + const mailOptions: nodemailer.SendMailOptions = { + from: data.senderEmail, + to: data.to, + subject: data.subject, + headers: Object.keys(data.headers).length > 0 ? data.headers : undefined, + replyTo: data.replyTo, + } + + if (data.html) mailOptions.html = data.html + if (data.text) mailOptions.text = data.text + if (data.attachments) { + mailOptions.attachments = data.attachments.map((attachment) => ({ + filename: attachment.filename, + content: attachment.content, + contentType: attachment.contentType, + contentDisposition: attachment.disposition || 'attachment', + })) + } + + const result = await smtpTransporter.sendMail(mailOptions) + + return { + success: true, + message: 'Email sent successfully via SMTP', + data: { id: result.messageId }, + } +} + export async function sendBatchEmails(options: BatchEmailOptions): Promise { try { const results: SendEmailResult[] = [] diff --git a/apps/sim/providers/anthropic/index.ts b/apps/sim/providers/anthropic/index.ts index 543c328fb1..a8ec6c62fb 100644 --- a/apps/sim/providers/anthropic/index.ts +++ b/apps/sim/providers/anthropic/index.ts @@ -1,6 +1,7 @@ import Anthropic from '@anthropic-ai/sdk' import { createLogger } from '@sim/logger' import type { StreamingExecution } from '@/executor/types' +import { env } from '@/lib/core/config/env' import { executeAnthropicProviderRequest } from '@/providers/anthropic/core' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types' @@ -24,6 +25,7 @@ export const anthropicProvider: ProviderConfig = { createClient: (apiKey, useNativeStructuredOutputs) => new Anthropic({ apiKey, + baseURL: env.ANTHROPIC_BASE_URL?.replace(/\/$/, '') || undefined, defaultHeaders: useNativeStructuredOutputs ? { 'anthropic-beta': 'structured-outputs-2025-11-13' } : undefined, diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 970915fb72..62490f7357 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -3,7 +3,7 @@ import type OpenAI from 'openai' import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' import { env } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isHosted, isServerKeysEnabled } from '@/lib/core/config/feature-flags' import { isCustomTool } from '@/executor/constants' import { getComputerUseModels, @@ -663,7 +663,7 @@ export function getApiKey(provider: string, model: string, userProvidedKey?: str const isClaudeModel = provider === 'anthropic' const isGeminiModel = provider === 'google' - if (isHosted && (isOpenAIModel || isClaudeModel || isGeminiModel)) { + if ((isHosted && (isOpenAIModel || isClaudeModel || isGeminiModel)) || (isServerKeysEnabled && isClaudeModel)) { const hostedModels = getHostedModels() const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase()) diff --git a/helm/sim/templates/deployment-app.yaml b/helm/sim/templates/deployment-app.yaml index 31be48aa37..0a445f892a 100644 --- a/helm/sim/templates/deployment-app.yaml +++ b/helm/sim/templates/deployment-app.yaml @@ -70,10 +70,12 @@ spec: value: {{ include "sim.socketServerUrl" . | quote }} - name: OLLAMA_URL value: {{ include "sim.ollamaUrl" . | quote }} - {{- range $key, $value := omit .Values.app.env "DATABASE_URL" "SOCKET_SERVER_URL" "OLLAMA_URL" }} + {{- range $key, $value := omit .Values.app.env "DATABASE_URL" "SOCKET_SERVER_URL" "OLLAMA_URL" "SMTP_USERNAME" "SMTP_PASSWORD" }} + {{- if not (kindIs "invalid" $value) }} - name: {{ $key }} value: {{ $value | quote }} {{- end }} + {{- end }} {{- if .Values.telemetry.enabled }} # OpenTelemetry configuration - name: OTEL_EXPORTER_OTLP_ENDPOINT @@ -140,4 +142,4 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} {{- end }} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/helm/sim/templates/external-secret-app.yaml b/helm/sim/templates/external-secret-app.yaml index 3377901fcc..885017cfe3 100644 --- a/helm/sim/templates/external-secret-app.yaml +++ b/helm/sim/templates/external-secret-app.yaml @@ -41,4 +41,14 @@ spec: remoteRef: key: {{ .Values.externalSecrets.remoteRefs.app.API_ENCRYPTION_KEY }} {{- end }} + {{- if .Values.externalSecrets.remoteRefs.app.SMTP_USERNAME }} + - secretKey: SMTP_USERNAME + remoteRef: + key: {{ .Values.externalSecrets.remoteRefs.app.SMTP_USERNAME }} + {{- end }} + {{- if .Values.externalSecrets.remoteRefs.app.SMTP_PASSWORD }} + - secretKey: SMTP_PASSWORD + remoteRef: + key: {{ .Values.externalSecrets.remoteRefs.app.SMTP_PASSWORD }} + {{- end }} {{- end }} diff --git a/helm/sim/templates/secrets-app.yaml b/helm/sim/templates/secrets-app.yaml index 29a9d065f2..b7c930da68 100644 --- a/helm/sim/templates/secrets-app.yaml +++ b/helm/sim/templates/secrets-app.yaml @@ -24,4 +24,10 @@ stringData: {{- if .Values.app.env.API_ENCRYPTION_KEY }} API_ENCRYPTION_KEY: {{ .Values.app.env.API_ENCRYPTION_KEY | quote }} {{- end }} + {{- if .Values.app.env.SMTP_USERNAME }} + SMTP_USERNAME: {{ .Values.app.env.SMTP_USERNAME | quote }} + {{- end }} + {{- if .Values.app.env.SMTP_PASSWORD }} + SMTP_PASSWORD: {{ .Values.app.env.SMTP_PASSWORD | quote }} + {{- end }} {{- end }} diff --git a/helm/sim/values.schema.json b/helm/sim/values.schema.json index 9eb8fe8ec9..2dc6671d88 100644 --- a/helm/sim/values.schema.json +++ b/helm/sim/values.schema.json @@ -156,6 +156,28 @@ "type": "string", "description": "Domain for sending emails (fallback when FROM_EMAIL_ADDRESS not set)" }, + "SMTP_HOST": { + "type": "string", + "description": "SMTP hostname for app-level outgoing email" + }, + "SMTP_PORT": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "SMTP port (e.g. 587 or 465)" + }, + "SMTP_SECURE": { + "type": "string", + "enum": ["TLS", "SSL", "None"], + "description": "SMTP security mode" + }, + "SMTP_USERNAME": { + "type": "string", + "description": "SMTP username for authenticated relays" + }, + "SMTP_PASSWORD": { + "type": "string", + "description": "SMTP password for authenticated relays" + }, "GOOGLE_CLIENT_ID": { "type": "string", "description": "Google OAuth client ID" diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index d5eecb51ec..92ab9e96c7 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -64,6 +64,8 @@ app: INTERNAL_API_SECRET: "INTERNAL_API_SECRET" CRON_SECRET: "CRON_SECRET" API_ENCRYPTION_KEY: "API_ENCRYPTION_KEY" + SMTP_USERNAME: "SMTP_USERNAME" + SMTP_PASSWORD: "SMTP_PASSWORD" # Environment variables env: @@ -100,6 +102,11 @@ app: RESEND_API_KEY: "" # Resend API key for transactional 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) + SMTP_HOST: "" # SMTP hostname for app-level outgoing email (alternative to Resend/Azure) + SMTP_PORT: "587" # SMTP port (587 for STARTTLS, 465 for SSL) + SMTP_SECURE: "TLS" # SMTP security mode: TLS, SSL, or None + SMTP_USERNAME: "" # SMTP username (stored in app secret when chart manages secrets) + SMTP_PASSWORD: "" # SMTP password (stored in app secret when chart manages secrets) # OAuth Integration Credentials (leave empty if not using) GOOGLE_CLIENT_ID: "" # Google OAuth client ID @@ -1214,6 +1221,10 @@ externalSecrets: CRON_SECRET: "" # Path to API_ENCRYPTION_KEY in external store (optional) API_ENCRYPTION_KEY: "" + # Path to SMTP_USERNAME in external store (optional) + SMTP_USERNAME: "" + # Path to SMTP_PASSWORD in external store (optional) + SMTP_PASSWORD: "" # PostgreSQL password (for internal PostgreSQL) postgresql: @@ -1272,4 +1283,4 @@ certManager: # CA ClusterIssuer configuration # This is the issuer that applications should reference for obtaining certificates caIssuer: - name: "sim-ca-issuer" \ No newline at end of file + name: "sim-ca-issuer" diff --git a/packages/testing/src/mocks/env.mock.ts b/packages/testing/src/mocks/env.mock.ts index f216721afa..08dafa9eed 100644 --- a/packages/testing/src/mocks/env.mock.ts +++ b/packages/testing/src/mocks/env.mock.ts @@ -16,6 +16,11 @@ export const defaultMockEnv = { FROM_EMAIL_ADDRESS: 'Sim ', EMAIL_DOMAIN: 'test.sim.ai', PERSONAL_EMAIL_FROM: 'Test ', + SMTP_HOST: 'smtp.test.sim.ai', + SMTP_PORT: '587', + SMTP_SECURE: 'TLS', + SMTP_USERNAME: 'smtp-user', + SMTP_PASSWORD: 'smtp-password', // URLs NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',