Project Files
src / imap.ts
import { ImapFlow } from "imapflow";
import { simpleParser } from "mailparser";
export interface ImapConfig {
host: string;
port: number;
user: string;
password: string;
}
export interface EmailSummary {
uid: number;
messageId: string;
subject: string;
from: string;
to: string;
date: string;
snippet: string;
hasAttachments: boolean;
folder: string;
}
export interface EmailFull extends EmailSummary {
body: string;
html: string;
attachments: string[];
cc: string;
replyTo: string;
references: string;
inReplyTo: string;
}
function makeClient(cfg: ImapConfig): ImapFlow {
return new ImapFlow({
host: cfg.host,
port: cfg.port,
secure: cfg.port === 993,
auth: { user: cfg.user, pass: cfg.password },
logger: false,
});
}
export async function listFolders(cfg: ImapConfig): Promise<string[]> {
const client = makeClient(cfg);
await client.connect();
try {
const list = await client.list();
return list.map(f => f.path).sort();
} finally {
await client.logout();
}
}
export interface SearchEmailsResult {
emails: EmailSummary[];
total: number;
offset: number;
hasMore: boolean;
}
export async function searchEmails(
cfg: ImapConfig,
folder: string,
options: {
query?: string;
from?: string;
to?: string;
subject?: string;
since?: Date;
before?: Date;
unseen?: boolean;
limit: number;
offset?: number;
}
): Promise<SearchEmailsResult> {
const client = makeClient(cfg);
await client.connect();
try {
await client.mailboxOpen(folder);
const criteria: any[] = [];
if (options.query) criteria.push(["TEXT", options.query]);
if (options.from) criteria.push(["FROM", options.from]);
if (options.to) criteria.push(["TO", options.to]);
if (options.subject) criteria.push(["SUBJECT", options.subject]);
if (options.since) criteria.push(["SINCE", options.since]);
if (options.before) criteria.push(["BEFORE", options.before]);
if (options.unseen) criteria.push("UNSEEN");
const searchCriteria = criteria.length > 0
? (criteria.length === 1 ? criteria[0] : ["AND", ...criteria])
: "ALL";
const uids = await client.search(searchCriteria, { uid: true }) as number[];
// Newest first, then slice the requested page
const allNewestFirst = [...uids].reverse();
const total = allNewestFirst.length;
const offset = options.offset ?? 0;
const limited = allNewestFirst.slice(offset, offset + options.limit);
const summaries: EmailSummary[] = [];
for await (const msg of client.fetch(limited, {
uid: true,
envelope: true,
bodyStructure: true,
bodyParts: ["1"],
}, { uid: true })) {
const env = msg.envelope;
if (!env) continue;
const fromAddr = env.from?.[0];
const toAddr = env.to?.[0];
const hasAtt = !!(msg.bodyStructure && JSON.stringify(msg.bodyStructure).includes("attachment"));
let snippet = "";
try {
const partRaw = msg.bodyParts?.get("1");
if (partRaw) {
const chunks: Buffer[] = [];
for await (const chunk of partRaw as any) chunks.push(chunk);
snippet = Buffer.concat(chunks).toString("utf8").replace(/\s+/g, " ").slice(0, 200);
}
} catch { /* snippet is optional */ }
summaries.push({
uid: msg.uid,
messageId: env.messageId ?? "",
subject: env.subject ?? "(no subject)",
from: fromAddr ? `${fromAddr.name ?? ""} <${fromAddr.address ?? ""}>`.trim() : "",
to: toAddr ? `${toAddr.name ?? ""} <${toAddr.address ?? ""}>`.trim() : "",
date: env.date?.toISOString() ?? "",
snippet,
hasAttachments: hasAtt,
folder,
});
}
return {
emails: summaries,
total,
offset: options.offset ?? 0,
hasMore: (options.offset ?? 0) + options.limit < total,
};
} finally {
await client.logout();
}
}
// Common Trash folder names across providers
const TRASH_CANDIDATES = ["Trash", "[Gmail]/Trash", "Deleted Items", "Deleted Messages", "INBOX.Trash"];
export async function moveToTrash(
cfg: ImapConfig,
folder: string,
uids: number[],
trashFolder?: string,
): Promise<{ moved: number; trashFolder: string }> {
const client = makeClient(cfg);
await client.connect();
try {
// Discover trash folder if not specified
let trash = trashFolder?.trim() || "";
if (!trash) {
const allFolders = await client.list();
const paths = allFolders.map(f => f.path);
// Prefer folder with \Trash special-use attribute
const special = allFolders.find(f => (f as any).specialUse === "\\Trash");
if (special) {
trash = special.path;
} else {
trash = TRASH_CANDIDATES.find(c => paths.includes(c)) ?? "Trash";
}
}
await client.mailboxOpen(folder);
await client.messageMove(uids, trash, { uid: true });
return { moved: uids.length, trashFolder: trash };
} finally {
await client.logout();
}
}
export async function readEmail(
cfg: ImapConfig,
folder: string,
uid: number
): Promise<EmailFull> {
const client = makeClient(cfg);
await client.connect();
try {
await client.mailboxOpen(folder);
let rawBuffer = Buffer.alloc(0);
for await (const msg of client.fetch([uid], { source: true }, { uid: true })) {
const chunks: Buffer[] = [];
for await (const chunk of msg.source as any) chunks.push(chunk);
rawBuffer = Buffer.concat(chunks);
}
const parsed = await simpleParser(rawBuffer);
const from = parsed.from?.text ?? "";
const to = Array.isArray(parsed.to) ? parsed.to.map((a: { text: string }) => a.text).join(", ") : (parsed.to?.text ?? "");
const cc = Array.isArray(parsed.cc) ? parsed.cc.map((a: { text: string }) => a.text).join(", ") : (parsed.cc?.text ?? "");
return {
uid,
messageId: parsed.messageId ?? "",
subject: parsed.subject ?? "(no subject)",
from,
to,
cc,
replyTo: parsed.replyTo?.text ?? "",
date: parsed.date?.toISOString() ?? "",
body: parsed.text ?? "",
html: parsed.html || "",
snippet: (parsed.text ?? "").replace(/\s+/g, " ").slice(0, 200),
hasAttachments: (parsed.attachments?.length ?? 0) > 0,
attachments: (parsed.attachments ?? []).map((a: { filename?: string; contentType: string; size: number }) => `${a.filename ?? "unnamed"} (${a.contentType}, ${Math.round(a.size / 1024)}KB)`),
folder,
references: (Array.isArray(parsed.references) ? parsed.references.join(" ") : parsed.references) ?? "",
inReplyTo: parsed.inReplyTo ?? "",
};
} finally {
await client.logout();
}
}