Skip to content

Buylist Offer Email 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 merchant clicks "Send Offer to Customer", email the customer their offer with links that reach a working accept/decline page.

Architecture: A fresh claim token is minted at send-offer time (the one from order creation is unreachable by then), stored as a hash with a 30-day expiry. A pure builder turns the order into {subject, html, text}; a single provider adapter posts it to Resend over HTTP. The send is awaited inline with a 5s timeout and can never fail the request — the order still becomes offer_sent, and the merchant gets a copyable link either way.

Tech Stack: Node 24 CommonJS server, Express 5, Mongoose 8, axios (already a dependency — no new npm package), Vitest with ESM test files, React 18 + retroui on the client.

Spec: docs/superpowers/specs/2026-08-25-buylist-offer-email-design.md

Global Constraints

  • No new npm dependencies. The Resend REST API is one axios.post. Do not install the resend SDK.
  • vi.mock of a server module is silently inertvitest.config.js externalizes server source to native require(). Use the _setDeps()/_resetDeps() seam. Every service in this plan exposes one.
  • Server files are CommonJS (require); test files are ESM (import) and co-located as x.test.js next to x.js.
  • Every new persisted field is declared field-by-field in the Mongoose schema and ships with a round-trip test (§5.2). Strict mode drops undeclared sub-document fields on write with no error.
  • Secrets go in three places in the same sitting (§5.7): .env.example, the --set-env-vars block in .github/workflows/deploy-api.yml, and the matching GitHub Actions secret.
  • dotenv-safe runs with allowEmptyValues: true (server/index.js:9) — a key added to .env.example must exist in every local .env, but may be empty.
  • §5.1 game parity is N/A for this whole plan — nothing here is per-game. State that explicitly in the PR description.
  • Never make accept/decline a GET link in an email. Gmail and Outlook prefetch links; a GET that mutates would auto-accept offers. Every email link lands on the portal page, which POSTs on a real click.
  • Commit messages: one imperative sentence naming the merchant-visible outcome, sentence case, no trailing period.
  • Verify with npm test and read the per-file coverage table yourself — the Vitest 4 threshold gate is silently inoperative and exits 0 regardless.

Task 0: Provision the sending domain (mostly automatable)

Nothing leaves the building until send.lgsforge.com is verified. Only one step here needs a human — creating the Resend account and API key, which is a browser login. Everything after that is API + gcloud.

Preconditions established by inspection, not assumption:

  • lgsforge.com is a Google Cloud DNS zone named lgsforge-com and gcloud is authenticated on this machine, so records can be written from the CLI.
  • The root domain already sends mail through ZohoMX → mx.zoho.com, TXT v=spf1 include:zohomail.com ~all, and a DKIM key at lgsforge._domainkey.lgsforge.com. This is why we send from the subdomain: nothing here touches those records, so support@lgsforge.com cannot break.
  • There is no DMARC record on the zone today.

Files: none — infrastructure only.

  • [ ] Step 1 (human, cannot be delegated): create the Resend account and API key

Sign up at resend.com, create an API key with Sending access, and export it locally:

bash
export RESEND_API_KEY=re_...

A subagent cannot do this: it requires a browser login and an account that doesn't exist yet. Every later step in this task can be run by an agent once the key exists.

  • [ ] Step 2: Register the sending domain and capture the records Resend wants
bash
curl -s -X POST https://api.resend.com/domains \
  -H "Authorization: Bearer $RESEND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"send.lgsforge.com","region":"us-east-1"}' | tee resend-domain.json

