init commit

This commit is contained in:
Thomas Faour 2025-08-10 12:55:16 -04:00
commit 21afb0abfc
22 changed files with 991 additions and 0 deletions

21
README.md Normal file
View File

@ -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).

18
backend/Dockerfile Normal file
View File

@ -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"]

30
backend/package.json Normal file
View File

@ -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"
}
}

120
backend/src/db.ts Normal file
View File

@ -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=?`)
};

131
backend/src/imapSync.ts Normal file
View File

@ -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<number, Promise<void>>();
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();

153
backend/src/server.ts Normal file
View File

@ -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'));

12
backend/tsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Node",
"esModuleInterop": true,
"outDir": "dist",
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}

35
docker-compose.yml Normal file
View File

@ -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: {}

12
frontend/Dockerfile Normal file
View File

@ -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

15
frontend/index.html Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>IMAP Client</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
body,html,#root { margin:0; height:100%; font-family: system-ui, sans-serif; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

21
frontend/package.json Normal file
View File

@ -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"
}
}

59
frontend/src/App.tsx Normal file
View File

@ -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<number|undefined>();
const [selectedMessage, setSelectedMessage] = useState<number|undefined>();
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 <div style={{ padding:40 }}>Loading...</div>;
if (!auth.authenticated) {
return (
<LoginScreen
theme={theme}
onToggleTheme={()=> setTheme(t=> t==='light' ? 'dark':'light')}
onLogin={()=> api.login()}
/>
);
}
return (
<div style={{ display:'grid', gridTemplateColumns:'240px 1fr 420px', height:'100vh' }}>
<div style={{ borderRight:'1px solid #ddd', overflow:'auto', display:'flex', flexDirection:'column' }}>
<div style={{ padding:8, borderBottom:'1px solid #eee', fontSize:12 }}>
Signed in as {auth.user?.email || 'user'} <button onClick={()=> api.logout().then(()=>refreshAuth())} style={{ marginLeft:8 }}>Logout</button>
</div>
<AddAccount onAdded={refreshMailboxes}/>
<MailboxList mailboxes={mailboxes||[]} selected={selectedMailbox} onSelect={(id)=> { setSelectedMailbox(id); setSelectedMessage(undefined); }}/>
</div>
<div style={{ borderRight:'1px solid #ddd', overflow:'auto' }}>
{selectedMailbox && <MessageList mailboxId={selectedMailbox} onSelect={setSelectedMessage} selected={selectedMessage}/>}
</div>
<div style={{ overflow:'auto' }}>
{selectedMessage && <MessageView id={selectedMessage}/>}
</div>
</div>
);
}

21
frontend/src/api.ts Normal file
View File

@ -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}`)
};

View File

@ -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 <button onClick={()=>setOpen(true)} style={{ width:'100%' }}>+ Add Account</button>;
return (
<form onSubmit={submit} style={{ padding:8, display:'grid', gap:4 }}>
<input placeholder="Email" value={form.email} onChange={e=>setForm({...form,email:e.target.value})}/>
<input placeholder="Host" value={form.host} onChange={e=>setForm({...form,host:e.target.value})}/>
<input placeholder="Port" type="number" value={form.port} onChange={e=>setForm({...form,port:Number(e.target.value)})}/>
<input placeholder="Username" value={form.username} onChange={e=>setForm({...form,username:e.target.value})}/>
<input placeholder="Password" type="password" value={form.password} onChange={e=>setForm({...form,password:e.target.value})}/>
<label><input type="checkbox" checked={form.secure} onChange={e=>setForm({...form,secure:e.target.checked})}/> Secure (SSL)</label>
<div style={{ display:'flex', gap:8 }}>
<button type="submit">Save</button>
<button type="button" onClick={()=>setOpen(false)}>Cancel</button>
</div>
</form>
);
}

View File

