Skip to content

Commit dc38f2e

Browse files
committed
feat(email): native Gmail API mail provider for GCP self-hosting
Adds Gmail as a fifth transactional mail provider (Resend → SES → SMTP → ACS → Gmail). GCP has no first-party SES/ACS equivalent, so the native Google path is the Gmail API: a service account with domain-wide delegation impersonates a Workspace sender (GMAIL_SENDER) and posts the raw RFC 822 message (built via nodemailer's MailComposer — full parity incl. attachments, replyTo, unsubscribe headers) to the media-upload messages.send endpoint. The Workspace SMTP relay alternative is documented against the existing SMTP provider.
1 parent caa454a commit dc38f2e

10 files changed

Lines changed: 366 additions & 5 deletions

File tree

apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ By default Sim writes uploads to local disk. For production, point it at AWS S3,
8888

8989
## Email Providers
9090

91-
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.
91+
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.
9292

9393
| Variable | Description |
9494
|----------|-------------|
@@ -124,6 +124,15 @@ Configure one provider — the mailer auto-detects in priority order: **Resend
124124
|----------|-------------|
125125
| `AZURE_ACS_CONNECTION_STRING` | Azure Communication Services connection string |
126126

127+
**Gmail** (Google-native — GCP has no first-party transactional email service, so the native path is the Gmail API with a Google Workspace sender)
128+
129+
| Variable | Description |
130+
|----------|-------------|
131+
| `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 |
132+
| `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 |
133+
134+
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.
135+
127136
## Limits
128137

129138
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.

apps/sim/.env.example

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
2727

2828
# Email Provider (Optional)
2929
# Configure ONE provider — the mailer auto-detects in priority order:
30-
# Resend → AWS SES → SMTP → Azure Communication Services. If none are
31-
# configured, emails are logged to console instead.
30+
# Resend → AWS SES → SMTP → Azure Communication Services → Gmail. If none
31+
# are configured, emails are logged to console instead.
3232
#
3333
# Resend
3434
# RESEND_API_KEY= # API key from https://resend.com
@@ -47,6 +47,11 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
4747
# Azure Communication Services
4848
# AZURE_ACS_CONNECTION_STRING=
4949
#
50+
# Gmail API (Google-native — service account with domain-wide delegation for
51+
# the gmail.send scope; the Google Workspace SMTP relay also works via SMTP_HOST)
52+
# GMAIL_CREDENTIALS_JSON= # Inline service-account JSON with domain-wide delegation
53+
# GMAIL_SENDER=noreply@yourdomain.com # Workspace user the service account impersonates
54+
#
5055
# Shared sender configuration
5156
# FROM_EMAIL_ADDRESS="Sim <noreply@example.com>"
5257
# EMAIL_DOMAIN=example.com # Fallback when FROM_EMAIL_ADDRESS is unset

apps/sim/lib/core/config/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ export const env = createEnv({
134134
SMTP_USER: z.string().min(1).optional(), // SMTP username
135135
SMTP_PASS: z.string().min(1).optional(), // SMTP password
136136
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
137+
GMAIL_CREDENTIALS_JSON: z.string().optional(), // Inline Google service-account JSON with domain-wide delegation for the Gmail API mail provider
138+
GMAIL_SENDER: z.string().min(1).optional(), // Google Workspace user the Gmail service account impersonates when sending (e.g., noreply@yourdomain.com)
137139

138140
// SMS & Messaging
139141
TWILIO_ACCOUNT_SID: z.string().min(1).optional(), // Twilio Account SID for SMS sending
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/**
2+
* Tests for the Gmail API mail provider
3+
*
4+
* @vitest-environment node
5+
*/
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockJwtConstructor, mockGetAccessToken, mockEnv } = vi.hoisted(() => {
9+
const mockGetAccessToken = vi.fn()
10+
const jwtInstance = { getAccessToken: mockGetAccessToken }
11+
const mockEnv: Record<string, string | undefined> = {}
12+
return {
13+
mockJwtConstructor: vi.fn().mockImplementation(
14+
class {
15+
constructor() {
16+
// 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
17+
return jwtInstance
18+
}
19+
}
20+
),
21+
mockGetAccessToken,
22+
mockEnv,
23+
}
24+
})
25+
26+
vi.mock('google-auth-library', () => ({
27+
JWT: mockJwtConstructor,
28+
}))
29+
30+
vi.mock('@/lib/core/config/env', () => ({
31+
env: mockEnv,
32+
getEnv: (key: string) => mockEnv[key],
33+
}))
34+
35+
import { createGmailProvider } from '@/lib/messaging/email/providers/gmail'
36+
import type { ProcessedEmailData } from '@/lib/messaging/email/types'
37+
38+
const VALID_CREDENTIALS = JSON.stringify({
39+
client_email: 'mailer@my-project.iam.gserviceaccount.com',
40+
private_key: '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n',
41+
})
42+
43+
const BASE_DATA: ProcessedEmailData = {
44+
to: 'user@example.com',
45+
subject: 'Welcome to Sim',
46+
html: '<p>Hello</p>',
47+
senderEmail: 'Sim <noreply@sim.example>',
48+
headers: {},
49+
}
50+
51+
const mockFetch = vi.fn()
52+
53+
describe('Gmail mail provider', () => {
54+
beforeEach(() => {
55+
vi.clearAllMocks()
56+
vi.stubGlobal('fetch', mockFetch)
57+
mockEnv.GMAIL_SENDER = 'noreply@sim.example'
58+
mockEnv.GMAIL_CREDENTIALS_JSON = VALID_CREDENTIALS
59+
mockGetAccessToken.mockResolvedValue({ token: 'test-token' })
60+
})
61+
62+
afterEach(() => {
63+
vi.unstubAllGlobals()
64+
vi.restoreAllMocks()
65+
})
66+
67+
describe('createGmailProvider', () => {
68+
it('returns null when neither GMAIL_SENDER nor GMAIL_CREDENTIALS_JSON is set', () => {
69+
mockEnv.GMAIL_SENDER = undefined
70+
mockEnv.GMAIL_CREDENTIALS_JSON = undefined
71+
72+
expect(createGmailProvider()).toBeNull()
73+
})
74+
75+
it('returns null when only one of the two variables is set', () => {
76+
mockEnv.GMAIL_CREDENTIALS_JSON = undefined
77+
expect(createGmailProvider()).toBeNull()
78+
79+
mockEnv.GMAIL_CREDENTIALS_JSON = VALID_CREDENTIALS
80+
mockEnv.GMAIL_SENDER = undefined
81+
expect(createGmailProvider()).toBeNull()
82+
})
83+
84+
it('returns null for invalid or incomplete credentials JSON', () => {
85+
mockEnv.GMAIL_CREDENTIALS_JSON = 'not-json'
86+
expect(createGmailProvider()).toBeNull()
87+
88+
mockEnv.GMAIL_CREDENTIALS_JSON = JSON.stringify({ client_email: 'x@y.iam' })
89+
expect(createGmailProvider()).toBeNull()
90+
})
91+
92+
it('creates a JWT client impersonating the configured sender with the gmail.send scope', () => {
93+
const provider = createGmailProvider()
94+
95+
expect(provider?.name).toBe('gmail')
96+
expect(mockJwtConstructor).toHaveBeenCalledWith({
97+
email: 'mailer@my-project.iam.gserviceaccount.com',
98+
key: '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n',
99+
scopes: ['https://www.googleapis.com/auth/gmail.send'],
100+
subject: 'noreply@sim.example',
101+
})
102+
})
103+
})
104+
105+
describe('send', () => {
106+
it('posts the raw RFC 822 message to the Gmail media-upload endpoint', async () => {
107+
mockFetch.mockResolvedValueOnce(
108+
new Response(JSON.stringify({ id: 'msg-1', threadId: 'thr-1' }), { status: 200 })
109+
)
110+
111+
const provider = createGmailProvider()
112+
const result = await provider!.send({
113+
...BASE_DATA,
114+
headers: { 'List-Unsubscribe': '<https://sim.example/unsub>' },
115+
replyTo: 'help@sim.example',
116+
})
117+
118+
expect(result).toEqual({
119+
success: true,
120+
message: 'Email sent successfully via Gmail',
121+
data: { id: 'msg-1' },
122+
})
123+
124+
const [url, init] = mockFetch.mock.calls[0]
125+
expect(url).toBe(
126+
'https://gmail.googleapis.com/upload/gmail/v1/users/me/messages/send?uploadType=media'
127+
)
128+
expect(init.method).toBe('POST')
129+
expect(init.headers).toEqual({
130+
Authorization: 'Bearer test-token',
131+
'Content-Type': 'message/rfc822',
132+
})
133+
134+
const raw = (init.body as Buffer).toString()
135+
expect(raw).toContain('To: user@example.com')
136+
expect(raw).toContain('Subject: Welcome to Sim')
137+
expect(raw).toContain('From: Sim <noreply@sim.example>')
138+
expect(raw).toContain('Reply-To: help@sim.example')
139+
expect(raw).toContain('List-Unsubscribe: <https://sim.example/unsub>')
140+
expect(raw).toContain('<p>Hello</p>')
141+
})
142+
143+
it('encodes attachments into the MIME payload', async () => {
144+
mockFetch.mockResolvedValueOnce(
145+
new Response(JSON.stringify({ id: 'msg-2' }), { status: 200 })
146+
)
147+
148+
const provider = createGmailProvider()
149+
await provider!.send({
150+
...BASE_DATA,
151+
attachments: [
152+
{
153+
filename: 'report.txt',
154+
content: Buffer.from('report body'),
155+
contentType: 'text/plain',
156+
},
157+
],
158+
})
159+
160+
const [, init] = mockFetch.mock.calls[0]
161+
const raw = (init.body as Buffer).toString()
162+
expect(raw).toContain('Content-Type: text/plain; name=report.txt')
163+
expect(raw).toContain('Content-Disposition: attachment; filename=report.txt')
164+
expect(raw).toContain(Buffer.from('report body').toString('base64'))
165+
})
166+
167+
it('throws when no access token can be obtained', async () => {
168+
mockGetAccessToken.mockResolvedValueOnce({ token: null })
169+
170+
const provider = createGmailProvider()
171+
await expect(provider!.send(BASE_DATA)).rejects.toThrow(
172+
'Failed to obtain a Gmail API access token'
173+
)
174+
expect(mockFetch).not.toHaveBeenCalled()
175+
})
176+
177+
it('throws with status details when the Gmail API rejects the send', async () => {
178+
mockFetch.mockResolvedValueOnce(
179+
new Response(JSON.stringify({ error: { message: 'Delegation denied' } }), {
180+
status: 403,
181+
statusText: 'Forbidden',
182+
})
183+
)
184+
185+
const provider = createGmailProvider()
186+
await expect(provider!.send(BASE_DATA)).rejects.toThrow(
187+
'Gmail API send failed: 403 Forbidden'
188+
)
189+
})
190+
})
191+
})
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { createLogger } from '@sim/logger'
2+
import { JWT } from 'google-auth-library'
3+
import MailComposer from 'nodemailer/lib/mail-composer'
4+
import { env } from '@/lib/core/config/env'
5+
import type { MailProvider, ProcessedEmailData, SendEmailResult } from '@/lib/messaging/email/types'
6+
7+
const logger = createLogger('GmailMailProvider')
8+
9+
const GMAIL_SEND_SCOPE = 'https://www.googleapis.com/auth/gmail.send'
10+
11+
/**
12+
* Media-upload variant of `users.messages.send` — accepts the raw RFC 822
13+
* message as the request body (up to ~35 MiB), so no base64url re-encoding
14+
* of the MIME payload is needed.
15+
*/
16+
const GMAIL_SEND_ENDPOINT =
17+
'https://gmail.googleapis.com/upload/gmail/v1/users/me/messages/send?uploadType=media'
18+
19+
interface GmailServiceAccount {
20+
client_email: string
21+
private_key: string
22+
}
23+
24+
function parseServiceAccount(credentialsJson: string): GmailServiceAccount | null {
25+
let parsed: unknown
26+
try {
27+
parsed = JSON.parse(credentialsJson)
28+
} catch {
29+
logger.warn('GMAIL_CREDENTIALS_JSON is not valid JSON; skipping Gmail provider.')
30+
return null
31+
}
32+
33+
const credentials = parsed as Partial<GmailServiceAccount>
34+
if (!credentials.client_email || !credentials.private_key) {
35+
logger.warn(
36+
'GMAIL_CREDENTIALS_JSON must contain client_email and private_key; skipping Gmail provider.'
37+
)
38+
return null
39+
}
40+
41+
return credentials as GmailServiceAccount
42+
}
43+
44+
/** Build the raw RFC 822 message via nodemailer's composer (multipart, attachments, headers). */
45+
function buildRawMessage(data: ProcessedEmailData): Promise<Buffer> {
46+
const composer = new MailComposer({
47+
from: data.senderEmail,
48+
to: data.to,
49+
subject: data.subject,
50+
html: data.html,
51+
text: data.text,
52+
replyTo: data.replyTo,
53+
headers: Object.keys(data.headers).length > 0 ? data.headers : undefined,
54+
attachments: data.attachments?.map((att) => ({
55+
filename: att.filename,
56+
content: att.content,
57+
contentType: att.contentType,
58+
contentDisposition: att.disposition || 'attachment',
59+
})),
60+
})
61+
62+
return new Promise<Buffer>((resolve, reject) => {
63+
composer.compile().build((error, message) => {
64+
if (error) reject(error)
65+
else resolve(message)
66+
})
67+
})
68+
}
69+
70+
/**
71+
* Gmail API via a service account with domain-wide delegation, impersonating
72+
* the Google Workspace user in `GMAIL_SENDER`. This is the Google-native
73+
* transactional mail path (GCP has no first-party SES/ACS equivalent); the
74+
* Workspace SMTP relay alternative works through the generic SMTP provider.
75+
*/
76+
export function createGmailProvider(): MailProvider | null {
77+
const sender = env.GMAIL_SENDER
78+
const credentialsJson = env.GMAIL_CREDENTIALS_JSON
79+
if (!sender && !credentialsJson) return null
80+
if (!sender || !credentialsJson) {
81+
logger.warn(
82+
'Gmail provider requires both GMAIL_SENDER and GMAIL_CREDENTIALS_JSON; skipping Gmail provider.'
83+
)
84+
return null
85+
}
86+
87+
const serviceAccount = parseServiceAccount(credentialsJson)
88+
if (!serviceAccount) return null
89+
90+
const jwtClient = new JWT({
91+
email: serviceAccount.client_email,
92+
key: serviceAccount.private_key,
93+
scopes: [GMAIL_SEND_SCOPE],
94+
subject: sender,
95+
})
96+
97+
return {
98+
name: 'gmail',
99+
async send(data: ProcessedEmailData): Promise<SendEmailResult> {
100+
const raw = await buildRawMessage(data)
101+
102+
const { token } = await jwtClient.getAccessToken()
103+
if (!token) {
104+
throw new Error(
105+
'Failed to obtain a Gmail API access token – check GMAIL_CREDENTIALS_JSON and that domain-wide delegation is granted for the gmail.send scope.'
106+
)
107+
}
108+
109+
const response = await fetch(GMAIL_SEND_ENDPOINT, {
110+
method: 'POST',
111+
headers: {
112+
Authorization: `Bearer ${token}`,
113+
'Content-Type': 'message/rfc822',
114+
},
115+
// Buffer is a valid BodyInit at runtime; undici's types only admit ArrayBufferView
116+
body: raw as BodyInit,
117+
})
118+
119+
if (!response.ok) {
120+
const errorBody = await response.text().catch(() => '')
121+
throw new Error(
122+
`Gmail API send failed: ${response.status} ${response.statusText}${errorBody ? ` – ${errorBody.slice(0, 500)}` : ''}`
123+
)
124+
}
125+
126+
const result = (await response.json()) as { id?: string }
127+
return {
128+
success: true,
129+
message: 'Email sent successfully via Gmail',
130+
data: { id: result.id },
131+
}
132+
},
133+
}
134+
}

apps/sim/lib/messaging/email/providers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { createAzureProvider } from '@/lib/messaging/email/providers/azure'
3+
import { createGmailProvider } from '@/lib/messaging/email/providers/gmail'
34
import { createResendProvider } from '@/lib/messaging/email/providers/resend'
45
import { createSesProvider } from '@/lib/messaging/email/providers/ses'
56
import { createSmtpProvider } from '@/lib/messaging/email/providers/smtp'
@@ -12,6 +13,7 @@ const factories = [
1213
createSesProvider,
1314
createSmtpProvider,
1415
createAzureProvider,
16+
createGmailProvider,
1517
] as const
1618

1719
function safeCreate(factory: () => MailProvider | null): MailProvider | null {

0 commit comments

Comments
 (0)