From 26063bf7232e396a6e8c0d5433ae76fb7203414e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 6 Oct 2026 08:47:12 +0000 Subject: [PATCH] Post email-intake coupons to the c0upons.com/bbs forums Each coupon submit@c0upons.com creates gets a thread on the tsbb board, in the forum its words fit (freebies, grocery, tech, home, fashion, travel; else Coupons & Promo Codes for a code, Hot Deals for a sale), linking to its page. - lib/bbs-post.ts: pickForum, topicFor, postCouponsToBbs (POST /api/v1/forums//topics with BBS_POST_TOKEN; bbs_posts table so a coupon is posted once; 16 s spacing and one retry for tsbb's flood guard) - inbound-email: returns `created` (id, store slug, code, discount, expiry) for the coupons it inserted; the webhook posts them after answering - ops/bbs/mint-poster.ts: board user `c0upons` + its token (already run; token in vault c0upons--prod) - 7 tests (forums, thread text, once-only, flood retry, no token, refusals, intake hand-over) Co-Authored-By: Claude Opus 5.5 --- apps/web/app/api/webhooks/email/route.ts | 11 ++ apps/web/lib/bbs-post.ts | 205 +++++++++++++++++++++++ apps/web/lib/inbound-email.ts | 22 ++- ops/bbs/README.md | 21 +++ ops/bbs/mint-poster.ts | 33 ++++ test/bbs-post.test.mjs | 168 +++++++++++++++++++ 6 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 apps/web/lib/bbs-post.ts create mode 100644 ops/bbs/mint-poster.ts create mode 100644 test/bbs-post.test.mjs diff --git a/apps/web/app/api/webhooks/email/route.ts b/apps/web/app/api/webhooks/email/route.ts index 9d7e905..b9be112 100644 --- a/apps/web/app/api/webhooks/email/route.ts +++ b/apps/web/app/api/webhooks/email/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import Anthropic from '@anthropic-ai/sdk'; import { getDb } from '@/lib/db'; import { loadRootEnv } from '@/lib/root-env'; +import { postCouponsToBbs } from '@/lib/bbs-post'; import { MODEL, handleInboundEmail, @@ -97,6 +98,16 @@ export async function POST(req: NextRequest) { console.log( `inbound email: ${result.outcome} from=${mail.from?.value?.[0]?.address ?? '?'} posted=${result.posted.length}`, ); + // Each new coupon also gets a thread on c0upons.com/bbs. Not awaited: the + // board's flood guard spaces posts 16 s apart, and Forward Email should not + // wait on that. This server is long-lived (dev2), so the promise finishes. + if (result.created.length) { + void postCouponsToBbs(getDb(), result.created) + .then((posts) => { + for (const p of posts) console.log(`bbs post: coupon ${p.coupon_id} -> ${p.forum} ${p.status} ${p.url ?? p.error ?? ''}`); + }) + .catch((err) => console.error('bbs post failed:', err)); + } return NextResponse.json(result); } catch (err) { console.error('inbound email failed:', err); diff --git a/apps/web/lib/bbs-post.ts b/apps/web/lib/bbs-post.ts new file mode 100644 index 0000000..c562425 --- /dev/null +++ b/apps/web/lib/bbs-post.ts @@ -0,0 +1,205 @@ +/** + * Coupons from the email intake, posted to the c0upons.com/bbs forums. + * + * Every coupon submit@c0upons.com turns into gets a thread in the forum that + * fits it (Grocery & Weekly Ads, Tech Deals, Freebies...), with a link back to + * its page here. Only the email intake does this: the nichedb, Flipp and Reddit + * syncs bring in hundreds at a time, and a forum that is a mirror of the site + * is a forum nobody reads. + * + * The board is tsbb, which takes `POST /api/v1/forums//topics` with a + * `tsbb_` bearer token minted for the poster account. Its flood guard refuses a + * second post by the same account inside `posts.floodSeconds` (15 by default), + * and one email can carry several offers, so posts go one at a time with a gap, + * and a `flooding` answer waits and tries once more. + * + * `bbs_posts` remembers what was posted, so a coupon is never posted twice, + * whatever retries upstream. + */ +import type { SqlDb } from './nichedb-sync.ts'; + +export const SITE = 'https://c0upons.com'; + +export interface BbsCoupon { + id: number; + store: string; + storeSlug: string; + title: string; + code: string | null; + description: string | null; + discount: string | null; + expiry_date: string | null; +} + +/** The forums a coupon can land in, strongest signal first. Slugs are the board's. */ +const FORUMS: Array<{ slug: string; words: RegExp }> = [ + { + slug: 'grocery', + words: + /\b(grocer(y|ies)|supermarket|weekly ad|produce|snacks?|coffee|cereal|food|meal kits?|kroger|safeway|albertsons|aldi|publix|whole foods|trader joe'?s|instacart|hello ?fresh|heb|meijer|food lion|giant eagle|wegmans)\b/i, + }, + { + slug: 'tech-deals', + words: + /\b(tech|electronics?|laptops?|computers?|pcs?|phones?|iphone|android|tablets?|ipad|tvs?|monitors?|headphones?|earbuds|speakers?|gaming|xbox|playstation|ps5|nintendo|switch|ssd|gpus?|graphics card|vpn|software|apps?|smart ?home|camera|best ?buy|newegg|apple|samsung|dell|lenovo|logitech|anker)\b/i, + }, + { + slug: 'home-deals', + words: + /\b(home|garden|furniture|mattress(es)?|bedding|kitchen|cookware|appliances?|tools?|hardware|patio|lawn|decor|rugs?|harbor freight|home depot|lowe'?s|wayfair|ikea|overstock|bed bath)\b/i, + }, + { + slug: 'fashion-deals', + words: + /\b(clothing|clothes|apparel|fashion|shoes|sneakers|boots|dress(es)?|jeans|jackets?|handbags?|jewelry|watches|beauty|makeup|skin ?care|cosmetics|fragrance|perfume|hair ?care|nike|adidas|sephora|ulta|old navy|gap|h&m|zara|nordstrom|macy'?s)\b/i, + }, + { + slug: 'travel-deals', + words: + /\b(travel|flights?|airlines?|airfare|hotels?|resorts?|cruises?|car rentals?|vacations?|getaways?|expedia|booking\.com|priceline|hotels\.com|airbnb|vrbo|southwest|delta|united)\b/i, + }, +]; + +/** "Free shipping" is a perk, not a freebie. */ +const FREEBIE = /\b(free(?!\s+(shipping|delivery|returns|trial))|freebies?|giveaway|free samples?|on the house)\b/i; + +/** + * Which forum a coupon belongs in. + * + * Coupons and stores carry no category, so this reads the words. A freebie is a + * freebie wherever it is from; otherwise the topic forum that matches most, and + * failing that Coupons & Promo Codes for a code or Hot Deals for a sale. + */ +export function pickForum(c: Pick): string { + const text = `${c.store} ${c.title} ${c.description ?? ''}`; + if (FREEBIE.test(c.title) && !/\d+\s*%|\$\s*\d/.test(c.discount ?? '')) return 'freebies'; + let best: { slug: string; hits: number } | null = null; + for (const f of FORUMS) { + const hits = (text.match(new RegExp(f.words.source, 'gi')) ?? []).length; + if (hits && (!best || hits > best.hits)) best = { slug: f.slug, hits }; + } + if (best) return best.slug; + return c.code ? 'coupons' : 'hot-deals'; +} + +const clean = (s: string | null | undefined) => (s ?? '').replace(/\s+/g, ' ').trim(); + +/** The thread: a title a deal hunter scans for, and a body that sends them to the coupon. */ +export function topicFor(c: BbsCoupon): { title: string; body: string } { + const store = clean(c.store); + const title = clean(c.title); + const head = title.toLowerCase().includes(store.toLowerCase()) ? title : `${store}: ${title}`; + const withCode = c.code && !head.includes(c.code) ? `${head} (code ${c.code})` : head; + const lines = [ + `**Store:** [${store}](${SITE}/stores/${c.storeSlug})`, + c.code ? `**Code:** \`${c.code}\`` : '**Code:** none needed', + c.discount ? `**Deal:** ${clean(c.discount)}` : '', + c.expiry_date ? `**Expires:** ${clean(c.expiry_date).slice(0, 10)}` : '', + '', + clean(c.description) && clean(c.description) !== title ? clean(c.description).slice(0, 1000) : '', + '', + `**[Get this deal on c0upons](${SITE}/coupons/${c.id})**`, + '', + '_Found in a retailer email to submit@c0upons.com. Codes can expire or have limits; reply if it worked or not._', + ]; + return { + title: withCode.slice(0, 160), + body: lines.filter((l, i, all) => l !== '' || (all[i - 1] ?? '') !== '').join('\n').trim(), + }; +} + +export async function ensureBbsSchema(db: SqlDb): Promise { + await db.sql` + CREATE TABLE IF NOT EXISTS bbs_posts ( + coupon_id INTEGER PRIMARY KEY, + forum TEXT NOT NULL, + topic_url TEXT, + status TEXT NOT NULL, + error TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `; +} + +export interface BbsDeps { + fetch?: typeof fetch; + sleep?: (ms: number) => Promise; + /** Defaults to BBS_API_URL, else https://c0upons.com/bbs. */ + baseUrl?: string; + /** Defaults to BBS_POST_TOKEN. Without one nothing is posted. */ + token?: string; + /** Gap between two posts; tsbb's flood guard is 15 s by default. */ + gapMs?: number; +} + +export interface BbsPostResult { + coupon_id: number; + forum: string; + status: 'posted' | 'already' | 'failed' | 'disabled'; + url?: string; + error?: string; +} + +/** + * Post each coupon once, one at a time. + * + * Read config at call time, not module load: Next inlines process.env at build. + */ +export async function postCouponsToBbs(db: SqlDb, coupons: BbsCoupon[], deps: BbsDeps = {}): Promise { + const token = deps.token ?? process.env.BBS_POST_TOKEN ?? ''; + const base = (deps.baseUrl ?? process.env.BBS_API_URL ?? `${SITE}/bbs`).replace(/\/+$/, ''); + const doFetch = deps.fetch ?? fetch; + const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const gap = deps.gapMs ?? 16_000; + const out: BbsPostResult[] = []; + if (!coupons.length) return out; + if (!token) { + return coupons.map((c) => ({ coupon_id: c.id, forum: pickForum(c), status: 'disabled' as const, error: 'BBS_POST_TOKEN is not set' })); + } + await ensureBbsSchema(db); + + let last = 0; + for (const c of coupons) { + const forum = pickForum(c); + const prior = await db.sql`SELECT status, topic_url FROM bbs_posts WHERE coupon_id = ${c.id} LIMIT 1`; + if (prior.length && prior[0].status === 'posted') { + out.push({ coupon_id: c.id, forum, status: 'already', url: String(prior[0].topic_url ?? '') }); + continue; + } + const topic = topicFor(c); + let result: BbsPostResult = { coupon_id: c.id, forum, status: 'failed' }; + for (let attempt = 0; attempt < 2; attempt++) { + const wait = last + gap - Date.now(); + if (wait > 0) await sleep(wait); + let res: Response; + try { + res = await doFetch(`${base}/api/v1/forums/${encodeURIComponent(forum)}/topics`, { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ title: topic.title, body: topic.body, format: 'markdown' }), + }); + } catch (err) { + result = { coupon_id: c.id, forum, status: 'failed', error: String((err as Error)?.message ?? err).slice(0, 300) }; + break; + } + last = Date.now(); + const data = (await res.json().catch(() => ({}))) as { url?: string; error?: string; code?: string }; + if (res.ok && data.url) { + result = { coupon_id: c.id, forum, status: 'posted', url: `${base}${data.url}` }; + break; + } + const why = `${res.status} ${data.code ?? data.error ?? ''}`.trim(); + result = { coupon_id: c.id, forum, status: 'failed', error: why.slice(0, 300) }; + // The board's flood guard is the one refusal worth waiting out. + if (!(res.status === 400 && /flood/i.test(why))) break; + } + await db.sql` + INSERT INTO bbs_posts (coupon_id, forum, topic_url, status, error) + VALUES (${c.id}, ${forum}, ${result.url ?? null}, ${result.status}, ${result.error ?? null}) + ON CONFLICT(coupon_id) DO UPDATE SET forum = excluded.forum, topic_url = excluded.topic_url, + status = excluded.status, error = excluded.error + `; + out.push(result); + } + return out; +} diff --git a/apps/web/lib/inbound-email.ts b/apps/web/lib/inbound-email.ts index b775e9b..53c5011 100644 --- a/apps/web/lib/inbound-email.ts +++ b/apps/web/lib/inbound-email.ts @@ -44,6 +44,7 @@ */ import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; +import type { BbsCoupon } from './bbs-post.ts'; import { lookup } from 'node:dns/promises'; import { isIP } from 'node:net'; import { displayName, formatDiscount, upsertStore, upsertCoupon, ensureSyncSchema, type SqlDb } from './nichedb-sync.ts'; @@ -539,6 +540,8 @@ export interface InboundResult { message_key: string; engine?: Extraction['engine']; posted: Array<{ store: string; code: string | null; title: string }>; + /** The coupons this message created, with what a forum thread about each needs. */ + created: BbsCoupon[]; duplicates: Array<{ store: string; code: string | null }>; skipped: Array<{ title: string; reason: string }>; clicked: ClickResult[]; @@ -565,7 +568,7 @@ async function log(db: SqlDb, key: string, mail: InboundMail, outcome: Outcome, export async function handleInboundEmail(db: SqlDb, mail: InboundMail, deps: InboundDeps = {}): Promise { await ensureInboundSchema(db); const key = messageKey(mail); - const result: InboundResult = { ok: true, outcome: 'no-offer', message_key: key, posted: [], duplicates: [], skipped: [], clicked: [] }; + const result: InboundResult = { ok: true, outcome: 'no-offer', message_key: key, posted: [], created: [], duplicates: [], skipped: [], clicked: [] }; const prior = await db.sql`SELECT outcome FROM inbound_emails WHERE message_key = ${key} LIMIT 1`; if (prior.length && prior[0].outcome !== 'error') { @@ -619,10 +622,11 @@ export async function handleInboundEmail(db: SqlDb, mail: InboundMail, deps: Inb website: o.store_website, logo_url: site ? `https://www.google.com/s2/favicons?domain=${site}&sz=128` : null, }); + const sourceId = `${key}:${result.posted.length}`; await upsertCoupon(db, storeId, { store: { name, slug, website: o.store_website, logo_url: null }, source: SOURCE, - source_id: `${key}:${result.posted.length}`, + source_id: sourceId, code: o.code, title: o.title, description: o.description, @@ -635,6 +639,20 @@ export async function handleInboundEmail(db: SqlDb, mail: InboundMail, deps: Inb created_at: null, }); result.posted.push({ store: name, code: o.code, title: o.title }); + // upsertCoupon returns nothing; the row's id is what its page and its thread link to. + const row = await db.sql`SELECT id FROM coupons WHERE source = ${SOURCE} AND source_id = ${sourceId} LIMIT 1`; + if (row.length) { + result.created.push({ + id: Number(row[0].id), + store: displayName(name, slug), + storeSlug: slug, + title: o.title, + code: o.code, + description: o.description, + discount: formatDiscount(o.discount_type, o.discount_value), + expiry_date: o.expiry_date, + }); + } } result.outcome = result.posted.length ? 'posted' : result.duplicates.length ? 'duplicate' : 'no-offer'; await log(db, key, mail, result.outcome, extraction.engine, { diff --git a/ops/bbs/README.md b/ops/bbs/README.md index e8e1fb8..d906c54 100644 --- a/ops/bbs/README.md +++ b/ops/bbs/README.md @@ -22,3 +22,24 @@ docker cp ops/bbs/seed-forums.ts c0uponscom-bbs-app-1:/app/data/seed-forums.ts docker exec c0uponscom-bbs-app-1 node /app/data/seed-forums.ts docker restart c0uponscom-bbs-app-1 # settings are cached per process ``` + +## Email-intake coupons are posted here + +Every coupon that mail to submit@c0upons.com creates also gets a thread on the board +(`apps/web/lib/bbs-post.ts`, called from the email webhook after it answers): + +- **Forum:** read from the offer's words, since coupons carry no category: + - Freebies (a free item, never "free shipping") + - Grocery & Weekly Ads, Tech Deals, Home & Garden, Clothing & Beauty, Travel Deals + - otherwise Coupons & Promo Codes for a code, Hot Deals for a sale +- **Body:** store, code, deal and expiry, linking to the coupon's page. +- **Once only:** `bbs_posts` records each coupon, so a retried mail never posts twice. +- **Pacing:** posts are 16 s apart, because tsbb's flood guard refuses a second post inside 15 s. A `flooding` refusal waits and tries once more. +- **Account:** posts go out as board user `c0upons` (#4), with the `tsbb_` token in vault `c0upons--prod` as `BBS_POST_TOKEN`. Without it nothing is posted. `BBS_API_URL` overrides the board (default `https://c0upons.com/bbs`). + +To mint a new token (it reuses the user, and the old token keeps working until it is revoked in the board's settings): + +```sh +docker cp ops/bbs/mint-poster.ts c0uponscom-bbs-app-1:/app/data/mint-poster.ts +docker exec -e TSBB_CHECKOUT_DIR=/app/data/app c0uponscom-bbs-app-1 node /app/data/mint-poster.ts +``` diff --git a/ops/bbs/mint-poster.ts b/ops/bbs/mint-poster.ts new file mode 100644 index 0000000..42a12ef --- /dev/null +++ b/ops/bbs/mint-poster.ts @@ -0,0 +1,33 @@ +/** + * The account the site posts email-intake coupons as, and its API token. + * + * Runs inside the tsbb container, against the board's own database: + * + * docker cp ops/bbs/mint-poster.ts c0uponscom-bbs-app-1:/app/data/mint-poster.ts + * docker exec -e TSBB_CHECKOUT_DIR=/app/data/app c0uponscom-bbs-app-1 node /app/data/mint-poster.ts + * + * Prints a `tsbb_` token once; it goes into the c0upons vault as BBS_POST_TOKEN + * (the board keeps only its hash). Idempotent for the account: re-running + * reuses the user and mints a fresh token, which is how a lost one is replaced. + * TSBB_CHECKOUT_DIR points at the self-updated checkout (/app/data/app) when + * the board has updated past its image. + */ +const CHECKOUT = process.env.TSBB_CHECKOUT_DIR ?? '/app'; +const USERNAME = process.env.POSTER_USERNAME ?? 'c0upons'; +const EMAIL = process.env.POSTER_EMAIL ?? 'deals@c0upons.com'; + +const { migrate } = await import(`${CHECKOUT}/packages/db/src/migrate.ts`); +const { one } = await import(`${CHECKOUT}/packages/db/src/client.ts`); +const { createUser } = await import(`${CHECKOUT}/packages/core/src/users.ts`); +const { mintToken } = await import(`${CHECKOUT}/packages/core/src/auth.ts`); + +await migrate(undefined, { quiet: true }); + +const existing = (await one('SELECT id FROM users WHERE username_lower = ?', [USERNAME.toLowerCase()])) as + | { id: number } + | undefined; +const userId = existing?.id ?? (await createUser({ username: USERNAME, email: EMAIL })).id; +const token = await mintToken({ userId, label: 'c0upons.com email intake' }); + +console.error(`${existing ? 'reused' : 'created'} user ${USERNAME} (#${userId}); token below is shown once`); +console.log(token); diff --git a/test/bbs-post.test.mjs b/test/bbs-post.test.mjs new file mode 100644 index 0000000..20ae7a4 --- /dev/null +++ b/test/bbs-post.test.mjs @@ -0,0 +1,168 @@ +// Coupons the email intake creates are posted to c0upons.com/bbs. What has to +// hold: each lands in the forum that fits it; the thread links back to the +// coupon; a coupon is posted once however often the mail is retried; the board's +// flood guard is waited out rather than lost; no token means nothing is posted; +// and the intake hands over exactly the coupons it created, with their ids. +import { test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(new URL('../apps/web/package.json', import.meta.url)); +const { createClient } = await import(require.resolve('@libsql/client')); +const { pickForum, topicFor, postCouponsToBbs } = await import('../apps/web/lib/bbs-post.ts'); +const { handleInboundEmail } = await import('../apps/web/lib/inbound-email.ts'); + +const dir = mkdtempSync(join(tmpdir(), 'c0upons-bbs-')); +const url = `file:${join(dir, 'local.db')}`; +let client; +const db = { + sql: async (strings, ...values) => { + const rs = await client.execute({ sql: strings.join('?'), args: values.map((v) => (v === undefined ? null : v)) }); + return rs.rows; + }, +}; + +before(() => { + execFileSync('node', ['apps/web/scripts/migrate.mjs'], { + cwd: new URL('..', import.meta.url), + env: { ...process.env, TURSO_DATABASE_URL: url, TURSO_AUTH_TOKEN: '' }, + stdio: 'pipe', + }); + client = createClient({ url }); +}); + +after(() => { + client?.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +const coupon = (over = {}) => ({ + id: 42, + store: 'Bookshop.org', + storeSlug: 'bookshop-org', + title: '20% off banned books', + code: 'BBW26', + description: 'Take 20% off banned books through October 10.', + discount: '20% off', + expiry_date: '2026-10-10', + ...over, +}); + +/** A tsbb board that records every topic and can refuse with the flood guard. */ +function board({ floodFirst = false } = {}) { + const calls = []; + let flooded = !floodFirst; + const fetch = async (u, init) => { + calls.push({ url: String(u), auth: init.headers.authorization, body: JSON.parse(init.body) }); + if (!flooded) { + flooded = true; + return new Response(JSON.stringify({ error: 'flooding', code: 'flooding' }), { status: 400 }); + } + return new Response(JSON.stringify({ id: calls.length, slug: 't', url: `/t/t-${calls.length}` }), { status: 201 }); + }; + return { calls, fetch }; +} + +const noSleep = async () => {}; + +test('each coupon goes to the forum that fits it', () => { + assert.equal(pickForum(coupon()), 'coupons'); + assert.equal(pickForum(coupon({ code: null, title: 'Spring sale up to 50% off' })), 'hot-deals'); + assert.equal(pickForum(coupon({ store: 'Kroger', title: '$5 off your grocery order', code: 'SAVE5' })), 'grocery'); + assert.equal(pickForum(coupon({ store: 'Best Buy', title: '$100 off laptops' })), 'tech-deals'); + assert.equal(pickForum(coupon({ store: 'Harbor Freight', title: '25% off one hand tool' })), 'home-deals'); + assert.equal(pickForum(coupon({ store: 'Sephora', title: '15% off makeup and skincare' })), 'fashion-deals'); + assert.equal(pickForum(coupon({ store: 'Expedia', title: '10% off hotels' })), 'travel-deals'); + assert.equal(pickForum(coupon({ store: 'Wendys', title: 'Free Frosty with any purchase', discount: null, code: null })), 'freebies'); + // Free shipping is a perk, not a freebie. + assert.equal(pickForum(coupon({ title: '20% off plus free shipping' })), 'coupons'); +}); + +test('the thread names the store and code and links back to the coupon', () => { + const t = topicFor(coupon()); + assert.equal(t.title, 'Bookshop.org: 20% off banned books (code BBW26)'); + assert.match(t.body, /\*\*Code:\*\* `BBW26`/); + assert.match(t.body, /\(https:\/\/c0upons\.com\/stores\/bookshop-org\)/); + assert.match(t.body, /\(https:\/\/c0upons\.com\/coupons\/42\)/); + assert.ok(!t.body.includes('\n\n\n')); + assert.ok(topicFor(coupon({ title: 'x'.repeat(300) })).title.length <= 160); +}); + +test('a coupon is posted once, to the board, with the bearer token', async () => { + const b = board(); + const first = await postCouponsToBbs(db, [coupon({ id: 101 })], { fetch: b.fetch, token: 'tsbb_x', baseUrl: 'https://c0upons.com/bbs', sleep: noSleep }); + assert.deepEqual(first.map((r) => r.status), ['posted']); + assert.equal(first[0].url, 'https://c0upons.com/bbs/t/t-1'); + assert.equal(b.calls[0].url, 'https://c0upons.com/bbs/api/v1/forums/coupons/topics'); + assert.equal(b.calls[0].auth, 'Bearer tsbb_x'); + assert.equal(b.calls[0].body.format, 'markdown'); + + const again = await postCouponsToBbs(db, [coupon({ id: 101 })], { fetch: b.fetch, token: 'tsbb_x', sleep: noSleep }); + assert.deepEqual(again.map((r) => r.status), ['already']); + assert.equal(b.calls.length, 1); +}); + +test('the flood guard is waited out, and posts are spaced', async () => { + const b = board({ floodFirst: true }); + const waits = []; + const out = await postCouponsToBbs(db, [coupon({ id: 201 }), coupon({ id: 202, store: 'Kroger', title: '$5 off groceries' })], { + fetch: b.fetch, + token: 'tsbb_x', + gapMs: 16_000, + sleep: async (ms) => waits.push(ms), + }); + assert.deepEqual(out.map((r) => r.status), ['posted', 'posted']); + assert.equal(b.calls.length, 3); // one refused, retried, then the second coupon + assert.ok(waits.length >= 2 && waits.every((ms) => ms > 0 && ms <= 16_000)); + assert.equal(out[1].forum, 'grocery'); +}); + +test('no token: nothing is posted and nothing is recorded', async () => { + const b = board(); + const out = await postCouponsToBbs(db, [coupon({ id: 301 })], { fetch: b.fetch, token: '', sleep: noSleep }); + assert.equal(out[0].status, 'disabled'); + assert.equal(b.calls.length, 0); + const rows = await db.sql`SELECT * FROM bbs_posts WHERE coupon_id = ${301}`.catch(() => []); + assert.equal(rows.length, 0); +}); + +test('a refusal other than flooding is recorded as failed, not retried', async () => { + const calls = []; + const fetch = async (u) => { + calls.push(u); + return new Response(JSON.stringify({ error: 'forbidden' }), { status: 403 }); + }; + const out = await postCouponsToBbs(db, [coupon({ id: 401 })], { fetch, token: 'tsbb_x', sleep: noSleep }); + assert.equal(out[0].status, 'failed'); + assert.match(out[0].error, /403/); + assert.equal(calls.length, 1); + const [row] = await db.sql`SELECT status FROM bbs_posts WHERE coupon_id = ${401}`; + assert.equal(row.status, 'failed'); +}); + +test('the intake hands over the coupons it created, with their ids', async () => { + const PASS = { dkim: { results: [{ status: { result: 'pass' }, signingDomain: 'e.bookshop.org' }] }, spf: { status: { result: 'pass' } } }; + const res = await handleInboundEmail( + db, + { + from: { value: [{ address: 'news@e.bookshop.org', name: 'Bookshop.org' }], text: 'Bookshop.org ' }, + to: { value: [{ address: 'submit@c0upons.com' }] }, + subject: 'Autumn reads: 15% off with code FALL15', + text: 'Take 15% off everything with code FALL15 through October 31. Shop now: https://bookshop.org/fall', + html: '

Take 15% off with code FALL15.

Shop now', + messageId: `<${Math.random().toString(36).slice(2)}@e.bookshop.org>`, + ...PASS, + }, + { model: null, resolve: async () => ['93.184.216.34'] }, + ); + assert.equal(res.outcome, 'posted'); + assert.equal(res.created.length, 1); + const [row] = await db.sql`SELECT id, code FROM coupons WHERE id = ${res.created[0].id}`; + assert.equal(row.code, 'FALL15'); + assert.equal(res.created[0].storeSlug.length > 0, true); + assert.equal(pickForum(res.created[0]), 'coupons'); +});