From 5ade5d75120dd4a59cdae1eb0ec4ae8d0d1101f0 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Fri, 27 Feb 2026 14:43:21 +0000 Subject: [PATCH] fix(email): use Gmail REST API for sending on OAuth accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SMTP port 465 is blocked in the worker container, causing all ReplyToEmail/SendEmail gadget calls on Gmail OAuth accounts to silently hang for 30 s before the gadget timeout fires — confirmed in the car-dealership email-joke run (three ReplyToEmail calls, each 30 000 ms). Root cause: nodemailer's createTransport() has no connection-level timeout, so the gadget outer timer was the only kill-switch, yielding a cryptic "Gadget exceeded timeout" error with no actionable detail. Fix: for OAuth accounts (authMethod === 'oauth'), route sendEmail() and replyToEmail() through the Gmail REST API (messages.send) instead of SMTP. IMAP operations (SearchEmails, ReadEmail, MarkEmailAsSeen) are unchanged — port 993 is not blocked. Implementation: - src/email/gmail/send.ts (new): sendViaGmailApi() and replyViaGmailApi() use nodemailer streamTransport (in-process, no network) to build RFC 822 bytes, then POST to gmail.users.messages.send via @googleapis/gmail. OAuth2Client passes the existing access token directly — no re-auth needed (token refresh is already handled in oauth.ts before this point). - src/email/client.ts: branch on creds.authMethod at the top of both send functions; password/SMTP accounts are completely unaffected. - @googleapis/gmail added as a production dependency (~1 MB, pulls in google-auth-library transitively). Tests: 8 new unit tests in tests/unit/email/gmail/send.test.ts covering success paths, base64url encoding, reply-to-sender, reply-all (self excluded), Re: prefix deduplication, and error propagation. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 150 ++++++++++++++++++++ package.json | 1 + src/email/client.ts | 22 ++- src/email/gmail/send.ts | 126 +++++++++++++++++ tests/unit/email/gmail/send.test.ts | 203 ++++++++++++++++++++++++++++ 5 files changed, 499 insertions(+), 3 deletions(-) create mode 100644 src/email/gmail/send.ts create mode 100644 tests/unit/email/gmail/send.test.ts diff --git a/package-lock.json b/package-lock.json index 52d6109f..93982de2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.42", + "@googleapis/gmail": "^16.1.1", "@hono/node-server": "^1.13.7", "@hono/trpc-server": "^0.4.2", "@llmist/cli": "^15.2.1", @@ -1807,6 +1808,18 @@ } } }, + "node_modules/@googleapis/gmail": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@googleapis/gmail/-/gmail-16.1.1.tgz", + "integrity": "sha512-XFMMJvhpEoKzwBOHYrcbBu/jHS+8Aer5YmbMUsrZeJgXAbTD6K7K6+/1RJczXIqDoBzVDdECm4WtjAcvEHPLVg==", + "license": "Apache-2.0", + "dependencies": { + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.14.3", "license": "Apache-2.0", @@ -4777,6 +4790,22 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "license": "MIT", @@ -6817,6 +6846,22 @@ "node": ">=14" } }, + "node_modules/googleapis-common": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz", + "integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^7.0.0-rc.4", + "google-auth-library": "^10.1.0", + "qs": "^6.7.0", + "url-template": "^2.0.8" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/gopd": { "version": "1.2.0", "license": "MIT", @@ -8406,6 +8451,18 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -8954,6 +9011,21 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -9285,6 +9357,78 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "dev": true, @@ -9958,6 +10102,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, "node_modules/util-deprecate": { "version": "1.0.2", "license": "MIT" diff --git a/package.json b/package.json index 0652bd50..b0676208 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.42", + "@googleapis/gmail": "^16.1.1", "@hono/node-server": "^1.13.7", "@hono/trpc-server": "^0.4.2", "@llmist/cli": "^15.2.1", diff --git a/src/email/client.ts b/src/email/client.ts index 3fd1ec5b..e71fafbf 100644 --- a/src/email/client.ts +++ b/src/email/client.ts @@ -10,6 +10,7 @@ import { ImapFlow } from 'imapflow'; import nodemailer from 'nodemailer'; import type { Transporter } from 'nodemailer'; import { logger } from '../utils/logging.js'; +import { replyViaGmailApi, sendViaGmailApi } from './gmail/send.js'; import type { EmailCredentials, EmailMessage, @@ -374,9 +375,17 @@ export async function readEmail(folder: string, uid: number): Promise { + const creds = getEmailCredentials(); + + if (creds.authMethod === 'oauth') { + logger.debug('Sending email via Gmail API', { to: options.to, subject: options.subject }); + return sendViaGmailApi(options, creds.accessToken, creds.email); + } + const fromEmail = getEmailAddress(); const transport = createSmtpTransport(); @@ -445,9 +454,16 @@ export async function markEmailAsSeen(folder: string, uid: number): Promise { - // First, fetch the original message to get threading info + // First, fetch the original message to get threading info (IMAP — unchanged) const original = await readEmail(options.folder, options.uid); + const creds = getEmailCredentials(); + + if (creds.authMethod === 'oauth') { + logger.debug('Sending reply via Gmail API', { uid: options.uid, replyAll: options.replyAll }); + return replyViaGmailApi(options, original, creds.accessToken, creds.email); + } + // Get our email address for filtering and from field const fromEmail = getEmailAddress(); const selfEmailLower = fromEmail.toLowerCase(); @@ -473,7 +489,7 @@ export async function replyToEmail(options: ReplyEmailOptions): Promise): Promise { + const transport = nodemailer.createTransport({ streamTransport: true, newline: 'unix' }); + const info = await transport.sendMail(mailOptions as Parameters[0]); + const chunks: Buffer[] = []; + for await (const chunk of info.message as AsyncIterable) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string)); + } + return Buffer.concat(chunks).toString('base64url'); +} + +/** + * Create a Gmail API client authenticated with the given access token. + */ +function createGmailClient(accessToken: string) { + const auth = new OAuth2Client(); + auth.setCredentials({ access_token: accessToken }); + return gmail({ version: 'v1', auth }); +} + +/** + * Send an email via the Gmail REST API. + * + * @param options - Email send options (to, subject, body, etc.) + * @param accessToken - Valid Gmail OAuth2 access token + * @param fromEmail - Sender's email address + */ +export async function sendViaGmailApi( + options: SendEmailOptions, + accessToken: string, + fromEmail: string, +): Promise { + const raw = await buildRawMessage({ + from: fromEmail, + to: options.to, + cc: options.cc, + bcc: options.bcc, + subject: options.subject, + text: options.body, + html: options.html, + }); + + const client = createGmailClient(accessToken); + const res = await client.users.messages.send({ userId: 'me', requestBody: { raw } }); + + return { + messageId: `<${res.data.id}@mail.gmail.com>`, + accepted: options.to, + rejected: [], + }; +} + +/** + * Send an email reply via the Gmail REST API. + * + * Takes the already-fetched original message (via IMAP) to build correct + * threading headers (In-Reply-To, References). Only the sending step uses + * the REST API — IMAP reading is unchanged. + * + * @param options - Reply options (body, replyAll flag) + * @param original - Original email message fetched via IMAP + * @param accessToken - Valid Gmail OAuth2 access token + * @param fromEmail - Sender's email address + */ +export async function replyViaGmailApi( + options: ReplyEmailOptions, + original: EmailMessage, + accessToken: string, + fromEmail: string, +): Promise { + const selfLower = fromEmail.toLowerCase(); + + const recipients = options.replyAll + ? [ + original.from, + ...original.to.filter((a) => !a.toLowerCase().includes(selfLower)), + ...original.cc.filter((a) => !a.toLowerCase().includes(selfLower)), + ] + : [original.from]; + + const subject = original.subject.startsWith('Re:') ? original.subject : `Re: ${original.subject}`; + + const references = [...original.references]; + if (original.messageId && !references.includes(original.messageId)) { + references.push(original.messageId); + } + + const raw = await buildRawMessage({ + from: fromEmail, + to: recipients, + subject, + text: options.body, + inReplyTo: original.messageId ? `<${original.messageId}>` : undefined, + references: references.length > 0 ? references.map((r) => `<${r}>`).join(' ') : undefined, + }); + + const client = createGmailClient(accessToken); + const res = await client.users.messages.send({ userId: 'me', requestBody: { raw } }); + + return { + messageId: `<${res.data.id}@mail.gmail.com>`, + accepted: recipients, + rejected: [], + }; +} diff --git a/tests/unit/email/gmail/send.test.ts b/tests/unit/email/gmail/send.test.ts new file mode 100644 index 00000000..64d56de7 --- /dev/null +++ b/tests/unit/email/gmail/send.test.ts @@ -0,0 +1,203 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// vi.mock factories are hoisted to the top of the file, so variables used +// inside them must be created with vi.hoisted(). +const { mockSend, mockCreateTransport } = vi.hoisted(() => { + const mockSend = vi.fn().mockResolvedValue({ data: { id: 'gmail-msg-id-1' } }); + + const rawBuf = Buffer.from('raw-email-content'); + async function* makeStream() { + yield rawBuf; + } + const mockSendMail = vi.fn().mockResolvedValue({ message: makeStream() }); + const mockCreateTransport = vi.fn().mockReturnValue({ sendMail: mockSendMail }); + + return { mockSend, mockCreateTransport }; +}); + +vi.mock('nodemailer', () => ({ + default: { createTransport: mockCreateTransport }, + __esModule: true, +})); + +vi.mock('@googleapis/gmail', () => ({ + gmail: vi.fn().mockReturnValue({ + users: { messages: { send: mockSend } }, + }), + __esModule: true, +})); + +vi.mock('google-auth-library', () => ({ + OAuth2Client: vi.fn().mockImplementation(() => ({ + setCredentials: vi.fn(), + })), + __esModule: true, +})); + +// --- imports ----------------------------------------------------------------- + +import { replyViaGmailApi, sendViaGmailApi } from '../../../../src/email/gmail/send.js'; +import type { + EmailMessage, + ReplyEmailOptions, + SendEmailOptions, +} from '../../../../src/email/types.js'; + +// --- helpers ----------------------------------------------------------------- + +function makeSendOptions(overrides: Partial = {}): SendEmailOptions { + return { + to: ['recipient@example.com'], + subject: 'Test subject', + body: 'Test body', + ...overrides, + }; +} + +function makeOriginal(overrides: Partial = {}): EmailMessage { + return { + uid: 42, + messageId: 'original-msg-id', + date: new Date('2024-01-01'), + from: 'sender@example.com', + to: ['me@example.com', 'other@example.com'], + cc: ['cc@example.com'], + subject: 'Hello', + textBody: 'Original body', + attachments: [], + references: ['ref-id-1'], + ...overrides, + }; +} + +function makeReplyOptions(overrides: Partial = {}): ReplyEmailOptions { + return { + folder: 'INBOX', + uid: 42, + body: 'Reply body', + replyAll: false, + ...overrides, + }; +} + +/** Helper: get the mail options passed to the most recent nodemailer sendMail call */ +function getLastMailOptions(): Record { + const transport = mockCreateTransport.mock.results.at(-1)?.value as { + sendMail: ReturnType; + }; + return (transport?.sendMail.mock.calls.at(-1)?.[0] ?? {}) as Record; +} + +// --- tests ------------------------------------------------------------------- + +describe('sendViaGmailApi', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSend.mockResolvedValue({ data: { id: 'gmail-msg-id-1' } }); + + // Re-configure nodemailer mock with a fresh stream each test + const rawBuf = Buffer.from('raw-email-content'); + async function* makeStream() { + yield rawBuf; + } + const mockSendMail = vi.fn().mockResolvedValue({ message: makeStream() }); + mockCreateTransport.mockReturnValue({ sendMail: mockSendMail }); + }); + + it('returns correct accepted array and messageId on success', async () => { + const options = makeSendOptions({ to: ['a@b.com', 'c@d.com'] }); + const result = await sendViaGmailApi(options, 'access-token-123', 'from@example.com'); + + expect(result.messageId).toBe(''); + expect(result.accepted).toEqual(['a@b.com', 'c@d.com']); + expect(result.rejected).toEqual([]); + }); + + it('passes raw base64url message to Gmail API', async () => { + const options = makeSendOptions(); + await sendViaGmailApi(options, 'tok', 'me@gmail.com'); + + expect(mockSend).toHaveBeenCalledWith({ + userId: 'me', + requestBody: { raw: expect.stringMatching(/^[A-Za-z0-9_-]+$/) }, + }); + }); + + it('propagates Gmail API errors', async () => { + mockSend.mockRejectedValue(new Error('API quota exceeded')); + await expect(sendViaGmailApi(makeSendOptions(), 'tok', 'me@gmail.com')).rejects.toThrow( + 'API quota exceeded', + ); + }); +}); + +describe('replyViaGmailApi', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSend.mockResolvedValue({ data: { id: 'reply-msg-id-2' } }); + + const rawBuf = Buffer.from('raw-reply-content'); + async function* makeStream() { + yield rawBuf; + } + const mockSendMail = vi.fn().mockResolvedValue({ message: makeStream() }); + mockCreateTransport.mockReturnValue({ sendMail: mockSendMail }); + }); + + it('reply-to-sender: single recipient, messageId from API response', async () => { + const original = makeOriginal({ subject: 'Hello', messageId: 'orig-id' }); + const result = await replyViaGmailApi( + makeReplyOptions({ replyAll: false }), + original, + 'access-tok', + 'me@example.com', + ); + + expect(result.messageId).toBe(''); + expect(result.accepted).toEqual(['sender@example.com']); + expect(result.rejected).toEqual([]); + }); + + it('reply-all: excludes self, includes original To and CC', async () => { + const original = makeOriginal({ + from: 'sender@example.com', + to: ['me@example.com', 'other@example.com'], + cc: ['cc@example.com', 'me@example.com'], + }); + const result = await replyViaGmailApi( + makeReplyOptions({ replyAll: true }), + original, + 'access-tok', + 'me@example.com', + ); + + expect(result.accepted).toContain('sender@example.com'); + expect(result.accepted).toContain('other@example.com'); + expect(result.accepted).toContain('cc@example.com'); + // self should be excluded + expect(result.accepted.filter((a) => a.includes('me@example.com'))).toHaveLength(0); + }); + + it('does not double Re: prefix when subject already starts with Re:', async () => { + const original = makeOriginal({ subject: 'Re: Already a reply' }); + await replyViaGmailApi(makeReplyOptions(), original, 'tok', 'me@example.com'); + + const mailOpts = getLastMailOptions(); + expect(mailOpts.subject).toBe('Re: Already a reply'); + }); + + it('adds Re: prefix when subject does not start with Re:', async () => { + const original = makeOriginal({ subject: 'Plain subject' }); + await replyViaGmailApi(makeReplyOptions(), original, 'tok', 'me@example.com'); + + const mailOpts = getLastMailOptions(); + expect(mailOpts.subject).toBe('Re: Plain subject'); + }); + + it('propagates Gmail API errors on reply', async () => { + mockSend.mockRejectedValue(new Error('Unauthorized')); + await expect( + replyViaGmailApi(makeReplyOptions(), makeOriginal(), 'bad-tok', 'me@example.com'), + ).rejects.toThrow('Unauthorized'); + }); +});