Scrooge & Marley · 1 Cornhill, London · Est. 1836
📋 Explanation · Proposed Extension 02 of 03

THE DEAD LETTER OFFICE

Automated dunning correspondence — escalating in register, inexorable in frequency, dispatched without the Firm lifting a pen
Version 1.0 | March 2026 | Reviewed by Dev the Dev
Version 1.0 | March 2026 | Reviewed by Dev the Dev
What this document is A technical specification for The Dead Letter Office — the Scrooge & Marley automated dunning correspondence extension. Covers the business case, escalation registers, template variable system, database schema, Vercel cron design, idempotency constraints on the despatch log, and effort estimate. Requires The Ledger to be implemented first.

1. The business case

The cost of debt recovery is correspondence. A clerk who writes the same letter fifteen times to fifteen debtors who have not paid is a clerk who could be doing something else. The letter does not change. The tone escalates on a schedule. The schedule is known in advance. This is a machine's work, not a man's.

"The letter is not a courtesy. It is a record of notice given. It will be produced in proceedings."

The Dead Letter Office automates dunning — the sequence of increasingly firm correspondence dispatched to a debtor who has missed a payment or failed to respond. It does not negotiate. It does not apologise. It states the sum, the date it was due, the consequence of continued non-payment, and the address to which funds should be directed.

2. The four registers

Each letter in a dunning sequence is written in one of four registers. The register escalates as the sequence progresses. It never de-escalates. A debt that has reached Pre-Legal does not receive a Formal notice unless a new sequence is opened by the Firm.

Formal

The opening notice. Factual, impersonal, professional. States the sum, the due date, and the Firm's banking details. Assumes the debtor may have overlooked the obligation. Does not threaten. Does not remind them of what the Firm can do. That comes later.

Typical trigger: 7 days after payment missed.

Firm

The second notice. Acknowledges that the first letter was received and not acted upon. States that this is the second correspondence on the matter. Notes that continued non-payment will result in escalation. Does not specify what escalation means — that specificity belongs to the next register.

Typical trigger: 14 days after Formal notice, no payment received.

Final

The final demand. States explicitly that legal proceedings are being considered. Specifies the exact sum now due including accrued interest and the Firm's recovery costs. Sets a final date by which payment must be received to avoid further action. Short. Unambiguous.

Typical trigger: 21 days after Firm notice, no payment received.

3. Template variable system

Letter templates use a minimal handlebars-style syntax. The renderer resolves variables at despatch time against the live debt record. Templates are stored as plain text in the database — no HTML, no rich text. The Firm writes letters, not web pages.

VariableResolves toSource
{{debtor_name}} Full name of the debtor debtors.name
{{debtor_address}} Debtor's address for formal correspondence debtors.address
{{debt_ref}} First 8 characters of the debt UUID — used as the account reference debts.id
{{principal}} Original principal, formatted: £X,XXX.XX debts.principal_pence
{{outstanding}} Current outstanding balance including accrued interest, formatted: £X,XXX.XX Computed at render time
{{due_date}} Next scheduled payment date, or original due date if no schedule repayment_schedule.due_date
{{days_overdue}} Calendar days since the debt or instalment fell due Computed: CURRENT_DATE - due_date
{{despatch_date}} Date this letter is being despatched, formatted: DD Month YYYY NOW() at despatch time
{{final_payment_date}} Deadline stated in Final and Pre-Legal notices — 14 days from despatch Computed: NOW() + INTERVAL '14 days'
{{firm_name}} Scrooge & Marley public/theme.json
{{firm_address}} 1 Cornhill, London EC3V 3ND public/theme.json

4. Database schema

Three new tables. The despatch log has no DELETE policy — it is a permanent record and cannot be removed.

-- Letter templates (the named sequences and their steps)
CREATE TABLE IF NOT EXISTS dunning_templates (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,                  -- e.g. 'Standard Arrears — First Notice'
  sequence_name TEXT NOT NULL,         -- e.g. 'Standard Arrears'
  sequence_position INTEGER NOT NULL,  -- 1, 2, 3... within the sequence
  register TEXT NOT NULL,              -- formal | firm | final | pre_legal
  subject_template TEXT NOT NULL,      -- e.g. 'Account {{debt_ref}} — Notice of Arrears'
  body_template TEXT NOT NULL,         -- full letter body with {{variables}}
  trigger_days INTEGER NOT NULL,       -- days since last despatch (or referral date for step 1)
  requires_approval BOOLEAN            -- pre_legal always true; others configurable
    GENERATED ALWAYS AS (register = 'pre_legal') STORED,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(sequence_name, sequence_position)
);
ALTER TABLE dunning_templates ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service_role_templates" ON dunning_templates
  FOR ALL USING (true) WITH CHECK (true);

-- Assignment of a dunning sequence to a specific debt
CREATE TABLE IF NOT EXISTS dunning_assignments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  debt_id UUID NOT NULL REFERENCES debts(id) ON DELETE CASCADE,
  sequence_name TEXT NOT NULL,
  auto_despatch BOOLEAN DEFAULT false, -- must be explicitly enabled per debt
  paused BOOLEAN DEFAULT false,
  current_position INTEGER DEFAULT 1,
  next_trigger_date DATE,              -- set by cron after each despatch
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(debt_id)                      -- one active sequence per debt
);
ALTER TABLE dunning_assignments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service_role_assignments" ON dunning_assignments
  FOR ALL USING (true) WITH CHECK (true);

-- Despatch log — permanent record, no DELETE policy
CREATE TABLE IF NOT EXISTS correspondence_log (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  debt_id UUID NOT NULL REFERENCES debts(id) ON DELETE RESTRICT,
  assignment_id UUID REFERENCES dunning_assignments(id),
  template_id UUID REFERENCES dunning_templates(id),
  despatched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  recipient_email TEXT NOT NULL,
  subject TEXT NOT NULL,
  body_rendered TEXT NOT NULL,         -- full rendered text at despatch time, immutable
  resend_message_id TEXT,              -- Resend delivery ID for tracking
  status TEXT DEFAULT 'sent',          -- sent | bounced | failed
  approved_by TEXT                     -- 'auto' or admin identifier for manual approvals
);
ALTER TABLE correspondence_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service_role_log_read" ON correspondence_log FOR SELECT USING (true);
CREATE POLICY "service_role_log_insert" ON correspondence_log FOR INSERT WITH CHECK (true);
-- No UPDATE or DELETE policy — the log is permanent

5. Cron trigger design

A Vercel cron job fires daily at 08:00. It evaluates all active dunning assignments and queues any letters whose trigger date has been reached. It does not despatch — it queues. Despatch requires a second step.

Cron logic

-- Letters ready to queue (run by cron at 08:00 daily)
SELECT
  da.id AS assignment_id,
  da.debt_id,
  da.current_position,
  da.auto_despatch,
  dt.id AS template_id,
  dt.register,
  dt.requires_approval,
  dt.subject_template,
  dt.body_template,
  d.debtor_id

FROM dunning_assignments da
JOIN dunning_templates dt
  ON dt.sequence_name = da.sequence_name
  AND dt.sequence_position = da.current_position
JOIN debts d ON d.id = da.debt_id

WHERE da.paused = false
  AND da.next_trigger_date <= CURRENT_DATE
  AND d.status = 'active'

  -- Do not queue if already despatched today (idempotency guard)
  AND NOT EXISTS (
    SELECT 1 FROM correspondence_log cl
    WHERE cl.assignment_id = da.id
      AND cl.despatched_at::DATE = CURRENT_DATE
  );

Idempotency

The cron job must not despatch a letter twice. Two mechanisms enforce this. First, the query above excludes assignments that have already produced a log entry today. Second, the cron sets a next_trigger_date on the assignment immediately after queuing — before Resend is called. If Resend fails, the letter is in the queue but not logged as sent; the administrator is notified and can retry manually. The assignment is not advanced until despatch is confirmed.

Pre-legal letters require manual approval without exception The requires_approval column is a generated column — it is always true when register = 'pre_legal' and cannot be overridden. The cron job places pre-legal letters in the approval queue and stops. It does not despatch them. An administrator must review and approve before the letter leaves the Firm. This is not a configuration option.

6. New routes

7. Effort and dependencies

ItemDetail
Effort estimateHigh — 14–18 days
New database tables3
New admin routes3 pages, 5 API routes, 1 cron endpoint
External dependenciesResend (email despatch — one env var, one API call). The Ledger (required).
New environment variableCRON_SECRET — validates Vercel cron requests to prevent external triggering
RequiresThe Ledger must be implemented and tested before this extension begins

The template engine and trigger evaluation are straightforward in isolation. The complexity is in the guarantees: no letter despatched without a log entry, no log entry deleted, no trigger misfiring on a paused or settled debt, no pre-legal letter despatched without human approval. The Vercel cron pattern is well-documented but requires careful idempotency design — the query above handles the common case; edge cases (Resend timeout, partial failure) require explicit retry logic in the queue processor.