340 lines
9.0 KiB
TypeScript
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();
|
|
}
|
|
}
|