Ctrl K
ComunidadGuías…

scheduler.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Background scheduler and concurrency-safe feed delivery pipeline."""

from __future__ import annotations

import base64
import json
import logging
import secrets
import threading
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
from urllib.parse import urlparse

import httpx
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger

import settings
import plans
from database import get_db, timestamp_str, prune_feed_items, as_utc_naive
from email_sender import send_email
from security import decrypt_secret, encrypt_secret
from feed_parser import (
    fetch_feed,
    fetch_image,
    fetch_page_metadata,
    get_new_items,
    get_backdated_items,
    _parse_item_date,
    html_to_text,
    sanitize_html,
    truncate,
)
from filters import is_filtered, match_reason
from mastodon import (
    MastodonAuthError,
    MastodonError,
    idempotency_key as mastodon_idempotency_key,
    post_status,
    upload_media,
)
from bluesky import (
    BLUESKY_IMAGE_TYPES,
    MAX_BLOB_BYTES,
    MAX_IMAGES as BLUESKY_MAX_IMAGES,
    MAX_POST_BYTES,
    MAX_POST_GRAPHEMES,
    BlueskyAccountGoneError,
    BlueskyAuthError,
    BlueskyError,
    build_external_embed,
    build_facets,
    build_image_embed,
    create_post,
    create_session,
    EXTERNAL_THUMB_MAX_BYTES,
    first_link_uri,
    refresh_session,
    resolve_pds,
    session_expiry,
    truncate_graphemes,
    upload_blob,
)
from microblog import (
    MicroblogAuthError,
    MicroblogError,
    create_post as microblog_create_post,
)
from matrix import (
    MATRIX_IMAGE_TYPES,
    MAX_UPLOAD_BYTES as MATRIX_MAX_UPLOAD_BYTES,
    MatrixAuthError,
    MatrixError,
    MatrixPermissionError,
    permalink as matrix_permalink,
    send_image as matrix_send_image,
    send_message as matrix_send_message,
    transaction_id as matrix_transaction_id,
    upload_media as matrix_upload_media,
)
from discord import (
    DiscordAuthError,
    DiscordBadRequestError,
    DiscordError,
    DiscordNotFoundError,
    build_embed as discord_build_embed,
    send_webhook as discord_send_webhook,
)
from webhook import (
    WebhookAuthError,
    WebhookError,
    WebhookNotFoundError,
    WebhookRejectedError,
    build_body_payload as webhook_build_body_payload,
    build_payload as webhook_build_payload,
    load_headers,
    send_webhook as webhook_send_webhook,
)
from github import (
    MAX_FILE_BYTES as GITHUB_MAX_FILE_BYTES,
    GitHubAuthError,
    GitHubError,
    GitHubNotFoundError,
    GitHubRateLimitError,
    GitHubRejectedError,
    build_commit_message as github_build_commit_message,
    build_committer as github_build_committer,
    render_path as github_render_path,
    send_file as github_send_file,
)
from notify import (
    max_attempts,
    next_retry_delay,
    record_failure,
    record_success,
)
import telegram
from telegram import (
    MAX_CAPTION_CHARS,
    MAX_MESSAGE_CHARS,
    TelegramAuthError,
    TelegramBadRequestError,
    TelegramError,
    TelegramNotFoundError,
    build_message as telegram_build_message,
    message_url as telegram_message_url,
    send_message as telegram_send_message,
    send_photo as telegram_send_photo,
)
from template_engine import render_template, render_template_rich
import alt_text
import images
from utils import FeedItem
from utils import utc_now_str as _now
from utils import categories_from_value

logger = logging.getLogger("feedecho.scheduler")

scheduler: BackgroundScheduler | None = None

MASTODON_MAX_CHARS = 500
PENDING_RECLAIM_SECONDS = 10 * 60
FEED_LEASE_SECONDS = 15 * 60
DRIP_QUEUE_CAP = 30
# Releases per flush on the unlimited (limit removed) path, to avoid
# dumping a stale backlog as one burst.
DRIP_DOWNGRADE_BATCH = 10

# Digest email size cap. Bodies are assembled item-by-item; overflow is
# held for the next flush instead of silently truncated (which reported
# dropped content as delivered). A single item larger than the whole cap
# is sent truncated so one giant item cannot wedge the queue forever.
DIGEST_MAX_CHARS = 10000
# Room reserved for the held-items notice appended when overflow occurs.
DIGEST_NOTICE_RESERVE = 80

# Email image embedding caps. Instant-mode email echoes with attach_image
# embed up to EMAIL_MAX_IMAGES images inline (mirrors Mastodon's default
# media limit); EMAIL_MAX_IMAGE_BYTES bounds the total raw bytes so the
# MIME message stays well under common relay size limits after base64
# (roughly 1.37x) inflation. Images beyond the budget are skipped, not
# silently dropped from the log.
EMAIL_MAX_IMAGES = 4
EMAIL_MAX_IMAGE_BYTES = 8 * 1024 * 1024

# Per-echo locks serializing drip rate checks across the feed-check and
# flush threads, so concurrent workers cannot exceed the hourly cap.
_drip_locks: dict[int, threading.Lock] = {}
_drip_locks_guard = threading.Lock()


def _drip_lock(echo_id: int) -> threading.Lock:
    with _drip_locks_guard:
        lock = _drip_locks.get(echo_id)
        if lock is None:
            lock = threading.Lock()
            _drip_locks[echo_id] = lock
        return lock


def _timestamp_after(seconds: int) -> str:
    return (datetime.now(timezone.utc) + timedelta(seconds=seconds)).strftime(
        "%Y-%m-%d %H:%M:%S"
    )


def _acquire_feed_lease(feed_id: int, lease_token: str) -> bool:
    """Atomically acquire a per-feed lease, reclaiming expired leases only."""
    now = _now()
    expires_at = _timestamp_after(FEED_LEASE_SECONDS)

    with get_db() as db:
        result = db.execute(
            """
            UPDATE feeds
               SET lease_token = ?,
                   lease_expires_at = ?
             WHERE id = ?
               AND (
                    lease_token IS NULL
                    OR lease_expires_at IS NULL
                    OR lease_expires_at <= ?
               )
            """,
            (lease_token, expires_at, feed_id, now),
        )
        return result.rowcount == 1


def _renew_feed_lease(feed_id: int, lease_token: str) -> bool:
    """Extend a lease owned by this worker without taking another worker's lease."""
    with get_db() as db:
        result = db.execute(
            """
            UPDATE feeds
               SET lease_expires_at = ?
             WHERE id = ?
               AND lease_token = ?
            """,
            (_timestamp_after(FEED_LEASE_SECONDS), feed_id, lease_token),
        )
        return result.rowcount == 1


def _release_feed_lease(feed_id: int, lease_token: str) -> None:
    """Release only the lease held by this worker."""
    with get_db() as db:
        db.execute(
            """
            UPDATE feeds
               SET lease_token = NULL,
                   lease_expires_at = NULL
             WHERE id = ?
               AND lease_token = ?
            """,
            (feed_id, lease_token),
        )


# ── Job-level leases (D2) ──────────────────────────────────────────────────

# Per-process identifier so every instance generates a unique lease owner.
# Reused across all job-level leases for the lifetime of this process.
_instance_id = secrets.token_urlsafe(16)

# TTLs are generous: each job runs minutes, not seconds, and the lease is
# released in a finally block. The TTL is the crash-safety net.
FLUSH_DIGEST_LEASE_SECONDS = 15 * 60
FLUSH_DRIP_LEASE_SECONDS = 5 * 60
FLUSH_QUEUE_LEASE_SECONDS = 10 * 60


def _acquire_job_lease(job_name: str, ttl_seconds: int) -> bool:
    """Atomically claim a scheduler lease for `job_name`.

    Returns True if the lease was taken (either created fresh, stolen from
    an expired owner, or renewed by the current owner). Returns False if
    another live instance holds the lease.
    """
    now = _now()
    expires_at = _timestamp_after(ttl_seconds)
    with get_db() as db:
        result = db.execute(
            """
            INSERT INTO scheduler_leases (job_name, instance_id, expires_at)
            VALUES (?, ?, ?)
            ON CONFLICT(job_name) DO UPDATE
            SET instance_id = excluded.instance_id,
                expires_at = excluded.expires_at
            WHERE scheduler_leases.instance_id = excluded.instance_id
               OR scheduler_leases.expires_at <= ?
            """,
            (job_name, _instance_id, expires_at, now),
        )
        return result.rowcount == 1


def _release_job_lease(job_name: str) -> None:
    """Release only the lease held by this instance."""
    with get_db() as db:
        db.execute(
            "DELETE FROM scheduler_leases WHERE job_name = ? AND instance_id = ?",
            (job_name, _instance_id),
        )


def _update_last_fetched(feed_id: int, lease_token: str) -> None:
    with get_db() as db:
        db.execute(
            """
            UPDATE feeds
               SET last_fetched = ?
             WHERE id = ?
               AND lease_token = ?
            """,
            (_now(), feed_id, lease_token),
        )


def _set_feed_error(feed_id: int, lease_token: str, message: str) -> None:
    with get_db() as db:
        db.execute(
            "UPDATE feeds SET last_error = ? WHERE id = ? AND lease_token = ?",
            (message[:300], feed_id, lease_token),
        )


def _clear_feed_error(feed_id: int, lease_token: str) -> None:
    with get_db() as db:
        db.execute(
            "UPDATE feeds SET last_error = NULL WHERE id = ? AND lease_token = ?",
            (feed_id, lease_token),
        )


def _save_feed_validators(
    feed_id: int,
    lease_token: str,
    etag: str | None,
    last_modified: str | None,
) -> None:
    """Persist the conditional-GET validators observed on the last fetch.

    A 200 overwrites unconditionally (a response without ETag clears the
    stored one so the next poll fetches unconditionally again); a 304 echoes
    the previously-sent value back through fetch_feed, so this write is a
    no-op on the not-modified path.
    """
    with get_db() as db:
        db.execute(
            "UPDATE feeds SET etag = ?, last_modified = ?"
            " WHERE id = ? AND lease_token = ?",
            (etag, last_modified, feed_id, lease_token),
        )


def _update_cursor(feed_id: int, lease_token: str, cursor_id: str) -> bool:
    """Advance a cursor only while the current worker still owns the lease."""
    with get_db() as db:
        result = db.execute(
            """
            UPDATE feeds
               SET last_item_id = ?,
                   last_fetched = ?
             WHERE id = ?
               AND lease_token = ?
            """,
            (cursor_id, _now(), feed_id, lease_token),
        )
        return result.rowcount == 1


def _store_feed_items(feed_id: int, items: list[dict]) -> None:
    """Persist parsed feed items for the reader, idempotent per (feed_id, item_id).

    Runs on every poll for read-enabled feeds, decoupled from echo delivery.
    ``ON CONFLICT DO NOTHING`` (backed by the UNIQUE(feed_id, item_id) index)
    skips already-stored rows; ``prune_feed_items`` then trims the feed to the
    retention cap. Item dates are normalized to the canonical UTC string so
    publish ordering is stable on both dialects.
    """
    rows = []
    for item in items:
        item_id = item.get("id")
        if not item_id:
            continue
        parsed = _parse_item_date(item.get("date")) if item.get("date") else None
        published_at = (
            parsed.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
            if parsed is not None
            else None
        )
        rows.append((
            feed_id,
            item_id,
            item.get("title") or "",
            item.get("link") or "",
            item.get("summary") or "",
            item.get("content") or "",
            item.get("content_text") or "",
            item.get("content_link") or "",
            item.get("content_html") or "",
            json.dumps(item.get("categories") or {}, ensure_ascii=False),
            item.get("author") or "",
            item.get("image_url") or "",
            item.get("image_alt") or "",
            json.dumps(item.get("image_urls") or [], ensure_ascii=False),
            item.get("enclosure_url") or "",
            published_at,
        ))

    if not rows:
        return

    with get_db() as db:
        for row in rows:
            db.execute(
                """
                INSERT INTO feed_items (
                    feed_id, item_id, title, link, summary, content,
                    content_text, content_link, content_html, categories, author,
                    image_url, image_alt, image_urls, enclosure_url, published_at
                )
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT(feed_id, item_id) DO UPDATE SET
                    title = excluded.title,
                    link = excluded.link,
                    summary = excluded.summary,
                    content = excluded.content,
                    content_text = excluded.content_text,
                    content_link = excluded.content_link,
                    content_html = excluded.content_html,
                    categories = excluded.categories,
                    author = excluded.author,
                    image_url = excluded.image_url,
                    image_alt = excluded.image_alt,
                    image_urls = excluded.image_urls,
                    enclosure_url = excluded.enclosure_url,
                    published_at = excluded.published_at
                """,
                row,
            )
        prune_feed_items(db, feed_id)


def check_feed(feed_id: int) -> None:
    """Fetch and deliver new feed items under an exclusive per-feed lease."""
    lease_token = secrets.token_urlsafe(32)

    if not _acquire_feed_lease(feed_id, lease_token):
        logger.info("Feed %s is already being checked; skipping", feed_id)
        return

    try:
        _check_feed_with_lease(feed_id, lease_token)
    finally:
        _release_feed_lease(feed_id, lease_token)


def _check_feed_with_lease(feed_id: int, lease_token: str) -> None:
    with get_db() as db:
        feed = db.execute(
            "SELECT * FROM feeds WHERE id = ? AND deleted_at IS NULL", (feed_id,)
        ).fetchone()
        if not feed:
            logger.warning("Feed %s not found", feed_id)
            return

        if feed["paused"]:
            logger.info("Feed %s (%s): paused; skipping", feed_id, feed["name"])
            _update_last_fetched(feed_id, lease_token)
            return

        # Hosted mode gate: unverified owners' echoes are skipped until the
        # account email is verified, and EXPIRED-TRIAL owners' feeds are
        # paused entirely (posting_paused: plan='trial' past trial_ends_at).
        # Both gates live in the SQL so no user table is materialized per
        # feed check. Single mode keeps the original query (user 1 has no
        # trial semantics there).
        if settings.MULTI:
            echoes = db.execute(
                "SELECT e.*, u.plan FROM echoes e JOIN users u ON u.id = e.user_id"
                " WHERE e.feed_id = ? AND e.enabled = 1 AND u.email_verified = 1"
                " AND u.suspended = 0"
                " AND NOT (u.plan = 'trial' AND u.trial_ends_at IS NOT NULL"
                "          AND u.trial_ends_at <= ?)",
                (feed_id, _now()),
            ).fetchall()
        else:
            echoes = db.execute(
                "SELECT * FROM echoes WHERE feed_id = ? AND enabled = 1",
                (feed_id,),
            ).fetchall()

    feed_url = feed["url"]
    feed_name = feed["name"]
    last_seen_id = feed["last_item_id"]
    read_enabled = bool(feed["read_enabled"] or 0)

    if not echoes and not read_enabled:
        logger.info(
            "Feed %s (%s): no enabled echoes and reading disabled",
            feed_id,
            feed_name,
        )
        _update_last_fetched(feed_id, lease_token)
        return

    # Conditional GET: advertise validators only when we actually hold them, so
    # fresh feeds fetch unconditionally and existing single-arg fetch_feed
    # fakes keep their seam.
    fetch_kwargs: dict = {}
    if feed["etag"]:
        fetch_kwargs["etag"] = feed["etag"]
    if feed["last_modified"]:
        fetch_kwargs["last_modified"] = feed["last_modified"]

    try:
        feed_data = fetch_feed(feed_url, **fetch_kwargs)
        _clear_feed_error(feed_id, lease_token)
        _save_feed_validators(
            feed_id,
            lease_token,
            feed_data.get("etag"),
            feed_data.get("last_modified"),
        )
    except Exception as exc:
        logger.exception("Feed %s (%s): fetch failed", feed_id, feed_name)
        _set_feed_error(feed_id, lease_token, str(exc))
        _update_last_fetched(feed_id, lease_token)
        return

    if feed_data.get("not_modified"):
        # The feed is unchanged since the stored validators: nothing parsed,
        # nothing to deliver, cursor untouched.
        _update_last_fetched(feed_id, lease_token)
        return

    items = feed_data.get("items") or []
    if not items:
        logger.info("Feed %s (%s): no items", feed_id, feed_name)
        _update_last_fetched(feed_id, lease_token)
        return

    # Reader ingestion is decoupled from echo delivery: store every parsed
    # item regardless of echo config when reading is on, so a read-enabled
    # feed keeps its reading list even before (or without) any attached
    # echo. Queue/review echoes ALSO need the ingestion: the curation queue
    # reconstructs item metadata (images, link cards, categories) from
    # feed_items at dispatch, so a feed destined for the queue must have
    # its items stored even without the reader. The cursor/ordering logic
    # below runs only when echoes exist and is otherwise unchanged.
    try:
        ingest_items = read_enabled or any(
            (e["delivery_mode"] or "instant") in ("queue", "review") for e in echoes
        )
    except (KeyError, IndexError):
        # Malformed echo rows: fall back to the read-enabled gate only.
        ingest_items = read_enabled
    if ingest_items:
        _store_feed_items(feed_id, items)

    if not echoes:
        _update_last_fetched(feed_id, lease_token)
        return

    if last_seen_id is None:
        _update_cursor(feed_id, lease_token, items[0]["id"])
        logger.info(
            "Feed %s (%s): initialized cursor to %s",
            feed_id,
            feed_name,
            items[0]["id"],
        )
        return

    new_items = get_new_items(items, last_seen_id)
    if not new_items and not settings.ALLOW_BACKDATED_ENTRIES:
        _update_last_fetched(feed_id, lease_token)
        return

    cursor_id = last_seen_id

    # Positionally-new items: delivered oldest-first, advancing the cursor.
    for item in new_items:
        if not _renew_feed_lease(feed_id, lease_token):
            logger.warning(
                "Feed %s (%s): lease was lost before processing item %s",
                feed_id,
                feed_name,
                item["id"],
            )
            return

        all_succeeded = True
        for echo in echoes:
            if not process_echo(echo, item, feed_name=feed_name):
                all_succeeded = False

        if not all_succeeded:
            # H-2: Never process or advance past a failed earlier item.
            logger.warning(
                "Feed %s (%s): delivery failed for item %s; stopping cursor advancement",
                feed_id,
                feed_name,
                item["id"],
            )
            break

        cursor_id = item["id"]

    # Backdated items: delivered without moving the cursor. Each echo's
    # _claim_post upsert deduplicates against already-posted rows, so items
    # that were already delivered (positionally or in a prior backdated pass)
    # are silently skipped. A delivery failure here does NOT stop the cursor
    # — the cursor was never at risk of advancing past these items.
    if settings.ALLOW_BACKDATED_ENTRIES:
        backdated = get_backdated_items(
            items,
            last_seen_id,
            max_days=settings.MAX_BACKDATED_ENTRY_DAYS,
            limit=20,
        )
        for item in backdated:
            if not _renew_feed_lease(feed_id, lease_token):
                logger.warning(
                    "Feed %s (%s): lease was lost during backdated delivery",
                    feed_id,
                    feed_name,
                )
                return

            for echo in echoes:
                process_echo(echo, item, feed_name=feed_name)

    if cursor_id != last_seen_id:
        if not _update_cursor(feed_id, lease_token, cursor_id):
            logger.warning("Feed %s: cursor was not updated because lease was lost", feed_id)
    else:
        _update_last_fetched(feed_id, lease_token)

    _retry_due_failures(feed_id, echoes, feed_name=feed_name)


def _retry_due_failures(feed_id: int, echoes, feed_name: str = "") -> None:
    """Reprocess failed rows whose backoff has elapsed, regardless of cursor.

    Normal cursor replay only covers items at-or-after the cursor. Failed rows
    can sit behind it (e.g. an item that failed, was manually reset, or was
    blocked while another echo's item gated advancement). This sweep gives
    them their scheduled retries without disturbing feed ordering guarantees.
    """
    if not echoes:
        return
    echo_ids = [e["id"] for e in echoes]
    placeholders = ",".join("?" for _ in echo_ids)

    with get_db() as db:
        due = db.execute(
            f"""
            SELECT id, echo_id, item_id FROM posted_items
             WHERE echo_id IN ({placeholders})
               AND status = 'failed'
               AND next_retry_at IS NOT NULL
               AND next_retry_at <= ?
             ORDER BY id
             LIMIT 25
            """,
            (*echo_ids, _now()),
        ).fetchall()

    if not due:
        return

    # We only have item_id stored, not the full item payload. Fetch the feed
    # once and match; items that have aged out of the feed are marked gave_up.
    # This fetch is deliberately UNCONDITIONAL (no etag/last_modified): the
    # sweep needs the full current body to match item_ids, and a 304 here
    # would leave every pending item unmatched and marked "gave_up".
    try:
        feed_data = fetch_feed(db_feed_url(feed_id))
    except Exception:
        logger.exception("Feed %s: fetch failed during retry sweep", feed_id)
        return

    items_by_id = {it["id"]: it for it in (feed_data.get("items") or [])}
    echoes_by_id = {e["id"]: e for e in echoes}

    for row in due:
        item = items_by_id.get(row["item_id"])
        if item is None:
            with get_db() as db:
                db.execute(
                    """UPDATE posted_items SET status = 'gave_up',
                          error_message = 'Item no longer in feed; cannot retry',
                          next_retry_at = NULL
                        WHERE id = ? AND status = 'failed'""",
                    (row["id"],),
                )
            continue
        echo = echoes_by_id.get(row["echo_id"])
        if echo is not None:
            process_echo(echo, item, feed_name=feed_name)


def db_feed_url(feed_id: int) -> str:
    with get_db() as db:
        row = db.execute(
            "SELECT url FROM feeds WHERE id = ? AND deleted_at IS NULL", (feed_id,)
        ).fetchone()
    if not row:
        raise ValueError(f"Feed {feed_id} not found")
    return row["url"]


def _claim_post(echo_id: int, item: FeedItem) -> tuple[int, str] | None:
    """Atomically claim an echo/item row.

    Returns ``(posted_item_id, claim_token)`` only for the worker that owns the
    pending attempt. Fresh pending rows cannot be claimed. Failed rows can be
    reclaimed once their backoff (next_retry_at) has elapsed, and pending rows
    abandoned for over ten minutes can be reclaimed.
    """
    item_id = item["id"]
    claim_token = secrets.token_urlsafe(32)
    now = _now()
    stale_before = _timestamp_after(-PENDING_RECLAIM_SECONDS)

    with get_db() as db:
        result = db.execute(
            """
            INSERT INTO posted_items (
                echo_id, item_id, item_title, item_url, status,
                claimed_at, claim_token, attempt_count, error_message
            )
            VALUES (?, ?, ?, ?, 'pending', ?, ?, 1, NULL)
            ON CONFLICT(echo_id, item_id) DO UPDATE SET
                item_title = excluded.item_title,
                item_url = excluded.item_url,
                status = 'pending',
                claimed_at = excluded.claimed_at,
                claim_token = excluded.claim_token,
                attempt_count = posted_items.attempt_count + 1,
                error_message = NULL
            WHERE posted_items.status = 'failed'
               AND (
                    posted_items.next_retry_at IS NULL
                    OR posted_items.next_retry_at <= ?
               )
               OR (
                    posted_items.status = 'pending'
                    AND (
                        posted_items.claimed_at IS NULL
                        OR posted_items.claimed_at <= ?
                    )
               )
            """,
            (
                echo_id,
                item_id,
                item.get("title", ""),
                item.get("link", ""),
                now,
                claim_token,
                now,
                stale_before,
            ),
        )

        if result.rowcount != 1:
            return None

        row = db.execute(
            """
            SELECT id, claim_token
              FROM posted_items
             WHERE echo_id = ?
               AND item_id = ?
            """,
            (echo_id, item_id),
        ).fetchone()

    if not row or row["claim_token"] != claim_token:
        return None

    return row["id"], claim_token


def _row_state(echo_id: int, item_id: str) -> str | None:
    with get_db() as db:
        row = db.execute(
            "SELECT status FROM posted_items WHERE echo_id = ? AND item_id = ?",
            (echo_id, item_id),
        ).fetchone()
    return row["status"] if row else None


def _post_succeeded(echo_id: int, item_id: str) -> bool:
    """True when the item is in a terminal state for this echo.

    'success', 'filtered', 'gave_up', and 'queued' all count as handled:
    they must not be retried and must not block cursor advancement.
    'queued' means the item is waiting for digest flush.
    A 'failed' row waiting out its retry backoff returns False here —
    it still gates the cursor — but process_echo distinguishes it from
    a fresh failure via _row_state so the run stops quietly instead of
    logging new failures.
    """
    return _row_state(echo_id, item_id) in ("success", "filtered", "gave_up", "queued")


def _record_filtered(echo_id: int, item: FeedItem, reason: str | None = None) -> None:
    """Record an item suppressed by the echo's keyword filter.

    Uses status 'filtered' so history shows what was dropped and the claim
    logic never retries it (only 'failed' and stale 'pending' rows are
    reclaimable). Storing the filter reason explains why it was dropped.
    """
    with get_db() as db:
        db.execute(
            """
            INSERT INTO posted_items (
                echo_id, item_id, item_title, item_url, status,
                attempt_count, error_message
            )
            VALUES (?, ?, ?, ?, 'filtered', 0, ?)
            ON CONFLICT(echo_id, item_id) DO UPDATE SET
                error_message = excluded.error_message
            WHERE posted_items.status = 'filtered'
            """,
            (echo_id, item["id"], item.get("title", ""), item.get("link", ""), reason),
        )


def process_echo(
    echo,
    item: FeedItem,
    feed_name: str = "",
    override_content: str | None = None,
) -> bool:
    """Deliver one item to one echo using an atomic pending-row claim.

    Items past a drip rate limit are held in the drip queue (status
    'queued') instead of dispatching immediately; flush_drips() releases
    them as the rate window allows.

    feed_name is optional context for template rendering ({{ feed_name }}).
    When override_content is provided, template rendering is skipped and
    that content is dispatched directly (e.g. from Reader Compose).
    """
    echo_id = echo["id"]
    item_id = item["id"]

    # Keyword filter: suppressed items count as handled so the cursor
    # advances and they are never delivered or retried.
    # .get-style access keeps plain-dict fixtures in tests working.
    try:
        filter_kw = echo["filter_keywords"]
        filter_mode = echo["filter_mode"]
    except (KeyError, IndexError):
        filter_kw, filter_mode = None, None
    reason = match_reason(item, filter_kw, filter_mode)
    if reason is not None:
        _record_filtered(echo_id, item, reason=reason)
        logger.info("Echo %s: item %s suppressed by keyword filter: %s", echo_id, item_id, reason)
        return True

    claimed = _claim_post(echo_id, item)
    if claimed is None:
        # Terminal states (success/filtered/gave_up) count as handled. A fresh
        # pending row belongs to another worker, and a failed row waiting out
        # its retry backoff is deferred — both are "not done", so the cursor
        # stays put and the run stops at this item (H-2), quietly.
        return _post_succeeded(echo_id, item_id)

    posted_id, claim_token = claimed

    try:
        delivery_mode = echo["delivery_mode"] or "instant"
    except (KeyError, IndexError):
        delivery_mode = "instant"
    if delivery_mode in ("queue", "review"):
        return _enqueue_for_queue_mode(
            echo, item, posted_id, claim_token, delivery_mode, feed_name=feed_name
        )

    if _drip_applies(echo):
        # Serialize the rate check + dispatch per echo so the flush job
        # and feed checks cannot both see an open window and exceed the cap.
        with _drip_lock(echo_id):
            if _drip_rate(echo_id) >= _drip_limit(echo):
                return _queue_for_drip(echo, item, posted_id, claim_token)
            return _render_and_dispatch(
                echo, item, feed_name, posted_id, claim_token, override_content=override_content
            )

    return _render_and_dispatch(
        echo, item, feed_name, posted_id, claim_token, override_content=override_content
    )


def _drip_limit(echo) -> int:
    """The echo's drip rate limit (posts/hour); 0 disables drip.

    Hosted mode clamps the configured limit DOWN to the plan's ceiling.
    The plan comes from the 'plan' key on the echo row (joined at selection
    time — set-based, never a per-echo lookup). A missing/unreadable plan
    fails CLOSED at the trial ceiling: drip enforcement never silently
    vanishes; the worst case is over-throttling until the next tick.
    """
    try:
        limit = int(echo["drip_limit"] or 0)
    except (KeyError, IndexError, TypeError, ValueError):
        return 0
    if settings.MULTI and limit > 0:
        try:
            plan = echo["plan"] or "trial"
        except (KeyError, IndexError):
            plan = "trial"  # fail closed
        limit = plans.clamp_drip_limit(limit, plan)
    return limit


def _drip_applies(echo) -> bool:
    """Drip applies to rate-limited echoes with instant delivery only.

    Digest-mode echoes already batch on their own schedule, and
    queue/review modes buffer into the curation queue at a different
    point, so a drip limit would double-throttle them.
    """
    if _drip_limit(echo) <= 0:
        return False
    try:
        delivery_mode = echo["delivery_mode"] or "instant"
    except (KeyError, IndexError):
        delivery_mode = "instant"
    return delivery_mode == "instant"


def _drip_rate(echo_id: int) -> int:
    """Successful posts by this echo within the sliding 60-minute window."""
    cutoff = _timestamp_after(-60 * 60)
    with get_db() as db:
        row = db.execute(
            """
            SELECT COUNT(*) AS n FROM posted_items
             WHERE echo_id = ?
               AND status = 'success'
               AND posted_at >= ?
            """,
            (echo_id, cutoff),
        ).fetchone()
    return row["n"] if row else 0


def _queue_for_drip(echo, item: FeedItem, posted_id: int, claim_token: str) -> bool:
    """Hold an item until the drip window has room.

    Marks the posted row 'queued' (terminal for the cursor, same as
    digest mode) and stores the item payload for flush_drips(). When the
    queue is full the item is dropped as gave_up so an oversubscribed
    feed cannot pile up an unbounded backlog.
    """
    echo_id = echo["id"]
    item_id = item["id"]

    with get_db() as db:
        row = db.execute(
            "SELECT COUNT(*) AS n FROM drip_items WHERE echo_id = ?", (echo_id,)
        ).fetchone()
    if row["n"] >= DRIP_QUEUE_CAP:
        logger.warning(
            "Echo %s: drip queue full (%d); dropping item %s",
            echo_id,
            DRIP_QUEUE_CAP,
            item_id,
        )
        return _fail_post(
            posted_id,
            claim_token,
            echo_id,
            f"Drip queue full ({DRIP_QUEUE_CAP} pending); item dropped",
            permanent=True,
        )

    # 'raw' holds feedparser internals (struct_time etc.) that are not
    # JSON-serializable and never used for rendering.
    payload = {k: v for k, v in item.items() if k != "raw"}
    with get_db() as db:
        db.execute(
            """
            INSERT INTO drip_items (echo_id, item_id, item_json)
            VALUES (?, ?, ?)
            ON CONFLICT(echo_id, item_id) DO NOTHING
            """,
            (echo_id, item_id, json.dumps(payload)),
        )

    ok = _update_post(posted_id, claim_token, "queued")
    if ok:
        # Deliberately no record_success() here: queueing is not a
        # delivery, and clearing the failure-notify state on a hold would
        # suppress alerts for echoes that are not actually delivering.
        logger.info("Echo %s: item %s held for drip release", echo_id, item_id)
    return ok


