Files
redefined-designs/docs/superpowers/plans/2026-09-01-intake-notification.md
T
bermudalambandClaude Opus 5 c88d1eee11
Linting / lint (pull_request) Successful in 2m9s
SonarQube Analysis / sonarqube (pull_request) Successful in 26m6s
docs(intake): plan the submission notification (#224)
Five tasks: a pure HMAC signer, the editable template and its recipient setting, the send from the drafting worker, the public routes that act on a signed link, and the quiet paths.

The plan makes one decision the issue does not, and it changes the shape of the feature. Mail scanners and link-rewriting gateways issue a GET against every URL in a message before a human sees it, so a GET that discarded a draft would fire itself on delivery — with a valid signature, looking entirely legitimate in the log, and nobody would know to go and recover it. So the signed link is a safe GET that confirms and a POST that acts. It costs one extra click and is cheap to reverse if that is the wrong trade.

Everything about the notification is best-effort. No recipient configured, no INTAKE_ACTION_SECRET, or SMTP down all end in a log line: the review queue is the source of truth, and a draft that was written correctly must never be marked failed because an email did not send.

The email still cannot publish. The two signable actions are exactly the ones whose worst case is a wasted API call or a hide the queue can undo, which is what makes putting them in an inbox acceptable at all.

Branched from feature/225-review-queue rather than main, because the review link has nowhere to land without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 14:57:55 -05:00

35 KiB

Intake Notification Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: When a draft is ready, the admin gets an email with the drafted copy, a link into the review queue, and two signed links that can regenerate or discard it without signing in.

Architecture: A pure HMAC signer, a new editable email template, a notification sent by the drafting worker after it writes a draft, and a small public router that verifies a signature and performs one of two state changes. Nothing in the email can publish.

Tech Stack: Express 4, TypeScript, crypto (HMAC), nodemailer via the existing mailer, Jest + supertest.

Spec: docs/superpowers/specs/2026-08-29-intake-pipeline-design.md (issue #224, slice 3 of #220)

Depends on #225, which is on feature/225-review-queue and not yet merged. The review link has nowhere to land without it, and the two actions perform the same transitions its admin routes already perform. Branch from feature/225-review-queue, not main.

Already verified against the tree, so no need to re-check: TemplateKey is a union in src/emailTemplates.ts:14; each entry of TEMPLATES has label, required, available, defaultSubject, defaultBody, and an optional footer. SAMPLE_VALUES must gain an entry for every new available name — a unit test asserts this. renderTemplate(key, stored, values) returns { subject, html }. Callers send fire-and-forget: sendMail(to, subject, html).catch(err => console.error(...)). Admin settings are rows in the DEFINITIONS array in src/adminSettings.ts:23 typed hours | text | choice; adding one means adding a row and nothing else. There is no HMAC anywhere yet; src/middleware/adminGate.ts:48 shows the timing-safe comparison idiom — hash both sides first, because timingSafeEqual throws on buffers of different length. PUBLIC_URL is read as process.env.PUBLIC_URL ?? '' (favoriteAlerts.ts:58).

Global Constraints

  • The email cannot publish. The two signed actions are exactly the ones whose worst case is a wasted API call or a recoverable hide. Publishing stays a deliberate act on a screen showing the price — that is what bounds the risk taken by pricing items on arrival.
  • SMTP being down must not strand a draft. The queue is the source of truth: a ready draft is visible and actionable whether or not its notification ever sent. Send fire-and-forget with a logged catch, exactly like customers.ts:54.
  • INTAKE_ACTION_SECRET is optional. Absent, the notification still sends with its review link and simply omits the two signed links, and boot warns. A missing secret must not stop the admin being told an item arrived.
  • Signatures are timing-safe and expire in 30 days. Signed over (itemId, action, expiry).
  • reviewUrl is a required placeholder. A notification with no link in it still sends, still looks fine in the log, and is useless to whoever receives it — which is what the required-placeholder validation exists to catch.
  • Every route handler wrapped in asyncRoutetests/unit/routesAreWrapped.test.ts enforces it.
  • QA silently drops mail to unlisted addresses. MAIL_ALLOWLIST=thomlamb@gmail.com is hardcoded in docker-compose.qa.yml and is the entire safety property there. Testing this in QA against any other address looks like a silent failure — the flow succeeds and no mail arrives, with a [mail-blocked] line naming the address.
  • Commit style: Conventional Commits, subject ending (#224), no hard wrapping in bodies.

The decision this plan makes that the issue does not

Email clients and security scanners prefetch links. Outlook Safe Links, Gmail's scanners, and most corporate mail gateways issue a GET against every URL in a message before a human sees it. A GET /intake-actions/discard?... would therefore fire itself on delivery, and the admin would find drafts discarded that nobody touched — with a valid signature in the logs saying it was legitimate.

So the signed link is a GET that renders a confirmation page, and a POST that performs the action. The GET is safe and idempotent, which is what makes it survive a prefetch; the POST carries the same signature and is what actually changes state.

This costs one extra click. It is worth it: the alternative is a destructive action that a mail scanner can trigger, which no amount of recoverability makes acceptable, because nobody would know to go and recover it. It is also cheap to reverse if you would rather have one click — the verification and the transition are unchanged, only the handler that performs them moves.

One wording difference from the issue

The issue says the signature covers (draftId, action, expiry). This signs (itemId, action, expiry) instead. item_drafts.item_id is UNIQUE and is the key everything else addresses a draft by — the #225 routes are all /:itemId/... — so there is no separate draft id in circulation, and introducing one only for the signature would mean two ways to name the same row. Same property, same guarantees.

File Structure

Created:

  • backend/src/intake/actionLinks.ts — sign and verify. Pure.
  • backend/src/intake/notifyDraft.ts — build the values and send.
  • backend/src/routes/intakeActions.ts — the public GET/POST pair.
  • backend/tests/unit/actionLinks.test.ts
  • backend/tests/integration/intakeActions.integration.test.ts

Modified:

  • backend/src/emailTemplates.ts — the intakeDraft template and its samples
  • backend/src/adminSettings.ts — where the notification goes
  • backend/src/intake/draftingWorker.ts — send after a successful draft
  • backend/src/envValidation.ts — warn when the secret is absent
  • backend/src/app.ts — mount the public router

Task 1: Signing and verifying

Files:

  • Create: backend/src/intake/actionLinks.ts, backend/tests/unit/actionLinks.test.ts

Interfaces:

  • Produces: type IntakeAction = 'regenerate' | 'discard', signAction(itemId, action, expiresAt): string, verifyAction(itemId, action, expiresAt, signature, now?): boolean, actionUrl(itemId, action): string | null, ACTION_TTL_MS

  • Step 1: Write the failing test

import {
  signAction,
  verifyAction,
  actionUrl,
  ACTION_TTL_MS
} from '../../src/intake/actionLinks';

const SECRET = 'test-intake-secret';
const NOW = 1_800_000_000_000;
const EXPIRY = NOW + ACTION_TTL_MS;

beforeEach(() => {
  process.env.INTAKE_ACTION_SECRET = SECRET;
  process.env.PUBLIC_URL = 'https://shop.example.com';
});

describe('signAction / verifyAction', () => {
  it('accepts a signature it produced', () => {
    const sig = signAction(7, 'discard', EXPIRY);
    expect(verifyAction(7, 'discard', EXPIRY, sig, NOW)).toBe(true);
  });

  // Each of these is a different link. A signature that survives any of these
  // swaps is a signature that authorises more than it names.
  it('refuses a signature reused for another item', () => {
    const sig = signAction(7, 'discard', EXPIRY);
    expect(verifyAction(8, 'discard', EXPIRY, sig, NOW)).toBe(false);
  });

  it('refuses a signature reused for another action', () => {
    const sig = signAction(7, 'discard', EXPIRY);
    expect(verifyAction(7, 'regenerate', EXPIRY, sig, NOW)).toBe(false);
  });

  // Otherwise the expiry is decoration: anyone holding an expired link could
  // extend it themselves by editing the timestamp.
  it('refuses a signature whose expiry was altered', () => {
    const sig = signAction(7, 'discard', EXPIRY);
    expect(verifyAction(7, 'discard', EXPIRY + 1000, sig, NOW)).toBe(false);
  });

  it('refuses an expired link even with a valid signature', () => {
    const sig = signAction(7, 'discard', EXPIRY);
    expect(verifyAction(7, 'discard', EXPIRY, sig, EXPIRY + 1)).toBe(false);
  });

  it('refuses a malformed signature without throwing', () => {
    expect(verifyAction(7, 'discard', EXPIRY, 'not-a-signature', NOW)).toBe(false);
    expect(verifyAction(7, 'discard', EXPIRY, '', NOW)).toBe(false);
  });

  // A different secret must not validate. This is what makes rotating the
  // secret revoke every outstanding link.
  it('refuses a signature made with a different secret', () => {
    const sig = signAction(7, 'discard', EXPIRY);
    process.env.INTAKE_ACTION_SECRET = 'a-different-secret';
    expect(verifyAction(7, 'discard', EXPIRY, sig, NOW)).toBe(false);
  });
});

describe('actionUrl', () => {
  it('builds an absolute url carrying the expiry and signature', () => {
    const url = actionUrl(7, 'discard');
    expect(url).toContain('https://shop.example.com/api/intake-actions/7/discard');
    expect(url).toMatch(/expires=\d+/);
    expect(url).toMatch(/sig=[A-Za-z0-9_-]+/);
  });

  // Absent secret is a working configuration: the notification still sends with
  // its review link. A link that cannot be verified must never be offered.
  it('returns null when there is no secret', () => {
    delete process.env.INTAKE_ACTION_SECRET;
    expect(actionUrl(7, 'discard')).toBeNull();
  });

  it('returns null when there is no public url to build against', () => {
    delete process.env.PUBLIC_URL;
    expect(actionUrl(7, 'discard')).toBeNull();
  });
});
  • Step 2: Run it to verify it fails
cd backend && npx jest -c jest.unit.config.js actionLinks

Expected: FAIL — module not found.

  • Step 3: Write it
import crypto from 'crypto';

/**
 * Links in the notification email that act without a login.
 *
 * Only two actions are signable, and neither can publish. The worst case of a
 * leaked link is a wasted API call or a hide that the review queue can undo —
 * which is what makes it acceptable to put them in an inbox at all.
 *
 * Signed over the item, the action and the expiry together. Signing any subset
 * would let a link be replayed against a different item or upgraded to a
 * different action, and leaving the expiry out of the payload would let anyone
 * holding an expired link extend it by editing the timestamp.
 */
export type IntakeAction = 'regenerate' | 'discard';

/** Thirty days. Long enough to survive a holiday, short enough to lapse. */
export const ACTION_TTL_MS = 30 * 24 * 60 * 60 * 1000;

function secret(): string | null {
  const value = process.env.INTAKE_ACTION_SECRET;
  return value && value.trim() !== '' ? value : null;
}

export function signAction(itemId: number, action: IntakeAction, expiresAt: number): string {
  const key = secret();
  if (!key) throw new Error('INTAKE_ACTION_SECRET is not set');
  return crypto
    .createHmac('sha256', key)
    .update(`${itemId}:${action}:${expiresAt}`)
    .digest('base64url');
}

/**
 * Compared through a second digest rather than directly, because
 * timingSafeEqual throws when the two buffers differ in length — and a
 * malformed signature from a truncated link is an ordinary thing to receive,
 * not an exception. Same idiom as middleware/adminGate.ts.
 */
function digest(value: string): Buffer {
  return crypto.createHash('sha256').update(value).digest();
}

export function verifyAction(
  itemId: number,
  action: IntakeAction,
  expiresAt: number,
  signature: string,
  now: number = Date.now()
): boolean {
  if (!secret()) return false;
  if (!Number.isFinite(expiresAt) || now > expiresAt) return false;

  const expected = signAction(itemId, action, expiresAt);
  return crypto.timingSafeEqual(digest(expected), digest(signature));
}

/**
 * The absolute link, or null when one cannot be made.
 *
 * Null rather than a throw or a relative path. An unconfigured environment
 * still sends the notification with its review link — being told an item
 * arrived matters more than the shortcuts — and a link that could not be
 * verified must never be offered in the first place.
 */
export function actionUrl(itemId: number, action: IntakeAction): string | null {
  const base = process.env.PUBLIC_URL;
  if (!secret() || !base || base.trim() === '') return null;

  const expiresAt = Date.now() + ACTION_TTL_MS;
  const sig = signAction(itemId, action, expiresAt);
  const origin = base.replace(/\/+$/, '');
  return `${origin}/api/intake-actions/${itemId}/${action}?expires=${expiresAt}&sig=${sig}`;
}
  • Step 4: Run it to verify it passes
cd backend && npx jest -c jest.unit.config.js actionLinks

Expected: PASS, 10 tests.

  • Step 5: Commit
git add backend/src/intake/actionLinks.ts backend/tests/unit/actionLinks.test.ts
git commit -m "feat(intake): sign the two actions an email may take (#224)"

Task 2: The template and the recipient

Files:

  • Modify: backend/src/emailTemplates.ts, backend/src/adminSettings.ts, backend/src/envValidation.ts
  • Test: backend/tests/unit/emailTemplates.test.ts, backend/tests/unit/envValidation.test.ts

Interfaces:

  • Produces: TemplateKey gains 'intakeDraft'; SettingName gains 'intakeNotifyEmail'

  • Step 1: Write the failing test

Add to backend/tests/unit/emailTemplates.test.ts:

describe('the intake notification template', () => {
  // Without the review link the email is a notification you cannot act on.
  it('requires the review url', () => {
    expect(missingPlaceholders('intakeDraft', 'An item arrived.')).toContain('reviewUrl');
  });

  it('accepts a body carrying the review url', () => {
    expect(missingPlaceholders('intakeDraft', 'Review it: {{reviewUrl}}')).toEqual([]);
  });

  // The signed links are optional in the body: they are absent whenever
  // INTAKE_ACTION_SECRET is unset, and a template that demanded them would make
  // an unconfigured environment unable to send at all.
  it('does not require the signed action links', () => {
    expect(missingPlaceholders('intakeDraft', '{{reviewUrl}}')).not.toContain('discardUrl');
    expect(missingPlaceholders('intakeDraft', '{{reviewUrl}}')).not.toContain('regenerateUrl');
  });

  it('offers the drafted copy to the template author', () => {
    const available = TEMPLATES.intakeDraft.available;
    for (const name of ['itemName', 'draftName', 'draftDescription', 'price', 'submitterNote']) {
      expect(available).toContain(name);
    }
  });
});

The existing test that every available name has a SAMPLE_VALUES entry will fail until the samples are added — that is the point of it.

Add to backend/tests/unit/envValidation.test.ts:

describe('the intake action secret', () => {
  it('is not required', () => {
    expect(validateEnv(MINIMAL).errors).toEqual([]);
  });

  it('warns when it is absent', () => {
    expect(validateEnv(MINIMAL).warnings.join(' ')).toMatch(/INTAKE_ACTION_SECRET/);
  });

  it('says nothing when it is set', () => {
    const { warnings } = validateEnv(withEnv({ INTAKE_ACTION_SECRET: 'a-secret' }));
    expect(warnings.join(' ')).not.toMatch(/INTAKE_ACTION_SECRET/);
  });
});
  • Step 2: Run to verify it fails
cd backend && npx jest -c jest.unit.config.js emailTemplates envValidation

Expected: FAIL — intakeDraft is not a TemplateKey, and no such warning.

  • Step 3: Add the template

In src/emailTemplates.ts, extend the union:

export type TemplateKey =
  | 'verification'
  | 'passwordReset'
  | 'favoriteSold'
  | 'favoriteWithdrawn'
  | 'cartReminder'
  | 'emailChanged'
  | 'intakeDraft';

and add to TEMPLATES:

  intakeDraft: {
    label: 'Item submitted for review',
    // Only the review link. The signed shortcuts are absent whenever
    // INTAKE_ACTION_SECRET is unset, and requiring them would make an
    // unconfigured environment unable to send this at all.
    required: ['reviewUrl'],
    available: [
      'itemName',
      'draftName',
      'draftDescription',
      'price',
      'submitterNote',
      'linkLabel',
      'reviewUrl',
      'regenerateUrl',
      'discardUrl'
    ],
    defaultSubject: 'An item was submitted: {{draftName}}',
    defaultBody:
      'Someone sent in an item through {{linkLabel}}.\n\n' +
      '**{{draftName}}**\n\n' +
      '{{draftDescription}}\n\n' +
      'Suggested price: {{price}}\n\n' +
      "The sender's note: {{submitterNote}}\n\n" +
      '[Review and publish it]({{reviewUrl}})\n\n' +
      'Nothing is listed until you publish it from that screen, and the price ' +
      'above is a suggestion rather than a decision.\n\n' +
      '[Ask for another draft]({{regenerateUrl}}) — [Discard it]({{discardUrl}})'
  }

and to SAMPLE_VALUES:

  draftName: 'Blue stoneware vase',
  draftDescription: 'A hand-thrown vase with a chipped base.',
  price: '$80.00',
  submitterNote: 'Found in a loft clearance.',
  linkLabel: 'Autumn drop-off',
  reviewUrl: 'https://example.com/admin?tab=review-queue',
  regenerateUrl: 'https://example.com/api/intake-actions/1/regenerate?expires=0&sig=sample',
  discardUrl: 'https://example.com/api/intake-actions/1/discard?expires=0&sig=sample',
  • Step 4: Add the recipient setting

In src/adminSettings.ts, add a row to DEFINITIONS:

  // Where the intake notification goes (#224). A setting rather than an
  // environment variable, for the same reason drafting_model is one: it is
  // changed by the person running the shop, not by whoever deploys it, and a
  // redeploy to change an address would be absurd. Empty means do not notify,
  // which is a working configuration and the default.
  { key: 'intake_notify_email', name: 'intakeNotifyEmail', type: 'text', fallback: '' }
  • Step 5: Warn about the missing secret

In src/envValidation.ts, beside checkDraftingKey:

// Optional, like the drafting key. Absent, the notification still sends with
// its review link and simply carries no shortcuts — being told an item arrived
// matters far more than being able to discard it in one click.
function checkIntakeActionSecret(env: NodeJS.ProcessEnv): string[] {
  if (isPresent(env, 'INTAKE_ACTION_SECRET')) return [];
  return [
    'INTAKE_ACTION_SECRET is not set — intake notifications will link to the review queue ' +
      'but carry no regenerate or discard shortcuts.'
  ];
}

and add ...checkIntakeActionSecret(env) to the warnings array in validateEnv.

  • Step 6: Verify
cd backend && npm run test:unit && npm run lint && npm run build

Expected: PASS. If the SAMPLE_VALUES completeness test fails, a name in available has no sample — add it rather than removing the name.

  • Step 7: Commit
git add backend/src/emailTemplates.ts backend/src/adminSettings.ts backend/src/envValidation.ts backend/tests/unit
git commit -m "feat(intake): add the submission notification template (#224)"

Task 3: Sending it

Files:

  • Create: backend/src/intake/notifyDraft.ts
  • Modify: backend/src/intake/draftingWorker.ts

Interfaces:

  • Consumes: actionUrl (Task 1), the intakeDraft template (Task 2)

  • Produces: notifyDraftReady(itemId: number): Promise<void>

  • Step 1: Write it

import { pool } from '../db';
import { sendMail } from '../mailer';
import { renderTemplate } from '../emailTemplates';
import { loadStoredTemplate } from '../routes/adminEmailTemplates';
import { getSettings } from '../adminSettings';
import { actionUrl } from './actionLinks';

interface NotifyRow {
  item_name: string;
  price_cents: number;
  ai_name: string | null;
  ai_description: string | null;
  submitter_note: string | null;
  link_label: string | null;
}

/**
 * Tells the admin an item arrived and is drafted.
 *
 * Everything here is best-effort by design. The review queue is the source of
 * truth: a ready draft is visible and actionable whether or not this ever sent,
 * so a missing recipient, an SMTP outage or a template that will not render
 * must all end in a log line rather than an exception that reaches the worker
 * and marks a perfectly good draft as failed.
 */
export async function notifyDraftReady(itemId: number): Promise<void> {
  const { intakeNotifyEmail } = await getSettings();
  const to = intakeNotifyEmail?.trim();
  if (!to) {
    // Not an error. Nobody has said where to send it, and the draft is waiting
    // in the queue regardless.
    return;
  }

  const { rows } = await pool.query<NotifyRow>(
    `SELECT i.name AS item_name, i.price_cents,
            d.ai_name, d.ai_description, d.submitter_note,
            l.label AS link_label
     FROM item_drafts d
     JOIN items i ON i.id = d.item_id
     LEFT JOIN upload_links l ON l.id = d.upload_link_id
     WHERE d.item_id = $1`,
    [itemId]
  );
  const row = rows[0];
  if (!row) return;

  const base = (process.env.PUBLIC_URL ?? '').replace(/\/+$/, '');
  const regenerate = actionUrl(itemId, 'regenerate');
  const discard = actionUrl(itemId, 'discard');

  const template = renderTemplate('intakeDraft', await loadStoredTemplate('intakeDraft'), {
    itemName: row.item_name,
    draftName: row.ai_name ?? row.item_name,
    // Said plainly rather than left blank. An empty description in a
    // notification reads as a bug; "not drafted" reads as the fact it is.
    draftDescription: row.ai_description ?? 'No description was drafted for this item.',
    price: `$${(row.price_cents / 100).toFixed(2)}`,
    submitterNote: row.submitter_note ?? 'The sender left no note.',
    linkLabel: row.link_label ?? 'an upload link',
    reviewUrl: `${base}/admin`,
    // Empty rather than a broken link when there is no secret to sign with.
    regenerateUrl: regenerate ?? '',
    discardUrl: discard ?? ''
  });

  await sendMail(to, template.subject, template.html);
}

loadStoredTemplate is exported from src/routes/adminEmailTemplates.ts and is how favoriteAlerts.ts:57 and customers.ts already load a stored template. Use it rather than querying a table directly — it is the only place that knows where stored overrides live.

  • Step 2: Call it from the worker

In src/intake/draftingWorker.ts, immediately after the transaction that applies a draft commits and drafted++ runs:

      // Fire and forget, and deliberately outside the transaction. A mail
      // failure must never roll back a draft that was written correctly, and
      // the queue is what the admin actually works from — the email is a
      // convenience on top of it.
      void notifyDraftReady(row.item_id).catch((err) =>
        console.error(`[drafting] notifying for item ${row.item_id}:`, err)
      );

with the import at the top:

import { notifyDraftReady } from './notifyDraft';
  • Step 3: Verify
cd backend && npm run build && npm run lint && npm run test:unit
npx jest -c jest.integration.config.js --runInBand drafting

Expected: all pass. The drafting integration tests run with no recipient configured, so notifyDraftReady returns before touching the mailer — which is the path that must not break them.

  • Step 4: Commit
git add backend/src/intake/notifyDraft.ts backend/src/intake/draftingWorker.ts
git commit -m "feat(intake): tell the admin when a draft is ready (#224)"

Files:

  • Create: backend/src/routes/intakeActions.ts, backend/tests/integration/intakeActions.integration.test.ts
  • Modify: backend/src/app.ts

Interfaces:

  • Consumes: verifyAction, IntakeAction (Task 1)

  • Produces: GET /api/intake-actions/:itemId/:action (confirmation page), POST /api/intake-actions/:itemId/:action (performs it)

  • Step 1: Write the failing test

import request from 'supertest';
import app from '../../src/app';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
import { signAction, ACTION_TTL_MS } from '../../src/intake/actionLinks';

const SECRET = 'integration-intake-secret';
const original = process.env.INTAKE_ACTION_SECRET;

beforeAll(() => {
  process.env.INTAKE_ACTION_SECRET = SECRET;
});

afterAll(async () => {
  if (original === undefined) delete process.env.INTAKE_ACTION_SECRET;
  else process.env.INTAKE_ACTION_SECRET = original;
  await pool.end();
  await closeDb();
});

beforeEach(async () => {
  await resetDb();
});

async function seedDraft(): Promise<number> {
  const { rows } = await pool.query<{ id: number }>(
    `INSERT INTO items (name, status) VALUES ('Submission', 'pending') RETURNING id`
  );
  const itemId = rows[0]!.id;
  await pool.query(
    `INSERT INTO item_drafts (item_id, state, ai_name) VALUES ($1, 'ready', 'Blue vase')`,
    [itemId]
  );
  return itemId;
}

function link(itemId: number, action: 'regenerate' | 'discard', expiresAt: number): string {
  const sig = signAction(itemId, action, expiresAt);
  return `/api/intake-actions/${itemId}/${action}?expires=${expiresAt}&sig=${sig}`;
}

const soon = () => Date.now() + ACTION_TTL_MS;

describe('the signed action links', () => {
  /**
   * The reason GET does not act. Mail scanners and Safe Links issue a GET
   * against every URL in a message before a human sees it, so a GET that
   * discarded a draft would fire itself on delivery — with a valid signature,
   * looking entirely legitimate in the log.
   */
  it('GET confirms without changing anything', async () => {
    const itemId = await seedDraft();

    const res = await request(app).get(link(itemId, 'discard', soon()));

    expect(res.status).toBe(200);
    const { rows } = await pool.query(`SELECT state FROM item_drafts WHERE item_id = $1`, [itemId]);
    expect(rows[0]?.state).toBe('ready');
  });

  it('POST discards', async () => {
    const itemId = await seedDraft();

    const res = await request(app).post(link(itemId, 'discard', soon()));

    expect(res.status).toBe(200);
    const { rows } = await pool.query(`SELECT state FROM item_drafts WHERE item_id = $1`, [itemId]);
    expect(rows[0]?.state).toBe('discarded');
    const item = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]);
    expect(item.rows[0]?.status).toBe('pending');
  });

  it('POST regenerates', async () => {
    const itemId = await seedDraft();
    await pool.query(`UPDATE item_drafts SET attempts = 3 WHERE item_id = $1`, [itemId]);

    await request(app).post(link(itemId, 'regenerate', soon()));

    const { rows } = await pool.query(
      `SELECT state, attempts FROM item_drafts WHERE item_id = $1`,
      [itemId]
    );
    expect(rows[0]).toMatchObject({ state: 'queued', attempts: 0 });
  });

  it('refuses a tampered signature', async () => {
    const itemId = await seedDraft();
    const expires = soon();

    const res = await request(app).post(
      `/api/intake-actions/${itemId}/discard?expires=${expires}&sig=forged`
    );

    expect(res.status).toBe(403);
    const { rows } = await pool.query(`SELECT state FROM item_drafts WHERE item_id = $1`, [itemId]);
    expect(rows[0]?.state).toBe('ready');
  });

  // The signature names the item, so one link must not act on another.
  it('refuses a signature minted for a different item', async () => {
    const mine = await seedDraft();
    const other = await seedDraft();
    const expires = soon();
    const sig = signAction(other, 'discard', expires);

    const res = await request(app).post(
      `/api/intake-actions/${mine}/discard?expires=${expires}&sig=${sig}`
    );

    expect(res.status).toBe(403);
  });

  it('refuses an expired link', async () => {
    const itemId = await seedDraft();
    const expired = Date.now() - 1000;

    const res = await request(app).post(link(itemId, 'discard', expired));

    expect(res.status).toBe(403);
  });

  it('refuses an action it does not recognise', async () => {
    const itemId = await seedDraft();
    const expires = soon();

    const res = await request(app).post(
      `/api/intake-actions/${itemId}/publish?expires=${expires}&sig=anything`
    );

    expect(res.status).toBe(404);
  });

  it('404s for an item with no draft', async () => {
    const { rows } = await pool.query<{ id: number }>(
      `INSERT INTO items (name) VALUES ('ordinary') RETURNING id`
    );
    const res = await request(app).post(link(rows[0]!.id, 'discard', soon()));
    expect(res.status).toBe(404);
  });
});
  • Step 2: Run to verify it fails
cd backend && npx jest -c jest.integration.config.js --runInBand intakeActions

Expected: FAIL — 404 everywhere, the router does not exist.

  • Step 3: Write the router
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
import { IntakeAction, verifyAction } from '../intake/actionLinks';

const router = Router();

const ACTIONS: readonly IntakeAction[] = ['regenerate', 'discard'];

function isAction(value: string): value is IntakeAction {
  return (ACTIONS as readonly string[]).includes(value);
}

/**
 * Public, and protected by the signature rather than by the admin gate.
 *
 * These are clicked from an inbox by someone who is not signed in, which is the
 * whole point. Neither action can publish: the worst outcome of a leaked link
 * is a wasted API call or a hide the review queue can undo, and that is exactly
 * what makes putting them in an email acceptable.
 */
interface Checked {
  itemId: number;
  action: IntakeAction;
}

function check(req: Request, res: Response): Checked | null {
  const action = req.params.action ?? '';
  if (!isAction(action)) {
    res.status(404).json({ error: 'unknown action' });
    return null;
  }

  const itemId = Number(req.params.itemId);
  const expiresAt = Number(req.query.expires);
  const sig = typeof req.query.sig === 'string' ? req.query.sig : '';

  if (!Number.isInteger(itemId) || !verifyAction(itemId, action, expiresAt, sig)) {
    // One response for a forged signature, an expired link and a missing
    // secret alike. Distinguishing them would tell someone probing which of
    // those they had achieved.
    res.status(403).json({ error: 'this link is not valid, or has expired' });
    return null;
  }

  return { itemId, action };
}

/**
 * Confirms, and changes nothing.
 *
 * Mail scanners and corporate link-rewriting gateways issue a GET against every
 * URL in a message before a human ever sees it. A GET that discarded a draft
 * would therefore fire itself on delivery, with a valid signature, looking
 * entirely legitimate. So the state change lives on POST and this page exists
 * only to let a person confirm it.
 */
router.get(
  '/:itemId/:action',
  asyncRoute(async (req: Request, res: Response) => {
    const checked = check(req, res);
    if (!checked) return;

    const { rows } = await pool.query<{ item_name: string }>(
      `SELECT i.name AS item_name FROM item_drafts d JOIN items i ON i.id = d.item_id
       WHERE d.item_id = $1`,
      [checked.itemId]
    );
    if (!rows[0]) return res.status(404).json({ error: 'no draft for this item' });

    res.json({
      itemId: checked.itemId,
      action: checked.action,
      itemName: rows[0].item_name,
      confirmWith: 'POST to this same url'
    });
  })
);

router.post(
  '/:itemId/:action',
  asyncRoute(async (req: Request, res: Response) => {
    const checked = check(req, res);
    if (!checked) return;

    if (checked.action === 'regenerate') {
      const { rowCount } = await pool.query(
        `UPDATE item_drafts SET state = 'queued', attempts = 0, ai_error = NULL WHERE item_id = $1`,
        [checked.itemId]
      );
      if (rowCount === 0) return res.status(404).json({ error: 'no draft for this item' });
      return res.json({ state: 'queued' });
    }

    const client = await pool.connect();
    try {
      await client.query('BEGIN');
      const { rowCount } = await client.query(
        `UPDATE item_drafts SET state = 'discarded' WHERE item_id = $1`,
        [checked.itemId]
      );
      if (rowCount === 0) {
        await client.query('ROLLBACK');
        return res.status(404).json({ error: 'no draft for this item' });
      }
      await client.query(`UPDATE items SET status = 'pending' WHERE id = $1`, [checked.itemId]);
      await client.query('COMMIT');
      res.json({ state: 'discarded' });
    } catch (err) {
      await client.query('ROLLBACK');
      console.error(err);
      res.status(500).json({ error: 'internal error' });
    } finally {
      client.release();
    }
  })
);

export default router;
  • Step 4: Mount it, publicly

In src/app.ts, beside the other public routers — not behind requireAdminGate, which would defeat the purpose:

import intakeActionsRouter from './routes/intakeActions';
app.use('/api/intake-actions', intakeActionsRouter);

Put it next to app.use('/api/intake', intakeRouter); so the two public intake surfaces sit together.

  • Step 5: Run to verify it passes
cd backend
npx jest -c jest.integration.config.js --runInBand intakeActions
npx jest -c jest.unit.config.js routesAreWrapped
npm run build && npm run lint

Expected: PASS, 8 integration tests, the wrapper guard green.

  • Step 6: Commit
git add backend/src/routes/intakeActions.ts backend/src/app.ts backend/tests/integration/intakeActions.integration.test.ts
git commit -m "feat(intake): act on a signed link from the notification (#224)"

Task 5: The whole path, once

Files:

  • Modify: backend/tests/integration/intakeActions.integration.test.ts

  • Step 1: Add the end-to-end integration test

import { notifyDraftReady } from '../../src/intake/notifyDraft';

describe('the notification itself', () => {
  // Nowhere to send it is a working configuration, and must not throw into the
  // worker and fail a draft that was written correctly.
  it('does nothing when no recipient is configured', async () => {
    const itemId = await seedDraft();
    await expect(notifyDraftReady(itemId)).resolves.toBeUndefined();
  });

  it('does not throw when the item has no draft', async () => {
    const { rows } = await pool.query<{ id: number }>(
      `INSERT INTO items (name) VALUES ('ordinary') RETURNING id`
    );
    await expect(notifyDraftReady(rows[0]!.id)).resolves.toBeUndefined();
  });
});
  • Step 2: Run the whole backend
cd backend
npm run test:unit
npx jest -c jest.integration.config.js --runInBand
npm run lint && npm run build

Expected: everything passes.

  • Step 3: Commit
git add backend/tests/integration/intakeActions.integration.test.ts
git commit -m "test(intake): cover the notification's quiet paths (#224)"

Done when

  • A draft becoming ready sends one email to the configured address, carrying the drafted name, description, suggested price and the sender's note.
  • The email links into the review queue, and offers regenerate and discard as signed links.
  • The email contains no way to publish.
  • A signed link cannot be replayed against a different item, upgraded to a different action, extended past its expiry, or forged.
  • A GET on a signed link changes nothing, so a mail scanner cannot act on the admin's behalf.
  • With no recipient configured, or no INTAKE_ACTION_SECRET, or SMTP down, the draft is still ready and actionable in the queue.
  • Unit, integration, lint and build all pass.

Not in this plan

A rendered confirmation page. The GET returns JSON describing what the link would do; making it a styled page that POSTs on a button press is frontend work worth its own issue, and the security property — that GET does not act — is already in place without it.

Manual verification against real SMTP. Worth doing once in QA, remembering that MAIL_ALLOWLIST there silently drops anything but the allowlisted address.