mailbox-utils.ts
1 import fs from 'fs' 2 import path from 'path' 3 import { ImapFlow } from 'imapflow' 4 import { createTransport } from 'nodemailer' 5 import { simpleParser } from 'mailparser' 6 import type { Connector } from '@/types' 7 import { loadConnectors } from '@/lib/server/connectors/connector-repository' 8 import { getExtensionManager } from '@/lib/server/extensions' 9 import { UPLOAD_DIR } from '@/lib/server/upload-path' 10 11 export interface MailboxConfig { 12 imapHost: string 13 imapPort: number 14 smtpHost: string 15 smtpPort: number 16 user: string 17 password: string 18 smtpUsername: string 19 smtpPassword: string 20 folder: string 21 subjectPrefix?: string 22 fromAddress: string 23 fromName: string 24 } 25 26 export interface MailboxAttachment { 27 id: string 28 filename: string 29 contentType: string | null 30 sizeBytes: number 31 } 32 33 export interface MailboxMessage { 34 id: string 35 uid: number 36 messageId: string | null 37 subject: string 38 from: string 39 fromName: string 40 date: string | null 41 snippet: string 42 text: string 43 html: string | null 44 threadKey: string 45 references: string[] 46 hasAttachments: boolean 47 attachments: MailboxAttachment[] 48 flags: string[] 49 } 50 51 function pickString(...values: unknown[]): string { 52 for (const value of values) { 53 if (typeof value !== 'string') continue 54 const trimmed = value.trim() 55 if (trimmed) return trimmed 56 } 57 return '' 58 } 59 60 function pickNumber(fallback: number, ...values: unknown[]): number { 61 for (const value of values) { 62 const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN 63 if (Number.isFinite(parsed) && parsed > 0) return Math.trunc(parsed) 64 } 65 return fallback 66 } 67 68 function normalizeThreadKey(subject: string, references: string[]): string { 69 if (references.length > 0) return references[references.length - 1] 70 return subject.replace(/^re:\s*/i, '').trim().toLowerCase() 71 } 72 73 function sanitizeAttachmentName(value: string | undefined, fallback: string): string { 74 const cleaned = String(value || '').replace(/[^a-zA-Z0-9._-]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '') 75 return cleaned || fallback 76 } 77 78 export function getMailboxConfig(): MailboxConfig { 79 const extensionManager = getExtensionManager() 80 const mailboxSettings = extensionManager.getExtensionSettings('mailbox') as Record<string, unknown> 81 const emailSettings = extensionManager.getExtensionSettings('email') as Record<string, unknown> 82 const connectors = loadConnectors() 83 const emailConnector = Object.values(connectors) 84 .find((entry) => entry.platform === 'email') as Connector | undefined 85 const connectorConfig = emailConnector && typeof emailConnector.config === 'object' && emailConnector.config 86 ? emailConnector.config as Record<string, unknown> 87 : {} 88 89 const user = pickString(mailboxSettings.user, connectorConfig.user) 90 const password = pickString(mailboxSettings.password, connectorConfig.password) 91 92 return { 93 imapHost: pickString(mailboxSettings.imapHost, connectorConfig.imapHost), 94 imapPort: pickNumber(993, mailboxSettings.imapPort, connectorConfig.imapPort), 95 smtpHost: pickString(mailboxSettings.smtpHost, emailSettings.host, connectorConfig.smtpHost), 96 smtpPort: pickNumber(587, mailboxSettings.smtpPort, emailSettings.port, connectorConfig.smtpPort), 97 user, 98 password, 99 smtpUsername: pickString(mailboxSettings.smtpUsername, emailSettings.username, connectorConfig.user, user), 100 smtpPassword: pickString(mailboxSettings.smtpPassword, emailSettings.password, connectorConfig.password, password), 101 folder: pickString(mailboxSettings.folder, connectorConfig.folder, 'INBOX') || 'INBOX', 102 subjectPrefix: pickString(mailboxSettings.subjectPrefix, connectorConfig.subjectPrefix) || undefined, 103 fromAddress: pickString(mailboxSettings.fromAddress, emailSettings.fromAddress, connectorConfig.user, user), 104 fromName: pickString(mailboxSettings.fromName, emailSettings.fromName, 'SwarmClaw Agent'), 105 } 106 } 107 108 function ensureMailboxConfigured(config: MailboxConfig): void { 109 if (!config.imapHost || !config.user || !config.password) { 110 throw new Error('Mailbox extension requires IMAP host, user, and password.') 111 } 112 } 113 114 async function withImapClient<T>(config: MailboxConfig, fn: (client: ImapFlow) => Promise<T>): Promise<T> { 115 ensureMailboxConfigured(config) 116 const client = new ImapFlow({ 117 host: config.imapHost, 118 port: config.imapPort, 119 secure: config.imapPort === 993, 120 auth: { 121 user: config.user, 122 pass: config.password, 123 }, 124 logger: false, 125 }) 126 await client.connect() 127 try { 128 return await fn(client) 129 } finally { 130 try { await client.logout() } catch { /* ignore */ } 131 } 132 } 133 134 function messageMatchesFilters(message: MailboxMessage, filters: { 135 query?: string 136 from?: string 137 subjectContains?: string 138 bodyContains?: string 139 unreadOnly?: boolean 140 hasAttachments?: boolean 141 uidGreaterThan?: number 142 }) { 143 if (typeof filters.uidGreaterThan === 'number' && message.uid <= filters.uidGreaterThan) return false 144 if (filters.unreadOnly === true && message.flags.includes('\\Seen')) return false 145 if (filters.hasAttachments === true && !message.hasAttachments) return false 146 const from = filters.from?.trim().toLowerCase() 147 if (from && !message.from.toLowerCase().includes(from) && !message.fromName.toLowerCase().includes(from)) return false 148 const subjectContains = filters.subjectContains?.trim().toLowerCase() 149 if (subjectContains && !message.subject.toLowerCase().includes(subjectContains)) return false 150 const bodyContains = filters.bodyContains?.trim().toLowerCase() 151 if (bodyContains && !message.text.toLowerCase().includes(bodyContains)) return false 152 const query = filters.query?.trim().toLowerCase() 153 if (query) { 154 const hay = `${message.subject}\n${message.from}\n${message.fromName}\n${message.text}`.toLowerCase() 155 if (!hay.includes(query)) return false 156 } 157 return true 158 } 159 160 function toMailboxMessage(raw: { 161 uid: number 162 envelope?: { 163 from?: Array<{ name?: string; address?: string }> 164 subject?: string 165 messageId?: string 166 date?: Date 167 inReplyTo?: string 168 references?: string[] 169 } 170 flags?: Set<string> 171 source?: Buffer 172 }, parsed: Awaited<ReturnType<typeof simpleParser>>): MailboxMessage { 173 const fromAddress = raw.envelope?.from?.[0]?.address || parsed.from?.value?.[0]?.address || 'unknown' 174 const fromName = raw.envelope?.from?.[0]?.name || parsed.from?.value?.[0]?.name || fromAddress 175 const references = [ 176 ...(Array.isArray(raw.envelope?.references) ? raw.envelope?.references : []), 177 ...(parsed.references ? (Array.isArray(parsed.references) ? parsed.references : [parsed.references]) : []), 178 ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0) 179 180 return { 181 id: String(raw.uid), 182 uid: raw.uid, 183 messageId: raw.envelope?.messageId || parsed.messageId || null, 184 subject: raw.envelope?.subject || parsed.subject || '(no subject)', 185 from: fromAddress, 186 fromName, 187 date: raw.envelope?.date ? raw.envelope.date.toISOString() : (parsed.date ? parsed.date.toISOString() : null), 188 snippet: (parsed.text || parsed.html || '').replace(/\s+/g, ' ').trim().slice(0, 240), 189 text: (parsed.text || '').trim(), 190 html: typeof parsed.html === 'string' ? parsed.html : null, 191 threadKey: normalizeThreadKey(raw.envelope?.subject || parsed.subject || '', references), 192 references, 193 hasAttachments: parsed.attachments.length > 0, 194 attachments: parsed.attachments.map((attachment, index) => ({ 195 id: `${raw.uid}:${index}`, 196 filename: sanitizeAttachmentName(attachment.filename || undefined, `attachment-${index + 1}`), 197 contentType: attachment.contentType || null, 198 sizeBytes: attachment.size || 0, 199 })), 200 flags: Array.from(raw.flags || new Set<string>()), 201 } 202 } 203 204 export async function getMailboxHighwaterUid(config = getMailboxConfig(), folder?: string): Promise<number> { 205 return withImapClient(config, async (client) => { 206 const targetFolder = folder || config.folder || 'INBOX' 207 const lock = await client.getMailboxLock(targetFolder) 208 try { 209 const status = await client.status(targetFolder, { uidNext: true }) 210 return typeof status.uidNext === 'number' ? Math.max(0, status.uidNext - 1) : 0 211 } finally { 212 lock.release() 213 } 214 }) 215 } 216 217 export async function fetchMailboxMessages(filters?: { 218 folder?: string 219 query?: string 220 from?: string 221 subjectContains?: string 222 bodyContains?: string 223 unreadOnly?: boolean 224 hasAttachments?: boolean 225 uidGreaterThan?: number 226 limit?: number 227 }): Promise<MailboxMessage[]> { 228 const config = getMailboxConfig() 229 return withImapClient(config, async (client) => { 230 const folder = filters?.folder || config.folder || 'INBOX' 231 const limit = Math.max(1, Math.min(filters?.limit || 20, 100)) 232 const lock = await client.getMailboxLock(folder) 233 try { 234 const status = await client.status(folder, { uidNext: true }) 235 const endUid = typeof status.uidNext === 'number' ? Math.max(0, status.uidNext - 1) : 0 236 if (endUid <= 0) return [] 237 const startUid = Math.max(1, endUid - Math.max(limit * 4, 60) + 1) 238 const messages: MailboxMessage[] = [] 239 for await (const raw of client.fetch(`${startUid}:${endUid}`, { 240 uid: true, 241 envelope: true, 242 flags: true, 243 source: true, 244 }, { uid: true })) { 245 if (!raw.source) continue 246 const parsed = await simpleParser(raw.source) 247 const message = toMailboxMessage(raw, parsed) 248 if (!messageMatchesFilters(message, filters || {})) continue 249 if (config.subjectPrefix && !message.subject.startsWith(config.subjectPrefix)) continue 250 messages.push(message) 251 } 252 return messages.sort((a, b) => b.uid - a.uid).slice(0, limit) 253 } finally { 254 lock.release() 255 } 256 }) 257 } 258 259 export async function fetchMailboxMessageByUid(uid: number, folder?: string): Promise<MailboxMessage | null> { 260 const messages = await fetchMailboxMessages({ folder, uidGreaterThan: uid - 1, limit: 100 }) 261 return messages.find((message) => message.uid === uid) || null 262 } 263 264 export async function downloadMailboxAttachment(params: { 265 uid: number 266 attachmentId?: string 267 attachmentName?: string 268 folder?: string 269 saveTo?: string 270 cwd?: string 271 }): Promise<{ filePath: string; fileName: string; url: string | null }> { 272 const config = getMailboxConfig() 273 return withImapClient(config, async (client) => { 274 const folder = params.folder || config.folder || 'INBOX' 275 const lock = await client.getMailboxLock(folder) 276 try { 277 for await (const raw of client.fetch(String(params.uid), { uid: true, source: true }, { uid: true })) { 278 if (!raw.source) continue 279 const parsed = await simpleParser(raw.source) 280 const selected = parsed.attachments.find((attachment, index) => { 281 const generatedId = `${params.uid}:${index}` 282 if (params.attachmentId && generatedId === params.attachmentId) return true 283 if (params.attachmentName && attachment.filename === params.attachmentName) return true 284 return !params.attachmentId && !params.attachmentName && index === 0 285 }) 286 if (!selected) throw new Error('Attachment not found.') 287 288 const fileName = sanitizeAttachmentName(selected.filename || undefined, `attachment-${params.uid}`) 289 const targetPath = params.saveTo 290 ? path.resolve(params.cwd || process.cwd(), params.saveTo) 291 : path.join(UPLOAD_DIR, `${Date.now()}-${fileName}`) 292 fs.mkdirSync(path.dirname(targetPath), { recursive: true }) 293 fs.writeFileSync(targetPath, selected.content) 294 295 const publicPath = targetPath.startsWith(UPLOAD_DIR) 296 ? targetPath 297 : path.join(UPLOAD_DIR, `${Date.now()}-${path.basename(targetPath)}`) 298 if (publicPath !== targetPath) fs.copyFileSync(targetPath, publicPath) 299 return { 300 filePath: targetPath, 301 fileName, 302 url: `/api/uploads/${path.basename(publicPath)}`, 303 } 304 } 305 throw new Error(`Mailbox message not found: ${params.uid}`) 306 } finally { 307 lock.release() 308 } 309 }) 310 } 311 312 export async function replyMailboxMessage(params: { 313 uid: number 314 text: string 315 html?: string 316 subject?: string 317 folder?: string 318 }): Promise<{ to: string; subject: string }> { 319 const config = getMailboxConfig() 320 if (!config.smtpHost || !config.fromAddress) { 321 throw new Error('Mailbox reply requires SMTP host and fromAddress configuration.') 322 } 323 324 const message = await fetchMailboxMessageByUid(params.uid, params.folder) 325 if (!message) throw new Error(`Mailbox message not found: ${params.uid}`) 326 327 const transport = createTransport({ 328 host: config.smtpHost, 329 port: config.smtpPort, 330 secure: config.smtpPort === 465, 331 auth: { 332 user: config.smtpUsername || config.user, 333 pass: config.smtpPassword || config.password, 334 }, 335 }) 336 337 const subject = params.subject?.trim() || `Re: ${message.subject.replace(/^Re:\s*/i, '')}` 338 await transport.sendMail({ 339 from: config.fromName ? `"${config.fromName}" <${config.fromAddress}>` : config.fromAddress, 340 to: message.from, 341 subject, 342 text: params.text, 343 html: params.html, 344 inReplyTo: message.messageId || undefined, 345 references: message.messageId || undefined, 346 }) 347 348 return { to: message.from, subject } 349 }