From 7212eb6f7add405840e2fc98e55fa190e470933c Mon Sep 17 00:00:00 2001 From: Meik Date: Thu, 22 Jan 2026 15:27:37 +0100 Subject: [PATCH] Init --- README.md | 37 ++++ backend/.env.example | 5 + backend/Dockerfile | 14 ++ backend/package.json | 39 ++++ backend/prisma/schema.prisma | 198 +++++++++++++++++++ backend/src/auth/plugin.ts | 12 ++ backend/src/auth/routes.ts | 63 ++++++ backend/src/config.ts | 19 ++ backend/src/db.ts | 5 + backend/src/health/routes.ts | 5 + backend/src/mail/cleanup.ts | 50 +++++ backend/src/mail/imap.ts | 54 +++++ backend/src/mail/newsletter.ts | 45 +++++ backend/src/mail/providers.ts | 35 ++++ backend/src/mail/routes.ts | 81 ++++++++ backend/src/main.ts | 51 +++++ backend/src/queue/jobEvents.ts | 12 ++ backend/src/queue/queue.ts | 25 +++ backend/src/queue/routes.ts | 48 +++++ backend/src/tenant/routes.ts | 18 ++ backend/src/types.d.ts | 16 ++ backend/src/worker.ts | 48 +++++ backend/tsconfig.json | 15 ++ docker-compose.yml | 69 +++++++ frontend/Dockerfile | 13 ++ frontend/index.html | 12 ++ frontend/package.json | 24 +++ frontend/src/App.tsx | 111 +++++++++++ frontend/src/i18n.ts | 16 ++ frontend/src/locales/de/translation.json | 25 +++ frontend/src/locales/en/translation.json | 25 +++ frontend/src/main.tsx | 11 ++ frontend/src/styles.css | 238 +++++++++++++++++++++++ frontend/tsconfig.json | 13 ++ frontend/vite.config.ts | 10 + 35 files changed, 1462 insertions(+) create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/package.json create mode 100644 backend/prisma/schema.prisma create mode 100644 backend/src/auth/plugin.ts create mode 100644 backend/src/auth/routes.ts create mode 100644 backend/src/config.ts create mode 100644 backend/src/db.ts create mode 100644 backend/src/health/routes.ts create mode 100644 backend/src/mail/cleanup.ts create mode 100644 backend/src/mail/imap.ts create mode 100644 backend/src/mail/newsletter.ts create mode 100644 backend/src/mail/providers.ts create mode 100644 backend/src/mail/routes.ts create mode 100644 backend/src/main.ts create mode 100644 backend/src/queue/jobEvents.ts create mode 100644 backend/src/queue/queue.ts create mode 100644 backend/src/queue/routes.ts create mode 100644 backend/src/tenant/routes.ts create mode 100644 backend/src/types.d.ts create mode 100644 backend/src/worker.ts create mode 100644 backend/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/i18n.ts create mode 100644 frontend/src/locales/de/translation.json create mode 100644 frontend/src/locales/en/translation.json create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/styles.css create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/README.md b/README.md new file mode 100644 index 00000000..16728d93 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# Simple Mail Cleaner + +State-of-the-art mail cleanup tool with multi-tenant support, newsletter unsubscribe automation, and a modern web UI. + +## Stack +- Backend: Node.js + TypeScript + Fastify +- Frontend: React + Vite + TypeScript + i18n +- DB: PostgreSQL +- Queue: Redis + BullMQ worker + +## Quick start +```bash +docker compose up --build +``` + +- Web UI: http://localhost:3000 +- API: http://localhost:8000 +- API Docs: http://localhost:8000/docs + +## API (initial) +- `POST /auth/register` `{ tenantName, email, password }` +- `POST /auth/login` `{ email, password }` +- `GET /tenants/me` (auth) +- `GET /mail/accounts` (auth) +- `POST /mail/accounts` (auth) +- `POST /mail/cleanup` (auth) +- `GET /jobs` (auth) +- `GET /jobs/:id/events` (auth) + +## Notes +- Newsletter detection will use `List-Unsubscribe` headers + heuristics. +- Weblink unsubscribe is queued and handled by a worker (placeholder). +- Worker currently scans headers for newsletter candidates (no destructive actions yet). +- DSGVO: data storage is designed for tenant isolation; encryption at rest will be added. + +## Environment +`docker-compose.yml` sets default dev credentials. Adjust before production use. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 00000000..02a5ded1 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,5 @@ +NODE_ENV=development +PORT=8000 +DATABASE_URL=postgresql://mailcleaner:mailcleaner@localhost:5432/mailcleaner +REDIS_URL=redis://localhost:6379 +JWT_SECRET=change-me-super-secret diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..088a0865 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,14 @@ +FROM node:20-slim + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install + +COPY tsconfig.json ./ +COPY prisma ./prisma +COPY src ./src + +EXPOSE 8000 + +CMD ["npm", "run", "dev"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 00000000..9cccd0a7 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,39 @@ +{ + "name": "simple-mail-cleaner-backend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "node --watch --loader ts-node/esm src/main.ts", + "worker:dev": "node --watch --loader ts-node/esm src/worker.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/main.js", + "start:worker": "node dist/worker.js", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev" + }, + "dependencies": { + "@fastify/cors": "^9.0.1", + "@fastify/helmet": "^12.1.0", + "@fastify/jwt": "^9.0.2", + "@fastify/swagger": "^9.3.0", + "@fastify/swagger-ui": "^4.2.0", + "@prisma/client": "^5.22.0", + "argon2": "^0.41.1", + "bullmq": "^5.48.1", + "fastify": "^4.28.1", + "fastify-plugin": "^4.5.1", + "imapflow": "^1.0.180", + "ioredis": "^5.5.0", + "mailparser": "^3.7.1", + "pino": "^9.5.0", + "pino-pretty": "^10.3.1", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/node": "^22.10.7", + "prisma": "^5.22.0", + "ts-node": "^10.9.2", + "typescript": "^5.7.3" + } +} diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 00000000..3cdb448d --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,198 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum MailProvider { + GMAIL + GMX + WEBDE +} + +enum JobStatus { + QUEUED + RUNNING + SUCCEEDED + FAILED + CANCELED +} + +enum RuleActionType { + MOVE + DELETE + ARCHIVE + LABEL +} + +enum RuleConditionType { + HEADER + SUBJECT + FROM + LIST_UNSUBSCRIBE + LIST_ID +} + +model Tenant { + id String @id @default(cuid()) + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + users User[] + mailboxAccounts MailboxAccount[] + rules Rule[] + jobs CleanupJob[] +} + +model User { + id String @id @default(cuid()) + tenantId String + email String @unique + password String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant Tenant @relation(fields: [tenantId], references: [id]) +} + +model MailboxAccount { + id String @id @default(cuid()) + tenantId String + email String + provider MailProvider + imapHost String + imapPort Int + imapTLS Boolean + smtpHost String? + smtpPort Int? + smtpTLS Boolean? + oauthToken String? + appPassword String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant Tenant @relation(fields: [tenantId], references: [id]) + folders MailboxFolder[] + jobs CleanupJob[] + + @@index([tenantId]) +} + +model MailboxFolder { + id String @id @default(cuid()) + mailboxAccountId String + name String + remoteId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + mailboxAccount MailboxAccount @relation(fields: [mailboxAccountId], references: [id]) + mailItems MailItem[] + + @@index([mailboxAccountId]) +} + +model MailItem { + id String @id @default(cuid()) + folderId String + messageId String + subject String? + from String? + receivedAt DateTime? + listId String? + listUnsubscribe String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + folder MailboxFolder @relation(fields: [folderId], references: [id]) + + @@index([folderId]) + @@index([messageId]) +} + +model Rule { + id String @id @default(cuid()) + tenantId String + name String + enabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant Tenant @relation(fields: [tenantId], references: [id]) + conditions RuleCondition[] + actions RuleAction[] + + @@index([tenantId]) +} + +model RuleCondition { + id String @id @default(cuid()) + ruleId String + type RuleConditionType + value String + + rule Rule @relation(fields: [ruleId], references: [id]) + + @@index([ruleId]) +} + +model RuleAction { + id String @id @default(cuid()) + ruleId String + type RuleActionType + target String? + + rule Rule @relation(fields: [ruleId], references: [id]) + + @@index([ruleId]) +} + +model CleanupJob { + id String @id @default(cuid()) + tenantId String + mailboxAccountId String + status JobStatus @default(QUEUED) + startedAt DateTime? + finishedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant Tenant @relation(fields: [tenantId], references: [id]) + mailboxAccount MailboxAccount @relation(fields: [mailboxAccountId], references: [id]) + unsubscribeAttempts UnsubscribeAttempt[] + events CleanupJobEvent[] + + @@index([tenantId]) + @@index([mailboxAccountId]) +} + +model UnsubscribeAttempt { + id String @id @default(cuid()) + jobId String + mailItemId String? + method String + target String + status String + createdAt DateTime @default(now()) + + job CleanupJob @relation(fields: [jobId], references: [id]) + + @@index([jobId]) +} + +model CleanupJobEvent { + id String @id @default(cuid()) + jobId String + level String + message String + progress Int? + createdAt DateTime @default(now()) + + job CleanupJob @relation(fields: [jobId], references: [id]) + + @@index([jobId]) +} diff --git a/backend/src/auth/plugin.ts b/backend/src/auth/plugin.ts new file mode 100644 index 00000000..c84825ba --- /dev/null +++ b/backend/src/auth/plugin.ts @@ -0,0 +1,12 @@ +import fp from "fastify-plugin"; +import { FastifyInstance } from "fastify"; + +export default fp(async function authPlugin(app: FastifyInstance) { + app.decorate("authenticate", async (request, reply) => { + try { + await request.jwtVerify(); + } catch (err) { + reply.code(401).send({ message: "Unauthorized" }); + } + }); +}); diff --git a/backend/src/auth/routes.ts b/backend/src/auth/routes.ts new file mode 100644 index 00000000..b5cdb19d --- /dev/null +++ b/backend/src/auth/routes.ts @@ -0,0 +1,63 @@ +import { FastifyInstance } from "fastify"; +import argon2 from "argon2"; +import { z } from "zod"; +import { prisma } from "../db.js"; + +const registerSchema = z.object({ + tenantName: z.string().min(2), + email: z.string().email(), + password: z.string().min(10) +}); + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(1) +}); + +export async function authRoutes(app: FastifyInstance) { + app.post("/register", async (request, reply) => { + const input = registerSchema.parse(request.body); + + const existing = await prisma.user.findUnique({ where: { email: input.email } }); + if (existing) { + return reply.code(409).send({ message: "Email already registered" }); + } + + const hashed = await argon2.hash(input.password); + + const tenant = await prisma.tenant.create({ + data: { name: input.tenantName } + }); + + const user = await prisma.user.create({ + data: { + tenantId: tenant.id, + email: input.email, + password: hashed + } + }); + + const token = app.jwt.sign({ sub: user.id, tenantId: user.tenantId }); + + return { token, user: { id: user.id, email: user.email, tenantId: user.tenantId } }; + }); + + app.post("/login", async (request, reply) => { + const input = loginSchema.parse(request.body); + + const user = await prisma.user.findUnique({ where: { email: input.email } }); + if (!user) { + return reply.code(401).send({ message: "Invalid credentials" }); + } + + const valid = await argon2.verify(user.password, input.password); + if (!valid) { + return reply.code(401).send({ message: "Invalid credentials" }); + } + + const token = app.jwt.sign({ sub: user.id, tenantId: user.tenantId }); + return { token, user: { id: user.id, email: user.email, tenantId: user.tenantId } }; + }); + + app.post("/logout", async () => ({ success: true })); +} diff --git a/backend/src/config.ts b/backend/src/config.ts new file mode 100644 index 00000000..551b11c7 --- /dev/null +++ b/backend/src/config.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +const envSchema = z.object({ + NODE_ENV: z.string().default("development"), + PORT: z.coerce.number().default(8000), + DATABASE_URL: z.string().url(), + REDIS_URL: z.string().url(), + JWT_SECRET: z.string().min(12) +}); + +export type AppConfig = z.infer; + +export const config = envSchema.parse({ + NODE_ENV: process.env.NODE_ENV, + PORT: process.env.PORT, + DATABASE_URL: process.env.DATABASE_URL, + REDIS_URL: process.env.REDIS_URL, + JWT_SECRET: process.env.JWT_SECRET +}); diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 00000000..bf73a9a0 --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from "@prisma/client"; + +export const prisma = new PrismaClient({ + log: ["error", "warn"] +}); diff --git a/backend/src/health/routes.ts b/backend/src/health/routes.ts new file mode 100644 index 00000000..5fa9d9eb --- /dev/null +++ b/backend/src/health/routes.ts @@ -0,0 +1,5 @@ +import { FastifyInstance } from "fastify"; + +export async function healthRoutes(app: FastifyInstance) { + app.get("/", async () => ({ status: "ok" })); +} diff --git a/backend/src/mail/cleanup.ts b/backend/src/mail/cleanup.ts new file mode 100644 index 00000000..f5255cd4 --- /dev/null +++ b/backend/src/mail/cleanup.ts @@ -0,0 +1,50 @@ +import { prisma } from "../db.js"; +import { logJobEvent } from "../queue/jobEvents.js"; +import { createImapClient, fetchHeaders, listMailboxes } from "./imap.js"; +import { detectNewsletter } from "./newsletter.js"; + +export const runCleanup = async (cleanupJobId: string, mailboxAccountId: string) => { + const account = await prisma.mailboxAccount.findUnique({ where: { id: mailboxAccountId } }); + if (!account) { + throw new Error("Mailbox account not found"); + } + + await logJobEvent(cleanupJobId, "info", `Connecting to ${account.email}`); + + const client = createImapClient(account); + await client.connect(); + + try { + const mailboxes = await listMailboxes(client); + await logJobEvent(cleanupJobId, "info", `Found ${mailboxes.length} mailboxes`, 10); + + const targetMailbox = mailboxes.find((box) => /inbox/i.test(box.path))?.path ?? "INBOX"; + await logJobEvent(cleanupJobId, "info", `Scanning ${targetMailbox}`, 20); + + const headers = await fetchHeaders(client, targetMailbox, 300); + let newsletterCount = 0; + + for (const msg of headers) { + const result = detectNewsletter({ + headers: msg.headers, + subject: msg.subject, + from: msg.from + }); + + if (result.isNewsletter) { + newsletterCount += 1; + } + } + + await logJobEvent(cleanupJobId, "info", `Detected ${newsletterCount} newsletter candidates`, 60); + + await logJobEvent( + cleanupJobId, + "info", + "Routing, unsubscribe, and deletion steps will be executed in the next phase", + 80 + ); + } finally { + await client.logout().catch(() => undefined); + } +}; diff --git a/backend/src/mail/imap.ts b/backend/src/mail/imap.ts new file mode 100644 index 00000000..c54d80f7 --- /dev/null +++ b/backend/src/mail/imap.ts @@ -0,0 +1,54 @@ +import { ImapFlow } from "imapflow"; +import { simpleParser } from "mailparser"; +import { MailboxAccount } from "@prisma/client"; + +export const createImapClient = (account: MailboxAccount) => { + return new ImapFlow({ + host: account.imapHost, + port: account.imapPort, + secure: account.imapTLS, + auth: account.oauthToken + ? { user: account.email, accessToken: account.oauthToken } + : { user: account.email, pass: account.appPassword ?? "" } + }); +}; + +export const listMailboxes = async (client: ImapFlow) => { + const mailboxes = [] as { name: string; path: string }[]; + for await (const mailbox of client.list()) { + mailboxes.push({ name: mailbox.name, path: mailbox.path }); + } + return mailboxes; +}; + +export const fetchHeaders = async (client: ImapFlow, mailbox: string, limit = 500) => { + await client.mailboxOpen(mailbox); + + const messages = [] as { + uid: number; + subject?: string; + from?: string; + headers: Map; + }[]; + + const search = await client.search({ all: true }); + const slice = search.slice(-limit); + + for await (const msg of client.fetch(slice, { envelope: true, source: true })) { + const parsed = await simpleParser(msg.source ?? ""); + const headers = new Map(); + + for (const [key, value] of parsed.headers) { + headers.set(key.toLowerCase(), Array.isArray(value) ? value.join(",") : String(value)); + } + + messages.push({ + uid: msg.uid, + subject: parsed.subject, + from: parsed.from?.text, + headers + }); + } + + return messages; +}; diff --git a/backend/src/mail/newsletter.ts b/backend/src/mail/newsletter.ts new file mode 100644 index 00000000..e411663c --- /dev/null +++ b/backend/src/mail/newsletter.ts @@ -0,0 +1,45 @@ +const headerIncludes = (headers: Map, key: string) => + headers.has(key.toLowerCase()); + +const headerValue = (headers: Map, key: string) => + headers.get(key.toLowerCase()) ?? ""; + +const containsAny = (value: string, tokens: string[]) => + tokens.some((token) => value.includes(token)); + +export const detectNewsletter = (params: { + headers: Map; + subject?: string | null; + from?: string | null; +}) => { + const subject = (params.subject ?? "").toLowerCase(); + const from = (params.from ?? "").toLowerCase(); + const headers = params.headers; + + const hasListUnsubscribe = headerIncludes(headers, "list-unsubscribe"); + const hasListId = headerIncludes(headers, "list-id"); + + const precedence = headerValue(headers, "precedence").toLowerCase(); + const bulkHeader = headerValue(headers, "x-precedence").toLowerCase(); + + const headerHints = containsAny(precedence, ["bulk", "list"]) || + containsAny(bulkHeader, ["bulk", "list"]) || + headerIncludes(headers, "list-unsubscribe-post"); + + const subjectHints = containsAny(subject, ["newsletter", "unsubscribe", "update", "news", "digest"]); + const fromHints = containsAny(from, ["newsletter", "no-reply", "noreply", "news", "updates"]); + + const score = [hasListUnsubscribe, hasListId, headerHints, subjectHints, fromHints].filter(Boolean).length; + + return { + isNewsletter: score >= 2, + score, + signals: { + hasListUnsubscribe, + hasListId, + headerHints, + subjectHints, + fromHints + } + }; +}; diff --git a/backend/src/mail/providers.ts b/backend/src/mail/providers.ts new file mode 100644 index 00000000..7f4c2cce --- /dev/null +++ b/backend/src/mail/providers.ts @@ -0,0 +1,35 @@ +import { MailProvider } from "@prisma/client"; + +export const providerDefaults: Record = { + GMAIL: { + imapHost: "imap.gmail.com", + imapPort: 993, + imapTLS: true, + smtpHost: "smtp.gmail.com", + smtpPort: 587, + smtpTLS: true + }, + GMX: { + imapHost: "imap.gmx.net", + imapPort: 993, + imapTLS: true, + smtpHost: "smtp.gmx.net", + smtpPort: 587, + smtpTLS: true + }, + WEBDE: { + imapHost: "imap.web.de", + imapPort: 993, + imapTLS: true, + smtpHost: "smtp.web.de", + smtpPort: 587, + smtpTLS: true + } +}; diff --git a/backend/src/mail/routes.ts b/backend/src/mail/routes.ts new file mode 100644 index 00000000..1aada6d5 --- /dev/null +++ b/backend/src/mail/routes.ts @@ -0,0 +1,81 @@ +import { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { prisma } from "../db.js"; +import { providerDefaults } from "./providers.js"; +import { queueCleanupJob } from "../queue/queue.js"; + +const createAccountSchema = z.object({ + email: z.string().email(), + provider: z.enum(["GMAIL", "GMX", "WEBDE"]), + imapHost: z.string().optional(), + imapPort: z.number().optional(), + imapTLS: z.boolean().optional(), + smtpHost: z.string().optional(), + smtpPort: z.number().optional(), + smtpTLS: z.boolean().optional(), + oauthToken: z.string().optional(), + appPassword: z.string().optional() +}); + +const cleanupSchema = z.object({ + mailboxAccountId: z.string() +}); + +export async function mailRoutes(app: FastifyInstance) { + app.addHook("preHandler", app.authenticate); + + app.get("/accounts", async (request) => { + const accounts = await prisma.mailboxAccount.findMany({ + where: { tenantId: request.user.tenantId } + }); + + return { accounts }; + }); + + app.post("/accounts", async (request, reply) => { + const input = createAccountSchema.parse(request.body); + const defaults = providerDefaults[input.provider]; + + const account = await prisma.mailboxAccount.create({ + data: { + tenantId: request.user.tenantId, + email: input.email, + provider: input.provider, + imapHost: input.imapHost ?? defaults.imapHost, + imapPort: input.imapPort ?? defaults.imapPort, + imapTLS: input.imapTLS ?? defaults.imapTLS, + smtpHost: input.smtpHost ?? defaults.smtpHost, + smtpPort: input.smtpPort ?? defaults.smtpPort, + smtpTLS: input.smtpTLS ?? defaults.smtpTLS, + oauthToken: input.oauthToken, + appPassword: input.appPassword + } + }); + + return reply.code(201).send({ account }); + }); + + app.get("/mailboxes", async () => ({ mailboxes: [] })); + + app.post("/cleanup", async (request, reply) => { + const input = cleanupSchema.parse(request.body); + + const account = await prisma.mailboxAccount.findFirst({ + where: { id: input.mailboxAccountId, tenantId: request.user.tenantId } + }); + if (!account) { + return reply.code(404).send({ message: "Mailbox account not found" }); + } + + const job = await prisma.cleanupJob.create({ + data: { + tenantId: request.user.tenantId, + mailboxAccountId: account.id + } + }); + + await queueCleanupJob(job.id, account.id); + + return reply.code(202).send({ jobId: job.id }); + }); +} diff --git a/backend/src/main.ts b/backend/src/main.ts new file mode 100644 index 00000000..4d934dcf --- /dev/null +++ b/backend/src/main.ts @@ -0,0 +1,51 @@ +import Fastify from "fastify"; +import cors from "@fastify/cors"; +import helmet from "@fastify/helmet"; +import jwt from "@fastify/jwt"; +import swagger from "@fastify/swagger"; +import swaggerUi from "@fastify/swagger-ui"; +import { config } from "./config.js"; +import authPlugin from "./auth/plugin.js"; +import { healthRoutes } from "./health/routes.js"; +import { authRoutes } from "./auth/routes.js"; +import { tenantRoutes } from "./tenant/routes.js"; +import { mailRoutes } from "./mail/routes.js"; +import { queueRoutes } from "./queue/routes.js"; + +const app = Fastify({ + logger: { + transport: { + target: "pino-pretty", + options: { colorize: true } + } + } +}); + +await app.register(cors, { origin: true }); +await app.register(helmet); +await app.register(jwt, { secret: config.JWT_SECRET }); +await app.register(authPlugin); + +await app.register(swagger, { + openapi: { + info: { title: "Simple Mail Cleaner API", version: "0.1.0" } + } +}); +await app.register(swaggerUi, { routePrefix: "/docs" }); + +await app.register(healthRoutes, { prefix: "/health" }); +await app.register(authRoutes, { prefix: "/auth" }); +await app.register(tenantRoutes, { prefix: "/tenants" }); +await app.register(mailRoutes, { prefix: "/mail" }); +await app.register(queueRoutes, { prefix: "/jobs" }); + +const start = async () => { + try { + await app.listen({ port: config.PORT, host: "0.0.0.0" }); + } catch (err) { + app.log.error(err); + process.exit(1); + } +}; + +await start(); diff --git a/backend/src/queue/jobEvents.ts b/backend/src/queue/jobEvents.ts new file mode 100644 index 00000000..b6e1c750 --- /dev/null +++ b/backend/src/queue/jobEvents.ts @@ -0,0 +1,12 @@ +import { prisma } from "../db.js"; + +export const logJobEvent = async (jobId: string, level: string, message: string, progress?: number) => { + await prisma.cleanupJobEvent.create({ + data: { + jobId, + level, + message, + progress + } + }); +}; diff --git a/backend/src/queue/queue.ts b/backend/src/queue/queue.ts new file mode 100644 index 00000000..ee86d6b0 --- /dev/null +++ b/backend/src/queue/queue.ts @@ -0,0 +1,25 @@ +import { Queue } from "bullmq"; +import IORedis from "ioredis"; +import { config } from "../config.js"; + +let cleanupQueue: Queue | null = null; + +const getConnection = () => new IORedis(config.REDIS_URL, { maxRetriesPerRequest: null }); + +export const getCleanupQueue = () => { + if (!cleanupQueue) { + cleanupQueue = new Queue("cleanup", { + connection: getConnection() + }); + } + return cleanupQueue; +}; + +export const queueCleanupJob = async (cleanupJobId: string, mailboxAccountId: string) => { + const queue = getCleanupQueue(); + await queue.add( + "cleanup", + { cleanupJobId, mailboxAccountId }, + { jobId: cleanupJobId } + ); +}; diff --git a/backend/src/queue/routes.ts b/backend/src/queue/routes.ts new file mode 100644 index 00000000..e77d5227 --- /dev/null +++ b/backend/src/queue/routes.ts @@ -0,0 +1,48 @@ +import { FastifyInstance } from "fastify"; +import { prisma } from "../db.js"; + +export async function queueRoutes(app: FastifyInstance) { + app.addHook("preHandler", app.authenticate); + + app.get("/", async (request) => { + const jobs = await prisma.cleanupJob.findMany({ + where: { tenantId: request.user.tenantId }, + orderBy: { createdAt: "desc" } + }); + + return { jobs }; + }); + + app.get("/:id", async (request, reply) => { + const params = request.params as { id: string }; + + const job = await prisma.cleanupJob.findFirst({ + where: { id: params.id, tenantId: request.user.tenantId } + }); + + if (!job) { + return reply.code(404).send({ message: "Job not found" }); + } + + return { job }; + }); + + app.get("/:id/events", async (request, reply) => { + const params = request.params as { id: string }; + + const job = await prisma.cleanupJob.findFirst({ + where: { id: params.id, tenantId: request.user.tenantId } + }); + + if (!job) { + return reply.code(404).send({ message: "Job not found" }); + } + + const events = await prisma.cleanupJobEvent.findMany({ + where: { jobId: job.id }, + orderBy: { createdAt: "asc" } + }); + + return { events }; + }); +} diff --git a/backend/src/tenant/routes.ts b/backend/src/tenant/routes.ts new file mode 100644 index 00000000..5445e262 --- /dev/null +++ b/backend/src/tenant/routes.ts @@ -0,0 +1,18 @@ +import { FastifyInstance } from "fastify"; +import { prisma } from "../db.js"; + +export async function tenantRoutes(app: FastifyInstance) { + app.addHook("preHandler", app.authenticate); + + app.get("/me", async (request) => { + const user = await prisma.user.findUnique({ + where: { id: request.user.sub }, + include: { tenant: true } + }); + + return { + user: user ? { id: user.id, email: user.email } : null, + tenant: user?.tenant ? { id: user.tenant.id, name: user.tenant.name } : null + }; + }); +} diff --git a/backend/src/types.d.ts b/backend/src/types.d.ts new file mode 100644 index 00000000..b24fbb7f --- /dev/null +++ b/backend/src/types.d.ts @@ -0,0 +1,16 @@ +import "@fastify/jwt"; +import type { FastifyReply, FastifyRequest } from "fastify"; +import "fastify"; + +declare module "@fastify/jwt" { + interface FastifyJWT { + payload: { sub: string; tenantId: string }; + user: { sub: string; tenantId: string }; + } +} + +declare module "fastify" { + interface FastifyInstance { + authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise; + } +} diff --git a/backend/src/worker.ts b/backend/src/worker.ts new file mode 100644 index 00000000..6ce69c9a --- /dev/null +++ b/backend/src/worker.ts @@ -0,0 +1,48 @@ +import { Worker } from "bullmq"; +import IORedis from "ioredis"; +import { prisma } from "./db.js"; +import { config } from "./config.js"; +import { runCleanup } from "./mail/cleanup.js"; +import { logJobEvent } from "./queue/jobEvents.js"; + +const connection = new IORedis(config.REDIS_URL, { maxRetriesPerRequest: null }); + +const worker = new Worker( + "cleanup", + async (job) => { + const { cleanupJobId, mailboxAccountId } = job.data as { cleanupJobId: string; mailboxAccountId: string }; + + await prisma.cleanupJob.update({ + where: { id: cleanupJobId }, + data: { status: "RUNNING", startedAt: new Date() } + }); + await logJobEvent(cleanupJobId, "info", "Cleanup started", 5); + + await runCleanup(cleanupJobId, mailboxAccountId); + + await prisma.cleanupJob.update({ + where: { id: cleanupJobId }, + data: { status: "SUCCEEDED", finishedAt: new Date() } + }); + await logJobEvent(cleanupJobId, "info", "Cleanup finished", 100); + + return { ok: true }; + }, + { connection } +); + +worker.on("failed", async (job, err) => { + if (!job) return; + const cleanupJobId = job.data?.cleanupJobId as string | undefined; + if (!cleanupJobId) return; + + await prisma.cleanupJob.update({ + where: { id: cleanupJobId }, + data: { status: "FAILED", finishedAt: new Date() } + }); + + await logJobEvent(cleanupJobId, "error", `Job failed: ${err.message}`); + process.stderr.write(`[worker] job ${cleanupJobId} failed: ${err.message}\n`); +}); + +process.stdout.write("[worker] cleanup worker ready\n"); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 00000000..18e0e075 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..a63d919b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,69 @@ +version: "3.9" + +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: mailcleaner + POSTGRES_PASSWORD: mailcleaner + POSTGRES_DB: mailcleaner + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - "5432:5432" + + redis: + image: redis:7 + ports: + - "6379:6379" + + api: + build: + context: ./backend + dockerfile: Dockerfile + environment: + NODE_ENV: development + PORT: 8000 + DATABASE_URL: postgresql://mailcleaner:mailcleaner@postgres:5432/mailcleaner + REDIS_URL: redis://redis:6379 + JWT_SECRET: dev-change-me + depends_on: + - postgres + - redis + ports: + - "8000:8000" + volumes: + - ./backend:/app + + worker: + build: + context: ./backend + dockerfile: Dockerfile + environment: + NODE_ENV: development + DATABASE_URL: postgresql://mailcleaner:mailcleaner@postgres:5432/mailcleaner + REDIS_URL: redis://redis:6379 + JWT_SECRET: dev-change-me + depends_on: + - postgres + - redis + command: ["npm", "run", "worker:dev"] + volumes: + - ./backend:/app + + web: + build: + context: ./frontend + dockerfile: Dockerfile + environment: + PORT: 3000 + VITE_API_URL: http://localhost:8000 + depends_on: + - api + ports: + - "3000:3000" + volumes: + - ./frontend:/app + +volumes: + pgdata: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..50d08538 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-slim + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install + +COPY tsconfig.json vite.config.ts index.html ./ +COPY src ./src + +EXPOSE 3000 + +CMD ["npm", "run", "dev"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..1f708dd0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Simple Mail Cleaner + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..8c4ac0d9 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "simple-mail-cleaner-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 3000", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "i18next": "^23.12.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-i18next": "^14.1.3" + }, + "devDependencies": { + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^5.4.10" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 00000000..292416ff --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,111 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +const languages = [ + { code: "de", label: "Deutsch" }, + { code: "en", label: "English" } +]; + +export default function App() { + const { t, i18n } = useTranslation(); + const [activeLang, setActiveLang] = useState(i18n.language); + + const switchLanguage = (code: string) => { + i18n.changeLanguage(code); + setActiveLang(code); + }; + + return ( +
+
+
+

