From dc38f2ed852515fbaa378121af2b45453e5ac7bd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 12:14:03 -0700 Subject: [PATCH 1/2] feat(email): native Gmail API mail provider for GCP self-hosting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../self-hosting/environment-variables.mdx | 11 +- apps/sim/.env.example | 9 +- apps/sim/lib/core/config/env.ts | 2 + .../messaging/email/providers/gmail.test.ts | 191 ++++++++++++++++++ .../lib/messaging/email/providers/gmail.ts | 134 ++++++++++++ .../lib/messaging/email/providers/index.ts | 2 + apps/sim/lib/messaging/email/types.ts | 2 +- helm/sim/examples/values-gcp.yaml | 8 + helm/sim/values.schema.json | 8 + helm/sim/values.yaml | 4 +- 10 files changed, 366 insertions(+), 5 deletions(-) create mode 100644 apps/sim/lib/messaging/email/providers/gmail.test.ts create mode 100644 apps/sim/lib/messaging/email/providers/gmail.ts diff --git a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx index 3e9c2212abd..0e084dfc858 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx @@ -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 | |----------|-------------| @@ -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. diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 349d017e792..08003939323 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -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 @@ -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 " # EMAIL_DOMAIN=example.com # Fallback when FROM_EMAIL_ADDRESS is unset diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 386ddbb5506..c86eb9e7ff4 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -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 diff --git a/apps/sim/lib/messaging/email/providers/gmail.test.ts b/apps/sim/lib/messaging/email/providers/gmail.test.ts new file mode 100644 index 00000000000..5f3aa8c8493 --- /dev/null +++ b/apps/sim/lib/messaging/email/providers/gmail.test.ts @@ -0,0 +1,191 @@ +/** + * 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 = {} + 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: '

Hello

', + senderEmail: 'Sim ', + 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': '' }, + 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 ') + expect(raw).toContain('Reply-To: help@sim.example') + expect(raw).toContain('List-Unsubscribe: ') + expect(raw).toContain('

Hello

