email: compose + reply via Gmail SMTP (app password), with recipient autocomplete

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 07:12:34 +00:00
co-authored by Claude Opus 4.8
parent 3b13b98532
commit ead8f53951
5 changed files with 275 additions and 3 deletions
+56
View File
@@ -1,8 +1,11 @@
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import nodemailer from 'nodemailer';
import type { EmailMessage } from 'types';
import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import { DATA_PATH } from '@@/data-path';
import { getEmailAccounts } from 'officerdb';
import { openEmailDb, rowToSummary, getSyncMeta, searchEmails } from './email-db';
import { accountsRouter } from './accounts';
@@ -10,6 +13,59 @@ export const emailRouter = createRouter();
emailRouter.route('/accounts', accountsRouter);
// ── Sending (SMTP) — sends as the connected account using its app password ──
async function getSmtpTransport(userId: number): Promise<{ transport: ReturnType<typeof nodemailer.createTransport>; from: string }> {
const accounts = await getEmailAccounts(userId);
const acct = accounts.find((a) => a.enabled) ?? accounts[0];
if (!acct) throw errors.BAD_REQUEST('No email account configured');
const pass = (acct.credentials as Record<string, unknown> | null)?.password as string | undefined;
if (!pass) throw errors.BAD_REQUEST('This account has no SMTP password — sending needs an app-password account');
// Gmail: smtp.gmail.com:465 (SSL). Derive from the IMAP host for other providers.
const host = acct.provider === 'gmail' ? 'smtp.gmail.com' : acct.imapHost.replace(/^imap\./, 'smtp.');
const transport = nodemailer.createTransport({ host, port: 465, secure: true, auth: { user: acct.email, pass } });
return { transport, from: acct.email };
}
// POST /send — compose/reply. Gmail auto-files the sent copy in "Sent", so IMAP sync picks it up.
emailRouter.post('/send', async (ctx) => {
const user = ctx.get('user');
const b = await ctx.req.json<{ to: string; cc?: string; bcc?: string; subject?: string; body?: string; inReplyTo?: string }>();
if (!b.to?.trim()) throw errors.BAD_REQUEST('At least one recipient is required');
const { transport, from } = await getSmtpTransport(user.id);
await transport.sendMail({
from,
to: b.to,
cc: b.cc?.trim() || undefined,
bcc: b.bcc?.trim() || undefined,
subject: b.subject?.trim() || '(no subject)',
text: b.body ?? '',
...(b.inReplyTo ? { headers: { 'In-Reply-To': b.inReplyTo, References: b.inReplyTo } } : {}),
});
return ctx.json({ ok: true });
});
// GET /contacts?q= — address autocomplete from people you've received mail from, ranked by frequency.
emailRouter.get('/contacts', (ctx) => {
const email = ctx.get('user').email;
const like = `%${(ctx.req.query('q') ?? '').trim().toLowerCase()}%`;
const db = openEmailDb(email);
try {
const rows = db
.query(
`SELECT lower(from_address) AS address, from_name AS name, count(*) AS c
FROM emails
WHERE from_address IS NOT NULL AND from_address != '' AND deleted = 0
AND (lower(from_address) LIKE ? OR lower(from_name) LIKE ?)
GROUP BY lower(from_address)
ORDER BY c DESC LIMIT 10`,
)
.all(like, like) as Array<{ address: string; name: string | null }>;
return ctx.json(rows.map((r) => ({ address: r.address, name: r.name || '' })));
} finally {
db.close();
}
});
// ── Real-time: per-user SSE stream of email events (fed by the IMAP IDLE watcher) ──
const emailSseClients = new Map<string, Set<ReadableStreamDefaultController<Uint8Array>>>();