diff --git a/nx-webmail/.dockerignore b/nx-webmail/.dockerignore new file mode 100644 index 0000000..ca118a9 --- /dev/null +++ b/nx-webmail/.dockerignore @@ -0,0 +1,9 @@ +node_modules +npm-debug.log +dist +.git +.gitignore +Dockerfile* +.dockerignore +.env +.env.* diff --git a/nx-webmail/.env.example b/nx-webmail/.env.example new file mode 100644 index 0000000..a9b5fd9 --- /dev/null +++ b/nx-webmail/.env.example @@ -0,0 +1,2 @@ +PORT=3001 +NODE_ENV=production diff --git a/nx-webmail/Dockerfile b/nx-webmail/Dockerfile new file mode 100644 index 0000000..f8ebc65 --- /dev/null +++ b/nx-webmail/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1 +FROM node:20-alpine AS base +WORKDIR /app + +FROM base AS deps +COPY package*.json ./ +RUN npm install + +FROM deps AS build +COPY . . +RUN npm run build + +FROM node:20-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3001 +COPY package*.json ./ +COPY --from=deps /app/node_modules ./node_modules +RUN npm prune --omit=dev +COPY --from=build /app/dist ./dist +COPY ./server.js ./server.js +EXPOSE 3001 +CMD ["node", "server.js"] diff --git a/nx-webmail/README.md b/nx-webmail/README.md new file mode 100644 index 0000000..bec2c80 --- /dev/null +++ b/nx-webmail/README.md @@ -0,0 +1,40 @@ +# Standalone Webmail (Docker-ready) + +## Local setup + +1. Install dependencies: + npm install +2. Run dev mode (Vite + API): + npm run dev +3. Open: + http://localhost:5173 + +## Docker (standalone) + +1. Copy env file: + cp .env.example .env +2. Build + run: + docker compose up --build -d +3. Open: + http://localhost:3001 + +## Umbrel app packaging + +This repository is prepared for Umbrel app-store usage. + +1. Put this project into your app folder (folder name must match `id` in `umbrel-app.yml`, currently `nx-webmail`). +2. Keep these files in that folder: + - `docker-compose.yml` + - `umbrel-app.yml` + - `Dockerfile` +3. Add the app folder to your Umbrel community app store repo and push. + +Notes: +- Umbrel uses the `app_proxy` service in `docker-compose.yml`. +- Internal app port is `3001`. +- If your store prefix is not `nx`, change `id` in `umbrel-app.yml` and update `APP_HOST` accordingly. + +## Notes + +- Frontend calls API on `/api/webmail/*`. +- IMAP/SMTP credentials are provided by user login in UI. diff --git a/nx-webmail/docker-compose.yml b/nx-webmail/docker-compose.yml new file mode 100644 index 0000000..e6c917a --- /dev/null +++ b/nx-webmail/docker-compose.yml @@ -0,0 +1,16 @@ +version: "3.7" + +services: + app_proxy: + environment: + APP_HOST: nx-webmail_server_1 + APP_PORT: 3001 + + server: + build: . + image: nx-webmail:1.0.0 + init: true + restart: on-failure + environment: + NODE_ENV: production + PORT: 3001 diff --git a/nx-webmail/index.html b/nx-webmail/index.html new file mode 100644 index 0000000..e7429d7 --- /dev/null +++ b/nx-webmail/index.html @@ -0,0 +1,12 @@ + + + + + + Webmail + + +
+ + + diff --git a/nx-webmail/logo.svg b/nx-webmail/logo.svg new file mode 100644 index 0000000..a1c1b4b --- /dev/null +++ b/nx-webmail/logo.svg @@ -0,0 +1,8 @@ + + Webmail + Envelope icon for Webmail app + + + + + diff --git a/nx-webmail/package.json b/nx-webmail/package.json new file mode 100644 index 0000000..84263c0 --- /dev/null +++ b/nx-webmail/package.json @@ -0,0 +1,26 @@ +{ + "name": "weektab-webmail-standalone", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "concurrently \"vite\" \"node server.js\"", + "build": "vite build", + "start": "node server.js" + }, + "dependencies": { + "dotenv": "^16.4.5", + "express": "^4.21.2", + "imap-simple": "^5.1.0", + "mailparser": "^3.7.2", + "nodemailer": "^6.10.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "remixicon": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^9.1.2", + "vite": "^5.4.11" + } +} diff --git a/nx-webmail/server.js b/nx-webmail/server.js new file mode 100644 index 0000000..2d07aac --- /dev/null +++ b/nx-webmail/server.js @@ -0,0 +1,342 @@ +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}`); +}); diff --git a/nx-webmail/src/components/Webmail.jsx b/nx-webmail/src/components/Webmail.jsx new file mode 100644 index 0000000..057fe11 --- /dev/null +++ b/nx-webmail/src/components/Webmail.jsx @@ -0,0 +1,1830 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +const initialCredentials = { + mode: 'weektab', + localPart: '', + customUser: '', + password: '', + customImapHost: 'mail.weektab.org', + customImapPort: '993' +}; + +const WEBMAIL_SESSION_KEY = 'weektab_webmail_session_v1'; +const WEBMAIL_ACCOUNTS_KEY = 'weektab_webmail_accounts_v1'; +const WEBMAIL_DATA_CACHE_KEY = 'weektab_webmail_data_cache_v1'; +const DEFAULT_DOMAIN = 'weektab.org'; +const DEFAULT_IMAP_HOST = 'mail.weektab.org'; +const DEFAULT_IMAP_PORT = '993'; +const INBOX_PAGE_SIZE = 50; + +function extractEmail(value) { + const match = String(value || '').match(/<(.*?)>/); + if (match?.[1]) return match[1]; + return String(value || '').trim(); +} + +function extractMailBackgroundColor(html) { + const source = String(html || ''); + if (!source) return ''; + + const pickColor = (text) => { + const candidate = String(text || '').trim(); + if (!candidate) return ''; + if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(candidate)) return candidate; + if (/^(rgb|rgba|hsl|hsla)\(/i.test(candidate)) return candidate; + if (/^[a-z]+$/i.test(candidate)) return candidate; + return ''; + }; + + const readStyleColor = (styleValue) => { + const style = String(styleValue || ''); + if (!style) return ''; + const bgColor = style.match(/background-color\s*:\s*([^;]+)/i)?.[1]; + const fromBgColor = pickColor(bgColor); + if (fromBgColor) return fromBgColor; + const bg = style.match(/background\s*:\s*([^;]+)/i)?.[1]; + if (!bg) return ''; + const firstToken = String(bg).trim().split(/\s+/)[0]; + return pickColor(firstToken); + }; + + const bodyTag = source.match(/]*>/i)?.[0] || ''; + if (bodyTag) { + const bodyBg = pickColor(bodyTag.match(/bgcolor=["']?([^"'\s>]+)/i)?.[1]); + if (bodyBg) return bodyBg; + const bodyStyle = bodyTag.match(/style=["']([^"']*)["']/i)?.[1]; + const bodyStyleColor = readStyleColor(bodyStyle); + if (bodyStyleColor) return bodyStyleColor; + } + + const firstTableTag = source.match(/]*>/i)?.[0] || ''; + if (firstTableTag) { + const tableBg = pickColor(firstTableTag.match(/bgcolor=["']?([^"'\s>]+)/i)?.[1]); + if (tableBg) return tableBg; + const tableStyle = firstTableTag.match(/style=["']([^"']*)["']/i)?.[1]; + const tableStyleColor = readStyleColor(tableStyle); + if (tableStyleColor) return tableStyleColor; + } + + return ''; +} + +export default function Webmail() { + const [credentials, setCredentials] = useState(initialCredentials); + const [isConnecting, setIsConnecting] = useState(false); + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [loginError, setLoginError] = useState(''); + + const [folders, setFolders] = useState([]); + const [activeFolder, setActiveFolder] = useState('INBOX'); + const [emails, setEmails] = useState([]); + const [isLoadingInbox, setIsLoadingInbox] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [isRefreshingInbox, setIsRefreshingInbox] = useState(false); + const [pagingByAccount, setPagingByAccount] = useState({}); + const [viewCacheByAccount, setViewCacheByAccount] = useState({}); + const [searchQuery, setSearchQuery] = useState(''); + const [sortOrder, setSortOrder] = useState('newest'); + + const [selectedEmailInfo, setSelectedEmailInfo] = useState(null); + const [activeMessageData, setActiveMessageData] = useState(null); + const [isLoadingMessage, setIsLoadingMessage] = useState(false); + const [isEmptyingTrash, setIsEmptyingTrash] = useState(false); + const [selectedEmailIds, setSelectedEmailIds] = useState([]); + const [isDeletingSelected, setIsDeletingSelected] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + + const [replyText, setReplyText] = useState(''); + const [isSending, setIsSending] = useState(false); + const [isRestoringSession, setIsRestoringSession] = useState(true); + const [isAddingAccount, setIsAddingAccount] = useState(false); + const [savedAccounts, setSavedAccounts] = useState([]); + const [activeAccountId, setActiveAccountId] = useState(''); + const [quotaByAccount, setQuotaByAccount] = useState({}); + const [folderSizesByAccount, setFolderSizesByAccount] = useState({}); + const inboxRequestTokenRef = useRef(0); + const messageRequestTokenRef = useRef(0); + const activeFolderRef = useRef('INBOX'); + const activeAccountIdRef = useRef(''); + const prevSortOrderRef = useRef('newest'); + + const getAuthFromCredentials = (cred) => { + const isCustom = cred.mode === 'custom'; + const user = isCustom + ? String(cred.customUser || '').trim() + : `${String(cred.localPart || '').trim()}@${DEFAULT_DOMAIN}`; + const host = isCustom + ? String(cred.customImapHost || DEFAULT_IMAP_HOST).trim() + : DEFAULT_IMAP_HOST; + const port = isCustom + ? Number(cred.customImapPort || DEFAULT_IMAP_PORT) + : Number(DEFAULT_IMAP_PORT); + + return { + user, + password: cred.password, + host, + port, + tls: true + }; + }; + + const makeAccountId = (auth) => `${String(auth.user || '').toLowerCase()}|${String(auth.host || '').toLowerCase()}|${String(auth.port || '')}`; + + const formatBytes = (bytes) => { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; + }; + + const buildFirstPageView = (emailList, totalCount, direction) => { + const sorted = [...(Array.isArray(emailList) ? emailList : [])] + .sort((a, b) => Number(a?.id || 0) - Number(b?.id || 0)); + const visible = direction === 'oldest' + ? sorted.slice(0, INBOX_PAGE_SIZE) + : sorted.slice(Math.max(0, sorted.length - INBOX_PAGE_SIZE)); + const safeTotal = Number(totalCount || sorted.length || 0); + const nextOffset = Math.min(safeTotal, visible.length); + const hasMore = nextOffset < safeTotal; + return { + emails: visible, + paging: { + total: safeTotal, + nextOffset, + hasMore, + loadedAll: !hasMore, + direction, + updatedAt: Date.now() + } + }; + }; + + const persistAccounts = (accounts) => { + try { + localStorage.setItem(WEBMAIL_ACCOUNTS_KEY, JSON.stringify(accounts)); + } catch { + // Ignore storage errors. + } + }; + + const readDataCache = () => { + try { + const raw = localStorage.getItem(WEBMAIL_DATA_CACHE_KEY); + const parsed = raw ? JSON.parse(raw) : {}; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } + }; + + const writeDataCache = (cache) => { + try { + localStorage.setItem(WEBMAIL_DATA_CACHE_KEY, JSON.stringify(cache)); + } catch { + // Ignore storage errors. + } + }; + + const getAccountCache = (accountId) => { + const cache = readDataCache(); + return cache[accountId] || null; + }; + + const upsertAccountCache = ({ accountId, foldersMeta, folderName, folderEmails }) => { + if (!accountId) return; + + const cache = readDataCache(); + const existing = cache[accountId] || { folders: [], emailsByFolder: {}, updatedAt: 0 }; + const next = { + ...existing, + folders: Array.isArray(foldersMeta) ? foldersMeta : existing.folders, + emailsByFolder: { + ...(existing.emailsByFolder || {}), + ...(folderName ? { [folderName]: folderEmails } : {}) + }, + updatedAt: Date.now() + }; + + cache[accountId] = next; + writeDataCache(cache); + }; + + const authPayload = useMemo(() => getAuthFromCredentials(credentials), [credentials]); + + useEffect(() => { + activeFolderRef.current = activeFolder; + }, [activeFolder]); + + useEffect(() => { + activeAccountIdRef.current = activeAccountId; + }, [activeAccountId]); + + const handleLoginChange = (event) => { + const { name, value } = event.target; + setCredentials((prev) => ({ ...prev, [name]: value })); + }; + + const postJson = async (url, payload) => { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.error || `Request failed (${res.status})`); + } + return data; + }; + + const saveSession = (credState, lastFolder) => { + try { + localStorage.setItem(WEBMAIL_SESSION_KEY, JSON.stringify({ + credentials: credState, + lastFolder: lastFolder || 'INBOX' + })); + } catch { + // Ignore storage errors. + } + }; + + const clearSession = () => { + try { + localStorage.removeItem(WEBMAIL_SESSION_KEY); + } catch { + // Ignore storage errors. + } + }; + + const removeAccountCache = (accountId) => { + if (!accountId) return; + const cache = readDataCache(); + if (Object.prototype.hasOwnProperty.call(cache, accountId)) { + delete cache[accountId]; + writeDataCache(cache); + } + }; + + const clearAllAccountCaches = () => { + try { + localStorage.removeItem(WEBMAIL_DATA_CACHE_KEY); + } catch { + // Ignore storage errors. + } + }; + + const setFolderPaging = (accountId, folderName, nextPaging) => { + if (!accountId || !folderName) return; + setPagingByAccount((prev) => ({ + ...prev, + [accountId]: { + ...(prev[accountId] || {}), + [folderName]: { + ...(prev[accountId]?.[folderName] || {}), + ...(nextPaging || {}) + } + } + })); + }; + + const getViewCache = (accountId, folderName, direction) => ( + viewCacheByAccount?.[accountId]?.[folderName]?.[direction] || null + ); + + const setViewCache = (accountId, folderName, direction, emailsList, paging) => { + if (!accountId || !folderName || !direction) return; + setViewCacheByAccount((prev) => ({ + ...prev, + [accountId]: { + ...(prev[accountId] || {}), + [folderName]: { + ...(prev[accountId]?.[folderName] || {}), + [direction]: { + emails: Array.isArray(emailsList) ? emailsList : [], + paging: paging || {} + } + } + } + })); + }; + + const upsertAccount = (credState, lastFolder = 'INBOX') => { + const auth = getAuthFromCredentials(credState); + const accountId = makeAccountId(auth); + const account = { + id: accountId, + email: auth.user, + host: auth.host, + port: auth.port, + lastFolder, + credentials: { ...credState } + }; + + setSavedAccounts((prev) => { + const next = [...prev]; + const index = next.findIndex((a) => a.id === accountId); + if (index >= 0) next[index] = account; + else next.push(account); + persistAccounts(next); + return next; + }); + setActiveAccountId(accountId); + }; + + const fetchInbox = async ( + folderName, + authOverride = authPayload, + persistSession = false, + credOverride = credentials, + options = {} + ) => { + const requestToken = ++inboxRequestTokenRef.current; + const isRequestCurrent = () => requestToken === inboxRequestTokenRef.current; + const { + keepSelection = false, + silent = false, + append = false, + fetchAll = false, + forceRefresh = false, + syncOnly = false, + resetToFirstPage = false + } = options; + const folder = folderName || activeFolder || 'INBOX'; + const accountId = makeAccountId(authOverride); + const requestDirection = String(sortOrder || 'newest'); + const viewCache = getViewCache(accountId, folder, requestDirection); + const accountCache = getAccountCache(accountId); + const cachedFolderEntry = accountCache?.emailsByFolder?.[folder]; + const viewCachedEmails = Array.isArray(viewCache?.emails) ? viewCache.emails : []; + const legacyCachedEmails = Array.isArray(cachedFolderEntry) + ? cachedFolderEntry + : Array.isArray(cachedFolderEntry?.emails) + ? cachedFolderEntry.emails + : []; + const cachedFolderEmails = viewCachedEmails; + const currentFolderPaging = viewCache?.paging || pagingByAccount?.[accountId]?.[folder] || {}; + const currentOffset = append ? Number(currentFolderPaging.nextOffset || emails.length || 0) : 0; + const currentEmails = append ? emails : []; + const folderChanged = Boolean(folderName) && String(folderName) !== String(activeFolderRef.current); + const shouldResetWindow = !append && (resetToFirstPage || folderChanged); + const baseEmailsForSync = cachedFolderEmails; + const lastLoadedDirection = String(currentFolderPaging.direction || requestDirection); + const canUseIncrementalSync = !append + && !fetchAll + && cachedFolderEmails.length > 0 + && (syncOnly || !forceRefresh) + && baseEmailsForSync.length > 0 + && lastLoadedDirection === 'newest'; + + setActiveFolder(folder); + if (!append && !forceRefresh && cachedFolderEmails.length > 0) { + const initial = shouldResetWindow + ? buildFirstPageView(cachedFolderEmails, currentFolderPaging.total || cachedFolderEmails.length, requestDirection) + : { emails: cachedFolderEmails, paging: currentFolderPaging }; + setEmails(initial.emails); + setIsLoadingInbox(false); + if (!currentFolderPaging.nextOffset) { + const paging = buildFirstPageView(cachedFolderEmails, cachedFolderEmails.length, requestDirection).paging; + setFolderPaging(accountId, folder, paging); + setViewCache(accountId, folder, requestDirection, cachedFolderEmails, paging); + } else if (shouldResetWindow) { + setFolderPaging(accountId, folder, initial.paging); + setViewCache(accountId, folder, requestDirection, cachedFolderEmails, initial.paging); + } + } else if (!append && !forceRefresh && viewCachedEmails.length === 0 && legacyCachedEmails.length > 0) { + // Legacy storage fallback: show quickly, but do not treat as direction cache. + const firstPage = buildFirstPageView(legacyCachedEmails, legacyCachedEmails.length, requestDirection); + setEmails(firstPage.emails); + } + + // Show loading only if there is no cached folder list to show immediately. + if (!silent && !append && (!cachedFolderEmails.length || forceRefresh)) { + setIsLoadingInbox(true); + } + if (append) setIsLoadingMore(true); + if (!keepSelection) { + messageRequestTokenRef.current += 1; + setSelectedEmailInfo(null); + setActiveMessageData(null); + setSelectedEmailIds([]); + } + + try { + if (canUseIncrementalSync) { + const knownIds = baseEmailsForSync + .map((item) => Number(item?.id)) + .filter((uid) => Number.isFinite(uid) && uid > 0); + const maxUid = knownIds.length ? Math.max(...knownIds) : 0; + + const syncData = await postJson('/api/webmail/inbox', { + ...authOverride, + folder, + sync: true, + sinceUid: maxUid, + knownUids: knownIds + }); + + const deletedSet = new Set( + Array.isArray(syncData?.deletedUids) + ? syncData.deletedUids.map((uid) => Number(uid)) + : [] + ); + const merged = baseEmailsForSync.filter((msg) => !deletedSet.has(Number(msg?.id))); + const mergedSet = new Set(merged.map((msg) => Number(msg?.id))); + const newEmails = Array.isArray(syncData?.newEmails) ? syncData.newEmails : []; + for (const msg of newEmails) { + const uid = Number(msg?.id); + if (!mergedSet.has(uid)) { + merged.push(msg); + mergedSet.add(uid); + } + } + + const total = Number(syncData?.total || merged.length || 0); + const firstPageState = shouldResetWindow + ? buildFirstPageView(merged, total, lastLoadedDirection) + : null; + const paging = shouldResetWindow + ? firstPageState.paging + : { + total, + nextOffset: merged.length, + hasMore: merged.length < total, + loadedAll: merged.length >= total, + direction: lastLoadedDirection, + updatedAt: Date.now() + }; + const visibleEmails = shouldResetWindow + ? firstPageState.emails + : merged; + + if (!isRequestCurrent()) return; + setEmails(visibleEmails); + setFolderPaging(accountId, folder, paging); + setViewCache(accountId, folder, lastLoadedDirection, merged, paging); + if (persistSession) { + saveSession(credOverride, folder); + upsertAccount(credOverride, folder); + } + upsertAccountCache({ + accountId, + folderName: folder, + folderEmails: merged + }); + return; + } + + const data = await postJson('/api/webmail/inbox', { + ...authOverride, + folder, + offset: fetchAll ? 0 : currentOffset, + limit: INBOX_PAGE_SIZE, + fetchAll, + direction: requestDirection + }); + const incomingEmails = Array.isArray(data.emails) ? data.emails : []; + const nextEmails = append + ? [...currentEmails, ...incomingEmails.filter((msg) => !currentEmails.some((existing) => existing.id === msg.id))] + : incomingEmails; + const safeTotal = Number(data.total || nextEmails.length || 0); + const safeNextOffset = Number(data.nextOffset || nextEmails.length || 0); + const hasMore = Boolean(data.hasMore); + const loadedAll = fetchAll || safeNextOffset >= safeTotal || !hasMore; + const paging = { + total: safeTotal, + nextOffset: safeNextOffset, + hasMore, + loadedAll, + direction: requestDirection, + updatedAt: Date.now() + }; + const visibleState = shouldResetWindow + ? buildFirstPageView(nextEmails, safeTotal, requestDirection) + : { emails: nextEmails, paging }; + + if (!isRequestCurrent()) return; + setEmails(visibleState.emails); + setFolderPaging(accountId, folder, visibleState.paging); + setViewCache(accountId, folder, requestDirection, nextEmails, paging); + if (persistSession) { + saveSession(credOverride, folder); + upsertAccount(credOverride, folder); + } + upsertAccountCache({ + accountId, + folderName: folder, + folderEmails: nextEmails + }); + } catch (error) { + if (!isRequestCurrent()) return; + setLoginError(error.message || 'Failed to load mailbox.'); + } finally { + if (!isRequestCurrent()) return; + if (!silent && !append && (!cachedFolderEmails.length || forceRefresh)) { + setIsLoadingInbox(false); + } + if (append) setIsLoadingMore(false); + } + }; + + const connectWithCredentials = async (auth, lastFolder, credOverride = credentials, options = {}) => { + const { skipInitialFetchIfCached = false } = options; + try { + const data = await postJson('/api/webmail/connect', auth); + const folderList = Array.isArray(data.folders) ? data.folders : []; + const normalized = folderList.map((f) => (typeof f === 'string' ? { name: f, unseen: 0, total: 0 } : f)); + const accountId = makeAccountId(auth); + const accountCache = getAccountCache(accountId); + + setFolders(normalized); + setIsLoggedIn(true); + setIsAddingAccount(false); + setActiveAccountId(accountId); + + const defaultFolder = normalized.find((f) => String(f.name).toUpperCase() === String(lastFolder || '').toUpperCase())?.name + || normalized.find((f) => String(f.name).toUpperCase() === 'INBOX')?.name + || normalized[0]?.name + || 'INBOX'; + + const cachedDefaultFolderEntry = accountCache?.emailsByFolder?.[defaultFolder]; + const cachedDefaultEmails = Array.isArray(cachedDefaultFolderEntry) + ? cachedDefaultFolderEntry + : Array.isArray(cachedDefaultFolderEntry?.emails) + ? cachedDefaultFolderEntry.emails + : null; + + if (cachedDefaultEmails) { + const firstPage = buildFirstPageView(cachedDefaultEmails, cachedDefaultEmails.length, sortOrder); + setActiveFolder(defaultFolder); + setEmails(firstPage.emails); + } + + upsertAccountCache({ + accountId, + foldersMeta: normalized + }); + + if (skipInitialFetchIfCached && cachedDefaultEmails) { + await fetchInbox(defaultFolder, auth, true, credOverride, { + keepSelection: true, + silent: true, + syncOnly: true, + resetToFirstPage: true + }); + } else { + await fetchInbox(defaultFolder, auth, true, credOverride, { + keepSelection: true, + silent: Boolean(cachedDefaultEmails), + resetToFirstPage: true + }); + } + saveSession(credOverride, defaultFolder); + upsertAccount(credOverride, defaultFolder); + return true; + } catch (error) { + setLoginError(error.message || 'Login failed.'); + setIsLoggedIn(false); + return false; + } + }; + + const connectIMAP = async (event) => { + event.preventDefault(); + setIsConnecting(true); + setLoginError(''); + + try { + const localPart = String(credentials.localPart || '').trim(); + const customUser = String(credentials.customUser || '').trim(); + const customHost = String(credentials.customImapHost || '').trim(); + + if (credentials.mode === 'custom') { + if (!customUser || !customHost) { + setLoginError('Please fill in custom email and IMAP host.'); + return; + } + } else if (!localPart) { + setLoginError('Please enter your username.'); + return; + } + + await connectWithCredentials(authPayload, undefined, credentials); + await fetchQuota(authPayload); + await fetchFolderSizes(authPayload); + } finally { + setIsConnecting(false); + } + }; + + const fetchMessage = async (email) => { + if (!email?.id) return; + const requestToken = ++messageRequestTokenRef.current; + const folderAtRequest = activeFolderRef.current; + const accountAtRequest = activeAccountIdRef.current; + setSelectedEmailInfo(email); + setIsLoadingMessage(true); + + try { + const data = await postJson('/api/webmail/message', { + ...authPayload, + folder: folderAtRequest || 'INBOX', + uid: email.id + }); + if ( + requestToken !== messageRequestTokenRef.current + || folderAtRequest !== activeFolderRef.current + || accountAtRequest !== activeAccountIdRef.current + ) return; + setActiveMessageData(data); + } catch (error) { + if ( + requestToken !== messageRequestTokenRef.current + || folderAtRequest !== activeFolderRef.current + || accountAtRequest !== activeAccountIdRef.current + ) return; + setActiveMessageData({ + html: `

