Skip to content

Commit 1bb70a8

Browse files
ralyodioclaude
andauthored
Post email-intake coupons to the c0upons.com/bbs forums (#82)
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/<slug>/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 <noreply@anthropic.com>
1 parent 2b3998f commit 1bb70a8

6 files changed

Lines changed: 458 additions & 2 deletions

File tree

‎apps/web/app/api/webhooks/email/route.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
22
import Anthropic from '@anthropic-ai/sdk';
33
import { getDb } from '@/lib/db';
44
import { loadRootEnv } from '@/lib/root-env';
5+
import { postCouponsToBbs } from '@/lib/bbs-post';
56
import {
67
MODEL,
78
handleInboundEmail,
@@ -97,6 +98,16 @@ export async function POST(req: NextRequest) {
9798
console.log(
9899
`inbound email: ${result.outcome} from=${mail.from?.value?.[0]?.address ?? '?'} posted=${result.posted.length}`,
99100
);
101+
// Each new coupon also gets a thread on c0upons.com/bbs. Not awaited: the
102+
// board's flood guard spaces posts 16 s apart, and Forward Email should not
103+
// wait on that. This server is long-lived (dev2), so the promise finishes.
104+
if (result.created.length) {
105+
void postCouponsToBbs(getDb(), result.created)
106+
.then((posts) => {
107+
for (const p of posts) console.log(`bbs post: coupon ${p.coupon_id} -> ${p.forum} ${p.status} ${p.url ?? p.error ?? ''}`);
108+
})
109+
.catch((err) => console.error('bbs post failed:', err));
110+
}
100111
return NextResponse.json(result);
101112
} catch (err) {
102113
console.error('inbound email failed:', err);

‎apps/web/lib/bbs-post.ts‎

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/**
2+
* Coupons from the email intake, posted to the c0upons.com/bbs forums.
3+
*
4+
* Every coupon submit@c0upons.com turns into gets a thread in the forum that
5+
* fits it (Grocery & Weekly Ads, Tech Deals, Freebies...), with a link back to
6+
* its page here. Only the email intake does this: the nichedb, Flipp and Reddit
7+
* syncs bring in hundreds at a time, and a forum that is a mirror of the site
8+
* is a forum nobody reads.
9+
*
10+
* The board is tsbb, which takes `POST /api/v1/forums/<slug>/topics` with a
11+
* `tsbb_` bearer token minted for the poster account. Its flood guard refuses a
12+
* second post by the same account inside `posts.floodSeconds` (15 by default),
13+
* and one email can carry several offers, so posts go one at a time with a gap,
14+
* and a `flooding` answer waits and tries once more.
15+
*
16+
* `bbs_posts` remembers what was posted, so a coupon is never posted twice,
17+
* whatever retries upstream.
18+
*/
19+
import type { SqlDb } from './nichedb-sync.ts';
20+
21+
export const SITE = 'https://c0upons.com';
22+
23+
export interface BbsCoupon {
24+
id: number;
25+
store: string;
26+
storeSlug: string;
27+
title: string;
28+
code: string | null;
29+
description: string | null;
30+
discount: string | null;
31+
expiry_date: string | null;
32+
}
33+
34+
/** The forums a coupon can land in, strongest signal first. Slugs are the board's. */
35+
const FORUMS: Array<{ slug: string; words: RegExp }> = [
36+
{
37+
slug: 'grocery',
38+
words:
39+
/\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,
40+
},
41+
{
42+
slug: 'tech-deals',
43+
words:
44+
/\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,
45+
},
46+
{
47+
slug: 'home-deals',
48+
words:
49+
/\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,
50+
},
51+
{
52+
slug: 'fashion-deals',
53+
words:
54+
/\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,
55+
},
56+
{
57+
slug: 'travel-deals',
58+
words:
59+
/\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,
60+
},
61+
];
62+
63+
/** "Free shipping" is a perk, not a freebie. */
64+
const FREEBIE = /\b(free(?!\s+(shipping|delivery|returns|trial))|freebies?|giveaway|free samples?|on the house)\b/i;
65+
66+
/**
67+
* Which forum a coupon belongs in.
68+
*
69+
* Coupons and stores carry no category, so this reads the words. A freebie is a
70+
* freebie wherever it is from; otherwise the topic forum that matches most, and
71+
* failing that Coupons & Promo Codes for a code or Hot Deals for a sale.
72+
*/
73+
export function pickForum(c: Pick<BbsCoupon, 'store' | 'title' | 'description' | 'code' | 'discount'>): string {
74+
const text = `${c.store} ${c.title} ${c.description ?? ''}`;
75+
if (FREEBIE.test(c.title) && !/\d+\s*%|\$\s*\d/.test(c.discount ?? '')) return 'freebies';
76+
let best: { slug: string; hits: number } | null = null;
77+
for (const f of FORUMS) {
78+
const hits = (text.match(new RegExp(f.words.source, 'gi')) ?? []).length;
79+
if (hits && (!best || hits > best.hits)) best = { slug: f.slug, hits };
80+
}
81+
if (best) return best.slug;
82+
return c.code ? 'coupons' : 'hot-deals';
83+
}
84+
85+
const clean = (s: string | null | undefined) => (s ?? '').replace(/\s+/g, ' ').trim();
86+
87+
/** The thread: a title a deal hunter scans for, and a body that sends them to the coupon. */
88+
export function topicFor(c: BbsCoupon): { title: string; body: string } {
89+
const store = clean(c.store);
90+
const title = clean(c.title);
91+
const head = title.toLowerCase().includes(store.toLowerCase()) ? title : `${store}: ${title}`;
92+
const withCode = c.code && !head.includes(c.code) ? `${head} (code ${c.code})` : head;
93+
const lines = [
94+
`**Store:** [${store}](${SITE}/stores/${c.storeSlug})`,
95+
c.code ? `**Code:** \`${c.code}\`` : '**Code:** none needed',
96+
c.discount ? `**Deal:** ${clean(c.discount)}` : '',
97+
c.expiry_date ? `**Expires:** ${clean(c.expiry_date).slice(0, 10)}` : '',
98+
'',
99+
clean(c.description) && clean(c.description) !== title ? clean(c.description).slice(0, 1000) : '',
100+
'',
101+
`**[Get this deal on c0upons](${SITE}/coupons/${c.id})**`,
102+
'',
103+
'_Found in a retailer email to submit@c0upons.com. Codes can expire or have limits; reply if it worked or not._',
104+
];
105+
return {
106+
title: withCode.slice(0, 160),
107+
body: lines.filter((l, i, all) => l !== '' || (all[i - 1] ?? '') !== '').join('\n').trim(),
108+
};
109+
}
110+
111+
export async function ensureBbsSchema(db: SqlDb): Promise<void> {
112+
await db.sql`
113+
CREATE TABLE IF NOT EXISTS bbs_posts (
114+
coupon_id INTEGER PRIMARY KEY,
115+
forum TEXT NOT NULL,
116+
topic_url TEXT,
117+
status TEXT NOT NULL,
118+
error TEXT,
119+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
120+
)
121+
`;
122+
}
123+
124+
export interface BbsDeps {
125+
fetch?: typeof fetch;
126+
sleep?: (ms: number) => Promise<void>;
127+
/** Defaults to BBS_API_URL, else https://c0upons.com/bbs. */
128+
baseUrl?: string;
129+
/** Defaults to BBS_POST_TOKEN. Without one nothing is posted. */
130+
token?: string;
131+
/** Gap between two posts; tsbb's flood guard is 15 s by default. */
132+
gapMs?: number;
133+
}
134+
135+
export interface BbsPostResult {
136+
coupon_id: number;
137+
forum: string;
138+
status: 'posted' | 'already' | 'failed' | 'disabled';
139+
url?: string;
140+
error?: string;
141+
}
142+
143+
/**
144+
* Post each coupon once, one at a time.
145+
*
146+
* Read config at call time, not module load: Next inlines process.env at build.
147+
*/
148+
export async function postCouponsToBbs(db: SqlDb, coupons: BbsCoupon[], deps: BbsDeps = {}): Promise<BbsPostResult[]> {
149+
const token = deps.token ?? process.env.BBS_POST_TOKEN ?? '';
150+
const base = (deps.baseUrl ?? process.env.BBS_API_URL ?? `${SITE}/bbs`).replace(/\/+$/, '');
151+
const doFetch = deps.fetch ?? fetch;
152+
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
153+
const gap = deps.gapMs ?? 16_000;
154+
const out: BbsPostResult[] = [];
155+
if (!coupons.length) return out;
156+
if (!token) {
157+
return coupons.map((c) => ({ coupon_id: c.id, forum: pickForum(c), status: 'disabled' as const, error: 'BBS_POST_TOKEN is not set' }));
158+
}
159+
await ensureBbsSchema(db);
160+
161+
let last = 0;
162+
for (const c of coupons) {
163+
const forum = pickForum(c);
164+
const prior = await db.sql`SELECT status, topic_url FROM bbs_posts WHERE coupon_id = ${c.id} LIMIT 1`;
165+
if (prior.length && prior[0].status === 'posted') {
166+
out.push({ coupon_id: c.id, forum, status: 'already', url: String(prior[0].topic_url ?? '') });
167+
continue;
168+
}
169+
const topic = topicFor(c);
170+
let result: BbsPostResult = { coupon_id: c.id, forum, status: 'failed' };
171+
for (let attempt = 0; attempt < 2; attempt++) {
172+
const wait = last + gap - Date.now();
173+
if (wait > 0) await sleep(wait);
174+
let res: Response;
175+
try {
176+
res = await doFetch(`${base}/api/v1/forums/${encodeURIComponent(forum)}/topics`, {
177+
method: 'POST',
178+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
179+
body: JSON.stringify({ title: topic.title, body: topic.body, format: 'markdown' }),
180+
});
181+
} catch (err) {
182+
result = { coupon_id: c.id, forum, status: 'failed', error: String((err as Error)?.message ?? err).slice(0, 300) };
183+
break;
184+
}
185+
last = Date.now();
186+
const data = (await res.json().catch(() => ({}))) as { url?: string; error?: string; code?: string };
187+
if (res.ok && data.url) {
188+
result = { coupon_id: c.id, forum, status: 'posted', url: `${base}${data.url}` };
189+
break;
190+
}
191+
const why = `${res.status} ${data.code ?? data.error ?? ''}`.trim();
192+
result = { coupon_id: c.id, forum, status: 'failed', error: why.slice(0, 300) };
193+
// The board's flood guard is the one refusal worth waiting out.
194+
if (!(res.status === 400 && /flood/i.test(why))) break;
195+
}
196+
await db.sql`
197+
INSERT INTO bbs_posts (coupon_id, forum, topic_url, status, error)
198+
VALUES (${c.id}, ${forum}, ${result.url ?? null}, ${result.status}, ${result.error ?? null})
199+
ON CONFLICT(coupon_id) DO UPDATE SET forum = excluded.forum, topic_url = excluded.topic_url,
200+
status = excluded.status, error = excluded.error
201+
`;
202+
out.push(result);
203+
}
204+
return out;
205+
}

‎apps/web/lib/inbound-email.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
*/
4545

4646
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
47+
import type { BbsCoupon } from './bbs-post.ts';
4748
import { lookup } from 'node:dns/promises';
4849
import { isIP } from 'node:net';
4950
import { displayName, formatDiscount, upsertStore, upsertCoupon, ensureSyncSchema, type SqlDb } from './nichedb-sync.ts';
@@ -539,6 +540,8 @@ export interface InboundResult {
539540
message_key: string;
540541
engine?: Extraction['engine'];
541542
posted: Array<{ store: string; code: string | null; title: string }>;
543+
/** The coupons this message created, with what a forum thread about each needs. */
544+
created: BbsCoupon[];
542545
duplicates: Array<{ store: string; code: string | null }>;
543546
skipped: Array<{ title: string; reason: string }>;
544547
clicked: ClickResult[];
@@ -565,7 +568,7 @@ async function log(db: SqlDb, key: string, mail: InboundMail, outcome: Outcome,
565568
export async function handleInboundEmail(db: SqlDb, mail: InboundMail, deps: InboundDeps = {}): Promise<InboundResult> {
566569
await ensureInboundSchema(db);
567570
const key = messageKey(mail);
568-
const result: InboundResult = { ok: true, outcome: 'no-offer', message_key: key, posted: [], duplicates: [], skipped: [], clicked: [] };
571+
const result: InboundResult = { ok: true, outcome: 'no-offer', message_key: key, posted: [], created: [], duplicates: [], skipped: [], clicked: [] };
569572

570573
const prior = await db.sql`SELECT outcome FROM inbound_emails WHERE message_key = ${key} LIMIT 1`;
571574
if (prior.length && prior[0].outcome !== 'error') {
@@ -619,10 +622,11 @@ export async function handleInboundEmail(db: SqlDb, mail: InboundMail, deps: Inb
619622
website: o.store_website,
620623
logo_url: site ? `https://www.google.com/s2/favicons?domain=${site}&sz=128` : null,
621624
});
625+
const sourceId = `${key}:${result.posted.length}`;
622626
await upsertCoupon(db, storeId, {
623627
store: { name, slug, website: o.store_website, logo_url: null },
624628
source: SOURCE,
625-
source_id: `${key}:${result.posted.length}`,
629+
source_id: sourceId,
626630
code: o.code,
627631
title: o.title,
628632
description: o.description,
@@ -635,6 +639,20 @@ export async function handleInboundEmail(db: SqlDb, mail: InboundMail, deps: Inb
635639
created_at: null,
636640
});
637641
result.posted.push({ store: name, code: o.code, title: o.title });
642+
// upsertCoupon returns nothing; the row's id is what its page and its thread link to.
643+
const row = await db.sql`SELECT id FROM coupons WHERE source = ${SOURCE} AND source_id = ${sourceId} LIMIT 1`;
644+
if (row.length) {
645+
result.created.push({
646+
id: Number(row[0].id),
647+
store: displayName(name, slug),
648+
storeSlug: slug,
649+
title: o.title,
650+
code: o.code,
651+
description: o.description,
652+
discount: formatDiscount(o.discount_type, o.discount_value),
653+
expiry_date: o.expiry_date,
654+
});
655+
}
638656
}
639657
result.outcome = result.posted.length ? 'posted' : result.duplicates.length ? 'duplicate' : 'no-offer';
640658
await log(db, key, mail, result.outcome, extraction.engine, {

‎ops/bbs/README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,24 @@ docker cp ops/bbs/seed-forums.ts c0uponscom-bbs-app-1:/app/data/seed-forums.ts
2222
docker exec c0uponscom-bbs-app-1 node /app/data/seed-forums.ts
2323
docker restart c0uponscom-bbs-app-1 # settings are cached per process
2424
```
25+
26+
## Email-intake coupons are posted here
27+
28+
Every coupon that mail to submit@c0upons.com creates also gets a thread on the board
29+
(`apps/web/lib/bbs-post.ts`, called from the email webhook after it answers):
30+
31+
- **Forum:** read from the offer's words, since coupons carry no category:
32+
- Freebies (a free item, never "free shipping")
33+
- Grocery & Weekly Ads, Tech Deals, Home & Garden, Clothing & Beauty, Travel Deals
34+
- otherwise Coupons & Promo Codes for a code, Hot Deals for a sale
35+
- **Body:** store, code, deal and expiry, linking to the coupon's page.
36+
- **Once only:** `bbs_posts` records each coupon, so a retried mail never posts twice.
37+
- **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.
38+
- **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`).
39+
40+
To mint a new token (it reuses the user, and the old token keeps working until it is revoked in the board's settings):
41+
42+
```sh
43+
docker cp ops/bbs/mint-poster.ts c0uponscom-bbs-app-1:/app/data/mint-poster.ts
44+
docker exec -e TSBB_CHECKOUT_DIR=/app/data/app c0uponscom-bbs-app-1 node /app/data/mint-poster.ts
45+
```

‎ops/bbs/mint-poster.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* The account the site posts email-intake coupons as, and its API token.
3+
*
4+
* Runs inside the tsbb container, against the board's own database:
5+
*
6+
* docker cp ops/bbs/mint-poster.ts c0uponscom-bbs-app-1:/app/data/mint-poster.ts
7+
* docker exec -e TSBB_CHECKOUT_DIR=/app/data/app c0uponscom-bbs-app-1 node /app/data/mint-poster.ts
8+
*
9+
* Prints a `tsbb_` token once; it goes into the c0upons vault as BBS_POST_TOKEN
10+
* (the board keeps only its hash). Idempotent for the account: re-running
11+
* reuses the user and mints a fresh token, which is how a lost one is replaced.
12+
* TSBB_CHECKOUT_DIR points at the self-updated checkout (/app/data/app) when
13+
* the board has updated past its image.
14+
*/
15+
const CHECKOUT = process.env.TSBB_CHECKOUT_DIR ?? '/app';
16+
const USERNAME = process.env.POSTER_USERNAME ?? 'c0upons';
17+
const EMAIL = process.env.POSTER_EMAIL ?? 'deals@c0upons.com';
18+
19+
const { migrate } = await import(`${CHECKOUT}/packages/db/src/migrate.ts`);
20+
const { one } = await import(`${CHECKOUT}/packages/db/src/client.ts`);
21+
const { createUser } = await import(`${CHECKOUT}/packages/core/src/users.ts`);
22+
const { mintToken } = await import(`${CHECKOUT}/packages/core/src/auth.ts`);
23+
24+
await migrate(undefined, { quiet: true });
25+
26+
const existing = (await one('SELECT id FROM users WHERE username_lower = ?', [USERNAME.toLowerCase()])) as
27+
| { id: number }
28+
| undefined;
29+
const userId = existing?.id ?? (await createUser({ username: USERNAME, email: EMAIL })).id;
30+
const token = await mintToken({ userId, label: 'c0upons.com email intake' });
31+
32+
console.error(`${existing ? 'reused' : 'created'} user ${USERNAME} (#${userId}); token below is shown once`);
33+
console.log(token);

0 commit comments

Comments
 (0)