[object Object]

← 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

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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 →