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
150 changes: 150 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 19 additions & 3 deletions src/email/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -374,9 +375,17 @@ export async function readEmail(folder: string, uid: number): Promise<EmailMessa
// ============================================================================

/**
* Send an email via SMTP.
* Send an email. Uses Gmail REST API for OAuth accounts (avoids SMTP port 465
* being blocked in container environments); falls back to SMTP for password accounts.
*/
export async function sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
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();

Expand Down Expand Up @@ -445,9 +454,16 @@ export async function markEmailAsSeen(folder: string, uid: number): Promise<void
}

export async function replyToEmail(options: ReplyEmailOptions): Promise<SendEmailResult> {
// 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();
Expand All @@ -473,7 +489,7 @@ export async function replyToEmail(options: ReplyEmailOptions): Promise<SendEmai
references.push(original.messageId);
}

// Send the reply
// Send the reply via SMTP
const transport = createSmtpTransport();

try {
Expand Down
126 changes: 126 additions & 0 deletions src/email/gmail/send.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* Gmail REST API send helpers.
*
* Used instead of SMTP for OAuth accounts to avoid SMTP port 465 being blocked
* in container environments. The access token is managed by the caller (already
* refreshed/cached in oauth.ts before this point).
*/

import { gmail } from '@googleapis/gmail';
import { OAuth2Client } from 'google-auth-library';
import nodemailer from 'nodemailer';
import type {
EmailMessage,
ReplyEmailOptions,
SendEmailOptions,
SendEmailResult,
} from '../types.js';

/**
* Build a base64url-encoded RFC 822 message using nodemailer's in-process
* streamTransport (no network connection — pure MIME encoding).
*/
async function buildRawMessage(mailOptions: Record<string, unknown>): Promise<string> {
const transport = nodemailer.createTransport({ streamTransport: true, newline: 'unix' });
const info = await transport.sendMail(mailOptions as Parameters<typeof transport.sendMail>[0]);
const chunks: Buffer[] = [];
for await (const chunk of info.message as AsyncIterable<Buffer | string>) {
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<SendEmailResult> {
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<SendEmailResult> {
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: [],
};
}
Loading