${error.message || 'Failed to load message.'}

` + }); + } finally { + if (requestToken !== messageRequestTokenRef.current) return; + setIsLoadingMessage(false); + } + }; + + const handleOpenMessageWindow = () => { + if (!selectedEmailInfo) return; + const popup = window.open('about:blank', '_blank', 'width=1200,height=800'); + if (!popup) return; + + const escapeHtml = (value) => String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + const subject = escapeHtml(selectedEmailInfo.subject || '(No Subject)'); + const sender = escapeHtml(selectedEmailInfo.sender || 'Unknown'); + const body = activeMessageData?.html || '

No content.

'; + + const popupHtml = ` + + + + ${subject} + + + + +
+
+

${subject}

+

From: ${sender}

+
+
${body}
+
+ +`; + + try { + popup.document.open(); + popup.document.write(popupHtml); + popup.document.close(); + } catch { + popup.location.href = `data:text/html;charset=utf-8,${encodeURIComponent(popupHtml)}`; + } + }; + + const handleDeleteMessage = async () => { + if (!selectedEmailInfo?.id) return; + const confirmed = window.confirm('Delete this email?'); + if (!confirmed) return; + + try { + const uid = Number(selectedEmailInfo.id); + await postJson('/api/webmail/delete', { + ...authPayload, + folder: activeFolder || 'INBOX', + uid + }); + + setEmails((prev) => prev.filter((msg) => Number(msg?.id) !== uid)); + setSelectedEmailInfo(null); + setActiveMessageData(null); + setSelectedEmailIds((prev) => prev.filter((id) => Number(id) !== uid)); + + const accountId = makeAccountId(authPayload); + setViewCacheByAccount((prev) => { + const accountCache = prev?.[accountId]; + if (!accountCache) return prev; + const folderCache = accountCache?.[activeFolder]; + if (!folderCache) return prev; + + const nextFolderCache = {}; + for (const directionKey of Object.keys(folderCache)) { + const directionView = folderCache[directionKey]; + const directionEmails = Array.isArray(directionView?.emails) ? directionView.emails : []; + const filtered = directionEmails.filter((msg) => Number(msg?.id) !== uid); + const total = Math.max(0, Number(directionView?.paging?.total || filtered.length) - 1); + const nextOffset = Math.min(Number(directionView?.paging?.nextOffset || filtered.length), filtered.length); + const hasMore = nextOffset < total; + nextFolderCache[directionKey] = { + ...directionView, + emails: filtered, + paging: { + ...(directionView?.paging || {}), + total, + nextOffset, + hasMore, + loadedAll: !hasMore + } + }; + } + + return { + ...prev, + [accountId]: { + ...accountCache, + [activeFolder]: nextFolderCache + } + }; + }); + + await fetchInbox(activeFolder, authPayload, true, credentials, { + keepSelection: false, + silent: true, + syncOnly: true + }); + } catch (error) { + setLoginError(error.message || 'Failed to delete email.'); + } + }; + + const handleEmptyTrash = async () => { + if (!isTrashFolder) return; + const confirmed = window.confirm('Permanently delete all emails in Trash?'); + if (!confirmed) return; + + setIsEmptyingTrash(true); + try { + await postJson('/api/webmail/empty-trash', { + ...authPayload, + folder: activeFolder || 'INBOX.Trash' + }); + + setEmails([]); + setSelectedEmailInfo(null); + setActiveMessageData(null); + setSelectedEmailIds([]); + + const accountId = makeAccountId(authPayload); + setViewCacheByAccount((prev) => { + const accountCache = prev?.[accountId]; + if (!accountCache) return prev; + const folderCache = accountCache?.[activeFolder] || {}; + const nextFolderCache = {}; + for (const key of Object.keys(folderCache)) { + nextFolderCache[key] = { + ...(folderCache[key] || {}), + emails: [], + paging: { + ...(folderCache[key]?.paging || {}), + total: 0, + nextOffset: 0, + hasMore: false, + loadedAll: true + } + }; + } + return { + ...prev, + [accountId]: { + ...accountCache, + [activeFolder]: nextFolderCache + } + }; + }); + } catch (error) { + setLoginError(error.message || 'Failed to empty trash.'); + } finally { + setIsEmptyingTrash(false); + } + }; + + const fetchQuota = async (authOverride = authPayload) => { + const accountId = makeAccountId(authOverride); + try { + const data = await postJson('/api/webmail/quota', authOverride); + setQuotaByAccount((prev) => ({ + ...prev, + [accountId]: data?.supported + ? { + supported: true, + usedBytes: Number(data.usedBytes || 0), + limitBytes: Number(data.limitBytes || 0), + percent: Number.isFinite(data.percent) ? data.percent : 0 + } + : data?.estimated + ? { + supported: false, + estimated: true, + usedBytes: Number(data.usedBytes || 0) + } + : { supported: false } + })); + } catch { + setQuotaByAccount((prev) => ({ ...prev, [accountId]: { supported: false } })); + } + }; + + const fetchFolderSizes = async (authOverride = authPayload) => { + const accountId = makeAccountId(authOverride); + try { + const data = await postJson('/api/webmail/folder-sizes', authOverride); + const sizes = data?.sizes && typeof data.sizes === 'object' ? data.sizes : {}; + setFolderSizesByAccount((prev) => ({ ...prev, [accountId]: sizes })); + } catch { + setFolderSizesByAccount((prev) => ({ ...prev, [accountId]: {} })); + } + }; + + const sendReply = async () => { + if (!replyText.trim() || !selectedEmailInfo) return; + + setIsSending(true); + try { + const smtpHost = authPayload.host.replace(/^imap\./i, 'smtp.'); + const to = extractEmail(selectedEmailInfo.sender); + + await postJson('/api/webmail/send', { + user: authPayload.user, + password: credentials.password, + host: smtpHost, + port: 465, + to, + subject: `Re: ${selectedEmailInfo.subject || ''}`, + body: `

