From 0ad67c54a6915bb3e74bfb83cfe024fcc999ad3e Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 16 Mar 2026 12:02:14 -0700 Subject: [PATCH] feat(rtasks): add email checklist with HMAC-signed click-to-check links POST /checklist/send builds and emails a styled checklist from backlog AC items. GET /checklist/:token verifies the HMAC signature, toggles the AC in the markdown file, and re-renders the page with fresh links for remaining items. Adds dev-ops volume mount and RTASKS_HMAC_SECRET/RTASKS_API_KEY env vars. Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 4 + modules/rtasks/lib/backlog.ts | 109 +++++++++++++++++++++++++ modules/rtasks/lib/checklist-config.ts | 14 ++++ modules/rtasks/lib/render.ts | 98 ++++++++++++++++++++++ modules/rtasks/lib/token.ts | 87 ++++++++++++++++++++ modules/rtasks/mod.ts | 108 ++++++++++++++++++++++++ 6 files changed, 420 insertions(+) create mode 100644 modules/rtasks/lib/backlog.ts create mode 100644 modules/rtasks/lib/checklist-config.ts create mode 100644 modules/rtasks/lib/render.ts create mode 100644 modules/rtasks/lib/token.ts diff --git a/docker-compose.yml b/docker-compose.yml index a208ae5..a38f288 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ services: - rspace-splats:/data/splats - rspace-docs:/data/docs - rspace-backups:/data/backups + - /opt/apps/dev-ops:/repos/dev-ops:rw environment: - NODE_ENV=production - STORAGE_DIR=/data/communities @@ -43,6 +44,9 @@ services: - SMTP_USER=${SMTP_USER:-noreply@rmail.online} - SMTP_PASS=${SMTP_PASS} - SITE_URL=https://rspace.online + - RTASKS_REPO_BASE=/repos + - RTASKS_HMAC_SECRET=${RTASKS_HMAC_SECRET} + - RTASKS_API_KEY=${RTASKS_API_KEY} - SPLAT_NOTIFY_EMAIL=jeffemmett@gmail.com - TWENTY_API_URL=http://twenty-ch-server:3000 - TWENTY_API_TOKEN=${TWENTY_API_TOKEN:-} diff --git a/modules/rtasks/lib/backlog.ts b/modules/rtasks/lib/backlog.ts new file mode 100644 index 0000000..defcf69 --- /dev/null +++ b/modules/rtasks/lib/backlog.ts @@ -0,0 +1,109 @@ +/** Direct markdown parsing of backlog task files for AC read/toggle. */ + +import { readdir } from "node:fs/promises"; +import { repoPath } from "./checklist-config"; + +const AC_BEGIN = ""; +const AC_END = ""; +const AC_REGEX = /^- \[([ x])\] #(\d+) (.+)$/; + +export interface AcceptanceCriterion { + index: number; + checked: boolean; + text: string; +} + +export interface TaskInfo { + title: string; + status: string; + criteria: AcceptanceCriterion[]; + filePath: string; +} + +/** + * Find the task markdown file in the backlog directory. + * Files are named like "task-high.3 - Some-title.md" where the taskId + * is the prefix before " - ". Case-insensitive match. + */ +async function findTaskFile(repo: string, taskId: string): Promise { + const base = repoPath(repo); + const tasksDir = `${base}/backlog/tasks`; + + const exact = `${tasksDir}/${taskId}.md`; + if (await Bun.file(exact).exists()) return exact; + + const files = await readdir(tasksDir); + const lower = taskId.toLowerCase(); + const match = files.find((f) => f.toLowerCase().startsWith(lower + " - ") && f.endsWith(".md")); + if (match) return `${tasksDir}/${match}`; + + throw new Error(`Task file not found for ${taskId} in ${tasksDir}`); +} + +function parseTitle(content: string): string { + const fmMatch = content.match(/^---\n[\s\S]*?title:\s*["']?(.+?)["']?\s*\n[\s\S]*?---/); + if (fmMatch?.[1]) return fmMatch[1]; + const headingMatch = content.match(/^#\s+(.+)$/m); + return headingMatch?.[1] || "Untitled Task"; +} + +function parseStatus(content: string): string { + const match = content.match(/^status:\s*["']?(.+?)["']?\s*$/m); + return match?.[1] || "Unknown"; +} + +function parseAC(content: string): AcceptanceCriterion[] { + const normalized = content.replace(/\r\n/g, "\n"); + const beginIdx = normalized.indexOf(AC_BEGIN); + const endIdx = normalized.indexOf(AC_END); + if (beginIdx === -1 || endIdx === -1) return []; + + const acContent = normalized.substring(beginIdx + AC_BEGIN.length, endIdx); + const lines = acContent.split("\n").filter((l) => l.trim()); + const criteria: AcceptanceCriterion[] = []; + + for (const line of lines) { + const match = line.match(AC_REGEX); + if (match?.[1] && match?.[2] && match?.[3]) { + criteria.push({ + checked: match[1] === "x", + text: match[3], + index: parseInt(match[2], 10), + }); + } + } + return criteria; +} + +export async function readTask(repo: string, taskId: string): Promise { + const filePath = await findTaskFile(repo, taskId); + const content = await Bun.file(filePath).text(); + return { + title: parseTitle(content), + status: parseStatus(content), + criteria: parseAC(content), + filePath, + }; +} + +/** + * Toggle an AC item to checked. Idempotent: if already checked, no-op. + */ +export async function checkAC(repo: string, taskId: string, acIndex: number): Promise { + const filePath = await findTaskFile(repo, taskId); + let content = await Bun.file(filePath).text(); + + const unchecked = `- [ ] #${acIndex} `; + const checked = `- [x] #${acIndex} `; + if (content.includes(unchecked)) { + content = content.replace(unchecked, checked); + await Bun.write(filePath, content); + } + + return { + title: parseTitle(content), + status: parseStatus(content), + criteria: parseAC(content), + filePath, + }; +} diff --git a/modules/rtasks/lib/checklist-config.ts b/modules/rtasks/lib/checklist-config.ts new file mode 100644 index 0000000..2fca9ab --- /dev/null +++ b/modules/rtasks/lib/checklist-config.ts @@ -0,0 +1,14 @@ +/** Configuration for the email checklist feature. */ + +export const checklistConfig = { + hmacSecret: process.env.RTASKS_HMAC_SECRET || "", + apiKey: process.env.RTASKS_API_KEY || "", + baseUrl: process.env.SITE_URL || "https://rspace.online", + tokenExpiryDays: parseInt(process.env.RTASKS_TOKEN_EXPIRY_DAYS || "7", 10), + repoBase: process.env.RTASKS_REPO_BASE || "/repos", +} as const; + +export function repoPath(repo: string): string { + const safe = repo.replace(/[^a-zA-Z0-9_-]/g, ""); + return `${checklistConfig.repoBase}/${safe}`; +} diff --git a/modules/rtasks/lib/render.ts b/modules/rtasks/lib/render.ts new file mode 100644 index 0000000..5465adc --- /dev/null +++ b/modules/rtasks/lib/render.ts @@ -0,0 +1,98 @@ +/** HTML rendering for email checklist and web confirmation page. */ + +import type { AcceptanceCriterion } from "./backlog"; +import { checklistConfig } from "./checklist-config"; + +const SUCCESS_COLOR = "#22c55e"; + +function esc(str: string): string { + return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +const styles = ` +body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f8fafc;color:#1e293b;margin:0;padding:20px} +.card{max-width:600px;margin:40px auto;background:#fff;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,.1);padding:32px} +h1{font-size:20px;margin:0 0 4px;color:#0f172a} +.sub{font-size:14px;color:#64748b;margin-bottom:24px} +.row{display:flex;align-items:flex-start;gap:12px;padding:10px 0;border-bottom:1px solid #f1f5f9} +.row:last-child{border-bottom:none} +.cb{width:22px;height:22px;border-radius:6px;display:inline-flex;align-items:center;justify-content:center;font-size:14px;text-decoration:none;flex-shrink:0;margin-top:1px} +.uc{border:2px solid #cbd5e1;color:#cbd5e1;background:#fff} +.uc:hover{border-color:#6366f1;color:#6366f1} +.ck{border:2px solid ${SUCCESS_COLOR};background:${SUCCESS_COLOR};color:#fff} +.txt{font-size:15px;line-height:1.5} +.done{color:#94a3b8;text-decoration:line-through} +.ft{text-align:center;font-size:12px;color:#94a3b8;margin-top:24px} +.pb{height:6px;background:#e2e8f0;border-radius:3px;overflow:hidden;margin:16px 0 8px} +.pf{height:100%;background:${SUCCESS_COLOR};border-radius:3px} +.pt{font-size:13px;color:#64748b;margin-bottom:24px} +.hi{background:#f0fdf4;border-radius:8px;padding:2px 0} +`; + +function progress(criteria: AcceptanceCriterion[]): string { + const total = criteria.length; + const done = criteria.filter((c) => c.checked).length; + const pct = total > 0 ? Math.round((done / total) * 100) : 0; + return `
${done} of ${total} complete (${pct}%)
`; +} + +function checklist(criteria: AcceptanceCriterion[], tokens: Map, justChecked?: number): string { + return criteria + .map((ac) => { + const hi = ac.index === justChecked ? " hi" : ""; + if (ac.checked) { + return `
#${ac.index} ${esc(ac.text)}
`; + } + const token = tokens.get(ac.index); + const url = token ? `${checklistConfig.baseUrl}/demo/rtasks/checklist/${token}` : "#"; + return `
 #${ac.index} ${esc(ac.text)}
