init commit
This commit is contained in:
commit
21afb0abfc
21
README.md
Normal file
21
README.md
Normal 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
18
backend/Dockerfile
Normal 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
30
backend/package.json
Normal 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
120
backend/src/db.ts
Normal 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
131
backend/src/imapSync.ts
Normal 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
153
backend/src/server.ts
Normal 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
12
backend/tsconfig.json
Normal 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
35
docker-compose.yml
Normal 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
12
frontend/Dockerfile
Normal 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
15
frontend/index.html
Normal 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
21
frontend/package.json
Normal 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
59
frontend/src/App.tsx
Normal 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
21
frontend/src/api.ts
Normal 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}`)
|
||||
};
|
28
frontend/src/components/AddAccount.tsx
Normal file
28
frontend/src/components/AddAccount.tsx
Normal 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>
|
||||
);
|
||||
}
|
149
frontend/src/components/LoginScreen.tsx
Normal file
149
frontend/src/components/LoginScreen.tsx
Normal 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 self‑hosted 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'
|
||||
};
|
17
frontend/src/components/MailboxList.tsx
Normal file
17
frontend/src/components/MailboxList.tsx
Normal 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>
|
||||
);
|
||||
}
|
34
frontend/src/components/MessageList.tsx
Normal file
34
frontend/src/components/MessageList.tsx
Normal 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>
|
||||
);
|
||||
}
|
26
frontend/src/components/MessageView.tsx
Normal file
26
frontend/src/components/MessageView.tsx
Normal 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
6
frontend/src/index.tsx
Normal 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
60
frontend/src/theme.css
Normal 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
13
frontend/tsconfig.json
Normal 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
10
frontend/vite.config.ts
Normal 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')
|
||||
}
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user