The response carries id and a records array, each entry with record, name, type, value, and priority for MX. Use these values verbatim — do not hand-write DKIM or SPF strings (§5.4: a wrong literal here doesn't error, it just never verifies). Note the returned name values are relative and already fully qualified for the registered domain (e.g. send.send.lgsforge.com for the return-path MX); that nesting is expected.

  • [ ] Step 3: Write those records into Cloud DNS
bash
gcloud dns record-sets transaction start --zone=lgsforge-com

# One --add per entry in the records array, e.g.:
#   MX:  gcloud dns record-sets transaction add "10 feedback-smtp.us-east-1.amazonses.com." \
#          --name="send.send.lgsforge.com." --ttl=300 --type=MX --zone=lgsforge-com
#   TXT: gcloud dns record-sets transaction add "\"v=spf1 include:amazonses.com ~all\"" \
#          --name="send.send.lgsforge.com." --ttl=300 --type=TXT --zone=lgsforge-com
#   DKIM TXT at resend._domainkey.send.lgsforge.com. — value from the API response

gcloud dns record-sets transaction execute --zone=lgsforge-com

If the transaction fails partway: gcloud dns record-sets transaction abort --zone=lgsforge-com and start again. Names must be fully qualified with a trailing dot; TXT values must be quoted.

  • [ ] Step 4: Confirm the records actually resolve
bash
gcloud dns record-sets list --zone=lgsforge-com --name="send.send.lgsforge.com." 
nslookup -type=TXT resend._domainkey.send.lgsforge.com

Expected: the DKIM TXT resolves and matches the API response value.

  • [ ] Step 5: Ask Resend to verify, then poll
bash
DOMAIN_ID=$(node -p "require('./resend-domain.json').id")
curl -s -X POST "https://api.resend.com/domains/$DOMAIN_ID/verify" -H "Authorization: Bearer $RESEND_API_KEY"
curl -s "https://api.resend.com/domains/$DOMAIN_ID" -H "Authorization: Bearer $RESEND_API_KEY"

Verification is asynchronous — the domain reads pending while it runs. Re-poll the GET until the status settles. Do not proceed to Task 9's end-to-end test until it reports verified.

  • [ ] Step 6: Add a DMARC record — p=none first

There is no DMARC today, and the root's Zoho mail is live. A restrictive policy added blind can start bouncing support@lgsforge.com, so publish monitoring-only and leave it there until reports look clean:

bash
gcloud dns record-sets create _dmarc.lgsforge.com. --zone=lgsforge-com --type=TXT --ttl=300 \
  --rrdatas='"v=DMARC1; p=none; rua=mailto:dmarc@lgsforge.com"'

Tightening to p=quarantine/p=reject is a separate, later decision — and must be checked against Zoho alignment first, not just Resend's.

  • [ ] Step 7: Delete the scratch file
bash
rm resend-domain.json

It contains the domain id and record values; nothing secret, but it must not be committed.

  • [ ] Step 8: Record the outcome

No commit — this task changes infrastructure, not the repo. State in the PR: the domain is verified, and RESEND_API_KEY / BUYLIST_EMAIL_FROM=buylist@send.lgsforge.com exist in both the Cloud Run env and the GitHub Actions secrets (§5.7).


Task 1: Persist the shop's customer-facing contact email

The offer email needs a Reply-To that reaches the merchant, not us. We don't store one. Store.branding is already populated from Shopify at install and refreshed when stale, so it's the right home.

Files:

  • Modify: server/services/shopifyAPI.js:762-779 (getShopBranding)
  • Modify: server/models/Store.js:416-423 (branding sub-document)
  • Test: server/models/Store.test.js (exists), server/services/shopifyAPI.test.js (exists)

Interfaces:

  • Consumes: nothing.
  • Produces: Store.branding.contactEmailString|null. Task 7 reads it as the Reply-To.

Field-name provenance (§5.4): Shop.contactEmail is String!, documented as "The public-facing contact email address for the shop. Customers will use this email to communicate with the shop owner."shopify.dev Shop object. This is the correct field; Shop.email is the owner's address and is not customer-facing. No additional access scope is needed beyond the token we already hold.

  • [ ] Step 1: Write the failing model round-trip test

Append to server/models/Store.test.js:

js
describe('Store branding.contactEmail', () => {
    it('round-trips contactEmail through the schema', () => {
        const store = new Store({
            shop: 'round-trip.myshopify.com',
            branding: { name: 'Round Trip Cards', contactEmail: 'hello@roundtrip.com' }
        });
        // Fails if the schema line is deleted: strict mode drops undeclared
        // sub-doc fields silently on write (§5.2).
        expect(store.branding.contactEmail).toBe('hello@roundtrip.com');
    });

    it('defaults contactEmail to null when Shopify returns none', () => {
        const store = new Store({ shop: 'no-email.myshopify.com', branding: { name: 'No Email' } });
        expect(store.branding.contactEmail).toBeNull();
    });
});
  • [ ] Step 2: Run it and confirm it fails

Run: npx vitest run server/models/Store.test.js -t contactEmail Expected: FAIL — expected undefined to be 'hello@roundtrip.com'.

  • [ ] Step 3: Declare the field

In server/models/Store.js, inside branding, after primaryDomainUrl:

js
        // Shopify's Shop.contactEmail — the public-facing address, not the
        // owner's Shop.email. Used as Reply-To on the buylist offer email so a
        // customer's reply reaches the merchant rather than LGS Forge.
        contactEmail: { type: String, default: null },
  • [ ] Step 4: Run it and confirm it passes

Run: npx vitest run server/models/Store.test.js -t contactEmail Expected: PASS (2 tests).

  • [ ] Step 5: Write the failing API test

Append to server/services/shopifyAPI.test.js, matching the file's existing mocking style for graphQL:

js
describe('getShopBranding contactEmail', () => {
    it('maps Shop.contactEmail into branding', async () => {
        const api = new ShopifyAPI('test.myshopify.com', 'token');
        api.graphQL = vi.fn().mockResolvedValue({
            shop: { name: 'Test', contactEmail: 'hello@test.com', primaryDomain: { host: 'test.com', url: 'https://test.com' } }
        });
        const branding = await api.getShopBranding();
        expect(branding.contactEmail).toBe('hello@test.com');
        expect(api.graphQL.mock.calls[0][0]).toContain('contactEmail');
    });

    it('nulls contactEmail when the shop has none', async () => {
        const api = new ShopifyAPI('test.myshopify.com', 'token');
        api.graphQL = vi.fn().mockResolvedValue({ shop: { name: 'Test' } });
        expect((await api.getShopBranding()).contactEmail).toBeNull();
    });
});
  • [ ] Step 6: Run it and confirm it fails

Run: npx vitest run server/services/shopifyAPI.test.js -t contactEmail Expected: FAIL — expected undefined to be 'hello@test.com'.

  • [ ] Step 7: Add the field to the query and the mapping

In server/services/shopifyAPI.js, getShopBranding:

js
        const query = `{
            shop {
                name
                contactEmail
                primaryDomain { host url }
            }
        }`;

and in the returned object, after primaryDomainUrl:

js
            contactEmail: shop.contactEmail || null,
  • [ ] Step 8: Run both test files and confirm they pass

Run: npx vitest run server/models/Store.test.js server/services/shopifyAPI.test.js Expected: PASS, no other test in those files regressed.

  • [ ] Step 9: Commit
bash
git add server/models/Store.js server/models/Store.test.js server/services/shopifyAPI.js server/services/shopifyAPI.test.js
git commit -m "Store each shop's public contact email for customer replies"

Note for the reviewer: existing stores get contactEmail on their next stale-branding refresh (24h, storeBrandingService.refreshBrandingIfStale). No migration; Task 7 treats a missing value as "no Reply-To".


Task 2: The email provider adapter

Files:

  • Create: server/services/emailService.js
  • Create: server/services/emailService.test.js
  • Modify: .env.example, server/schemas/env.js, .github/workflows/deploy-api.yml

Interfaces:

  • Consumes: nothing.

  • Produces:

    js
    send({ to, fromName, replyTo, subject, html, text })
    Promise<{ status: 'sent',    providerMessageId: string|null }
               | { status: 'failed',  error: string }
               | { status: 'skipped' }>

    Never throws. Tasks 7 persists this result verbatim onto order.offerEmail.

  • [ ] Step 1: Write the failing tests

Create server/services/emailService.test.js:

js
import { describe, it, expect, vi, afterEach } from 'vitest';
import { send, _setDeps, _resetDeps } from './emailService.js';

afterEach(() => _resetDeps());

function makeDeps(overrides = {}) {
    return {
        axios: { post: vi.fn().mockResolvedValue({ data: { id: 'msg_123' } }) },
        logger: { warn: vi.fn(), error: vi.fn() },
        apiKey: 're_test_key',
        from: 'buylist@send.lgsforge.com',
        ...overrides
    };
}

const message = {
    to: 'customer@example.com',
    fromName: 'Jonathan Cards',
    replyTo: 'hello@jonathancards.com',
    subject: 'Your buylist offer',
    html: '<p>hi</p>',
    text: 'hi'
};

describe('emailService.send', () => {
    it('posts the message to Resend and returns the provider id', async () => {
        const deps = makeDeps();
        _setDeps(deps);

        const result = await send(message);

        expect(result).toEqual({ status: 'sent', providerMessageId: 'msg_123' });
        const [url, body, config] = deps.axios.post.mock.calls[0];
        expect(url).toBe('https://api.resend.com/emails');
        expect(body.to).toEqual(['customer@example.com']);
        // Resend's REST API takes snake_case reply_to (the Node SDK's replyTo
        // is an SDK-only alias, and we post raw JSON).
        expect(body.reply_to).toBe('hello@jonathancards.com');
        expect(body.from).toBe('"Jonathan Cards" <buylist@send.lgsforge.com>');
        expect(config.headers.Authorization).toBe('Bearer re_test_key');
        expect(config.timeout).toBe(5000);
    });

    it('strips quotes and newlines from fromName so it cannot forge headers', async () => {
        const deps = makeDeps();
        _setDeps(deps);

        await send({ ...message, fromName: 'Evil"\r\nBcc: victim@example.com' });

        expect(deps.axios.post.mock.calls[0][1].from)
            .toBe('"EvilBcc: victim@example.com" <buylist@send.lgsforge.com>');
    });

    it('omits reply_to when the store has no contact email', async () => {
        const deps = makeDeps();
        _setDeps(deps);

        await send({ ...message, replyTo: null });

        expect(deps.axios.post.mock.calls[0][1]).not.toHaveProperty('reply_to');
    });

    it('skips without calling the provider when no API key is configured', async () => {
        const deps = makeDeps({ apiKey: '' });
        _setDeps(deps);

        expect(await send(message)).toEqual({ status: 'skipped' });
        expect(deps.axios.post).not.toHaveBeenCalled();
    });

    it('skips when no from address is configured', async () => {
        const deps = makeDeps({ from: '' });
        _setDeps(deps);

        expect(await send(message)).toEqual({ status: 'skipped' });
        expect(deps.axios.post).not.toHaveBeenCalled();
    });

    it('returns failed with the provider message instead of throwing', async () => {
        const deps = makeDeps({
            axios: { post: vi.fn().mockRejectedValue({ response: { data: { message: 'Domain not verified' } } }) }
        });
        _setDeps(deps);

        expect(await send(message)).toEqual({ status: 'failed', error: 'Domain not verified' });
    });

    it('returns failed on a timeout', async () => {
        const deps = makeDeps({
            axios: { post: vi.fn().mockRejectedValue(new Error('timeout of 5000ms exceeded')) }
        });
        _setDeps(deps);

        const result = await send(message);
        expect(result.status).toBe('failed');
        expect(result.error).toContain('timeout');
    });
});
  • [ ] Step 2: Run and confirm it fails

Run: npx vitest run server/services/emailService.test.js Expected: FAIL — cannot resolve ./emailService.js.

  • [ ] Step 3: Write the adapter

Create server/services/emailService.js:

js
/**
 * The only thing in the app that talks to an email provider.
 *
 * Kept deliberately thin and provider-shaped-at-one-point: free transactional
 * tiers churn (Postmark and Mailtrap both cut 3,000 -> 500 in Oct 2025;
 * SendGrid deleted its free plan outright), so swapping vendors must cost this
 * file and one secret, nothing else.
 *
 * Mirrors notificationService.js: env-gated, never throws, and injectable for
 * tests. A caller decides what a failure means; this returns it as data.
 */
'use strict';

const axios = require('axios');
const logger = require('../utils/logger');

const RESEND_ENDPOINT = 'https://api.resend.com/emails';
const SEND_TIMEOUT_MS = 5000;

let _deps = null;
function _setDeps(deps) { _deps = deps; }
function _resetDeps() { _deps = null; }
function getDeps() {
    // Env read per call, not at module load: the process may not have loaded
    // dotenv yet when this module is first required from a route.
    return _deps || {
        axios,
        logger,
        apiKey: process.env.RESEND_API_KEY,
        from: process.env.BUYLIST_EMAIL_FROM
    };
}

/**
 * A display name goes into an RFC 5322 header, so a store name containing a
 * quote or CRLF could otherwise inject headers. Strip both rather than escape.
 */
function sanitizeDisplayName(name) {
    return String(name).replace(/["\r\n]/g, '').trim();
}

function buildFrom(fromName, address) {
    if (!fromName) return address;
    const safe = sanitizeDisplayName(fromName);
    return safe ? `"${safe}" <${address}>` : address;
}

async function send({ to, fromName, replyTo, subject, html, text }, deps = getDeps()) {
    if (!deps.apiKey || !deps.from) {
        deps.logger.warn('Email send skipped (RESEND_API_KEY/BUYLIST_EMAIL_FROM not set)', { subject });
        return { status: 'skipped' };
    }

    try {
        const response = await deps.axios.post(
            RESEND_ENDPOINT,
            {
                from: buildFrom(fromName, deps.from),
                to: [to],
                subject,
                html,
                text,
                ...(replyTo ? { reply_to: replyTo } : {})
            },
            {
                headers: { Authorization: `Bearer ${deps.apiKey}`, 'Content-Type': 'application/json' },
                timeout: SEND_TIMEOUT_MS
            }
        );
        return { status: 'sent', providerMessageId: response.data?.id || null };
    } catch (error) {
        // Provider message first: "Domain not verified" is the failure that
        // will actually happen, and error.message alone hides it.
        const detail = error.response?.data?.message || error.message || 'Unknown email error';
        deps.logger.error('Email send failed', { subject, error: detail });
        return { status: 'failed', error: String(detail).substring(0, 300) };
    }
}

module.exports = { send, _setDeps, _resetDeps };
  • [ ] Step 4: Run and confirm it passes

Run: npx vitest run server/services/emailService.test.js Expected: PASS (7 tests).

  • [ ] Step 5: Add the two env keys in all three places (§5.7)

In .env.example, after the Slack block:

# Transactional email (optional - unset disables all outbound email, sends
# become status 'skipped'). Resend: https://resend.com/api-keys
RESEND_API_KEY=
# Verified sending address, e.g. buylist@send.lgsforge.com
BUYLIST_EMAIL_FROM=

In server/schemas/env.js, beside the other optional vars:

js
    RESEND_API_KEY: z.string().optional(),
    BUYLIST_EMAIL_FROM: z.string().email().or(z.literal('')).optional(),

In .github/workflows/deploy-api.yml, append to the --set-env-vars value (line 95), keeping the ||| delimiter:

|||RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}|||BUYLIST_EMAIL_FROM=${{ secrets.BUYLIST_EMAIL_FROM }}
  • [ ] Step 6: Verify the server still boots with the new example keys

Add RESEND_API_KEY= and BUYLIST_EMAIL_FROM= to your local .env (empty is fine), then run: npm run dev:no-worker Expected: server starts. If it exits with "Missing required environment variables", the local .env is missing one of the two keys — dotenv-safe requires every key present in .env.example.

  • [ ] Step 7: Commit
bash
git add server/services/emailService.js server/services/emailService.test.js .env.example server/schemas/env.js .github/workflows/deploy-api.yml
git commit -m "Add a transactional email adapter behind one swappable service"

Reviewer must confirm: the GitHub Actions secrets RESEND_API_KEY and BUYLIST_EMAIL_FROM are created before this merges, or the next deploy sets them to empty strings and every send silently becomes skipped (§5.7).


Task 3: Schema fields for the re-issued token and the send log

Files:

  • Modify: server/models/BuylistOrder.js (order-level fields, near claimTokenHash)
  • Test: server/models/BuylistOrder.test.js (create if absent)

Interfaces:

  • Consumes: nothing.

  • Produces: order.claimTokenExpiresAt (Date), order.offerEmail ({ sentAt: Date, to: String, providerMessageId: String, status: String, error: String, attempts: Number }). Tasks 4, 5, 7 and 8 all read or write these.

  • [ ] Step 1: Write the failing round-trip test

Create server/models/BuylistOrder.test.js (or append a describe if it exists):

js
/**
 * Schema-shape tests. Service tests build plain `{ offerEmail: {...} }` object
 * literals and never touch mongoose.Schema, so they pass whether or not these
 * fields are declared. Strict mode drops undeclared sub-doc fields on write
 * with no error (§5.2) — that is what these catch.
 */
import { describe, it, expect, beforeEach } from 'vitest';

let BuylistOrder;

beforeEach(async () => {
    BuylistOrder = (await import('./BuylistOrder.js')).default;
});

describe('BuylistOrder offer email fields', () => {
    const base = {
        shop: 'test.myshopify.com',
        source: 'storefront',
        customer: { email: 'customer@example.com' },
        lines: []
    };

    it('round-trips claimTokenExpiresAt', () => {
        const expires = new Date('2026-09-24T00:00:00Z');
        const order = new BuylistOrder({ ...base, claimTokenExpiresAt: expires });
        expect(order.claimTokenExpiresAt).toEqual(expires);
    });

    it('round-trips every offerEmail field', () => {
        const sentAt = new Date('2026-08-25T12:00:00Z');
        const order = new BuylistOrder({
            ...base,
            offerEmail: {
                sentAt,
                to: 'customer@example.com',
                providerMessageId: 'msg_123',
                status: 'sent',
                error: null,
                attempts: 1
            }
        });
        expect(order.offerEmail.sentAt).toEqual(sentAt);
        expect(order.offerEmail.to).toBe('customer@example.com');
        expect(order.offerEmail.providerMessageId).toBe('msg_123');
        expect(order.offerEmail.status).toBe('sent');
        expect(order.offerEmail.attempts).toBe(1);
    });

    it('rejects a status outside the enum', () => {
        const order = new BuylistOrder({ ...base, offerEmail: { status: 'delivered' } });
        expect(order.validateSync()?.errors?.['offerEmail.status']).toBeDefined();
    });
});
  • [ ] Step 2: Run and confirm it fails

Run: npx vitest run server/models/BuylistOrder.test.js Expected: FAIL — expected undefined to equal Date.

  • [ ] Step 3: Declare the fields

In server/models/BuylistOrder.js, immediately after claimTokenHash:

js
    // When the current claim token stops working. Set when a token is minted
    // at send-offer; absent on orders whose token predates expiry, which stay
    // valid forever rather than being retroactively killed.
    claimTokenExpiresAt: Date,
    // Outcome of the offer notification. Declared field by field (§5.2).
    // 'sent' means the provider accepted it, NOT that it arrived — we have no
    // bounce webhook yet (see the spec's Risks). 'skipped' means no API key is
    // configured, which is how local dev behaves and is not an error.
    offerEmail: {
        sentAt: Date,
        to: String,
        providerMessageId: String,
        status: { type: String, enum: ['sent', 'failed', 'skipped'] },
        error: String,
        // Increments on every resend, so "we emailed this person four times"
        // is answerable from the order.
        attempts: { type: Number, default: 0 }
    },
  • [ ] Step 4: Run and confirm it passes

Run: npx vitest run server/models/BuylistOrder.test.js Expected: PASS (3 tests).

  • [ ] Step 5: Commit
bash
git add server/models/BuylistOrder.js server/models/BuylistOrder.test.js
git commit -m "Record claim token expiry and offer email delivery on buy orders"

Task 4: Expire claim tokens

Files:

  • Modify: server/services/publicBuylistService.js:174-186 (findOrderByClaim) and its two callers
  • Test: server/services/publicBuylistService.test.js

Interfaces:

  • Consumes: order.claimTokenExpiresAt from Task 3.
  • Produces: getPublicOrderByClaim / actOnPublicOrder return { error: 'This offer link has expired', status: 410 } for a valid-but-expired token. Task 9's UI copy references it.

A 410 is only reachable by someone already holding a valid token, so it leaks nothing an attacker didn't have. A wrong token stays 404, unchanged.

  • [ ] Step 1: Write the failing tests

Append to server/services/publicBuylistService.test.js:

js
describe('claim token expiry', () => {
    const futureOrder = (extra = {}) => ({
        _id: { toString: () => 'order1' },
        status: 'offer_sent',
        claimTokenHash: 'b'.repeat(64),
        customer: { email: 'c@example.com' },
        lines: [],
        payout: {},
        createdAt: new Date(),
        save: vi.fn(),
        ...extra
    });

    it('rejects a token whose expiry has passed with 410', async () => {
        const deps = makeDeps({
            BuylistOrder: { findOne: vi.fn().mockResolvedValue(
                futureOrder({ claimTokenExpiresAt: new Date(Date.now() - 1000) })
            ) }
        });
        _setDeps(deps);

        const result = await getPublicOrderByClaim('s.myshopify.com', 'order1', 'a'.repeat(64));

        expect(result).toEqual({ error: 'This offer link has expired', status: 410 });
    });

    it('accepts a token whose expiry is in the future', async () => {
        const deps = makeDeps({
            BuylistOrder: { findOne: vi.fn().mockResolvedValue(
                futureOrder({ claimTokenExpiresAt: new Date(Date.now() + 60_000) })
            ) }
        });
        _setDeps(deps);

        expect((await getPublicOrderByClaim('s.myshopify.com', 'order1', 'a'.repeat(64))).error).toBeUndefined();
    });

    it('treats an order with no expiry as non-expiring', async () => {
        const deps = makeDeps({ BuylistOrder: { findOne: vi.fn().mockResolvedValue(futureOrder()) } });
        _setDeps(deps);

        expect((await getPublicOrderByClaim('s.myshopify.com', 'order1', 'a'.repeat(64))).error).toBeUndefined();
    });

    it('refuses to act on an expired token', async () => {
        const deps = makeDeps({
            BuylistOrder: { findOne: vi.fn().mockResolvedValue(
                futureOrder({ claimTokenExpiresAt: new Date(Date.now() - 1000) })
            ) }
        });
        _setDeps(deps);

        const result = await actOnPublicOrder('s.myshopify.com', 'order1', 'a'.repeat(64), 'accept');

        expect(result.status).toBe(410);
    });
});

Note: makeDeps in this file already stubs hashClaimToken to return 'b'.repeat(64), which is why these fixtures set claimTokenHash to the same value — the timing-safe compare then matches.

  • [ ] Step 2: Run and confirm it fails

Run: npx vitest run server/services/publicBuylistService.test.js -t "claim token expiry" Expected: FAIL — expired token returns the order instead of a 410.

  • [ ] Step 3: Separate "no match" from "expired"

In server/services/publicBuylistService.js, change findOrderByClaim to report why it refused, and update both callers:

js
/**
 * @returns {{order: Object}|{error: string, status: number}} — an expired token
 * is distinguished from a wrong one so the portal can tell the customer to ask
 * for a new link. Only someone already holding a valid token can see the 410,
 * so this leaks nothing to a guesser.
 */
async function findOrderByClaim(shop, orderId, token, deps) {
    const order = await deps.BuylistOrder.findOne({ _id: orderId, shop });
    if (!order || !order.claimTokenHash) return { error: 'Order not found', status: 404 };

    const providedHash = Buffer.from(deps.hashClaimToken(token));
    const storedHash = Buffer.from(order.claimTokenHash);
    // Timing-safe compare: a naive === would let response timing leak a
    // partial hash match to a scripted guesser probing this public endpoint.
    if (providedHash.length !== storedHash.length || !crypto.timingSafeEqual(providedHash, storedHash)) {
        return { error: 'Order not found', status: 404 };
    }
    // Absent on orders minted before expiry existed — those stay valid.
    if (order.claimTokenExpiresAt && order.claimTokenExpiresAt.getTime() <= Date.now()) {
        return { error: 'This offer link has expired', status: 410 };
    }
    return { order };
}

async function getPublicOrderByClaim(shop, orderId, token, deps = getDeps()) {
    const found = await findOrderByClaim(shop, orderId, token, deps);
    if (found.error) return found;
    return { order: toPublicOrder(found.order) };
}

async function actOnPublicOrder(shop, orderId, token, action, deps = getDeps()) {
    const found = await findOrderByClaim(shop, orderId, token, deps);
    if (found.error) return found;
    const order = found.order;

    if (order.status !== 'offer_sent') {
        return { error: `Cannot ${action} an order with status ${order.status}`, status: 409 };
    }

    order.status = action === 'accept' ? 'accepted' : 'declined';
    await order.save();
    deps.logger.info(`Customer ${action}ed buylist order`, { shop, orderId: order._id });

    return { order: toPublicOrder(order) };
}
  • [ ] Step 4: Run the whole file and confirm nothing regressed

Run: npx vitest run server/services/publicBuylistService.test.js Expected: PASS — including the pre-existing wrong-token and not-found tests, which must still be 404.

  • [ ] Step 5: Commit
bash
git add server/services/publicBuylistService.js server/services/publicBuylistService.test.js
git commit -m "Expire buylist claim links and tell the customer when one has lapsed"

Task 5: The offer email builder

Files:

  • Create: server/services/buylistOfferEmail.js
  • Create: server/services/buylistOfferEmail.test.js

Interfaces:

  • Consumes: formatOrderNumber from server/services/buylistNumberService.js (returns '#00123' (pad width 5) or null for legacy orders).
  • Produces:
    js
    buildOfferEmail({ order, branding, claimUrl })
      → { subject: string, html: string, text: string, fromName: string }
    Task 7 passes this straight into emailService.send.

Both CTAs link to the same portal URL. A GET link that accepted or declined would be fired by Gmail's and Outlook's link prefetchers, silently accepting offers nobody read — the customer must click through and POST from the page.

  • [ ] Step 1: Write the failing tests

Create server/services/buylistOfferEmail.test.js:

js
import { describe, it, expect } from 'vitest';
import { buildOfferEmail } from './buylistOfferEmail.js';

const order = {
    orderNumber: 123,
    customer: { email: 'customer@example.com', name: 'Sam' },
    payout: { method: 'store_credit', cashTotal: 40, creditTotal: 52, creditBonus: 12, marketTotal: 80 },
    lines: [
        { cardName: 'Black Lotus', quantity: 1, included: true },
        { cardName: 'Mox Pearl', quantity: 2, included: true },
        { cardName: 'Shivan Dragon', quantity: 1, included: false }
    ]
};
const branding = { name: "Jonathan's Cards", contactEmail: 'hello@jc.com' };
const claimUrl = 'https://app.lgsforge.com/portal/jc.myshopify.com/buylist/orders/abc?token=deadbeef';

describe('buildOfferEmail', () => {
    it('names the shop and the order in the subject', () => {
        const { subject } = buildOfferEmail({ order, branding, claimUrl });
        expect(subject).toBe("Your buylist offer from Jonathan's Cards (#00123)");
    });

    it('falls back to a subject without a number for legacy orders', () => {
        const { subject } = buildOfferEmail({ order: { ...order, orderNumber: null }, branding, claimUrl });
        expect(subject).toBe("Your buylist offer from Jonathan's Cards");
    });

    it('uses the shop name as the sender display name', () => {
        expect(buildOfferEmail({ order, branding, claimUrl }).fromName).toBe("Jonathan's Cards");
    });

    it('shows the offered total for the chosen payout method', () => {
        const { html, text } = buildOfferEmail({ order, branding, claimUrl });
        expect(html).toContain('$52.00');
        expect(text).toContain('$52.00');
        expect(html).toContain('store credit');
    });

    it('shows the cash total when cash is the chosen method', () => {
        const cashOrder = { ...order, payout: { ...order.payout, method: 'cash' } };
        expect(buildOfferEmail({ order: cashOrder, branding, claimUrl }).html).toContain('$40.00');
    });

    it('counts only included lines', () => {
        const { text } = buildOfferEmail({ order, branding, claimUrl });
        expect(text).toContain('3 cards');
    });

    it('puts the claim link behind both calls to action', () => {
        const { html } = buildOfferEmail({ order, branding, claimUrl });
        const links = [...html.matchAll(/href="([^"]+)"/g)].map((m) => m[1]);
        expect(links.filter((l) => l === claimUrl)).toHaveLength(2);
        // Neither CTA may be a mutating GET: mail clients prefetch links.
        expect(html).not.toMatch(/href="[^"]*\/(accept|decline)"/);
    });

    it('escapes HTML in a shop or card name', () => {
        const evil = { ...branding, name: '<script>alert(1)</script>' };
        const { html } = buildOfferEmail({ order, branding: evil, claimUrl });
        expect(html).not.toContain('<script>');
        expect(html).toContain('&lt;script&gt;');
    });

    it('falls back to a generic shop name when branding is absent', () => {
        const { subject, fromName } = buildOfferEmail({ order, branding: null, claimUrl });
        expect(subject).toBe('Your buylist offer (#00123)');
        expect(fromName).toBe('LGS Forge');
    });
});
  • [ ] Step 2: Run and confirm it fails

Run: npx vitest run server/services/buylistOfferEmail.test.js Expected: FAIL — cannot resolve ./buylistOfferEmail.js.

  • [ ] Step 3: Write the builder

Create server/services/buylistOfferEmail.js:

js
/**
 * Turns a buy order into the customer-facing offer email. Pure — no I/O, no
 * provider knowledge — so it can be rendered to a file and eyeballed, and so
 * emailService stays the single place that knows a vendor exists.
 */
'use strict';

const { formatOrderNumber } = require('./buylistNumberService');

const FALLBACK_SHOP_NAME = 'LGS Forge';

function escapeHtml(value) {
    return String(value ?? '')
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;');
}

function formatMoney(amount) {
    return `$${Number(amount || 0).toFixed(2)}`;
}

function payoutSummary(payout) {
    const isCredit = payout?.method === 'store_credit';
    return {
        label: isCredit ? 'store credit' : 'cash',
        total: formatMoney(isCredit ? payout?.creditTotal : payout?.cashTotal)
    };
}

function buildOfferEmail({ order, branding, claimUrl }) {
    const shopName = branding?.name || FALLBACK_SHOP_NAME;
    const number = formatOrderNumber(order.orderNumber);
    const included = (order.lines || []).filter((line) => line.included !== false);
    const cardCount = included.reduce((sum, line) => sum + (line.quantity || 0), 0);
    const { label, total } = payoutSummary(order.payout);

    const subject = [
        'Your buylist offer',
        branding?.name ? `from ${shopName}` : null,
        number ? `(${number})` : null
    ].filter(Boolean).join(' ');

    const text = [
        `${shopName} has reviewed your buylist submission${number ? ` ${number}` : ''}.`,
        '',
        `Offer: ${total} in ${label} for ${cardCount} cards.`,
        '',
        'Accept or decline your offer here:',
        claimUrl,
        '',
        'This link expires in 30 days.'
    ].join('\n');

    // Both buttons deliberately point at the same page. A GET link that
    // accepted or declined would be fired by Gmail/Outlook link prefetchers.
    const html = `<!doctype html>
<html><body style="font-family: system-ui, sans-serif; color: #111;">
  <p>${escapeHtml(shopName)} has reviewed your buylist submission${number ? ` ${escapeHtml(number)}` : ''}.</p>
  <p style="font-size: 20px;"><strong>${escapeHtml(total)}</strong> in ${escapeHtml(label)} for ${cardCount} cards.</p>
  <p>
    <a href="${escapeHtml(claimUrl)}" style="background:#C4A1FF;border:3px solid #000;padding:12px 20px;color:#000;text-decoration:none;font-weight:700;">Accept offer</a>
    &nbsp;
    <a href="${escapeHtml(claimUrl)}" style="border:3px solid #000;padding:12px 20px;color:#000;text-decoration:none;font-weight:700;">Decline offer</a>
  </p>
  <p style="color:#555;font-size:13px;">Both buttons open your offer page, where you can review every card before deciding. This link expires in 30 days.</p>
</body></html>`;

    return { subject, html, text, fromName: shopName };
}

module.exports = { buildOfferEmail };
  • [ ] Step 4: Run and confirm it passes

Run: npx vitest run server/services/buylistOfferEmail.test.js Expected: PASS (9 tests).

  • [ ] Step 5: Eyeball the rendered email
bash
node -e "const {buildOfferEmail}=require('./server/services/buylistOfferEmail');require('fs').writeFileSync('offer-preview.html',buildOfferEmail({order:{orderNumber:123,payout:{method:'store_credit',creditTotal:52,cashTotal:40},lines:[{cardName:'Black Lotus',quantity:1,included:true}]},branding:{name:\"Jonathan's Cards\"},claimUrl:'https://example.com'}).html)"

Open offer-preview.html in a browser, confirm it reads correctly, then delete it. Do not commit it.

  • [ ] Step 6: Commit
bash
git add server/services/buylistOfferEmail.js server/services/buylistOfferEmail.test.js
git commit -m "Compose the customer's buylist offer email"

Task 6: Send on send-offer, and add resend-offer

Files:

  • Modify: server/routes/buylist.js:571-593 (send-offer), plus a new resend-offer route
  • Create: server/routes/buylist.sendOffer.test.js

Interfaces:

  • Consumes: generateClaimToken, hashClaimToken (server/utils/crypto.js), buildOfferEmail (Task 5), emailService.send (Task 2), Store.branding.contactEmail (Task 1), the schema fields (Task 3).
  • Produces: exported handleSendOffer(req, res) and handleResendOffer(req, res); both respond 200 { order, email: { status, error? }, claimUrl }. Task 8's UI consumes all three fields.

Test harness note: route tests in this repo are per-feature files (buylist.settle.test.js, buylist.intake.test.js — there is no buylist.test.js) and call exported named handlers directly. They obtain the module via createRequire, because await import() of a CJS file returns a synthetic namespace whose getters only mirror the singleton the handler's inner require() sees — spying on the imported namespace silently does nothing. Copy the harness from server/routes/buylist.settle.test.js:9-31.

  • [ ] Step 1: Write the failing tests

Create server/routes/buylist.sendOffer.test.js:

js
/**
 * POST /api/buylist/orders/:id/send-offer and .../resend-offer — mint a fresh
 * claim link, email it, and never let a dead provider block the merchant.
 *
 * createRequire for the same module-cache reason as buylist.settle.test.js.
 */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createRequire } from 'module';

const require_ = createRequire(import.meta.url);
const { handleSendOffer, handleResendOffer } = require_('./buylist.js');
const BuylistOrder = require_('../models/BuylistOrder.js');
const emailService = require_('../services/emailService.js');
const cryptoUtils = require_('../utils/crypto.js');

const TOKEN = 't'.repeat(64);

function makeRes() {
    return { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() };
}

function makeReq(overrides = {}) {
    return {
        params: { id: 'order-1' },
        body: {},
        store: {
            shop: 'test-store.myshopify.com',
            branding: { name: "Jonathan's Cards", contactEmail: 'hello@jc.com' }
        },
        ...overrides
    };
}

function makeOrder(overrides = {}) {
    return {
        _id: 'order-1',
        shop: 'test-store.myshopify.com',
        orderNumber: 123,
        status: 'pending',
        claimTokenHash: 'hash-from-intake',
        customer: { email: 'customer@example.com' },
        lines: [{ cardName: 'Black Lotus', quantity: 1, included: true }],
        payout: { method: 'store_credit', creditTotal: 52, cashTotal: 40 },
        save: vi.fn().mockResolvedValue(undefined),
        ...overrides
    };
}

beforeEach(() => {
    vi.spyOn(cryptoUtils, 'generateClaimToken').mockReturnValue(TOKEN);
    vi.spyOn(cryptoUtils, 'hashClaimToken').mockImplementation((t) => `hash:${t}`);
    vi.spyOn(emailService, 'send').mockResolvedValue({ status: 'sent', providerMessageId: 'msg_1' });
    process.env.APP_DEPLOYMENT_URL = 'https://app.lgsforge.com';
});

afterEach(() => { vi.restoreAllMocks(); });

describe('handleSendOffer', () => {
    let res;
    beforeEach(() => { res = makeRes(); });

    it('404s when the order does not belong to this shop', async () => {
        vi.spyOn(BuylistOrder, 'findOne').mockResolvedValue(null);
        await handleSendOffer(makeReq(), res);
        expect(res.status).toHaveBeenCalledWith(404);
    });

    it('409s on an order that is not pending', async () => {
        vi.spyOn(BuylistOrder, 'findOne').mockResolvedValue(makeOrder({ status: 'accepted' }));
        await handleSendOffer(makeReq(), res);
        expect(res.status).toHaveBeenCalledWith(409);
    });

    it('re-mints the claim token rather than reusing the one from intake', async () => {
        const order = makeOrder();
        vi.spyOn(BuylistOrder, 'findOne').mockResolvedValue(order);

        await handleSendOffer(makeReq(), res);

        expect(order.claimTokenHash).toBe(`hash:${TOKEN}`);
        expect(order.claimTokenExpiresAt.getTime()).toBeGreaterThan(Date.now());
        expect(order.save).toHaveBeenCalled();
    });

    it('emails the customer a link carrying the plaintext token', async () => {
        vi.spyOn(BuylistOrder, 'findOne').mockResolvedValue(makeOrder());

        await handleSendOffer(makeReq(), res);

        const [message] = emailService.send.mock.calls[0];
        expect(message.to).toBe('customer@example.com');
        expect(message.replyTo).toBe('hello@jc.com');
        expect(message.subject).toContain("Jonathan's Cards");
        expect(res.json.mock.calls[0][0].claimUrl).toBe(
            `https://app.lgsforge.com/portal/test-store.myshopify.com/buylist/orders/order-1?token=${TOKEN}`
        );
    });

    it('omits reply-to when the store has no contact email yet', async () => {
        vi.spyOn(BuylistOrder, 'findOne').mockResolvedValue(makeOrder());
        const req = makeReq({ store: { shop: 'test-store.myshopify.com', branding: { name: 'X' } } });

        await handleSendOffer(req, res);

        expect(emailService.send.mock.calls[0][0].replyTo).toBeNull();
    });

    it('records a successful send on the order', async () => {
        const order = makeOrder();
        vi.spyOn(BuylistOrder, 'findOne').mockResolvedValue(order);

        await handleSendOffer(makeReq(), res);

        expect(order.status).toBe('offer_sent');
        expect(order.offerEmail).toMatchObject({
            status: 'sent', to: 'customer@example.com', providerMessageId: 'msg_1', attempts: 1
        });
        expect(res.json.mock.calls[0][0].email).toEqual({ status: 'sent' });
    });

    it('still offers the order when the email fails', async () => {
        const order = makeOrder();
        vi.spyOn(BuylistOrder, 'findOne').mockResolvedValue(order);
        emailService.send.mockResolvedValue({ status: 'failed', error: 'Domain not verified' });

        await handleSendOffer(makeReq(), res);

        expect(res.status).not.toHaveBeenCalled();
        expect(order.status).toBe('offer_sent');
        expect(order.offerEmail.status).toBe('failed');
        expect(res.json.mock.calls[0][0].email).toEqual({ status: 'failed', error: 'Domain not verified' });
    });

    it('records skipped when no provider is configured', async () => {
        const order = makeOrder();
        vi.spyOn(BuylistOrder, 'findOne').mockResolvedValue(order);
        emailService.send.mockResolvedValue({ status: 'skipped' });

        await handleSendOffer(makeReq(), res);

        expect(order.status).toBe('offer_sent');
        expect(order.offerEmail.status).toBe('skipped');
    });
});

describe('handleResendOffer', () => {
    let res;
    beforeEach(() => { res = makeRes(); });

    it('409s unless an offer is already out', async () => {
        vi.spyOn(BuylistOrder, 'findOne').mockResolvedValue(makeOrder({ status: 'pending' }));
        await handleResendOffer(makeReq(), res);
        expect(res.status).toHaveBeenCalledWith(409);
    });

    it('re-mints so the previous link stops working, and counts the attempt', async () => {
        const order = makeOrder({
            status: 'offer_sent',
            claimTokenHash: 'hash:previous',
            offerEmail: { attempts: 1, status: 'failed' }
        });
        vi.spyOn(BuylistOrder, 'findOne').mockResolvedValue(order);

        await handleResendOffer(makeReq(), res);

        expect(order.claimTokenHash).toBe(`hash:${TOKEN}`);
        expect(order.offerEmail.attempts).toBe(2);
        expect(order.status).toBe('offer_sent');
    });
});
  • [ ] Step 2: Run and confirm they fail

Run: npx vitest run server/routes/buylist.sendOffer.test.js Expected: FAIL — handleSendOffer is not a function (the handler is still an inline anonymous callback).

  • [ ] Step 3: Extract the shared send and wire both routes

In server/routes/buylist.js, add above the send-offer route:

js
const CLAIM_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;

/**
 * Mint a fresh claim link and email it. Shared by send-offer and resend-offer
 * so the two can't drift.
 *
 * Re-minting invalidates the token issued at order creation. That is the point:
 * that token was returned exactly once, in a response body the customer no
 * longer has, which is why the offer was unreachable (#642).
 *
 * Never throws. A dead provider must not block a merchant from offering — the
 * order moves to offer_sent either way and the response carries the link for
 * the merchant to pass on by hand.
 */
async function issueAndEmailOffer(order, store) {
    const { generateClaimToken, hashClaimToken } = require('../utils/crypto');
    const { buildOfferEmail } = require('../services/buylistOfferEmail');
    const emailService = require('../services/emailService');

    const claimToken = generateClaimToken();
    order.claimTokenHash = hashClaimToken(claimToken);
    order.claimTokenExpiresAt = new Date(Date.now() + CLAIM_TOKEN_TTL_MS);

    // Same origin serves the portal page and rewrites /api to Cloud Run
    // (firebase.json), so one base URL covers the link and the calls it makes.
    const base = process.env.APP_DEPLOYMENT_URL || 'http://localhost:3000';
    const claimUrl = `${base}/portal/${order.shop}/buylist/orders/${order._id}?token=${claimToken}`;

    const message = buildOfferEmail({ order, branding: store.branding, claimUrl });
    const result = await emailService.send({
        to: order.customer.email,
        fromName: message.fromName,
        // Absent until the store's branding refreshes (Task 1); no Reply-To is
        // better than one that lands in our inbox.
        replyTo: store.branding?.contactEmail || null,
        subject: message.subject,
        html: message.html,
        text: message.text
    });

    order.offerEmail = {
        sentAt: new Date(),
        to: order.customer.email,
        providerMessageId: result.providerMessageId || null,
        status: result.status,
        error: result.error || null,
        attempts: (order.offerEmail?.attempts || 0) + 1
    };

    return { claimUrl, email: { status: result.status, ...(result.error ? { error: result.error } : {}) } };
}

Then convert the existing inline router.post('/buylist/orders/:id/send-offer', async (req, res) => { … }) (line 571) into a named, exported handler — the tests call it directly, matching handleSettleOrder's pattern:

js
/**
 * POST /api/buylist/orders/:id/send-offer
 * Merchant has reviewed a customer-submitted (source !== 'merchant') order
 * and is ready for the customer to see + confirm it on their claim-link
 * status page. Only a 'pending' order can move to 'offer_sent'.
 */
async function handleSendOffer(req, res) {
    try {
        const BuylistOrder = require('../models/BuylistOrder');
        const order = await BuylistOrder.findOne({ _id: req.params.id, shop: req.store.shop });
        if (!order) {
            return res.status(404).json({ error: 'Buy order not found' });
        }
        if (order.status !== 'pending') {
            return res.status(409).json({ error: `Cannot send an offer for an order with status ${order.status}` });
        }
        order.status = 'offer_sent';
        const sent = await issueAndEmailOffer(order, req.store);
        await order.save();
        logger.info('Sent buylist offer to customer', {
            shop: req.store.shop, orderId: order._id, email: sent.email.status
        });
        res.json({ order, email: sent.email, claimUrl: sent.claimUrl });
    } catch (error) {
        logger.error('Failed to send buylist offer', { error: error.message, orderId: req.params.id });
        res.status(500).json({ error: 'Failed to send offer' });
    }
}

/**
 * POST /api/buylist/orders/:id/resend-offer
 * Re-mint and re-send. For a bad address corrected at the counter, or a
 * customer who deleted the email. Only meaningful once an offer is out.
 */
async function handleResendOffer(req, res) {
    try {
        const BuylistOrder = require('../models/BuylistOrder');
        const order = await BuylistOrder.findOne({ _id: req.params.id, shop: req.store.shop });
        if (!order) {
            return res.status(404).json({ error: 'Buy order not found' });
        }
        if (order.status !== 'offer_sent') {
            return res.status(409).json({ error: `Cannot resend an offer for an order with status ${order.status}` });
        }
        const sent = await issueAndEmailOffer(order, req.store);
        await order.save();
        logger.info('Resent buylist offer to customer', {
            shop: req.store.shop, orderId: order._id, email: sent.email.status
        });
        res.json({ order, email: sent.email, claimUrl: sent.claimUrl });
    } catch (error) {
        logger.error('Failed to resend buylist offer', { error: error.message, orderId: req.params.id });
        res.status(500).json({ error: 'Failed to resend offer' });
    }
}

router.post('/buylist/orders/:id/send-offer', handleSendOffer);
router.post('/buylist/orders/:id/resend-offer', handleResendOffer);
  • [ ] Step 3b: Export both handlers for the tests

At the bottom of server/routes/buylist.js, beside the existing handler exports:

js
module.exports.handleSendOffer = handleSendOffer;
module.exports.handleResendOffer = handleResendOffer;
  • [ ] Step 4: Run and confirm they pass

Run: npx vitest run server/routes/buylist.sendOffer.test.js && npx vitest run server/routes/buylist.settle.test.js server/routes/buylist.updateOrder.test.js Expected: PASS — the new file, and the neighbouring route tests that share buylist.js must be unregressed by the handler extraction.

  • [ ] Step 5: Confirm no identity default crept in (§5.5)

Run: git diff main -- server/routes/buylist.js | grep -nE "\|\| 'mtg'|\?\? 'mtg'" Expected: no output.

  • [ ] Step 6: Commit
bash
git add server/routes/buylist.js server/routes/buylist.sendOffer.test.js
git commit -m "Email the customer a working offer link when the merchant sends an offer"

Task 7: Erase the emailed address on GDPR redaction

Files:

  • Modify: server/services/gdprService.js:163
  • Test: server/services/gdprService.test.js

Interfaces:

  • Consumes: order.offerEmail.to, order.claimTokenExpiresAt (Task 3).
  • Produces: nothing downstream.

offerEmail.to is a second copy of the customer's email address. customers/redact currently unsets claimTokenHash only, so the copy would survive erasure.

  • [ ] Step 1: Write the failing test

Append to server/services/gdprService.test.js:

js
it('unsets the emailed offer address and token expiry on redaction', async () => {
    const updateMany = vi.fn().mockResolvedValue({ modifiedCount: 1 });
    _setDeps({ BuylistOrder: { updateMany } });

    // This file imports the module as a namespace (`const gdprService = await
    // import('./gdprService.js')`) — call through it, not as a bare binding.
    await gdprService.redactCustomerData('test.myshopify.com', { email: 'customer@example.com' });

    const unset = updateMany.mock.calls[0][1].$unset;
    expect(unset).toHaveProperty('offerEmail.to');
    expect(unset).toHaveProperty('claimTokenExpiresAt');
    expect(unset).toHaveProperty('claimTokenHash');
});
  • [ ] Step 2: Run and confirm it fails

Run: npx vitest run server/services/gdprService.test.js -t redaction Expected: FAIL — offerEmail.to is not in $unset.

  • [ ] Step 3: Extend the unset
js
            $unset: {
                'customer.name': '',
                'customer.shopifyCustomerId': '',
                claimTokenHash: '',
                claimTokenExpiresAt: '',
                // A second copy of the customer's address; erasure must take
                // both. The rest of offerEmail is delivery metadata about a
                // business record and stays.
                'offerEmail.to': ''
            }
  • [ ] Step 4: Run and confirm it passes

Run: npx vitest run server/services/gdprService.test.js Expected: PASS.

  • [ ] Step 5: Commit
bash
git add server/services/gdprService.js server/services/gdprService.test.js
git commit -m "Erase the emailed offer address when a customer requests deletion"

Files:

  • Modify: client/src/pages/buylist/BuylistReviewPage.jsx (the act handler ~line 258; the isPending && source !== 'merchant' block ~line 593; the isOfferSent block ~line 600)
  • Test: client/src/pages/buylist/BuylistReviewPage.test.jsx

Interfaces:

  • Consumes: { order, email: { status, error? }, claimUrl } from Task 6.
  • Produces: nothing downstream.

The existing act(action) already POSTs to /buylist/orders/${id}/${action}, so act('resend-offer') needs no new API helper — only the extra response fields must be captured.

  • [ ] Step 1: Write the failing tests

Append these to the existing describe('BuylistReviewPage — storefront orders', …) block in client/src/pages/buylist/BuylistReviewPage.test.jsx (line 536), which already defines storefrontPendingOrder and offerSentOrder. That file renders with renderAt(id) and clicks with fireEvent — not userEvent — and gates on await screen.findByText('Lightning Bolt') for load. The fixture customer is jamie@example.com.

jsx
  const CLAIM_URL = 'https://app.lgsforge.com/portal/s.myshopify.com/buylist/orders/order-1?token=abc';

  it('shows the claim link and confirms the email after sending an offer', async () => {
    api.get.mockResolvedValue({ data: { order: storefrontPendingOrder } });
    api.post.mockResolvedValue({ data: {
      order: { ...storefrontPendingOrder, status: 'offer_sent' },
      email: { status: 'sent' },
      claimUrl: CLAIM_URL
    } });
    renderAt('order-1');
    await screen.findByText('Lightning Bolt');

    fireEvent.click(screen.getByRole('button', { name: /send offer to customer/i }));

    expect(await screen.findByText(/emailed jamie@example\.com/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /copy link/i })).toBeInTheDocument();
  });

  it('tells the merchant to pass the link on when the email fails', async () => {
    api.get.mockResolvedValue({ data: { order: storefrontPendingOrder } });
    api.post.mockResolvedValue({ data: {
      order: { ...storefrontPendingOrder, status: 'offer_sent' },
      email: { status: 'failed', error: 'Domain not verified' },
      claimUrl: CLAIM_URL
    } });
    renderAt('order-1');
    await screen.findByText('Lightning Bolt');

    fireEvent.click(screen.getByRole('button', { name: /send offer to customer/i }));

    expect(await screen.findByText(/couldn't be emailed/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /copy link/i })).toBeInTheDocument();
  });

  it('offers a resend once an offer is out', async () => {
    api.get.mockResolvedValue({ data: { order: offerSentOrder } });
    api.post.mockResolvedValue({ data: { order: offerSentOrder, email: { status: 'sent' }, claimUrl: CLAIM_URL } });
    renderAt('order-1');
    await screen.findByText('Lightning Bolt');

    fireEvent.click(screen.getByRole('button', { name: /resend email/i }));

    await waitFor(() => expect(api.post).toHaveBeenCalledWith('/buylist/orders/order-1/resend-offer'));
  });

Note: the panel renders inside the isOfferSent block, so on the send-offer path it appears only after adoptOrder flips the status — which is why each test asserts after the click rather than before.

  • [ ] Step 2: Run and confirm they fail

Run: npx vitest run --config vitest.client.config.js client/src/pages/buylist/BuylistReviewPage.test.jsx Expected: FAIL — no copy-link button exists.

  • [ ] Step 3: Capture the new response fields

Add state beside the existing declarations (~line 141):

jsx
  const [offerLink, setOfferLink] = useState(null);
  const [emailResult, setEmailResult] = useState(null);

and record them in act (~line 262):

jsx
      const { data } = await api.post(`/buylist/orders/${id}/${action}`);
      // Only send-offer/resend-offer return these; leave the previous values
      // alone for accept/decline/settle rather than blanking the panel.
      if (data.claimUrl) setOfferLink(data.claimUrl);
      if (data.email) setEmailResult(data.email);
      adoptOrder(data.order);
  • [ ] Step 4: Render the panel

Add inside the isOfferSent block, above the existing Accept/Decline buttons:

jsx
          {emailResult && (
            <p className="text-sm">
              {emailResult.status === 'sent' && `Emailed ${order.customer.email}.`}
              {emailResult.status === 'failed' && `This offer couldn't be emailed (${emailResult.error}). Send the link below to the customer yourself.`}
              {emailResult.status === 'skipped' && 'Email isn\'t configured, so nothing was sent. Send the link below to the customer yourself.'}
            </p>
          )}
          {offerLink && (
            <div className="flex items-center gap-2">
              <code className="truncate text-xs">{offerLink}</code>
              <Button variant="outline" onClick={() => navigator.clipboard.writeText(offerLink)}>Copy link</Button>
            </div>
          )}
          <Button variant="outline" onClick={() => act('resend-offer')} disabled={acting}>Resend email</Button>
  • [ ] Step 5: Run and confirm they pass

Run: npx vitest run --config vitest.client.config.js client/src/pages/buylist/BuylistReviewPage.test.jsx Expected: PASS, including the file's pre-existing tests.

  • [ ] Step 6: Commit
bash
git add client/src/pages/buylist/BuylistReviewPage.jsx client/src/pages/buylist/BuylistReviewPage.test.jsx
git commit -m "Show merchants the customer's offer link and whether the email sent"

Task 9: Full verification

Files: none — this task only runs commands and reports.

  • [ ] Step 1: Full server suite

Run: npm test Expected: exit 0.

  • [ ] Step 2: Client suite and build

Run: npm run test:client && npm run build Expected: both exit 0.

  • [ ] Step 3: Lint

Run: npm run lint Expected: exit 0 with no new warnings. Two warnings pre-date this work. If the count looks wrong, get the baseline with git stash-free comparison: git worktree add /tmp/lint-base main && (cd /tmp/lint-base && npm run lint). Never git stash in a worktree — the stash stack is shared across all of them and a concurrent session can pop your entry.

  • [ ] Step 4: Read the coverage table yourself

Run: npm run test:coverage Expected: every file this plan touched reports ≥70% on all four metrics. The command exits 0 regardless — the Vitest 4 threshold gate is inoperative. Read the per-file rows for: emailService.js, buylistOfferEmail.js, publicBuylistService.js, routes/buylist.js, gdprService.js.

  • [ ] Step 5: Confirm no raw Shopify calls were introduced

Run: git diff main --name-only | xargs grep -ln "myshopify.com" | xargs grep -n "axios\|fetch" | grep -v shopifyAPI.js Expected: no output (rule 6 — all Shopify traffic goes through shopifyAPI.js).

  • [ ] Step 6: End-to-end against a real inbox

Prerequisite: Task 0 complete — the domain reports verified in Resend — and RESEND_API_KEY + BUYLIST_EMAIL_FROM set in your local .env.

  1. npm run dev, submit a storefront buylist order to the dev store with your own email.
  2. In the merchant dashboard, open the order and click Send Offer to Customer.
  3. Confirm: the email arrives; the subject names the shop and order; both buttons open the portal status page; the page shows the offer and its Accept/Decline buttons; clicking Accept moves the order to accepted in the dashboard.
  4. Click Resend email, confirm a second email arrives and that the first email's link now returns "This offer link has expired" — that is the re-mint working as designed.
  • [ ] Step 7: Open the PR

The description must state: §5.1 parity is N/A (nothing per-game); the bounce webhook is knowingly deferred (offerEmail.status: 'sent' means accepted-by-provider, not delivered); and that RESEND_API_KEY / BUYLIST_EMAIL_FROM exist as GitHub Actions secrets and in the Cloud Run env (§5.7).


Deferred, deliberately

Recorded so a later reader doesn't mistake these for oversights:

  • Bounce/complaint webhooks (email.bounced, email.delivered via Svix signature verification). Until this lands, an offer emailed to a dead address looks successful. Follow-up PR.
  • ?intent=accept|decline on the claim URL so the portal can pre-highlight the button the customer clicked. Needs client work and buys little; both CTAs land on the same page today.
  • Per-merchant sending domains. One LGS Forge domain with the merchant as Reply-To until a merchant asks.