letsbe-hub-dashboard/src/lib/imap-client.ts

343 lines
9.2 KiB
TypeScript

import { ImapFlow } from 'imapflow'
import { simpleParser, ParsedMail } from 'mailparser'
function getImapConfig() {
return {
host: process.env.IMAP_HOST || 'localhost',
port: Number(process.env.IMAP_PORT) || 993,
secure: (Number(process.env.IMAP_PORT) || 993) === 993,
auth: {
user: process.env.MAIL_USER || '',
pass: process.env.MAIL_PASSWORD || '',
},
logger: false as const,
}
}
async function withClient<T>(fn: (client: ImapFlow) => Promise<T>): Promise<T> {
const client = new ImapFlow(getImapConfig())
await client.connect()
try {
return await fn(client)
} finally {
await client.logout()
}
}
export interface MailFolder {
path: string
name: string
delimiter: string
specialUse: string | null
subscribed: boolean
messages: number
unseen: number
}
export interface MessageSummary {
uid: number
from: { name: string; address: string } | null
to: { name: string; address: string }[]
subject: string
date: string
flags: string[]
preview: string
}
export interface FullMessage {
uid: number
from: { name: string; address: string } | null
to: { name: string; address: string }[]
cc: { name: string; address: string }[]
bcc: { name: string; address: string }[]
replyTo: { name: string; address: string } | null
subject: string
date: string
flags: string[]
html: string
text: string
attachments: {
filename: string
contentType: string
size: number
contentId: string | null
contentDisposition: string
}[]
messageId: string | null
inReplyTo: string | null
references: string[]
}
function parseAddress(addr: { name?: string; address?: string } | undefined) {
if (!addr) return null
return { name: addr.name || '', address: addr.address || '' }
}
function parseAddressList(
addrs: ParsedMail['to']
): { name: string; address: string }[] {
if (!addrs) return []
const list = Array.isArray(addrs) ? addrs : [addrs]
const result: { name: string; address: string }[] = []
for (const group of list) {
if ('value' in group) {
for (const a of group.value) {
result.push({ name: a.name || '', address: a.address || '' })
}
}
}
return result
}
export async function listFolders(): Promise<MailFolder[]> {
return withClient(async (client) => {
const mailboxes = await client.list()
const folders: MailFolder[] = []
for (const mb of mailboxes) {
let messages = 0
let unseen = 0
try {
const status = await client.status(mb.path, { messages: true, unseen: true })
messages = status.messages ?? 0
unseen = status.unseen ?? 0
} catch {
// Some folders may not support STATUS
}
const parts = mb.path.split(mb.delimiter || '/')
folders.push({
path: mb.path,
name: parts[parts.length - 1] || mb.path,
delimiter: mb.delimiter || '/',
specialUse: (mb as Record<string, unknown>).specialUse as string | null ?? null,
subscribed: mb.subscribed ?? false,
messages,
unseen,
})
}
return folders
})
}
export async function listMessages(
folder: string,
page: number,
limit: number
): Promise<{ messages: MessageSummary[]; total: number }> {
return withClient(async (client) => {
const lock = await client.getMailboxLock(folder)
try {
const total = client.mailbox?.exists ?? 0
if (total === 0) {
return { messages: [], total: 0 }
}
// Calculate range (newest first)
const end = total - (page - 1) * limit
const start = Math.max(1, end - limit + 1)
if (end < 1) {
return { messages: [], total }
}
const messages: MessageSummary[] = []
for await (const msg of client.fetch(`${start}:${end}`, {
uid: true,
envelope: true,
flags: true,
bodyStructure: true,
headers: ['content-type'],
})) {
const env = msg.envelope
messages.push({
uid: msg.uid,
from: env.from?.[0]
? { name: env.from[0].name || '', address: env.from[0].address || '' }
: null,
to: (env.to || []).map((a: { name?: string; address?: string }) => ({
name: a.name || '',
address: a.address || '',
})),
subject: env.subject || '(No Subject)',
date: env.date ? new Date(env.date).toISOString() : new Date().toISOString(),
flags: Array.from(msg.flags || []),
preview: '',
})
}
// Reverse so newest first
messages.reverse()
return { messages, total }
} finally {
lock.release()
}
})
}
export async function getMessage(
folder: string,
uid: number
): Promise<FullMessage | null> {
return withClient(async (client) => {
const lock = await client.getMailboxLock(folder)
try {
const raw = await client.fetchOne(String(uid), { source: true, flags: true, uid: true }, { uid: true })
if (!raw?.source) return null
const parsed = await simpleParser(raw.source)
return {
uid: raw.uid,
from: parseAddress(parsed.from?.value?.[0]),
to: parseAddressList(parsed.to),
cc: parseAddressList(parsed.cc),
bcc: parseAddressList(parsed.bcc),
replyTo: parsed.replyTo?.value?.[0]
? parseAddress(parsed.replyTo.value[0])
: null,
subject: parsed.subject || '(No Subject)',
date: parsed.date ? parsed.date.toISOString() : new Date().toISOString(),
flags: Array.from(raw.flags || []),
html: parsed.html || '',
text: parsed.text || '',
attachments: (parsed.attachments || []).map((att) => ({
filename: att.filename || 'attachment',
contentType: att.contentType || 'application/octet-stream',
size: att.size || 0,
contentId: att.contentId || null,
contentDisposition: att.contentDisposition || 'attachment',
})),
messageId: parsed.messageId || null,
inReplyTo: (parsed.inReplyTo as string) || null,
references: Array.isArray(parsed.references)
? parsed.references
: parsed.references
? [parsed.references]
: [],
}
} finally {
lock.release()
}
})
}
export async function updateFlags(
folder: string,
uid: number,
action: 'add' | 'remove',
flags: string[]
): Promise<void> {
return withClient(async (client) => {
const lock = await client.getMailboxLock(folder)
try {
if (action === 'add') {
await client.messageFlagsAdd(String(uid), flags, { uid: true })
} else {
await client.messageFlagsRemove(String(uid), flags, { uid: true })
}
} finally {
lock.release()
}
})
}
export async function moveMessage(
folder: string,
uid: number,
destination: string
): Promise<void> {
return withClient(async (client) => {
const lock = await client.getMailboxLock(folder)
try {
await client.messageMove(String(uid), destination, { uid: true })
} finally {
lock.release()
}
})
}
export async function deleteMessage(
folder: string,
uid: number
): Promise<void> {
return withClient(async (client) => {
const lock = await client.getMailboxLock(folder)
try {
// Try to move to Trash first
const mailboxes = await client.list()
const trash = mailboxes.find(
(mb) => (mb as Record<string, unknown>).specialUse === '\\Trash'
)
if (trash && folder !== trash.path) {
await client.messageMove(String(uid), trash.path, { uid: true })
} else {
// Already in trash or no trash folder -- permanently delete
await client.messageFlagsAdd(String(uid), ['\\Deleted'], { uid: true })
await client.messageDelete(String(uid), { uid: true })
}
} finally {
lock.release()
}
})
}
export async function searchMessages(
query: string,
folder: string
): Promise<MessageSummary[]> {
return withClient(async (client) => {
const lock = await client.getMailboxLock(folder)
try {
const uids = await client.search(
{
or: [
{ subject: query },
{ from: query },
{ to: query },
{ body: query },
],
},
{ uid: true }
)
if (uids.length === 0) return []
// Limit results
const limitedUids = uids.slice(-100)
const uidRange = limitedUids.join(',')
const messages: MessageSummary[] = []
for await (const msg of client.fetch(uidRange, {
uid: true,
envelope: true,
flags: true,
}, { uid: true })) {
const env = msg.envelope
messages.push({
uid: msg.uid,
from: env.from?.[0]
? { name: env.from[0].name || '', address: env.from[0].address || '' }
: null,
to: (env.to || []).map((a: { name?: string; address?: string }) => ({
name: a.name || '',
address: a.address || '',
})),
subject: env.subject || '(No Subject)',
date: env.date ? new Date(env.date).toISOString() : new Date().toISOString(),
flags: Array.from(msg.flags || []),
preview: '',
})
}
messages.reverse()
return messages
} finally {
lock.release()
}
})
}