`; + }) + .join("\n"); +} + +export function renderEmailHTML( + title: string, + taskId: string, + criteria: AcceptanceCriterion[], + tokens: Map, +): string { + return ` +
+

${esc(title)}

+
${esc(taskId)} · Click a checkbox to mark it done
+${progress(criteria)} +${checklist(criteria, tokens)} +
Sent by rTasks · Links expire in ${checklistConfig.tokenExpiryDays} days
+
`; +} + +export function renderWebPage( + title: string, + taskId: string, + criteria: AcceptanceCriterion[], + tokens: Map, + justChecked?: number, +): string { + const justAC = justChecked !== undefined ? criteria.find((c) => c.index === justChecked) : undefined; + const banner = justAC + ? `
✓ Checked off: #${justAC.index} ${esc(justAC.text)}
` + : ""; + const allDone = criteria.every((c) => c.checked); + const doneMsg = allDone ? `
🎉 All items complete!
` : ""; + + return `${esc(title)} - rTasks +
+${banner} +

${esc(title)}

+
${esc(taskId)}
+${progress(criteria)} +${checklist(criteria, tokens, justChecked)} +${doneMsg} +
rTasks · rspace.online
+
`; +} + +export function renderError(title: string, message: string): string { + return `Error - rTasks +

${esc(title)}

