Skip to content

myna newsletter: crawlproof tracking, A/B variants, stats (stacked on #89) - #90

Merged
ralyodio merged 1 commit into
feat/newsletterfrom
feat/newsletter-crawlproof
Sep 24, 2026
Merged

ralyodio merged 1 commit into
feat/newsletterfrom
feat/newsletter-crawlproof

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #89. The base is feat/newsletter. Merge #89 first, then retarget this PR to main. This PR does not change #89's branch.

What it adds to #89's myna newsletter

The code sits in #89's design: one command, one core/newsletter.ts, the same issues, subscriber lists, per-recipient ledger, myna cloud unsubscribe inbox, daemon job and MCP tools. #89 behaves exactly as before unless tracking, --subject-b or a CTA set is used.

  • crawlproof tracking (myna newsletter track set <trackingId> [--secret <hex>] | track status [--check] | track off). The 24-hex tracking id goes in settings.newsletter.trackingId and the secret in the vault. Once it is set, every issue gets:

    • signed click URLs on every http(s) link, in both text and HTML;
    • an open pixel in the HTML;
    • crawlproof's signed unsubscribe link, with RFC 8058 one-click, in the footer and List-Unsubscribe, in place of the myna cloud one. A tracked send does not need myna cloud login.

    This follows the contract in Email tracking: per-project tracking URL (opens, clicks, unsubscribes, events API) crawlproof.com#266: next is null or an absolute URL to GET, the signature covers u exactly as sent, and machine is only set on opens and clicks.

  • A/B variants. An issue's subjects (--subject-b on create/edit) are crossed with its CTA set. create uses default unless --cta-set <name> or --cta-set none is given. Each person's variant comes from sha256(issueId:email), so a resumed send never changes it. {{cta}} marks where the button goes; without it the CTA goes last, before the footer. myna newsletter cta list | add | rm [--set] manages the sets. The four defaults are Book a demo, Schedule a call, See our plans and Support us. Changing the variants of an issue that is part way sent is refused.

  • --service moshcode changes the footer line to "You get this because you have an account at moshcode; our Terms say we may email news and updates." The default line says the person subscribed to the list.

  • send

    • --to <addr>, an alias of --test, sends one [test] copy as variant A and says so. It is not written to the ledger. An address that has opted out is refused.
    • A list send needs --yes. Without it, the command prints the counts and the variant split and exits 1.
    • --max-per-day N overrides the cap for one run. --pace-ms N overrides newsletter.paceMs (1000). The daemon paces too.
    • The ledger now records msgId, variant, subjectKey and cta for each recipient.
  • Unsubscribes. Every send pulls from each source that is set up. For myna cloud this works as in 0.29.0: myna newsletter, with one-click unsubscribe on every issue #89: a failure is a warning. For crawlproof, a failure stops the send before anyone is mailed. sync-optouts (an alias of sync) and the daemon pull from both. An address that is not a contact yet is added as opted out.

  • stats <id> [--json] joins events to variants through the ledger's msgIds. Per variant it shows sent, unique human opens (all opens beside them), unique human clicks, CTR and unsubscribes, and names the leader. Machine-flagged clicks are left out as well, so a link scanner cannot pick the winner. Test copies are never counted.

  • SMTP. A MIME part with any line longer than 998 octets is sent quoted-printable. Parts with shorter lines stay 8bit.

Tests

packages/core/test/newsletter-tracking.test.ts adds 11 tests. They run against a fake SMTP server on a local port, with crawlproof played by a stubbed fetch that pages through next URLs. No real mail is sent. #89's newsletter.test.ts is unchanged and passes.

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

🤖 Generated with Claude Code

@ralyodio
ralyodio marked this pull request as ready for review September 24, 2026 05:11
)

Ports the tracked newsletter onto #89's issue model instead of beside it:
one `myna newsletter`, one core/newsletter.ts.

- track set|status|off: crawlproof tracking id (24 hex) in settings, the
  secret in the vault. Set, every issue's links go through signed click
  URLs, the HTML carries an open pixel, and the unsubscribe link is
  crawlproof's signed one (one-click) instead of myna cloud's.
- A/B variants: an issue's subjects (--subject-b) crossed with its CTA set
  (--cta-set, default "default": Book a demo, Schedule a call, See our
  plans, Support us), one per person by sha256(issue:email). {{cta}}
  marks the button. `cta list|add|rm` edits the sets.
- --service puts "You get this because you have an account at <service>;
  our Terms say we may email news and updates." in the footer.
- send: --to (alias of --test) sends one copy as variant A; a list send
  needs --yes, else prints the count and variant split; --max-per-day and
  --pace-ms (newsletter.paceMs, 1000); the ledger records msgId and
  variant per recipient.
- Before a send, unsubscribes are pulled from every configured source; a
  tracked send refuses if crawlproof cannot be read. sync-optouts (alias
  of sync) pulls both; the daemon does too.
- stats <id>: per variant sent, human and all opens, human clicks, CTR,
  unsubscribes, and the leader, joined through the ledger's msgIds.
- SMTP: a part with a line over 998 octets is sent quoted-printable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio force-pushed the feat/newsletter-crawlproof branch from 117b65a to 4ac4a15 Compare September 24, 2026 05:20
@ralyodio ralyodio changed the title myna newsletter: crawlproof tracking, A/B variants, unsubscribe in every footer myna newsletter: crawlproof tracking, A/B variants, stats (stacked on #89) Sep 24, 2026
@ralyodio
ralyodio changed the base branch from main to feat/newsletter September 24, 2026 05:21
@ralyodio
ralyodio marked this pull request as draft September 24, 2026 05:21
@ralyodio
ralyodio marked this pull request as ready for review September 24, 2026 05:24
@ralyodio
ralyodio merged commit fc102a0 into feat/newsletter Sep 24, 2026
2 checks passed
@ralyodio

Copy link
Copy Markdown
Contributor Author

Merged into feat/newsletter after #89 had already been squashed into main, so this code never reached main. Replaced by #92, which is based on main (with #91) and adds fail-closed opt-out pulls.

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>
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