Skip to content

Commit 5eafa86

Browse files
authored
feat(email): native Gmail API mail provider for GCP self-hosting (#5736)
* 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. * fix(email): review round 1 + audit hardening for Gmail provider - a 2xx from Gmail with an empty/malformed body no longer surfaces as a send failure (the mailer's fallback chain would deliver the same email twice); covered by a regression test - normalize bare-LF line endings in html/text bodies to CRLF (RFC 822) before composing the raw message - add multi-recipient and text-only test cases
1 parent c9399f8 commit 5eafa86

10 files changed

Lines changed: 414 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: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
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('joins multiple recipients into one To header', async () => {
168+
mockFetch.mockResolvedValueOnce(
169+
new Response(JSON.stringify({ id: 'msg-3' }), { status: 200 })
170+
)
171+
172+
const provider = createGmailProvider()
173+
await provider!.send({ ...BASE_DATA, to: ['a@example.com', 'b@example.com'] })
174+
175+
const [, init] = mockFetch.mock.calls[0]
176+
expect((init.body as Buffer).toString()).toContain('To: a@example.com, b@example.com')
177+
})
178+
179+
it('sends text-only messages', async () => {
180+
mockFetch.mockResolvedValueOnce(
181+
new Response(JSON.stringify({ id: 'msg-4' }), { status: 200 })
182+
)
183+
184+
const provider = createGmailProvider()
185+
await provider!.send({ ...BASE_DATA, html: undefined, text: 'plain body' })
186+
187+
const [, init] = mockFetch.mock.calls[0]
188+
const raw = (init.body as Buffer).toString()
189+
expect(raw).toContain('Content-Type: text/plain')
190+
expect(raw).toContain('plain body')
191+
expect(raw).not.toContain('text/html')
192+
})
193+
194+
it('treats an accepted send with an empty response body as success (no fallback re-send)', async () => {
195+
mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 }))
196+
197+
const provider = createGmailProvider()
198+
const result = await provider!.send(BASE_DATA)
199+
200+
expect(result.success).toBe(true)
201+
expect(result.data).toEqual({ id: undefined })
202+
})
203+
204+
it('throws when no access token can be obtained', async () => {
205+
mockGetAccessToken.mockResolvedValueOnce({ token: null })
206+
207+
const provider = createGmailProvider()
208+
await expect(provider!.send(BASE_DATA)).rejects.toThrow(
209+
'Failed to obtain a Gmail API access token'
210+
)
211+
expect(mockFetch).not.toHaveBeenCalled()
212+
})
213+
214+
it('throws with status details when the Gmail API rejects the send', async () => {
215+
mockFetch.mockResolvedValueOnce(
216+
new Response(JSON.stringify({ error: { message: 'Delegation denied' } }), {
217+
status: 403,
218+
statusText: 'Forbidden',
219+
})
220+
)
221+
222+
const provider = createGmailProvider()
223+
await expect(provider!.send(BASE_DATA)).rejects.toThrow(
224+
'Gmail API send failed: 403 Forbidden'
225+
)
226+
})
227+
})
228+
})

0 commit comments

Comments
 (0)