src / imap.ts
src / imap.ts
import { ImapFlow } from "imapflow";
import { simpleParser } from "mailparser";
import {
buildSearchCriteria,
normalizeSearchUids,
paginateNewestFirst,
type EmailSearchFilters,
} from "./searchCriteria";
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;
bodyTruncated: boolean;
html: string;
htmlIncluded: boolean;
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,
connectionTimeout: 20_000,
greetingTimeout: 20_000,
socketTimeout: 60_000,
});
}
async function safeLogout(client: ImapFlow): Promise<void> {
try {
await client.logout();
} catch {
// Preserve the original operation error if the connection is already closed.
}
}
async function collectSource(source: unknown): Promise<Buffer> {
if (Buffer.isBuffer(source)) return source;
if (!source || typeof (source as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] !== "function") {
return Buffer.alloc(0);
}
const chunks: Buffer[] = [];
for await (const chunk of source as AsyncIterable<Buffer | Uint8Array | string>) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
function truncate(value: string, maxChars: number): { text: string; truncated: boolean } {
if (value.length <= maxChars) return { text: value, truncated: false };
return {
text: `${value.slice(0, maxChars)}\n\n[Email body truncated at ${maxChars} characters by plugin configuration.]`,
truncated: true,
};
}
export async function listFolders(cfg: ImapConfig): Promise<string[]> {
const client = makeClient(cfg);
await client.connect();
try {
const list = (await client.list()) as Array<{ path: string }>;
return list.map((folder: { path: string }) => folder.path).sort((a: string, b: string) => a.localeCompare(b));
} finally {
await safeLogout(client);
}
}
export interface SearchEmailsResult {
emails: EmailSummary[];
total: number;
offset: number;
hasMore: boolean;
}
export async function searchEmails(
cfg: ImapConfig,
folder: string,
options: EmailSearchFilters & { limit: number; offset?: number },
): Promise<SearchEmailsResult> {
const client = makeClient(cfg);
await client.connect();
try {
await client.mailboxOpen(folder, { readOnly: true });
const rawResult = await client.search(buildSearchCriteria(options) as never, { uid: true });
const uids = normalizeSearchUids(rawResult);
const pagination = paginateNewestFirst(uids, options.offset ?? 0, options.limit);
if (pagination.page.length === 0) {
return {
emails: [],
total: pagination.total,
offset: pagination.offset,
hasMore: false,
};
}
const rank = new Map(pagination.page.map((uid, index) => [uid, index]));
const summaries: EmailSummary[] = [];
for await (const msg of client.fetch(
pagination.page,
{
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 hasAttachments = Boolean(
msg.bodyStructure && JSON.stringify(msg.bodyStructure).toLowerCase().includes("attachment"),
);
let snippet = "";
try {
const partRaw = msg.bodyParts?.get("1");
if (partRaw) {
const body = await collectSource(partRaw);
snippet = body.toString("utf8").replace(/\s+/g, " ").trim().slice(0, 200);
}
} catch {
// Snippets are optional; envelope data is still useful.
}
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,
folder,
});
}
// IMAP servers do not guarantee fetch iteration order.
summaries.sort(
(a, b) => (rank.get(a.uid) ?? Number.MAX_SAFE_INTEGER) - (rank.get(b.uid) ?? Number.MAX_SAFE_INTEGER),
);
return {
emails: summaries,
total: pagination.total,
offset: pagination.offset,
hasMore: pagination.hasMore,
};
} finally {
await safeLogout(client);
}
}
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 uniqueUids = [...new Set(uids)];
if (uniqueUids.length === 0) throw new Error("At least one UID is required.");
const client = makeClient(cfg);
await client.connect();
try {
const allFolders = (await client.list()) as Array<{ path: string; specialUse?: string }>;
const paths = allFolders.map((item: { path: string }) => item.path);
let trash = trashFolder?.trim() ?? "";
if (trash) {
if (!paths.includes(trash)) {
throw new Error(
`Trash folder "${trash}" does not exist. Call email_list_folders and use an exact folder name.`,
);
}
} else {
const special = allFolders.find(
(item: { path: string; specialUse?: string }) => item.specialUse === "\\Trash",
);
trash = special?.path ?? TRASH_CANDIDATES.find((candidate) => paths.includes(candidate)) ?? "";
if (!trash) {
throw new Error(
"Could not auto-detect a Trash folder. Call email_list_folders and pass trash_folder explicitly.",
);
}
}
await client.mailboxOpen(folder);
const result = await client.messageMove(uniqueUids, trash, { uid: true });
if (result === false) {
throw new Error("The IMAP server did not confirm that the messages were moved to Trash.");
}
return { moved: uniqueUids.length, trashFolder: trash };
} finally {
await safeLogout(client);
}
}
export async function readEmail(
cfg: ImapConfig,
folder: string,
uid: number,
options: { maxBodyChars: number; includeHtml?: boolean },
): Promise<EmailFull> {
const client = makeClient(cfg);
await client.connect();
try {
await client.mailboxOpen(folder, { readOnly: true });
let rawBuffer: Buffer = Buffer.alloc(0);
for await (const msg of client.fetch([uid], { source: true }, { uid: true })) {
rawBuffer = await collectSource(msg.source);
break;
}
if (rawBuffer.length === 0) {
throw new Error(`Email UID ${uid} was not found in folder "${folder}".`);
}
const parsed = await simpleParser(rawBuffer);
const from = parsed.from?.text ?? "";
const to = Array.isArray(parsed.to)
? parsed.to.map((address: { text: string }) => address.text).join(", ")
: (parsed.to?.text ?? "");
const cc = Array.isArray(parsed.cc)
? parsed.cc.map((address: { text: string }) => address.text).join(", ")
: (parsed.cc?.text ?? "");
const plainBody = parsed.text ?? "";
const body = truncate(plainBody, options.maxBodyChars);
const rawHtml = typeof parsed.html === "string" ? parsed.html : "";
const html = options.includeHtml ? truncate(rawHtml, options.maxBodyChars).text : "";
return {
uid,
messageId: parsed.messageId ?? "",
subject: parsed.subject ?? "(no subject)",
from,
to,
cc,
replyTo: parsed.replyTo?.text ?? "",
date: parsed.date?.toISOString() ?? "",
body: body.text,
bodyTruncated: body.truncated,
html,
htmlIncluded: Boolean(options.includeHtml),
snippet: plainBody.replace(/\s+/g, " ").trim().slice(0, 200),
hasAttachments: (parsed.attachments?.length ?? 0) > 0,
attachments: (parsed.attachments ?? []).map(
(attachment: { filename?: string; contentType: string; size: number }) =>
`${attachment.filename ?? "unnamed"} (${attachment.contentType}, ${Math.round(attachment.size / 1024)}KB)`,
),
folder,
references:
(Array.isArray(parsed.references)
? parsed.references.join(" ")
: parsed.references) ?? "",
inReplyTo: parsed.inReplyTo ?? "",
};
} finally {
await safeLogout(client);
}
}
import { ImapFlow } from "imapflow";
import { simpleParser } from "mailparser";
import {
buildSearchCriteria,
normalizeSearchUids,
paginateNewestFirst,
type EmailSearchFilters,
} from "./searchCriteria";
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;
bodyTruncated: boolean;
html: string;
htmlIncluded: boolean;
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,
connectionTimeout: 20_000,
greetingTimeout: 20_000,
socketTimeout: 60_000,
});
}
async function safeLogout(client: ImapFlow): Promise<void> {
try {
await client.logout();
} catch {
// Preserve the original operation error if the connection is already closed.
}
}
async function collectSource(source: unknown): Promise<Buffer> {
if (Buffer.isBuffer(source)) return source;
if (!source || typeof (source as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] !== "function") {
return Buffer.alloc(0);
}
const chunks: Buffer[] = [];
for await (const chunk of source as AsyncIterable<Buffer | Uint8Array | string>) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
function truncate(value: string, maxChars: number): { text: string; truncated: boolean } {
if (value.length <= maxChars) return { text: value, truncated: false };
return {
text: `${value.slice(0, maxChars)}\n\n[Email body truncated at ${maxChars} characters by plugin configuration.]`,
truncated: true,
};
}
export async function listFolders(cfg: ImapConfig): Promise<string[]> {
const client = makeClient(cfg);
await client.connect();
try {
const list = (await client.list()) as Array<{ path: string }>;
return list.map((folder: { path: string }) => folder.path).sort((a: string, b: string) => a.localeCompare(b));
} finally {
await safeLogout(client);
}
}
export interface SearchEmailsResult {
emails: EmailSummary[];
total: number;
offset: number;
hasMore: boolean;
}
export async function searchEmails(
cfg: ImapConfig,
folder: string,
options: EmailSearchFilters & { limit: number; offset?: number },
): Promise<SearchEmailsResult> {
const client = makeClient(cfg);
await client.connect();
try {
await client.mailboxOpen(folder, { readOnly: true });
const rawResult = await client.search(buildSearchCriteria(options) as never, { uid: true });
const uids = normalizeSearchUids(rawResult);
const pagination = paginateNewestFirst(uids, options.offset ?? 0, options.limit);
if (pagination.page.length === 0) {
return {
emails: [],
total: pagination.total,
offset: pagination.offset,
hasMore: false,
};
}
const rank = new Map(pagination.page.map((uid, index) => [uid, index]));
const summaries: EmailSummary[] = [];
for await (const msg of client.fetch(
pagination.page,
{
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 hasAttachments = Boolean(
msg.bodyStructure && JSON.stringify(msg.bodyStructure).toLowerCase().includes("attachment"),
);
let snippet = "";
try {
const partRaw = msg.bodyParts?.get("1");
if (partRaw) {
const body = await collectSource(partRaw);
snippet = body.toString("utf8").replace(/\s+/g, " ").trim().slice(0, 200);
}
} catch {
// Snippets are optional; envelope data is still useful.
}
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,
folder,
});
}
// IMAP servers do not guarantee fetch iteration order.
summaries.sort(
(a, b) => (rank.get(a.uid) ?? Number.MAX_SAFE_INTEGER) - (rank.get(b.uid) ?? Number.MAX_SAFE_INTEGER),
);
return {
emails: summaries,
total: pagination.total,
offset: pagination.offset,
hasMore: pagination.hasMore,
};
} finally {
await safeLogout(client);
}
}
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 uniqueUids = [...new Set(uids)];
if (uniqueUids.length === 0) throw new Error("At least one UID is required.");
const client = makeClient(cfg);
await client.connect();
try {
const allFolders = (await client.list()) as Array<{ path: string; specialUse?: string }>;
const paths = allFolders.map((item: { path: string }) => item.path);
let trash = trashFolder?.trim() ?? "";
if (trash) {
if (!paths.includes(trash)) {
throw new Error(
`Trash folder "${trash}" does not exist. Call email_list_folders and use an exact folder name.`,
);
}
} else {
const special = allFolders.find(
(item: { path: string; specialUse?: string }) => item.specialUse === "\\Trash",
);
trash = special?.path ?? TRASH_CANDIDATES.find((candidate) => paths.includes(candidate)) ?? "";
if (!trash) {
throw new Error(
"Could not auto-detect a Trash folder. Call email_list_folders and pass trash_folder explicitly.",
);
}
}
await client.mailboxOpen(folder);
const result = await client.messageMove(uniqueUids, trash, { uid: true });
if (result === false) {
throw new Error("The IMAP server did not confirm that the messages were moved to Trash.");
}
return { moved: uniqueUids.length, trashFolder: trash };
} finally {
await safeLogout(client);
}
}
export async function readEmail(
cfg: ImapConfig,
folder: string,
uid: number,
options: { maxBodyChars: number; includeHtml?: boolean },
): Promise<EmailFull> {
const client = makeClient(cfg);
await client.connect();
try {
await client.mailboxOpen(folder, { readOnly: true });
let rawBuffer: Buffer = Buffer.alloc(0);
for await (const msg of client.fetch([uid], { source: true }, { uid: true })) {
rawBuffer = await collectSource(msg.source);
break;
}
if (rawBuffer.length === 0) {
throw new Error(`Email UID ${uid} was not found in folder "${folder}".`);
}
const parsed = await simpleParser(rawBuffer);
const from = parsed.from?.text ?? "";
const to = Array.isArray(parsed.to)
? parsed.to.map((address: { text: string }) => address.text).join(", ")
: (parsed.to?.text ?? "");
const cc = Array.isArray(parsed.cc)
? parsed.cc.map((address: { text: string }) => address.text).join(", ")
: (parsed.cc?.text ?? "");
const plainBody = parsed.text ?? "";
const body = truncate(plainBody, options.maxBodyChars);
const rawHtml = typeof parsed.html === "string" ? parsed.html : "";
const html = options.includeHtml ? truncate(rawHtml, options.maxBodyChars).text : "";
return {
uid,
messageId: parsed.messageId ?? "",
subject: parsed.subject ?? "(no subject)",
from,
to,
cc,
replyTo: parsed.replyTo?.text ?? "",
date: parsed.date?.toISOString() ?? "",
body: body.text,
bodyTruncated: body.truncated,
html,
htmlIncluded: Boolean(options.includeHtml),
snippet: plainBody.replace(/\s+/g, " ").trim().slice(0, 200),
hasAttachments: (parsed.attachments?.length ?? 0) > 0,
attachments: (parsed.attachments ?? []).map(
(attachment: { filename?: string; contentType: string; size: number }) =>
`${attachment.filename ?? "unnamed"} (${attachment.contentType}, ${Math.round(attachment.size / 1024)}KB)`,
),
folder,
references:
(Array.isArray(parsed.references)
? parsed.references.join(" ")
: parsed.references) ?? "",
inReplyTo: parsed.inReplyTo ?? "",
};
} finally {
await safeLogout(client);
}
}