Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/build-ecr.yml
Original file line number Diff line number Diff line change
@@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ECR workflow exposes AWS account ID in public repo

Low Severity

The new build-ecr.yml workflow hardcodes the AWS account ID (310455165573) and ECR repository details directly in the file. This is unrelated to the SMTP feature described in the PR and exposes internal infrastructure details in a public repository. It also runs on every push to main, building and pushing a :latest tag without a commit SHA, which means image provenance is lost.

Fix in Cursor Fix in Web


jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Comment on lines +16 to +17
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 AWS account ID and ECR registry URL hardcoded

The ECR registry URL 310455165573.dkr.ecr.us-west-2.amazonaws.com exposes the AWS account ID directly in the repository's source history. It is safer to store this in a GitHub Actions secret (e.g., ${{ secrets.ECR_REGISTRY }}) so the account ID is not committed to version control.

Suggested change
steps:
- uses: actions/checkout@v4
ECR_REGISTRY: ${{ secrets.ECR_REGISTRY }}


- 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
12 changes: 12 additions & 0 deletions apps/docs/content/docs/en/self-hosting/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -81,7 +86,14 @@ ENCRYPTION_KEY=<openssl rand -hex 32>
INTERNAL_API_SECRET=<openssl rand -hex 32>
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.
10 changes: 10 additions & 0 deletions apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <noreply@example.com>"
# 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
Expand Down
10 changes: 9 additions & 1 deletion apps/sim/blocks/utils.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions apps/sim/lib/api-key/byok.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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())
Expand Down
11 changes: 11 additions & 0 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions apps/sim/lib/core/config/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
142 changes: 142 additions & 0 deletions apps/sim/lib/messaging/email/mailer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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(),
Expand All @@ -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 <noreply@sim.ai>',
SMTP_HOST: 'smtp.test.sim.ai',
SMTP_PORT: '587',
SMTP_SECURE: 'TLS',
SMTP_USERNAME: 'smtp-user',
SMTP_PASSWORD: 'smtp-password',
})
)

Expand All @@ -82,6 +98,38 @@ import {
} from '@/lib/messaging/email/mailer'
import { generateUnsubscribeToken, isUnsubscribed } from '@/lib/messaging/email/unsubscribe'

async function loadMailerWithEnv(overrides: Record<string, string | undefined> = {}) {
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 <noreply@sim.ai>',
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',
Expand All @@ -105,6 +153,10 @@ describe('mailer', () => {
error: null,
})

mockSmtpSend.mockResolvedValue({
messageId: 'smtp-message-id',
})

// Mock successful Azure response
mockAzurePollUntilDone.mockResolvedValue({
status: 'Succeeded',
Expand Down Expand Up @@ -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 <noreply@sim.ai>',
to: 'test@example.com',
subject: 'Test Subject',
html: '<p>Test email content</p>',
})
)
})
})

describe('sendBatchEmails', () => {
Expand Down Expand Up @@ -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()
})
})
})
Loading