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 @@
+
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 = (
+
+ );
+
+ 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)}
+ >
+
+ {senderInitial}
+ {email.avatar && (
+
{
+ e.currentTarget.style.display = 'none';
+ }}
+ />
+ )}
+
+
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)'}
+
+
+
+
+ {getSenderInitial(senderName)}
+
+ {selectedEmailInfo?.avatar && (
+
{
+ e.currentTarget.style.display = 'none';
+ }}
+ />
+ )}
+
+
+
{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'
+ }
+});