${esc(message)}

rTasks · rspace.online
`; +} diff --git a/modules/rtasks/lib/token.ts b/modules/rtasks/lib/token.ts new file mode 100644 index 0000000..b3f306e --- /dev/null +++ b/modules/rtasks/lib/token.ts @@ -0,0 +1,87 @@ +/** HMAC-SHA256 signed tokens for email checklist links. */ + +import { checklistConfig } from "./checklist-config"; + +interface TokenPayload { + /** repo slug */ + r: string; + /** task ID */ + t: string; + /** AC index */ + a: number; + /** expiry (unix seconds) */ + e: number; +} + +interface TokenData extends TokenPayload { + /** hex signature */ + s: string; +} + +async function getKey(): Promise { + return crypto.subtle.importKey( + "raw", + new TextEncoder().encode(checklistConfig.hmacSecret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"], + ); +} + +function toBase64Url(data: string): string { + return btoa(data).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function fromBase64Url(b64: string): string { + return atob(b64.replace(/-/g, "+").replace(/_/g, "/")); +} + +function toHex(buf: ArrayBuffer): string { + return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +async function sign(payload: string): Promise { + const key = await getKey(); + const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload)); + return toHex(sig); +} + +export async function createToken(repo: string, taskId: string, acIndex: number): Promise { + const expiry = Math.floor(Date.now() / 1000) + checklistConfig.tokenExpiryDays * 86400; + const payload: TokenPayload = { r: repo, t: taskId, a: acIndex, e: expiry }; + const payloadStr = `${payload.r}:${payload.t}:${payload.a}:${payload.e}`; + const sig = await sign(payloadStr); + const token: TokenData = { ...payload, s: sig }; + return toBase64Url(JSON.stringify(token)); +} + +export interface VerifiedToken { + repo: string; + taskId: string; + acIndex: number; +} + +export async function verifyToken(token: string): Promise { + let data: TokenData; + try { + data = JSON.parse(fromBase64Url(token)); + } catch { + throw new Error("Invalid token format"); + } + + if (!data.r || !data.t || typeof data.a !== "number" || !data.e || !data.s) { + throw new Error("Malformed token"); + } + + if (data.e < Math.floor(Date.now() / 1000)) { + throw new Error("Token expired"); + } + + const payloadStr = `${data.r}:${data.t}:${data.a}:${data.e}`; + const expected = await sign(payloadStr); + if (data.s !== expected) { + throw new Error("Invalid signature"); + } + + return { repo: data.r, taskId: data.t, acIndex: data.a }; +} diff --git a/modules/rtasks/mod.ts b/modules/rtasks/mod.ts index 201f645..5ffd7de 100644 --- a/modules/rtasks/mod.ts +++ b/modules/rtasks/mod.ts @@ -19,6 +19,12 @@ 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"; + const routes = new Hono(); // ── Local-first helpers ── @@ -452,6 +458,108 @@ 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";