@ -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<Props> = ({ theme, onToggleTheme, onLogin, market='en-US' }) => {
const [bgUrl,setBgUrl] = useState<string|null>(null);
const [attribution,setAttribution] = useState<string>('');
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 (
<div style={{
position:'relative',
minHeight:'100vh',
fontFamily:'system-ui, sans-serif',
color:'var(--color-text)',
background: bgUrl
? `center/cover no-repeat url(${bgUrl}) fixed`
: 'linear-gradient(135deg,var(--grad-a),var(--grad-b))'
}}>
<div style={{
position:'absolute',
inset:0,
background:'var(--overlay)'
}}/>
<div style={{
position:'relative',
zIndex:2,
display:'flex',
flexDirection:'column',
minHeight:'100vh'
}}>
<header style={{
display:'flex',
justifyContent:'flex-end',
padding:'16px 20px',
gap:12
}}>
<button
onClick={onToggleTheme}
style={iconButtonStyle}
aria-label="Toggle dark mode"
title="Toggle dark mode"
>
{theme==='dark'
? '☀️'
: '🌙'}
</button>
</header>
<main style={{
flex:1,
display:'flex',
alignItems:'center',
justifyContent:'center',
padding:24
}}>
<div style={cardStyle}>
<h1 style={{ margin:'0 0 8px', fontSize:32, lineHeight:1.1 }}>IMAP Client</h1>
<p style={{ margin:'0 0 24px', opacity:0.85 }}>
Secure selfhosted mail dashboard. Sign in with your identity provider.
</p>
<button
onClick={onLogin}
style={primaryButtonStyle}
>
{`Sign in with ${providerName}`}
</button>
{attribution && (
<div style={{ marginTop:18, fontSize:11, opacity:0.6, textWrap:'balance' }}>
{attribution}
</div>
)}
</div>
</main>
<footer style={{
position:'relative',
zIndex:2,
textAlign:'center',
padding:'12px 8px',
fontSize:12,
color:'var(--color-footer)'
}}>
© {new Date().getFullYear()} IMAP Client Background: Bing Image of the Day
</footer>
</div>
</div>
);
};
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'
};

View File

@ -0,0 +1,17 @@
import React from 'react';
export function MailboxList({ mailboxes, selected, onSelect }:{
mailboxes:any[]; selected?:number; onSelect:(id:number)=>void;
}) {
return (
<div>
{mailboxes.map(m=>(
<div key={m.id}
onClick={()=>onSelect(m.id)}
style={{ padding:'6px 12px', cursor:'pointer', background:m.id===selected?'#e8f0fe':'transparent' }}>
{m.name} {m.messageCount? <span style={{ float:'right', opacity:0.6 }}>{m.messageCount}</span>:null}
</div>
))}
</div>
);
}

View File

@ -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 <div>Loading...</div>;
return (
<div>
{(data?.rows||[]).map((m:any)=>(
<div key={m.id} onClick={()=>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'
}}>
<span style={{ fontWeight:600 }}>{JSON.parse(m.fromJson||'[]')[0]?.name || JSON.parse(m.fromJson||'[]')[0]?.address || ''}</span>
<span style={{ whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>{m.subject}</span>
<span style={{ textAlign:'right', opacity:0.6 }}>{new Date(m.internalDate).toLocaleDateString()}</span>
</div>
))}
<div style={{ display:'flex', gap:8, padding:8 }}>
<button disabled={page===0} onClick={()=>setPage(p=>p-1)}>Prev</button>
<button disabled={(data?.rows||[]).length < (data?.pageSize||50)} onClick={()=>setPage(p=>p+1)}>Next</button>
</div>
</div>
);
}

View File

@ -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 <div style={{ padding:16 }}>Loading...</div>;
const from = JSON.parse(data.fromJson||'[]');
const to = JSON.parse(data.toJson||'[]');
return (
<div style={{ padding:16 }}>
<h2 style={{ marginTop:0 }}>{data.subject}</h2>
<div style={{ fontSize:14, marginBottom:12 }}>
<div><strong>From:</strong> {from.map((x:any)=> x.name||x.address).join(', ')}</div>
<div><strong>To:</strong> {to.map((x:any)=> x.name||x.address).join(', ')}</div>
<div style={{ opacity:0.7 }}>{new Date(data.internalDate).toString()}</div>
</div>
<pre style={{ whiteSpace:'pre-wrap', fontFamily:'inherit', background:'#f7f7f7', padding:12, borderRadius:4 }}>
{data.body ? data.body.slice(0, 20000) : '(Body loading or not fetched yet)'}
</pre>
</div>
);
}

6
frontend/src/index.tsx Normal file
View File

@ -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(<App />);

60
frontend/src/theme.css Normal file
View File

@ -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; }
}

13
frontend/tsconfig.json Normal file
View File

@ -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"]
}

10
frontend/vite.config.ts Normal file
View File

@ -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')
}
});