Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/web/app/api/webhooks/email/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
205 changes: 205 additions & 0 deletions apps/web/lib/bbs-post.ts
Original file line number Diff line number Diff line change
@@ -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/<slug>/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<BbsCoupon, 'store' | 'title' | 'description' | 'code' | 'discount'>): 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<void> {
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<void>;
/** 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<BbsPostResult[]> {
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<void>((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;
}
22 changes: 20 additions & 2 deletions apps/web/lib/inbound-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -539,6 +540,8 @@
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[];
Expand All @@ -565,7 +568,7 @@
export async function handleInboundEmail(db: SqlDb, mail: InboundMail, deps: InboundDeps = {}): Promise<InboundResult> {
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') {
Expand Down Expand Up @@ -619,10 +622,11 @@
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,
Expand All @@ -635,6 +639,20 @@
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, {
Expand Down
21 changes: 21 additions & 0 deletions ops/bbs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
33 changes: 33 additions & 0 deletions ops/bbs/mint-poster.ts
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading