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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-16 12:02:14 -07:00
parent d008b78727
commit 0ad67c54a6
6 changed files with 420 additions and 0 deletions

View File

@ -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:-}

View File

@ -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 = "<!-- AC:BEGIN -->";
const AC_END = "<!-- 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<string> {
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<TaskInfo> {
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<TaskInfo> {
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,
};
}

View File

@ -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}`;
}

View File

@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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 `<div class="pb"><div class="pf" style="width:${pct}%"></div></div><div class="pt">${done} of ${total} complete (${pct}%)</div>`;
}
function checklist(criteria: AcceptanceCriterion[], tokens: Map<number, string>, justChecked?: number): string {
return criteria
.map((ac) => {
const hi = ac.index === justChecked ? " hi" : "";
if (ac.checked) {
return `<div class="row${hi}"><span class="cb ck">&#10003;</span><span class="txt done">#${ac.index} ${esc(ac.text)}</span></div>`;
}
const token = tokens.get(ac.index);
const url = token ? `${checklistConfig.baseUrl}/demo/rtasks/checklist/${token}` : "#";
return `<div class="row${hi}"><a href="${url}" class="cb uc" title="Click to check off">&nbsp;</a><span class="txt">#${ac.index} ${esc(ac.text)}</span></div>`;
})
.join("\n");
}
export function renderEmailHTML(
title: string,
taskId: string,
criteria: AcceptanceCriterion[],
tokens: Map<number, string>,
): string {
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><style>${styles}</style></head>
<body><div class="card">
<h1>${esc(title)}</h1>
<div class="sub">${esc(taskId)} &middot; Click a checkbox to mark it done</div>
${progress(criteria)}
${checklist(criteria, tokens)}
<div class="ft">Sent by rTasks &middot; Links expire in ${checklistConfig.tokenExpiryDays} days</div>
</div></body></html>`;
}
export function renderWebPage(
title: string,
taskId: string,
criteria: AcceptanceCriterion[],
tokens: Map<number, string>,
justChecked?: number,
): string {
const justAC = justChecked !== undefined ? criteria.find((c) => c.index === justChecked) : undefined;
const banner = justAC
? `<div style="background:${SUCCESS_COLOR};color:#fff;padding:12px;border-radius:8px;margin-bottom:20px;font-size:14px;">&#10003; Checked off: <strong>#${justAC.index} ${esc(justAC.text)}</strong></div>`
: "";
const allDone = criteria.every((c) => c.checked);
const doneMsg = allDone ? `<div style="text-align:center;padding:24px;font-size:18px;color:${SUCCESS_COLOR};">&#127881; All items complete!</div>` : "";
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)} - rTasks</title><style>${styles}</style></head>
<body><div class="card">
${banner}
<h1>${esc(title)}</h1>
<div class="sub">${esc(taskId)}</div>
${progress(criteria)}
${checklist(criteria, tokens, justChecked)}
${doneMsg}
<div class="ft">rTasks &middot; rspace.online</div>
</div></body></html>`;
}
export function renderError(title: string, message: string): string {
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Error - rTasks</title><style>${styles} .err{color:#ef4444}</style></head>
<body><div class="card" style="text-align:center"><h1 class="err">${esc(title)}</h1><p style="color:#64748b">${esc(message)}</p><div class="ft">rTasks &middot; rspace.online</div></div></body></html>`;
}

View File

@ -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<CryptoKey> {
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<string> {
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<string> {
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<VerifiedToken> {
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 };
}

View File

@ -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<number, string>();
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<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);
// 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";