commit 21afb0abfc97e3b4e48fba7b010b72ae577147e3 Author: Thomas Faour Date: Sun Aug 10 12:55:16 2025 -0400 init commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..e10e379 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# IMAP Client (Gmail-like Skeleton) + +Features: +- Node.js + ImapFlow incremental sync to SQLite +- React frontend (Vite) +- Dockerized (backend + nginx served frontend) +- Basic message listing & viewing + +Quick start: +1. docker compose build +2. docker compose up +3. Open http://localhost:5173 +4. Add account (host, port=993, secure checked if SSL/TLS) + +Notes: +- Credentials are stored unencrypted (DO NOT USE IN PRODUCTION) -> implement encryption (eg. libsodium sealed boxes + master key env). +- No push websocket yet (polling every 15s). +- Body fetch is lazy; first open triggers retrieval. +- To extend: add WebSocket, flags actions, search, offline IndexedDB caching. + +License: MIT (adjust as needed). diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..59d25c9 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + +FROM node:20-alpine +WORKDIR /app +ENV IMAP_CLIENT_DB_PATH=/data/app.db +RUN addgroup -S app && adduser -S app -G app && mkdir -p /data && chown app:app /data +COPY --from=build /app/dist ./dist +COPY package*.json ./ +RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit=dev; fi +USER app +EXPOSE 8080 +CMD ["node", "dist/server.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..7051cc3 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,30 @@ +{ + "name": "imap_client_backend", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "ts-node-dev --respawn src/server.ts", + "build": "tsc -p tsconfig.json" + }, + "dependencies": { + "better-sqlite3": "^9.4.0", + "express": "^4.19.0", + "imapflow": "^1.0.141", + "ws": "^8.17.0", + "cors": "^2.8.5", + "pino": "^9.0.0", + "pino-pretty": "^11.0.0", + "openid-client": "^5.6.5", + "express-session": "^1.17.3" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/ws": "^8.5.10", + "@types/node": "^20.11.30", + "ts-node-dev": "^2.0.0", + "typescript": "^5.4.0", + "@types/express-session": "^1.17.8", + "@types/better-sqlite3": "^7.6.9", + "@types/cors": "^2.8.17" + } +} diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..850fe3f --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,120 @@ +import Database from 'better-sqlite3'; +import fs from 'fs'; +import path from 'path'; + +const dbPath = process.env.IMAP_CLIENT_DB_PATH || path.join(process.cwd(), 'data', 'app.db'); +fs.mkdirSync(path.dirname(dbPath), { recursive: true }); +export const db = new Database(dbPath); + +db.exec(` +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sub TEXT UNIQUE NOT NULL, + email TEXT, + createdAt DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + userId INTEGER REFERENCES users(id) ON DELETE CASCADE, + email TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER NOT NULL, + secure INTEGER NOT NULL, + username TEXT NOT NULL, + password_enc TEXT NOT NULL, + createdAt DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS mailboxes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + accountId INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + name TEXT NOT NULL, + path TEXT NOT NULL, + uidValidity INTEGER, + highestModSeq TEXT, + lastUid INTEGER, + UNIQUE(accountId, path) +); +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + accountId INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + mailboxId INTEGER NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE, + uid INTEGER NOT NULL, + msgid TEXT, + subject TEXT, + fromJson TEXT, + toJson TEXT, + flags TEXT, + internalDate DATETIME, + snippet TEXT, + size INTEGER, + hasBody INTEGER DEFAULT 0, + body TEXT, + UNIQUE(mailboxId, uid) +); +CREATE INDEX IF NOT EXISTS idx_messages_mailbox_uid ON messages(mailboxId, uid DESC); +CREATE INDEX IF NOT EXISTS idx_accounts_user ON accounts(userId); +`); + +// Backward compatibility: add userId column if missing (older DB) +try { + const cols = db.prepare(`PRAGMA table_info(accounts)`).all() as any[]; + if (!cols.find(c => c.name === 'userId')) { + db.exec(`ALTER TABLE accounts ADD COLUMN userId INTEGER REFERENCES users(id) ON DELETE CASCADE`); + } +} catch { /* ignore */ } + +export function upsertUser(sub:string, email?:string) { + const row = db.prepare(`SELECT * FROM users WHERE sub=?`).get(sub) as any | undefined; + if (row) { + if (email && row.email !== email) { + db.prepare(`UPDATE users SET email=? WHERE id=?`).run(email, row.id); + row.email = email; + } + return row; + } + const info = db.prepare(`INSERT INTO users (sub,email) VALUES (?,?)`).run(sub, email); + return db.prepare(`SELECT * FROM users WHERE id=?`).get(info.lastInsertRowid) as any; +} + +export function insertAccount(a: { + userId:number; email:string; host:string; port:number; secure:boolean; username:string; password:string; +}) { + const stmt = db.prepare(`INSERT INTO accounts (userId,email,host,port,secure,username,password_enc) VALUES (?,?,?,?,?,?,?)`); + const info = stmt.run(a.userId, a.email, a.host, a.port, a.secure ? 1 : 0, a.username, a.password); + return info.lastInsertRowid as number; +} + +export const queries = { + getAccounts: db.prepare(`SELECT * FROM accounts`), + getAccount: db.prepare(`SELECT * FROM accounts WHERE id=?`), + upsertMailbox: db.prepare(` + INSERT INTO mailboxes (accountId,name,path,uidValidity,highestModSeq,lastUid) + VALUES (@accountId,@name,@path,@uidValidity,@highestModSeq,@lastUid) + ON CONFLICT(accountId,path) DO UPDATE SET + name=excluded.name, + uidValidity=excluded.uidValidity, + highestModSeq=excluded.highestModSeq, + lastUid=COALESCE(mailboxes.lastUid, excluded.lastUid) + RETURNING *; + `), + getMailboxByPath: db.prepare(`SELECT * FROM mailboxes WHERE accountId=? AND path=?`), + updateMailboxState: db.prepare(`UPDATE mailboxes SET highestModSeq=@highestModSeq, lastUid=@lastUid WHERE id=@id`), + insertOrUpdateMessage: db.prepare(` + INSERT INTO messages (accountId, mailboxId, uid, msgid, subject, fromJson, toJson, flags, internalDate, snippet, size, hasBody, body) + VALUES (@accountId,@mailboxId,@uid,@msgid,@subject,@fromJson,@toJson,@flags,@internalDate,@snippet,@size,@hasBody,@body) + ON CONFLICT(mailboxId,uid) DO UPDATE SET + subject=excluded.subject, + flags=excluded.flags, + snippet=COALESCE(excluded.snippet,messages.snippet), + size=excluded.size, + hasBody=CASE WHEN excluded.hasBody=1 THEN 1 ELSE messages.hasBody END, + body=COALESCE(excluded.body,messages.body) + RETURNING id; + `), + pagedMessages: db.prepare(` + SELECT id,uid,subject,fromJson,toJson,flags,internalDate,snippet,size,hasBody + FROM messages WHERE mailboxId=? ORDER BY uid DESC LIMIT ? OFFSET ?; + `), + getMessage: db.prepare(`SELECT * FROM messages WHERE id=?`), + setBody: db.prepare(`UPDATE messages SET body=?, hasBody=1 WHERE id=?`) +}; diff --git a/backend/src/imapSync.ts b/backend/src/imapSync.ts new file mode 100644 index 0000000..563712b --- /dev/null +++ b/backend/src/imapSync.ts @@ -0,0 +1,131 @@ +import { ImapFlow } from 'imapflow'; +import { db, queries } from './db.js'; +import Pino from 'pino'; +const log = Pino({ name: 'imap-sync' }); + +type AccountRow = { + id:number; email:string; host:string; port:number; secure:number; username:string; password_enc:string; +}; + +export class SyncManager { + private running = new Map>(); + + startForAccount(accountId:number) { + if (this.running.has(accountId)) return; + const p = this.run(accountId) + .catch(e => log.error(e, 'sync error')) + .finally(()=> this.running.delete(accountId)); + this.running.set(accountId, p); + } + + private async run(accountId:number) { + const acct = queries.getAccount.get(accountId) as any; + if (!acct || !acct.userId) { log.warn({accountId}, 'account missing or unowned'); return; } + + const client = new ImapFlow({ + host: acct.host, + port: acct.port, + secure: !!acct.secure, + auth: { user: acct.username, pass: acct.password_enc } + }); + + await client.connect(); + log.info({accountId}, 'connected'); + + // List mailboxes (array form for current imapflow version) + const mailboxes = await client.list(); + for (const mailbox of mailboxes) { + const status = await client.status(mailbox.path, { uidValidity: true, highestModseq: true, uidNext: true }).catch(()=> ({} as any)); + const mb = queries.upsertMailbox.get({ + accountId, + name: mailbox.name, + path: mailbox.path, + uidValidity: status.uidValidity || null, + highestModSeq: status.highestModseq ? String(status.highestModseq) : null, + lastUid: null + }) as any; + await this.syncMailbox(client, accountId, mb.id, mailbox.path); + } + + await client.logout(); + log.info({accountId}, 'disconnected (one-shot sync complete)'); + } + + private async syncMailbox(client: ImapFlow, accountId:number, mailboxId:number, path:string) { + // Lock mailbox + const lock = await client.getMailboxLock(path); + try { + const mailboxDb = db.prepare('SELECT * FROM mailboxes WHERE id=?').get(mailboxId) as any; + const lastUidStored = mailboxDb?.lastUid || 0; + + // Get UIDNEXT to know upper bound + const status = await client.status(path, { uidNext: true }); + const uidNext = status.uidNext || 1; + if (lastUidStored >= uidNext - 1) return; // nothing new + + const fetchRange = `${lastUidStored + 1}:*`; + log.info({ path, fetchRange }, 'fetching new messages'); + + for await (const msg of client.fetch({ uid: fetchRange }, { uid: true, envelope: true, internalDate: true, flags: true, size: true })) { + const env = msg.envelope; + if (!env) continue; + const flagsArr = msg.flags ? Array.from(msg.flags) : []; + const internalDateVal = + msg.internalDate instanceof Date + ? msg.internalDate.toISOString() + : (typeof msg.internalDate === 'string' + ? new Date(msg.internalDate).toISOString() + : null); + queries.insertOrUpdateMessage.get({ + accountId, + mailboxId, + uid: msg.uid, + msgid: env.messageId, + subject: env.subject, + fromJson: JSON.stringify(env.from), + toJson: JSON.stringify(env.to), + flags: JSON.stringify(flagsArr), + internalDate: internalDateVal, + snippet: (env.subject || '').slice(0, 160), + size: msg.size, + hasBody: 0, + body: null + }); + db.prepare('UPDATE mailboxes SET lastUid=? WHERE id=?').run(msg.uid, mailboxId); + // TODO: websocket notify + } + } finally { + lock.release(); + } + } + + async loadBody(messageId:number) { + const row = queries.getMessage.get(messageId) as any; + if (!row) return null; + if (row.hasBody) return row; + + const acct = queries.getAccount.get(row.accountId) as any; + const mailbox = db.prepare('SELECT path FROM mailboxes WHERE id=?').get(row.mailboxId) as any; + const client = new ImapFlow({ + host: acct.host, + port: acct.port, + secure: !!acct.secure, + auth: { user: acct.username, pass: acct.password_enc } + }); + await client.connect(); + const lock = await client.getMailboxLock(mailbox.path); + try { + for await (const msg of client.fetch({ uid: String(row.uid) }, { source: true })) { + if (msg.source) { + queries.setBody.run(msg.source.toString('utf8'), messageId); + } + } + } finally { + lock.release(); + await client.logout(); + } + return queries.getMessage.get(messageId); + } +} + +export const syncManager = new SyncManager(); diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..a9fecce --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,153 @@ +import express from 'express'; +import cors from 'cors'; +import session from 'express-session'; +import { Issuer, generators, type Client } from 'openid-client'; +import { insertAccount, queries, db, upsertUser } from './db.js'; +import { syncManager } from './imapSync.js'; +import Pino from 'pino'; + +const log = Pino({ name: 'api', transport: process.env.NODE_ENV==='production'?undefined:{ target:'pino-pretty'} }); +const app = express(); + +app.use(cors({ + origin: process.env.FRONTEND_ORIGIN?.split(',') || true, + credentials: true +})); +app.use(express.json()); +app.use(session({ + secret: process.env.SESSION_SECRET || 'dev_insecure', + resave:false, + saveUninitialized:false, + cookie: { secure:false, httpOnly:true, sameSite:'lax' } +})); + +/* OIDC setup (lazy) */ +let oidcClient: Client | null = null; +async function getClient() { + if (oidcClient) return oidcClient; + const issuerUrl = process.env.OIDC_ISSUER; + if (!issuerUrl) throw new Error('OIDC_ISSUER missing'); + const issuer = await Issuer.discover(issuerUrl); + oidcClient = new issuer.Client({ + client_id: process.env.OIDC_CLIENT_ID!, + client_secret: process.env.OIDC_CLIENT_SECRET!, + redirect_uris: [process.env.OIDC_REDIRECT_URI!], + response_types: ['code'] + }); + return oidcClient; +} + +app.get('/api/auth/login', async (req,res)=>{ + try { + const client = await getClient(); + const state = generators.state(); + const nonce = generators.nonce(); + (req.session as any).oidc = { state, nonce }; + const url = client.authorizationUrl({ + scope: 'openid email profile', + state, + nonce + }); + res.redirect(url); + } catch (e:any) { + log.error(e); + res.status(500).json({ error:'oidc_setup_failed' }); + } +}); + +app.get('/api/auth/callback', async (req,res)=>{ + try { + const client = await getClient(); + const params = client.callbackParams(req); + const saved = (req.session as any).oidc; + if (!saved || params.state !== saved.state) return res.status(400).send('Bad state'); + const tokenSet = await client.callback(process.env.OIDC_REDIRECT_URI!, params, { state: saved.state, nonce: saved.nonce }); + const claims = tokenSet.claims(); + const user = upsertUser(claims.sub, claims.email); + (req.session as any).userId = user.id; + (req.session as any).user = { id:user.id, email:user.email }; + res.redirect(process.env.FRONTEND_ORIGIN || '/'); + } catch (e:any) { + log.error(e); + res.status(500).send('Auth failed'); + } +}); + +app.get('/api/auth/me', (req,res)=>{ + if (!(req.session as any).userId) return res.status(401).json({ authenticated:false }); + res.json({ authenticated:true, user:(req.session as any).user }); +}); + +app.post('/api/auth/logout', (req,res)=>{ + req.session.destroy(()=>{}); + res.json({ ok:true }); +}); + +// Auth guard middleware +function requireAuth(req:express.Request,res:express.Response,next:express.NextFunction) { + if (!(req.session as any).userId) return res.status(401).json({ error:'unauthorized' }); + next(); +} + +app.get('/api/health', (_req,res)=> res.json({ ok:true })); + +app.post('/api/accounts', requireAuth, (req,res)=>{ + const { email, host, port, secure, username, password } = req.body; + if (!email || !host || !username || !password) return res.status(400).json({ error:'missing fields' }); + const userId = (req.session as any).userId; + const id = insertAccount({ userId, email, host, port: port||993, secure: secure!==false, username, password }); + syncManager.startForAccount(id); + res.json({ id }); +}); + +app.get('/api/mailboxes', requireAuth, (req,res)=>{ + const userId = (req.session as any).userId; + const rows = db.prepare(` + SELECT m.*, (SELECT COUNT(*) FROM messages ms WHERE ms.mailboxId=m.id) as messageCount + FROM mailboxes m + JOIN accounts a ON a.id=m.accountId + WHERE a.userId=?`).all(userId); + res.json(rows); +}); + +app.get('/api/messages', requireAuth, (req,res)=>{ + const mailboxId = Number(req.query.mailbox); + if (!mailboxId) return res.status(400).json({ error:'mailbox required' }); + // ownership check + const owns = db.prepare(` + SELECT 1 FROM mailboxes m JOIN accounts a ON a.id=m.accountId + WHERE m.id=? AND a.userId=?`).get(mailboxId, (req.session as any).userId); + if (!owns) return res.status(404).json({ error:'not found' }); + const page = Number(req.query.page)||0; + const pageSize = 50; + const rows = queries.pagedMessages.all(mailboxId, pageSize, page*pageSize); + res.json({ page, pageSize, rows }); +}); + +app.get('/api/messages/:id', requireAuth, async (req,res)=>{ + const id = Number(req.params.id); + let row: any = queries.getMessage.get(id); + if (!row) return res.sendStatus(404); + // ownership check + const owns = db.prepare(` + SELECT 1 FROM messages ms + JOIN mailboxes m ON m.id=ms.mailboxId + JOIN accounts a ON a.id=m.accountId + WHERE ms.id=? AND a.userId=?`).get(id, (req.session as any).userId); + if (!owns) return res.status(404).end(); + if (!row.hasBody) { + row = await syncManager.loadBody(id); + } + res.json(row); +}); + +app.post('/api/sync/:accountId', requireAuth, (req,res)=>{ + const id = Number(req.params.accountId); + const owns = db.prepare(`SELECT 1 FROM accounts WHERE id=? AND userId=?`).get(id, (req.session as any).userId); + if (!owns) return res.status(404).end(); + syncManager.startForAccount(id); + res.json({ started:true }); +}); + +const port = Number(process.env.PORT||8080); +app.listen(port, ()=> log.info({port}, 'listening')); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..4e21114 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Node", + "esModuleInterop": true, + "outDir": "dist", + "strict": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0b235ab --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +version: "3.9" +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + environment: + - IMAP_CLIENT_DB_PATH=/data/app.db + - NODE_ENV=production + - PORT=8080 + - OIDC_ISSUER=https://auth.thumeit.com/.well-known/openid-configuration + - OIDC_CLIENT_ID=changeme + - OIDC_CLIENT_SECRET=changeme + - OIDC_REDIRECT_URI=http://localhost:8080/api/auth/callback + - SESSION_SECRET=dev_change_me + - FRONTEND_ORIGIN=http://localhost:5173 + - OIDC_PROVIDER=AuthServer + # Optional: BING_MKT=en-US (frontend will default to en-US if unset) + volumes: + - backend_data:/data + ports: + - "8080:8080" + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + environment: + - VITE_API_BASE=http://localhost:8080 + - OIDC_PROVIDER=AuthServer + ports: + - "5173:80" + depends_on: + - backend +volumes: + backend_data: {} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..819c401 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi && npm install -D @vitejs/plugin-react +COPY index.html vite.config.ts tsconfig.json ./ +COPY src ./src +ARG VITE_API_BASE +ENV VITE_API_BASE=$VITE_API_BASE +RUN npm run build + +FROM nginx:1.27-alpine +COPY --from=build /app/dist /usr/share/nginx/html diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4dcf065 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + IMAP Client + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2938e6b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "imap_client_frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "swr": "^2.2.5" + }, + "devDependencies": { + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "typescript": "^5.4.0", + "vite": "^5.2.0" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..c47a668 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,59 @@ +import React, { useState, useEffect } from 'react'; +import useSWR from 'swr'; +import { api } from './api'; +import { MailboxList } from './components/MailboxList'; +import { MessageList } from './components/MessageList'; +import { MessageView } from './components/MessageView'; +import { AddAccount } from './components/AddAccount'; +import { LoginScreen } from './components/LoginScreen'; +import './theme.css'; + +export function App() { + const { data: auth, mutate: refreshAuth } = useSWR('me', api.me, { refreshInterval: 5*60*1000 }); + const { data: mailboxes, mutate: refreshMailboxes } = useSWR(auth?.authenticated ? 'mailboxes' : null, api.mailboxes, { refreshInterval: 15000 }); + const [selectedMailbox, setSelectedMailbox] = useState(); + const [selectedMessage, setSelectedMessage] = useState(); + const [theme,setTheme] = useState<'light'|'dark'>(()=> { + const saved = localStorage.getItem('theme'); + if (saved==='light' || saved==='dark') return saved; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark':'light'; + }); + + useEffect(()=>{ + document.documentElement.dataset.theme = theme; + localStorage.setItem('theme', theme); + }, [theme]); + + useEffect(()=>{ + if (mailboxes && !selectedMailbox && mailboxes.length) setSelectedMailbox(mailboxes[0].id); + }, [mailboxes]); + + if (!auth) return
Loading...
; + if (!auth.authenticated) { + return ( + setTheme(t=> t==='light' ? 'dark':'light')} + onLogin={()=> api.login()} + /> + ); + } + + return ( +
+
+
+ Signed in as {auth.user?.email || 'user'} +
+ + { setSelectedMailbox(id); setSelectedMessage(undefined); }}/> +
+
+ {selectedMailbox && } +
+
+ {selectedMessage && } +
+
+ ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..b9bfe51 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,21 @@ +const API = (globalThis as any).__API_BASE__ || (import.meta as any).env.VITE_API_BASE || 'http://localhost:8080'; + +export async function fetchJSON(path:string, opts?:RequestInit) { + const r = await fetch(API + path, { + ...opts, + credentials: 'include', + headers: { 'Content-Type':'application/json', ...(opts?.headers||{}) } + }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); +} + +export const api = { + me: ()=> fetchJSON('/api/auth/me').catch(()=> ({ authenticated:false })), + login: ()=> { window.location.href = API + '/api/auth/login'; }, + logout: ()=> fetchJSON('/api/auth/logout', { method:'POST' }), + addAccount: (data:any)=> fetchJSON('/api/accounts', { method:'POST', body: JSON.stringify(data) }), + mailboxes: ()=> fetchJSON('/api/mailboxes'), + messages: (mailbox:number, page=0)=> fetchJSON(`/api/messages?mailbox=${mailbox}&page=${page}`), + message: (id:number)=> fetchJSON(`/api/messages/${id}`) +}; diff --git a/frontend/src/components/AddAccount.tsx b/frontend/src/components/AddAccount.tsx new file mode 100644 index 0000000..5dddb4b --- /dev/null +++ b/frontend/src/components/AddAccount.tsx @@ -0,0 +1,28 @@ +import React, { useState } from 'react'; +import { api } from '../api'; + +export function AddAccount({ onAdded }: { onAdded: ()=>void }) { + const [open,setOpen] = useState(false); + const [form,setForm] = useState({ email:'', host:'', port:993, secure:true, username:'', password:'' }); + async function submit(e:React.FormEvent) { + e.preventDefault(); + await api.addAccount(form); + setOpen(false); + onAdded(); + } + if (!open) return ; + return ( +
+ setForm({...form,email:e.target.value})}/> + setForm({...form,host:e.target.value})}/> + setForm({...form,port:Number(e.target.value)})}/> + setForm({...form,username:e.target.value})}/> + setForm({...form,password:e.target.value})}/> + +
+ + +
+
+ ); +} diff --git a/frontend/src/components/LoginScreen.tsx b/frontend/src/components/LoginScreen.tsx new file mode 100644 index 0000000..53c292e --- /dev/null +++ b/frontend/src/components/LoginScreen.tsx @@ -0,0 +1,149 @@ +import React, { useEffect, useState } from 'react'; + +interface Props { + theme: 'light'|'dark'; + onToggleTheme: ()=>void; + onLogin: ()=>void; + market?: string; +} + +export const LoginScreen: React.FC = ({ theme, onToggleTheme, onLogin, market='en-US' }) => { + const [bgUrl,setBgUrl] = useState(null); + const [attribution,setAttribution] = useState(''); + + useEffect(()=>{ + let cancelled = false; + fetch(`https://www.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=${market}`) + .then(r=> r.ok ? r.json(): Promise.reject(r.status)) + .then(json=>{ + if (cancelled) return; + if (json?.images?.[0]) { + const img = json.images[0]; + setBgUrl('https://www.bing.com' + img.url); + setAttribution(img.copyright || ''); + } + }) + .catch(()=>{ /* silent fallback */ }); + return ()=> { cancelled = true; }; + }, [market]); + + const providerName: string = (typeof __OIDC_PROVIDER__ !== 'undefined' && __OIDC_PROVIDER__) || 'Identity Provider'; + + return ( +
+
+
+
+ +
+
+
+

IMAP Client

+

+ Secure self‑hosted mail dashboard. Sign in with your identity provider. +

+ + {attribution && ( +
+ {attribution} +
+ )} +
+
+
+ © {new Date().getFullYear()} IMAP Client • Background: Bing Image of the Day +
+
+
+ ); +}; + +const cardStyle: React.CSSProperties = { + width:'min(420px, 100%)', + backdropFilter:'blur(16px)', + background:'var(--card-bg)', + border:'1px solid var(--card-border)', + padding:'32px 34px 40px', + borderRadius:20, + boxShadow:'0 8px 32px -4px rgba(0,0,0,0.35)', +}; + +const primaryButtonStyle: React.CSSProperties = { + all:'unset', + cursor:'pointer', + background:'linear-gradient(90deg,var(--btn-a),var(--btn-b))', + color:'#fff', + fontWeight:600, + padding:'14px 22px', + borderRadius:12, + fontSize:15, + letterSpacing:0.3, + textAlign:'center', + boxShadow:'0 4px 18px -4px rgba(0,0,0,0.4)', + transition:'transform .15s ease, box-shadow .15s ease' +}; + +const iconButtonStyle: React.CSSProperties = { + all:'unset', + cursor:'pointer', + width:44, + height:44, + display:'grid', + placeItems:'center', + fontSize:20, + background:'var(--icon-btn-bg)', + border:'1px solid var(--icon-btn-border)', + borderRadius:12, + boxShadow:'0 4px 14px -4px rgba(0,0,0,0.4)', + transition:'background .2s' +}; diff --git a/frontend/src/components/MailboxList.tsx b/frontend/src/components/MailboxList.tsx new file mode 100644 index 0000000..a92a0f8 --- /dev/null +++ b/frontend/src/components/MailboxList.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +export function MailboxList({ mailboxes, selected, onSelect }:{ + mailboxes:any[]; selected?:number; onSelect:(id:number)=>void; +}) { + return ( +
+ {mailboxes.map(m=>( +
onSelect(m.id)} + style={{ padding:'6px 12px', cursor:'pointer', background:m.id===selected?'#e8f0fe':'transparent' }}> + {m.name} {m.messageCount? {m.messageCount}:null} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx new file mode 100644 index 0000000..83d51f0 --- /dev/null +++ b/frontend/src/components/MessageList.tsx @@ -0,0 +1,34 @@ +import React, { useState } from 'react'; +import useSWR from 'swr'; +import { api } from '../api'; + +export function MessageList({ mailboxId, selected, onSelect }:{ + mailboxId:number; selected?:number; onSelect:(id:number)=>void; +}) { + const [page,setPage] = useState(0); + const { data, isLoading } = useSWR(['messages', mailboxId, page], ()=> api.messages(mailboxId,page), { refreshInterval: 15000 }); + if (isLoading) return
Loading...
; + return ( +
+ {(data?.rows||[]).map((m:any)=>( +
onSelect(m.id)} + style={{ + padding:'6px 10px', + borderBottom:'1px solid #eee', + cursor:'pointer', + background: m.id===selected ? '#d2e3fc':'#fff', + display:'grid', + gridTemplateColumns:'180px 1fr 80px' + }}> + {JSON.parse(m.fromJson||'[]')[0]?.name || JSON.parse(m.fromJson||'[]')[0]?.address || ''} + {m.subject} + {new Date(m.internalDate).toLocaleDateString()} +
+ ))} +
+ + +
+
+ ); +} diff --git a/frontend/src/components/MessageView.tsx b/frontend/src/components/MessageView.tsx new file mode 100644 index 0000000..5fe1173 --- /dev/null +++ b/frontend/src/components/MessageView.tsx @@ -0,0 +1,26 @@ + + +import React from 'react'; +import useSWR from 'swr'; +import { api } from '../api'; + + +export function MessageView({ id }:{ id:number }) { + const { data } = useSWR(['message', id], ()=> api.message(id)); + if (!data) return
Loading...
; + const from = JSON.parse(data.fromJson||'[]'); + const to = JSON.parse(data.toJson||'[]'); + return ( +
+

{data.subject}

+
+
From: {from.map((x:any)=> x.name||x.address).join(', ')}
+
To: {to.map((x:any)=> x.name||x.address).join(', ')}
+
{new Date(data.internalDate).toString()}
+
+
+        {data.body ? data.body.slice(0, 20000) : '(Body loading or not fetched yet)'}
+      
+
+ ); +} diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx new file mode 100644 index 0000000..a956a65 --- /dev/null +++ b/frontend/src/index.tsx @@ -0,0 +1,6 @@ +import './theme.css'; +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App'; + +createRoot(document.getElementById('root')!).render(); diff --git a/frontend/src/theme.css b/frontend/src/theme.css new file mode 100644 index 0000000..57ec0ee --- /dev/null +++ b/frontend/src/theme.css @@ -0,0 +1,60 @@ +:root { + --color-text:#1b1f23; + --color-footer:#222; + --grad-a:#4facfe; + --grad-b:#00f2fe; + --overlay:linear-gradient(180deg,rgba(0,0,0,0.35),rgba(0,0,0,0.65)); + --card-bg:rgba(255,255,255,0.72); + --card-border:rgba(255,255,255,0.55); + --btn-a:#6366f1; + --btn-b:#8b5cf6; + --icon-btn-bg:rgba(255,255,255,0.65); + --icon-btn-border:rgba(0,0,0,0.08); + --focus-ring:0 0 0 3px rgba(99,102,241,0.45); +} + +:root[data-theme='dark'] { + --color-text:#e6e8ea; + --color-footer:#bbb; + --grad-a:#141e30; + --grad-b:#243b55; + --overlay:linear-gradient(180deg,rgba(0,0,0,0.55),rgba(0,0,0,0.85)); + --card-bg:rgba(24,26,31,0.72); + --card-border:rgba(255,255,255,0.08); + --btn-a:#6366f1; + --btn-b:#3b82f6; + --icon-btn-bg:rgba(40,42,48,0.7); + --icon-btn-border:rgba(255,255,255,0.08); +} + +html,body { + margin:0; + padding:0; + background:#000; + color:var(--color-text); + font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif; + -webkit-font-smoothing:antialiased; +} + +button { + font-family:inherit; +} + +button:focus-visible, +[role="button"]:focus-visible { + outline:none; + box-shadow:var(--focus-ring); +} + +@media (prefers-reduced-motion: no-preference) { + :root { + scroll-behavior:smooth; + } + * { + transition: background-color .3s ease, color .3s ease, border-color .3s ease; + } +} + +@supports (backdrop-filter: blur(8px)) { + .no-backdrop { backdrop-filter:none; } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..bcd93bd --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "module": "ESNext", + "moduleResolution": "Node", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..dfc69c8 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins:[react()], + define: { + __API_BASE__: JSON.stringify(process.env.VITE_API_BASE || 'http://localhost:8080'), + __OIDC_PROVIDER__: JSON.stringify(process.env.OIDC_PROVIDER || process.env.VITE_OIDC_PROVIDER || 'Identity Provider') + } +});