Back to DevLog

Newsletter backup dropped unsubscribe and source columns

2 min read

The analytics backup and restore round-trip dropped source and unsubscribed entirely. A restore would silently resurrect deleted rows and re-subscribe every opt-out as active.

The column that never existed in code

_init_newsletter_table() created the table with source but never with unsubscribed. The column existed in prod only via a manual ALTER. Since _merge_analytics() calls _init_newsletter_table(), a real restore would have recreated the table without the column and killed every sender, the CSV export and /api/subscribers with no such column: unsubscribed.

The fix

_backup_analytics() now declares and writes both columns. _merge_analytics() restores them. _init_newsletter_table() now creates unsubscribed and backfills both columns.

A restored subscriber whose unsubscribed value cannot be determined comes back as unsubscribed = 1, not 0. Mailing someone who opted out is unrecoverable. Not mailing them is recoverable via /subscribe, which clears the flag on re-signup. The merge prints a loud [ANALYTICS MERGE] WARNING naming the missing columns.

INSERT OR IGNORE is kept on the restore. An existing row is never touched, so a stale backup can never flip a live opt-out back to active.

Admin delete and unsubscribe

/admin/subscribers now has two per-row actions: reversible unsubscribe, and a de-emphasized permanent delete with type-to-confirm. Both routes check session['admin_authenticated'] directly, not _admin_authenticated(), so the X-Admin-Secret header cannot delete.

The admin subscriber rows emit sub.id in a hidden field. The visible # column is loop.index over an ORDER BY signup_date DESC list and would target the wrong row.

Type-to-confirm is verified server-side against the id with hmac.compare_digest. A stale page cannot delete a different row than the one on screen.

Email reaches JS via data-email and dataset. An address containing a quote would otherwise break out once the browser decodes the HTML entity.

Share this post