Merge branch 'sidecars-email' into sidecars
This commit is contained in:
@@ -14,7 +14,9 @@ export const emailRouter = createRouter();
|
|||||||
emailRouter.route('/accounts', accountsRouter);
|
emailRouter.route('/accounts', accountsRouter);
|
||||||
|
|
||||||
// ── Sending (SMTP) — sends as the connected account using its app password ──
|
// ── Sending (SMTP) — sends as the connected account using its app password ──
|
||||||
async function getSmtpTransport(userId: number): Promise<{ transport: ReturnType<typeof nodemailer.createTransport>; from: string }> {
|
async function getSmtpTransport(
|
||||||
|
userId: number,
|
||||||
|
): Promise<{ transport: ReturnType<typeof nodemailer.createTransport>; from: string }> {
|
||||||
const accounts = await getEmailAccounts(userId);
|
const accounts = await getEmailAccounts(userId);
|
||||||
const acct = accounts.find((a) => a.enabled) ?? accounts[0];
|
const acct = accounts.find((a) => a.enabled) ?? accounts[0];
|
||||||
if (!acct) throw errors.BAD_REQUEST('No email account configured');
|
if (!acct) throw errors.BAD_REQUEST('No email account configured');
|
||||||
@@ -35,9 +37,14 @@ emailRouter.post('/send', async (ctx) => {
|
|||||||
const to = str(form.to).trim();
|
const to = str(form.to).trim();
|
||||||
if (!to) throw errors.BAD_REQUEST('At least one recipient is required');
|
if (!to) throw errors.BAD_REQUEST('At least one recipient is required');
|
||||||
|
|
||||||
const toFiles = (raw: unknown) => (Array.isArray(raw) ? raw : raw ? [raw] : []).filter((f): f is File => f instanceof File);
|
const toFiles = (raw: unknown) =>
|
||||||
|
(Array.isArray(raw) ? raw : raw ? [raw] : []).filter((f): f is File => f instanceof File);
|
||||||
const attachments = await Promise.all(
|
const attachments = await Promise.all(
|
||||||
toFiles(form.files).map(async (f) => ({ filename: f.name, content: Buffer.from(await f.arrayBuffer()), contentType: f.type || undefined })),
|
toFiles(form.files).map(async (f) => ({
|
||||||
|
filename: f.name,
|
||||||
|
content: Buffer.from(await f.arrayBuffer()),
|
||||||
|
contentType: f.type || undefined,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
// Inline images: cid `inline-<i>` matches the `<img src="cid:inline-i">` the composer put in the html.
|
// Inline images: cid `inline-<i>` matches the `<img src="cid:inline-i">` the composer put in the html.
|
||||||
const inline = await Promise.all(
|
const inline = await Promise.all(
|
||||||
@@ -164,14 +171,24 @@ emailRouter.get('/search', async (ctx) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// `folder` is request input and was being interpolated straight into the SQL. It is bound now — the
|
||||||
|
// fragment and its parameters travel together because each call site builds several statements from the
|
||||||
|
// same fragment and has to spread the params in the right order.
|
||||||
|
type FolderFilter = { where: string; params: string[] };
|
||||||
|
const folderFilter = (folder: string): FolderFilter =>
|
||||||
|
folder === 'all'
|
||||||
|
? { where: 'deleted = 0', params: [] }
|
||||||
|
: { where: 'deleted = 0 AND labels LIKE ?', params: [`%${folder}%`] };
|
||||||
|
|
||||||
emailRouter.get('/messages', async (ctx) => {
|
emailRouter.get('/messages', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const page = Number(ctx.req.query('page') ?? '1');
|
// `|| n` also catches NaN from a non-numeric query param, which used to reach the bindings as NaN.
|
||||||
const limit = Number(ctx.req.query('limit') ?? '50');
|
const page = Math.max(Number(ctx.req.query('page') ?? '1') || 1, 1);
|
||||||
|
const limit = Math.max(Number(ctx.req.query('limit') ?? '50') || 50, 1);
|
||||||
const folder = ctx.req.query('folder') ?? 'inbox';
|
const folder = ctx.req.query('folder') ?? 'inbox';
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
const folderWhere = folder === 'all' ? 'deleted = 0' : `deleted = 0 AND labels LIKE '%${folder}%'`;
|
const { where: folderWhere, params: folderParams } = folderFilter(folder);
|
||||||
|
|
||||||
const db = await openUserEmailDb(user.email, user.id);
|
const db = await openUserEmailDb(user.email, user.id);
|
||||||
if (!db) return ctx.json({ messages: [], total: 0 });
|
if (!db) return ctx.json({ messages: [], total: 0 });
|
||||||
@@ -190,10 +207,10 @@ emailRouter.get('/messages', async (ctx) => {
|
|||||||
) WHERE rn = 1
|
) WHERE rn = 1
|
||||||
ORDER BY date DESC LIMIT ? OFFSET ?`,
|
ORDER BY date DESC LIMIT ? OFFSET ?`,
|
||||||
)
|
)
|
||||||
.all(limit, offset) as Record<string, unknown>[];
|
.all(...folderParams, limit, offset) as Record<string, unknown>[];
|
||||||
const countRow = db
|
const countRow = db
|
||||||
.query(`SELECT COUNT(DISTINCT COALESCE(thread_id, id)) as total FROM emails WHERE ${folderWhere}`)
|
.query(`SELECT COUNT(DISTINCT COALESCE(thread_id, id)) as total FROM emails WHERE ${folderWhere}`)
|
||||||
.get() as { total: number };
|
.get(...folderParams) as { total: number };
|
||||||
const messages = rows.map(rowToSummary);
|
const messages = rows.map(rowToSummary);
|
||||||
return ctx.json({ messages, total: countRow.total });
|
return ctx.json({ messages, total: countRow.total });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -202,9 +219,9 @@ emailRouter.get('/messages', async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function buildMessage(db: ReturnType<typeof openEmailDb>, row: Record<string, unknown>): EmailMessage {
|
function buildMessage(db: ReturnType<typeof openEmailDb>, row: Record<string, unknown>): EmailMessage {
|
||||||
const attachmentRows = db.query('SELECT * FROM attachments WHERE email_id = ? ORDER BY idx').all(row.id as string) as Array<
|
const attachmentRows = db
|
||||||
Record<string, unknown>
|
.query('SELECT * FROM attachments WHERE email_id = ? ORDER BY idx')
|
||||||
>;
|
.all(row.id as string) as Array<Record<string, unknown>>;
|
||||||
const from = row.from_name ? `${row.from_name} <${row.from_address}>` : (row.from_address as string);
|
const from = row.from_name ? `${row.from_name} <${row.from_address}>` : (row.from_address as string);
|
||||||
return {
|
return {
|
||||||
id: row.id as string,
|
id: row.id as string,
|
||||||
@@ -250,9 +267,10 @@ emailRouter.get('/thread/:id', async (ctx) => {
|
|||||||
const db = await openUserEmailDb(user.email, user.id);
|
const db = await openUserEmailDb(user.email, user.id);
|
||||||
if (!db) return ctx.text('Not found', 404);
|
if (!db) return ctx.text('Not found', 404);
|
||||||
try {
|
try {
|
||||||
const head = db.query('SELECT thread_id, subject FROM emails WHERE id = ? AND deleted = 0').get(id) as
|
const head = db.query('SELECT thread_id, subject FROM emails WHERE id = ? AND deleted = 0').get(id) as {
|
||||||
| { thread_id: string | null; subject: string }
|
thread_id: string | null;
|
||||||
| null;
|
subject: string;
|
||||||
|
} | null;
|
||||||
if (!head) return ctx.text('Not found', 404);
|
if (!head) return ctx.text('Not found', 404);
|
||||||
|
|
||||||
const threadKey = head.thread_id ?? id;
|
const threadKey = head.thread_id ?? id;
|
||||||
@@ -363,23 +381,24 @@ emailRouter.get('/stats', async (ctx) => {
|
|||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const folder = ctx.req.query('folder') ?? 'inbox';
|
const folder = ctx.req.query('folder') ?? 'inbox';
|
||||||
|
|
||||||
const folderWhere = folder === 'all' ? 'deleted = 0' : `deleted = 0 AND labels LIKE '%${folder}%'`;
|
const { where: folderWhere, params: folderParams } = folderFilter(folder);
|
||||||
|
|
||||||
const db = await openUserEmailDb(user.email, user.id);
|
const db = await openUserEmailDb(user.email, user.id);
|
||||||
if (!db) return ctx.json({ total: 0, byDomain: [], bySender: [] });
|
if (!db) return ctx.json({ total: 0, byDomain: [], bySender: [] });
|
||||||
try {
|
try {
|
||||||
const total = (db.query(`SELECT COUNT(*) as count FROM emails WHERE ${folderWhere}`).get() as { count: number })
|
const total = (
|
||||||
.count;
|
db.query(`SELECT COUNT(*) as count FROM emails WHERE ${folderWhere}`).get(...folderParams) as { count: number }
|
||||||
|
).count;
|
||||||
const byDomain = db
|
const byDomain = db
|
||||||
.query(
|
.query(
|
||||||
`SELECT from_domain, COUNT(*) as count FROM emails WHERE ${folderWhere} GROUP BY from_domain ORDER BY count DESC LIMIT 20`,
|
`SELECT from_domain, COUNT(*) as count FROM emails WHERE ${folderWhere} GROUP BY from_domain ORDER BY count DESC LIMIT 20`,
|
||||||
)
|
)
|
||||||
.all() as Array<{ from_domain: string; count: number }>;
|
.all(...folderParams) as Array<{ from_domain: string; count: number }>;
|
||||||
const bySender = db
|
const bySender = db
|
||||||
.query(
|
.query(
|
||||||
`SELECT from_address, from_name, COUNT(*) as count FROM emails WHERE ${folderWhere} GROUP BY from_address ORDER BY count DESC LIMIT 20`,
|
`SELECT from_address, from_name, COUNT(*) as count FROM emails WHERE ${folderWhere} GROUP BY from_address ORDER BY count DESC LIMIT 20`,
|
||||||
)
|
)
|
||||||
.all() as Array<{ from_address: string; from_name: string; count: number }>;
|
.all(...folderParams) as Array<{ from_address: string; from_name: string; count: number }>;
|
||||||
|
|
||||||
return ctx.json({ total, byDomain, bySender });
|
return ctx.json({ total, byDomain, bySender });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
import { getAllSyncedAccounts, getUserById } from 'officerdb';
|
|
||||||
import { getValidGoogleAccessToken } from '../api/integrations/google-auth';
|
|
||||||
import * as queueRunner from './queue-runner';
|
|
||||||
|
|
||||||
const INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
|
|
||||||
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
async function tick() {
|
|
||||||
try {
|
|
||||||
const accounts = await getAllSyncedAccounts();
|
|
||||||
if (accounts.length === 0) return;
|
|
||||||
|
|
||||||
const allJobs = await queueRunner.listAllJobs();
|
|
||||||
const activeEmailSyncIds = new Set(
|
|
||||||
allJobs
|
|
||||||
.filter((j) => j.type === 'email-sync' && (j.status === 'queued' || j.status === 'running'))
|
|
||||||
.map((j) => (j.meta as Record<string, unknown> | undefined)?.emailAccountId),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const account of accounts) {
|
|
||||||
if (activeEmailSyncIds.has(account.id)) continue;
|
|
||||||
|
|
||||||
const user = await getUserById(account.userId);
|
|
||||||
if (!user) continue;
|
|
||||||
|
|
||||||
// Resolve IMAP auth
|
|
||||||
const imapAuth: Record<string, unknown> = { user: account.email };
|
|
||||||
if (account.authType === 'oauth') {
|
|
||||||
let accessToken: string | null = null;
|
|
||||||
try {
|
|
||||||
accessToken = await getValidGoogleAccessToken(account.userId);
|
|
||||||
} catch (err) {
|
|
||||||
console.log(`[email-cron] Skipping ${account.email}: token refresh failed —`, err instanceof Error ? err.message : err);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!accessToken) {
|
|
||||||
console.log(`[email-cron] Skipping ${account.email}: no OAuth access token`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
imapAuth.accessToken = accessToken;
|
|
||||||
} else {
|
|
||||||
const creds = account.credentials as Record<string, unknown>;
|
|
||||||
imapAuth.pass = creds.password;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await queueRunner.enqueue({
|
|
||||||
lane: 'email',
|
|
||||||
type: 'email-sync',
|
|
||||||
userId: user.email,
|
|
||||||
meta: {
|
|
||||||
emailAccountId: account.id,
|
|
||||||
userEmail: user.email,
|
|
||||||
account: {
|
|
||||||
id: account.id,
|
|
||||||
userId: account.userId,
|
|
||||||
email: account.email,
|
|
||||||
imapHost: account.imapHost,
|
|
||||||
imapPort: account.imapPort,
|
|
||||||
imapSecure: account.imapSecure,
|
|
||||||
provider: account.provider,
|
|
||||||
authType: account.authType,
|
|
||||||
credentials: account.credentials,
|
|
||||||
},
|
|
||||||
imapAuth,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log(`[email-cron] Enqueued incremental sync for ${account.email}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[email-cron] Failed to enqueue sync for ${account.email}:`, err instanceof Error ? err.message : err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[email-cron] Error:', err instanceof Error ? err.message : err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initEmailCron() {
|
|
||||||
if (timer) return;
|
|
||||||
console.log(`[email-cron] Starting email sync cron (every ${INTERVAL_MS / 60_000} min)`);
|
|
||||||
timer = setInterval(tick, INTERVAL_MS);
|
|
||||||
// Run first tick after a short delay to let the queue initialize
|
|
||||||
setTimeout(tick, 30_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopEmailCron() {
|
|
||||||
if (timer) {
|
|
||||||
clearInterval(timer);
|
|
||||||
timer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user