From 249998f3f6992e9e4025320e5c824fa0123613f8 Mon Sep 17 00:00:00 2001 From: Jorel97 Date: Sat, 30 May 2026 16:42:22 -0600 Subject: [PATCH] Add assignment email digests --- README.md | 15 +++ index.html | 7 ++ server.js | 300 +++++++++++++++++++++++++++++++++++++++++++- src/app.js | 12 +- src/tasks.js | 3 +- styles.css | 22 ++++ test/server.test.js | 210 ++++++++++++++++++++++++++++++- 7 files changed, 559 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f1eba62..b9fef98 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A simple, community-maintained open-source task tracker. Add tasks, check them o - Single-page, zero-build web app - Tasks persist in `localStorage` by default, with optional GitHub sign-in sync - GitHub OAuth uses PKCE in the browser and an HTTP-only session cookie +- Optional assignee emails queue hourly assignment digests with unsubscribe links - Keyboard-friendly (more shortcuts coming, see #5) - Light & dark themes (dark coming, see #1) - MIT licensed @@ -26,6 +27,20 @@ GitHub sign-in is enabled when `GITHUB_CLIENT_ID` is set. If your OAuth app requires it, set `GITHUB_CLIENT_SECRET` on the server too; the secret is only used by the backend token exchange endpoint. +Assignment digest emails are queued when a signed-in user saves a task with an +assignee email. By default the server logs digest bodies locally. To send with +Resend, configure: + +```sh +EMAIL_PROVIDER=resend +RESEND_API_KEY=re_... +EMAIL_FROM="TaskForge " +APP_BASE_URL=https://your-taskforge-host.example +``` + +Digests wait one hour by default. For development, set +`ASSIGNMENT_DIGEST_DELAY_MS=0` to deliver immediately on the next task save. + ## Contributing We welcome contributions! See [CONTRIBUTING.md](./CONTRIBUTING.md) — TL;DR: diff --git a/index.html b/index.html index b6b00fb..bcf11d6 100644 --- a/index.html +++ b/index.html @@ -35,6 +35,13 @@

TaskForge

autocomplete="off" required /> + diff --git a/server.js b/server.js index 695407f..cecc301 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,10 @@ const DATA_FILE = process.env.TASKFORGE_DATA_FILE || path.join(__dirname, ".taskforge-data.json"); const SESSION_COOKIE = "taskforge_session"; const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; +const ASSIGNMENT_DIGEST_DELAY_MS = Number( + process.env.ASSIGNMENT_DIGEST_DELAY_MS || 60 * 60 * 1000, +); +const APP_BASE_URL = process.env.APP_BASE_URL || `http://${HOST}:${PORT}`; const CONTENT_TYPES = { ".css": "text/css; charset=utf-8", @@ -26,11 +30,28 @@ function createServer(options = {}) { const dataFile = options.dataFile || DATA_FILE; const fetchImpl = options.fetchImpl || global.fetch; const sessionStore = options.sessionStore || sessions; + const emailTransport = options.emailTransport || null; + const digestDelayMs = + options.digestDelayMs ?? Math.max(0, ASSIGNMENT_DIGEST_DELAY_MS); + const digestSweepIntervalMs = + options.digestSweepIntervalMs || Math.min(60 * 1000, digestDelayMs || 1000); + const enableDigestTimer = options.enableDigestTimer !== false; + const now = options.now || (() => Date.now()); + const appBaseUrl = options.appBaseUrl || APP_BASE_URL; + const context = { + appBaseUrl, + dataFile, + digestDelayMs, + emailTransport, + fetchImpl, + now, + sessionStore, + }; - return http.createServer(async (req, res) => { + const server = http.createServer(async (req, res) => { try { if (req.url.startsWith("/api/")) { - await handleApi(req, res, { dataFile, fetchImpl, sessionStore }); + await handleApi(req, res, context); return; } await serveStatic(req, res); @@ -39,6 +60,17 @@ function createServer(options = {}) { sendJson(res, 500, { error: "internal_server_error" }); } }); + + if (enableDigestTimer) { + const timer = setInterval( + () => sweepAssignmentDigests(context), + digestSweepIntervalMs, + ); + timer.unref?.(); + server.on("close", () => clearInterval(timer)); + } + + return server; } async function handleApi(req, res, context) { @@ -72,6 +104,11 @@ async function handleApi(req, res, context) { return; } + if (url.pathname === "/api/notifications/unsubscribe") { + await unsubscribeFromNotifications(req, res, context, url); + return; + } + if (url.pathname === "/api/tasks") { await handleTasks(req, res, context); return; @@ -160,7 +197,8 @@ async function exchangeGitHubCode(req, res, { fetchImpl, sessionStore }) { sendJson(res, 200, { user }); } -async function handleTasks(req, res, { dataFile, sessionStore }) { +async function handleTasks(req, res, context) { + const { dataFile, sessionStore } = context; const session = getSession(req, sessionStore); if (!session) { sendJson(res, 401, { error: "authentication_required" }); @@ -177,6 +215,14 @@ async function handleTasks(req, res, { dataFile, sessionStore }) { const body = await readJson(req); const tasks = sanitizeTasks(body.tasks); const data = await readData(dataFile); + const previousTasks = data.tasksByUser[session.user.id] || []; + await flushDueAssignmentDigests(data, context); + queueAssignmentDigests( + data, + collectAssignmentChanges(previousTasks, tasks, session.user, context.now()), + context, + ); + await flushDueAssignmentDigests(data, context); data.tasksByUser[session.user.id] = tasks; await writeData(dataFile, data); sendJson(res, 200, { tasks }); @@ -233,12 +279,12 @@ async function readData(dataFile) { try { const parsed = JSON.parse(await fs.readFile(dataFile, "utf8")); if (parsed && typeof parsed === "object" && parsed.tasksByUser) { - return parsed; + return normalizeData(parsed); } } catch (error) { if (error.code !== "ENOENT") throw error; } - return { tasksByUser: {} }; + return normalizeData({}); } async function writeData(dataFile, data) { @@ -261,6 +307,7 @@ function sanitizeTasks(tasks) { .map((task) => ({ id: normalizeString(task.id).slice(0, 128), title: normalizeString(task.title).slice(0, 500), + assigneeEmail: normalizeEmail(task.assigneeEmail), done: Boolean(task.done), createdAt: Number.isFinite(task.createdAt) ? task.createdAt : Date.now(), })) @@ -271,6 +318,247 @@ function normalizeString(value) { return typeof value === "string" ? value.trim() : ""; } +function normalizeEmail(value) { + const email = normalizeString(value).toLowerCase(); + if (!email || email.length > 254) return ""; + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return ""; + return email; +} + +function normalizeData(data) { + if (!data || typeof data !== "object") { + return { tasksByUser: {}, notificationState: emptyNotificationState() }; + } + return { + tasksByUser: + data.tasksByUser && typeof data.tasksByUser === "object" + ? data.tasksByUser + : {}, + notificationState: normalizeNotificationState(data.notificationState), + }; +} + +function emptyNotificationState() { + return { pendingDigests: [], recipients: {} }; +} + +function normalizeNotificationState(state) { + if (!state || typeof state !== "object") return emptyNotificationState(); + return { + pendingDigests: Array.isArray(state.pendingDigests) + ? state.pendingDigests + : [], + recipients: + state.recipients && typeof state.recipients === "object" + ? state.recipients + : {}, + }; +} + +function collectAssignmentChanges(previousTasks, nextTasks, user, assignedAt) { + const previousById = new Map(previousTasks.map((task) => [task.id, task])); + return nextTasks + .filter((task) => task.assigneeEmail) + .filter((task) => { + const previous = previousById.get(task.id); + return previous?.assigneeEmail !== task.assigneeEmail; + }) + .map((task) => ({ + assignedAt, + assignedBy: user.login, + assigneeEmail: task.assigneeEmail, + taskId: task.id, + title: task.title, + })); +} + +function queueAssignmentDigests(data, assignments, { digestDelayMs, now }) { + if (assignments.length === 0) return; + const notificationState = data.notificationState; + const currentTime = now(); + + for (const assignment of assignments) { + const recipient = ensureRecipient(notificationState, assignment.assigneeEmail); + if (recipient.unsubscribedAt) continue; + + let digest = notificationState.pendingDigests.find( + (item) => + item.assigneeEmail === assignment.assigneeEmail && + item.dueAt >= currentTime, + ); + if (!digest) { + digest = { + id: crypto.randomBytes(12).toString("hex"), + assigneeEmail: assignment.assigneeEmail, + dueAt: currentTime + digestDelayMs, + token: recipient.token, + assignments: [], + }; + notificationState.pendingDigests.push(digest); + } + const duplicate = digest.assignments.some( + (item) => item.taskId === assignment.taskId, + ); + if (!duplicate) digest.assignments.push(assignment); + } +} + +function ensureRecipient(notificationState, email) { + if (!notificationState.recipients[email]) { + notificationState.recipients[email] = { + token: crypto.randomBytes(24).toString("hex"), + unsubscribedAt: 0, + }; + } + return notificationState.recipients[email]; +} + +async function flushDueAssignmentDigests(data, context) { + const notificationState = data.notificationState; + const due = []; + const pending = []; + const currentTime = context.now(); + + for (const digest of notificationState.pendingDigests) { + if (digest.dueAt <= currentTime) { + due.push(digest); + } else { + pending.push(digest); + } + } + notificationState.pendingDigests = pending; + + for (const digest of due) { + const recipient = notificationState.recipients[digest.assigneeEmail]; + if (recipient?.unsubscribedAt) continue; + try { + await sendAssignmentDigest(digest, context); + } catch (error) { + console.error("Failed to send assignment digest", error); + notificationState.pendingDigests.push({ + ...digest, + dueAt: currentTime + context.digestDelayMs, + }); + } + } +} + +async function sweepAssignmentDigests(context) { + try { + const data = await readData(context.dataFile); + const previousCount = data.notificationState.pendingDigests.length; + await flushDueAssignmentDigests(data, context); + if (previousCount > 0) { + await writeData(context.dataFile, data); + } + } catch (error) { + console.error("Failed to sweep assignment digests", error); + } +} + +async function sendAssignmentDigest(digest, context) { + const message = buildAssignmentDigestMessage(digest, context.appBaseUrl); + if (typeof context.emailTransport === "function") { + await context.emailTransport(message); + return; + } + if (process.env.EMAIL_PROVIDER === "resend") { + await sendWithResend(message, context.fetchImpl); + return; + } + console.log( + `Assignment digest for ${message.to}: ${message.subject}\n${message.text}`, + ); +} + +function buildAssignmentDigestMessage(digest, appBaseUrl) { + const unsubscribeUrl = new URL("/api/notifications/unsubscribe", appBaseUrl); + unsubscribeUrl.searchParams.set("token", digest.token); + const lines = digest.assignments.map( + (assignment) => `- ${assignment.title} (assigned by ${assignment.assignedBy})`, + ); + const listItems = digest.assignments + .map( + (assignment) => + `
  • ${escapeHtml(assignment.title)} assigned by ${escapeHtml( + assignment.assignedBy, + )}
  • `, + ) + .join(""); + + return { + html: `

    You have ${digest.assignments.length} new TaskForge assignment${ + digest.assignments.length === 1 ? "" : "s" + }.

    Unsubscribe from assignment digests

    `, + subject: `TaskForge assignment digest (${digest.assignments.length})`, + text: `You have ${digest.assignments.length} new TaskForge assignment${ + digest.assignments.length === 1 ? "" : "s" + }.\n\n${lines.join("\n")}\n\nUnsubscribe: ${unsubscribeUrl}`, + to: digest.assigneeEmail, + }; +} + +async function sendWithResend(message, fetchImpl) { + if (!process.env.RESEND_API_KEY || !process.env.EMAIL_FROM) { + throw new Error("resend_not_configured"); + } + if (typeof fetchImpl !== "function") { + throw new Error("fetch_unavailable"); + } + const response = await fetchImpl("https://api.resend.com/emails", { + method: "POST", + headers: { + authorization: `Bearer ${process.env.RESEND_API_KEY}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + from: process.env.EMAIL_FROM, + html: message.html, + subject: message.subject, + text: message.text, + to: message.to, + }), + }); + if (!response.ok) throw new Error("resend_delivery_failed"); +} + +async function unsubscribeFromNotifications(req, res, { dataFile, now }, url) { + if (req.method !== "GET" && req.method !== "POST") { + sendJson(res, 405, { error: "method_not_allowed" }); + return; + } + const token = normalizeString(url.searchParams.get("token")); + const data = await readData(dataFile); + const entry = Object.entries(data.notificationState.recipients).find( + ([, recipient]) => recipient.token === token, + ); + if (!token || !entry) { + sendText(res, 404, "Unsubscribe link not found."); + return; + } + const [email, recipient] = entry; + recipient.unsubscribedAt = now(); + data.notificationState.pendingDigests = + data.notificationState.pendingDigests.filter( + (digest) => digest.assigneeEmail !== email, + ); + await writeData(dataFile, data); + sendText(res, 200, "You have been unsubscribed from assignment digests."); +} + +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, (char) => { + const entities = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return entities[char]; + }); +} + function getSession(req, sessionStore) { const sessionId = getCookie(req, SESSION_COOKIE); if (!sessionId) return null; @@ -319,6 +607,8 @@ if (require.main === module) { module.exports = { SESSION_COOKIE, + collectAssignmentChanges, createServer, + normalizeEmail, sanitizeTasks, }; diff --git a/src/app.js b/src/app.js index 46dd724..34fb300 100644 --- a/src/app.js +++ b/src/app.js @@ -9,6 +9,7 @@ import { createTask, toggleTask, removeTask } from "./tasks.js"; const form = document.getElementById("task-form"); const input = document.getElementById("task-input"); +const assigneeEmailInput = document.getElementById("assignee-email-input"); const list = document.getElementById("task-list"); const emptyState = document.getElementById("empty-state"); const authStatus = document.getElementById("auth-status"); @@ -69,6 +70,11 @@ function render() { label.className = "task-title"; label.textContent = task.title; + const assignee = document.createElement("span"); + assignee.className = "task-assignee"; + assignee.textContent = task.assigneeEmail || ""; + assignee.hidden = !task.assigneeEmail; + const del = document.createElement("button"); del.type = "button"; del.className = "task-delete"; @@ -80,7 +86,7 @@ function render() { render(); }); - li.append(checkbox, label, del); + li.append(checkbox, label, assignee, del); list.appendChild(li); } } @@ -88,10 +94,12 @@ function render() { form.addEventListener("submit", async (e) => { e.preventDefault(); const title = input.value.trim(); + const assigneeEmail = assigneeEmailInput.value.trim(); if (!title) return; - tasks = [createTask(title), ...tasks]; + tasks = [createTask(title, assigneeEmail), ...tasks]; await save(tasks, user); input.value = ""; + assigneeEmailInput.value = ""; input.focus(); render(); }); diff --git a/src/tasks.js b/src/tasks.js index a54af4a..103f4a9 100644 --- a/src/tasks.js +++ b/src/tasks.js @@ -1,5 +1,6 @@ -export function createTask(title) { +export function createTask(title, assigneeEmail = "") { return { + assigneeEmail: assigneeEmail.trim(), id: cryptoRandomId(), title: title.trim(), done: false, diff --git a/styles.css b/styles.css index a6c7afc..03800ff 100644 --- a/styles.css +++ b/styles.css @@ -111,6 +111,10 @@ body { border-radius: var(--radius); } +.task-form input[type="email"] { + flex: 0 1 14rem; +} + .task-form input:focus { outline: 2px solid var(--accent); outline-offset: -1px; @@ -159,6 +163,16 @@ body { word-break: break-word; } +.task-assignee { + max-width: 12rem; + padding: 0.15rem 0.45rem; + color: var(--muted); + background: rgba(9, 105, 218, 0.08); + border-radius: var(--radius); + font-size: 0.82rem; + overflow-wrap: anywhere; +} + .task-delete { appearance: none; background: transparent; @@ -208,4 +222,12 @@ body { flex: 1; max-width: none; } + + .task-form { + flex-direction: column; + } + + .task-form input[type="email"] { + flex-basis: auto; + } } diff --git a/test/server.test.js b/test/server.test.js index c3b9f65..eb5d812 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -5,7 +5,12 @@ const os = require("node:os"); const path = require("node:path"); const test = require("node:test"); -const { SESSION_COOKIE, createServer, sanitizeTasks } = require("../server.js"); +const { + SESSION_COOKIE, + createServer, + normalizeEmail, + sanitizeTasks, +} = require("../server.js"); test("sanitizeTasks drops malformed rows and trims persisted fields", () => { assert.deepEqual( @@ -15,7 +20,39 @@ test("sanitizeTasks drops malformed rows and trims persisted fields", () => { { id: "missing-title", title: "" }, null, ]), - [{ id: "a", title: "Write tests", done: true, createdAt: 10 }], + [ + { + assigneeEmail: "", + id: "a", + title: "Write tests", + done: true, + createdAt: 10, + }, + ], + ); +}); + +test("sanitizeTasks normalizes valid assignee emails and drops invalid ones", () => { + assert.equal(normalizeEmail(" PERSON@Example.COM "), "person@example.com"); + assert.equal(normalizeEmail("not-an-email"), ""); + assert.deepEqual( + sanitizeTasks([ + { + id: "task-1", + title: "Assign this", + assigneeEmail: " PERSON@Example.COM ", + createdAt: 20, + }, + ]), + [ + { + assigneeEmail: "person@example.com", + id: "task-1", + title: "Assign this", + done: false, + createdAt: 20, + }, + ], ); }); @@ -112,6 +149,7 @@ test("authenticated task API persists tasks by GitHub user id", async () => { const loaded = await request(server, "/api/tasks", { cookie }); assert.deepEqual(loaded.body.tasks, [ { + assigneeEmail: "", id: "task-1", title: "Ship OAuth", done: false, @@ -127,6 +165,150 @@ test("authenticated task API persists tasks by GitHub user id", async () => { } }); +test("assignment notifications send one digest for multiple assigned tasks", async () => { + const previousClientId = process.env.GITHUB_CLIENT_ID; + process.env.GITHUB_CLIENT_ID = "client-id"; + const messages = []; + const dataFile = await tempDataFile(); + const server = await listen( + createServer({ + appBaseUrl: "https://taskforge.example", + dataFile, + digestDelayMs: 0, + emailTransport: async (message) => messages.push(message), + fetchImpl: async (url) => { + if (url === "https://github.com/login/oauth/access_token") { + return jsonResponse({ access_token: "token" }); + } + return jsonResponse({ id: 13, login: "octo", avatar_url: "" }); + }, + }), + ); + + try { + const cookie = await signIn(server); + + await request(server, "/api/tasks", { + method: "PUT", + cookie, + body: { + tasks: [ + { + id: "task-1", + title: "Write rollout plan", + assigneeEmail: "Dev@Example.com", + }, + { + id: "task-2", + title: "Review launch notes", + assigneeEmail: "dev@example.com", + }, + ], + }, + }); + + assert.equal(messages.length, 1); + assert.equal(messages[0].to, "dev@example.com"); + assert.match(messages[0].subject, /\(2\)/); + assert.match(messages[0].text, /Write rollout plan/); + assert.match(messages[0].text, /Review launch notes/); + assert.match(messages[0].text, /Unsubscribe: https:\/\/taskforge\.example/); + + await request(server, "/api/tasks", { + method: "PUT", + cookie, + body: { + tasks: [ + { + id: "task-1", + title: "Write rollout plan", + assigneeEmail: "dev@example.com", + }, + { + id: "task-2", + title: "Review launch notes", + assigneeEmail: "dev@example.com", + }, + ], + }, + }); + assert.equal(messages.length, 1); + } finally { + await close(server); + restoreEnv("GITHUB_CLIENT_ID", previousClientId); + } +}); + +test("unsubscribe links remove pending digests and block future assignments", async () => { + const previousClientId = process.env.GITHUB_CLIENT_ID; + process.env.GITHUB_CLIENT_ID = "client-id"; + const messages = []; + const dataFile = await tempDataFile(); + const server = await listen( + createServer({ + dataFile, + digestDelayMs: 60 * 60 * 1000, + emailTransport: async (message) => messages.push(message), + fetchImpl: async (url) => { + if (url === "https://github.com/login/oauth/access_token") { + return jsonResponse({ access_token: "token" }); + } + return jsonResponse({ id: 14, login: "octo", avatar_url: "" }); + }, + }), + ); + + try { + const cookie = await signIn(server); + await request(server, "/api/tasks", { + method: "PUT", + cookie, + body: { + tasks: [ + { + id: "task-1", + title: "Prepare brief", + assigneeEmail: "dev@example.com", + }, + ], + }, + }); + + let persisted = JSON.parse(await fs.readFile(dataFile, "utf8")); + const token = persisted.notificationState.recipients["dev@example.com"].token; + assert.equal(persisted.notificationState.pendingDigests.length, 1); + + const unsubscribe = await textRequest( + server, + `/api/notifications/unsubscribe?token=${token}`, + ); + assert.equal(unsubscribe.status, 200); + assert.match(unsubscribe.text, /unsubscribed/); + + await request(server, "/api/tasks", { + method: "PUT", + cookie, + body: { + tasks: [ + { + id: "task-2", + title: "Prepare follow-up", + assigneeEmail: "dev@example.com", + }, + ], + }, + }); + + persisted = JSON.parse(await fs.readFile(dataFile, "utf8")); + assert.equal(messages.length, 0); + assert.equal(persisted.notificationState.pendingDigests.length, 0); + assert.ok(persisted.notificationState.recipients["dev@example.com"].unsubscribedAt); + } finally { + await close(server); + restoreEnv("GITHUB_CLIENT_ID", previousClientId); + } +}); + test("logout clears the server session and browser cookie", async () => { const previousClientId = process.env.GITHUB_CLIENT_ID; process.env.GITHUB_CLIENT_ID = "client-id"; @@ -209,6 +391,30 @@ async function request(server, pathname, options = {}) { }; } +async function textRequest(server, pathname, options = {}) { + const url = new URL(pathname, `http://127.0.0.1:${server.address().port}`); + const response = await fetch(url, { + method: options.method || "GET", + }); + return { + headers: response.headers, + status: response.status, + text: await response.text(), + }; +} + +async function signIn(server) { + const login = await request(server, "/api/auth/github/exchange", { + method: "POST", + body: { + code: "code", + codeVerifier: "verifier", + redirectUri: "http://127.0.0.1:8000/", + }, + }); + return login.headers.get("set-cookie").split(";")[0]; +} + function listen(server) { return new Promise((resolve) => { server.listen(0, "127.0.0.1", () => resolve(server));