import express from 'express'; import path from 'path'; import { fileURLToPath } from 'url'; import crypto from 'crypto'; import dotenv from 'dotenv'; import imaps from 'imap-simple'; import { simpleParser } from 'mailparser'; import nodemailer from 'nodemailer'; dotenv.config(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); app.use(express.json({ limit: '5mb' })); function imapConfig({ user, password, host, port, tls }) { return { imap: { user, password, host, port: Number(port || 993), tls: tls !== false, authTimeout: 10000, connTimeout: 15000, tlsOptions: { rejectUnauthorized: false } } }; } function extractSenderEmail(value) { const raw = String(value || '').trim(); const match = raw.match(/<([^>]+)>/); return (match?.[1] || raw).trim().toLowerCase(); } function gravatarUrl(senderValue) { const email = extractSenderEmail(senderValue); if (!email || !email.includes('@')) return ''; const hash = crypto.createHash('md5').update(email).digest('hex'); return `https://www.gravatar.com/avatar/${hash}?s=64&d=404`; } function flattenBoxes(tree, prefix = '') { const names = []; for (const key of Object.keys(tree || {})) { const box = tree[key]; const delimiter = box?.delimiter || '/'; const full = prefix ? `${prefix}${delimiter}${key}` : key; if (!box?.attribs || !box.attribs.includes('\\Noselect')) names.push(full); if (box?.children) names.push(...flattenBoxes(box.children, full)); } return names; } function parseHeaderRow(res) { const header = res.parts.find((p) => p.which === 'HEADER.FIELDS (FROM TO SUBJECT DATE)')?.body || {}; const textPart = res.parts.find((p) => p.which === 'TEXT'); let snippet = ''; if (textPart?.body) { snippet = String(textPart.body) .replace(/(<([^>]+)>)/gi, '') .replace(/\s+/g, ' ') .trim() .substring(0, 120); if (snippet) snippet += '...'; } const sender = header.from ? header.from[0] : 'Unknown'; return { id: res.attributes.uid, subject: header.subject ? header.subject[0] : '(No Subject)', sender, avatar: gravatarUrl(sender), time: header.date ? new Date(header.date[0]).toLocaleString() : '', unread: !res.attributes.flags.includes('\\Seen'), snippet }; } app.post('/api/webmail/connect', async (req, res) => { const { user, password, host } = req.body; if (!user || !password || !host) return res.status(400).json({ error: 'Missing credentials' }); let connection; try { connection = await imaps.connect(imapConfig(req.body)); const boxes = await connection.getBoxes(); const folderNames = flattenBoxes(boxes); const folders = []; for (const name of folderNames) { try { const box = await connection.openBox(name, true); folders.push({ name, total: box.messages.total || 0, unseen: box.messages.unseen || 0 }); } catch { folders.push({ name, total: 0, unseen: 0 }); } } res.json({ success: true, folders }); } catch (err) { res.status(500).json({ error: err.message || 'Failed to connect to IMAP server' }); } finally { if (connection) connection.end(); } }); app.post('/api/webmail/inbox', async (req, res) => { const { user, password, host, folder = 'INBOX', offset = 0, limit = 50, fetchAll = false, direction = 'newest', sync = false, sinceUid = 0, knownUids = [] } = req.body; if (!user || !password || !host) return res.status(400).json({ error: 'Missing credentials' }); let connection; try { connection = await imaps.connect(imapConfig(req.body)); await connection.openBox(folder); const safeOffset = Math.max(0, Number(offset) || 0); const safeLimit = Math.max(1, Math.min(200, Number(limit) || 50)); const loadAll = fetchAll === true || String(fetchAll).toLowerCase() === 'true'; const oldest = String(direction).toLowerCase() === 'oldest'; const syncMode = sync === true || String(sync).toLowerCase() === 'true'; const fetchOptions = { bodies: ['HEADER.FIELDS (FROM TO SUBJECT DATE)', 'TEXT'], struct: true, markSeen: false }; const allUids = await new Promise((resolve, reject) => { connection.imap.search(['ALL'], (err, results) => { if (err) return reject(err); resolve(Array.isArray(results) ? results : []); }); }); const sortedUids = [...allUids].sort((a, b) => a - b); const total = sortedUids.length; if (syncMode) { const since = Math.max(0, Number(sinceUid) || 0); const known = new Set(Array.isArray(knownUids) ? knownUids.map((u) => Number(u)).filter((u) => Number.isFinite(u) && u > 0) : []); const current = new Set(sortedUids); const deletedUids = [...known].filter((uid) => !current.has(uid)); const newUidList = sortedUids.filter((uid) => uid > since); let newResults = []; if (newUidList.length > 0) { newResults = await connection.search([['UID', newUidList.join(',')]], fetchOptions); } const newEmails = newResults.map(parseHeaderRow).sort((a, b) => Number(a.id || 0) - Number(b.id || 0)); return res.json({ sync: true, total, newEmails, deletedUids }); } let targetUids = sortedUids; if (!loadAll) { if (oldest) { const start = safeOffset; const end = Math.min(total, start + safeLimit); targetUids = sortedUids.slice(start, end); } else { const end = Math.max(0, total - safeOffset); const start = Math.max(0, end - safeLimit); targetUids = sortedUids.slice(start, end); } } if (targetUids.length === 0) { return res.json({ emails: [], total, hasMore: false, nextOffset: safeOffset }); } const results = await connection.search([['UID', targetUids.join(',')]], fetchOptions); const emails = results.map(parseHeaderRow).sort((a, b) => Number(a.id || 0) - Number(b.id || 0)); const nextOffset = loadAll ? total : Math.min(total, safeOffset + targetUids.length); const hasMore = loadAll ? false : nextOffset < total; res.json({ emails, total, hasMore, nextOffset }); } catch (err) { res.status(500).json({ error: err.message || 'Failed to fetch inbox' }); } finally { if (connection) connection.end(); } }); app.post('/api/webmail/message', async (req, res) => { const { user, password, host, folder = 'INBOX', uid } = req.body; if (!user || !password || !host || !uid) return res.status(400).json({ error: 'Missing credentials or UID' }); let connection; try { connection = await imaps.connect(imapConfig(req.body)); await connection.openBox(folder); const results = await connection.search([['UID', uid]], { bodies: [''], markSeen: true }); if (!results.length) return res.status(404).json({ error: 'Message not found' }); const rawEmail = results[0].parts.find((p) => p.which === '').body; const parsed = await simpleParser(rawEmail); const hasHtmlBody = typeof parsed.html === 'string' && parsed.html.trim().length > 0; let html = parsed.html || parsed.textAsHtml || parsed.text || ''; if (typeof html !== 'string') html = String(html || ''); const inlineAttachments = (parsed.attachments || []).filter((a) => a?.content && (a?.cid || a?.contentId)); if (html && inlineAttachments.length > 0) { for (const attachment of inlineAttachments) { const rawCid = String(attachment.cid || attachment.contentId || '').trim(); const cid = rawCid.replace(/^<|>$/g, ''); if (!cid) continue; const mime = String(attachment.contentType || 'application/octet-stream'); const base64 = Buffer.from(attachment.content).toString('base64'); const safeCid = cid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); html = html.replace(new RegExp(`cid:${safeCid}`, 'gi'), `data:${mime};base64,${base64}`); } } res.json({ id: uid, subject: parsed.subject, sender: parsed.from ? parsed.from.text : '', to: parsed.to ? parsed.to.text : '', date: parsed.date, html, isPlainText: !hasHtmlBody, attachments: (parsed.attachments || []).map((a) => ({ filename: a.filename, size: a.size, contentType: a.contentType })) }); } catch (err) { res.status(500).json({ error: err.message || 'Failed to fetch message' }); } finally { if (connection) connection.end(); } }); app.post('/api/webmail/send', async (req, res) => { const { user, password, host, port, to, subject, body } = req.body; if (!user || !password || !host || !to) return res.status(400).json({ error: 'Missing arguments' }); try { const transporter = nodemailer.createTransport({ host, port: Number(port || 465), secure: Number(port || 465) === 465, auth: { user, pass: password }, tls: { rejectUnauthorized: false } }); await transporter.sendMail({ from: user, to, subject: subject || 'No Subject', html: body }); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message || 'Failed to send message' }); } }); app.post('/api/webmail/delete', async (req, res) => { const { user, password, host, folder = 'INBOX', uid } = req.body; if (!user || !password || !host || !uid) return res.status(400).json({ error: 'Missing credentials or UID' }); let connection; try { connection = await imaps.connect(imapConfig(req.body)); await connection.openBox(folder); await connection.addFlags(uid, '\\Deleted'); await new Promise((resolve, reject) => connection.imap.expunge((err) => (err ? reject(err) : resolve()))); res.json({ success: true, uid: Number(uid) }); } catch (err) { res.status(500).json({ error: err.message || 'Failed to delete message' }); } finally { if (connection) connection.end(); } }); app.post('/api/webmail/delete-bulk', async (req, res) => { const { user, password, host, folder = 'INBOX', uids } = req.body; const uidList = Array.isArray(uids) ? uids.map((u) => Number(u)).filter((u) => Number.isFinite(u) && u > 0) : []; if (!user || !password || !host || uidList.length === 0) return res.status(400).json({ error: 'Missing credentials or UIDs' }); let connection; try { connection = await imaps.connect(imapConfig(req.body)); await connection.openBox(folder); await connection.addFlags(uidList, '\\Deleted'); await new Promise((resolve, reject) => connection.imap.expunge((err) => (err ? reject(err) : resolve()))); res.json({ success: true, deleted: uidList.length, uids: uidList }); } catch (err) { res.status(500).json({ error: err.message || 'Failed to delete selected messages' }); } finally { if (connection) connection.end(); } }); app.post('/api/webmail/empty-trash', async (req, res) => { const { user, password, host, folder = 'INBOX.Trash' } = req.body; if (!user || !password || !host) return res.status(400).json({ error: 'Missing credentials' }); let connection; try { connection = await imaps.connect(imapConfig(req.body)); await connection.openBox(folder); const uids = await new Promise((resolve, reject) => { connection.imap.search(['ALL'], (err, results) => { if (err) return reject(err); resolve(Array.isArray(results) ? results : []); }); }); if (!uids.length) return res.json({ success: true, deleted: 0 }); await connection.addFlags(uids, '\\Deleted'); await new Promise((resolve, reject) => connection.imap.expunge((err) => (err ? reject(err) : resolve()))); res.json({ success: true, deleted: uids.length }); } catch (err) { res.status(500).json({ error: err.message || 'Failed to empty trash' }); } finally { if (connection) connection.end(); } }); app.post('/api/webmail/quota', async (_req, res) => { // Safe default for standalone starter. Can be enhanced for provider-specific quota later. res.json({ supported: false }); }); app.post('/api/webmail/folder-sizes', async (_req, res) => { // Safe default for standalone starter. Can be enhanced when needed. res.json({ success: true, sizes: {} }); }); if (process.env.NODE_ENV === 'production') { app.use(express.static(path.join(__dirname, 'dist'))); app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')); }); } const PORT = Number(process.env.PORT || 3001); app.listen(PORT, () => { console.log(`Webmail server running on http://localhost:${PORT}`); });