Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -869,9 +869,9 @@ myna cloud login # mynaposter.com hosts the un
myna newsletter import subscribers.csv --list moshcode # email,name,tags (or .json)
myna newsletter subscribe ada@example.com --list moshcode --name Ada
myna newsletter create --subject "Moshcode weekly #1" --list moshcode < issue.md
myna newsletter send moshcode-weekly-1 --test you@example.com # one [test] copy
myna newsletter send moshcode-weekly-1 --to you@example.com # one [test] copy (--test works too)
myna newsletter send moshcode-weekly-1 --dry-run
myna newsletter send moshcode-weekly-1
myna newsletter send moshcode-weekly-1 --yes # without --yes: the numbers, nothing sent
myna newsletter edit moshcode-weekly-2 --at "friday 9am" # the daemon sends it
myna newsletter list | show <id> [--body] | rm <id> [--force]
myna newsletter subscribers --list moshcode
Expand Down Expand Up @@ -900,7 +900,46 @@ it. A refusal is `failed` and is retried only with `--retry-failed`; a send
that died mid-message leaves `pending`, retried only with `--retry-uncertain`,
because that person may already have it. An issue sent by hand that stopped
part way (say with `--limit 5`) waits for a hand; only scheduled issues resume
on their own.
on their own. Messages go out one a second (`newsletter.paceMs`), and
`--max-per-day N` raises the cap for one run.

### Tracking and A/B tests

With [crawlproof.com](https://crawlproof.com) tracking set up, every link in an
issue goes through a signed click URL, the HTML part carries an open pixel,
and the unsubscribe link is crawlproof's signed one (one-click too) instead of
myna cloud's, so no `myna cloud login` is needed.

```bash
myna newsletter track set <trackingId> --secret <hex> # from the project's Tracking tab; the secret goes in the vault
myna newsletter track status --check
myna newsletter create --id moshcode-001 --list moshcode-users --service moshcode \
--subject "What shipped in moshcode" --subject-b "Five new things in moshcode" < issue.md
myna newsletter send moshcode-001 --dry-run # the variants and how the list splits
myna newsletter stats moshcode-001 # per variant: sent, opens, clicks, CTR, unsubscribes
myna newsletter sync-optouts # unsubscribes from myna cloud and crawlproof
myna newsletter cta list | add "<label>" <url> | rm "<label>" [--set name]
```

- **Variants.** An issue's subjects (one, or A and B with `--subject-b`) crossed
with the calls to action in its CTA set: two subjects and the four default
CTAs (Book a demo, Schedule a call, See our plans, Support us) make eight
variants, A to H. Which one a person gets is a hash of the issue id and their
address, so a resumed send gives nobody a different one. `{{cta}}` in the
body marks where the button goes; without it the CTA goes last, before the
footer. `create` uses the `default` set unless `--cta-set <name>` or
`--cta-set none`.
- **The footer.** `--service moshcode` makes it say "You get this because you
have an account at moshcode; our Terms say we may email news and updates."
instead of "you subscribed to <list>", for a list of your users.
- **Unsubscribes.** Before every send, myna pulls unsubscribes from every source
that is set up and makes them permanent opt-outs. If crawlproof cannot be
read, a tracked send refuses to go.
- **Stats.** Events are joined to variants through each recipient's msgId in
`newsletters.json`. Opens and clicks count unique messages, with machine-flagged
ones (mail proxies, scanners, Apple's prefetch) left out; all opens are shown
beside them. The leader has the best click-through rate, or the best open rate
before anyone has clicked. Test copies (`--to`) are never counted.

## Your settings on every machine

Expand Down
266 changes: 241 additions & 25 deletions apps/cli/src/cli/newsletter.ts

Large diffs are not rendered by default.

17 changes: 13 additions & 4 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,14 +145,23 @@ Direct (mail and texts to people, not posts to networks):
Newsletter (issues to a contacts list; one-click unsubscribe and your postal
address on every one, paced by outreach.maxEmailsPerDay):
newsletter create --subject "..." --list <L> [--at when] [--smtp id] [--reply-to r] < issue.md
[--subject-b "..."] A/B: variants are the subjects x the CTA set,
[--cta-set default|none] one per person; {{cta}} marks the button
[--service moshcode] [--id slug] footer: "an account at moshcode" not "subscribed"
newsletter list | show <id> [--body] | rm <id> [--force]
newsletter edit <id> [--subject] [--list] [--at when | --draft] [< issue.md]
newsletter send <id> [--dry-run] [--test addr] [--limit N]
[--retry-failed] [--retry-uncertain] Resumes from its ledger; nobody gets it twice
newsletter edit <id> [--subject] [--subject-b] [--list] [--cta-set] [--at when | --draft] [< issue.md]
newsletter send <id> [--dry-run] [--to addr] [--yes] [--limit N] [--max-per-day N]
[--retry-failed] [--retry-uncertain] --yes sends the list; --to one test copy (variant A)
Resumes from its ledger; nobody gets it twice
newsletter stats <id> Per variant: sent, opens, clicks, CTR, unsubscribes
newsletter subscribe <email...> --list <L> [--name] [--tags]
newsletter unsubscribe <email|token> [--list L] Without --list: opted out for good
newsletter subscribers --list <L> | import <file.csv|.json> --list <L>
newsletter sync Pull one-click unsubscribes from myna cloud
newsletter sync | sync-optouts Pull unsubscribes from myna cloud and crawlproof
newsletter track set <id> [--secret hex] | track status [--check] | track off
crawlproof tracking: signed links, open pixel,
its unsubscribe link; the secret goes in the vault
newsletter cta list | add "<label>" <url> | rm "<label>" [--set default]
myna config newsletter.address "..." required (CAN-SPAM)
myna config newsletter.unsubscribeUrl https://you/u/{token} else myna cloud hosts it
AT Protocol (the network behind Bluesky; a directory of its servers):
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/core/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { pluginContext } from "../plugins/context.ts";
import { runEvergreen } from "./evergreen.ts";
import { runRecap } from "./recap.ts";
import { canSync, syncConfigOnce } from "../store/synconfig.ts";
import { runDueNewsletters, syncUnsubscribes } from "./newsletter.ts";
import { runDueNewsletters, syncAllUnsubscribes } from "./newsletter.ts";
import { readNewsletters } from "../store/newsletters.ts";

export interface DaemonJob {
Expand Down Expand Up @@ -69,11 +69,12 @@ export function builtinJobs(log: (line: string) => void, tickMs: number): Daemon
everyMs: 600_000,
async run() {
if (!readNewsletters().newsletters.some((entry) => entry.scheduledFor && entry.status !== "sent")) {
const synced = await syncUnsubscribes();
if (synced.optedOut.length) return `${synced.optedOut.length} unsubscribed`;
const synced = await syncAllUnsubscribes();
const out = synced.cloud.optedOut.length + (synced.tracking?.optedOut.length ?? 0);
if (out) return `${out} unsubscribed`;
return;
}
const reports = await runDueNewsletters(new Date(), { log });
const reports = await runDueNewsletters(new Date(), { log, paceMs: loadSettings().newsletter.paceMs });
const lines = reports
.filter((report) => report.sent.length || report.failed.length)
.map((report) => `${report.id}: ${report.sent.length} sent, ${report.failed.length} failed, ${report.remaining} left (${report.status})`);
Expand Down
Loading