first commit
This commit is contained in:
339
lib/imap.ts
Normal file
339
lib/imap.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user