') + }) + + 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('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' + ) + }) + }) +}) diff --git a/apps/sim/lib/messaging/email/providers/gmail.ts b/apps/sim/lib/messaging/email/providers/gmail.ts new file mode 100644 index 00000000000..c059e58c2cb --- /dev/null +++ b/apps/sim/lib/messaging/email/providers/gmail.ts @@ -0,0 +1,134 @@ +import { createLogger } from '@sim/logger' +import { JWT } from 'google-auth-library' +import MailComposer from 'nodemailer/lib/mail-composer' +import { env } from '@/lib/core/config/env' +import type { MailProvider, ProcessedEmailData, SendEmailResult } from '@/lib/messaging/email/types' + +const logger = createLogger('GmailMailProvider') + +const GMAIL_SEND_SCOPE = 'https://www.googleapis.com/auth/gmail.send' + +/** + * Media-upload variant of `users.messages.send` — accepts the raw RFC 822 + * message as the request body (up to ~35 MiB), so no base64url re-encoding + * of the MIME payload is needed. + */ +const GMAIL_SEND_ENDPOINT = + 'https://gmail.googleapis.com/upload/gmail/v1/users/me/messages/send?uploadType=media' + +interface GmailServiceAccount { + client_email: string + private_key: string +} + +function parseServiceAccount(credentialsJson: string): GmailServiceAccount | null { + let parsed: unknown + try { + parsed = JSON.parse(credentialsJson) + } catch { + logger.warn('GMAIL_CREDENTIALS_JSON is not valid JSON; skipping Gmail provider.') + return null + } + + const credentials = parsed as Partial + if (!credentials.client_email || !credentials.private_key) { + logger.warn( + 'GMAIL_CREDENTIALS_JSON must contain client_email and private_key; skipping Gmail provider.' + ) + return null + } + + return credentials as GmailServiceAccount +} + +/** Build the raw RFC 822 message via nodemailer's composer (multipart, attachments, headers). */ +function buildRawMessage(data: ProcessedEmailData): Promise { + const composer = new MailComposer({ + from: data.senderEmail, + to: data.to, + subject: data.subject, + html: data.html, + text: data.text, + replyTo: data.replyTo, + headers: Object.keys(data.headers).length > 0 ? data.headers : undefined, + attachments: data.attachments?.map((att) => ({ + filename: att.filename, + content: att.content, + contentType: att.contentType, + contentDisposition: att.disposition || 'attachment', + })), + }) + + return new Promise((resolve, reject) => { + composer.compile().build((error, message) => { + if (error) reject(error) + else resolve(message) + }) + }) +} + +/** + * Gmail API via a service account with domain-wide delegation, impersonating + * the Google Workspace user in `GMAIL_SENDER`. This is the Google-native + * transactional mail path (GCP has no first-party SES/ACS equivalent); the + * Workspace SMTP relay alternative works through the generic SMTP provider. + */ +export function createGmailProvider(): MailProvider | null { + const sender = env.GMAIL_SENDER + const credentialsJson = env.GMAIL_CREDENTIALS_JSON + if (!sender && !credentialsJson) return null + if (!sender || !credentialsJson) { + logger.warn( + 'Gmail provider requires both GMAIL_SENDER and GMAIL_CREDENTIALS_JSON; skipping Gmail provider.' + ) + return null + } + + const serviceAccount = parseServiceAccount(credentialsJson) + if (!serviceAccount) return null + + const jwtClient = new JWT({ + email: serviceAccount.client_email, + key: serviceAccount.private_key, + scopes: [GMAIL_SEND_SCOPE], + subject: sender, + }) + + return { + name: 'gmail', + async send(data: ProcessedEmailData): Promise { + const raw = await buildRawMessage(data) + + const { token } = await jwtClient.getAccessToken() + if (!token) { + throw new Error( + 'Failed to obtain a Gmail API access token – check GMAIL_CREDENTIALS_JSON and that domain-wide delegation is granted for the gmail.send scope.' + ) + } + + const response = await fetch(GMAIL_SEND_ENDPOINT, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'message/rfc822', + }, + // Buffer is a valid BodyInit at runtime; undici's types only admit ArrayBufferView + body: raw as BodyInit, + }) + + if (!response.ok) { + const errorBody = await response.text().catch(() => '') + throw new Error( + `Gmail API send failed: ${response.status} ${response.statusText}${errorBody ? ` – ${errorBody.slice(0, 500)}` : ''}` + ) + } + + const result = (await response.json()) as { id?: string } + return { + success: true, + message: 'Email sent successfully via Gmail', + data: { id: result.id }, + } + }, + } +} diff --git a/apps/sim/lib/messaging/email/providers/index.ts b/apps/sim/lib/messaging/email/providers/index.ts index 71a07517506..e383efc1e85 100644 --- a/apps/sim/lib/messaging/email/providers/index.ts +++ b/apps/sim/lib/messaging/email/providers/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { createAzureProvider } from '@/lib/messaging/email/providers/azure' +import { createGmailProvider } from '@/lib/messaging/email/providers/gmail' import { createResendProvider } from '@/lib/messaging/email/providers/resend' import { createSesProvider } from '@/lib/messaging/email/providers/ses' import { createSmtpProvider } from '@/lib/messaging/email/providers/smtp' @@ -12,6 +13,7 @@ const factories = [ createSesProvider, createSmtpProvider, createAzureProvider, + createGmailProvider, ] as const function safeCreate(factory: () => MailProvider | null): MailProvider | null { diff --git a/apps/sim/lib/messaging/email/types.ts b/apps/sim/lib/messaging/email/types.ts index f554b65db89..407e1d7ae6c 100644 --- a/apps/sim/lib/messaging/email/types.ts +++ b/apps/sim/lib/messaging/email/types.ts @@ -47,7 +47,7 @@ export interface ProcessedEmailData { replyTo?: string } -export type MailProviderName = 'resend' | 'ses' | 'smtp' | 'azure' +export type MailProviderName = 'resend' | 'ses' | 'smtp' | 'azure' | 'gmail' export interface MailProvider { readonly name: MailProviderName diff --git a/helm/sim/examples/values-gcp.yaml b/helm/sim/examples/values-gcp.yaml index 4a3a27f9762..545d955ff97 100644 --- a/helm/sim/examples/values-gcp.yaml +++ b/helm/sim/examples/values-gcp.yaml @@ -106,6 +106,14 @@ app: GCS_OG_IMAGES_BUCKET_NAME: "myorg-sim-og-images" # OpenGraph preview images GCS_WORKSPACE_LOGOS_BUCKET_NAME: "myorg-sim-workspace-logos" # Workspace logos + # Email via the Gmail API (Google-native transactional mail) + # Requires a Google Workspace domain: grant the service account domain-wide + # delegation for the gmail.send scope in the Workspace admin console. + # Alternatively use the Workspace SMTP relay via SMTP_HOST=smtp-relay.gmail.com. + # GMAIL_CREDENTIALS_JSON: '{"type":"service_account","client_email":"...","private_key":"..."}' + # GMAIL_SENDER: "noreply@yourdomain.com" + # FROM_EMAIL_ADDRESS: "Sim " + # Realtime service realtime: enabled: true diff --git a/helm/sim/values.schema.json b/helm/sim/values.schema.json index 9ba8ac8f657..b3abcbf2703 100644 --- a/helm/sim/values.schema.json +++ b/helm/sim/values.schema.json @@ -212,6 +212,14 @@ "type": "string", "description": "Set to 'true' to force TLS on connect. Defaults to true when SMTP_PORT=465." }, + "GMAIL_CREDENTIALS_JSON": { + "type": "string", + "description": "Inline Google service-account JSON with domain-wide delegation for the Gmail API mail provider." + }, + "GMAIL_SENDER": { + "type": "string", + "description": "Google Workspace user the Gmail service account impersonates when sending (e.g., noreply@yourdomain.com)." + }, "GOOGLE_CLIENT_ID": { "type": "string", "description": "Google OAuth client ID" diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 019c8a4e9d4..3f8d0a183c4 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -111,7 +111,7 @@ app: # Email & Communication # Configure one provider — the mailer auto-detects in priority order: - # Resend → AWS SES → SMTP → Azure Communication Services. + # Resend → AWS SES → SMTP → Azure Communication Services → Gmail. EMAIL_VERIFICATION_ENABLED: "" # Set to "true" to enable email verification for user registration and login (default "false" via envDefaults) RESEND_API_KEY: "" # Resend API key for transactional emails FROM_EMAIL_ADDRESS: "" # Complete from address (e.g., "Sim " or "DoNotReply@domain.com") @@ -123,6 +123,8 @@ app: SMTP_USER: "" # SMTP username (optional — omit for unauthenticated relays like MailHog) SMTP_PASS: "" # SMTP password (optional — omit for unauthenticated relays) SMTP_SECURE: "" # Set to "true" to force TLS on connect; defaults to true when SMTP_PORT=465 + GMAIL_CREDENTIALS_JSON: "" # Inline Google service-account JSON with domain-wide delegation for the Gmail API mail provider + GMAIL_SENDER: "" # Google Workspace user the Gmail service account impersonates when sending (e.g., noreply@yourdomain.com) # OAuth Integration Credentials (leave empty if not using) From 193c8346568e7cd84a03bb8151522abfbcc9fb88 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 12:30:10 -0700 Subject: [PATCH 2/2] 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 --- .../messaging/email/providers/gmail.test.ts | 37 +++++++++++++++++++ .../lib/messaging/email/providers/gmail.ts | 17 +++++++-- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/messaging/email/providers/gmail.test.ts b/apps/sim/lib/messaging/email/providers/gmail.test.ts index 5f3aa8c8493..f7c79e0de5a 100644 --- a/apps/sim/lib/messaging/email/providers/gmail.test.ts +++ b/apps/sim/lib/messaging/email/providers/gmail.test.ts @@ -164,6 +164,43 @@ describe('Gmail mail provider', () => { 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 }) diff --git a/apps/sim/lib/messaging/email/providers/gmail.ts b/apps/sim/lib/messaging/email/providers/gmail.ts index c059e58c2cb..281b05dda19 100644 --- a/apps/sim/lib/messaging/email/providers/gmail.ts +++ b/apps/sim/lib/messaging/email/providers/gmail.ts @@ -41,14 +41,22 @@ function parseServiceAccount(credentialsJson: string): GmailServiceAccount | nul return credentials as GmailServiceAccount } +/** + * RFC 822 requires CRLF line endings throughout; MailComposer preserves + * caller-supplied bare-LF endings inside html/text bodies, so normalize them. + */ +function normalizeCrlf(value: string | undefined): string | undefined { + return value?.replace(/\r?\n/g, '\r\n') +} + /** Build the raw RFC 822 message via nodemailer's composer (multipart, attachments, headers). */ function buildRawMessage(data: ProcessedEmailData): Promise { const composer = new MailComposer({ from: data.senderEmail, to: data.to, subject: data.subject, - html: data.html, - text: data.text, + html: normalizeCrlf(data.html), + text: normalizeCrlf(data.text), replyTo: data.replyTo, headers: Object.keys(data.headers).length > 0 ? data.headers : undefined, attachments: data.attachments?.map((att) => ({ @@ -123,7 +131,10 @@ export function createGmailProvider(): MailProvider | null { ) } - const result = (await response.json()) as { id?: string } + // Gmail accepted the message once the status is 2xx; a missing or + // malformed body must not surface as a send failure, or the mailer's + // fallback chain would deliver the same email again via another provider. + const result = (await response.json().catch(() => ({}))) as { id?: string } return { success: true, message: 'Email sent successfully via Gmail',