Files
simple-mail-cleaner/backend/src/admin/routes.ts
2026-01-22 16:40:19 +01:00

360 lines
13 KiB
TypeScript

import { FastifyInstance } from "fastify";
import { z } from "zod";
import { prisma } from "../db.js";
import { logJobEvent } from "../queue/jobEvents.js";
import { queueCleanupJob, removeQueueJob, queueExportJob } from "../queue/queue.js";
import { createReadStream } from "node:fs";
import { access, unlink } from "node:fs/promises";
import { cleanupExpiredExports } from "./exportCleanup.js";
const roleSchema = z.object({
role: z.enum(["USER", "ADMIN"])
});
const activeSchema = z.object({
isActive: z.boolean()
});
const resetSchema = z.object({
password: z.string().min(10)
});
export async function adminRoutes(app: FastifyInstance) {
app.addHook("preHandler", app.requireAdmin);
app.get("/tenants", async () => {
const tenants = await prisma.tenant.findMany({
include: { _count: { select: { users: true, mailboxAccounts: true, jobs: true } } },
orderBy: { createdAt: "desc" }
});
return { tenants };
});
app.get("/tenants/:id/export", async (request, reply) => {
const params = request.params as { id: string };
const query = request.query as { format?: string; scope?: string };
const tenant = await prisma.tenant.findUnique({
where: { id: params.id },
include: {
users: true,
mailboxAccounts: true,
rules: { include: { conditions: true, actions: true } },
jobs: { include: { unsubscribeAttempts: true, events: true } }
}
});
if (!tenant) return reply.code(404).send({ message: "Tenant not found" });
const scope = query.scope ?? "all";
if (query.format === "csv") {
const rows = [
["type", "id", "email", "name", "createdAt"].join(",")
];
if (scope === "all" || scope === "users") {
for (const user of tenant.users) {
rows.push(["user", user.id, user.email, tenant.name, user.createdAt.toISOString()].join(","));
}
}
if (scope === "all" || scope === "accounts") {
for (const account of tenant.mailboxAccounts) {
rows.push(["account", account.id, account.email, account.provider, account.createdAt.toISOString()].join(","));
}
}
if (scope === "all" || scope === "jobs") {
for (const job of tenant.jobs) {
rows.push(["job", job.id, "", job.status, job.createdAt.toISOString()].join(","));
}
}
reply.header("Content-Type", "text/csv");
return reply.send(rows.join("\n"));
}
if (query.format === "zip" || query.format === "async") {
const exportJob = await prisma.exportJob.create({
data: {
tenantId: tenant.id,
format: "zip",
scope: scope
}
});
await queueExportJob(exportJob.id);
return { jobId: exportJob.id };
}
if (scope === "users") return { users: tenant.users };
if (scope === "accounts") return { accounts: tenant.mailboxAccounts };
if (scope === "jobs") return { jobs: tenant.jobs };
if (scope === "rules") return { rules: tenant.rules };
return { tenant };
});
app.get("/exports/:id", async (request, reply) => {
const params = request.params as { id: string };
const job = await prisma.exportJob.findUnique({ where: { id: params.id } });
if (!job) return reply.code(404).send({ message: "Export job not found" });
return { job };
});
app.get("/exports/:id/download", async (request, reply) => {
const params = request.params as { id: string };
const job = await prisma.exportJob.findUnique({ where: { id: params.id } });
if (!job || !job.filePath) return reply.code(404).send({ message: "Export file not ready" });
if (job.expiresAt && job.expiresAt < new Date()) {
return reply.code(410).send({ message: "Export expired" });
}
try {
await access(job.filePath);
} catch {
return reply.code(404).send({ message: "Export file missing" });
}
reply.header("Content-Type", "application/zip");
reply.header("Content-Disposition", `attachment; filename=export-${job.id}.zip`);
return reply.send(createReadStream(job.filePath));
});
app.get("/exports", async (request) => {
const query = request.query as { status?: string };
const exports = await prisma.exportJob.findMany({
where: query.status ? { status: query.status as "QUEUED" | "RUNNING" | "DONE" | "FAILED" } : undefined,
orderBy: { createdAt: "desc" }
});
const sanitized = exports.map((job) => ({
id: job.id,
status: job.status,
format: job.format,
scope: job.scope,
progress: job.progress,
expiresAt: job.expiresAt,
createdAt: job.createdAt
}));
return { exports: sanitized };
});
app.post("/exports/purge", async () => {
await cleanupExpiredExports();
return { success: true };
});
app.delete("/exports/:id", async (request, reply) => {
const params = request.params as { id: string };
const job = await prisma.exportJob.findUnique({ where: { id: params.id } });
if (!job) return reply.code(404).send({ message: "Export job not found" });
if (job.filePath) {
try {
await unlink(job.filePath);
} catch {
// ignore
}
}
await prisma.exportJob.delete({ where: { id: job.id } });
return { success: true };
});
app.delete("/tenants/:id", async (request, reply) => {
const params = request.params as { id: string };
const tenant = await prisma.tenant.findUnique({ where: { id: params.id } });
if (!tenant) return reply.code(404).send({ message: "Tenant not found" });
await prisma.$transaction(async (tx) => {
const jobs = await tx.cleanupJob.findMany({ where: { tenantId: tenant.id } });
const jobIds = jobs.map((job) => job.id);
await tx.cleanupJobEvent.deleteMany({ where: { jobId: { in: jobIds } } });
await tx.unsubscribeAttempt.deleteMany({ where: { jobId: { in: jobIds } } });
await tx.cleanupJob.deleteMany({ where: { tenantId: tenant.id } });
await tx.ruleAction.deleteMany({ where: { rule: { tenantId: tenant.id } } });
await tx.ruleCondition.deleteMany({ where: { rule: { tenantId: tenant.id } } });
await tx.rule.deleteMany({ where: { tenantId: tenant.id } });
await tx.mailItem.deleteMany({ where: { folder: { mailboxAccount: { tenantId: tenant.id } } } });
await tx.mailboxFolder.deleteMany({ where: { mailboxAccount: { tenantId: tenant.id } } });
await tx.mailboxAccount.deleteMany({ where: { tenantId: tenant.id } });
await tx.user.deleteMany({ where: { tenantId: tenant.id } });
await tx.tenant.delete({ where: { id: tenant.id } });
});
return { success: true };
});
app.put("/tenants/:id", async (request, reply) => {
const params = request.params as { id: string };
const input = activeSchema.parse(request.body);
const tenant = await prisma.tenant.findUnique({ where: { id: params.id } });
if (!tenant) return reply.code(404).send({ message: "Tenant not found" });
const updated = await prisma.tenant.update({
where: { id: params.id },
data: { isActive: input.isActive }
});
return { tenant: updated };
});
app.get("/users", async () => {
const users = await prisma.user.findMany({
include: { tenant: true },
orderBy: { createdAt: "desc" }
});
const sanitized = users.map((user) => ({
id: user.id,
email: user.email,
role: user.role,
isActive: user.isActive,
tenant: user.tenant ? { id: user.tenant.id, name: user.tenant.name } : null
}));
return { users: sanitized };
});
app.put("/users/:id/role", async (request, reply) => {
const params = request.params as { id: string };
const input = roleSchema.parse(request.body);
const user = await prisma.user.findUnique({ where: { id: params.id } });
if (!user) return reply.code(404).send({ message: "User not found" });
const updated = await prisma.user.update({
where: { id: params.id },
data: { role: input.role }
});
return { user: updated };
});
app.put("/users/:id", async (request, reply) => {
const params = request.params as { id: string };
const input = activeSchema.parse(request.body);
const user = await prisma.user.findUnique({ where: { id: params.id } });
if (!user) return reply.code(404).send({ message: "User not found" });
const updated = await prisma.user.update({
where: { id: params.id },
data: { isActive: input.isActive }
});
return { user: updated };
});
app.post("/users/:id/reset", async (request, reply) => {
const params = request.params as { id: string };
const input = resetSchema.parse(request.body);
const user = await prisma.user.findUnique({ where: { id: params.id } });
if (!user) return reply.code(404).send({ message: "User not found" });
const argon2 = (await import("argon2")).default;
const hashed = await argon2.hash(input.password);
const updated = await prisma.user.update({
where: { id: params.id },
data: { password: hashed }
});
return { success: true };
});
app.get("/accounts", async () => {
const accounts = await prisma.mailboxAccount.findMany({
include: { tenant: true },
orderBy: { createdAt: "desc" }
});
const sanitized = accounts.map((account) => ({
id: account.id,
email: account.email,
provider: account.provider,
isActive: account.isActive,
tenant: account.tenant ? { id: account.tenant.id, name: account.tenant.name } : null,
imapHost: account.imapHost,
imapPort: account.imapPort,
imapTLS: account.imapTLS,
oauthExpiresAt: account.oauthExpiresAt,
oauthLastCheckedAt: account.oauthLastCheckedAt,
oauthLastErrorCode: account.oauthLastErrorCode,
hasOauth: Boolean(account.oauthRefreshToken || account.oauthAccessToken)
}));
return { accounts: sanitized };
});
app.put("/accounts/:id", async (request, reply) => {
const params = request.params as { id: string };
const input = activeSchema.parse(request.body);
const account = await prisma.mailboxAccount.findUnique({ where: { id: params.id } });
if (!account) return reply.code(404).send({ message: "Account not found" });
const updated = await prisma.mailboxAccount.update({
where: { id: params.id },
data: { isActive: input.isActive }
});
return { account: updated };
});
app.get("/jobs", async () => {
const jobs = await prisma.cleanupJob.findMany({
include: { tenant: true, mailboxAccount: true },
orderBy: { createdAt: "desc" }
});
const sanitized = jobs.map((job) => ({
id: job.id,
status: job.status,
createdAt: job.createdAt,
tenant: job.tenant ? { id: job.tenant.id, name: job.tenant.name } : null,
mailboxAccount: job.mailboxAccount ? { id: job.mailboxAccount.id, email: job.mailboxAccount.email } : null
}));
return { jobs: sanitized };
});
app.get("/jobs/:id/events", async (request, reply) => {
const params = request.params as { id: string };
const job = await prisma.cleanupJob.findUnique({ where: { id: params.id } });
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 };
});
app.post("/jobs/:id/cancel", async (request, reply) => {
const params = request.params as { id: string };
const job = await prisma.cleanupJob.findUnique({ where: { id: params.id } });
if (!job) return reply.code(404).send({ message: "Job not found" });
await prisma.cleanupJob.update({ where: { id: job.id }, data: { status: "CANCELED" } });
await removeQueueJob(job.id);
await logJobEvent(job.id, "info", "Job canceled by admin", 100);
return { success: true };
});
app.post("/jobs/:id/retry", async (request, reply) => {
const params = request.params as { id: string };
const job = await prisma.cleanupJob.findUnique({ where: { id: params.id } });
if (!job) return reply.code(404).send({ message: "Job not found" });
const newJob = await prisma.cleanupJob.create({
data: {
tenantId: job.tenantId,
mailboxAccountId: job.mailboxAccountId,
dryRun: job.dryRun,
unsubscribeEnabled: job.unsubscribeEnabled,
routingEnabled: job.routingEnabled
}
});
await queueCleanupJob(newJob.id, newJob.mailboxAccountId);
await logJobEvent(newJob.id, "info", "Job queued by admin", 5);
return { jobId: newJob.id };
});
app.post("/impersonate/:userId", async (request, reply) => {
const params = request.params as { userId: string };
const user = await prisma.user.findUnique({ where: { id: params.userId } });
if (!user) return reply.code(404).send({ message: "User not found" });
const token = app.jwt.sign({ sub: user.id, tenantId: user.tenantId });
return { token };
});
}