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:
parent
d008b78727
commit
0ad67c54a6
|
|
@ -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:-}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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, ">").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 `<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">✓</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"> </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)} · Click a checkbox to mark it done</div>
|
||||
${progress(criteria)}
|
||||
${checklist(criteria, tokens)}
|
||||
<div class="ft">Sent by rTasks · 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;">✓ 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};">🎉 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 · 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 · rspace.online</div></div></body></html>`;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in New Issue