diff --git a/modules/rtasks/checklist-routes.ts b/modules/rtasks/checklist-routes.ts new file mode 100644 index 0000000..74b2075 --- /dev/null +++ b/modules/rtasks/checklist-routes.ts @@ -0,0 +1,113 @@ +/** + * 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, checkAC } 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 { + await checkAC(verified.repo, verified.taskId, verified.acIndex); + const task = await readTask(verified.repo, verified.taskId); + + const tokens = new Map(); + for (const ac of task.criteria) { + if (!ac.checked) { + tokens.set(ac.index, await createToken(verified.repo, verified.taskId, ac.index)); + } + } + + return c.html(renderWebPage(task.title, verified.taskId, task.criteria, tokens, verified.acIndex)); + } 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(); + 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 || "mail.rmail.online", + 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); + } +}); diff --git a/modules/rtasks/lib/render.ts b/modules/rtasks/lib/render.ts index 5465adc..87286c7 100644 --- a/modules/rtasks/lib/render.ts +++ b/modules/rtasks/lib/render.ts @@ -44,7 +44,7 @@ function checklist(criteria: AcceptanceCriterion[], tokens: Map, return `
#${ac.index} ${esc(ac.text)}
`; } const token = tokens.get(ac.index); - const url = token ? `${checklistConfig.baseUrl}/demo/rtasks/checklist/${token}` : "#"; + const url = token ? `${checklistConfig.baseUrl}/rtasks/check/${token}` : "#"; return `
 #${ac.index} ${esc(ac.text)}
`; }) .join("\n"); diff --git a/modules/rtasks/mod.ts b/modules/rtasks/mod.ts index 5ffd7de..e6b7b1e 100644 --- a/modules/rtasks/mod.ts +++ b/modules/rtasks/mod.ts @@ -19,11 +19,7 @@ import type { SyncServer } from '../../server/local-first/sync-server'; import { boardSchema, boardDocId, createTaskItem } from './schemas'; import type { BoardDoc, TaskItem, BoardMeta } from './schemas'; -// Email checklist feature — HMAC-signed links toggle backlog AC items -import { checklistConfig } from "./lib/checklist-config"; -import { createToken, verifyToken } from "./lib/token"; -import { readTask, checkAC } from "./lib/backlog"; -import { renderEmailHTML, renderWebPage, renderError } from "./lib/render"; +// Email checklist routes exported separately — see checklist-routes.ts const routes = new Hono(); @@ -458,108 +454,6 @@ routes.get("/api/spaces/:slug/activity", async (c) => { return c.json([]); }); -// ── Email Checklist: GET /checklist/:token ── -// Clicked from email — the signed token IS the auth. -routes.get("/checklist/: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 { - await checkAC(verified.repo, verified.taskId, verified.acIndex); - const task = await readTask(verified.repo, verified.taskId); - - const tokens = new Map(); - for (const ac of task.criteria) { - if (!ac.checked) { - tokens.set(ac.index, await createToken(verified.repo, verified.taskId, ac.index)); - } - } - - return c.html(renderWebPage(task.title, verified.taskId, task.criteria, tokens, verified.acIndex)); - } catch (err) { - console.error("[Tasks/checklist] Check error:", err); - return c.html(renderError("Error", "Could not update task. Please try again."), 500); - } -}); - -// ── Email Checklist: POST /checklist/send ── -// Auth: Bearer token matching RTASKS_API_KEY env var. -routes.post("/checklist/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(); - 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); - - // Use rspace's existing SMTP transport (lazy import to avoid circular deps) - const nodemailer = await import("nodemailer"); - const transport = nodemailer.createTransport({ - host: process.env.SMTP_HOST || "mail.rmail.online", - 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}/demo/rtasks/checklist/${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); - } -}); - // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; diff --git a/server/index.ts b/server/index.ts index a0cdcff..f3b9aea 100644 --- a/server/index.ts +++ b/server/index.ts @@ -57,6 +57,7 @@ import { voteModule } from "../modules/rvote/mod"; import { notesModule } from "../modules/rnotes/mod"; import { mapsModule } from "../modules/rmaps/mod"; import { tasksModule } from "../modules/rtasks/mod"; +import { checklistCheckRoutes, checklistApiRoutes } from "../modules/rtasks/checklist-routes"; import { tripsModule } from "../modules/rtrips/mod"; import { calModule } from "../modules/rcal/mod"; import { networkModule } from "../modules/rnetwork/mod"; @@ -402,6 +403,9 @@ app.route("/api/notifications", notificationRouter); // ── MI — AI assistant endpoints ── app.route("/api/mi", miRoutes); +// ── Email Checklist (top-level, bypasses space auth) ── +app.route("/rtasks/check", checklistCheckRoutes); +app.route("/api/rtasks", checklistApiRoutes); // ── EncryptID proxy (forward /encryptid/* to encryptid container) ── const ENCRYPTID_INTERNAL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";