343 lines
9.2 KiB
TypeScript
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()
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|