v0.1

+

{t("appName")}

+

{t("tagline")}

+
+
+ {t("language")} +
+ {languages.map((lang) => ( + + ))} +
+
+
+ +
+
+
+

{t("welcome")}

+

{t("description")}

+
+ + +
+
+
+
+ {t("progress")} + 0% +
+
+
+
+
+
+

{t("mailboxes")}

+

1

+
+
+

{t("jobs")}

+

0

+
+
+

{t("rules")}

+

0

+
+
+

{t("progressNote")}

+
+
+ +
+
+

{t("featureOne")}

+

{t("featureOneText")}

+
+
+

{t("featureTwo")}

+

{t("featureTwoText")}

+
+
+

{t("featureThree")}

+

{t("featureThreeText")}

+
+
+ +
+
+

{t("queue")}

+

{t("queueText")}

+
+
+

{t("security")}

+

{t("securityText")}

+
+
+
+
+ ); +} diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts new file mode 100644 index 00000000..aa324d1d --- /dev/null +++ b/frontend/src/i18n.ts @@ -0,0 +1,16 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import en from "./locales/en/translation.json"; +import de from "./locales/de/translation.json"; + +i18n.use(initReactI18next).init({ + resources: { + en: { translation: en }, + de: { translation: de } + }, + lng: "de", + fallbackLng: "en", + interpolation: { escapeValue: false } +}); + +export default i18n; diff --git a/frontend/src/locales/de/translation.json b/frontend/src/locales/de/translation.json new file mode 100644 index 00000000..a6db1534 --- /dev/null +++ b/frontend/src/locales/de/translation.json @@ -0,0 +1,25 @@ +{ + "appName": "Simple Mail Cleaner", + "tagline": "Postfächer sicher und skalierbar bereinigen.", + "start": "Bereinigung starten", + "progress": "Fortschritt", + "mailboxes": "Postfächer", + "jobs": "Jobs", + "rules": "Regeln", + "status": "Status", + "welcome": "Willkommen zurück", + "description": "Verbinde GMX-, Gmail- und web.de-Accounts, deabonniere Newsletter, sortiere Mails und verfolge jeden Schritt.", + "language": "Sprache", + "overview": "Überblick", + "queue": "Queue", + "security": "Sicherheit", + "securityText": "Tokens verschlüsselt, audit-fähige Logs, DSGVO-first Design.", + "featureOne": "Automatisches Deabonnieren", + "featureTwo": "Konfigurierbares Routing", + "featureThree": "Multi-Tenant bereit", + "featureOneText": "List-Unsubscribe one-click und Weblinks vorbereitet.", + "featureTwoText": "Flexible Regeln für Ordner, Labels und Löschungen.", + "featureThreeText": "Mandantenfähig mit separaten Konten, Jobs und Regeln.", + "queueText": "Jobs, Fortschritt und Logs in Echtzeit verfolgen.", + "progressNote": "Security-Preview – Verschlüsselung und Audit-Trails sind geplant." +} diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json new file mode 100644 index 00000000..9ff57871 --- /dev/null +++ b/frontend/src/locales/en/translation.json @@ -0,0 +1,25 @@ +{ + "appName": "Simple Mail Cleaner", + "tagline": "Clean inboxes at scale, safely.", + "start": "Start cleanup", + "progress": "Progress", + "mailboxes": "Mailboxes", + "jobs": "Jobs", + "rules": "Rules", + "status": "Status", + "welcome": "Welcome back", + "description": "Connect GMX, Gmail, and web.de accounts to unsubscribe newsletters, sort mail, and track every step.", + "language": "Language", + "overview": "Overview", + "queue": "Queue", + "security": "Security", + "securityText": "Tokens encrypted, audit-ready logs, GDPR-first design.", + "featureOne": "Automated unsubscribe", + "featureTwo": "Configurable routing", + "featureThree": "Multi-tenant ready", + "featureOneText": "List-Unsubscribe one-click plus web-link handling.", + "featureTwoText": "Flexible rules for folders, labels, and deletions.", + "featureThreeText": "Tenant isolation with accounts, jobs, and rules per user.", + "queueText": "Track jobs, progress, and logs in real time.", + "progressNote": "Security posture preview – encryption and audit trails are planned." +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 00000000..178a4711 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import "./i18n"; +import "./styles.css"; +import App from "./App"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +); diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 00000000..1c7f3859 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,238 @@ +@import url("https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Space+Grotesk:wght@400;500;600;700&display=swap"); + +:root { + color-scheme: light; + --bg: #f7f2ea; + --bg-accent: #fbe9d2; + --ink: #111010; + --muted: #5b5b57; + --primary: #e8702a; + --secondary: #0b6e6b; + --card: #ffffff; + --border: rgba(17, 16, 16, 0.12); + --shadow: 0 20px 60px rgba(17, 16, 16, 0.12); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: "Space Grotesk", "Segoe UI", sans-serif; + background: radial-gradient(circle at top, var(--bg-accent), var(--bg)); + color: var(--ink); + min-height: 100vh; +} + +.app { + max-width: 1200px; + margin: 0 auto; + padding: 40px 24px 80px; +} + +.topbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + margin-bottom: 48px; +} + +.badge { + font-size: 12px; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--secondary); + margin-bottom: 8px; +} + +h1 { + font-family: "DM Serif Display", "Georgia", serif; + font-size: clamp(32px, 4vw, 54px); + margin-bottom: 8px; +} + +.tagline { + color: var(--muted); + font-size: 18px; +} + +.lang { + background: var(--card); + border: 1px solid var(--border); + border-radius: 20px; + padding: 16px; + box-shadow: var(--shadow); + min-width: 200px; +} + +.lang span { + display: block; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--muted); + margin-bottom: 12px; +} + +.lang-buttons { + display: flex; + gap: 8px; +} + +.lang button { + border: 1px solid var(--border); + background: transparent; + padding: 8px 12px; + border-radius: 999px; + cursor: pointer; + font-weight: 600; +} + +.lang button.active { + background: var(--secondary); + color: #fff; + border-color: var(--secondary); +} + +.hero { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 32px; + margin-bottom: 48px; +} + +.hero h2 { + font-size: clamp(26px, 3vw, 40px); + margin-bottom: 16px; +} + +.description { + color: var(--muted); + font-size: 18px; + line-height: 1.6; + margin-bottom: 24px; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +button.primary { + background: var(--primary); + color: #fff; + border: none; + padding: 12px 20px; + border-radius: 12px; + font-weight: 700; + cursor: pointer; + box-shadow: 0 10px 30px rgba(232, 112, 42, 0.35); +} + +button.ghost { + background: transparent; + border: 1px solid var(--border); + padding: 12px 20px; + border-radius: 12px; + font-weight: 600; + cursor: pointer; +} + +.status-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 24px; + padding: 24px; + box-shadow: var(--shadow); + display: grid; + gap: 16px; +} + +.status-header { + display: flex; + justify-content: space-between; + align-items: center; + text-transform: uppercase; + font-size: 12px; + letter-spacing: 0.2em; + color: var(--muted); +} + +.progress-bar { + background: rgba(11, 110, 107, 0.12); + border-radius: 999px; + height: 10px; + overflow: hidden; +} + +.progress { + height: 100%; + background: linear-gradient(90deg, var(--secondary), var(--primary)); +} + +.status-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +.status-grid p { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--muted); +} + +.status-grid h3 { + font-size: 24px; + margin-top: 4px; +} + +.status-note { + font-size: 14px; + color: var(--muted); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 20px; + margin-bottom: 40px; +} + +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 20px; + padding: 20px; + box-shadow: 0 12px 30px rgba(17, 16, 16, 0.08); +} + +.card h3 { + margin-bottom: 8px; +} + +.split { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 24px; + background: #111010; + color: #f8f1e8; + padding: 24px; + border-radius: 20px; +} + +.split h3 { + margin-bottom: 8px; +} + +@media (max-width: 720px) { + .topbar { + flex-direction: column; + align-items: stretch; + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 00000000..31644017 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 00000000..b2c3aeff --- /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()], + server: { + host: true, + port: 3000 + } +});