Skip to content

myna newsletter: crawlproof tracking, A/B variants, stats, fail-closed opt-outs - #92

Merged
ralyodio merged 4 commits into
mainfrom
feat/newsletter-tracking-main
Sep 24, 2026
Merged

ralyodio merged 4 commits into
mainfrom
feat/newsletter-tracking-main

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

This replaces #90. #90 was merged into feat/newsletter after #89 had already been squash-merged into main (c6ff655), so its code (fc102a0) never reached main. This branch is based on main as it is now (82fca38, which includes #91). It brings #90 over and adds the fail-closed opt-out change.

Commits

  1. Port of myna newsletter: crawlproof tracking, A/B variants, stats (stacked on #89) #90 (747d398). This is fc102a0 cherry-picked onto main. The conflicts with 0.29.1: one click unsubscribes, and the page offers Re-subscribe #91 were resolved in 0.29.1: one click unsubscribes, and the page offers Re-subscribe #91's favour: its one-click unsubscribe, the Re-subscribe page, and optIn on a re-subscribe all stay as they are. sync and the daemon job report re-subscribes as well. crawlproof's signed unsubscribe link is used only when tracking is on; otherwise the myna cloud link (or newsletter.unsubscribeUrl) is used, as in 0.29.0: myna newsletter, with one-click unsubscribe on every issue #89/0.29.1: one click unsubscribes, and the page offers Re-subscribe #91.

  2. Fail closed on both opt-out sources (fb4cf56, fc07975). If the pull from the myna cloud inbox or from crawlproof fails:

    • a list send stops before anyone is mailed;
    • the daemon's newsletter job fails that turn, whether an issue is due or it is only pulling;
    • myna newsletter sync / sync-optouts prints FAILED for that source and exits 1.

    syncAllUnsubscribes tries every configured source and names each one that failed. A --to test copy still goes out, with a warning. Error text is plain, with no em dashes: the HTTP helper's dash is shown as a colon.

  3. The newest opt-out or re-subscribe wins across both sources (e8d3882). newsletters.json now keeps optChanges: for each contact, the newest unsubscribe or re-subscribe applied from either source, with that source's timestamp. A pulled event older than that is skipped. So a myna cloud re-subscribe is not undone by an older crawlproof unsubscribe that arrives in the same pull or a later one, and the reverse also holds.

What #90 adds (unchanged from #90)

  • myna newsletter track set <24-hex id> [--secret] | status [--check] | off. The secret is kept in the vault.
  • When tracking is on, links are signed click URLs, the HTML part has an open pixel, and the footer and List-Unsubscribe use crawlproof's signed one-click unsubscribe link.
  • A/B variants: the subjects (--subject-b) crossed with a CTA set (--cta-set, default default). Each person's variant is fixed by sha256(issueId:email). {{cta}} marks where the CTA goes. cta list|add|rm manages the sets.
  • --service sets the footer line to "an account at ".
  • send --to sends one test copy as variant A. A list send needs --yes. --max-per-day overrides the daily cap for one run, and sends are paced.
  • stats <id> shows each variant's results and names the leader.
  • MIME parts with lines over 998 octets are sent quoted-printable.

What the data cannot tell

  • Clocks. The ordering compares the timestamps each server records (mynaposter.com and crawlproof.com) and assumes their clocks agree to within the gap between one person's two clicks. If two events for one person carry the same timestamp, the one pulled later wins.
  • Events from before this version. Events applied before this version have no optChanges entry. The first event pulled for a contact after upgrading is applied as-is, with nothing older to compare against.
  • Manual opt-outs. A manual opt-out (myna newsletter unsubscribe, myna contacts optout) is not recorded in optChanges. As in 0.29.1: one click unsubscribes, and the page offers Re-subscribe #91, a later myna cloud re-subscribe from that person lifts it.

Tests

packages/core/test/newsletter-tracking.test.ts has 16 tests. They run against a fake SMTP server, with a stubbed fetch standing in for crawlproof and myna cloud. They cover:

  • a list send refused when the myna cloud pull fails, tracked or not;
  • both sources down, with both named in the error;
  • the daemon refusing a due issue, and its idle pull failing loudly;
  • the --to test copy going out with a warning;
  • syncAllUnsubscribes trying every source;
  • newest-wins ordering within one pull and across pulls, in both directions.

No real mail is sent.

  • bun x tsc --noEmit -p tsconfig.json: clean
  • bun run test: 554 pass, 19 skip, 0 fail
  • release workflow test line: 560 pass, 0 fail

🤖 Generated with Claude Code

ralyodio and others added 4 commits September 24, 2026 05:27
…to main)

#90 was merged into feat/newsletter after #89 had already been squashed
into main, so its commit (fc102a0) never reached main. This is that
commit cherry-picked onto main, with the conflicts against #91 resolved:
sync and the daemon report re-subscribes from myna cloud as well as
unsubscribes from both sources.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A list send, the daemon's newsletter job and myna newsletter sync now
treat a failed myna cloud pull the way they already treated crawlproof:
the send stops before anyone is mailed, the daemon job fails that turn,
and sync exits 1. syncAllUnsubscribes tries every configured source and
names each that failed. A --to test copy still goes out, with a warning.
Error text is plain; the HTTP helper's dash is shown as a colon.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
newsletters.json keeps, per contact, the newest unsubscribe or
re-subscribe applied from myna cloud or crawlproof, with the source's
timestamp. A pulled event older than that is skipped, so a myna cloud
re-subscribe is not undone by an older crawlproof unsubscribe that
arrives in the same or a later pull, and the reverse.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio merged commit 89154ae into main Sep 24, 2026
2 checks passed
This was referenced Sep 24, 2026
ralyodio added a commit that referenced this pull request Sep 24, 2026
Tags what is on main after v0.29.1: #92, the port of #90 (crawlproof
open/click tracking, A/B subject x CTA variants, newsletter stats, the
quoted-printable body for long lines), merged without a bump.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ralyodio added a commit that referenced this pull request Sep 24, 2026
Newsletter tracking through crawlproof, A/B subjects crossed with a rotating
CTA set, per-variant stats, and opt-out pulls that fail closed (#92).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant