← back to Animals
yolo: Lost-pet listing → SMS alert to neighbors in ZIP
f2d7975f70dbb690f0646d69fb9d16f77986c94c · 2026-05-09 09:03:12 -0700 · animal-yolo
Task: yolo.lost-pet-alerts
Prompt: In ~/Projects/animals: when a marketplace listing of type='lost_pet' is posted, find all app_users with home_zip matching listing.zip OR within ZIP-3 prefix, AND who opted in (new column: alert_lost_p
Files touched
M .env.exampleM agents/animal-agent/server.jsM agents/animal-yolo/state.jsonA migrations/016_weekly_newsletter.sqlA migrations/017_lost_pet_alerts.sqlM package.jsonA src/lib/lost_pet_alert.jsA src/lib/weekly_newsletter.jsA src/scripts/run_weekly_newsletter.jsM src/server/community.js
Diff
commit f2d7975f70dbb690f0646d69fb9d16f77986c94c
Author: animal-yolo <steve@designerwallcoverings.com>
Date: Sat May 9 09:03:12 2026 -0700
yolo: Lost-pet listing → SMS alert to neighbors in ZIP
Task: yolo.lost-pet-alerts
Prompt: In ~/Projects/animals: when a marketplace listing of type='lost_pet' is posted, find all app_users with home_zip matching listing.zip OR within ZIP-3 prefix, AND who opted in (new column: alert_lost_p
---
.env.example | 6 +
agents/animal-agent/server.js | 20 +-
agents/animal-yolo/state.json | 4 +-
migrations/016_weekly_newsletter.sql | 34 ++++
migrations/017_lost_pet_alerts.sql | 44 +++++
package.json | 2 +
src/lib/lost_pet_alert.js | 255 +++++++++++++++++++++++++
src/lib/weekly_newsletter.js | 360 +++++++++++++++++++++++++++++++++++
src/scripts/run_weekly_newsletter.js | 38 ++++
src/server/community.js | 18 ++
10 files changed, 778 insertions(+), 3 deletions(-)
diff --git a/.env.example b/.env.example
index 73b832d..33094df 100644
--- a/.env.example
+++ b/.env.example
@@ -34,6 +34,12 @@ AD_PROVIDER=inhouse # 'inhouse' | 'adsense'
# Outbound email (uses Steve's George gmail agent on tailnet)
GEORGE_GMAIL_URL=http://localhost:9850
GEORGE_GMAIL_AUTH=admin:DWSecure2024!
+GEORGE_FROM_NAME=AnimalsDirectory
+
+# Weekly newsletter (Sunday digest via animal-agent task queue)
+# CAN-SPAM § 5 requires a valid physical postal address in every commercial
+# message — runWeeklyNewsletter() refuses to send if this env is empty.
+NEWSLETTER_MAILING_ADDRESS=
# Domain research (uses domain-suite MCP via local helper)
DOMAIN_SUITE_PROVIDER=godaddy
diff --git a/agents/animal-agent/server.js b/agents/animal-agent/server.js
index 9bc9230..527af31 100644
--- a/agents/animal-agent/server.js
+++ b/agents/animal-agent/server.js
@@ -99,9 +99,13 @@ const PORT = parseInt(process.env.PORT || '9725', 10);
app.listen(PORT, () => log.info(`animal-agent listening on :${PORT}`));
// ── cron rules (internal, no external scheduler needed) ───────────────────
-// Each rule: { kind, cron_label, every_minutes, throttle_minutes }
+// Each rule: { kind, cron_label, every_minutes, throttle_minutes, cron_dow?, cron_hour? }
// `throttle_minutes` = don't queue if a task with same cron_label was created
// in the last N minutes (keeps the queue from blowing up after a restart).
+// `cron_dow` (0=Sun..6=Sat) and `cron_hour` (0..23) gate the rule to specific
+// times — cronTick still runs every minute, but skips the rule unless local
+// day-of-week and hour match. Useful for weekly digests that should fire on
+// a specific day.
const CRON_RULES = [
{ kind: 'audit_websites', cron_label: 'cron.audit_15m', every_minutes: 15, throttle_minutes: 14, payload: { limit: 25 } },
{ kind: 'gen_mockups', cron_label: 'cron.mockups_30m', every_minutes: 30, throttle_minutes: 28, payload: { score_below: 65, limit: 15 } },
@@ -111,6 +115,9 @@ const CRON_RULES = [
{ kind: 'ingest_osm_state', cron_label: 'cron.osm_240m', every_minutes: 240, throttle_minutes: 230, payload: { state: 'ROTATE' } },
{ kind: 'codex_debate', cron_label: 'cron.codex_360m', every_minutes: 360, throttle_minutes: 340, payload: { rounds: 4, budget: 4 } },
{ kind: 'build_subsite', cron_label: 'cron.subsite_60m', every_minutes: 60, throttle_minutes: 55, payload: { limit: 5 } },
+ // Weekly digest — Sundays at 08:00 local. Throttle far over a week so a
+ // restart late Sunday doesn't double-fire next Sunday.
+ { kind: 'weekly_newsletter', cron_label: 'cron.newsletter_weekly', every_minutes: 60, throttle_minutes: 10080, cron_dow: 0, cron_hour: 8, payload: {} },
];
// Rotation pool for OSM state ingest
@@ -119,8 +126,12 @@ const STATE_ROTATION = ['CA','TX','FL','NY','IL','WA','CO','GA','MA','PA','OH','
// ── cron tick (every minute) ──────────────────────────────────────────────
async function cronTick() {
if (paused) return;
+ const now = new Date();
for (const rule of CRON_RULES) {
try {
+ // Day-of-week / hour gate (used by weekly_newsletter on Sundays at 08:00).
+ if (rule.cron_dow !== undefined && now.getDay() !== rule.cron_dow) continue;
+ if (rule.cron_hour !== undefined && now.getHours() !== rule.cron_hour) continue;
const recent = await one(
`SELECT 1 AS x FROM agent_tasks WHERE cron_label = $1 AND created_at > NOW() - ($2 || ' minutes')::interval LIMIT 1`,
[rule.cron_label, rule.throttle_minutes]);
@@ -223,6 +234,13 @@ async function execTask(task) {
'--rounds', String(p.rounds || 4), '--budget', String(p.budget || 4),
'--apply'], 60_000); // APPLY MODE — codex actually edits the code
case 'build_subsite': return await runCmd('node', [path.join(ROOT, 'src/scripts/build_subsites.js'), '--limit', String(p.limit || 5)], 15 * 60_000);
+ case 'weekly_newsletter': {
+ const args = [];
+ if (p.limit) args.push('--limit', String(p.limit));
+ if (p.user_ids) args.push('--user-ids', Array.isArray(p.user_ids) ? p.user_ids.join(',') : String(p.user_ids));
+ if (p.dry_run) args.push('--dry-run');
+ return await runCmd('node', [path.join(ROOT, 'src/scripts/run_weekly_newsletter.js'), ...args], 30 * 60_000);
+ }
default: throw new Error(`unknown kind: ${task.kind}`);
}
}
diff --git a/agents/animal-yolo/state.json b/agents/animal-yolo/state.json
index fb5b1f4..392b5f1 100644
--- a/agents/animal-yolo/state.json
+++ b/agents/animal-yolo/state.json
@@ -1,5 +1,5 @@
{
- "cursor": 7,
- "runs": 7,
+ "cursor": 9,
+ "runs": 9,
"started_at": "2026-05-08T07:01:22.386Z"
}
\ No newline at end of file
diff --git a/migrations/016_weekly_newsletter.sql b/migrations/016_weekly_newsletter.sql
new file mode 100644
index 0000000..7160979
--- /dev/null
+++ b/migrations/016_weekly_newsletter.sql
@@ -0,0 +1,34 @@
+-- Project: Animals — weekly newsletter unsubscribe + send tracking.
+--
+-- Wires the agents/animal-agent `weekly_newsletter` task:
+-- 1. Sunday cron picks up users with unsubscribed_at IS NULL.
+-- 2. Each user gets a digest (5 random breed photos + upcoming dog shows in
+-- home_state + latest 3 marketplace listings near home_zip).
+-- 3. Email contains an "unsubscribe" link tied to a stable per-user
+-- `unsubscribe_token` — clicking it sets unsubscribed_at without requiring
+-- a login (per CAN-SPAM: unsubscribe must be one click, no auth).
+-- 4. last_newsletter_sent_at gates against double-sends if the cron tick
+-- runs more than once on the same Sunday.
+--
+-- Token model: unlike email_verification_token (single-use, cleared after
+-- click), unsubscribe_token is *stable* — the same link should keep working
+-- forever, even after the user re-subscribes. Re-subscribe is a re-click
+-- of the same link (or a checkbox in /me settings later).
+
+BEGIN;
+
+ALTER TABLE app_users
+ ADD COLUMN IF NOT EXISTS unsubscribe_token TEXT,
+ ADD COLUMN IF NOT EXISTS unsubscribed_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS last_newsletter_sent_at TIMESTAMPTZ;
+
+-- Tokens must be unique while populated (NULL allowed for grandfathered users
+-- whose token gets backfilled lazily on first send).
+CREATE UNIQUE INDEX IF NOT EXISTS idx_app_users_unsubscribe_token
+ ON app_users (unsubscribe_token)
+ WHERE unsubscribe_token IS NOT NULL;
+
+CREATE INDEX IF NOT EXISTS idx_app_users_last_newsletter
+ ON app_users (last_newsletter_sent_at);
+
+COMMIT;
diff --git a/migrations/017_lost_pet_alerts.sql b/migrations/017_lost_pet_alerts.sql
new file mode 100644
index 0000000..f5a6a2d
--- /dev/null
+++ b/migrations/017_lost_pet_alerts.sql
@@ -0,0 +1,44 @@
+-- Project: Animals — lost-pet alert opt-in + dispatch dedupe.
+--
+-- Wires src/lib/lost_pet_alert.js → fires when a marketplace listing of
+-- listing_type='lost_pet' is posted. Recipients are app_users who:
+-- 1. Set alert_lost_pets = TRUE (must opt in — default FALSE)
+-- 2. Have a home_zip matching listing.zip OR same ZIP-3 prefix
+-- (sectional center facility — typically dozens of cities)
+-- 3. Have a verified email and have not unsubscribed entirely
+--
+-- SMS — alert_lost_pets_sms exists for future TCPA-compliant double opt-in
+-- flow, but no sender code references it. Per Steve's standing rule: no SMS
+-- without an explicit opt-in event we can prove (timestamp + source + IP).
+--
+-- Dedupe: lost_pet_alerts_sent is a (listing_id, app_user_id, channel) record
+-- so re-bumping a listing does not re-blast the neighborhood.
+
+BEGIN;
+
+ALTER TABLE app_users
+ ADD COLUMN IF NOT EXISTS alert_lost_pets BOOLEAN NOT NULL DEFAULT FALSE,
+ ADD COLUMN IF NOT EXISTS alert_lost_pets_sms BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Same ZIP: idx_app_users_zip (from migration 003) already covers home_zip = $1.
+-- ZIP-3 prefix lookup wants a partial expression index so we don't full-scan
+-- app_users every time someone posts a lost pet.
+CREATE INDEX IF NOT EXISTS idx_app_users_zip3_alerts
+ ON app_users (LEFT(home_zip, 3))
+ WHERE alert_lost_pets = TRUE AND home_zip IS NOT NULL;
+
+-- Per-recipient send ledger. Channel column lets us add 'sms' later without
+-- a schema change. UNIQUE prevents duplicate sends if a worker retries.
+CREATE TABLE IF NOT EXISTS lost_pet_alerts_sent (
+ listing_id BIGINT NOT NULL REFERENCES marketplace_listings(id) ON DELETE CASCADE,
+ app_user_id BIGINT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
+ channel TEXT NOT NULL CHECK (channel IN ('email','sms')),
+ sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ status TEXT NOT NULL DEFAULT 'sent' CHECK (status IN ('sent','failed','skipped')),
+ error TEXT,
+ PRIMARY KEY (listing_id, app_user_id, channel)
+);
+CREATE INDEX IF NOT EXISTS idx_lost_pet_alerts_listing ON lost_pet_alerts_sent(listing_id);
+CREATE INDEX IF NOT EXISTS idx_lost_pet_alerts_user ON lost_pet_alerts_sent(app_user_id);
+
+COMMIT;
diff --git a/package.json b/package.json
index e31236f..f399463 100644
--- a/package.json
+++ b/package.json
@@ -45,6 +45,8 @@
"audit:mockups": "node src/audit/generate_mockups.js",
"audit:mockups-unique": "node src/audit/unique_mockups.js",
"domains:research": "node src/scripts/research_domains.js",
+ "newsletter:weekly": "node src/scripts/run_weekly_newsletter.js",
+ "newsletter:weekly:dry": "node src/scripts/run_weekly_newsletter.js --dry-run --limit 1",
"stats": "node src/scripts/stats.js",
"server": "node src/server/index.js",
"dev": "node --watch src/server/index.js",
diff --git a/src/lib/lost_pet_alert.js b/src/lib/lost_pet_alert.js
new file mode 100644
index 0000000..133aa8f
--- /dev/null
+++ b/src/lib/lost_pet_alert.js
@@ -0,0 +1,255 @@
+// Lost-pet alert dispatcher — fires when a marketplace listing of
+// listing_type='lost_pet' is posted.
+//
+// Audience: app_users where alert_lost_pets = TRUE, email_verified, not
+// unsubscribed, home_zip = listing.zip OR LEFT(home_zip,3) = LEFT(listing.zip,3).
+// Author of the listing is excluded (no point alerting the person who posted).
+//
+// Channels:
+// - email — sent via George (same pattern as weekly_newsletter.js)
+// - sms — NOT IMPLEMENTED. alert_lost_pets_sms column exists, but no SMS
+// code path runs here. Per Steve's rule: never SMS without a
+// provable opt-in event (timestamp + source + IP) and a working
+// one-keyword STOP receiver. Until that exists, email only.
+//
+// Dedupe: each (listing_id, app_user_id, 'email') is recorded in
+// lost_pet_alerts_sent. Re-bumping a listing won't re-blast the neighborhood.
+// We INSERT … ON CONFLICT DO NOTHING and skip the send if rowCount = 0.
+//
+// Failure mode: per-recipient. One George 5xx does not block the rest.
+
+import { pool, many, one } from './db.js';
+import { log } from './log.js';
+
+const GEORGE_URL = process.env.GEORGE_GMAIL_URL || 'http://localhost:9850';
+const GEORGE_AUTH = process.env.GEORGE_GMAIL_AUTH || process.env.GEORGE_AUTH || 'admin:DWSecure2024!';
+const PUBLIC_BASE = process.env.PUBLIC_BASE_URL || 'https://animalsdirectory.com';
+const FROM_NAME = process.env.GEORGE_FROM_NAME || 'AnimalsDirectory';
+// CAN-SPAM §5: every commercial-ish blast must contain a valid physical
+// postal address. Lost-pet alerts are user-opted-in safety notices, but we
+// hold to the same bar Steve set for the weekly digest.
+const MAILING_ADDRESS = process.env.NEWSLETTER_MAILING_ADDRESS || '';
+
+const MIN_INTERVAL_MS = 200;
+
+function esc(s) {
+ return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+}
+
+async function fetchListing(listingId) {
+ return await one(`
+ SELECT l.id, l.listing_type, l.title, l.body, l.zip, l.city, l.state,
+ l.species, l.breed_id, l.contact_email, l.contact_phone,
+ l.photo_urls, l.app_user_id, l.created_at,
+ u.handle AS author_handle
+ FROM marketplace_listings l
+ JOIN app_users u ON u.id = l.app_user_id
+ WHERE l.id = $1`, [listingId]);
+}
+
+// Recipients: opted-in, verified, not unsubscribed, in ZIP or ZIP-3, and
+// haven't already received an alert for this listing.
+async function pickRecipients(listing) {
+ return await many(`
+ SELECT u.id, u.email, u.full_name, u.home_zip, u.unsubscribe_token
+ FROM app_users u
+ LEFT JOIN lost_pet_alerts_sent s
+ ON s.listing_id = $1 AND s.app_user_id = u.id AND s.channel = 'email'
+ WHERE u.alert_lost_pets = TRUE
+ AND u.email_verified = TRUE
+ AND u.email IS NOT NULL
+ AND u.unsubscribed_at IS NULL
+ AND u.id <> $2
+ AND (u.home_zip = $3 OR LEFT(u.home_zip, 3) = LEFT($3, 3))
+ AND s.app_user_id IS NULL
+ ORDER BY u.id
+ `, [listing.id, listing.app_user_id, listing.zip]);
+}
+
+function renderAlertEmail({ listing, recipient, alertsOffUrl }) {
+ const greeting = recipient.full_name ? `Hi ${recipient.full_name.split(/\s+/)[0]},` : 'Hi,';
+ const where = [listing.city, listing.state, listing.zip].filter(Boolean).join(', ');
+ const photo = Array.isArray(listing.photo_urls) && listing.photo_urls.length ? listing.photo_urls[0] : null;
+ const listingUrl = `${PUBLIC_BASE}/marketplace/${listing.id}`;
+ const subject = `🆘 Lost pet near ${where || listing.zip}: ${listing.title}`;
+
+ const photoBlock = photo
+ ? `<a href="${esc(listingUrl)}"><img src="${esc(photo)}" alt="${esc(listing.title)}" style="width:100%;max-width:560px;height:auto;border-radius:8px;display:block;margin:0 0 16px"></a>`
+ : '';
+
+ const description = listing.body
+ ? `<div style="background:#fff7ed;border-left:4px solid #c97a3e;padding:12px 16px;margin:0 0 16px;border-radius:4px">
+ <div style="font-size:12px;color:#92400e;text-transform:uppercase;letter-spacing:.05em;font-weight:600;margin-bottom:4px">Last seen / details</div>
+ <div style="font-size:14px;color:#222;white-space:pre-line">${esc(listing.body.slice(0, 2000))}</div>
+ </div>`
+ : '';
+
+ const contactRows = [];
+ if (listing.contact_email) contactRows.push(`<div>Email: <a href="mailto:${esc(listing.contact_email)}" style="color:#2563eb">${esc(listing.contact_email)}</a></div>`);
+ if (listing.contact_phone) contactRows.push(`<div>Phone: <a href="tel:${esc(listing.contact_phone)}" style="color:#2563eb">${esc(listing.contact_phone)}</a></div>`);
+ const contactBlock = contactRows.length
+ ? `<div style="background:#f0fdf4;border-left:4px solid #16a34a;padding:12px 16px;margin:0 0 16px;border-radius:4px">
+ <div style="font-size:12px;color:#166534;text-transform:uppercase;letter-spacing:.05em;font-weight:600;margin-bottom:4px">Contact the owner</div>
+ <div style="font-size:14px;color:#222;line-height:1.6">${contactRows.join('')}</div>
+ </div>`
+ : '';
+
+ const body = `
+<div style="max-width:600px;margin:0 auto;font-family:system-ui,-apple-system,sans-serif;color:#111;line-height:1.5">
+ <h1 style="color:#dc2626;font-size:22px;margin:0 0 6px">🆘 Lost pet alert</h1>
+ <p style="color:#666;font-size:14px;margin:0 0 16px">${esc(greeting)} A neighbor in ${esc(where || listing.zip)} just posted that their pet is missing. If you've seen this animal, please reach out to the owner.</p>
+
+ <h2 style="font-size:18px;color:#111;margin:0 0 12px">${esc(listing.title)}</h2>
+ ${photoBlock}
+ ${description}
+ ${contactBlock}
+
+ <p style="text-align:center;margin:24px 0">
+ <a href="${esc(listingUrl)}" style="display:inline-block;background:#dc2626;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:600">View full listing</a>
+ </p>
+
+ <hr style="margin:32px 0;border:0;border-top:1px solid #ddd">
+ <p style="font-size:12px;color:#888;text-align:center">
+ You're getting this alert because you opted in to lost-pet notifications for ZIP ${esc(recipient.home_zip || '')} and nearby (ZIP-3 ${esc((recipient.home_zip || '').slice(0,3))}).<br>
+ <a href="${esc(alertsOffUrl)}" style="color:#888">Turn off lost-pet alerts</a> · <a href="${esc(PUBLIC_BASE)}/me" style="color:#888">Manage preferences</a><br>
+ — ${esc(FROM_NAME)}<br>
+ <span style="color:#aaa">${esc(MAILING_ADDRESS)}</span>
+ </p>
+</div>
+`.trim();
+
+ return { subject, body };
+}
+
+async function sendViaGeorge({ to, subject, body }) {
+ const r = await fetch(`${GEORGE_URL}/api/send`, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'authorization': 'Basic ' + Buffer.from(GEORGE_AUTH).toString('base64'),
+ },
+ body: JSON.stringify({ to, subject, body }),
+ signal: AbortSignal.timeout(15000),
+ });
+ if (!r.ok) {
+ const text = await r.text().catch(() => '');
+ throw new Error(`george returned ${r.status}: ${text.slice(0, 200)}`);
+ }
+}
+
+// Reserve the (listing, user, email) row atomically. Returns true if WE got
+// the slot — false if another worker already claimed it. Prevents two
+// concurrent dispatchers from double-sending.
+async function claimRecipient(listingId, userId) {
+ const r = await pool.query(
+ `INSERT INTO lost_pet_alerts_sent (listing_id, app_user_id, channel, status)
+ VALUES ($1, $2, 'email', 'sent')
+ ON CONFLICT (listing_id, app_user_id, channel) DO NOTHING`,
+ [listingId, userId]
+ );
+ return r.rowCount === 1;
+}
+
+async function markFailed(listingId, userId, errMsg) {
+ await pool.query(
+ `UPDATE lost_pet_alerts_sent
+ SET status = 'failed', error = $3
+ WHERE listing_id = $1 AND app_user_id = $2 AND channel = 'email'`,
+ [listingId, userId, String(errMsg).slice(0, 500)]
+ );
+}
+
+export async function dispatchLostPetAlert(listingId, { dryRun = false } = {}) {
+ const t0 = Date.now();
+ const listing = await fetchListing(listingId);
+ if (!listing) return { ok: false, reason: 'listing_not_found', listing_id: listingId };
+ if (listing.listing_type !== 'lost_pet') return { ok: false, reason: 'wrong_listing_type', listing_type: listing.listing_type };
+
+ if (!dryRun && !MAILING_ADDRESS) {
+ const reason = 'NEWSLETTER_MAILING_ADDRESS env unset — refusing to send (CAN-SPAM)';
+ log.error('lost_pet_alert: blocked', { listing_id: listingId, reason });
+ return { ok: false, reason: 'no_mailing_address', listing_id: listingId };
+ }
+
+ const recipients = await pickRecipients(listing);
+ log.info('lost_pet_alert: starting', { listing_id: listingId, count: recipients.length, dryRun });
+
+ let sent = 0, failed = 0, skipped = 0;
+ const failures = [];
+
+ for (const r of recipients) {
+ if (dryRun) { sent++; continue; }
+
+ const claimed = await claimRecipient(listing.id, r.id);
+ if (!claimed) { skipped++; continue; }
+
+ const alertsOffUrl = r.unsubscribe_token
+ ? `${PUBLIC_BASE}/auth/alerts/lost-pets/off?token=${encodeURIComponent(r.unsubscribe_token)}`
+ : `${PUBLIC_BASE}/me`;
+
+ const { subject, body } = renderAlertEmail({ listing, recipient: r, alertsOffUrl });
+
+ try {
+ await sendViaGeorge({ to: r.email, subject, body });
+ sent++;
+ } catch (err) {
+ failed++;
+ failures.push({ user_id: r.id, err: err.message });
+ await markFailed(listing.id, r.id, err.message).catch(() => {});
+ }
+ await sleep(MIN_INTERVAL_MS);
+ }
+
+ const summary = {
+ elapsed_ms: Date.now() - t0,
+ listing_id: listingId,
+ candidates: recipients.length,
+ sent, failed, skipped,
+ failures: failures.slice(0, 10),
+ dryRun: dryRun || undefined,
+ };
+ log.info('lost_pet_alert: done', summary);
+ return { ok: true, ...summary };
+}
+
+// One-click "turn off lost-pet alerts" — uses the same stable unsubscribe_token
+// as the weekly digest, but flips alert_lost_pets only (does NOT mark
+// unsubscribed_at). User keeps their newsletter, just opts out of alerts.
+export async function alertsOff(req, res) {
+ const token = String(req.query?.token || '');
+ if (!token) return res.status(400).send(page('Missing token.', false));
+ const row = await one(
+ `UPDATE app_users
+ SET alert_lost_pets = FALSE
+ WHERE unsubscribe_token = $1
+ RETURNING id, email`,
+ [token]
+ );
+ if (!row) return res.status(400).send(page('This link is invalid or expired.', false));
+ log.info('lost_pet_alerts: opted out', { userId: row.id });
+ res.send(page(
+ `${esc(row.email)} will no longer receive lost-pet alerts. You'll still get the weekly digest and any verification emails.`,
+ true
+ ));
+}
+
+function page(message, ok) {
+ const color = ok ? '#16a34a' : '#dc2626';
+ const title = ok ? 'Alerts turned off' : 'Couldn\'t turn off alerts';
+ return `<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8">
+<title>${esc(title)} — AnimalsDirectory</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<style>
+ body{font-family:system-ui,sans-serif;max-width:560px;margin:64px auto;padding:0 20px;color:#111;line-height:1.5}
+ h1{color:${color};margin:0 0 16px;font-size:1.5rem}
+ a.btn{display:inline-block;margin-top:18px;background:#2563eb;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none}
+</style></head><body>
+<h1>${esc(title)}</h1>
+<p>${esc(message)}</p>
+<a class="btn" href="/">Continue</a>
+</body></html>`;
+}
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
diff --git a/src/lib/weekly_newsletter.js b/src/lib/weekly_newsletter.js
new file mode 100644
index 0000000..516bf36
--- /dev/null
+++ b/src/lib/weekly_newsletter.js
@@ -0,0 +1,360 @@
+// Weekly newsletter — Sunday digest to verified, non-unsubscribed app_users.
+//
+// Each recipient gets:
+// - 5 random breed gallery photos (breed_images, with attribution)
+// - upcoming dog shows in their home_state (next 60 days)
+// - latest 3 marketplace listings in their home_zip
+//
+// Sent via George (localhost:9850 /api/send), same auth pattern as
+// email_verification.js. Each user has a stable `unsubscribe_token` minted on
+// first send; the digest's unsubscribe link points at /auth/unsubscribe?token=…
+// which is one-click, no login (CAN-SPAM).
+//
+// Idempotency: we filter recipients to last_newsletter_sent_at < (NOW() - 6 days)
+// so re-running on the same Sunday is safe — the second run finds an empty list.
+//
+// Failure mode: per-recipient. A George failure on user N does NOT stop the
+// loop for users N+1..M; it's logged and counted in the run summary.
+
+import crypto from 'node:crypto';
+import { pool, many, one } from './db.js';
+import { log } from './log.js';
+
+const GEORGE_URL = process.env.GEORGE_GMAIL_URL || 'http://localhost:9850';
+const GEORGE_AUTH = process.env.GEORGE_GMAIL_AUTH || process.env.GEORGE_AUTH || 'admin:DWSecure2024!';
+const PUBLIC_BASE = process.env.PUBLIC_BASE_URL || 'https://animalsdirectory.com';
+const FROM_NAME = process.env.GEORGE_FROM_NAME || 'AnimalsDirectory';
+// CAN-SPAM §5: every commercial message must contain a valid physical postal
+// address. We fail closed if the env is unset rather than emit a non-compliant
+// blast — the same pattern Steve uses in DROPS.
+const MAILING_ADDRESS = process.env.NEWSLETTER_MAILING_ADDRESS || '';
+
+const STALE_DAYS = 6; // skip users sent within the last N days
+const MIN_INTERVAL_MS = 200; // gentle throttle between George calls
+
+export function generateUnsubscribeToken() {
+ return crypto.randomBytes(24).toString('base64url');
+}
+
+// Pick recipients: verified, not unsubscribed, not recently sent. Caller may
+// pass `limit` (testing) and `userIds` (manual single-user re-send).
+export async function pickRecipients({ limit = null, userIds = null } = {}) {
+ const params = [STALE_DAYS];
+ let where = `email_verified = TRUE
+ AND email IS NOT NULL
+ AND unsubscribed_at IS NULL
+ AND (last_newsletter_sent_at IS NULL
+ OR last_newsletter_sent_at < NOW() - ($1 || ' days')::interval)`;
+ if (userIds && userIds.length) {
+ params.push(userIds);
+ where += ` AND id = ANY($${params.length}::bigint[])`;
+ }
+ let sql = `SELECT id, email, full_name, home_zip, home_state, home_city,
+ unsubscribe_token
+ FROM app_users
+ WHERE ${where}
+ ORDER BY id`;
+ if (limit) { params.push(limit); sql += ` LIMIT $${params.length}`; }
+ return await many(sql, params);
+}
+
+async function ensureUnsubscribeToken(user) {
+ if (user.unsubscribe_token) return user.unsubscribe_token;
+ const token = generateUnsubscribeToken();
+ await pool.query(
+ `UPDATE app_users SET unsubscribe_token = $1 WHERE id = $2 AND unsubscribe_token IS NULL`,
+ [token, user.id]
+ );
+ // Re-read in case of unique-constraint race (another worker minted first).
+ const row = await one(`SELECT unsubscribe_token FROM app_users WHERE id = $1`, [user.id]);
+ return row?.unsubscribe_token || token;
+}
+
+// 5 random breed images. We sample across the whole library — the digest is a
+// "what's new in the kingdom" tease, not a personalized recommendation.
+async function fetchBreedPhotos() {
+ return await many(`
+ SELECT bi.image_url, bi.thumb_url, bi.title, bi.author, bi.author_url,
+ bi.license_short, bi.source_url, b.common_name AS breed_name, b.slug AS breed_slug,
+ b.species_id
+ FROM breed_images bi
+ JOIN breeds b ON b.id = bi.breed_id
+ WHERE bi.image_url IS NOT NULL
+ ORDER BY RANDOM()
+ LIMIT 5
+ `);
+}
+
+async function fetchUpcomingDogShows(state) {
+ if (!state) return [];
+ return await many(`
+ SELECT id, title, organizer, venue, city, state, start_at, end_at, url
+ FROM events
+ WHERE kind = 'dog_show'
+ AND status = 'scheduled'
+ AND state = $1
+ AND start_at >= NOW()
+ AND start_at < NOW() + INTERVAL '60 days'
+ ORDER BY start_at ASC
+ LIMIT 8
+ `, [state.toUpperCase()]);
+}
+
+async function fetchLatestListings(zip) {
+ if (!zip) return [];
+ return await many(`
+ SELECT id, listing_type, title, body, zip, city, state, price_cents,
+ photo_urls, bumped_at
+ FROM marketplace_listings
+ WHERE status = 'active'
+ AND zip = $1
+ AND visibility IN ('public','statewide','home_zip_25mi','home_zip')
+ ORDER BY bumped_at DESC
+ LIMIT 3
+ `, [zip]);
+}
+
+// ── render ────────────────────────────────────────────────────────────────
+function esc(s) {
+ return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+}
+
+function fmtDate(d) {
+ if (!d) return '';
+ const dt = new Date(d);
+ return dt.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' });
+}
+
+function fmtPrice(cents) {
+ if (cents == null) return '';
+ return `$${(cents / 100).toFixed(2)}`;
+}
+
+function renderPhotos(photos) {
+ if (!photos.length) return '';
+ const cards = photos.map(p => {
+ const img = p.thumb_url || p.image_url;
+ const breedHref = `${PUBLIC_BASE}/breeds/${esc(p.breed_slug || '')}`;
+ const attrib = p.author
+ ? `Photo: <a href="${esc(p.author_url || p.source_url)}" style="color:#666">${esc(p.author)}</a> · ${esc(p.license_short)}`
+ : `${esc(p.license_short)}`;
+ return `<td style="padding:6px;vertical-align:top;width:33%">
+ <a href="${esc(breedHref)}"><img src="${esc(img)}" alt="${esc(p.breed_name)}" style="width:100%;max-width:200px;height:140px;object-fit:cover;border-radius:6px;display:block"></a>
+ <div style="font-size:13px;font-weight:600;margin-top:6px"><a href="${esc(breedHref)}" style="color:#0f4d3a;text-decoration:none">${esc(p.breed_name)}</a></div>
+ <div style="font-size:10px;color:#888;line-height:1.3">${attrib}</div>
+ </td>`;
+ });
+ // Wrap in 3-up + 2-up rows to keep email clients happy.
+ const rows = [cards.slice(0,3), cards.slice(3,5)].filter(r => r.length);
+ return rows.map(r => `<table cellpadding="0" cellspacing="0" border="0" width="100%"><tr>${r.join('')}</tr></table>`).join('');
+}
+
+function renderShows(shows, state) {
+ if (!shows.length) {
+ return `<p style="color:#666;font-size:14px">No upcoming AKC-style dog shows in ${esc(state || 'your state')} in the next 60 days. <a href="${esc(PUBLIC_BASE)}/events" style="color:#2563eb">Browse all events</a>.</p>`;
+ }
+ const rows = shows.map(s => {
+ const when = fmtDate(s.start_at);
+ const where = [s.venue, s.city, s.state].filter(Boolean).join(', ');
+ const link = s.url ? `<a href="${esc(s.url)}" style="color:#2563eb;text-decoration:none">${esc(s.title)}</a>` : esc(s.title);
+ return `<tr>
+ <td style="padding:6px 8px;border-bottom:1px solid #eee;font-size:13px;color:#555;white-space:nowrap">${esc(when)}</td>
+ <td style="padding:6px 8px;border-bottom:1px solid #eee;font-size:14px">
+ <div style="font-weight:600">${link}</div>
+ <div style="font-size:12px;color:#777">${esc(where)}${s.organizer ? ' · ' + esc(s.organizer) : ''}</div>
+ </td>
+ </tr>`;
+ });
+ return `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="border-collapse:collapse">${rows.join('')}</table>`;
+}
+
+function renderListings(listings, zip) {
+ if (!listings.length) {
+ return `<p style="color:#666;font-size:14px">No new listings in ${esc(zip || 'your area')} this week. <a href="${esc(PUBLIC_BASE)}/marketplace" style="color:#2563eb">Post one yourself</a>.</p>`;
+ }
+ return listings.map(l => {
+ const href = `${PUBLIC_BASE}/marketplace/${l.id}`;
+ const photo = Array.isArray(l.photo_urls) && l.photo_urls.length
+ ? `<img src="${esc(l.photo_urls[0])}" alt="" style="width:80px;height:80px;object-fit:cover;border-radius:6px;float:left;margin-right:12px">`
+ : '';
+ const price = l.price_cents != null ? `<span style="color:#0f4d3a;font-weight:600;margin-left:6px">${fmtPrice(l.price_cents)}</span>` : '';
+ const where = [l.city, l.state, l.zip].filter(Boolean).join(', ');
+ const snippet = (l.body || '').slice(0, 140) + ((l.body || '').length > 140 ? '…' : '');
+ return `<div style="overflow:hidden;padding:12px 0;border-bottom:1px solid #eee">
+ ${photo}
+ <div style="font-size:14px;font-weight:600;margin-bottom:2px">
+ <a href="${esc(href)}" style="color:#111;text-decoration:none">${esc(l.title)}</a>${price}
+ </div>
+ <div style="font-size:12px;color:#777;margin-bottom:4px">${esc(l.listing_type.replace(/_/g,' '))} · ${esc(where)}</div>
+ <div style="font-size:13px;color:#444">${esc(snippet)}</div>
+ </div>`;
+ }).join('');
+}
+
+export function renderDigestEmail({ user, photos, shows, listings, unsubscribeUrl }) {
+ const greeting = user.full_name ? `Hi ${user.full_name.split(/\s+/)[0]},` : 'Hi,';
+ const state = user.home_state || '';
+ const zip = user.home_zip || '';
+
+ const subject = `Your AnimalsDirectory weekly digest — ${fmtDate(new Date())}`;
+
+ const body = `
+<div style="max-width:640px;margin:0 auto;font-family:system-ui,-apple-system,sans-serif;color:#111;line-height:1.5">
+ <h1 style="color:#0f4d3a;font-size:22px;margin:0 0 6px">${esc(greeting)} 🐾</h1>
+ <p style="color:#666;font-size:14px;margin:0 0 24px">A few things worth a look this week from your corner of the kingdom.</p>
+
+ <h2 style="font-size:16px;color:#c97a3e;border-bottom:2px solid #c97a3e;padding-bottom:4px;margin:24px 0 12px">5 photos from the breed gallery</h2>
+ ${renderPhotos(photos)}
+
+ <h2 style="font-size:16px;color:#c97a3e;border-bottom:2px solid #c97a3e;padding-bottom:4px;margin:32px 0 12px">Upcoming dog shows${state ? ` in ${esc(state)}` : ''}</h2>
+ ${renderShows(shows, state)}
+
+ <h2 style="font-size:16px;color:#c97a3e;border-bottom:2px solid #c97a3e;padding-bottom:4px;margin:32px 0 12px">Latest marketplace listings${zip ? ` near ${esc(zip)}` : ''}</h2>
+ ${renderListings(listings, zip)}
+
+ <hr style="margin:32px 0;border:0;border-top:1px solid #ddd">
+ <p style="font-size:12px;color:#888;text-align:center">
+ You're getting this weekly digest because you signed up at <a href="${esc(PUBLIC_BASE)}" style="color:#888">${esc(PUBLIC_BASE.replace(/^https?:\/\//,''))}</a>.<br>
+ <a href="${esc(unsubscribeUrl)}" style="color:#888">Unsubscribe in one click</a> · <a href="${esc(PUBLIC_BASE)}/me" style="color:#888">Manage preferences</a><br>
+ — ${esc(FROM_NAME)}<br>
+ <span style="color:#aaa">${esc(MAILING_ADDRESS)}</span>
+ </p>
+</div>
+`.trim();
+
+ return { subject, body };
+}
+
+// ── send ──────────────────────────────────────────────────────────────────
+
+async function sendViaGeorge({ to, subject, body }) {
+ const r = await fetch(`${GEORGE_URL}/api/send`, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'authorization': 'Basic ' + Buffer.from(GEORGE_AUTH).toString('base64'),
+ },
+ body: JSON.stringify({ to, subject, body }),
+ signal: AbortSignal.timeout(15000),
+ });
+ if (!r.ok) {
+ const text = await r.text().catch(() => '');
+ throw new Error(`george returned ${r.status}: ${text.slice(0, 200)}`);
+ }
+}
+
+// Build + send the digest for a single user. Marks last_newsletter_sent_at on
+// success. Returns {ok, skipped, error?} per user — never throws.
+export async function sendDigestToUser(user, { dryRun = false } = {}) {
+ try {
+ const token = await ensureUnsubscribeToken(user);
+ const unsubscribeUrl = `${PUBLIC_BASE}/auth/unsubscribe?token=${encodeURIComponent(token)}`;
+
+ const [photos, shows, listings] = await Promise.all([
+ fetchBreedPhotos(),
+ fetchUpcomingDogShows(user.home_state),
+ fetchLatestListings(user.home_zip),
+ ]);
+
+ // Skip if there's literally nothing to show. Sending a digest of three
+ // empty sections is just noise.
+ if (!photos.length && !shows.length && !listings.length) {
+ return { ok: false, skipped: true, reason: 'empty_digest' };
+ }
+
+ const { subject, body } = renderDigestEmail({ user, photos, shows, listings, unsubscribeUrl });
+
+ if (dryRun) {
+ return { ok: true, dryRun: true, subject, body_len: body.length, photos: photos.length, shows: shows.length, listings: listings.length };
+ }
+
+ await sendViaGeorge({ to: user.email, subject, body });
+ await pool.query(`UPDATE app_users SET last_newsletter_sent_at = NOW() WHERE id = $1`, [user.id]);
+ return { ok: true, photos: photos.length, shows: shows.length, listings: listings.length };
+ } catch (err) {
+ return { ok: false, error: err.message };
+ }
+}
+
+// Top-level entry point. Used both by agents/animal-agent (kind:weekly_newsletter)
+// and by the standalone `npm run newsletter:weekly` script.
+export async function runWeeklyNewsletter({ limit = null, userIds = null, dryRun = false } = {}) {
+ const t0 = Date.now();
+
+ // Fail closed if compliance gates aren't set. Dry-run still proceeds so
+ // Steve can preview the body locally without setting prod env.
+ if (!dryRun && !MAILING_ADDRESS) {
+ const err = 'NEWSLETTER_MAILING_ADDRESS env unset — refusing to send (CAN-SPAM)';
+ log.error('weekly_newsletter: blocked', { reason: err });
+ return { recipients: 0, ok: 0, skipped: 0, failed: 0, blocked: err };
+ }
+
+ const recipients = await pickRecipients({ limit, userIds });
+ log.info('weekly_newsletter: starting', { count: recipients.length, dryRun });
+
+ let ok = 0, skipped = 0, failed = 0;
+ const failures = [];
+
+ for (const user of recipients) {
+ const result = await sendDigestToUser(user, { dryRun });
+ if (result.ok) ok++;
+ else if (result.skipped) skipped++;
+ else { failed++; failures.push({ user_id: user.id, email: user.email, err: result.error }); }
+ await sleep(MIN_INTERVAL_MS);
+ }
+
+ const summary = {
+ elapsed_ms: Date.now() - t0,
+ recipients: recipients.length,
+ ok, skipped, failed,
+ failures: failures.slice(0, 10), // cap for log size
+ };
+ log.info('weekly_newsletter: done', summary);
+ return summary;
+}
+
+// ── unsubscribe HTTP handler (mounted in community.js) ────────────────────
+
+export async function unsubscribe(req, res) {
+ const token = String(req.query?.token || '');
+ if (!token) return res.status(400).send(unsubPage('Missing token.', false));
+ const row = await one(
+ `UPDATE app_users
+ SET unsubscribed_at = NOW()
+ WHERE unsubscribe_token = $1
+ RETURNING id, email`,
+ [token]
+ );
+ if (!row) {
+ return res.status(400).send(unsubPage(
+ 'This unsubscribe link is invalid or expired. If you keep getting our emails, reply to one and we\'ll remove you manually.',
+ false
+ ));
+ }
+ log.info('newsletter unsubscribed', { userId: row.id });
+ res.send(unsubPage(
+ `${esc(row.email)} has been unsubscribed from the weekly digest. You'll still receive transactional emails (verification, password reset, business-claim links).`,
+ true
+ ));
+}
+
+function unsubPage(message, ok) {
+ const color = ok ? '#16a34a' : '#dc2626';
+ const title = ok ? 'Unsubscribed' : 'Unsubscribe problem';
+ return `<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8">
+<title>${esc(title)} — AnimalsDirectory</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<style>
+ body{font-family:system-ui,sans-serif;max-width:560px;margin:64px auto;padding:0 20px;color:#111;line-height:1.5}
+ h1{color:${color};margin:0 0 16px;font-size:1.5rem}
+ a.btn{display:inline-block;margin-top:18px;background:#2563eb;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none}
+</style></head><body>
+<h1>${esc(title)}</h1>
+<p>${esc(message)}</p>
+<a class="btn" href="/">Continue</a>
+</body></html>`;
+}
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
diff --git a/src/scripts/run_weekly_newsletter.js b/src/scripts/run_weekly_newsletter.js
new file mode 100644
index 0000000..cf914a8
--- /dev/null
+++ b/src/scripts/run_weekly_newsletter.js
@@ -0,0 +1,38 @@
+#!/usr/bin/env node
+// Standalone runner for the Sunday weekly digest. Invoked by the
+// agents/animal-agent `weekly_newsletter` task and by `npm run newsletter:weekly`.
+//
+// Flags:
+// --limit N only send to first N recipients (testing)
+// --user-ids 1,2,3 only send to these app_users.id values (manual re-send)
+// --dry-run build the digest but don't actually call George;
+// don't touch last_newsletter_sent_at
+//
+// Exit 0 if the run completed (even with per-user failures); exit 1 only on
+// hard infra failures (DB unreachable, etc).
+
+import 'dotenv/config';
+import { runWeeklyNewsletter } from '../lib/weekly_newsletter.js';
+
+function parseArgs(argv) {
+ const out = {};
+ for (let i = 2; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--limit') out.limit = parseInt(argv[++i], 10);
+ else if (a === '--user-ids') out.userIds = argv[++i].split(',').map(s => parseInt(s.trim(), 10)).filter(Boolean);
+ else if (a === '--dry-run') out.dryRun = true;
+ }
+ return out;
+}
+
+const opts = parseArgs(process.argv);
+
+try {
+ const summary = await runWeeklyNewsletter(opts);
+ console.log(JSON.stringify(summary, null, 2));
+ process.exit(0);
+} catch (err) {
+ console.error('weekly_newsletter fatal:', err.message);
+ console.error(err.stack);
+ process.exit(1);
+}
diff --git a/src/server/community.js b/src/server/community.js
index f9248e1..5de97f4 100644
--- a/src/server/community.js
+++ b/src/server/community.js
@@ -5,6 +5,8 @@ import express from 'express';
import { pool, many, one } from '../lib/db.js';
import { attachUser, requireUser, signup, login, logout, userCanSeeListing, userCanSeeListingSync } from '../lib/auth.js';
import { verifyToken, resendVerification, requireVerifiedEmail } from '../lib/email_verification.js';
+import { unsubscribe as newsletterUnsubscribe } from '../lib/weekly_newsletter.js';
+import { dispatchLostPetAlert, alertsOff as lostPetAlertsOff } from '../lib/lost_pet_alert.js';
import { geocodeZip } from '../lib/geo.js';
import { renderSignup, renderLogin, renderMarketplace, renderListing, renderListingNew, renderCircle, renderMyCircles, renderMarketingFirms, renderUpgradePreview } from './community_render.js';
@@ -71,6 +73,11 @@ community.post('/auth/login', login);
community.post('/auth/logout', logout);
community.get ('/auth/verify', verifyToken);
community.post('/auth/resend-verification', requireUser, resendVerification);
+// One-click unsubscribe for the weekly digest. No login required (CAN-SPAM).
+community.get ('/auth/unsubscribe', newsletterUnsubscribe);
+// One-click "turn off lost-pet alerts" — same stable unsubscribe_token, but
+// only flips alert_lost_pets (keeps newsletter on).
+community.get ('/auth/alerts/lost-pets/off', lostPetAlertsOff);
// ── marketplace ───────────────────────────────────────────────────────────
community.get('/marketplace', async (req, res) => {
@@ -186,6 +193,17 @@ community.post('/marketplace/new', requireUser, requireVerifiedEmail, rateLimit(
visibility || req.user.visibility_default || 'home_zip_25mi',
safePhotoUrls,
coords?.lat ?? null, coords?.lng ?? null]);
+
+ // Lost-pet listings fan out an opt-in email alert to neighbors (same ZIP
+ // or ZIP-3 prefix). Fire-and-forget — the listing is already committed and
+ // we don't want a slow George response to gate the user's redirect. The
+ // dispatcher dedupes via lost_pet_alerts_sent so retries are safe.
+ if (listing_type === 'lost_pet') {
+ dispatchLostPetAlert(r.id).catch(err =>
+ console.error('lost_pet_alert dispatch failed', { listing_id: r.id, err: err.message })
+ );
+ }
+
res.json({ ok: true, id: r.id, url: `/marketplace/${r.id}` });
});
← 2fa8980 yolo: Business owners can claim their listing
·
back to Animals
·
yolo: Wire Stripe Checkout for upgrade orders 856153b →