${replyText.replace(/\n/g, '
')}

` + }); + + setReplyText(''); + } catch (error) { + setLoginError(error.message || 'Failed to send reply.'); + } finally { + setIsSending(false); + } + }; + + const handleLogout = () => { + inboxRequestTokenRef.current += 1; + messageRequestTokenRef.current += 1; + const currentId = activeAccountId; + const remainingAccounts = savedAccounts.filter((a) => a.id !== currentId); + + removeAccountCache(currentId); + persistAccounts(remainingAccounts); + + setIsLoggedIn(false); + setFolders([]); + setEmails([]); + setSelectedEmailInfo(null); + setActiveMessageData(null); + setReplyText(''); + setCredentials(initialCredentials); + setSavedAccounts(remainingAccounts); + setActiveAccountId(remainingAccounts[0]?.id || ''); + setQuotaByAccount((prev) => { + const next = { ...prev }; + delete next[currentId]; + return next; + }); + setFolderSizesByAccount((prev) => { + const next = { ...prev }; + delete next[currentId]; + return next; + }); + setViewCacheByAccount((prev) => { + const next = { ...prev }; + delete next[currentId]; + return next; + }); + setPagingByAccount((prev) => { + const next = { ...prev }; + delete next[currentId]; + return next; + }); + setIsAddingAccount(false); + clearSession(); + }; + + const handleLogoutAll = () => { + inboxRequestTokenRef.current += 1; + messageRequestTokenRef.current += 1; + clearSession(); + clearAllAccountCaches(); + persistAccounts([]); + + setIsLoggedIn(false); + setFolders([]); + setEmails([]); + setSelectedEmailInfo(null); + setActiveMessageData(null); + setReplyText(''); + setCredentials(initialCredentials); + setSavedAccounts([]); + setActiveAccountId(''); + setQuotaByAccount({}); + setFolderSizesByAccount({}); + setViewCacheByAccount({}); + setPagingByAccount({}); + setLoginError(''); + setIsAddingAccount(false); + }; + + const handleAddAccount = () => { + inboxRequestTokenRef.current += 1; + messageRequestTokenRef.current += 1; + setIsLoggedIn(false); + setIsConnecting(false); + setLoginError(''); + setFolders([]); + setEmails([]); + setSelectedEmailInfo(null); + setActiveMessageData(null); + setReplyText(''); + setCredentials(initialCredentials); + setViewCacheByAccount({}); + setIsAddingAccount(true); + clearSession(); + }; + + const handleCancelAddAccount = async () => { + if (savedAccounts.length === 0) { + setIsAddingAccount(false); + return; + } + const targetId = activeAccountId || savedAccounts[0].id; + setIsAddingAccount(false); + await handleSwitchAccount(targetId); + }; + + const handleSwitchAccount = async (accountId) => { + const account = savedAccounts.find((a) => a.id === accountId); + if (!account) return; + + setIsConnecting(true); + setLoginError(''); + setCredentials(account.credentials); + setActiveAccountId(account.id); + + try { + const auth = getAuthFromCredentials(account.credentials); + const cached = getAccountCache(account.id); + const targetFolder = account.lastFolder || 'INBOX'; + + if (cached?.folders?.length) { + setFolders(cached.folders); + } + const cachedTargetEntry = cached?.emailsByFolder?.[targetFolder]; + const cachedTargetEmails = Array.isArray(cachedTargetEntry) + ? cachedTargetEntry + : Array.isArray(cachedTargetEntry?.emails) + ? cachedTargetEntry.emails + : null; + if (cachedTargetEmails) { + const firstPage = buildFirstPageView(cachedTargetEmails, cachedTargetEmails.length, sortOrder); + setActiveFolder(targetFolder); + setEmails(firstPage.emails); + } + + await connectWithCredentials(auth, account.lastFolder || 'INBOX', account.credentials); + await fetchQuota(auth); + await fetchFolderSizes(auth); + } finally { + setIsConnecting(false); + } + }; + + useEffect(() => { + let cancelled = false; + + const restoreSession = async () => { + try { + const rawAccounts = localStorage.getItem(WEBMAIL_ACCOUNTS_KEY); + if (rawAccounts) { + const parsedAccounts = JSON.parse(rawAccounts); + if (Array.isArray(parsedAccounts)) { + setSavedAccounts(parsedAccounts); + } + } + + const raw = localStorage.getItem(WEBMAIL_SESSION_KEY); + if (!raw) return; + + const parsed = JSON.parse(raw); + const parsedCredentials = parsed?.credentials || {}; + let savedCredentials = { ...initialCredentials, ...parsedCredentials }; + + // Backward compatibility with old saved format (user/imapHost/imapPort). + if (!Object.prototype.hasOwnProperty.call(parsedCredentials, 'mode') && parsedCredentials.user) { + const oldUser = String(parsedCredentials.user || '').trim(); + const oldHost = String(parsedCredentials.imapHost || DEFAULT_IMAP_HOST).trim(); + const oldPort = String(parsedCredentials.imapPort || DEFAULT_IMAP_PORT).trim(); + const oldPassword = String(parsedCredentials.password || '').trim(); + + if (oldUser.toLowerCase().endsWith(`@${DEFAULT_DOMAIN}`)) { + savedCredentials = { + ...initialCredentials, + mode: 'weektab', + localPart: oldUser.slice(0, oldUser.indexOf('@')), + password: oldPassword, + customImapHost: DEFAULT_IMAP_HOST, + customImapPort: DEFAULT_IMAP_PORT + }; + } else { + savedCredentials = { + ...initialCredentials, + mode: 'custom', + customUser: oldUser, + password: oldPassword, + customImapHost: oldHost || DEFAULT_IMAP_HOST, + customImapPort: oldPort || DEFAULT_IMAP_PORT + }; + } + } + + const savedIsCustom = savedCredentials.mode === 'custom'; + const savedAuth = { + user: savedIsCustom + ? String(savedCredentials.customUser || '').trim() + : `${String(savedCredentials.localPart || '').trim()}@${DEFAULT_DOMAIN}`, + password: savedCredentials.password, + host: savedIsCustom + ? String(savedCredentials.customImapHost || DEFAULT_IMAP_HOST).trim() + : DEFAULT_IMAP_HOST, + port: savedIsCustom + ? Number(savedCredentials.customImapPort || DEFAULT_IMAP_PORT) + : Number(DEFAULT_IMAP_PORT), + tls: true + }; + + if (!savedAuth.user || !savedAuth.password || !savedAuth.host) { + clearSession(); + return; + } + + setCredentials(savedCredentials); + setActiveAccountId(makeAccountId(savedAuth)); + setIsConnecting(true); + setLoginError(''); + + if (!cancelled) { + await connectWithCredentials(savedAuth, parsed?.lastFolder || 'INBOX', savedCredentials, { + skipInitialFetchIfCached: true + }); + await fetchQuota(savedAuth); + await fetchFolderSizes(savedAuth); + } + } catch { + clearSession(); + } finally { + if (!cancelled) { + setIsConnecting(false); + setIsRestoringSession(false); + } + } + }; + + restoreSession(); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + const onKeyDown = (event) => { + if (event.key === 'Escape') { + setIsFullscreen(false); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, []); + + useEffect(() => { + if (!isLoggedIn || !activeAccountId || !activeFolder) return undefined; + + const intervalId = window.setInterval(() => { + fetchInbox(activeFolder, authPayload, true, credentials, { + keepSelection: true, + silent: true, + syncOnly: true + }); + }, 5 * 60 * 1000); + + return () => window.clearInterval(intervalId); + }, [isLoggedIn, activeAccountId, activeFolder, authPayload, credentials, sortOrder]); + + const activeFolderPaging = getViewCache(activeAccountId, activeFolder, sortOrder)?.paging + || pagingByAccount?.[activeAccountId]?.[activeFolder] + || {}; + + useEffect(() => { + if (prevSortOrderRef.current === sortOrder) return; + prevSortOrderRef.current = sortOrder; + if (!isLoggedIn || !activeAccountId || !activeFolder) return; + + const cachedView = getViewCache(activeAccountId, activeFolder, sortOrder); + if (cachedView?.emails?.length) { + const firstPage = buildFirstPageView( + cachedView.emails, + cachedView.paging?.total || cachedView.emails.length, + sortOrder + ); + setEmails(firstPage.emails); + setFolderPaging(activeAccountId, activeFolder, firstPage.paging); + setViewCache(activeAccountId, activeFolder, sortOrder, cachedView.emails, firstPage.paging); + return; + } + + fetchInbox(activeFolder, authPayload, true, credentials, { + keepSelection: true, + forceRefresh: true, + resetToFirstPage: true + }); + }, [sortOrder, isLoggedIn, activeAccountId, activeFolder, authPayload, credentials, viewCacheByAccount]); + + const filteredEmails = useMemo(() => { + const q = String(searchQuery || '').trim().toLowerCase(); + if (!q) return emails; + + return emails.filter((email) => { + const haystack = [ + email?.subject, + email?.sender, + email?.snippet, + email?.preview, + email?.text, + email?.body + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + return haystack.includes(q); + }); + }, [emails, searchQuery]); + + const displayedEmails = useMemo(() => { + const list = [...filteredEmails]; + list.sort((a, b) => { + const aId = Number(a?.id || 0); + const bId = Number(b?.id || 0); + return sortOrder === 'newest' ? bId - aId : aId - bId; + }); + return list; + }, [filteredEmails, sortOrder]); + + const canShowLoadMore = useMemo(() => { + if (isLoadingInbox) return false; + if (String(searchQuery || '').trim()) return false; + const loadedCount = Number(emails?.length || 0); + const totalCount = Number(activeFolderPaging?.total || 0); + if (totalCount > loadedCount) return true; + return Boolean(activeFolderPaging?.hasMore); + }, [isLoadingInbox, searchQuery, emails, activeFolderPaging]); + + const isTrashFolder = useMemo(() => /trash$/i.test(String(activeFolder || '')), [activeFolder]); + const selectedCount = selectedEmailIds.length; + const mailBackgroundColor = useMemo( + () => extractMailBackgroundColor(activeMessageData?.html), + [activeMessageData?.html] + ); + + useEffect(() => { + setSelectedEmailIds([]); + }, [activeFolder, sortOrder]); + + const toggleEmailSelection = (emailId) => { + const uid = Number(emailId); + if (!Number.isFinite(uid)) return; + setSelectedEmailIds((prev) => ( + prev.includes(uid) + ? prev.filter((id) => id !== uid) + : [...prev, uid] + )); + }; + + const getSenderInitial = (sender) => { + const clean = String(sender || '').replace(/<.*?>/g, '').trim(); + const first = clean.charAt(0).toUpperCase(); + return first || '?'; + }; + + const handleDeleteSelected = async () => { + if (selectedEmailIds.length === 0) return; + const confirmed = window.confirm(`Delete ${selectedEmailIds.length} selected email(s)?`); + if (!confirmed) return; + + setIsDeletingSelected(true); + try { + const uids = selectedEmailIds + .map((id) => Number(id)) + .filter((uid) => Number.isFinite(uid) && uid > 0); + + // Use proven single-delete endpoint per UID for maximum compatibility. + await Promise.all( + uids.map((uid) => postJson('/api/webmail/delete', { + ...authPayload, + folder: activeFolder || 'INBOX', + uid + })) + ); + + const deleteSet = new Set(uids); + setEmails((prev) => prev.filter((msg) => !deleteSet.has(Number(msg?.id)))); + + if (selectedEmailInfo?.id && deleteSet.has(Number(selectedEmailInfo.id))) { + setSelectedEmailInfo(null); + setActiveMessageData(null); + } + + const accountId = makeAccountId(authPayload); + setViewCacheByAccount((prev) => { + const accountCache = prev?.[accountId]; + if (!accountCache) return prev; + const folderCache = accountCache?.[activeFolder]; + if (!folderCache) return prev; + + const nextFolderCache = {}; + for (const directionKey of Object.keys(folderCache)) { + const directionView = folderCache[directionKey]; + const directionEmails = Array.isArray(directionView?.emails) ? directionView.emails : []; + const filtered = directionEmails.filter((msg) => !deleteSet.has(Number(msg?.id))); + const total = Math.max(0, Number(directionView?.paging?.total || filtered.length) - deleteSet.size); + const nextOffset = Math.min(Number(directionView?.paging?.nextOffset || filtered.length), filtered.length); + const hasMore = nextOffset < total; + nextFolderCache[directionKey] = { + ...directionView, + emails: filtered, + paging: { + ...(directionView?.paging || {}), + total, + nextOffset, + hasMore, + loadedAll: !hasMore + } + }; + } + + return { + ...prev, + [accountId]: { + ...accountCache, + [activeFolder]: nextFolderCache + } + }; + }); + + setSelectedEmailIds([]); + await fetchInbox(activeFolder, authPayload, true, credentials, { + keepSelection: false, + silent: true, + syncOnly: true + }); + } catch (error) { + setLoginError(error.message || 'Failed to delete selected emails.'); + } finally { + setIsDeletingSelected(false); + } + }; + + const handleLoadMore = async () => { + if (!isLoggedIn || !activeFolder) return; + if (isLoadingMore || isLoadingInbox) return; + if (!activeFolderPaging.hasMore || activeFolderPaging.loadedAll) return; + + await fetchInbox(activeFolder, authPayload, true, credentials, { + keepSelection: true, + append: true, + silent: true + }); + }; + + const handleManualRefresh = async () => { + setIsRefreshingInbox(true); + try { + await fetchInbox(activeFolder, authPayload, true, credentials, { + keepSelection: true, + syncOnly: true + }); + await fetchFolderSizes(authPayload); + await fetchQuota(authPayload); + } finally { + setIsRefreshingInbox(false); + } + }; + + const activeQuota = quotaByAccount[activeAccountId] || null; + const activeFolderSizes = folderSizesByAccount[activeAccountId] || {}; + + const loginForm = ( +
+ {loginError &&
{loginError}
} + +
+ + {credentials.mode === 'custom' ? ( + + ) : ( +
+ + @{DEFAULT_DOMAIN} +
+ )} +
+ +
+ + +
+ +
+
+

CUSTOM E-MAIL

+ +
+ +

+ Optional: Use your own mail domain. Then you can set host and port. +

+ + {credentials.mode === 'custom' && ( + <> +
+ + +
+ +
+ + +
+ + )} +
+ + + + {isAddingAccount && savedAccounts.length > 0 && ( + + )} + +

Use your IMAP credentials to sign in.

+
+ ); + + const webmailWorkspace = ( +
+
+
+
+ +
+ +
+ +
+ {(folders.length ? folders : [{ name: 'INBOX', unseen: 0, total: 0 }]).map((folder, index) => { + const folderName = typeof folder === 'string' ? folder : folder.name; + const displayFolderName = folderName === 'INBOX' + ? 'INBOX' + : String(folderName || '').replace(/^INBOX\./i, ''); + const unseen = folder.unseen || 0; + const total = folder.total || 0; + const isActive = activeFolder === folderName; + const sizeBytes = Number(activeFolderSizes[folderName] || 0); + const sizeLabel = sizeBytes > 0 ? formatBytes(sizeBytes) : ''; + + return ( +
fetchInbox(folderName, authPayload, true, credentials, { + resetToFirstPage: true, + syncOnly: true + })} + > + + {displayFolderName} + {unseen > 0 && {unseen}} + {total > 0 && {total}} + {sizeLabel && {sizeLabel}} +
+ ); + })} + +
+ +
+
+ + +
+ + {activeQuota?.supported ? ( +
+
+ Storage {formatBytes(activeQuota.usedBytes)} / {formatBytes(activeQuota.limitBytes)} +
+ +
+ ) : activeQuota?.estimated ? ( +
+
+ Total mailbox size: {formatBytes(activeQuota.usedBytes)} +
+
+ ) : ( +
Checking storage usage...
+ )} + {savedAccounts.length > 1 ? ( +
+ + +
+ ) : ( + + )} +
+
+ +
+
+ + setSearchQuery(e.target.value)} + /> + +
+ +
+ {activeFolder || 'INBOX'} +
+ {selectedCount > 0 && ( + + )} + {isTrashFolder && selectedCount === 0 && ( + + )} +
+
+ +
+ {isLoadingInbox ? ( +
Loading messages...
+ ) : filteredEmails.length === 0 ? ( +
+ {emails.length === 0 ? 'No emails found.' : 'No results for your search.'} +
+ ) : ( + displayedEmails.map((email) => { + const isSelected = selectedEmailInfo?.id === email.id; + const isChecked = selectedEmailIds.includes(Number(email.id)); + const simpleSender = String(email.sender || '').split('<')[0].trim() || email.sender; + const senderInitial = getSenderInitial(simpleSender); + + return ( +
fetchMessage(email)} + > + + toggleEmailSelection(email.id)} + onClick={(e) => e.stopPropagation()} + aria-label="Select email" + /> +
+
+ {simpleSender || 'Unknown'} +
+ {String(email.time || '').split(',')[0]} +
+
+ {email.subject || '(No Subject)'} +
+
+ ); + }) + )} + {canShowLoadMore && ( +
+ +
+ )} +
+
+ +
+ {!selectedEmailInfo ? ( +
+

Select a message from the list.

+
+ ) : ( + <> +
+
+ + +
+
+
+ +
+
+ +
+ {(() => { + const rawSender = String(selectedEmailInfo.sender || 'Unknown'); + const senderMatch = rawSender.match(/^(.*?)\s*<([^>]+)>$/); + const senderName = (senderMatch?.[1] || rawSender).trim().replace(/^"(.*)"$/, '$1') || 'Unknown'; + const senderEmail = (senderMatch?.[2] || rawSender).trim(); + return ( + <> +
+

{selectedEmailInfo.subject || '(No Subject)'}

+
+
+ +
+
{senderName}
+
{senderEmail}
+
+
+
+
+
+
+ {isLoadingMessage ? ( +
Loading message...
+ ) : activeMessageData?.html ? ( +
+ ) : ( +
No content.
+ )} +
+
+ + ); + })()} +
+ +
+
+ setReplyText(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && sendReply()} + /> +
+ {isSending ? ( + Sending... + ) : ( + + )} +
+
+
+ + )} +
+
+ ); + + return ( + <> +
+

Webmail

+

Mailbox and message management

+

This layout uses the same page frame as the homepage.

+
+ + {!isLoggedIn ? ( +
+
{loginForm}
+
+
+

Login notes

+
    +
  • Use your IMAP credentials including server and port.
  • +
  • Your login stays saved in this browser until you click logout.
  • +
  • After login, your mailbox is loaded automatically.
  • +
  • Errors are shown directly in the form.
  • +
+
+
+
+ ) : ( +
{webmailWorkspace}
+ )} + + {isRestoringSession && ( +

Restoring saved session...

+ )} + + ); +} + + + + + + + + diff --git a/nx-webmail/src/main.jsx b/nx-webmail/src/main.jsx new file mode 100644 index 0000000..d2ad0a9 --- /dev/null +++ b/nx-webmail/src/main.jsx @@ -0,0 +1,7 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import Webmail from './components/Webmail.jsx'; +import './styles/webmail.css'; +import 'remixicon/fonts/remixicon.css'; + +createRoot(document.getElementById('root')).render(); diff --git a/nx-webmail/src/styles/webmail.css b/nx-webmail/src/styles/webmail.css new file mode 100644 index 0000000..ead22a1 --- /dev/null +++ b/nx-webmail/src/styles/webmail.css @@ -0,0 +1,1510 @@ +/* Webmail App CSS - Perfect Missive Clone */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + +body.webmail-active { + overflow: hidden; + margin: 0; + padding: 0; +} + +.webmail-app { + display: flex; + height: 100vh; + width: 100vw; + background-color: #ffffff; + font-family: 'Inter', -apple-system, sans-serif; + color: #111827; + overflow: hidden; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 10000; +} + +.webmail-app * { + box-sizing: border-box; +} + +.webmail-app button { + background: none; + border: none; + cursor: pointer; + font-family: inherit; + color: inherit; +} + +.webmail-app ul { + list-style: none; + padding: 0; + margin: 0; +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background-color: #d1d5db; + border-radius: 10px; +} + +/* ------------------------------------- + COLUMN 1: SIDEBAR +------------------------------------- */ +.missive-sidebar { + width: 240px; + min-width: 240px; + background-color: #f6f6f6; + border-right: 1px solid #e5e5e5; + display: flex; + flex-direction: column; + height: 100%; +} + +.sidebar-header { + padding: 16px 16px 12px 16px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.sidebar-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.fullscreen-btn { + width: 24px; + height: 24px; + border-radius: 6px; + background: #ffffff; + border: 1px solid #e5e5e5; + display: flex; + align-items: center; + justify-content: center; + color: #4b5563; + font-size: 14px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.mac-dots { + display: flex; + gap: 6px; +} + +.mac-dot { + width: 12px; + height: 12px; + border-radius: 50%; +} + +.mac-close { + background-color: #ff5f56; +} + +.mac-min { + background-color: #ffbd2e; +} + +.mac-max { + background-color: #27c93f; +} + +.profile-section { + display: flex; + align-items: center; + gap: 12px; +} + +.profile-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + position: relative; +} + +.profile-avatar img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; +} + +.status-dot { + position: absolute; + bottom: -2px; + right: -2px; + width: 10px; + height: 10px; + background-color: #27c93f; + border: 2px solid #f6f6f6; + border-radius: 50%; +} + +.add-btn { + width: 24px; + height: 24px; + border-radius: 50%; + background: white; + border: 1px solid #e5e5e5; + display: flex; + align-items: center; + justify-content: center; + color: #666; + font-size: 16px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.add-btn:disabled { + opacity: 0.7; + cursor: default; +} + +.add-btn.syncing i { + animation: webmail-spin 0.9s linear infinite; +} + +@keyframes webmail-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.sidebar-scroll { + flex: 1; + overflow-y: auto; + padding: 0 12px 20px 12px; +} + +.sidebar-account-switcher { + border-top: 1px solid #e5e7eb; + padding: 12px; + background: #f6f6f6; +} + +.sidebar-account-switcher label { + display: block; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #6b7280; + margin-bottom: 0; +} + +.account-switcher-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.add-account-icon-btn { + width: 22px; + height: 22px; + border-radius: 50%; + border: 1px solid #bfdbfe; + background: #dbeafe; + color: #1d4ed8; + font-size: 14px; + font-weight: 700; + line-height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + text-align: center; + transform: translateY(-0.5px); +} + +.add-account-icon-btn:hover { + background: #bfdbfe; + border-color: #93c5fd; +} + +.sidebar-account-switcher select { + width: 100%; + height: 34px; + border: 1px solid #d1d5db; + border-radius: 8px; + background: #ffffff; + color: #111827; + font-size: 13px; + padding: 0 10px; + outline: none; +} + +.account-quota { + margin-top: 8px; + text-align: center; +} + +.account-quota__text { + font-size: 11px; + color: #4b5563; + margin-bottom: 5px; + text-align: center; +} + +.account-quota__bar { + height: 6px; + border-radius: 999px; + background: #e5e7eb; + overflow: hidden; +} + +.account-quota__bar span { + display: block; + height: 100%; + background: #4e5cf4; +} + +.account-quota--muted { + margin-top: 8px; + font-size: 11px; + color: #9ca3af; + text-align: center; +} + +.account-quota__hint { + margin-top: 3px; + font-size: 10px; + color: #9ca3af; + text-align: center; +} + +.logout-grid { + margin-top: 8px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.logout-btn, +.logout-all-btn { + height: 34px; + width: 100%; + border-radius: 8px; + font-size: 12px; + font-weight: 700; +} + +.logout-btn { + border: 1px solid #fecaca; + background: #fff5f5; + color: #b91c1c; +} + +.logout-btn:hover { + border-color: #fca5a5; + background: #fee2e2; +} + +.logout-btn--single { + margin-top: 8px; +} + +.logout-all-btn { + margin-top: 0; + border: 1px solid #fecaca; + background: #fff5f5; + color: #b91c1c; +} + +.logout-all-btn:hover { + border-color: #fca5a5; + background: #fee2e2; +} + +.sidebar-item { + display: flex; + align-items: center; + padding: 6px 8px; + border-radius: 6px; + font-size: 13px; + color: #333; + margin-bottom: 2px; + cursor: pointer; +} + +.sidebar-item:hover { + background-color: #ebebeb; +} + +.sidebar-item.active { + background-color: #e4e4e7; + font-weight: 500; +} + +.sidebar-item i { + margin-right: 10px; + font-size: 16px; + color: #888; +} + +.sidebar-item i.icon-blue { + color: #4e5cf4; +} + +.sidebar-item .label { + flex: 1; +} + +.sidebar-folder-size { + margin-left: 6px; + font-size: 10px; + color: #9ca3af; + white-space: nowrap; +} + +.sidebar-item.active .sidebar-folder-size { + color: rgba(255, 255, 255, 0.9); +} + +.sidebar-badge { + font-size: 10px; + min-width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 10px; + font-weight: 700; + margin-left: 5px; + padding: 0 5px; + flex-shrink: 0; +} + +.badge-blue { + background: #4e5cf4; + color: white; +} + +.badge-gray { + background: #e5e7eb; + color: #4b5563; +} + +.sidebar-section-title { + font-size: 11px; + text-transform: uppercase; + color: #888; + font-weight: 600; + margin: 16px 0 6px 8px; + letter-spacing: 0.5px; +} + +/* ------------------------------------- + COLUMN 2: EMAIL LIST +------------------------------------- */ +.missive-list { + width: 340px; + min-width: 340px; + background-color: #ffffff; + border-right: 1px solid #e5e5e5; + display: flex; + flex-direction: column; + height: 100%; +} + +.list-search { + padding: 12px 16px; + border-bottom: 1px solid #e5e5e5; + display: flex; + align-items: center; + background: white; + height: 57px; + /* matches header height */ + flex-shrink: 0; +} + +.list-search i { + color: #9ca3af; + font-size: 16px; +} + +.list-sort-btn { + width: 24px; + height: 24px; + border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #9ca3af; +} + +.list-sort-btn:hover { + background: #f3f4f6; + color: #6b7280; +} + +.list-search input { + flex: 1; + border: none; + background: transparent; + padding: 0 10px; + outline: none; + font-size: 14px; + color: #333; +} + +.list-scroll { + flex: 1; + overflow-y: auto; +} + +.list-load-more-wrap { + padding: 12px 16px 14px; + display: flex; + justify-content: center; +} + +.list-load-more-btn { + min-width: 120px; + height: 34px; + border: 1px solid #d1d5db; + border-radius: 8px; + background: #ffffff; + color: #374151; + font-size: 13px; + font-weight: 600; +} + +.list-load-more-btn:hover:not(:disabled) { + background: #f9fafb; +} + +.list-load-more-btn:disabled { + opacity: 0.65; +} + +.list-section-header { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; + font-weight: 600; + color: #666; + padding: 8px 16px; + background: #f9fafb; + border-bottom: 1px solid #f3f4f6; + position: sticky; + top: 0; + z-index: 10; +} + +.list-section-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.header-delete-btn { + height: 26px; + border: 1px solid #fecaca; + background: #fff5f5; + color: #b91c1c; + border-radius: 7px; + padding: 0 10px; + font-size: 11px; + font-weight: 700; +} + +.header-delete-btn:hover:not(:disabled) { + background: #fee2e2; + border-color: #fca5a5; +} + +.header-delete-btn:disabled { + opacity: 0.65; +} + +.empty-trash-btn { + height: 26px; + border: 1px solid #fecaca; + background: #fff5f5; + color: #b91c1c; + border-radius: 7px; + padding: 0 10px; + font-size: 11px; + font-weight: 700; +} + +.empty-trash-btn:hover:not(:disabled) { + background: #fee2e2; + border-color: #fca5a5; +} + +.empty-trash-btn:disabled { + opacity: 0.65; +} + +.list-item { + padding: 12px 16px 12px 52px; + border-bottom: 1px solid #f0f0f0; + cursor: pointer; + position: relative; +} + +.email-sender-avatar { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + width: 30px; + height: 30px; + border-radius: 50%; + background: #e5e7eb; + color: #374151; + font-size: 12px; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + overflow: hidden; +} + +.email-sender-avatar__fallback { + position: relative; + z-index: 1; +} + +.email-sender-avatar img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + z-index: 2; +} + +.email-select-checkbox { + position: absolute; + left: 19px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + opacity: 0; + pointer-events: none; +} + +.list-item:hover .email-sender-avatar, +.list-item.checked .email-sender-avatar { + opacity: 0; +} + +.list-item:hover .email-select-checkbox, +.list-item.checked .email-select-checkbox { + opacity: 1; + pointer-events: auto; +} + +.list-item:hover { + background-color: #f9fafb; +} + +.list-item.active { + background-color: #4e5cf4; + color: white; + border-color: #4e5cf4; +} + +.item-top { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} + +.item-sender { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 13px; + color: #222; +} + +.list-item.active .item-sender, +.list-item.active .item-time, +.list-item.active .item-subject, +.list-item.active .item-snippet { + color: white; +} + +.sender-avatar { + width: 18px; + height: 18px; + border-radius: 4px; + /* missive uses slightly rounded squares for integration icons */ + object-fit: cover; +} + +.sender-avatar.circular { + border-radius: 50%; +} + +.item-time { + font-size: 11px; + color: #888; +} + +.item-subject { + font-size: 13px; + font-weight: 500; + color: #444; + margin-bottom: 4px; + display: flex; + justify-content: space-between; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.badge-outline { + font-size: 10px; + border: 1px solid #e5e5e5; + border-radius: 12px; + padding: 1px 6px; + color: #666; + margin-left: 6px; +} + +.list-item.active .badge-outline { + border-color: rgba(255, 255, 255, 0.4); + color: white; +} + +.item-snippet { + font-size: 13px; + color: #777; + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tiny-avatar { + width: 16px; + height: 16px; + border-radius: 50%; +} + +.tag { + font-size: 10px; + padding: 2px 6px; + border-radius: 12px; + background: #f3e8ff; + color: #9333ea; + font-weight: 600; +} + +.list-item.active .tag { + background: rgba(255, 255, 255, 0.2); + color: white; +} + +/* ------------------------------------- + COLUMN 3: READING PANE +------------------------------------- */ +.missive-reading { + flex: 1; + display: flex; + flex-direction: column; + background: transparent; + height: 100%; + position: relative; +} + +.empty-reading-state { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + text-align: center; +} + +.empty-reading-state p { + margin: 0; + color: #6b7280; + font-size: 14px; +} + +.reading-toolbar { + height: 57px; + padding: 0 20px; + border-bottom: 1px solid #e5e5e5; + display: flex; + justify-content: space-between; + align-items: center; + background: white; + flex-shrink: 0; +} + +.toolbar-left, +.toolbar-right { + display: flex; + align-items: center; + gap: 16px; +} + +.toolbar-left img { + width: 24px; + height: 24px; + border-radius: 50%; +} + +.toolbar-left i { + color: #666; + font-size: 14px; + cursor: pointer; +} + +.toolbar-action-btn { + width: 30px; + height: 30px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #ffffff; + color: #4b5563; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.toolbar-action-btn i { + font-size: 16px; +} + +.toolbar-action-btn:hover { + background: #f9fafb; + color: #1f2937; +} + +.toolbar-action-btn--danger { + border-color: #fecaca; + color: #dc2626; + background: #fff5f5; +} + +.toolbar-action-btn--danger:hover { + border-color: #fca5a5; + background: #fee2e2; + color: #b91c1c; +} + +.toolbar-center .assign-btn { + border: 1px solid #e5e5e5; + background: white; + border-radius: 20px; + padding: 6px 14px; + font-size: 13px; + font-weight: 500; + display: flex; + align-items: center; + gap: 6px; + color: #333; +} + +.toolbar-right i { + color: #666; + font-size: 18px; + cursor: pointer; +} + +.toolbar-right i:hover { + color: #111; +} + +.reading-content { + flex: 1; + overflow-y: auto; + padding: 0 0 86px; + display: flex; + flex-direction: column; +} + +.mail-meta-card { + position: sticky; + top: 0; + z-index: 20; + background: #ffffff; + border-bottom: 1px solid #e5e7eb; + padding: 18px 28px 14px; +} + +.mail-meta-card__title { + font-size: 20px; + font-weight: 600; + color: #111; + margin: 0 0 12px; +} + +.mail-body-panel { + width: 100%; + padding: 0; + background: transparent; +} + +.thread-meta { + display: flex; + align-items: center; + gap: 12px; + font-size: 13px; + color: #555; +} + +/* Base Email / Message Box */ +.message-box { + border: 1px solid #e5e5e5; + border-radius: 8px; + padding: 20px; + margin-bottom: 24px; + background: white; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02); +} + +.msg-header { + display: flex; + justify-content: space-between; + margin-bottom: 0; +} + +.msg-sender-info { + display: flex; + gap: 12px; + align-items: center; +} + +.msg-sender-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: #e5e7eb; + color: #374151; + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; + flex-shrink: 0; +} + +.msg-sender-avatar__fallback { + font-size: 14px; + font-weight: 700; + position: relative; + z-index: 1; +} + +.msg-sender-avatar img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + z-index: 2; +} + +.msg-logo { + width: 32px; + height: 32px; + border-radius: 4px; + background: #000; +} + +.msg-names { + display: flex; + flex-direction: column; +} + +.msg-name { + font-weight: 600; + font-size: 14px; + color: #111; +} + +.msg-email { + margin-top: 2px; + font-size: 12px; + color: #6b7280; +} + +.msg-name span { + color: #888; + font-weight: 400; + margin-left: 4px; +} + +.msg-to { + font-size: 13px; + color: #888; + margin-top: 2px; +} + +.msg-actions { + display: flex; + align-items: center; + gap: 12px; + color: #888; + font-size: 13px; +} + +.msg-body { + font-size: 14px; + line-height: 1.6; + color: #333; + width: 100%; + padding: 14px 28px 22px; + background: transparent; +} + +.msg-body--plain { + padding: 14px 28px 22px; +} + +.msg-body > * { + max-width: 100%; +} + +.msg-body p { + margin-bottom: 12px; +} + +/* Internal Chat Bubbles */ +.chat-row { + display: flex; + gap: 12px; + margin-bottom: 16px; +} + +.chat-avatar { + width: 28px; + height: 28px; + border-radius: 50%; +} + +.chat-bubble { + background: #e6ecf5; + padding: 10px 14px; + border-radius: 12px; + border-top-left-radius: 4px; + font-size: 14px; + color: #222; + position: relative; +} + +.chat-author { + font-weight: 600; + color: #111; + margin-right: 6px; +} + +.chat-row-alt .chat-bubble { + background: #e4e4e7; + border-radius: 12px; + border-top-left-radius: 4px; +} + +.divider { + display: flex; + align-items: center; + text-align: center; + color: #888; + font-size: 12px; + margin: 24px 0; +} + +.divider::before, +.divider::after { + content: ''; + flex: 1; + border-bottom: 1px solid #e5e5e5; +} + +.divider::before { + margin-right: 16px; +} + +.divider::after { + margin-left: 16px; +} + +/* Tasks Checklist */ +.task-list { + border: 1px solid #e5e5e5; + border-radius: 8px; + margin-bottom: 24px; +} + +.task-row { + display: flex; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid #e5e5e5; + font-size: 14px; +} + +.task-row:last-child { + border-bottom: none; +} + +.task-row input[type="checkbox"] { + width: 18px; + height: 18px; + border-radius: 50%; + border: 1px solid #ccc; + appearance: none; + margin-right: 12px; + cursor: pointer; +} + +.task-label { + flex: 1; + color: #333; +} + +.task-meta { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #666; +} + +.task-meta img { + width: 20px; + height: 20px; + border-radius: 50%; +} + +.task-meta .badge { + background: #f3f4f6; + padding: 2px 8px; + border-radius: 12px; +} + +/* Bottom Chat Input */ +.reading-footer { + position: absolute; + left: 16px; + right: 16px; + bottom: 12px; + z-index: 30; + padding: 0; + background: transparent; +} + +.chat-input-wrapper { + display: flex; + align-items: center; + border: 1px solid #e5e5e5; + border-radius: 24px; + padding: 10px 16px; + background: white; +} + +.chat-input-wrapper input { + flex: 1; + border: none; + background: transparent; + outline: none; + font-size: 14px; +} + +.chat-actions { + display: flex; + gap: 12px; + color: #888; + font-size: 18px; +} + +.chat-actions i:hover { + color: #333; + cursor: pointer; +} + + +/* ------------------------------------- + COLUMN 4: RIGHT TOOLBAR +------------------------------------- */ +.missive-right-toolbar { + width: 50px; + min-width: 50px; + background: #ffffff; + border-left: 1px solid #e5e5e5; + display: flex; + flex-direction: column; + align-items: center; + padding: 16px 0; + gap: 20px; + height: 100%; +} + +.toolbar-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + color: #666; + font-size: 18px; + cursor: pointer; + border-radius: 6px; +} + +.toolbar-icon:hover { + background: #f3f4f6; + color: #111; +} + +.toolbar-icon img { + width: 24px; + height: 24px; + border-radius: 50%; +} + + +/* ------------------------------------- + EMBEDDED LOGIN IN READING PANE +------------------------------------- */ +.webmail-login { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background-color: #f3f4f6; + padding: 20px; +} + +.login-form { + background: white; + padding: 2rem; + border-radius: 0.5rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + width: 100%; + max-width: 400px; +} + +.login-form h2 { + margin-bottom: 1.5rem; + font-size: 1.5rem; + font-weight: 700; + text-align: center; + color: #111827; +} + +.login-form .form-group { + margin-bottom: 1rem; +} + +.login-form .form-label { + display: block; + margin-bottom: 0.35rem; + font-size: 0.85rem; + font-weight: 600; + color: #374151; +} + +.login-form input { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + outline: none; +} + +.login-form input[readonly] { + background: #f3f4f6; + color: #4b5563; +} + +.email-fixed-domain { + display: flex; + align-items: stretch; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + overflow: hidden; +} + +.email-fixed-domain input { + border: 0; + border-radius: 0; + min-width: 0; +} + +.email-fixed-domain .email-domain { + display: inline-flex; + align-items: center; + padding: 0 0.75rem; + background: #f9fafb; + border-left: 1px solid #e5e7eb; + color: #374151; + font-size: 0.9rem; + white-space: nowrap; +} + +.email-fixed-domain:focus-within { + border-color: #4e5cf4; +} + +.custom-email-box { + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px solid #e5e7eb; +} + +.custom-email-box__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.custom-email-box__header h3 { + margin: 0; + font-size: 0.95rem; + letter-spacing: 0.02em; +} + +.custom-email-toggle { + border: 1px solid #d1d5db; + background: #ffffff; + color: #374151; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 600; + padding: 0.35rem 0.65rem; +} + +.custom-email-toggle.active { + border-color: #4e5cf4; + color: #4e5cf4; + background: #eef1ff; +} + +.custom-email-box__hint { + margin: 0.5rem 0 0.9rem; + font-size: 0.82rem; + color: #6b7280; +} + +.login-form input:focus { + border-color: #4e5cf4; +} + +.login-btn { + width: 100%; + background-color: #4e5cf4; + color: white; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + font-weight: 600; + transition: background-color 0.2s; + border: none; + cursor: pointer; +} + +.login-btn:hover { + background-color: #3b48e0; +} + +.login-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.cancel-account-btn { + width: 100%; + margin-top: 10px; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + background: #ffffff; + color: #374151; + padding: 0.5rem 1rem; + font-weight: 600; +} + +.cancel-account-btn:hover { + border-color: #9ca3af; + background: #f9fafb; +} + +.login-error { + background-color: #fee2e2; + border: 1px solid #ef4444; + color: #b91c1c; + padding: 0.75rem; + border-radius: 0.375rem; + margin-bottom: 1rem; + font-size: 0.875rem; +} + +.login-hint { + margin-top: 1rem; + font-size: 0.875rem; + color: #6b7280; + text-align: center; +} + +.loading-state { + text-align: center; + color: #888; + font-size: 14px; + margin-top: 40px; +} +.webmail-stage { + background: #ffffff; + border-radius: 16px; + padding: 24px 32px; + margin: 0 -4rem 2rem -4rem; +} + +.webmail-stage p { + margin: 0 0 6px; + color: #667085; +} + +.webmail-stage h1 { + margin: 0 0 8px; + font-size: 32px; + line-height: 1.2; +} + +.webmail-hero-split { + display: flex; + gap: 24px; + align-items: flex-start; + background: #fff; + border-radius: 16px; + padding: 3rem; + margin: 0 -4rem 2rem -4rem; +} + +.webmail-hero-split__left { + flex: 0 0 360px; +} + +.webmail-hero-split__right { + flex: 1 1 auto; +} + +.webmail-info-tile { + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 20px; + background: #fafafa; +} + +.webmail-info-tile h3 { + margin: 0 0 12px; +} + +.webmail-info-list { + margin: 0; + padding-left: 18px; +} + +.webmail-info-list li { + margin-bottom: 8px; +} + +.login-form--billing { + max-width: none; + width: 100%; + box-shadow: none; + border: 1px solid #e5e7eb; +} + +.webmail-shell-scroll { + width: auto; + overflow-x: auto; + background: #fff; + border-radius: 16px; + padding: 16px; + margin: 0 -4rem 2rem -4rem; +} + +.webmail-app { + position: relative !important; + inset: auto !important; + z-index: 1 !important; + width: 100% !important; + min-width: 980px; + height: min(76vh, 860px) !important; + border: 1px solid #e5e7eb; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.06); +} + +.webmail-app.webmail-app--fullscreen { + position: fixed !important; + inset: 0 !important; + width: 100vw !important; + height: 100vh !important; + min-width: 0; + z-index: 12000 !important; + border: 0; + border-radius: 0; + box-shadow: none; +} + +.webmail-app ::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.webmail-app ::-webkit-scrollbar-track { + background: transparent; +} + +.webmail-app ::-webkit-scrollbar-thumb { + background-color: #d1d5db; + border-radius: 10px; +} + +@media (max-width: 900px) { + .webmail-stage, + .webmail-hero-split { + margin-left: 0; + margin-right: 0; + } + + .webmail-shell-scroll { + margin-left: 0; + margin-right: 0; + } + + .webmail-hero-split { + flex-direction: column; + padding: 24px; + } + + .webmail-hero-split__left { + flex: 1 1 auto; + width: 100%; + } + + .webmail-stage { + padding: 20px 24px; + } +} diff --git a/nx-webmail/umbrel-app.yml b/nx-webmail/umbrel-app.yml new file mode 100644 index 0000000..fcb33c3 --- /dev/null +++ b/nx-webmail/umbrel-app.yml @@ -0,0 +1,24 @@ +manifestVersion: 1 +id: nx-webmail +name: Webmail +tagline: Self-hosted IMAP/SMTP webmail client +icon: logo.svg +category: utilities +version: "1.0.0" +port: 3001 +description: >- + Webmail is a lightweight, self-hosted webmail app for connecting to external + IMAP and SMTP accounts directly from your Umbrel. +developer: Weektab +website: https://git.weektab.org +submitter: Weektab +submission: https://git.weektab.org/nexus/umbrel-apps +repo: https://git.weektab.org/nexus/webmail +support: https://git.weektab.org/nexus/webmail/issues +gallery: [] +releaseNotes: >- + Initial Umbrel app packaging. +dependencies: [] +path: "" +defaultUsername: "" +defaultPassword: "" diff --git a/nx-webmail/vite.config.js b/nx-webmail/vite.config.js new file mode 100644 index 0000000..358ce9f --- /dev/null +++ b/nx-webmail/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': 'http://localhost:3001' + } + }, + build: { + outDir: 'dist' + } +});