Feat/spam #5

Merged
iamdoubz merged 7 commits from feat/spam into main 2026-10-27 18:35:05 -05:00
11 changed files with 406 additions and 19 deletions
Showing only changes of commit 49d529e185 - Show all commits

No files matched your search

+12 -1
View File
@@ -32,6 +32,7 @@ import { checkDirectory } from './sso/directory.ts';
import { checkOidcSessions } from './sso/oidcCheck.ts';
import { logEvent } from './services/events.ts';
import { spamQuotaCheck } from './spam/check.ts';
import { ftcSync } from './spam/ftc.ts';
import { deliverDue } from './services/webhooks.ts';
import type { SendMail } from './services/mail.ts';
import { getSetting, setSetting } from './services/settings.ts';
@@ -44,7 +45,11 @@ export type JobDeps = {
generator: Pick<Generator, 'apply'>;
voicemail: { scan(ext: string): Promise<void> };
send: SendMail;
log: { info(o: object, m: string): void; error(o: object, m: string): void };
log: {
info(o: object, m: string): void;
warn(o: object, m: string): void;
error(o: object, m: string): void;
};
now?: () => Date;
statfs?: typeof statfs;
};
@@ -279,6 +284,11 @@ export function createJobs(d: JobDeps) {
return {
spamQuotaCheck: () => spamQuotaCheck(d.db),
/** 14 §2.2: a failure is logged when it changes, and shown on Settings → Spam check. */
spamFtcSync: async () => {
const reason = await ftcSync(d.db, { now: now() });
if (reason) d.log.warn({ provider: 'ftc', reason }, 'FTC complaint download failed');
},
oidcCheck,
directoryCheck,
daily,
@@ -317,6 +327,7 @@ export function startJobs(d: JobDeps): () => void {
if (minute % 5 === 0) run('oidcCheck', j.oidcCheck);
if (minute % 60 === 0) run('directoryCheck', j.directoryCheck);
if (minute % 360 === 0) run('spamQuotaCheck', j.spamQuotaCheck);
run('spamFtcSync', j.spamFtcSync);
run('callTranscripts', async () => {
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
});
+7
View File
@@ -576,6 +576,13 @@ export async function settingsRoutes(app: FastifyInstance) {
return { items };
});
/** Settings → Spam check → Download now (14 §2.2): due at the next minute, unless under way. */
app.post('/api/settings/spam/ftc/sync', async () => {
if (!(await getSetting(db, 'spam')).ftc) throw badRequest('Set up the FTC list first');
await patchSpamState(db, { ftcLastSyncAt: null });
return { ok: true };
});
/** Hidden until the next time the credits run out (14 §3). */
app.post('/api/system/warnings/spam_quota/dismiss', async () => {
await patchSpamState(db, { warningDismissedAt: new Date().toISOString() });
+122 -6
View File
@@ -1,9 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The FTC's Do Not Call complaints (14 §2.2): a check reads the local copy, so it sends nothing
// out. The copy is synced by spamFtcSync (SD3).
import { sql } from 'drizzle-orm';
// out. spamFtcSync keeps the copy.
import { lt, sql } from 'drizzle-orm';
import type { FtcInfo, SpamRisk } from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { ftcComplaints } from '../db/schema.ts';
import { decrypt } from '../crypto/secrets.ts';
import { getSetting } from '../services/settings.ts';
import { todayIn } from '../generator/load.ts';
import { isCheckable, patchSpamState } from './check.ts';
import { SpamError, getJson } from './http.ts';
export const FTC_API = 'https://api.ftc.gov/v0/dnc-complaints';
@@ -41,15 +46,126 @@ export async function ftcLookup(
return { risk: ftcRisk(info.complaints), info };
}
/** Settings → Spam check → Test: one request with the api.data.gov key. */
export async function ftcTest(key: string): Promise<string> {
const OVER_LIMIT = 'api.data.gov says the key is over its hourly limit';
async function ftcGet(key: string, q: Record<string, string>) {
try {
await getJson(`${FTC_API}?items_per_page=1`, { 'X-Api-Key': key });
return await getJson(`${FTC_API}?${new URLSearchParams(q)}`, { 'X-Api-Key': key });
} catch (err) {
const m = (err as Error).message;
if (m === 'HTTP 403') throw new SpamError('api.data.gov refused the key');
if (m === 'HTTP 429') throw new SpamError('api.data.gov says the key is over its hourly limit');
if (m === 'HTTP 429') throw new SpamError(OVER_LIMIT);
throw err;
}
}
/** Settings → Spam check → Test: one request with the api.data.gov key. */
export async function ftcTest(key: string): Promise<string> {
await ftcGet(key, { items_per_page: '1' });
return 'The FTC answered.';
}
// ponytail: the FTC's dates have no zone; taken as Eastern (it's in Washington). Hours off at
// worst, against a window of weeks.
const FTC_TZ = 'America/New_York';
const PAGE = 50;
const DATE_TIME = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
const nextDay = (day: string) =>
new Date(Date.parse(`${day}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10);
const daysBefore = (day: string, n: number) =>
new Date(Date.parse(`${day}T00:00:00Z`) - n * 86_400_000).toISOString().slice(0, 10);
/** One complaint as stored, or null for one without a usable US number or date. */
function complaint(c: unknown) {
const { id, attributes: a } = (c ?? {}) as { id?: unknown; attributes?: Record<string, unknown> };
const phone = `1${String(a?.['company-phone-number'] ?? '')}`;
const at = a?.['created-date'];
if (
typeof id !== 'string' ||
!isCheckable(phone) ||
typeof at !== 'string' ||
!DATE_TIME.test(at)
)
return null;
const robo = a?.['recorded-message-or-robocall'];
return {
id,
number: phone,
reportedAt: sql`${at}::timestamp at time zone ${FTC_TZ}`,
subject: typeof a?.subject === 'string' && a.subject ? a.subject : null,
robocall: robo === 'Y' ? true : robo === 'N' ? false : null,
};
}
/**
* Job spamFtcSync (14 §2.2), every minute: when a sync is due or under way, read up to `budget`
* pages (15 a minute is 900 an hour, under api.data.gov's 1,000) a day at a time, oldest first,
* saving where it got to. A failure (a 429 among them) is saved for the Settings tab and the next
* minute carries on from the same page. Returns the error, for the job's log line once.
*/
export async function ftcSync(
db: Db,
opts: { now?: Date; budget?: number } = {},
): Promise<string | null> {
const now = opts.now ?? new Date();
const s = await getSetting(db, 'spam');
if (!s.enabled || !s.ftc) return null;
const st = await getSetting(db, 'spam.state');
let cursor = st.ftcCursor;
if (!cursor) {
const due =
!st.ftcLastSyncAt ||
now.getTime() - Date.parse(st.ftcLastSyncAt) >= s.ftc.syncHours * 3_600_000;
if (!due) return null;
// From the newest complaint kept, less a day for ones posted late; at most the window.
const r = await db.execute<{ day: string | null }>(
sql`select to_char(max(reported_at) at time zone ${FTC_TZ}, 'YYYY-MM-DD') as day from ftc_complaints`,
);
const first = daysBefore(todayIn(FTC_TZ, now), s.ftc.windowDays);
const last = r.rows[0]?.day ? daysBefore(r.rows[0].day, 1) : first;
cursor = { day: last > first ? last : first, offset: 0 };
}
const key = decrypt(s.ftc.keyEnc);
const today = todayIn(FTC_TZ, now);
for (let n = 0; n < (opts.budget ?? 15); n++) {
let page: unknown[];
try {
const j = await ftcGet(key, {
created_date_from: `"${cursor.day} 00:00:00"`,
created_date_to: `"${cursor.day} 23:59:59"`,
sort_order: 'ASC',
items_per_page: String(PAGE),
offset: String(cursor.offset),
});
if (!Array.isArray(j.data)) throw new SpamError('the answer had no complaints list');
page = j.data;
} catch (err) {
const error = err instanceof SpamError ? err.message : 'the sync failed';
await patchSpamState(db, { ftcCursor: cursor, ftcLastError: error });
if (!(err instanceof SpamError)) throw err;
return error === st.ftcLastError ? null : error;
}
const rows = page.map(complaint).filter((c) => c !== null);
if (rows.length) await db.insert(ftcComplaints).values(rows).onConflictDoNothing();
if (page.length === PAGE) cursor = { day: cursor.day, offset: cursor.offset + PAGE };
else if (cursor.day < today) cursor = { day: nextDay(cursor.day), offset: 0 };
else {
await db
.delete(ftcComplaints)
.where(
lt(
ftcComplaints.reportedAt,
sql`${now.toISOString()}::timestamptz - make_interval(days => ${s.ftc.windowDays})`,
),
);
await patchSpamState(db, {
ftcCursor: null,
ftcLastSyncAt: now.toISOString(),
ftcLastError: null,
});
return null;
}
}
await patchSpamState(db, { ftcCursor: cursor, ftcLastError: null });
return null;
}
+177
View File
@@ -0,0 +1,177 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// SD3: the FTC complaint download (14 §2.2) against a fake api.ftc.gov with three days of data.
import { randomBytes } from 'node:crypto';
import { eq, sql } from 'drizzle-orm';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { SPAM_DEFAULTS } from '@selfphoneng/shared';
import { encrypt, setSecretKey } from '../src/crypto/secrets.ts';
import type { Db } from '../src/db/client.ts';
import { ftcComplaints, settings } from '../src/db/schema.ts';
import { ftcSync } from '../src/spam/ftc.ts';
import { getSetting, setSetting } from '../src/services/settings.ts';
import { createTestDb, hasTestDb } from './db.ts';
const KEY = 'datagov-secret-key-456';
const NOW = new Date('2026-10-27T18:00:00Z'); // 14:00 in Washington
const hour = 3_600_000;
const at = (day: string, i: number) =>
`${day} ${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00`;
const ftc = (id: string, phone: string, created: string, robo = 'Y') => ({
type: 'dnc_complaint',
id,
attributes: {
'company-phone-number': phone,
'created-date': created,
subject: 'Warranties',
'recorded-message-or-robocall': robo,
},
});
/** 120 complaints on the 25th (three pages), 3 on the 26th, 3 today (one usable). */
const DATA = [
...Array.from({ length: 120 }, (_, i) => ftc(`a${i}`, '3125550100', at('2026-10-25', i))),
...[0, 1, 2].map((i) => ftc(`b${i}`, '3125550101', at('2026-10-26', i), 'N')),
ftc('c0', '3125550102', '2026-10-27 09:00:00', ''),
ftc('c1', '', '2026-10-27 09:01:00'), // no number
ftc('c2', '3125550102', '2026-10-27'), // no time
];
describe.skipIf(!hasTestDb)('FTC complaint download', () => {
let db: Db;
let drop: () => Promise<void>;
/** Each request's day and offset. */
const asked: string[] = [];
let fail: { at: number; status: number } | null = null;
const turnOn = (keyEnc = encrypt(KEY)) =>
setSetting(db, 'spam', {
...SPAM_DEFAULTS,
enabled: true,
ftc: { keyEnc, syncHours: 24, windowDays: 7 },
});
const stored = async () => (await db.select().from(ftcComplaints)).length;
const reset = async () => {
await db.delete(ftcComplaints);
await db.delete(settings).where(eq(settings.key, 'spam.state'));
};
beforeAll(async () => {
({ db, drop } = await createTestDb());
setSecretKey(randomBytes(32).toString('base64'));
});
afterAll(() => drop?.());
beforeEach(() => {
asked.length = 0;
fail = null;
vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {
const json = (j: unknown, status = 200) => new Response(JSON.stringify(j), { status });
if ((init?.headers as Record<string, string>)['X-Api-Key'] !== KEY) return json({}, 403);
const q = new URL(url).searchParams;
const day = /^"(\d{4}-\d{2}-\d{2}) 00:00:00"$/.exec(q.get('created_date_from')!)![1]!;
expect(q.get('created_date_to')).toBe(`"${day} 23:59:59"`);
expect(q.get('sort_order')).toBe('ASC');
const offset = Number(q.get('offset'));
asked.push(`${day}+${offset}`);
if (fail && asked.length === fail.at) return json({}, fail.status);
const all = DATA.filter((c) => c.attributes['created-date'].startsWith(day));
return json({ data: all.slice(offset, offset + 50), meta: { 'record-total': all.length } });
});
});
afterEach(async () => {
vi.unstubAllGlobals();
await reset();
});
it('downloads nothing while spam check or the FTC list is off', async () => {
await setSetting(db, 'spam', SPAM_DEFAULTS);
expect(await ftcSync(db, { now: NOW })).toBeNull();
await setSetting(db, 'spam', { ...SPAM_DEFAULTS, enabled: true });
expect(await ftcSync(db, { now: NOW })).toBeNull();
expect(asked).toEqual([]);
});
it('the first download fills the window a day at a time and drops old complaints', async () => {
await turnOn();
await db.insert(ftcComplaints).values({
id: 'old',
number: '13125550109',
reportedAt: sql`${NOW.toISOString()}::timestamptz - interval '8 days'`,
});
expect(await ftcSync(db, { now: NOW, budget: 100 })).toBeNull();
expect(asked).toEqual([
'2026-10-20+0',
'2026-10-21+0',
'2026-10-22+0',
'2026-10-23+0',
'2026-10-24+0',
'2026-10-25+0',
'2026-10-25+50',
'2026-10-25+100',
'2026-10-26+0',
'2026-10-27+0',
]);
expect(await stored()).toBe(124);
const row = async (id: string) =>
(await db.select().from(ftcComplaints).where(eq(ftcComplaints.id, id)))[0];
expect(await row('b0')).toMatchObject({
number: '13125550101',
subject: 'Warranties',
robocall: false,
reportedAt: new Date('2026-10-26T04:00:00Z'), // midnight in Washington
});
expect((await row('c0'))?.robocall).toBeNull();
expect(await getSetting(db, 'spam.state')).toMatchObject({
ftcCursor: null,
ftcLastSyncAt: NOW.toISOString(),
ftcLastError: null,
});
});
it('not again until syncHours; then from the newest day kept, less one', async () => {
await turnOn();
await ftcSync(db, { now: NOW, budget: 100 });
asked.length = 0;
await ftcSync(db, { now: new Date(NOW.getTime() + 23 * hour) });
expect(asked).toEqual([]);
await ftcSync(db, { now: new Date(NOW.getTime() + 24 * hour) });
expect(asked).toEqual(['2026-10-26+0', '2026-10-27+0', '2026-10-28+0']);
});
it('15 pages a minute, the place saved; a 429 stops there and the next minute carries on', async () => {
await turnOn();
await ftcSync(db, { now: NOW });
expect(asked).toHaveLength(10);
await reset();
asked.length = 0;
await ftcSync(db, { now: NOW, budget: 2 });
expect((await getSetting(db, 'spam.state')).ftcCursor).toEqual({
day: '2026-10-22',
offset: 0,
});
fail = { at: 7, status: 429 }; // the 25th's second page
const said = 'api.data.gov says the key is over its hourly limit';
expect(await ftcSync(db, { now: NOW })).toBe(said);
expect(await getSetting(db, 'spam.state')).toMatchObject({
ftcCursor: { day: '2026-10-25', offset: 50 },
ftcLastError: said,
ftcLastSyncAt: null,
});
fail = { at: 8, status: 429 }; // the same failure isn't returned (logged) twice
expect(await ftcSync(db, { now: NOW })).toBeNull();
fail = null;
expect(await ftcSync(db, { now: NOW })).toBeNull();
expect(await stored()).toBe(124);
expect(await getSetting(db, 'spam.state')).toMatchObject({
ftcCursor: null,
ftcLastError: null,
});
});
it('a refused key is saved for the Settings tab', async () => {
await turnOn(encrypt('wrong'));
expect(await ftcSync(db, { now: NOW })).toBe('api.data.gov refused the key');
expect((await getSetting(db, 'spam.state')).ftcLastError).toBe('api.data.gov refused the key');
});
});
+14
View File
@@ -315,6 +315,20 @@ describe.skipIf(!hasTestDb)('spam check', () => {
},
});
it('Download now: due at the next minute; refused without the FTC list', async () => {
await settings(keys.withIpqs);
const r = await admin('POST', '/api/settings/spam/ftc/sync');
expect(r.statusCode).toBe(400);
expect(r.body).toMatch('Set up the FTC list first');
await settings(keys.withFtc);
await setSetting(t.db, 'spam.state', {
...(await getSetting(t.db, 'spam.state')),
ftcLastSyncAt: new Date().toISOString(),
});
expect((await admin('POST', '/api/settings/spam/ftc/sync')).statusCode).toBe(200);
expect((await getSetting(t.db, 'spam.state')).ftcLastSyncAt).toBeNull();
});
it('keys are write-only, and empty keeps the saved one', async () => {
await setSetting(t.db, 'spam', SPAM_DEFAULTS);
const r = await admin('PATCH', '/api/settings', body());
+1
View File
@@ -263,6 +263,7 @@ export const settingsApi = {
testLdap: (v: LdapInput & { testLogin?: string; testPassword?: string }) =>
api<LdapTestResult>('POST', '/api/settings/auth/ldap/test', v),
/** 14 §7.1: the typed key, else the saved one. */
syncFtc: () => api<{ ok: true }>('POST', '/api/settings/spam/ftc/sync'),
testSpam: (provider: 'ipqs' | 'ftc', key?: string) =>
api<SpamTestResult>('POST', `/api/settings/spam/${provider}/test`, { key }),
};
+34 -6
View File
@@ -1400,6 +1400,39 @@ function SpamTest({ provider, keyText }: { provider: 'ipqs' | 'ftc'; keyText: st
const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : null);
/** Where the FTC download is, and Download now (14 §2.2). */
function FtcSyncStatus({ st }: { st: SettingsDto['spam.state'] }) {
const qc = useQueryClient();
const sync = useMutation({
mutationFn: settingsApi.syncFtc,
onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }),
});
return (
<div className="flex flex-col gap-1 text-sm">
<p className="text-zinc-500">
{st.ftcCursor
? `Downloading: at the complaints of ${st.ftcCursor.day}.`
: st.ftcLastSyncAt
? `Last download ${when(st.ftcLastSyncAt)}.`
: 'Not downloaded yet: it starts within a minute.'}
</p>
{st.ftcLastError && (
<p className="text-amber-700 dark:text-amber-400">
The last try failed: {st.ftcLastError}. It tries again every minute.
</p>
)}
{!st.ftcCursor && (
<div>
<Button onClick={() => sync.mutate()} disabled={sync.isPending}>
Download now
</Button>
</div>
)}
{sync.isError && <Alert>{sync.error.message}</Alert>}
</div>
);
}
/** Spam check (spec 14 §7.1): IPQualityScore first, then the FTC list. Information only. */
function SpamTab({ s }: { s: SettingsDto }) {
const save = useSave();
@@ -1540,12 +1573,7 @@ function SpamTab({ s }: { s: SettingsDto }) {
{num('syncHours', 'Download again every (hours, 1–168)')}
{num('windowDays', 'Keep complaints for (days, 7–365)')}
</div>
{st.ftcLastSyncAt && (
<p className="text-sm text-zinc-500">
Last download {when(st.ftcLastSyncAt)}
{st.ftcLastError ? `: ${st.ftcLastError}` : '.'}
</p>
)}
{sp.ftc && <FtcSyncStatus st={st} />}
</>
)}
</fieldset>
+2
View File
@@ -433,6 +433,7 @@ As built (M11-T6): `routes/reports.ts`. `from`/`to` are company-time-zone days,
| GET | `/api/audit-log` | |
| GET/PATCH | `/api/settings` | whole settings object; PATCH merges known keys only. `smtp` comes back as `{host, port, secure, username, from, hasPassword}` and is written with an optional `password` (empty keeps the stored one). `system.*`, `sms.webhookToken` and `setup.completed` are read-only. References (DIDs, extensions, destinations) are checked, and a transport a trunk uses can't be turned off |
| POST | `/api/settings/smtp/test` | `{to}` sends a test email |
| POST | `/api/settings/spam/ftc/sync` | Download now: the FTC download starts at the next minute (14 §9.3) |
| POST | `/api/settings/spam/ipqs/test`, `/api/settings/spam/ftc/test` | `{key?}`: the typed key, else the saved one; `{ok, detail}` or `{ok: false, error}` (14 §7.1) |
| POST | `/api/settings/emergency/test` | runs 04 §5.3 steps 2–4 with `TEST` in front (email, ring-out, banner); records nothing and dials nothing (06 §6.5). Returns `{notified}` |
| GET | `/api/system/warnings` | `{items: [{code, message, link, dismissable?}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`, `tls_cert` (TLS on and the cert missing or expiring within 14 days, M11-T2), `backup_old` (backups on and today's not made yet though the job had its chance, M11-T3), `directory_check` (12 §4.2, §4.5: the directory unreachable at a sign-in or by `directoryCheck`, or that job refusing to disable most people), `spam_quota` (14 §3, dismissable). "Trunk down" comes from live state in the browser |
@@ -476,6 +477,7 @@ Provisioning and inbound hooks (outside `/api`, no session):
| `contactsWarm` | every 5 min, Contrack mode | refresh the company's and each connected person's Contrack book, so a ringing phone's caller-name lookup (11 §6.1) finds them |
| `contrackLog` | every 60 s, Contrack mode | calls ended over 1 min ago (and within 2 days) of people logging calls: one `call` interaction on the matching contact's Contrack timeline (11 §4.4) |
| `callReports` | every 60 s | each user whose call report period (05 §4) ended over 5 min ago and isn't reported yet: email their calls of that period (by hang-up time) if there were any; `extensions.call_report_sent_at` = the period's end. Changing the schedule sets it to now, so the first report is the next period's |
| `spamFtcSync` | every 60 s, when due or under way | the FTC complaint download, 15 pages a minute (14 §2.2, §9.3) |
| `spamQuotaCheck` | every 6 hours, while IPQualityScore is paused | ask its Credit Usage API; credits back → resume (14 §3) |
Jobs must never overlap with themselves (use a running flag).
+1 -1
View File
@@ -9,7 +9,7 @@ Legend: 📖 read · 🔨 build · ✅ done when
---
## Backlog (owner's order, 2026-10-21)
0. **Spam check** (owner, 2026-10-27: SD1, SD2 done; SD3 next), tasks SD1–SD5
0. **Spam check** (owner, 2026-10-27: SD1–SD3 done; SD4 next), tasks SD1–SD5
in [14 §9](14-spam.md#9-order-of-work): IPQualityScore and the FTC complaint list for outside
callers who aren't in anyone's contacts; information only, off by default.
0. **Mobile app** (owner, 2026-10-26: next), tasks MB1–MB8 in [13 §5](13-mobile.md#5-order-of-work):
+31 -3
View File
@@ -187,7 +187,7 @@ Settings (02 §3):
| Key | Type | Default | Notes |
|---|---|---|---|
| `spam` | object | below | the admin's choices |
| `spam.state` | object | `{}` | read-only to the API, like `system.*`: `ipqsPausedAt`, `warningDismissedAt`, `ftcSyncedTo`, `ftcCursor`, `ftcLastSyncAt`, `ftcLastError` |
| `spam.state` | object | `{}` | read-only to the API, like `system.*`: `ipqsPausedAt`, `warningDismissedAt`, `ftcCursor` (`{day, offset}` while a download is under way), `ftcLastSyncAt`, `ftcLastError` |
```ts
spam = {
@@ -235,7 +235,7 @@ log masks it. Validated with zod (`packages/shared`).
|---|---|---|
| SD1 | Decision 0006; this spec; the §7 migration and settings; `spam/check.ts` with both providers' lookups, the cache and the shared requests; Settings → Spam check with keys, Test, privacy confirmation | `pnpm lint && typecheck && test` pass; checks, cache times, the order (IPQualityScore, then the FTC list) and failures are tested against fake HTTP. Done, see §9.1 |
| SD2 | §3: pause, events, the `spam_quota` warning and Dismiss, `spamQuotaCheck` | an exhausted fake pauses, falls back to the FTC list, warns once and resumes; the owner's real key shows the credit call is free. Done but the real-key check, see §9.2 |
| SD3 | §2.2 sync: paging, pacing, the saved cursor, the window, Sync now | a fake FTC API with 3 days of data syncs, resumes after a 429 and drops old complaints; one real sync with the owner's key |
| SD3 | §2.2 sync: paging, pacing, the saved cursor, the window, Sync now | a fake FTC API with 3 days of data syncs, resumes after a 429 and drops old complaints; one real sync with the owner's key. Done but the real sync, see §9.3 |
| SD4 | §5: `/api/me/caller-check`, the softphone badge and line, the desk phone labels | an unknown number rings the softphone and shows its answer; a contact or a low risk shows nothing; a desk phone shows "SPAM LIKELY" when the answer is in time |
| SD5 | §6 call history; the admin guide page | a checked call keeps its badge in call history after its cache expires |
@@ -245,7 +245,7 @@ log masks it. Validated with zod (`packages/shared`).
can't wait (14 §5.1, §5.2) races it against its own timer, and the check carries on and fills
the cache. The cache is only read while IPQualityScore is set up; turning it off leaves the
rows to expire.
- `spam/ipqs.ts`, `spam/ftc.ts` (the local lookup and Test; the sync is SD3), `spam/http.ts`
- `spam/ipqs.ts`, `spam/ftc.ts` (the local lookup and Test; the sync is §9.3), `spam/http.ts`
(`getJson`, 4 s timeout, and `SpamError`, the only error text that is logged or shown: never
the URL or fetch's own error). A provider message is cut to 200 characters with the key taken
out, in case it quotes it.
@@ -286,6 +286,34 @@ log masks it. Validated with zod (`packages/shared`).
0 credits and resuming above; the "are off" text without the FTC list; a new key; the job
asking nothing unless paused and failing without the key).
### 9.3 As built: SD3
- `spam/ftc.ts` `ftcSync(db, { now, budget })`, job `spamFtcSync` **every minute**. It does
nothing unless spam check and the FTC list are on and a download is due (`ftcLastSyncAt`
older than `syncHours`, or never) or under way (`ftcCursor`).
- One FTC date at a time (`created_date_from` / `_to` that day, `sort_order=ASC`, 50 a page,
`offset`), so an offset never grows past one day's complaints. A short page moves on to the
next day; today's short page ends the download: complaints older than `windowDays` are
deleted and `ftcLastSyncAt` set.
- Pacing: at most 15 pages a minute (900 an hour), and the place (`ftcCursor`) saved after each
run, so a restart carries on. The first 30 days take about 7 hours.
- Where a download starts: the newest complaint kept, less a day (for ones posted late, as on
weekends), but no further back than `windowDays`. Ids make the overlap harmless.
- The FTC's dates have no zone: taken as Eastern, so a day is midnight to midnight in
Washington. Complaints without a 10-digit US number or a full date are skipped.
- A failure (403 "api.data.gov refused the key", a 429 "…over its hourly limit", a timeout)
keeps the place and is saved in `ftcLastError`; the next minute tries again from the same page.
The job logs it (`warn`) only when it differs from the last one, so a long outage is one line.
- Settings → Spam check: "Downloading: at the complaints of {day}", "Last download {time}", the
last failure, and **Download now** (`POST /api/settings/spam/ftc/sync`, which clears
`ftcLastSyncAt` so the next minute starts one; 400 without the FTC list).
- Not done: one real download with the owner's api.data.gov key.
- Tests: `test/ftc-sync.test.ts` against a fake api.ftc.gov (off; the first download a day at a
time with three pages on one day, old complaints dropped, robocall and Eastern time; not again
until `syncHours`, then from the newest day less one; 15 pages a minute with the place saved; a
429 stopping and the next minute carrying on without duplicates; the same failure logged once;
a refused key). `test/spam.test.ts`: Download now.
Later, not now:
- **Report as spam** (owner, 2026-10-27: not now): a button in call history that reports the
number to a provider that takes reports (OpenSpam has `POST /api/report`).
+5 -2
View File
@@ -73,8 +73,11 @@ export const SPAM_DEFAULTS: SpamSettings = {
export const SpamStateSchema = z.object({
ipqsPausedAt: z.string().nullable().default(null),
warningDismissedAt: z.string().nullable().default(null),
ftcSyncedTo: z.string().nullable().default(null),
ftcCursor: z.number().int().nullable().default(null),
/** A sync under way (14 §2.2): the FTC's date (Eastern) being read and how far into it. */
ftcCursor: z
.object({ day: z.iso.date(), offset: z.number().int().min(0) })
.nullable()
.default(null),
ftcLastSyncAt: z.string().nullable().default(null),
ftcLastError: z.string().nullable().default(null),
});