Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ By default Sim writes uploads to local disk. For production, point it at AWS S3,

## Email Providers

Configure one provider — the mailer auto-detects in priority order: **Resend → AWS SES → SMTP → Azure Communication Services**. If none are configured, emails are logged to the console instead.
Configure one provider — the mailer auto-detects in priority order: **Resend → AWS SES → SMTP → Azure Communication Services → Gmail**. If none are configured, emails are logged to the console instead.

| Variable | Description |
|----------|-------------|
Expand Down Expand Up @@ -124,6 +124,15 @@ Configure one provider — the mailer auto-detects in priority order: **Resend
|----------|-------------|
| `AZURE_ACS_CONNECTION_STRING` | Azure Communication Services connection string |

**Gmail** (Google-native — GCP has no first-party transactional email service, so the native path is the Gmail API with a Google Workspace sender)

| Variable | Description |
|----------|-------------|
| `GMAIL_CREDENTIALS_JSON` | Inline service-account JSON. The service account needs [domain-wide delegation](https://developers.google.com/workspace/guides/create-credentials#delegate_domain-wide_authority_to_a_service_account) granted for the `https://www.googleapis.com/auth/gmail.send` scope in the Workspace admin console |
| `GMAIL_SENDER` | The Workspace user the service account impersonates when sending (e.g. `noreply@yourdomain.com`). `FROM_EMAIL_ADDRESS` should match this user or one of its registered aliases — Gmail rewrites unrecognized From addresses |

Alternatively, the [Google Workspace SMTP relay](https://support.google.com/a/answer/2956491) works through the generic SMTP provider (`SMTP_HOST=smtp-relay.gmail.com`, port `587`) with no service account required.

## Limits

Self-hosted deployments (billing disabled) run without plan limits: no rate limits, no execution timeouts, no table or storage caps, and no retention-based data deletion. Each limit can be opted back in individually by explicitly setting its variable.
Expand Down
9 changes: 7 additions & 2 deletions apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener

# Email Provider (Optional)
# Configure ONE provider — the mailer auto-detects in priority order:
# Resend → AWS SES → SMTP → Azure Communication Services. If none are
# configured, emails are logged to console instead.
# Resend → AWS SES → SMTP → Azure Communication Services → Gmail. If none
# are configured, emails are logged to console instead.
#
# Resend
# RESEND_API_KEY= # API key from https://resend.com
Expand All @@ -47,6 +47,11 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
# Azure Communication Services
# AZURE_ACS_CONNECTION_STRING=
#
# Gmail API (Google-native — service account with domain-wide delegation for
# the gmail.send scope; the Google Workspace SMTP relay also works via SMTP_HOST)
# GMAIL_CREDENTIALS_JSON= # Inline service-account JSON with domain-wide delegation
# GMAIL_SENDER=noreply@yourdomain.com # Workspace user the service account impersonates
#
# Shared sender configuration
# FROM_EMAIL_ADDRESS="Sim <noreply@example.com>"
# EMAIL_DOMAIN=example.com # Fallback when FROM_EMAIL_ADDRESS is unset
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ export const env = createEnv({
SMTP_USER: z.string().min(1).optional(), // SMTP username
SMTP_PASS: z.string().min(1).optional(), // SMTP password
SMTP_SECURE: z.boolean().optional(), // Force TLS on connect (defaults to true on port 465); read via envBoolean to handle string values from process.env
GMAIL_CREDENTIALS_JSON: z.string().optional(), // Inline Google service-account JSON with domain-wide delegation for the Gmail API mail provider
GMAIL_SENDER: z.string().min(1).optional(), // Google Workspace user the Gmail service account impersonates when sending (e.g., noreply@yourdomain.com)

// SMS & Messaging
TWILIO_ACCOUNT_SID: z.string().min(1).optional(), // Twilio Account SID for SMS sending
Expand Down
228 changes: 228 additions & 0 deletions apps/sim/lib/messaging/email/providers/gmail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
/**
* Tests for the Gmail API mail provider
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockJwtConstructor, mockGetAccessToken, mockEnv } = vi.hoisted(() => {
const mockGetAccessToken = vi.fn()
const jwtInstance = { getAccessToken: mockGetAccessToken }
const mockEnv: Record<string, string | undefined> = {}
return {
mockJwtConstructor: vi.fn().mockImplementation(
class {
constructor() {
// biome-ignore lint/correctness/noConstructorReturn: vitest constructs mocks via Reflect.construct; returning the object overrides the instance so `new JWT()` yields the shared mock the tests assert on
return jwtInstance
}
}
),
mockGetAccessToken,
mockEnv,
}
})

vi.mock('google-auth-library', () => ({
JWT: mockJwtConstructor,
}))

vi.mock('@/lib/core/config/env', () => ({
env: mockEnv,
getEnv: (key: string) => mockEnv[key],
}))

import { createGmailProvider } from '@/lib/messaging/email/providers/gmail'
import type { ProcessedEmailData } from '@/lib/messaging/email/types'

const VALID_CREDENTIALS = JSON.stringify({
client_email: 'mailer@my-project.iam.gserviceaccount.com',
private_key: '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n',
})

const BASE_DATA: ProcessedEmailData = {
to: 'user@example.com',
subject: 'Welcome to Sim',
html: '<p>Hello</p>',
senderEmail: 'Sim <noreply@sim.example>',
headers: {},
}

const mockFetch = vi.fn()

describe('Gmail mail provider', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', mockFetch)
mockEnv.GMAIL_SENDER = 'noreply@sim.example'
mockEnv.GMAIL_CREDENTIALS_JSON = VALID_CREDENTIALS
mockGetAccessToken.mockResolvedValue({ token: 'test-token' })
})

afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})

describe('createGmailProvider', () => {
it('returns null when neither GMAIL_SENDER nor GMAIL_CREDENTIALS_JSON is set', () => {
mockEnv.GMAIL_SENDER = undefined
mockEnv.GMAIL_CREDENTIALS_JSON = undefined

expect(createGmailProvider()).toBeNull()
})

it('returns null when only one of the two variables is set', () => {
mockEnv.GMAIL_CREDENTIALS_JSON = undefined
expect(createGmailProvider()).toBeNull()

mockEnv.GMAIL_CREDENTIALS_JSON = VALID_CREDENTIALS
mockEnv.GMAIL_SENDER = undefined
expect(createGmailProvider()).toBeNull()
})

it('returns null for invalid or incomplete credentials JSON', () => {
mockEnv.GMAIL_CREDENTIALS_JSON = 'not-json'
expect(createGmailProvider()).toBeNull()

mockEnv.GMAIL_CREDENTIALS_JSON = JSON.stringify({ client_email: 'x@y.iam' })
expect(createGmailProvider()).toBeNull()
})

it('creates a JWT client impersonating the configured sender with the gmail.send scope', () => {
const provider = createGmailProvider()

expect(provider?.name).toBe('gmail')
expect(mockJwtConstructor).toHaveBeenCalledWith({
email: 'mailer@my-project.iam.gserviceaccount.com',
key: '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n',
scopes: ['https://www.googleapis.com/auth/gmail.send'],
subject: 'noreply@sim.example',
})
})
})

describe('send', () => {
it('posts the raw RFC 822 message to the Gmail media-upload endpoint', async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ id: 'msg-1', threadId: 'thr-1' }), { status: 200 })
)

const provider = createGmailProvider()
const result = await provider!.send({
...BASE_DATA,
headers: { 'List-Unsubscribe': '<https://sim.example/unsub>' },
replyTo: 'help@sim.example',
})

expect(result).toEqual({
success: true,
message: 'Email sent successfully via Gmail',
data: { id: 'msg-1' },
})

const [url, init] = mockFetch.mock.calls[0]
expect(url).toBe(
'https://gmail.googleapis.com/upload/gmail/v1/users/me/messages/send?uploadType=media'
)
expect(init.method).toBe('POST')
expect(init.headers).toEqual({
Authorization: 'Bearer test-token',
'Content-Type': 'message/rfc822',
})

const raw = (init.body as Buffer).toString()
expect(raw).toContain('To: user@example.com')
expect(raw).toContain('Subject: Welcome to Sim')
expect(raw).toContain('From: Sim <noreply@sim.example>')
expect(raw).toContain('Reply-To: help@sim.example')
expect(raw).toContain('List-Unsubscribe: <https://sim.example/unsub>')
expect(raw).toContain('<p>Hello</p>')
})

it('encodes attachments into the MIME payload', async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ id: 'msg-2' }), { status: 200 })
)

const provider = createGmailProvider()
await provider!.send({
...BASE_DATA,
attachments: [
{
filename: 'report.txt',
content: Buffer.from('report body'),
contentType: 'text/plain',
},
],
})

const [, init] = mockFetch.mock.calls[0]
const raw = (init.body as Buffer).toString()
expect(raw).toContain('Content-Type: text/plain; name=report.txt')
expect(raw).toContain('Content-Disposition: attachment; filename=report.txt')
expect(raw).toContain(Buffer.from('report body').toString('base64'))
})

it('joins multiple recipients into one To header', async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ id: 'msg-3' }), { status: 200 })
)

const provider = createGmailProvider()
await provider!.send({ ...BASE_DATA, to: ['a@example.com', 'b@example.com'] })

const [, init] = mockFetch.mock.calls[0]
expect((init.body as Buffer).toString()).toContain('To: a@example.com, b@example.com')
})

it('sends text-only messages', async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ id: 'msg-4' }), { status: 200 })
)

const provider = createGmailProvider()
await provider!.send({ ...BASE_DATA, html: undefined, text: 'plain body' })

const [, init] = mockFetch.mock.calls[0]
const raw = (init.body as Buffer).toString()
expect(raw).toContain('Content-Type: text/plain')
expect(raw).toContain('plain body')
expect(raw).not.toContain('text/html')
})

it('treats an accepted send with an empty response body as success (no fallback re-send)', async () => {
mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 }))

const provider = createGmailProvider()
const result = await provider!.send(BASE_DATA)

expect(result.success).toBe(true)
expect(result.data).toEqual({ id: undefined })
})

it('throws when no access token can be obtained', async () => {
mockGetAccessToken.mockResolvedValueOnce({ token: null })

const provider = createGmailProvider()
await expect(provider!.send(BASE_DATA)).rejects.toThrow(
'Failed to obtain a Gmail API access token'
)
expect(mockFetch).not.toHaveBeenCalled()
})

it('throws with status details when the Gmail API rejects the send', async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ error: { message: 'Delegation denied' } }), {
status: 403,
statusText: 'Forbidden',
})
)

const provider = createGmailProvider()
await expect(provider!.send(BASE_DATA)).rejects.toThrow(
'Gmail API send failed: 403 Forbidden'
)
})
})
})
Loading
Loading