def _enqueue_for_queue_mode(
    echo,
    item: FeedItem,
    posted_id: int,
    claim_token: str,
    mode: str,
    feed_name: str = "",
) -> bool:
    """Bridge an auto-queue or review-mode echo item into the curation queue.

    Never dispatches via the destination sender: renders the stored template,
    inserts one queued_posts row (status 'queued' for auto-queue, 'draft' for
    review), marks the posted_items row 'queued' so the feed cursor advances
    (same terminal-hold semantics as drip/digest), and lets the existing
    _flush_queue / post-now machinery handle delivery. feed_name is template
    context only ({{ feed_name }}), mirroring the instant path.
    """
    echo_id = echo["id"]
    item_id = item["id"]

    # Defensive plan access mirroring _drip_applies: keys may be missing on
    # test fixtures and plain-dict rows; fail closed at 'trial'.
    try:
        user_id = echo["user_id"]
    except (KeyError, IndexError):
        user_id = 1
    try:
        plan = echo["plan"] or "trial"
    except (KeyError, IndexError):
        plan = "trial"

    with get_db() as db:
        if settings.MULTI:
            # Plan cap enforced like the Compose desk: fail LOUDLY, never
            # silently drop the item. _check_allowance adds its own +1
            # ("adding one more would exceed the cap"), so pass the raw
            # count — passing n+1 double-counted and rejected at cap-1.
            row = db.execute(
                """
                SELECT COUNT(*) AS n FROM queued_posts
                 WHERE user_id = ?
                   AND status IN ('queued', 'sending', 'draft')
                """,
                (user_id,),
            ).fetchone()
            try:
                plans.check_queue_allowance(row["n"], plan)
            except plans.PlanError:
                return _fail_post(
                    posted_id,
                    claim_token,
                    echo_id,
                    "Echo auto-queue is at your plan's queue limit;"
                    " free space or switch this echo back to Instant",
                )

    # Render the stored template into materialized text. The queued row
    # carries the content verbatim from here on (same as Reader Compose).
    try:
        content = render_template(echo["template"], item, feed_name=feed_name)
    except Exception as e:
        logger.exception("Echo %s: template render failed for item %s", echo_id, item_id)
        return _fail_post(posted_id, claim_token, echo_id, f"Template rendering failed: {e}")
    if not content.strip():
        return _fail_post(posted_id, claim_token, echo_id, "Rendered content was empty")

    try:
        visibility = echo["visibility"] or "public"
    except (KeyError, IndexError):
        visibility = "public"
    try:
        feed_id = echo["feed_id"]
    except (KeyError, IndexError):
        feed_id = 0
    attach_image = _echo_attach_image(echo)
    image_alt = item.get("image_alt") or ""
    try:
        content_warning = echo["content_warning"] or ""
    except (KeyError, IndexError):
        content_warning = ""
    try:
        use_markdown = 1 if echo["use_markdown"] else 0
    except (KeyError, IndexError):
        use_markdown = 0

    # Resolve the feed_items row id so _flush_queue dispatches with the
    # full item metadata (images, content_html, categories) the way the
    # Reader Compose enqueue does. _check_feed_with_lease ingests items for
    # ANY feed with a queue/review echo (the same condition that routes us
    # here), but the row can still be absent (pruned by retention before a
    # late slot, or the item fell out of the feed) — a miss is NOT fatal:
    # the queued row carries its rendered content and _flush_queue
    # tolerates NULL there (http metadata is then blank, matching the
    # manual-compose fallback).
    now_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
    with get_db() as db:
        fi_row = db.execute(
            "SELECT id FROM feed_items WHERE feed_id = ? AND item_id = ?",
            (feed_id, item_id),
        ).fetchone()
        metadata_feed_item_id = fi_row["id"] if fi_row else None

        # One-shot echo at enqueue time so crash-recovery retries reuse the
        # same echo_id (idempotency key for _claim_post), mirroring the
        # Reader Compose desk's insert. The stored content carries the real
        # text; this echoed row is inert for re-rendering. The parent's
        # content warning / markdown flag are carried over so dispatch
        # keeps the echo's configured voice.
        new_echo_id = db.execute(
            """
            INSERT INTO echoes (feed_id, destination_type, destination_id, template,
                                visibility, attach_image, content_warning, use_markdown,
                                enabled, one_shot, deleted_at, user_id)
            VALUES (?, ?, ?, '{{ title }}', ?, ?, ?, ?, 0, 1, ?, ?) RETURNING id
            """,
            (feed_id, echo["destination_type"], echo["destination_id"], visibility,
             attach_image, content_warning, use_markdown, now_utc, user_id),
        ).fetchone()["id"]

        # Prefer the freshly-read feed_items id when we got one; fall back
        # to whatever the caller supplied (tests pass items without rows).
        supplied = None
        try:
            supplied = item.get("feed_item_id")
        except AttributeError:
            pass
        queued_feed_item_id = metadata_feed_item_id or supplied

        if mode == "review":
            target_time = _now()
        else:
            target_time = next_free_slot(
                db, user_id, echo["destination_type"], echo["destination_id"]
            )

        db.execute(
            """
            INSERT INTO queued_posts (
                user_id, feed_item_id, item_id, feed_id, destination_type, destination_id,
                content, visibility, attach_image, image_alt, scheduled_at, status, echo_id
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                user_id,
                queued_feed_item_id,
                item_id,
                feed_id,
                echo["destination_type"],
                echo["destination_id"],
                content,
                visibility,
                attach_image,
                image_alt,
                target_time,
                "draft" if mode == "review" else "queued",
                new_echo_id,
            ),
        )

    ok = _update_post(posted_id, claim_token, "queued")
    if ok:
        # Deliberately no record_success() here: queueing is not a delivery,
        # matching drip semantics.
        logger.info(
            "Echo %s: item %s enqueued for %s at %s",
            echo_id,
            item_id,
            mode,
            target_time,
        )
    return ok


def next_free_slot(db, uid: int, dest_type: str, dest_id: int) -> str:
    """Compute the next free scheduled_at timestamp for (uid, destination).

    max(now, last scheduled_at) + interval_minutes.
    Snaps into queue_active_hours ("HH:MM-HH:MM") in UTC.
    """
    dest_key = f"{dest_type}:{dest_id}"
    setting = db.execute(
        "SELECT queue_interval_minutes, queue_active_hours FROM queue_settings WHERE user_id = ? AND destination_key = ?",
        (uid, dest_key),
    ).fetchone()

    interval = setting["queue_interval_minutes"] if setting else 240
    active_hours = setting["queue_active_hours"] if setting else "09:00-21:00"

    last_row = db.execute(
        """
        SELECT scheduled_at FROM queued_posts
         WHERE user_id = ? AND destination_type = ? AND destination_id = ?
           AND status IN ('queued', 'sending')
         ORDER BY scheduled_at DESC LIMIT 1
        """,
        (uid, dest_type, dest_id),
    ).fetchone()

    now_dt = datetime.now(timezone.utc).replace(tzinfo=None, second=0, microsecond=0)
    if last_row and last_row["scheduled_at"]:
        last_dt = as_utc_naive(last_row["scheduled_at"])
        base_dt = max(now_dt, last_dt) if last_dt else now_dt
    else:
        base_dt = now_dt

    candidate = base_dt + timedelta(minutes=interval)

    # Snap to active hours if configured
    if active_hours and "-" in active_hours:
        try:
            start_str, end_str = active_hours.split("-", 1)
            sh, sm = [int(p) for p in start_str.split(":", 1)]
            eh, em = [int(p) for p in end_str.split(":", 1)]
            start_mins = sh * 60 + sm
            end_mins = eh * 60 + em
            cand_mins = candidate.hour * 60 + candidate.minute

            if start_mins <= end_mins:
                # Same day window, e.g. 09:00 - 21:00
                if cand_mins < start_mins:
                    candidate = candidate.replace(hour=sh, minute=sm)
                elif cand_mins > end_mins:
                    # Move to next day start
                    candidate = (candidate + timedelta(days=1)).replace(hour=sh, minute=sm)
            else:
                # Overnight window, e.g. 22:00 - 06:00
                if end_mins < cand_mins < start_mins:
                    candidate = candidate.replace(hour=sh, minute=sm)
        except Exception:
            pass

    return candidate.strftime("%Y-%m-%d %H:%M:%S")


def _reclaim_queued(echo_id: int, item_id: str) -> tuple[int, str] | None:
    """Transition a queued posted row back to pending with a fresh claim.

    Returns (posted_id, claim_token), or None if the row is no longer
    queued (already released or finalized by another worker).

    Deliberately does not bump attempt_count: release attempts are
    tracked on the drip_items row so failed releases can retry from the
    stored payload without burning the normal retry budget.
    """
    claim_token = secrets.token_urlsafe(32)
    with get_db() as db:
        row = db.execute(
            """
            SELECT id FROM posted_items
             WHERE echo_id = ? AND item_id = ? AND status = 'queued'
            """,
            (echo_id, item_id),
        ).fetchone()
        if not row:
            return None
        result = db.execute(
            """
            UPDATE posted_items
               SET status = 'pending',
                   claimed_at = ?,
                   claim_token = ?
             WHERE id = ? AND status = 'queued'
            """,
            (_now(), claim_token, row["id"]),
        )
        if result.rowcount != 1:
            return None
    return row["id"], claim_token


def _render_and_dispatch(
    echo,
    item: FeedItem,
    feed_name: str,
    posted_id: int,
    claim_token: str,
    override_content: str | None = None,
) -> bool:
    """Render the echo template and dispatch to the destination sender.

    Shared by process_echo (fresh feed items) and flush_drips (released
    queue items) so both paths get identical rendering, failure handling,
    and image/alt-text/CW behavior. When override_content is not None,
    template rendering is skipped.
    """
    echo_id = echo["id"]
    item_id = item["id"]
    destination_type = echo["destination_type"]

    if override_content is not None:
        content = override_content
        from template_engine import _MAX_OUTPUT
        if len(content) > _MAX_OUTPUT:
            gave_up = _fail_post(
                posted_id,
                claim_token,
                echo_id,
                f"Content exceeds {_MAX_OUTPUT}-char cap",
            )
            return gave_up
    else:
        try:
            if destination_type == "bluesky":
                # Rich render: content_html anchors become visible text plus
                # link spans the Bluesky sender merges into facets. Stored
                # as _rich_links on a copied item; the drip path (override
                # content) has no spans and falls back to bare-URL facets.
                content, rich_links = render_template_rich(
                    echo["template"], dict(item), feed_name=feed_name
                )
                item = dict(item)  # type: ignore[assignment]
                item["_rich_links"] = rich_links  # type: ignore[typeddict-unknown-key]
            else:
                content = render_template(echo["template"], item, feed_name=feed_name)
        except Exception as e:
            logger.exception("Echo %s: template render failed for item %s", echo_id, item_id)
            gave_up = _fail_post(
                posted_id,
                claim_token,
                echo_id,
                f"Template rendering failed: {e}",
            )
            return gave_up

    if not content.strip():
        gave_up = _fail_post(posted_id, claim_token, echo_id, "Rendered content was empty")
        return gave_up

    if destination_type == "webhook":
        return _send_webhook(echo, item, content, feed_name, echo["destination_id"], posted_id, claim_token)

    handler = _DESTINATION_HANDLERS.get(destination_type)
    if handler is not None:
        return handler(echo, item, content, echo["destination_id"], posted_id, claim_token)

    gave_up = _fail_post(
        posted_id,
        claim_token,
        echo_id,
        f"Unknown destination type: {echo['destination_type']}",
    )
    return gave_up


def _still_owns_claim(posted_id: int, claim_token: str) -> bool:
    """Whether this worker still holds the claim on a pending row.

    Dispatch paths do slow network I/O between _claim_post and the actual
    send. If the lease lapses and another worker reclaims the row, both would
    post. Check immediately before the irreversible step.
    """
    with get_db() as db:
        return (
            db.execute(
                """
                SELECT 1 FROM posted_items
                 WHERE id = ? AND status = 'pending' AND claim_token = ?
                """,
                (posted_id, claim_token),
            ).fetchone()
            is not None
        )


def _update_post(
    posted_id: int,
    claim_token: str,
    status: str,
    error: str | None = None,
    post_url: str | None = None,
) -> bool:
    """Finalize only the pending row still owned by this claim token."""
    with get_db() as db:
        result = db.execute(
            """
            UPDATE posted_items
               SET status = ?,
                   error_message = ?,
                   post_url = COALESCE(?, post_url),
                   claimed_at = NULL,
                   claim_token = NULL,
                   posted_at = ?
             WHERE id = ?
               AND status = 'pending'
               AND claim_token = ?
            """,
            (status, error, post_url, _now(), posted_id, claim_token),
        )
        return result.rowcount == 1


def _fail_post(
    posted_id: int,
    claim_token: str,
    echo_id: int,
    error: str,
    permanent: bool = False,
) -> bool:
    """Mark a claimed row failed, scheduling the next automatic retry.

    If the row has exhausted retry_max_attempts, it is marked 'gave_up'
    (terminal) instead, which unblocks the feed cursor. Passing
    ``permanent=True`` skips straight to 'gave_up' for failures that no
    number of retries can fix (missing account, rejected credentials).
    Returns the final status written.
    """
    cap = max_attempts(echo_id)

    with get_db() as db:
        row = db.execute(
            "SELECT attempt_count FROM posted_items WHERE id = ?", (posted_id,)
        ).fetchone()
    attempts = row["attempt_count"] if row else 1

    if permanent or (cap > 0 and attempts >= cap):
        final = "gave_up"
        if permanent:
            error_out = error
        else:
            error_out = f"Gave up after {attempts} attempts. Last error: {error}"
        retry_at = None
    else:
        final = "failed"
        error_out = error
        retry_at = next_retry_delay(attempts, echo_id=echo_id)

    with get_db() as db:
        db.execute(
            """
            UPDATE posted_items
               SET status = ?,
                   error_message = ?,
                   next_retry_at = ?,
                   claimed_at = NULL,
                   claim_token = NULL,
                   posted_at = ?
             WHERE id = ?
               AND status = 'pending'
               AND claim_token = ?
            """,
            (final, error_out, retry_at, _now(), posted_id, claim_token),
        )

    record_failure(echo_id)
    if final == "gave_up":
        logger.error(
            "Echo %s: item gave up after %s attempts: %s", echo_id, attempts, error
        )
    return final == "gave_up"


def _item_image_entries(item: FeedItem, limit: int = 4) -> list[dict]:
    """Image URL/alt entries from an item, shared by destination senders.

    Returns up to `limit` entries of {"url", "alt"} taken from the item's
    image_urls list, falling back to the single image_url for legacy rows.
    A caller-supplied image_alt (reader compose / queue override) wins over
    the feed alt for the primary slot — the user wrote it.
    """
    image_entries: list[dict] = []
    raw_urls = item.get("image_urls")
    if raw_urls:
        if isinstance(raw_urls, str):
            try:
                raw_urls = json.loads(raw_urls)
            except ValueError:
                raw_urls = []
        if isinstance(raw_urls, list):
            for img in raw_urls[:limit]:
                if isinstance(img, dict) and isinstance(img.get("url"), str) and img["url"].strip():
                    image_entries.append(
                        {"url": img["url"].strip(), "alt": (img.get("alt") or "").strip()}
                    )
                elif isinstance(img, str) and img.strip():
                    image_entries.append({"url": img.strip(), "alt": ""})
    if not image_entries and item.get("image_url"):
        image_entries.append(
            {"url": item.get("image_url"), "alt": (item.get("image_alt") or "").strip()}
        )
    if image_entries and item.get("image_alt") and not image_entries[0]["alt"]:
        image_entries[0]["alt"] = item["image_alt"].strip()
    return image_entries


# --- Shared destination-dispatch skeleton ----------------------------------
#
# The seven _send_* dispatchers below all run the same shape: fetch the
# account row (missing = permanent failure), resolve the attach_image flag,
# re-validate the claim right before the irreversible send, then finalize.
# These helpers hold that shared skeleton so a fix lands in one place — the
# mastodon missing-account path drifting off permanent=True is what happens
# when it is copy-pasted instead.


def _destination_account(
    table: str,
    account_id: int,
    echo,
    posted_id: int,
    claim_token: str,
    label: str,
    user_scoped: bool = False,
):
    """Fetch the destination account row; fail permanently when gone.

    A missing account can never heal on retry (the user deleted it), so the
    row finalizes straight to 'gave_up'. Returns ``(account, None)`` on
    success, or ``(None, fail_result)`` after finalizing the row — callers
    return ``fail_result`` directly, preserving _fail_post's gave_up=True
    contract that unblocks the feed cursor.
    """
    if user_scoped:
        sql = f"SELECT * FROM {table} WHERE id = ? AND user_id = ?"
        params = (account_id, echo["user_id"])
    else:
        sql = f"SELECT * FROM {table} WHERE id = ?"
        params = (account_id,)
    with get_db() as db:
        account = db.execute(sql, params).fetchone()

    if not account:
        fail_result = _fail_post(
            posted_id,
            claim_token,
            echo["id"],
            f"{label} {account_id} not found",
            permanent=True,
        )
        return None, fail_result
    return account, None


def _echo_attach_image(echo) -> bool:
    """The echo's attach_image flag, defaulting off for legacy/partial rows."""
    try:
        return bool(echo["attach_image"])
    except (KeyError, IndexError):
        return False


def _echo_render_html(echo) -> bool:
    """The echo's render_html flag (email/Matrix destinations), defaulting off."""
    try:
        return bool(echo["render_html"])
    except (KeyError, IndexError):
        return False


def _echo_use_markdown(echo) -> bool:
    """The echo's use_markdown flag (Mastodon/GoToSocial), defaulting off."""
    try:
        return bool(echo["use_markdown"])
    except (KeyError, IndexError):
        return False


def _feed_ai_alt_override(feed_id) -> bool:
    """The feed's ai_alt_override flag, defaulting off for legacy/partial rows.

    Without the flag (missing column value, missing feed row, lookup
    failure) the historical feed-alt-wins precedence applies, so every
    failure mode here must read as False.
    """
    if feed_id is None:
        return False
    try:
        with get_db() as db:
            row = db.execute(
                "SELECT ai_alt_override FROM feeds WHERE id = ?", (feed_id,)
            ).fetchone()
    except Exception:
        logger.warning(
            "Feed %s: ai_alt_override lookup failed; defaulting to off",
            feed_id, exc_info=True,
        )
        return False
    if not row:
        return False
    try:
        return bool(row["ai_alt_override"])
    except (KeyError, IndexError):
        return False


def _echo_feed_id(echo):
    try:
        return echo["feed_id"]
    except (KeyError, IndexError):
        return None


def _resolve_alt_text(echo, item, feed_alt: str, img_bytes, img_type) -> str:
    """Resolve the alt text to attach to an uploaded image ("" when neither).

    Default precedence: feed-provided alt text wins; AI generation is the
    fallback, degrading to an empty string on AI failure. When the feed's
    feeds.ai_alt_override flag is set the precedence flips: AI generation
    runs even with non-empty feed_alt and wins on success, with feed_alt
    kept as the fallback when AI fails or is disabled for the user.
    """
    ai_override = _feed_ai_alt_override(_echo_feed_id(echo))
    description = feed_alt
    if ai_override:
        try:
            if alt_text.is_enabled(user_id=echo["user_id"]):
                generated = alt_text.generate_alt_text(
                    img_bytes, img_type, user_id=echo["user_id"]
                )
                if generated:
                    logger.info(
                        "Echo %s: AI alt text override wins for item %s (%d chars)",
                        echo["id"], item["id"], len(generated),
                    )
                    description = generated
        except Exception:
            # A failed override keeps the feed-provided alt rather than
            # posting the image with nothing. Documented in the v1.78.0 notes
            # ("Feed descriptions remain the fallback").
            logger.warning(
                "Echo %s: alt text override pipeline failed for item %s; "
                "falling back to feed-provided alt",
                echo["id"], item["id"],
                exc_info=True,
            )
    elif description:
        logger.info(
            "Echo %s: using feed-provided alt text for item %s (%d chars)",
            echo["id"], item["id"], len(description),
        )
    elif alt_text.is_enabled(user_id=echo["user_id"]):
        try:
            description = alt_text.generate_alt_text(
                img_bytes, img_type, user_id=echo["user_id"]
            )
            if description:
                logger.info(
                    "Echo %s: generated alt text for item %s (%d chars)",
                    echo["id"], item["id"], len(description),
                )
        except Exception:
            logger.warning(
                "Echo %s: alt text generation failed for item %s",
                echo["id"], item["id"],
                exc_info=True,
            )
    return description


def _guard_claim(posted_id: int, claim_token: str, echo_id, item_id, dest_name: str) -> bool:
    """Re-validate claim ownership immediately before the irreversible send.

    Dispatch paths do slow network I/O (image fetch, alt text, upload)
    between _claim_post and the send; if the lease lapsed and another worker
    reclaimed the row, sending now would duplicate the public post. Returns
    True when this worker still owns the claim.
    """
    if not _still_owns_claim(posted_id, claim_token):
        logger.warning(
            "Echo %s: claim lost before %s dispatch; skipping item %s",
            echo_id,
            dest_name,
            item_id,
        )
        return False
    return True


def _finalize_success(
    posted_id: int, claim_token: str, echo_id, post_url: str | None = None
) -> bool:
    """Mark the row delivered and clear the echo's failure alert state."""
    ok = _update_post(posted_id, claim_token, "success", post_url=post_url)
    if ok:
        record_success(echo_id)
    return ok


def _maybe_boost(account, echo, post_url, visibility: str = "public") -> None:
    """Fire-and-forget boost request to the FeedBooster service, when a
    booster is configured and either the destination account or this
    individual echo has boosting enabled (the account setting covers every
    echo to it, so either flag is sufficient).

    Caller-side visibility gate: only public/unlisted echoes are boosted.
    The booster re-checks the object's Public addressing on its side, but
    the leak must be prevented here, not caught downstream.

    Best-effort by design: the post is already published, so a booster outage
    or refusal must never fail the delivery — log-only, no retries from this
    side (the booster keeps its own retry queue).
    """
    if visibility not in ("public", "unlisted"):
        return
    try:
        account_on = bool(account["booster_enabled"])
    except (KeyError, IndexError, TypeError):
        account_on = False
    try:
        echo_on = bool(echo["booster_enabled"])
    except (KeyError, IndexError, TypeError):
        echo_on = False
    if not (account_on or echo_on):
        return
    if not settings.BOOSTER_URL or not settings.BOOSTER_TOKEN:
        return
    try:
        echo_id = echo["id"]
    except (KeyError, IndexError, TypeError):
        echo_id = "?"
    try:
        response = httpx.post(
            f"{settings.BOOSTER_URL}/internal/boost",
            json={"url": post_url},
            headers={"authorization": f"Bearer {settings.BOOSTER_TOKEN}"},
            timeout=5,
        )
    except Exception:
        logger.warning("Echo %s: boost request for %s failed", echo_id, post_url, exc_info=True)
        return
    if response.status_code >= 400:
        logger.warning(
            "Echo %s: booster refused %s: HTTP %s", echo_id, post_url, response.status_code
        )
    else:
        logger.info("Echo %s: boost requested for %s", echo_id, post_url)


def _send_mastodon(
    echo,
    item: FeedItem,
    content: str,
    account_id: int,
    posted_id: int,
    claim_token: str,
) -> bool:
    account, fail_result = _destination_account(
        "accounts", account_id, echo, posted_id, claim_token, "Account"
    )
    if account is None:
        return fail_result

    # Content warning: per-echo CW text, applied via Mastodon's spoiler_text
    try:
        cw_text = (echo["content_warning"] or "").strip()
    except (KeyError, IndexError):
        cw_text = ""
    sensitive = bool(cw_text)

    # Image attachment: if enabled, upload up to 4 images (Mastodon's default
    # limit) from the item's image_urls list, falling back to the single
    # image_url for legacy rows.
    media_ids: list[str] = []
    attach_image = _echo_attach_image(echo)

    if attach_image:
        image_entries = _item_image_entries(item)

        for entry in image_entries:
            image_result = fetch_image(entry["url"])
            if not image_result:
                logger.info(
                    "Echo %s: image fetch failed for %s, skipping that image",
                    echo["id"], entry["url"],
                )
                continue
            img_bytes, img_type = image_result
            # Feed-provided alt text wins (the author wrote it); AI
            # generation is the fallback when the feed has none.
            description = _resolve_alt_text(echo, item, entry["alt"], img_bytes, img_type)
            try:
                uploaded = upload_media(
                    instance=account["instance"],
                    access_token=decrypt_secret(account["access_token"]),
                    image_bytes=img_bytes,
                    content_type=img_type,
                    description=description,
                )
            except MastodonAuthError as e:
                # Permanent (revoked/expired token, or a token that can post
                # but lacks the media scope): finalize the post as
                # permanently failed right here. Falling through to
                # post_status would silently publish text-only whenever the
                # token can post but not upload media, and would route a
                # pure 401 into the transient-retry path whenever
                # post_status hit an unrelated transient failure.
                # _fail_post fences on the claim token, so a lease lost
                # during the image I/O no-ops here exactly as it does on
                # every other failure path.
                logger.error(
                    "Echo %s: Mastodon token rejected during image upload for item %s: %s",
                    echo["id"],
                    item["id"],
                    e,
                )
                return _fail_post(
                    posted_id,
                    claim_token,
                    echo["id"],
                    f"Mastodon token rejected: {e}",
                    permanent=True,
                )
            if uploaded and uploaded.get("id"):
                media_ids.append(str(uploaded["id"]))
                logger.info(
                    "Echo %s: uploaded image %s for item %s",
                    echo["id"],
                    uploaded["id"],
                    item["id"],
                )
            else:
                logger.warning(
                    "Echo %s: image upload failed for item %s, continuing without that image",
                    echo["id"],
                    item["id"],
                )
        if not media_ids and image_entries:
            logger.warning(
                "Echo %s: no images uploaded for item %s, posting text-only",
                echo["id"],
                item["id"],
            )

    # Re-validate claim ownership immediately before the post: the image
    # fetch, media upload and alt-text generation above are slow network I/O,
    # so the lease can lapse and another worker reclaim the row. Without this
    # both workers post and the loser's _update_post silently no-ops. The
    # Bluesky path has always done this.
    if not _guard_claim(posted_id, claim_token, echo["id"], item["id"], "Mastodon"):
        return False

    try:
        result = post_status(
            instance=account["instance"],
            access_token=decrypt_secret(account["access_token"]),
            content=truncate(content, MASTODON_MAX_CHARS),
            visibility=echo["visibility"],
            sensitive=sensitive,
            spoiler_text=cw_text,
            media_ids=media_ids or None,
            # Deterministic key from (echo_id, item_id): a crash-then-reclaim
            # retry of this same logical post reuses the key, so Mastodon
            # returns the original status instead of creating a duplicate.
            idempotency_key=mastodon_idempotency_key(echo["id"], item["id"]),
            content_type="text/markdown" if _echo_use_markdown(echo) else None,
        )
    except MastodonAuthError as e:
        # Token rejected: retries cannot help until the user reconnects.
        logger.error("Echo %s: Mastodon token rejected: %s", echo["id"], e)
        return _fail_post(
            posted_id,
            claim_token,
            echo["id"],
            f"Mastodon token rejected: {e}",
            permanent=True,
        )
    except (MastodonError, httpx.HTTPStatusError) as e:
        # _raise_for_status delegates every non-auth HTTP failure to
        # httpx's HTTPStatusError, so catch it here rather than in the
        # generic handler — the API's own error text belongs in the row's
        # error_message, not a flat "delivery failed".
        logger.exception("Echo %s: Mastodon post failed", echo["id"])
        return _fail_post(posted_id, claim_token, echo["id"], f"Mastodon delivery failed: {e}")
    except Exception:
        logger.exception("Echo %s: Mastodon post failed unexpectedly", echo["id"])
        return _fail_post(posted_id, claim_token, echo["id"], "Mastodon delivery failed")

    # The API returns the canonical permalink; persisting it gives the
    # history page a "view post" link exactly like the Bluesky and
    # micro.blog paths. Some instances omit `url`, and delivery still
    # succeeded, so a missing URL is not an error.
    post_url = ""
    raw_url = result.get("url") if isinstance(result, dict) else None
    if isinstance(raw_url, str) and raw_url:
        post_url = raw_url

    if post_url:
        # echo is a sqlite Row: bracket access can raise on a missing column.
        try:
            echo_visibility = echo["visibility"]
        except (KeyError, IndexError):
            echo_visibility = "public"
        _maybe_boost(account, echo, post_url, echo_visibility)

    return _finalize_success(posted_id, claim_token, echo["id"], post_url=post_url or None)


def _send_email_echo(
    echo,
    item: FeedItem,
    content: str,
    email_account_id: int,
    posted_id: int,
    claim_token: str,
) -> bool:
    # Check if this echo is in digest mode — if so, queue for later sending
    try:
        delivery_mode = echo["delivery_mode"]
    except (KeyError, IndexError):
        delivery_mode = "instant"

    if delivery_mode == "digest":
        return _queue_for_digest(echo, item, content, posted_id, claim_token)

    # Instant mode: send immediately
    account, fail_result = _destination_account(
        "email_accounts", email_account_id, echo, posted_id, claim_token, "Email account"
    )
    if account is None:
        return fail_result

    # Image embedding: mirroring the Mastodon path, attach_image fetches the
    # item's images (SSRF-validated, fallback-proxy aware) and embeds them
    # inline in the message. A failed fetch skips that image — the email
    # still goes out with the rendered text.
    images: list[dict] = []
    attach_image = _echo_attach_image(echo)
    if attach_image:
        total_bytes = 0
        for entry in _item_image_entries(item, limit=EMAIL_MAX_IMAGES):
            image_result = fetch_image(entry["url"])
            if not image_result:
                logger.info(
                    "Echo %s: image fetch failed for %s, skipping that image",
                    echo["id"], entry["url"],
                )
                continue
            img_bytes, img_type = image_result
            if total_bytes + len(img_bytes) > EMAIL_MAX_IMAGE_BYTES:
                logger.info(
                    "Echo %s: email image budget (%d bytes) reached, "
                    "skipping remaining images",
                    echo["id"], EMAIL_MAX_IMAGE_BYTES,
                )
                break
            total_bytes += len(img_bytes)
            images.append(
                {"data": img_bytes, "content_type": img_type, "alt": entry["alt"]}
            )

    # Re-validate claim ownership immediately before the send: email is a
    # one-way send too, and the image-fetch loop above is slow network I/O
    # that can outlast the lease, so the lease can lapse and another worker
    # reclaim the row. Checking here (right before send, not before the
    # fetch loop) matches the other senders and closes the window where a
    # slow fetch let both workers send.
    if not _guard_claim(posted_id, claim_token, echo["id"], item["id"], "email"):
        return False

    try:
        send_email(
            to_email=account["email"],
            subject=truncate(
                item.get("title") or item.get("link") or "FeedEcho: New Post",
                200,
            ),
            body=content,
            user_id=echo["user_id"],
            images=images,
            render_html=_echo_render_html(echo),
        )
    except Exception:
        logger.exception("Echo %s: email delivery failed", echo["id"])
        return _fail_post(posted_id, claim_token, echo["id"], "Email delivery failed")
    return _finalize_success(posted_id, claim_token, echo["id"])


def _bsky_session(account) -> dict:
    """Return a usable Bluesky session, caching JWTs in the account row.

    Resolves the account's PDS when unknown, reuses a cached access JWT while
    unexpired, refreshes via the refresh JWT when possible, and falls back to
    a fresh app-password login otherwise. All network I/O happens with no DB
    connection held; only the short cache write opens one.
    """
    handle = account["handle"]
    app_password = decrypt_secret(account["app_password"])
    pds = (account["pds"] or "").strip()
    did = (account["did"] or "").strip()
    access_jwt = decrypt_secret(account["access_jwt"] or "").strip()
    refresh_jwt = decrypt_secret(account["refresh_jwt"] or "").strip()
    now = _now()

    resolved_did = None
    if not pds:
        resolved_did, pds = resolve_pds(handle)

    if access_jwt:
        # Normalize before comparing: psycopg returns a datetime for the
        # TIMESTAMP column, sqlite returns the stored string. Comparing a
        # datetime against the _now() string raises TypeError, which the
        # caller's broad handler would report as "Bluesky session failed"
        # on every post after the first.
        expires_at = timestamp_str(account["session_expires_at"])
        if expires_at and expires_at > now:
            # Persist a late PDS/DID resolution without disturbing the session.
            if resolved_did:
                with get_db() as db:
                    db.execute(
                        "UPDATE bluesky_accounts SET did = ?, pds = ? WHERE id = ?",
                        (resolved_did, pds, account["id"]),
                    )
            return {
                "pds": pds,
                "did": did or resolved_did or "",
                "access_jwt": access_jwt,
            }

    session = None
    if refresh_jwt:
        try:
            session = refresh_session(pds, refresh_jwt)
        except BlueskyError:
            # Auth or network failure — fall through to a fresh login.
            session = None
    if session is None:
        session = create_session(pds, handle, app_password)

    with get_db() as db:
        db.execute(
            """
            UPDATE bluesky_accounts
               SET did = ?, pds = ?, access_jwt = ?, refresh_jwt = ?,
                   session_expires_at = ?
             WHERE id = ?
            """,
            (
                session["did"],
                pds,
                encrypt_secret(session["access_jwt"]),
                encrypt_secret(session["refresh_jwt"]),
                session_expiry(session["access_jwt"]),
                account["id"],
            ),
        )
    return {
        "pds": pds,
        "did": session["did"],
        "access_jwt": session["access_jwt"],
    }


def _bsky_reauth(account, session: dict) -> dict:
    """Re-login with the stored app password after a rejected session.

    Shared by the image-upload loop and the create_post retry so both heal
    the same way. Returns the fresh session dict with the new tokens
    persisted; raises BlueskyAccountGoneError when the account row is gone
    and BlueskyAuthError when the credentials themselves are dead (callers
    map both to a permanent failure — a plain BlueskyError from create_session,
    e.g. a PDS outage, stays a transient failure).
    """
    with get_db() as db:
        fresh = db.execute(
            "SELECT handle, app_password FROM bluesky_accounts WHERE id = ?",
            (account["id"],),
        ).fetchone()
    if not fresh:
        raise BlueskyAccountGoneError("Bluesky account was deleted during dispatch")
    refreshed = create_session(
        session["pds"], fresh["handle"], decrypt_secret(fresh["app_password"])
    )
    with get_db() as db:
        db.execute(
            """
            UPDATE bluesky_accounts
               SET did = ?, access_jwt = ?, refresh_jwt = ?,
                   session_expires_at = ?
             WHERE id = ?
            """,
            (
                refreshed["did"],
                encrypt_secret(refreshed["access_jwt"]),
                encrypt_secret(refreshed["refresh_jwt"]),
                session_expiry(refreshed["access_jwt"]),
                account["id"],
            ),
        )
    return {
        "pds": session["pds"],
        "did": refreshed["did"],
        "access_jwt": refreshed["access_jwt"],
    }


def _bsky_call_with_reauth(
    account,
    session: dict,
    echo,
    posted_id: int,
    claim_token: str,
    context: str,
    op,
    item=None,
) -> tuple[bool, dict, object]:
    """Run `op(session)`; on BlueskyAuthError, re-auth once and retry once.

    Shared by the image-upload loop and the create_post call so a fix to
    the recovery semantics (how many attempts, what counts as permanent)
    lands once instead of drifting between two hand-rolled copies. `op`
    performs the actual PDS call against the session it's given and
    returns its result, or raises BlueskyAuthError if that session was
    rejected; `context` is a short label ("post", "image upload") used in
    log messages.

    Returns (True, session, result) on success — result is whatever `op`
    returned (or mutated into shared state; the image-upload loop uses
    `op` for its side effects and ignores this slot). Returns (False,
    session, fail_result) when recovery could not complete: fail_result is
    already-persisted (via _fail_post) and the caller must `return
    fail_result` immediately. `item` (optional) adds the item id to the
    permanent-failure log lines, matching the neighboring image-loop logs.
    """

    def _give_up(reason: str) -> tuple[bool, dict, object]:
        return False, session, _fail_post(
            posted_id, claim_token, echo["id"], reason, permanent=True
        )

    try:
        return True, session, op(session)
    except BlueskyAuthError:
        logger.warning(
            "Echo %s: Bluesky session rejected during %s, re-authenticating",
            echo["id"],
            context,
        )
    except Exception:
        logger.exception("Echo %s: Bluesky %s failed", echo["id"], context)
        return False, session, _fail_post(
            posted_id, claim_token, echo["id"], "Bluesky delivery failed"
        )

    try:
        session = _bsky_reauth(account, session)
    except BlueskyAccountGoneError:
        if item is not None:
            logger.error(
                "Echo %s: Bluesky account deleted during %s for item %s; giving up",
                echo["id"],
                context,
                item["id"],
            )
        else:
            logger.error(
                "Echo %s: Bluesky account deleted during %s; giving up",
                echo["id"],
                context,
            )
        return _give_up("Bluesky account deleted")
    except BlueskyAuthError:
        if item is not None:
            logger.error(
                "Echo %s: Bluesky credentials rejected after re-auth during %s for item %s; giving up",
                echo["id"],
                context,
                item["id"],
            )
        else:
            logger.error(
                "Echo %s: Bluesky credentials rejected after re-auth during %s; giving up",
                echo["id"],
                context,
            )
        return _give_up("Bluesky credentials rejected")
    except Exception:
        logger.exception(
            "Echo %s: Bluesky re-auth failed during %s", echo["id"], context
        )
        return False, session, _fail_post(
            posted_id, claim_token, echo["id"], "Bluesky delivery failed"
        )

    try:
        return True, session, op(session)
    except BlueskyAuthError:
        if item is not None:
            logger.error(
                "Echo %s: Bluesky credentials rejected after re-auth during %s for item %s; giving up",
                echo["id"],
                context,
                item["id"],
            )
        else:
            logger.error(
                "Echo %s: Bluesky credentials rejected after re-auth during %s; giving up",
                echo["id"],
                context,
            )
        return _give_up("Bluesky credentials rejected")
    except Exception:
        logger.exception(
            "Echo %s: Bluesky %s failed after re-auth", echo["id"], context
        )
        return False, session, _fail_post(
            posted_id, claim_token, echo["id"], "Bluesky delivery failed"
        )


def _prepare_bluesky_image(
    echo, item: FeedItem, entry: dict
) -> tuple[bytes, str, str] | None:
    """Fetch, downscale, and resolve alt text for one image — no upload.

    Returns (img_bytes, img_type, alt_description) ready for upload_blob, or
    None to skip this image (dead link, unsupported type, still over the
    blob cap after a downscale attempt). This step touches no Bluesky
    session and can't raise BlueskyAuthError, so callers run it for every
    candidate image concurrently, ahead of the sequential, session-bound
    upload/retry loop. Oversized images are downscaled/re-encoded first
    (the 2026-09-09 glass.photo report: ~2.9 MB source JPEGs degraded every
    glass post to text-only under the old 1 MB cap).
    """
    try:
        image_result = fetch_image(entry["url"])
        if not image_result:
            logger.info(
                "Echo %s: image fetch failed for %s, skipping that image",
                echo["id"],
                entry["url"],
            )
            return None
        img_bytes, img_type = image_result
        # Oversized for Bluesky? Downscale/re-encode to fit. None = Pillow
        # unavailable or still too big — skip just this image.
        if len(img_bytes) > MAX_BLOB_BYTES:
            downscaled = images.downscale_image(img_bytes, img_type, MAX_BLOB_BYTES)
            if downscaled:
                img_bytes, img_type = downscaled
                logger.info(
                    "Echo %s: downscaled Bluesky image for item %s to %d bytes (%s)",
                    echo["id"],
                    item["id"],
                    len(img_bytes),
                    img_type,
                )
            else:
                logger.warning(
                    "Echo %s: Bluesky image still exceeds the %d byte blob limit after downscale attempt for item %s, skipping that image",
                    echo["id"],
                    MAX_BLOB_BYTES,
                    item["id"],
                )
                return None
        if img_type not in BLUESKY_IMAGE_TYPES or len(img_bytes) > MAX_BLOB_BYTES:
            logger.info(
                "Echo %s: image unsupported for Bluesky (type=%s, size=%d), skipping that image",
                echo["id"],
                img_type,
                len(img_bytes),
            )
            return None
        # Feed-provided alt text wins; AI is the fallback.
        alt_description = _resolve_alt_text(
            echo, item, entry.get("alt", ""), img_bytes, img_type
        ) or ""
        return img_bytes, img_type, alt_description
    except Exception:
        logger.warning(
            "Echo %s: Bluesky image pipeline failed for one image of item %s, skipping that image",
            echo["id"],
            item["id"],
            exc_info=True,
        )
        return None


def _prepare_bluesky_images(
    echo, item: FeedItem, entries: list[dict]
) -> list[tuple[bytes, str, str] | None]:
    """Run _prepare_bluesky_image for every entry concurrently.

    Fetch, downscale, and alt-text resolution are each independent,
    session-free network/CPU work per image — running them one at a time
    made a 4-image post pay for 4 sequential round-trips. Returns results
    aligned to `entries` by index (None where that entry was skipped), so
    the caller's upload loop keeps its existing sequential, index-based
    retry logic unchanged and just skips the None slots.
    """
    if not entries:
        return []
    if len(entries) == 1:
        return [_prepare_bluesky_image(echo, item, entries[0])]
    with ThreadPoolExecutor(max_workers=min(len(entries), BLUESKY_MAX_IMAGES)) as pool:
        futures = [
            pool.submit(_prepare_bluesky_image, echo, item, entry) for entry in entries
        ]
        return [future.result() for future in futures]


def _upload_bluesky_image(
    session: dict, echo, item: FeedItem, prepared: tuple[bytes, str, str]
) -> dict | None:
    """Upload one already-fetched, already-validated image blob.

    Returns {"blob", "alt"} on success, or None on an upload rejection
    (logs and lets the caller skip just this image). BlueskyAuthError
    propagates instead: a dead session is a post-level problem, not an
    image one.
    """
    img_bytes, img_type, alt_description = prepared
    try:
        blob = upload_blob(
            pds=session["pds"],
            access_jwt=session["access_jwt"],
            image_bytes=img_bytes,
            content_type=img_type,
        )
    except BlueskyAuthError:
        # A dead session is post-level, not image-level: propagate so the
        # caller fails the dispatch (fresh session on retry) instead of
        # silently publishing a degraded text-only post.
        raise
    except Exception:
        logger.warning(
            "Echo %s: Bluesky image upload failed for item %s, skipping that image",
            echo["id"],
            item["id"],
            exc_info=True,
        )
        return None
    if not blob:
        logger.warning(
            "Echo %s: Bluesky image upload failed for item %s, skipping that image",
            echo["id"],
            item["id"],
        )
        return None
    logger.info(
        "Echo %s: uploaded Bluesky image blob for item %s",
        echo["id"],
        item["id"],
    )
    return {"blob": blob, "alt": alt_description}


def _upload_bluesky_thumb(
    echo, item: FeedItem, image_url: str, session: dict
) -> dict | None:
    """Fetch, validate, and upload a link card thumbnail blob.

    Same pipeline as the image embeds (SSRF-validated fetch, downscale
    attempt, type allowlist) but under EXTERNAL_THUMB_MAX_BYTES: the
    app.bsky.embed.external lexicon caps the thumb at 1 MB (it did NOT get
    the 2 MB raise embed.images got), and an oversized blob would get the
    whole record rejected with a 400 — failing the post, not just the
    thumb. Returns the blob reference or None to render a text-only card.
    """
    prepared = fetch_image(image_url)
    if not prepared:
        return None
    img_bytes, img_type = prepared
    if len(img_bytes) > EXTERNAL_THUMB_MAX_BYTES:
        downscaled = images.downscale_image(img_bytes, img_type, EXTERNAL_THUMB_MAX_BYTES)
        if not downscaled:
            return None
        img_bytes, img_type = downscaled
    if img_type not in BLUESKY_IMAGE_TYPES or len(img_bytes) > EXTERNAL_THUMB_MAX_BYTES:
        return None
    return upload_blob(
        pds=session["pds"],
        access_jwt=session["access_jwt"],
        image_bytes=img_bytes,
        content_type=img_type,
    )


def _fetch_bluesky_link_meta(echo, item: FeedItem, uri: str) -> dict | None:
    """Fetch a link target's og: metadata. Session-free and best-effort.

    Runs once per dispatch, before the claim guard — the same placement as
    the image pipeline's prefetch — so a session re-auth retry reuses the
    cached metadata instead of re-fetching the page from origin.
    """
    try:
        meta = fetch_page_metadata(uri)
    except Exception:
        logger.warning(
            "Echo %s: Bluesky link card metadata fetch failed for item %s, "
            "posting without a card",
            echo["id"],
            item["id"],
            exc_info=True,
        )
        return None
    if meta is None:
        # fetch_page_metadata never raises and reports WHY it gave up at
        # DEBUG only (dead links are routine), so without this line a skipped
        # card is invisible at the default INFO level and an operator cannot
        # tell "card skipped" from "this build has no cards".
        logger.info(
            "Echo %s: no link card for item %s: could not read page metadata "
            "from %s (fetch failed, blocked, or not HTML; set "
            "FEEDECHO_LOG_LEVEL=DEBUG for the cause), posting without a card",
            echo["id"],
            item["id"],
            uri,
        )
    return meta


def _build_bluesky_link_card(
    echo, item: FeedItem, uri: str, meta: dict | None, session: dict
) -> dict | None:
    """Build an app.bsky.embed.external link card, or None.

    Bluesky renders link cards only when the posting client attaches an
    external embed (the AppView does not generate them for third-party
    records), so text-only echoes with a link attach one — the Echofeed
    behavior members expect. `meta` is the prefetched page metadata (see
    _fetch_bluesky_link_meta); only the thumb upload here touches the
    Bluesky session, which is why it lives inside the retried post unit.

    Best-effort decoration for everything the LINK can do to break the
    post (no metadata, a thumb that will not fetch or upload) — those
    degrade to a cardless or thumbless post. The one deliberate exception
    is BlueskyAuthError from the thumb upload: a dead session is a
    post-level problem, so it propagates and the caller's re-auth path
    retries the whole post (card included) with a fresh session, instead
    of shipping a thumbless card from a token that is about to fail
    create_post anyway. Pages without a title fall back to the hostname —
    an empty-title card renders as a blank container in clients, and the
    official app shows the host in the same situation.
    """
    if meta is None:
        return None
    try:
        thumb_blob = None
        image_url = meta.get("image")
        if image_url:
            try:
                thumb_blob = _upload_bluesky_thumb(echo, item, image_url, session)
            except BlueskyAuthError:
                raise
            except Exception:
                logger.warning(
                    "Echo %s: Bluesky link card thumb failed for item %s, "
                    "attaching a text-only card",
                    echo["id"],
                    item["id"],
                    exc_info=True,
                )
        title = (meta.get("title") or "").strip()
        if not title:
            title = urlparse(uri).netloc
        card = build_external_embed(
            uri=uri,
            title=title,
            description=meta.get("description") or "",
            thumb_blob=thumb_blob,
        )
        logger.info(
            "Echo %s: attached Bluesky link card for item %s (%s, %s)",
            echo["id"],
            item["id"],
            uri,
            "with thumbnail" if thumb_blob else "no thumbnail",
        )
        return card
    except BlueskyAuthError:
        # Session-level: let the caller's re-auth path retry the whole
        # post (card included) with a fresh session.
        raise
    except Exception:
        logger.warning(
            "Echo %s: Bluesky link card build failed for item %s, posting without one",
            echo["id"],
            item["id"],
            exc_info=True,
        )
        return None


def _send_bluesky(
    echo,
    item: FeedItem,
    content: str,
    account_id: int,
    posted_id: int,
    claim_token: str,
) -> bool:
    account, fail_result = _destination_account(
        "bluesky_accounts", account_id, echo, posted_id, claim_token, "Bluesky account"
    )
    if account is None:
        return fail_result

    # Content preparation is pure string work, but a bug here must not strand
    # the claimed row — finalize it as failed so the bounded retry owns it.
    try:
        text = truncate_graphemes(
            content or "", MAX_POST_GRAPHEMES, max_bytes=MAX_POST_BYTES
        )
        # Link spans from the rich render (content_html anchors) survive only
        # if fully inside the truncated text; partial spans are dropped. The
        # drip path (override content) has no spans and relies on bare-URL
        # detection alone.
        # Truncation always appends "…" as the final character — a span
        # reaching the last character covers the ellipsis and maps onto
        # text that was never part of the anchor, so it's dropped. Anchors
        # containing a literal "…" elsewhere keep their facet.
        limit = len(text) - 1 if len(text) < len(content or "") else len(text)
        # When middle truncation preserves a trailing URL (e.g. head + "… " + tail),
        # rich links beyond the head cut point no longer align with characters in text.
        # Restrict surviving rich links to those fully within the kept head prefix.
        if "… " in text and len(text) < len(content or ""):
            limit = min(limit, text.find("… "))
        rich_links = [
            (start, end, uri)
            for start, end, uri in (item.get("_rich_links") or [])
            if end <= limit
        ]
        facets = build_facets(text, extra_links=rich_links)
    except Exception:
        logger.exception("Echo %s: Bluesky content preparation failed", echo["id"])
        return _fail_post(
            posted_id, claim_token, echo["id"], "Content preparation failed"
        )

    try:
        session = _bsky_session(account)
    except Exception:
        logger.exception("Echo %s: Bluesky session failed", echo["id"])
        return _fail_post(posted_id, claim_token, echo["id"], "Bluesky session failed")

    # Image attachment: optional, up to BLUESKY_MAX_IMAGES images (Bluesky's
    # per-post embed cap), each with its own alt text. A failure on any
    # single image (fetch, downscale, type, upload) skips that image; only
    # when none survive does the post degrade to text-only.
    attach_image = _echo_attach_image(echo)

    image_entries_out: list[dict] = []
    if attach_image:
        image_entries = _item_image_entries(item, limit=BLUESKY_MAX_IMAGES)
        # Fetch/downscale/alt-text is session-free and independent per
        # image, so it runs concurrently up front; only the upload_blob
        # calls below stay sequential, since those are the ones that can
        # hit a rejected session and need the index-based retry.
        prepared = _prepare_bluesky_images(echo, item, image_entries)
        # Index-based resume: a per-image skip does not advance
        # image_entries_out, so the retry window must track the INPUT index —
        # slicing by output count would re-upload an already-uploaded image
        # as a duplicate after a re-auth.
        start_idx = 0

        def _upload_remaining(s: dict) -> None:
            nonlocal start_idx
            for idx in range(start_idx, len(image_entries)):
                if prepared[idx] is not None:
                    uploaded = _upload_bluesky_image(s, echo, item, prepared[idx])
                    if uploaded:
                        image_entries_out.append(uploaded)
                # Advance only after the entry has been handled (uploaded or
                # deliberately skipped); a BlueskyAuthError leaves the index
                # on the failed entry so the retry resumes there.
                start_idx = idx + 1

        ok, session, fail_result = _bsky_call_with_reauth(
            account,
            session,
            echo,
            posted_id,
            claim_token,
            "image upload",
            _upload_remaining,
            item=item,
        )
        if not ok:
            return fail_result
        if image_entries and not image_entries_out:
            logger.warning(
                "Echo %s: no images uploaded for item %s, posting text-only",
                echo["id"],
                item["id"],
            )

    embed = build_image_embed(image_entries_out) if image_entries_out else None

    # Link card prefetch: session-free page metadata, fetched ONCE per
    # dispatch before the claim guard (same placement as the image
    # pipeline's fetch phase) and cached, so a re-auth retry below rebuilds
    # the card without re-fetching the page from origin.
    link_uri = first_link_uri(facets) if embed is None else None
    link_meta = _fetch_bluesky_link_meta(echo, item, link_uri) if link_uri else None

    # Re-validate claim ownership immediately before the post: if the lease
    # lapsed and another worker reclaimed this row, posting would duplicate.
    if not _guard_claim(posted_id, claim_token, echo["id"], item["id"], "Bluesky"):
        return False

    def _do_post(s: dict) -> dict:
        # Rebinding session also updates the DID post_url builds from below
        # (a re-login may have resolved it for the first time).
        post_embed = embed
        if post_embed is None and link_uri:
            # No image embed: attach a link card for the first link in the
            # post (rich anchor wins over a bare permalink, matching the
            # facet order). The thumb upload is here so a session that only
            # refreshes after create_post rejects it gets the card on the
            # retry too. Best-effort: _build_bluesky_link_card returns None
            # instead of failing the post (BlueskyAuthError excepted).
            post_embed = _build_bluesky_link_card(echo, item, link_uri, link_meta, s)
        return create_post(
            pds=s["pds"],
            access_jwt=s["access_jwt"],
            repo=s["did"],
            text=text,
            facets=facets or None,
            embed=post_embed,
        )

    ok, session, result = _bsky_call_with_reauth(
        account, session, echo, posted_id, claim_token, "post", _do_post
    )
    if not ok:
        return result

    # Persist the post URL for auditing/duplicate detection.
    post_url = ""
    uri = result.get("uri", "")
    if uri:
        rkey = uri.rsplit("/", 1)[-1]
        post_url = f"https://bsky.app/profile/{session['did']}/post/{rkey}"

    return _finalize_success(posted_id, claim_token, echo["id"], post_url=post_url)


def _send_microblog(
    echo,
    item: FeedItem,
    content: str,
    account_id: int,
    posted_id: int,
    claim_token: str,
) -> bool:
    """Dispatch one rendered item to a micro.blog blog via Micropub.

    The stored token posts to the row's blog uid (mp-destination). When the
    echo attaches images, the feed item's first image URL is passed through
    as photo= (Micro.blog fetches and hosts it), with AI alt text in
    mp-photo-alt when the alt-text feature is enabled — matching the
    Mastodon/Bluesky degrade-to-text-only behavior on any image problem.
    """
    account, fail_result = _destination_account(
        "microblog_accounts", account_id, echo, posted_id, claim_token,
        "Micro.blog account", user_scoped=True,
    )
    if account is None:
        return fail_result

    # Image passthrough: Micro.blog fetches photo= itself, so unlike the
    # Mastodon/Bluesky paths there is no blob upload. Alt text still needs
    # the image bytes, so fetch here and degrade to photo-without-alt on
    # any failure. Content preparation is pure string work but must not
    # strand the claimed row either.
    photo_url = ""
    photo_alt = ""
    attach_image = _echo_attach_image(echo)

    if attach_image:
        image_url = item.get("image_url", "")
        if image_url:
            photo_url = image_url
            # Default precedence: feed-provided alt text wins; AI is the
            # fallback. With the feed's ai_alt_override flag set, AI
            # generation runs even with non-empty feed alt and wins on
            # success (feed alt remains the fallback when AI fails or is
            # disabled for the user).
            photo_alt = (item.get("image_alt") or "").strip()
            try:
                ai_override = _feed_ai_alt_override(_echo_feed_id(echo))
                if (ai_override or not photo_alt) and alt_text.is_enabled(
                    user_id=echo["user_id"]
                ):
                    image_result = fetch_image(image_url)
                    if image_result:
                        img_bytes, img_type = image_result
                        try:
                            generated = (
                                alt_text.generate_alt_text(
                                    img_bytes, img_type, user_id=echo["user_id"]
                                )
                                or ""
                            )
                            if generated:
                                if ai_override:
                                    logger.info(
                                        "Echo %s: AI alt text override wins for item %s (%d chars)",
                                        echo["id"], item["id"], len(generated),
                                    )
                                photo_alt = generated
                        except Exception:
                            # A failed override keeps the feed-provided
                            # alt rather than posting with nothing.
                            if ai_override:
                                logger.warning(
                                    "Echo %s: alt text generation failed for item %s; "
                                    "falling back to feed-provided alt",
                                    echo["id"],
                                    item["id"],
                                    exc_info=True,
                                )
                            else:
                                logger.warning(
                                    "Echo %s: alt text generation failed for item %s",
                                    echo["id"],
                                    item["id"],
                                    exc_info=True,
                                )
            except Exception:
                logger.warning(
                    "Echo %s: micro.blog alt-text pipeline failed for item %s",
                    echo["id"],
                    item["id"],
                    exc_info=True,
                )

    # Re-validate claim ownership immediately before the post: alt-text
    # generation above is slow network I/O, so the lease can lapse and
    # another worker reclaim the row. Same pattern as Mastodon/Bluesky.
    if not _guard_claim(posted_id, claim_token, echo["id"], item["id"], "micro.blog"):
        return False

    try:
        result = microblog_create_post(
            token=decrypt_secret(account["token"]),
            content=content,
            destination=account["uid"],
            photo_url=photo_url,
            photo_alt=photo_alt,
        )
    except MicroblogAuthError as e:
        # Token rejected: retries cannot help until the user reconnects.
        logger.error("Echo %s: micro.blog token rejected: %s", echo["id"], e)
        return _fail_post(
            posted_id,
            claim_token,
            echo["id"],
            f"Micro.blog token rejected: {e}",
            permanent=True,
        )
    except MicroblogError as e:
        logger.exception("Echo %s: micro.blog post failed", echo["id"])
        return _fail_post(posted_id, claim_token, echo["id"], f"Micro.blog delivery failed: {e}")
    except Exception:
        logger.exception("Echo %s: micro.blog post failed unexpectedly", echo["id"])
        return _fail_post(posted_id, claim_token, echo["id"], "Micro.blog delivery failed")

    # Persist the post URL for auditing/duplicate detection. Micropub
    # returns it in the Location header (captured as result["location"]).
    post_url = result.get("location", "") if isinstance(result, dict) else ""

    return _finalize_success(posted_id, claim_token, echo["id"], post_url=post_url)


def _send_matrix(
    echo,
    item: FeedItem,
    content: str,
    account_id: int,
    posted_id: int,
    claim_token: str,
) -> bool:
    """Dispatch one rendered item into a Matrix room.

    The text event is the delivery: it decides success/failure. An attached
    image is a SECOND event (Matrix has no combined text+image message), so it
    is sent after the text and any image failure only logs — the item is
    already delivered and re-running the text would duplicate it.

    Transaction IDs are derived from the echo + item ID, so a retry of a send
    whose response was lost is de-duplicated by the homeserver instead of
    posting the item twice.
    """
    account, fail_result = _destination_account(
        "matrix_accounts", account_id, echo, posted_id, claim_token,
        "Matrix account", user_scoped=True,
    )
    if account is None:
        return fail_result

    base_url = account["base_url"] or account["homeserver"]
    access_token = decrypt_secret(account["access_token"])
    room_id = account["room_id"]

    # Image work happens BEFORE the text event so its slow I/O (fetch, alt
    # text, upload) does not sit between the text send and the claim's final
    # state. Any failure degrades to text-only, as on the other destinations.
    attach_image = _echo_attach_image(echo)

    mxc_uri = ""
    image_body = ""
    image_type = ""
    image_size = 0
    if attach_image:
        image_url = item.get("image_url", "")
        if image_url:
            try:
                image_result = fetch_image(image_url)
                if image_result:
                    img_bytes, img_type = image_result
                    if (
                        img_type in MATRIX_IMAGE_TYPES
                        and len(img_bytes) <= MATRIX_MAX_UPLOAD_BYTES
                    ):
                        # Feed-provided alt text wins; AI is the fallback.
                        alt_description = _resolve_alt_text(
                            echo,
                            item,
                            (item.get("image_alt") or "").strip(),
                            img_bytes,
                            img_type,
                        ) or ""
                        mxc_uri = matrix_upload_media(
                            base_url,
                            access_token,
                            img_bytes,
                            img_type,
                            filename=_matrix_image_filename(img_type),
                        )
                        image_body = alt_description or _matrix_image_filename(img_type)
                        image_type = img_type
                        image_size = len(img_bytes)
                    else:
                        logger.info(
                            "Echo %s: image unsupported for Matrix (type=%s, size=%d), posting text-only",
                            echo["id"],
                            img_type,
                            len(img_bytes),
                        )
                else:
                    logger.info(
                        "Echo %s: image fetch failed for item %s, posting text-only",
                        echo["id"],
                        item["id"],
                    )
            except Exception:
                logger.warning(
                    "Echo %s: Matrix image pipeline failed for item %s, posting text-only",
                    echo["id"],
                    item["id"],
                    exc_info=True,
                )
                mxc_uri = ""

    # Re-validate claim ownership immediately before the post: the image
    # pipeline above is slow network I/O, so the lease can lapse and another
    # worker reclaim the row. Same pattern as the other senders.
    if not _guard_claim(posted_id, claim_token, echo["id"], item["id"], "Matrix"):
        return False

    try:
        # render_html echoes carry markup: the rendered output becomes the
        # formatted_body (re-sanitized at the send boundary — the template
        # may also interpolate raw-text variables) and the plain fallback is
        # the structure-preserving text conversion. Markup-only items (e.g.
        # a bare linked image) convert to an empty string, so the rendered
        # output itself backs the plain body up; Plain echoes keep the
        # legacy escape-and-linkify behavior.
        if _echo_render_html(echo):
            plain = html_to_text(content).strip() or content
            event_id = matrix_send_message(
                base_url,
                access_token,
                room_id,
                plain,
                matrix_transaction_id(echo["id"], item["id"]),
                formatted=sanitize_html(content),
            )
        else:
            event_id = matrix_send_message(
                base_url,
                access_token,
                room_id,
                content,
                matrix_transaction_id(echo["id"], item["id"]),
            )
    except MatrixAuthError as e:
        # Token rejected: retries cannot help until the user reconnects.
        logger.error("Echo %s: Matrix token rejected: %s", echo["id"], e)
        return _fail_post(
            posted_id,
            claim_token,
            echo["id"],
            f"Matrix token rejected: {e}",
            permanent=True,
        )
    except MatrixPermissionError as e:
        # Kicked from the room, or no permission to post in it: also permanent
        # until the user fixes it on the Matrix side.
        logger.error("Echo %s: Matrix refused the post: %s", echo["id"], e)
        return _fail_post(
            posted_id,
            claim_token,
            echo["id"],
            f"Matrix refused the post: {e}",
            permanent=True,
        )
    except MatrixError as e:
        logger.exception("Echo %s: Matrix post failed", echo["id"])
        return _fail_post(posted_id, claim_token, echo["id"], f"Matrix delivery failed: {e}")
    except Exception:
        logger.exception("Echo %s: Matrix post failed unexpectedly", echo["id"])
        return _fail_post(posted_id, claim_token, echo["id"], "Matrix delivery failed")

    if mxc_uri:
        # The text already landed; a failed image must not fail the item.
        try:
            matrix_send_image(
                base_url,
                access_token,
                room_id,
                mxc_uri,
                image_body,
                image_type,
                image_size,
                matrix_transaction_id(echo["id"], item["id"], suffix="img"),
            )
        except Exception:
            logger.warning(
                "Echo %s: Matrix image event failed for item %s (text was delivered)",
                echo["id"],
                item["id"],
                exc_info=True,
            )

    return _finalize_success(
        posted_id,
        claim_token,
        echo["id"],
        post_url=matrix_permalink(room_id, event_id),
    )


def _matrix_image_filename(content_type: str) -> str:
    """A filename for the media upload, derived from the image type.

    Matrix clients show this when an image has no description, and some use
    the extension to decide how to render it.
    """
    ext = (content_type or "").rsplit("/", 1)[-1].lower()
    if ext == "jpeg":
        ext = "jpg"
    if not ext.isalnum():
        ext = "img"
    return f"feed-image.{ext}"


def _send_discord(
    echo,
    item: FeedItem,
    content: str,
    account_id: int,
    posted_id: int,
    claim_token: str,
) -> bool:
    """Dispatch one rendered item to a Discord channel via its webhook.

    The message is one POST: ``content`` is the rendered template, and when
    image attachments are on and the item has one, a single embed carries the
    title, link, and image URL. Discord fetches the embed image itself, so
    there is no download/upload step on our side — an unusable image simply
    renders as text + link and the delivery still succeeds.

    A deleted or revoked webhook (401/404) is permanent until the user
    reconnects; rate limits and 5xx ride the transient retry pipeline.
    """
    account, fail_result = _destination_account(
        "discord_accounts", account_id, echo, posted_id, claim_token,
        "Discord account", user_scoped=True,
    )
    if account is None:
        return fail_result

    webhook_url = decrypt_secret(account["webhook_url"])

    attach_image = _echo_attach_image(echo)

    embed = None
    if attach_image:
        embed = discord_build_embed(
            item.get("title", ""),
            item.get("link", ""),
            item.get("image_url", ""),
        )

    # Discord work has no slow image pipeline that could lapse the lease, but
    # the claim is still re-validated so this path keeps the same contract as
    # the other senders.
    if not _guard_claim(posted_id, claim_token, echo["id"], item["id"], "Discord"):
        return False

    try:
        discord_send_webhook(webhook_url, content, embed)
    except (DiscordAuthError, DiscordNotFoundError, DiscordBadRequestError) as e:
        # Revoked/deleted webhooks and rejected payloads cannot heal on
        # retry: permanent until the user reconnects or fixes the template.
        logger.error("Echo %s: Discord delivery refused: %s", echo["id"], e)
        return _fail_post(
            posted_id,
            claim_token,
            echo["id"],
            f"Discord delivery refused: {e}",
            permanent=True,
        )
    except DiscordError as e:
        logger.exception("Echo %s: Discord post failed", echo["id"])
        return _fail_post(
            posted_id, claim_token, echo["id"], f"Discord delivery failed: {e}"
        )
    except Exception:
        logger.exception("Echo %s: Discord post failed unexpectedly", echo["id"])
        return _fail_post(
            posted_id, claim_token, echo["id"], "Discord delivery failed"
        )

    # Discord replies 204 with no message id (and a jump link would need the
    # guild id, which webhooks do not expose), so there is no post_url.
    return _finalize_success(posted_id, claim_token, echo["id"], post_url="")


def _send_telegram(
    echo,
    item: FeedItem,
    content: str,
    account_id: int,
    posted_id: int,
    claim_token: str,
) -> bool:
    """Dispatch one rendered item to a Telegram chat via the Bot API.

    Templates without {{ content_html }} send with no parse_mode (Telegram
    auto-links URLs and hashtags; raw prose can never break entity parsing).
    Templates embedding {{ content_html }} send as parse_mode=HTML after the
    sanitized HTML is reduced to Telegram's supported tag subset.

    When image attachments are on and the item has one, the message goes out
    as sendPhoto with the text as caption (1024-char cap); a failed image
    fetch degrades to a text-only message. A bad token (401) is permanent
    until the user reconnects; rate limits and 5xx ride the transient retry
    pipeline.
    """
    account, fail_result = _destination_account(
        "telegram_accounts", account_id, echo, posted_id, claim_token,
        "Telegram account", user_scoped=True,
    )
    if account is None:
        return fail_result

    bot_token = decrypt_secret(account["bot_token"])
    chat_id = account["chat_id"]
    rich = "content_html" in (echo["template"] or "")

    attach_image = _echo_attach_image(echo)
    photo: tuple[bytes, str] | None = None
    if attach_image:
        entries = _item_image_entries(item, limit=1)
        if entries:
            image_result = fetch_image(entries[0]["url"])
            if image_result:
                photo = image_result

    # Telegram work has no slow multi-image pipeline that could lapse the
    # lease, but the claim is still re-validated so this path keeps the same
    # contract as the other senders.
    if not _guard_claim(posted_id, claim_token, echo["id"], item["id"], "Telegram"):
        return False

    try:
        if photo is not None:
            caption, caption_mode = telegram_build_message(
                content or "", rich=rich, cap=MAX_CAPTION_CHARS
            )
            result = telegram_send_photo(
                bot_token, chat_id, photo[0], photo[1],
                caption=caption, parse_mode=caption_mode,
            )
        else:
            text, parse_mode = telegram_build_message(
                content or "", rich=rich, cap=MAX_MESSAGE_CHARS
            )
            result = telegram_send_message(bot_token, chat_id, text, parse_mode)
    except (TelegramAuthError, TelegramNotFoundError, TelegramBadRequestError) as e:
        # Bad tokens, chats the bot is no longer in, and rejected payloads
        # cannot heal on retry: permanent until the user reconnects or
        # fixes the template.
        logger.error("Echo %s: Telegram delivery refused: %s", echo["id"], e)
        return _fail_post(
            posted_id,
            claim_token,
            echo["id"],
            f"Telegram delivery refused: {e}",
            permanent=True,
        )
    except TelegramError as e:
        logger.exception("Echo %s: Telegram post failed", echo["id"])
        return _fail_post(
            posted_id, claim_token, echo["id"], f"Telegram delivery failed: {e}"
        )
    except Exception:
        logger.exception("Echo %s: Telegram post failed unexpectedly", echo["id"])
        return _fail_post(
            posted_id, claim_token, echo["id"], "Telegram delivery failed"
        )

    # Public-channel messages get a t.me link; private chats/groups do not.
    return _finalize_success(
        posted_id, claim_token, echo["id"], post_url=telegram_message_url(result)
    )


def _send_webhook(
    echo,
    item: FeedItem,
    content: str,
    feed_name: str,
    account_id: int,
    posted_id: int,
    claim_token: str,
) -> bool:
    """Dispatch one rendered item to a generic webhook endpoint.

    The payload is one flat JSON object: ``text`` (the rendered template)
    plus every parsed item field, so receivers can map whatever shape they
    need. Auth failures (401/403), vanished endpoints (404/410), and rejected
    payloads (400/422) are permanent until the user fixes the account; rate
    limits and 5xx ride the transient retry pipeline.

    There is no per-message URL for a generic webhook, so post_url is "".
    """
    account, fail_result = _destination_account(
        "webhook_accounts", account_id, echo, posted_id, claim_token,
        "Webhook account", user_scoped=True,
    )
    if account is None:
        return fail_result

    headers = load_headers(account["headers"])
    body_template = (account["body_template"] if "body_template" in account.keys() else "") or ""
    if body_template:
        # Custom JSON body: the rendered template IS the payload. A render or
        # parse failure is a stored-config problem — permanent, like a
        # rejected payload, because retrying cannot change the template.
        try:
            payload = webhook_build_body_payload(body_template, item, feed_name=feed_name)
        except WebhookRejectedError as e:
            logger.error("Echo %s: webhook body template failed: %s", echo["id"], e)
            return _fail_post(
                posted_id,
                claim_token,
                echo["id"],
                f"Webhook delivery refused: {e}",
                permanent=True,
            )
    else:
        payload = webhook_build_payload(item, content, feed_name=feed_name)

    # Same contract as the other senders: re-validate the claim immediately
    # before the irreversible step.
    if not _guard_claim(posted_id, claim_token, echo["id"], item["id"], "webhook"):
        return False

    try:
        webhook_send_webhook(account["url"], headers, payload)
    except (WebhookAuthError, WebhookNotFoundError, WebhookRejectedError) as e:
        # Wrong credentials, a deleted endpoint, or a rejected payload shape
        # cannot heal on retry: permanent until the user reconnects.
        logger.error("Echo %s: webhook delivery refused: %s", echo["id"], e)
        return _fail_post(
            posted_id,
            claim_token,
            echo["id"],
            f"Webhook delivery refused: {e}",
            permanent=True,
        )
    except WebhookError as e:
        logger.exception("Echo %s: webhook post failed", echo["id"])
        return _fail_post(
            posted_id, claim_token, echo["id"], f"Webhook delivery failed: {e}"
        )
    except Exception:
        logger.exception("Echo %s: webhook post failed unexpectedly", echo["id"])
        return _fail_post(
            posted_id, claim_token, echo["id"], "Webhook delivery failed"
        )

    return _finalize_success(posted_id, claim_token, echo["id"], post_url="")


def _send_github(
    echo,
    item: FeedItem,
    content: str,
    account_id: int,
    posted_id: int,
    claim_token: str,
) -> bool:
    """Dispatch one rendered item to a GitHub repository as a markdown file.

    The echo template IS the file body; the per-echo github_path template
    renders to the repo-relative file path (overwriting an existing file at
    that path via the contents API sha, so re-echoes and corrections land),
    and github_commit renders to the commit message. Auth failures (401,
    non-rate-limit 403), vanished repo/branch (404), and refused writes
    (409/422, including the size cap) are permanent until the user fixes the
    account; rate limits and 5xx ride the transient retry pipeline.

    Success records the file's html_url as post_url, so history links
    straight to the file on GitHub.
    """
    account, fail_result = _destination_account(
        "github_accounts", account_id, echo, posted_id, claim_token,
        "GitHub account", user_scoped=True,
    )
    if account is None:
        return bool(fail_result)

    try:
        token = decrypt_secret(account["token"])
    except ValueError:
        logger.exception(
            "Echo %s: github credential decryption failed", echo["id"]
        )
        return _fail_post(
            posted_id, claim_token, echo["id"],
            "GitHub credential could not be decrypted", permanent=True,
        )

    # feed_name resolves here (rather than through the registry signature,
    # like _send_webhook's special case) so every dispatch flow — fresh
    # items, drip flush, queue post-now — gets the same value.
    feed_name = ""
    with get_db() as db:
        feed = db.execute(
            "SELECT name FROM feeds WHERE id = ?", (echo["feed_id"],)
        ).fetchone()
    if feed is not None:
        feed_name = feed["name"] or ""

    # Render failures and the size cap are PERMANENT: a broken stored
    # template can never heal on retry, and truncating a backup file
    # silently corrupts it. The cap is checked BEFORE any network call.
    try:
        path = github_render_path(echo["github_path"], item, feed_name)
        message = github_build_commit_message(
            echo["github_commit"] if "github_commit" in echo.keys() else "",
            item, feed_name, path=path,
        )
    except Exception as e:
        logger.error(
            "Echo %s: github path/commit template failed: %s", echo["id"], e
        )
        return _fail_post(
            posted_id, claim_token, echo["id"],
            f"GitHub path or commit template failed: {e}", permanent=True,
        )
    if len(base64.b64encode(content.encode("utf-8"))) > GITHUB_MAX_FILE_BYTES:
        return _fail_post(
            posted_id, claim_token, echo["id"],
            f"GitHub file is over the {GITHUB_MAX_FILE_BYTES}-byte size cap",
            permanent=True,
        )

    # send_file owns the two-step round trip: the contents-API GET (idempotent,
    # it only reads the blob sha, so it may run before the claim guard), then
    # the claim guard, then the irreversible PUT. Keeping the guard inside
    # send_file means the shipped dispatch path is the path the tests drive.
    def _claim_guard() -> bool:
        return _guard_claim(
            posted_id, claim_token, echo["id"], item["id"], "github"
        )

    try:
        result = github_send_file(
            token, account["repo"], account["branch"], path, content, message,
            committer=github_build_committer(
                account["committer_name"], account["committer_email"]
            ),
            guard=_claim_guard,
        )
    except GitHubRateLimitError as e:
        return _fail_post(
            posted_id, claim_token, echo["id"], f"GitHub delivery rate-limited: {e}"
        )
    except (GitHubAuthError, GitHubNotFoundError, GitHubRejectedError) as e:
        logger.error("Echo %s: github delivery refused: %s", echo["id"], e)
        return _fail_post(
            posted_id, claim_token, echo["id"],
            f"GitHub delivery refused: {e}", permanent=True,
        )
    except GitHubError as e:
        logger.exception("Echo %s: github delivery failed", echo["id"])
        return _fail_post(
            posted_id, claim_token, echo["id"], f"GitHub delivery failed: {e}"
        )
    except Exception:
        logger.exception("Echo %s: github delivery failed unexpectedly", echo["id"])
        return _fail_post(
            posted_id, claim_token, echo["id"], "GitHub delivery failed"
        )

    if result is None:
        # The claim lapsed between the GET and the PUT; another worker owns
        # the row, so the write was skipped rather than duplicated.
        return False

    return _finalize_success(
        posted_id, claim_token, echo["id"], post_url=result["html_url"]
    )


# Strategy registry: destination_type -> sender. All eight share the
# (echo, item, content, account_id, posted_id, claim_token) -> bool shape
# except _send_webhook, which additionally needs feed_name for its payload
# template — handled as a special case in _render_and_dispatch rather than
# forcing an unused parameter onto the other seven. Replaces an 8-arm
# if/elif chain that was hand-copied in at least four other places
# (add_echo/edit_echo's destination-field resolution, echoes.html's
# destination-type badge, and the two account-table registries in app.py
# and import_export.py) — this is the one place a destination TYPE maps to
# posting BEHAVIOR, as opposed to a table name or form field.
_DESTINATION_HANDLERS: dict[str, Callable[[dict, FeedItem, str, int, int, str], bool]] = {
    "mastodon": _send_mastodon,
    "email": _send_email_echo,
    "bluesky": _send_bluesky,
    "microblog": _send_microblog,
    "matrix": _send_matrix,
    "discord": _send_discord,
    "telegram": _send_telegram,
    "github": _send_github,
}


def _queue_for_digest(
    echo,
    item: FeedItem,
    content: str,
    posted_id: int,
    claim_token: str,
) -> bool:
    """Queue an item for digest delivery instead of sending immediately.

    Inserts into digest_items and marks the posted_item as 'queued' so the
    cursor advances and the item isn't retried. The digest flush job sends
    all queued items for this echo as one email.
    """
    echo_id = echo["id"]
    item_id = item["id"]

    with get_db() as db:
        db.execute(
            """INSERT INTO digest_items (echo_id, item_id, item_title, item_url, rendered_content)
               VALUES (?, ?, ?, ?, ?)
               ON CONFLICT(echo_id, item_id) DO NOTHING""",
            (echo_id, item_id, item.get("title", ""), item.get("link", ""), content),
        )

    # Mark the posted_item as 'queued' — the digest job will finalize it.
    # No record_success here: queueing is a hold, not a delivery, and clearing
    # the alert state would send a spurious "recovered" email and let a
    # genuinely broken SMTP path keep failing silently until the threshold
    # re-accumulates. flush_digests records the success once the mail is sent.
    # (_queue_for_drip already worked this way; see the note there.)
    ok = _update_post(posted_id, claim_token, "queued")
    if ok:
        logger.info(
            "Echo %s: item %s queued for digest delivery",
            echo_id,
            item_id,
        )
    return ok


def _requeue_drip_release_error(
    echo_id: int,
    item_id: str,
    item_json: str,
    attempts: int,
    posted_id: int,
    claim_token: str,
    exc: Exception,
) -> None:
    """Requeue a drip release whose dispatch raised an unexpected exception.

    Unlike _requeue_drip_failure, which runs after a completed dispatch and
    can rely on the row being 'failed', an escaped exception leaves the
    posted row 'pending' under this worker's claim. Requeueing must
    therefore clear that claim, and it must clear it in one transaction
    with the drip_items insert: if the row were requeued but the insert
    failed, the next flush would re-release the item and double-insert the
    queue row; if the insert landed but the row stayed pending, the item
    would sit unclaimable for the reclaim window while the queue row is
    dead (the queue join only matches status 'queued').

    The UPDATE fences on THIS worker's claim_token, not just 'pending':
    if the reclaim sweep re-claimed the row while the dispatch was stuck,
    the token has changed and the new owner's claim must not be clobbered
    (the queue row was never deleted on this path, so there is nothing to
    restore either).

    'attempts' is deliberately NOT incremented here. This path is an
    infrastructure fault, not a delivery failure; record_failure still
    fires so a persistently faulting release reaches the notify threshold
    instead of retrying silently every flush.
    """
    with get_db() as db:
        result = db.execute(
            """UPDATE posted_items
                  SET status = 'queued',
                      claimed_at = NULL,
                      claim_token = NULL,
                      next_retry_at = NULL,
                      posted_at = ?
                WHERE id = ? AND status = 'pending' AND claim_token = ?""",
            (_now(), posted_id, claim_token),
        )
        if result.rowcount != 1:
            # The claim was lost (another worker or the reclaim sweep owns
            # the row now); their outcome wins and the queue row survives.
            return
        db.execute(
            """INSERT INTO drip_items (echo_id, item_id, item_json, attempts)
               VALUES (?, ?, ?, ?)
               ON CONFLICT(echo_id, item_id) DO UPDATE SET attempts = excluded.attempts""",
            (echo_id, item_id, item_json, attempts),
        )
    record_failure(echo_id)
    logger.error(
        "Echo %s: drip release raised %s: %s; item %s requeued (attempt count held at %d)",
        echo_id,
        type(exc).__name__,
        exc,
        item_id,
        attempts,
    )


def _requeue_drip_failure(
    echo_id: int,
    item_id: str,
    item_json: str,
    attempts: int,
    posted_id: int,
) -> None:
    """Return a failed drip release to the queue, or gave_up at the cap.

    Retries run from the stored payload, so items that have rotated out
    of the feed are never lost the way a feed-based retry would lose
    them. Release attempts are counted on the drip_items row.
    """
    attempts += 1
    cap = max_attempts(echo_id)
    if cap > 0 and attempts >= cap:
        with get_db() as db:
            db.execute(
                """UPDATE posted_items
                      SET status = 'gave_up',
                          error_message = ?,
                          claimed_at = NULL,
                          claim_token = NULL,
                          posted_at = ?
                    WHERE id = ?""",
                (
                    f"Drip release gave up after {attempts} attempts",
                    _now(),
                    posted_id,
                ),
            )
        record_failure(echo_id)
        logger.error(
            "Echo %s: drip release gave up for item %s after %s attempts",
            echo_id,
            item_id,
            attempts,
        )
        return

    with get_db() as db:
        result = db.execute(
            """UPDATE posted_items
                  SET status = 'queued',
                      claimed_at = NULL,
                      claim_token = NULL,
                      next_retry_at = NULL,
                      posted_at = ?
                WHERE id = ? AND status = 'failed'""",
            (_now(), posted_id),
        )
        if result.rowcount != 1:
            # The row moved on (e.g. claimed by the retry sweep); the
            # normal path owns it now.
            return
        db.execute(
            """INSERT INTO drip_items (echo_id, item_id, item_json, attempts)
               VALUES (?, ?, ?, ?)
               ON CONFLICT(echo_id, item_id) DO UPDATE SET attempts = excluded.attempts""",
            (echo_id, item_id, item_json, attempts),
        )
    logger.info(
        "Echo %s: dripped item %s release failed; re-queued (attempt %d)",
        echo_id,
        item_id,
        attempts,
    )


def _discard_drip_backlog(echo_id: int, reason: str) -> None:
    """Finalize an echo's queued drip rows as gave_up and clear the queue.

    A disabled or deleted echo must not hold items indefinitely: on
    re-enable they would dump as stale posts, and the queue cap would
    keep silently dropping new items meanwhile. History records what was
    discarded and why.
    """
    with get_db() as db:
        db.execute(
            """UPDATE posted_items
                  SET status = 'gave_up',
                      error_message = ?,
                      claimed_at = NULL,
                      claim_token = NULL,
                      posted_at = ?
                WHERE echo_id = ? AND status = 'queued'""",
            (reason, _now(), echo_id),
        )
        db.execute("DELETE FROM drip_items WHERE echo_id = ?", (echo_id,))
    logger.info("Echo %s: %s", echo_id, reason)


def flush_queue() -> None:
    """Dispatch due items from the user-scheduled curation queue.

    Runs every 5 minutes wrapped in a database job lease. Claims due posts
    atomically, materializes a one-shot echo, dispatches with the stored content
    verbatim via process_echo, and updates status, posted_item_id, and error.
    """
    if not _acquire_job_lease("flush_queue", FLUSH_QUEUE_LEASE_SECONDS):
        return
    try:
        _flush_queue()
    finally:
        _release_job_lease("flush_queue")


def _flush_queue() -> None:
    now_ts = _now()
    # Re-claim rows stuck in 'sending' past a 15-minute TTL (crash recovery)
    ttl_expired = (datetime.now(timezone.utc) - timedelta(minutes=15)).strftime("%Y-%m-%d %H:%M:%S")
    with get_db() as db:
        db.execute(
            """
            UPDATE queued_posts
               SET status = 'queued', claim_token = NULL, claimed_at = NULL
             WHERE status = 'sending' AND claimed_at <= ?
            """,
            (ttl_expired,),
        )
        if settings.MULTI:
            due = db.execute(
                """
                SELECT q.id FROM queued_posts q
                  JOIN users u ON u.id = q.user_id
                 WHERE q.status = 'queued' AND q.scheduled_at <= ?
                   AND u.suspended = 0
                   AND NOT (u.plan = 'trial' AND u.trial_ends_at IS NOT NULL
                            AND u.trial_ends_at <= ?)
                 ORDER BY q.scheduled_at ASC
                 LIMIT 50
                """,
                (now_ts, now_ts),
            ).fetchall()
        else:
            due = db.execute(
                """
                SELECT id FROM queued_posts
                 WHERE status = 'queued' AND scheduled_at <= ?
                 ORDER BY scheduled_at ASC
                 LIMIT 50
                """,
                (now_ts,),
            ).fetchall()

    # Pre-compute per-user hourly post counts once per tick (hoisted out of the loop)
    user_hourly_counts: dict[int, int] = {}
    user_caps: dict[int, int] = {}
    if settings.MULTI:
        hour_ago = (datetime.now(timezone.utc) - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S")
        with get_db() as db:
            counts = db.execute(
                """
                SELECT e.user_id AS uid, COUNT(*) AS c
                  FROM posted_items pi JOIN echoes e ON pi.echo_id = e.id
                 WHERE pi.status = 'success' AND pi.posted_at >= ?
                 GROUP BY e.user_id
                """,
                (hour_ago,),
            ).fetchall()
            for r in counts:
                user_hourly_counts[r["uid"]] = r["c"]

    for r in due:
        qp_id = r["id"]
        claim_token = secrets.token_urlsafe(16)
        claim_time = _now()
        with get_db() as db:
            claimed = db.execute(
                """
                UPDATE queued_posts
                   SET status = 'sending', claim_token = ?, claimed_at = ?
                 WHERE id = ? AND status = 'queued' AND scheduled_at <= ?
                """,
                (claim_token, claim_time, qp_id, now_ts),
            )
            # In sqlite rowcount indicates updated rows; in PG rowcount also indicates it
            if claimed.rowcount != 1:
                continue
            row = db.execute(
                """
                SELECT q.*, f.name AS feed_name, f.deleted_at AS feed_deleted_at
                  FROM queued_posts q
                  LEFT JOIN feeds f ON q.feed_id = f.id
                 WHERE q.id = ?
                """,
                (qp_id,),
            ).fetchone()

        if not row:
            continue

        # Never send from a feed the user deleted (the feed's stored items
        # are purged on delete, so the item lookup below would come back
        # empty and dispatch a blank post). Finalize with a reason so the
        # row shows what happened instead of sitting stuck or sending.
        if row["feed_deleted_at"] is not None:
            with get_db() as db:
                db.execute(
                    "UPDATE queued_posts SET status = 'failed',"
                    " error_message = 'Feed deleted'"
                    " WHERE id = ? AND status = 'sending'",
                    (qp_id,),
                )
            logger.info(
                "Queue flush: post %s finalized — its feed was deleted", qp_id
            )
            continue

        # Enforce max_posts_per_hour for the queue (multi mode only, using hoisted counts)
        if settings.MULTI:
            uid = row["user_id"]
            if uid not in user_caps:
                with get_db() as db:
                    u = db.execute("SELECT plan FROM users WHERE id = ?", (uid,)).fetchone()
                    user_caps[uid] = plans.posts_per_hour_cap(u["plan"]) if u else 0
            cap = user_caps.get(uid, 0)
            if cap and user_hourly_counts.get(uid, 0) >= cap:
                logger.info(
                    "Queue flush: user %s hit posts_per_hour cap (%d/%d), skipping",
                    uid, user_hourly_counts.get(uid, 0), cap,
                )
                # Release the claim back to queued for next tick
                with get_db() as db:
                    db.execute(
                        "UPDATE queued_posts SET status = 'queued', claim_token = NULL, claimed_at = NULL"
                        " WHERE id = ? AND claim_token = ?",
                        (qp_id, claim_token),
                    )
                continue

        # Reuse the echo_id stored at enqueue time (idempotency key for crash recovery)
        echo_id = row["echo_id"]
        with get_db() as db:
            echo = db.execute("SELECT * FROM echoes WHERE id = ?", (echo_id,)).fetchone() if echo_id else None

        # Fallback: create echo if missing (e.g. pre-migration rows without echo_id)
        if not echo:
            now_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
            with get_db() as db:
                new_echo_id = db.execute(
                    """
                    INSERT INTO echoes (feed_id, destination_type, destination_id, template,
                                       visibility, attach_image, enabled, one_shot, deleted_at, user_id)
                    VALUES (?, ?, ?, '{{ title }}', ?, ?, 0, 1, ?, ?) RETURNING id
                    """,
                    (
                        row["feed_id"],
                        row["destination_type"],
                        row["destination_id"],
                        row["visibility"] or "public",
                        row["attach_image"],
                        now_utc,
                        row["user_id"],
                    ),
                ).fetchone()["id"]
                echo = db.execute("SELECT * FROM echoes WHERE id = ?", (new_echo_id,)).fetchone()
            # Persist the echo_id so future retries reuse it
            with get_db() as db:
                db.execute(
                    "UPDATE queued_posts SET echo_id = ? WHERE id = ?",
                    (new_echo_id, qp_id),
                )
            echo_id = new_echo_id  # update the variable so the status lookup uses the right id

        item = {
            "id": row["item_id"],
            "title": "",
            "link": "",
            "summary": "",
            "content": "",
            "content_text": "",
            "content_link": "",
            "content_html": "",
            "author": "",
            "date": "",
            "image_url": "",
            "image_alt": row["image_alt"] or "",
        }

        # If feed_item_id is still present, load any existing images/metadata.
        # A missing row is NOT fatal: retention pruning removes feed_items
        # routinely, and the queue row is self-sufficient (it carries its own
        # materialized content).
        if row["feed_item_id"]:
            with get_db() as db:
                fi = db.execute(
                    "SELECT * FROM feed_items WHERE id = ?", (row["feed_item_id"],)
                ).fetchone()
                if fi:
                    item.update({
                        "title": fi["title"] or "",
                        "link": fi["link"] or "",
                        "summary": fi["summary"] or "",
                        "content": fi["content"] or "",
                        "content_html": (fi["content_html"] if "content_html" in fi.keys() else "") or "",
                        "categories": categories_from_value(fi["categories"]) if "categories" in fi.keys() else {},
                        "image_url": fi["image_url"] or "",
                    })

        # Race insurance: the feed may have been deleted between the claim
        # SELECT above and dispatch (feed deletion finalizes 'queued' rows,
        # but this row is 'sending'). Re-check immediately before sending so
        # nothing ever posts from a deleted feed.
        with get_db() as db:
            feed_state = db.execute(
                "SELECT deleted_at FROM feeds WHERE id = ?", (row["feed_id"],)
            ).fetchone()
        if feed_state is None or feed_state["deleted_at"] is not None:
            with get_db() as db:
                db.execute(
                    "UPDATE queued_posts SET status = 'failed',"
                    " error_message = 'Feed deleted'"
                    " WHERE id = ? AND status = 'sending'",
                    (qp_id,),
                )
            continue

        try:
            ok = process_echo(
                echo,
                item,
                feed_name=row["feed_name"] or "",
                override_content=row["content"],
            )

            with get_db() as db:
                pi = db.execute(
                    "SELECT id, status, post_url, error_message FROM posted_items"
                    " WHERE echo_id = ? AND item_id = ?",
                    (echo_id, row["item_id"]),
                ).fetchone()

                final_status = "sent" if (pi and pi["status"] == "success") else "failed"
                err_msg = pi["error_message"] if pi else "Unknown dispatch error"
                pi_id = pi["id"] if pi else None

                new_attempt = row["attempt_count"] + 1
                if final_status == "failed":
                    # Auto-retry with backoff: up to 3 attempts, then terminal.
                    # On non-terminal attempts, clear the stale posted_items row
                    # so _claim_post can create a fresh one on the next try.
                    # On the terminal attempt, leave the posted_items row intact
                    # so the failure is visible in /history (audit trail).
                    if new_attempt < 3:
                        if pi:
                            db.execute("DELETE FROM posted_items WHERE id = ?", (pi["id"],))
                        backoff_minutes = new_attempt * 10
                        retry_at = (datetime.now(timezone.utc) + timedelta(minutes=backoff_minutes)).strftime("%Y-%m-%d %H:%M:%S")
                        db.execute(
                            "UPDATE queued_posts SET status = 'queued', error_message = ?,"
                            " attempt_count = ?, scheduled_at = ?, claim_token = NULL, claimed_at = NULL"
                            " WHERE id = ? AND claim_token = ?",
                            (err_msg, new_attempt, retry_at, qp_id, claim_token),
                        )
                    else:
                        # Terminal: keep the posted_items row for history, don't write posted_item_id
                        # since the row's status already records the failure with the error.
                        db.execute(
                            "UPDATE queued_posts SET status = 'failed', error_message = ?,"
                            " attempt_count = ? WHERE id = ? AND claim_token = ?",
                            (err_msg, new_attempt, qp_id, claim_token),
                        )
                else:
                    db.execute(
                        "UPDATE queued_posts SET status = 'sent', posted_item_id = ?, error_message = NULL,"
                        " attempt_count = ? WHERE id = ? AND claim_token = ?",
                        (pi_id, new_attempt, qp_id, claim_token),
                    )
                    # Increment the in-memory hourly counter so the cap
                    # enforcement sees this dispatch within the same tick.
                    user_hourly_counts[row["user_id"]] = user_hourly_counts.get(row["user_id"], 0) + 1
        except Exception as exc:
            logger.exception("Queue flush: error processing queued post %s", qp_id)
            new_attempt = row["attempt_count"] + 1
            if new_attempt < 3:
                with get_db() as db:
                    retry_at = (datetime.now(timezone.utc) + timedelta(minutes=new_attempt * 10)).strftime("%Y-%m-%d %H:%M:%S")
                    db.execute(
                        "UPDATE queued_posts SET status = 'queued', error_message = ?,"
                        " attempt_count = ?, scheduled_at = ?, claim_token = NULL, claimed_at = NULL"
                        " WHERE id = ? AND claim_token = ?",
                        (f"Dispatch exception: {exc}", new_attempt, retry_at, qp_id, claim_token),
                    )
            else:
                with get_db() as db:
                    db.execute(
                        "UPDATE queued_posts SET status = 'failed', error_message = ?,"
                        " attempt_count = ? WHERE id = ? AND claim_token = ?",
                        (f"Dispatch exception: {exc}", new_attempt, qp_id, claim_token),
                    )


def flush_drips() -> None:
    """Release queued drip items whose rate window has room.

    Called by the drip scheduler job every 10 minutes. Each held item is
    re-rendered with the echo's current template and dispatched through
    the normal per-destination senders, so images, alt text, and content
    warnings behave exactly like instant posts. Failed releases return to
    the queue and retry from the stored payload. Disabled or deleted
    echoes have their backlog discarded as gave_up. Removing the limit
    entirely drains the queue in bounded batches per flush.
    """
    if not _acquire_job_lease("flush_drips", FLUSH_DRIP_LEASE_SECONDS):
        return
    try:
        _flush_drips()
    finally:
        _release_job_lease("flush_drips")


def _flush_drips() -> None:
    with get_db() as db:
        pending = db.execute(
            """
            SELECT d.id AS drip_id, d.item_id, d.item_json, d.attempts, e.*,
                   f.name AS feed_name, f.deleted_at AS feed_deleted_at
              FROM drip_items d
              JOIN echoes e ON d.echo_id = e.id
              JOIN feeds f ON e.feed_id = f.id
             ORDER BY d.id ASC
            """
        ).fetchall()

    discarded: set[int] = set()
    released: dict[int, int] = {}

    for row in pending:
        echo_id = row["id"]
        if echo_id in discarded:
            continue

        try:
            _flush_one_drip(row, discarded, released)
        except Exception:
            # Isolate one row's failure from the rest of the batch: an
            # uncaught exception here (a transient DB error, an unexpected
            # data shape) must not skip every other echo's pending drip
            # item for the whole tick. Mirrors _flush_queue's per-row
            # isolation.
            logger.exception(
                "Drip flush: unhandled error for echo %s item %s",
                echo_id,
                row["item_id"],
            )
            continue


def _flush_one_drip(row, discarded: set[int], released: dict[int, int]) -> None:
    echo_id = row["id"]

    if row["deleted_at"] or row["feed_deleted_at"]:
        _discard_drip_backlog(echo_id, "Echo deleted; drip backlog discarded")
        discarded.add(echo_id)
        return
    if not row["enabled"]:
        _discard_drip_backlog(echo_id, "Echo disabled; drip backlog discarded")
        discarded.add(echo_id)
        return

    # Serialize with process_echo so concurrent workers cannot both
    # see an open window and exceed the hourly cap.
    with _drip_lock(echo_id):
        limit = _drip_limit(row)
        if limit > 0 and _drip_rate(echo_id) >= limit:
            return
        if limit <= 0 and released.get(echo_id, 0) >= DRIP_DOWNGRADE_BATCH:
            # Limit removed: drain the stale backlog in batches
            # instead of one burst.
            return

        try:
            item = json.loads(row["item_json"])
        except (json.JSONDecodeError, TypeError):
            logger.warning(
                "Drip queue: unreadable payload %s for echo %s; dropping",
                row["drip_id"],
                echo_id,
            )
            with get_db() as db:
                db.execute("DELETE FROM drip_items WHERE id = ?", (row["drip_id"],))
            return

        claimed = _reclaim_queued(echo_id, row["item_id"])
        if claimed is None:
            # The posted row is no longer queued; remove the orphaned
            # queue entry so it is not reconsidered forever.
            with get_db() as db:
                db.execute("DELETE FROM drip_items WHERE id = ?", (row["drip_id"],))
            return

        posted_id, claim_token = claimed
        logger.info(
            "Echo %s: releasing dripped item %s (%d in window)",
            echo_id,
            row["item_id"],
            _drip_rate(echo_id),
        )
        try:
            _render_and_dispatch(dict(row), item, row["feed_name"] or "", posted_id, claim_token)
        except Exception as exc:
            # _render_and_dispatch converts its own and the senders'
            # failures into posted_items status changes; an exception
            # escaping it is an infrastructure fault (DB error, a bug).
            # The drip_items row has not been deleted yet at this point,
            # so the item must be requeued explicitly — leaving it
            # 'pending' after the claim would strand it until the ten
            # minute reclaim sweep, which does not restore the queue row
            # and would burn attempt_count. Retrying is the safe
            # direction for an item queued for delivery: the send may
            # not have been attempted, and the drip cap bounds how much
            # a persistently faulting release can dump per window.
            _requeue_drip_release_error(
                echo_id,
                row["item_id"],
                row["item_json"],
                row["attempts"],
                posted_id,
                claim_token,
                exc,
            )
            return

        with get_db() as db:
            db.execute("DELETE FROM drip_items WHERE id = ?", (row["drip_id"],))

        if _row_state(echo_id, row["item_id"]) == "failed":
            _requeue_drip_failure(
                echo_id,
                row["item_id"],
                row["item_json"],
                row["attempts"],
                posted_id,
            )
        else:
            released[echo_id] = released.get(echo_id, 0) + 1


def _discard_orphaned_digest_items() -> None:
    """Finalize digest items whose echo or destination no longer qualifies.

    flush_digests only picks up enabled, non-deleted echoes with a live email
    account (INNER JOIN). Anything else was never sent and never cleaned up:
    deleting an echo (which sets enabled = 0) or its email account stranded
    its queued items forever, leaving posted_items rows stuck at 'queued' and
    digest_items growing without bound. Mirrors _discard_drip_backlog, which
    already did this for drip mode.

    Deliberately NOT triggered by enabled = 0 on its own. That is the pause
    toggle, a reversible user action, and discarding a pause's backlog is
    irrecoverable data loss from a normal click. A paused echo queues nothing
    new (process_echo skips it), so its backlog is frozen, not growing, and it
    goes out on re-enable. Drip discards on pause for a different reason that
    does not apply here: releasing a drip backlog means a burst of separate
    posts, and its queue cap silently drops new items while full. A digest is
    one batched email with no cap, so keeping it is what a user expects.
    """
    with get_db() as db:
        orphans = db.execute(
            """
            SELECT DISTINCT d.echo_id
              FROM digest_items d
              LEFT JOIN echoes e ON e.id = d.echo_id
              LEFT JOIN feeds f ON f.id = e.feed_id
              LEFT JOIN email_accounts ea
                     ON ea.id = e.destination_id AND e.destination_type = 'email'
             WHERE e.id IS NULL
                OR e.deleted_at IS NOT NULL
                OR f.id IS NULL
                OR f.deleted_at IS NOT NULL
                OR ea.id IS NULL
            """
        ).fetchall()

    for row in orphans:
        echo_id = row["echo_id"]
        try:
            reason = "Digest discarded: echo or destination is no longer active"
            with get_db() as db:
                db.execute(
                    """UPDATE posted_items
                          SET status = 'gave_up',
                              error_message = ?,
                              claimed_at = NULL,
                              claim_token = NULL,
                              posted_at = ?
                        WHERE echo_id = ? AND status = 'queued'""",
                    (reason, _now(), echo_id),
                )
                db.execute("DELETE FROM digest_items WHERE echo_id = ?", (echo_id,))
            logger.info("Echo %s: %s", echo_id, reason)
        except Exception:
            # Isolate one orphan row's failure from the rest: this sweep
            # runs before the per-echo loop in _flush_digests, so an
            # unhandled exception here would abort the whole digest tick
            # before any live tenant's digest was even queried — the same
            # bug class the per-row isolation elsewhere guards against.
            logger.exception(
                "Digest orphan sweep: unhandled error for echo %s", echo_id
            )
            continue


def flush_digests() -> None:
    """Send pending digest items as batched emails and finalize them.

    Called by the digest scheduler job at the configured interval.
    Groups digest_items by echo, sends one email per echo, then
    deletes the items from digest_items and updates posted_items
    from 'queued' to 'success'.
    """
    if not _acquire_job_lease("flush_digests", FLUSH_DIGEST_LEASE_SECONDS):
        return
    try:
        _flush_digests()
    finally:
        _release_job_lease("flush_digests")


def _flush_digests() -> None:
    # Sweep first: stranded items are invisible to the query below, so nothing
    # would ever clear them. The sweep itself is per-row isolated; wrapping
    # the invocation too keeps an unexpected fault there from aborting the
    # whole tick before any live tenant's digest is queried.
    try:
        _discard_orphaned_digest_items()
    except Exception:
        logger.exception("Digest orphan sweep: unhandled error before flush")

    with get_db() as db:
        # Find all echoes that have pending digest items
        pending_echoes = db.execute("""
            SELECT DISTINCT d.echo_id, e.destination_id, e.feed_id, e.user_id,
                   f.name as feed_name, ea.email as to_email
              FROM digest_items d
              JOIN echoes e ON d.echo_id = e.id
              JOIN feeds f ON e.feed_id = f.id
              JOIN email_accounts ea ON e.destination_id = ea.id
              JOIN users u ON u.id = e.user_id
             WHERE e.destination_type = 'email'
               AND e.delivery_mode = 'digest'
               AND e.enabled = 1
               AND f.deleted_at IS NULL
               AND e.deleted_at IS NULL
               AND u.suspended = 0
               AND NOT (u.plan = 'trial' AND u.trial_ends_at IS NOT NULL
                        AND u.trial_ends_at <= ?)
        """, (_now(),)).fetchall()

    if not pending_echoes:
        return

    for echo_row in pending_echoes:
        echo_id = echo_row["echo_id"]
        try:
            _flush_one_digest(echo_id, echo_row)
        except Exception:
            # Isolate one echo's failure from the rest of the batch: an
            # uncaught exception here (a transient DB error, an unexpected
            # data shape) must not skip every other tenant's pending digest
            # for the whole tick. Mirrors _flush_queue's per-row isolation.
            logger.exception("Digest flush: unhandled error for echo %s", echo_id)
            # record_failure feeds the notify threshold, so a permanently
            # broken echo (corrupt payload, persistent DB error) surfaces
            # to the user instead of failing silently every tick. The
            # send_email failure path in _flush_one_digest calls it too;
            # only exceptions OUTSIDE that guard were silent before.
            record_failure(echo_id)
            continue


def _build_digest_body(echo_row, items) -> tuple[str, str, list, list]:
    """Assemble a digest email's subject/body from queued rows.

    Returns (subject, body, sent_items, held_items). Items that fit the
    char cap go in the body; the rest are returned as held for the next
    flush. Malformed rows raise, which the caller's per-echo isolation
    contains. Extracted so tests can fault-inject body assembly (the step
    the narrow send_email try/except never covered).
    """
    # Build digest body incrementally so overflow can be detected
    # per-item: everything that fits goes out now, the rest stays
    # queued for the next flush. Silently truncating here reported
    # dropped content as delivered.
    date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    subject = f"FeedEcho Digest — {echo_row['feed_name']} — {date_str}"

    header = [f"FeedEcho Digest for {echo_row['feed_name']}", f"Date: {date_str}", ""]

    body_parts = list(header)
    used = sum(len(p) + 1 for p in body_parts)
    sent_items, held_items = [], []
    for i, item in enumerate(items, 1):
        title = item["item_title"] or item["item_url"] or f"Item {i}"
        lines = (
            f"{i}. {title}",
            f"   {item['rendered_content']}",
            "",
        )
        added = sum(len(ln) + 1 for ln in lines)
        if used + added > DIGEST_MAX_CHARS - DIGEST_NOTICE_RESERVE:
            held_items.append(item)
            continue
        body_parts.extend(lines)
        used += added
        sent_items.append(item)

    if held_items and not sent_items:
        # A single item larger than the whole cap: normally send it
        # truncated so one giant item cannot wedge the queue forever.
        # Degenerate case (a pathological title/URL that leaves no room
        # for any content): keep everything held instead of sending a
        # content-less title.
        first = held_items.pop(0)
        body_parts.append(f"1. {first['item_title'] or first['item_url'] or 'Item 1'}")
        budget = DIGEST_MAX_CHARS - DIGEST_NOTICE_RESERVE - used - len(body_parts[-1]) - 8
        if budget <= 0:
            held_items.insert(0, first)
        else:
            body_parts.append(f"   {first['rendered_content'][:budget]}…")
            sent_items.append(first)

    body = "\n".join(body_parts)
    if held_items:
        body += (
            f"\n\n[{len(held_items)} newer item{'s' if len(held_items) != 1 else ''}"
            " held for the next digest to stay under the size cap.]"
        )
    return subject, body, sent_items, held_items


def _flush_one_digest(echo_id: int, echo_row) -> None:
    with get_db() as db:
        items = db.execute(
            "SELECT * FROM digest_items WHERE echo_id = ? ORDER BY created_at ASC",
            (echo_id,),
        ).fetchall()

    if not items:
        return

    subject, body, sent_items, held_items = _build_digest_body(echo_row, items)

    if not sent_items:
        # Nothing fit this round (pathological content only): leave the
        # queue untouched rather than send an empty digest or drop the
        # item. Logged loudly so a wedged queue is discoverable.
        logger.warning(
            "Digest for echo %s held: an item exceeds the %d char cap",
            echo_id,
            DIGEST_MAX_CHARS,
        )
        return

    try:
        send_email(
            to_email=echo_row["to_email"],
            subject=subject,
            body=body,
            user_id=echo_row["user_id"],
        )
    except Exception as exc:
        logger.exception(
            "Digest flush failed for echo %s (%s, %d items)",
            echo_id,
            echo_row["feed_name"],
            len(items),
        )
        # Surface digest send failures to the user: mark the ATTEMPTED
        # rows (the ones in the body) 'failed' — without a retry time,
        # so the retry sweep never grabs them; only flush_digests owns
        # digest delivery — so the failure counter in
        # notify.record_failure can reach its threshold and alert on a
        # permanently broken SMTP config. Held items were never
        # attempted this round and stay 'queued'.
        with get_db() as db:
            for item in sent_items:
                db.execute(
                    """UPDATE posted_items
                          SET status = 'failed',
                              error_message = ?,
                              next_retry_at = NULL,
                              attempt_count = attempt_count + 1
                        WHERE echo_id = ? AND item_id = ?
                          AND status IN ('queued', 'failed')""",
                    (f"Digest send failed: {exc}", echo_id, item["item_id"]),
                )
        record_failure(echo_id)
        # Leave digest_items intact — the next successful flush sends them.
        return

    # Success — delete only the sent items and update posted_items to
    # 'success'; held items stay queued for the next flush.
    sent_item_ids = [item["item_id"] for item in sent_items]
    with get_db() as db:
        for item in sent_items:
            # Update posted_items to 'success'. 'failed' is included:
            # a prior flush may have marked the row failed on a send
            # error; a successful send now must still finalize it,
            # else the queue row below is deleted with the posted_item
            # stuck on 'failed'.
            db.execute(
                """UPDATE posted_items SET status = 'success', posted_at = ?
                   WHERE echo_id = ? AND item_id = ?
                     AND status IN ('queued', 'failed')""",
                (_now(), echo_id, item["item_id"]),
            )
        # Delete only the sent items, not any that may have arrived concurrently
        placeholders = ",".join("?" for _ in sent_item_ids)
        db.execute(
            f"DELETE FROM digest_items WHERE echo_id = ? AND item_id IN ({placeholders})",
            (echo_id, *sent_item_ids),
        )

    record_success(echo_id)
    logger.info(
        "Digest flushed for echo %s (%s): %d items sent, %d held",
        echo_id,
        echo_row["feed_name"],
        len(sent_items),
        len(held_items),
    )


def check_all_feeds() -> None:
    now = datetime.now(timezone.utc)
    with get_db() as db:
        if settings.MULTI:
            # Trial-pause + suspension gate at the sweep level too: a paused
            # user's feeds are not even considered "due", so their cursors
            # freeze (no fetch, no delivery, no retries) and nothing is
            # deleted. Single mode has no per-user state to check.
            feeds = db.execute(
                """
                SELECT f.id, f.name, f.poll_interval, f.last_fetched
                  FROM feeds f JOIN users u ON u.id = f.user_id
                 WHERE f.deleted_at IS NULL
                   AND u.suspended = 0
                   AND NOT (u.plan = 'trial' AND u.trial_ends_at IS NOT NULL
                            AND u.trial_ends_at <= ?)
                """,
                (_now(),),
            ).fetchall()
        else:
            feeds = db.execute(
                "SELECT id, name, poll_interval, last_fetched FROM feeds WHERE deleted_at IS NULL"
            ).fetchall()

    due = []
    for feed in feeds:
        if not feed["last_fetched"]:
            due.append(feed)
            continue
        try:
            # timestamp_str first: psycopg returns a datetime for
            # last_fetched, and datetime.replace("T", " ") raises TypeError,
            # which this except would swallow into "always due" — making
            # every feed poll on every tick and ignoring poll_interval.
            ts = timestamp_str(feed["last_fetched"])
            last = datetime.strptime(ts, "%Y-%m-%d %H:%M:%S").replace(
                tzinfo=timezone.utc
            )
        except (ValueError, TypeError):
            # Malformed timestamp: treat as due rather than skipping forever
            due.append(feed)
            continue
        # poll_interval is clamped to >=1 at creation; a malformed or NULL
        # value can only come from direct DB writes, so degrade to the
        # default instead of crashing the whole run or hot-looping the feed.
        try:
            interval = max(1, int(feed["poll_interval"] or 15))
        except (ValueError, TypeError):
            interval = 15
        if last <= now - timedelta(minutes=interval):
            due.append(feed)

    for feed in due:
        try:
            check_feed(feed["id"])
        except Exception:
            logger.exception("Error checking feed %s (%s)", feed["id"], feed["name"])


def cleanup_pending_accounts() -> int:
    """Delete abandoned card-pending signups (hosted billing only).

    A card-pending account registered while billing is on but never finished
    Stripe Checkout: plan='trial' with the TRIAL_PENDING sentinel. It cannot
    reach the app at all, so once it is older than
    settings.PENDING_ACCOUNT_TTL_DAYS it is only a row, a scrypt hash, a
    verification email and admin-table noise. Deletion runs the same path as
    self-serve deletion (account-deletion hooks, then _hard_delete_user), so
    a Stripe customer created by the abandoned checkout does not outlive the
    row and no tenant data survives.

    Rows carrying a subscription id are SKIPPED and logged: a webhook that
    failed mid-flight must never get its paying user deleted. Overlay-
    registered guards (_pending_cleanup_guards) get the same veto first: the
    hosted billing module asks Stripe whether a supposedly-cardless account
    actually has a live subscription. A guard failure is fail-closed.

    Returns the number of accounts deleted (0 when not hosted billing).
    """
    if not (settings.MULTI and settings.BILLING_ENABLED):
        return 0
    # Imported lazily: app imports this module at startup.
    import app as app_module

    cutoff = (
        datetime.now(timezone.utc)
        - timedelta(days=settings.PENDING_ACCOUNT_TTL_DAYS)
    ).strftime("%Y-%m-%d %H:%M:%S")
    with get_db() as db:
        rows = db.execute(
            "SELECT id, email, trial_ends_at, stripe_subscription_id"
            " FROM users WHERE plan = 'trial' AND created_at < ?",
            (cutoff,),
        ).fetchall()
    # Sentinel check in Python: the stored form differs by dialect.
    pending = [row for row in rows if plans.trial_pending(row["trial_ends_at"])]

    deleted = 0
    for row in pending:
        uid = row["id"]
        if (row["stripe_subscription_id"] or "").strip():
            logger.warning(
                "Pending-signup cleanup: user %s has a subscription id; skipping",
                uid,
            )
            continue
        vetoed = False
        for guard in app_module._pending_cleanup_guards:
            try:
                guard(uid)
            except app_module.AccountDeletionAbort as exc:
                logger.warning(
                    "Pending-signup cleanup: guard vetoed user %s (%s)", uid, exc
                )
                vetoed = True
                break
            except Exception:  # noqa: BLE001 — fail closed, never guess
                logger.exception(
                    "Pending-signup cleanup: guard failed for user %s; skipping", uid
                )
                vetoed = True
                break
        if vetoed:
            continue
        for hook in app_module._account_deletion_hooks:
            try:
                hook(uid)
            except app_module.AccountDeletionAbort as exc:
                logger.warning(
                    "Pending-signup cleanup: hook vetoed user %s (%s)", uid, exc
                )
                vetoed = True
                break
            except Exception:  # noqa: BLE001 — mirrors the delete route
                logger.exception(
                    "Pending-signup cleanup: deletion hook failed for user %s", uid
                )
        if vetoed:
            continue
        # One account failing must not abort the batch: an unhandled error
        # here would re-hit the same row every run and stop cleaning later
        # accounts.
        try:
            with get_db() as db:
                app_module._hard_delete_user(db, uid)
        except Exception:  # noqa: BLE001
            logger.exception(
                "Pending-signup cleanup: deleting user %s failed; continuing", uid
            )
            continue
        deleted += 1
        logger.info(
            "Pending-signup cleanup: deleted user %s (%s)", uid, row["email"]
        )
    return deleted


def start_scheduler() -> None:
    global scheduler
    if scheduler is not None:
        return

    scheduler = BackgroundScheduler()
    scheduler.add_job(
        check_all_feeds,
        trigger=IntervalTrigger(minutes=2),
        id="check_all_feeds",
        replace_existing=True,
        max_instances=1,
        coalesce=True,
    )
    scheduler.start()
    scheduler.add_job(check_all_feeds, "date", id="startup_check", replace_existing=True)

    # Digest flush job — runs hourly, checks for pending digest items
    scheduler.add_job(
        flush_digests,
        trigger=IntervalTrigger(hours=1),
        id="flush_digests",
        replace_existing=True,
        max_instances=1,
        coalesce=True,
    )
    # Run a digest flush shortly after startup
    scheduler.add_job(flush_digests, "date", id="startup_digest_flush", replace_existing=True)

    # Drip flush job — releases queued items as their rate windows allow
    scheduler.add_job(
        flush_drips,
        trigger=IntervalTrigger(minutes=10),
        id="flush_drips",
        replace_existing=True,
        max_instances=1,
        coalesce=True,
    )
    # Run a drip flush shortly after startup
    scheduler.add_job(flush_drips, "date", id="startup_drip_flush", replace_existing=True)

    # Queue flush job — dispatches human-scheduled curation queue posts
    scheduler.add_job(
        flush_queue,
        trigger=IntervalTrigger(minutes=5),
        id="flush_queue",
        replace_existing=True,
        max_instances=1,
        coalesce=True,
    )
    scheduler.add_job(flush_queue, "date", id="startup_queue_flush", replace_existing=True)

    # Abandoned-signup cleanup — deletes card-pending accounts past the TTL
    # (no-op unless multi mode with billing enabled).
    scheduler.add_job(
        cleanup_pending_accounts,
        trigger=IntervalTrigger(hours=6),
        id="cleanup_pending_accounts",
        replace_existing=True,
        max_instances=1,
        coalesce=True,
    )
    scheduler.add_job(
        cleanup_pending_accounts,
        "date",
        id="startup_pending_cleanup",
        replace_existing=True,
    )


def stop_scheduler() -> None:
    global scheduler
    if scheduler is not None:
        scheduler.shutdown(wait=False)
        scheduler = None