/ src / lib / server / chatrooms / mailbox-utils.ts
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  }