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:
@@ -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>>>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user