111 lines
3.7 KiB
TypeScript
111 lines
3.7 KiB
TypeScript
/**
|
|
* Email checklist routes — mounted at top level to bypass space auth middleware.
|
|
* GET /rtasks/check/:token — verify HMAC, toggle AC, render page
|
|
* POST /api/rtasks/send — build + send checklist email
|
|
*/
|
|
|
|
import { Hono } from "hono";
|
|
import { checklistConfig } from "./lib/checklist-config";
|
|
import { createToken, verifyToken } from "./lib/token";
|
|
import { readTask, toggleAC } from "./lib/backlog";
|
|
import { renderEmailHTML, renderWebPage, renderError } from "./lib/render";
|
|
|
|
export const checklistCheckRoutes = new Hono();
|
|
export const checklistApiRoutes = new Hono();
|
|
|
|
// ── GET /rtasks/check/:token ──
|
|
checklistCheckRoutes.get("/:token", async (c) => {
|
|
const token = c.req.param("token");
|
|
|
|
let verified;
|
|
try {
|
|
verified = await verifyToken(token);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Unknown error";
|
|
const title = message === "Token expired" ? "Link Expired" : "Invalid Link";
|
|
const body = message === "Token expired"
|
|
? "This checklist link has expired. Request a new checklist email."
|
|
: "This link is not valid. It may have been corrupted.";
|
|
return c.html(renderError(title, body), title === "Link Expired" ? 410 : 400);
|
|
}
|
|
|
|
try {
|
|
const result = await toggleAC(verified.repo, verified.taskId, verified.acIndex);
|
|
|
|
const tokens = new Map<number, string>();
|
|
for (const ac of result.criteria) {
|
|
tokens.set(ac.index, await createToken(verified.repo, verified.taskId, ac.index));
|
|
}
|
|
|
|
return c.html(renderWebPage(result.title, verified.taskId, result.criteria, tokens, verified.acIndex, result.toggled));
|
|
} catch (err) {
|
|
console.error("[Tasks/checklist] Check error:", err);
|
|
return c.html(renderError("Error", "Could not update task. Please try again."), 500);
|
|
}
|
|
});
|
|
|
|
// ── POST /api/rtasks/send ──
|
|
checklistApiRoutes.post("/send", async (c) => {
|
|
const authHeader = c.req.header("Authorization");
|
|
if (!authHeader || authHeader !== `Bearer ${checklistConfig.apiKey}`) {
|
|
return c.json({ error: "Unauthorized" }, 401);
|
|
}
|
|
|
|
const body = await c.req.json<{
|
|
repo: string;
|
|
taskId: string;
|
|
to: string;
|
|
subject?: string;
|
|
}>();
|
|
|
|
if (!body.repo || !body.taskId || !body.to) {
|
|
return c.json({ error: "Missing required fields: repo, taskId, to" }, 400);
|
|
}
|
|
|
|
try {
|
|
const task = await readTask(body.repo, body.taskId);
|
|
|
|
const tokens = new Map<number, string>();
|
|
for (const ac of task.criteria) {
|
|
if (!ac.checked) {
|
|
tokens.set(ac.index, await createToken(body.repo, body.taskId, ac.index));
|
|
}
|
|
}
|
|
|
|
if (tokens.size === 0) {
|
|
return c.json({ error: "All acceptance criteria already checked" }, 400);
|
|
}
|
|
|
|
const subject = body.subject || `Checklist: ${task.title}`;
|
|
const html = renderEmailHTML(task.title, body.taskId, task.criteria, tokens);
|
|
|
|
const nodemailer = await import("nodemailer");
|
|
const transport = nodemailer.createTransport({
|
|
host: process.env.SMTP_HOST || "mailcowdockerized-postfix-mailcow-1",
|
|
port: Number(process.env.SMTP_PORT) || 587,
|
|
secure: false,
|
|
auth: {
|
|
user: process.env.SMTP_USER || "noreply@rmail.online",
|
|
pass: process.env.SMTP_PASS,
|
|
},
|
|
tls: { rejectUnauthorized: false },
|
|
});
|
|
|
|
const result = await transport.sendMail({
|
|
from: process.env.SMTP_FROM || `rTasks <${process.env.SMTP_USER || "noreply@rmail.online"}>`,
|
|
to: body.to,
|
|
subject,
|
|
html,
|
|
});
|
|
|
|
const firstToken = tokens.values().next().value;
|
|
const url = `${checklistConfig.baseUrl}/rtasks/check/${firstToken}`;
|
|
|
|
return c.json({ ok: true, messageId: result.messageId, url, unchecked: tokens.size });
|
|
} catch (err) {
|
|
console.error("[Tasks/checklist] Send error:", err);
|
|
const message = err instanceof Error ? err.message : "Unknown error";
|
|
return c.json({ error: message }, 500);
|
|
}
|
|
});
|