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
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@c0upons/cli",
"version": "1.8.4",
"version": "1.9.0",
"description": "c0upons CLI — search, submit, and manage coupons from the terminal",
"type": "module",
"bin": {
Expand Down
40 changes: 40 additions & 0 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,4 +211,44 @@ program
}
});

program
.command('inbox')
.description('What coupons@profullstack.com received and what became of each message (needs C0UPONS_INBOUND_SECRET)')
.option('-l, --limit <n>', 'How many, newest first', '25')
.option('--json', 'Raw JSON')
.action(async (opts: { limit: string; json?: boolean }) => {
const secret = process.env.C0UPONS_INBOUND_SECRET;
if (!secret) {
console.error(chalk.red('Set C0UPONS_INBOUND_SECRET (INBOUND_EMAIL_SECRET on the server).'));
process.exit(1);
}
const res = await fetch(`${BASE_URL}/api/webhooks/email?limit=${encodeURIComponent(opts.limit)}`, {
headers: { authorization: `Bearer ${secret}` },
});
if (!res.ok) {
console.error(chalk.red(`Failed: ${res.status} ${res.statusText}`));
process.exit(1);
}
const rows = (await res.json()) as Array<{
received_at: string;
outcome: string;
from_addr: string | null;
subject: string | null;
detail: string | null;
}>;
if (opts.json) {
console.log(JSON.stringify(rows, null, 2));
return;
}
for (const r of rows) {
const colour = r.outcome === 'posted' || r.outcome === 'confirmed' ? chalk.green : r.outcome === 'error' ? chalk.red : chalk.gray;
console.log(`${chalk.dim(r.received_at)} ${colour(r.outcome.padEnd(17))} ${r.from_addr ?? '?'} ${r.subject ?? ''}`);
if (r.outcome === 'posted' && r.detail) {
for (const p of (JSON.parse(r.detail).posted ?? []) as Array<{ store: string; code: string | null; title: string }>) {
console.log(` ${chalk.cyan(p.store)} ${p.code ? chalk.bold(p.code) : '(no code)'} ${p.title}`);
}
}
}
});

program.parse();
112 changes: 112 additions & 0 deletions apps/web/app/api/webhooks/email/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { NextRequest, NextResponse } from 'next/server';
import Anthropic from '@anthropic-ai/sdk';
import { getDb } from '@/lib/db';
import { loadRootEnv } from '@/lib/root-env';
import {
MODEL,
handleInboundEmail,
recentInbound,
safeEqual,
verifySignature,
type InboundMail,
type JsonModel,
} from '@/lib/inbound-email';

export const dynamic = 'force-dynamic';
export const maxDuration = 120;

/**
* coupons@profullstack.com, delivered by Forward Email.
*
* The alias's recipient is this URL with `?key=<INBOUND_EMAIL_SECRET>`.
* When FORWARDEMAIL_WEBHOOK_KEY (the account's "Webhook Signature Payload
* Verification Key") is set, a valid `X-Webhook-Signature` is accepted too.
* Anything else is a 401: the body decides what gets posted to the site.
*
* A handled message, posted or skipped, answers 200 so Forward Email stops
* retrying; only a failure answers 500, and the retry is safe because a
* message is processed once by its Message-ID.
*
* GET with `Authorization: Bearer <INBOUND_EMAIL_SECRET>` lists the log
* (`?limit=`, newest first); `c0upons inbox` reads it.
*/

loadRootEnv();

function keyOk(req: NextRequest): boolean {
const secret = process.env.INBOUND_EMAIL_SECRET;
if (!secret) return false;
const auth = req.headers.get('authorization') ?? '';
const candidates = [
new URL(req.url).searchParams.get('key') ?? '',
auth.startsWith('Bearer ') ? auth.slice(7).trim() : '',
];
return candidates.some((c) => c && safeEqual(c, secret));
}

let anthropic: Anthropic | null = null;
let modelBlockedUntil = 0;

function jsonModel(): JsonModel | null {
const key = process.env.ANTHROPIC_API_KEY;
if (!key || Date.now() < modelBlockedUntil) return null;
anthropic ??= new Anthropic({ apiKey: key, timeout: 60_000 });
const client = anthropic;
return async (prompt, schema) => {
try {
const res = await client.messages.create({
model: MODEL,
max_tokens: 2048,
output_config: { format: { type: 'json_schema', schema: schema as Record<string, unknown> } },
messages: [{ role: 'user', content: prompt }],
});
const text = res.content.find((b) => b.type === 'text');
if (!text || text.type !== 'text') throw new Error('model returned no text');
return JSON.parse(text.text);
} catch (err) {
// Usage cap, rate limit or a dead key: use the rules for an hour.
if (
err instanceof Anthropic.RateLimitError ||
err instanceof Anthropic.AuthenticationError ||
(err instanceof Anthropic.BadRequestError && /usage limits|credit/i.test(err.message))
) {
modelBlockedUntil = Date.now() + 60 * 60_000;
}
throw err;
}
};
}

export async function POST(req: NextRequest) {
const raw = await req.text();
const signed = verifySignature(raw, req.headers.get('x-webhook-signature'), process.env.FORWARDEMAIL_WEBHOOK_KEY);
if (!signed && !keyOk(req)) {
console.error('inbound email: unauthorized');
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}

let mail: InboundMail;
try {
mail = JSON.parse(raw) as InboundMail;
} catch {
return NextResponse.json({ error: 'body is not JSON' }, { status: 400 });
}

try {
const result = await handleInboundEmail(getDb(), mail, { model: jsonModel() });
console.log(
`inbound email: ${result.outcome} from=${mail.from?.value?.[0]?.address ?? '?'} posted=${result.posted.length}`,
);
return NextResponse.json(result);
} catch (err) {
console.error('inbound email failed:', err);
return NextResponse.json({ error: 'failed to process message' }, { status: 500 });
}
}

export async function GET(req: NextRequest) {
if (!keyOk(req)) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
const limit = Number(new URL(req.url).searchParams.get('limit') ?? 50);
const rows = await recentInbound(getDb(), limit);
return NextResponse.json(rows, { headers: { 'Cache-Control': 'no-store' } });
}
Loading
Loading