Files
webmailserver/lib/imap.ts
2026-05-14 01:57:52 +03:00

340 lines
9.0 KiB
TypeScript

/**
* lib/imap.ts
* IMAP client using imapflow — server-side only.
* Connects with user's mailbox credentials (from session), never stored in DB.
*/
import { ImapFlow } from "imapflow";
import { simpleParser, ParsedMail } from "mailparser";
const IMAP_HOST = process.env.MAILCOW_API_URL
? new URL(process.env.MAILCOW_API_URL).hostname
: "localhost";
const IMAP_PORT = parseInt(process.env.IMAP_PORT ?? "993");
export interface MailCredentials {
email: string;
password: string;
}
export interface MailFolder {
name: string;
path: string;
specialUse?: string;
messages: number;
unseen: number;
}
export interface MailEnvelope {
uid: number;
seq: number;
subject: string;
from: { name: string; address: string }[];
to: { name: string; address: string }[];
date: string;
seen: boolean;
flagged: boolean;
hasAttachments: boolean;
size: number;
preview: string;
}
export interface MailMessage {
uid: number;
subject: string;
from: { name: string; address: string }[];
to: { name: string; address: string }[];
cc: { name: string; address: string }[];
date: string;
html: string;
text: string;
seen: boolean;
flagged: boolean;
attachments: {
filename: string;
contentType: string;
size: number;
cid?: string;
}[];
}
/** Create an IMAP connection for the given credentials */
async function createConnection(creds: MailCredentials): Promise<ImapFlow> {
const client = new ImapFlow({
host: IMAP_HOST,
port: IMAP_PORT,
secure: true,
auth: {
user: creds.email,
pass: creds.password,
},
logger: false,
tls: {
rejectUnauthorized: false,
},
});
await client.connect();
return client;
}
/** List all mailbox folders */
export async function listFolders(creds: MailCredentials): Promise<MailFolder[]> {
const client = await createConnection(creds);
try {
const mailboxes = await client.list();
const folders: MailFolder[] = [];
for (const mb of mailboxes) {
try {
const status = await client.status(mb.path, {
messages: true,
unseen: true,
});
folders.push({
name: mb.name,
path: mb.path,
specialUse: mb.specialUse || undefined,
messages: status.messages ?? 0,
unseen: status.unseen ?? 0,
});
} catch {
folders.push({
name: mb.name,
path: mb.path,
specialUse: mb.specialUse || undefined,
messages: 0,
unseen: 0,
});
}
}
return folders;
} finally {
await client.logout();
}
}
/** Map imapflow address to our format */
function mapAddr(
addrs: { name?: string; address?: string }[] | undefined
): { name: string; address: string }[] {
if (!addrs) return [];
return addrs.map((a) => ({
name: a.name ?? "",
address: a.address ?? "",
}));
}
/** List messages in a folder (paginated) */
export async function listMessages(
creds: MailCredentials,
folder: string,
page: number = 1,
pageSize: number = 50
): Promise<{ messages: MailEnvelope[]; total: number }> {
const client = await createConnection(creds);
try {
const lock = await client.getMailboxLock(folder);
try {
const total = client.mailbox?.exists ?? 0;
if (total === 0) return { messages: [], total: 0 };
// Newest first: fetch from end
const end = total;
const start = Math.max(1, end - (page * pageSize) + 1);
const fetchStart = Math.max(1, end - ((page - 1) * pageSize));
const fetchEnd = Math.max(1, end - (page * pageSize) + 1);
const range = `${fetchEnd}:${fetchStart}`;
const messages: MailEnvelope[] = [];
for await (const msg of client.fetch(range, {
uid: true,
envelope: true,
flags: true,
bodyStructure: true,
size: true,
})) {
const env = msg.envelope;
messages.push({
uid: msg.uid,
seq: msg.seq,
subject: env.subject ?? "(Konu yok)",
from: mapAddr(env.from),
to: mapAddr(env.to),
date: env.date?.toISOString() ?? new Date().toISOString(),
seen: msg.flags?.has("\\Seen") ?? false,
flagged: msg.flags?.has("\\Flagged") ?? false,
hasAttachments: hasAttachmentParts(msg.bodyStructure),
size: msg.size ?? 0,
preview: "",
});
}
// Reverse so newest is first
messages.reverse();
return { messages, total };
} finally {
lock.release();
}
} finally {
await client.logout();
}
}
/** Check if bodyStructure has attachment parts */
function hasAttachmentParts(structure: any): boolean {
if (!structure) return false;
if (structure.disposition === "attachment") return true;
if (structure.childNodes) {
return structure.childNodes.some((n: any) => hasAttachmentParts(n));
}
return false;
}
/** Fetch a single message by UID with full body */
export async function getMessage(
creds: MailCredentials,
folder: string,
uid: number
): Promise<MailMessage | null> {
const client = await createConnection(creds);
try {
const lock = await client.getMailboxLock(folder);
try {
const raw = await client.download(String(uid), undefined, { uid: true });
if (!raw?.content) return null;
const parsed: ParsedMail = await simpleParser(raw.content);
// Mark as seen
await client.messageFlagsAdd(String(uid), ["\\Seen"], { uid: true });
return {
uid,
subject: parsed.subject ?? "(Konu yok)",
from: parsed.from?.value?.map((a) => ({ name: a.name ?? "", address: a.address ?? "" })) ?? [],
to: (Array.isArray(parsed.to) ? parsed.to : parsed.to ? [parsed.to] : [])
.flatMap((t) => t.value?.map((a) => ({ name: a.name ?? "", address: a.address ?? "" })) ?? []),
cc: (Array.isArray(parsed.cc) ? parsed.cc : parsed.cc ? [parsed.cc] : [])
.flatMap((c) => c.value?.map((a) => ({ name: a.name ?? "", address: a.address ?? "" })) ?? []),
date: parsed.date?.toISOString() ?? new Date().toISOString(),
html: parsed.html || "",
text: parsed.text || "",
seen: true,
flagged: false,
attachments: (parsed.attachments ?? []).map((att) => ({
filename: att.filename ?? "unnamed",
contentType: att.contentType ?? "application/octet-stream",
size: att.size ?? 0,
cid: att.cid,
})),
};
} finally {
lock.release();
}
} finally {
await client.logout();
}
}
/** Download attachment by filename */
export async function getAttachment(
creds: MailCredentials,
folder: string,
uid: number,
filename: string
): Promise<{ content: Buffer; contentType: string } | null> {
const client = await createConnection(creds);
try {
const lock = await client.getMailboxLock(folder);
try {
const raw = await client.download(String(uid), undefined, { uid: true });
if (!raw?.content) return null;
const parsed = await simpleParser(raw.content);
const att = parsed.attachments?.find((a) => a.filename === filename);
if (!att) return null;
return {
content: att.content,
contentType: att.contentType ?? "application/octet-stream",
};
} finally {
lock.release();
}
} finally {
await client.logout();
}
}
/** Move message to another folder */
export async function moveMessage(
creds: MailCredentials,
fromFolder: string,
uid: number,
toFolder: string
): Promise<void> {
const client = await createConnection(creds);
try {
const lock = await client.getMailboxLock(fromFolder);
try {
await client.messageMove(String(uid), toFolder, { uid: true });
} finally {
lock.release();
}
} finally {
await client.logout();
}
}
/** Delete message (move to Trash or permanent delete) */
export async function deleteMessage(
creds: MailCredentials,
folder: string,
uid: number
): Promise<void> {
const client = await createConnection(creds);
try {
const lock = await client.getMailboxLock(folder);
try {
// If already in Trash, permanently delete
if (folder.toLowerCase().includes("trash")) {
await client.messageFlagsAdd(String(uid), ["\\Deleted"], { uid: true });
await client.expunge();
} else {
// Move to Trash
await client.messageMove(String(uid), "Trash", { uid: true });
}
} finally {
lock.release();
}
} finally {
await client.logout();
}
}
/** Toggle flag on a message */
export async function toggleFlag(
creds: MailCredentials,
folder: string,
uid: number,
flag: string,
add: boolean
): Promise<void> {
const client = await createConnection(creds);
try {
const lock = await client.getMailboxLock(folder);
try {
if (add) {
await client.messageFlagsAdd(String(uid), [flag], { uid: true });
} else {
await client.messageFlagsRemove(String(uid), [flag], { uid: true });
}
} finally {
lock.release();
}
} finally {
await client.logout();
}
}