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-splats:/data/splats
|
||||||
- rspace-docs:/data/docs
|
- rspace-docs:/data/docs
|
||||||
- rspace-backups:/data/backups
|
- rspace-backups:/data/backups
|
||||||
|
- /opt/apps/dev-ops:/repos/dev-ops:rw
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- STORAGE_DIR=/data/communities
|
- STORAGE_DIR=/data/communities
|
||||||
|
|
@ -43,6 +44,9 @@ services:
|
||||||
- SMTP_USER=${SMTP_USER:-noreply@rmail.online}
|
- SMTP_USER=${SMTP_USER:-noreply@rmail.online}
|
||||||
- SMTP_PASS=${SMTP_PASS}
|
- SMTP_PASS=${SMTP_PASS}
|
||||||
- SITE_URL=https://rspace.online
|
- 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
|
- SPLAT_NOTIFY_EMAIL=jeffemmett@gmail.com
|
||||||
- TWENTY_API_URL=http://twenty-ch-server:3000
|
- TWENTY_API_URL=http://twenty-ch-server:3000
|
||||||
- TWENTY_API_TOKEN=${TWENTY_API_TOKEN:-}
|
- 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 { boardSchema, boardDocId, createTaskItem } from './schemas';
|
||||||
import type { BoardDoc, TaskItem, BoardMeta } 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();
|
const routes = new Hono();
|
||||||
|
|
||||||
// ── Local-first helpers ──
|
// ── Local-first helpers ──
|
||||||
|
|
@ -452,6 +458,108 @@ routes.get("/api/spaces/:slug/activity", async (c) => {
|
||||||
return c.json([]);
|
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 ──
|
// ── Page route ──
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue