From 7fe86ebb32cd3d78a0eea0b053f040601bd61095 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 06:34:17 +0000 Subject: [PATCH] myna newsletter track connect : CrawlProof tracking in one step, on every surface connectTracking reads the project's tracking id and secret from CrawlProof's new GET /api/v1/email-tracking/?secret=1 with the token myna already holds (myna crawlproof login), switches tracking on when it is off, and keeps the id in settings and the secret in the vault. CLI: myna newsletter track connect . TUI: /newsletter track . Desktop: Connect tracking. MCP: myna_newsletter_track_connect. Needs crawlproof.com#268 deployed. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 7 +- apps/api/package.json | 2 +- apps/cli/package.json | 2 +- apps/cli/src/cli/newsletter.ts | 10 ++- apps/cli/src/main.ts | 2 + apps/cli/src/tui/newsletter.ts | 17 ++++- apps/desktop/renderer/app.js | 8 +++ apps/desktop/renderer/index.html | 4 ++ apps/desktop/src/main.cjs | 1 + apps/desktop/src/preload.cjs | 1 + bun.lock | 10 +-- packages/core/package.json | 2 +- packages/core/src/core/newsletter.ts | 48 ++++++++++++- packages/core/src/index.ts | 2 + packages/core/src/version.ts | 2 +- packages/core/test/newsletter-connect.test.ts | 67 +++++++++++++++++++ packages/mcp/package.json | 2 +- packages/mcp/src/tools.ts | 17 +++++ packages/plugin-dashboard/package.json | 2 +- 19 files changed, 188 insertions(+), 18 deletions(-) create mode 100644 packages/core/test/newsletter-connect.test.ts diff --git a/README.md b/README.md index 7c6578c..f44a2c8 100644 --- a/README.md +++ b/README.md @@ -980,7 +980,8 @@ 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 --secret # from the project's Tracking tab; the secret goes in the vault +myna newsletter track connect moshcode.sh # from your CrawlProof project: switched on, id and secret kept +myna newsletter track set --secret # or by hand, from the project's Tracking tab 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 @@ -1125,7 +1126,7 @@ bun run db:migrate { "mcpServers": { "myna": { "command": "bunx", "args": ["@profullstack/myna-mcp"] } } } ``` -Twenty-seven tools: `myna_accounts`, `myna_networks`, `myna_skills`, +Twenty-eight tools: `myna_accounts`, `myna_networks`, `myna_skills`, `myna_skill`, `myna_preview`, `myna_post`, `myna_update`, `myna_schedule`, `myna_queue`, `myna_cancel`, `myna_history`, `myna_draft`, `myna_timeline`, `myna_search`, and for [directories](#directories) `myna_directories`, @@ -1134,7 +1135,7 @@ and for [directories](#directories) `myna_directories`, `myna_newsletters`, `myna_newsletter`, `myna_newsletter_create`, `myna_newsletter_edit`, `myna_newsletter_delete`, `myna_newsletter_send` (a dry run unless `dry_run` is false), `myna_newsletter_subscribe`, -`myna_newsletter_unsubscribe` and `myna_newsletter_subscribers`. An agent should call `myna_skill` for an account +`myna_newsletter_unsubscribe`, `myna_newsletter_subscribers` and `myna_newsletter_track_connect`. An agent should call `myna_skill` for an account before `myna_post` to it: the [skill](#skills-the-rules-per-network-and-per-account) says what belongs there and how often. diff --git a/apps/api/package.json b/apps/api/package.json index 0e45fec..5bca1b0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/myna-api", - "version": "0.31.0", + "version": "0.32.0", "private": true, "type": "module", "scripts": { diff --git a/apps/cli/package.json b/apps/cli/package.json index 090613b..f7a8038 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/myna", - "version": "0.31.0", + "version": "0.32.0", "description": "A terminal social media manager. Log in, compose, schedule and post to every network from one TUI.", "license": "MIT", "type": "module", diff --git a/apps/cli/src/cli/newsletter.ts b/apps/cli/src/cli/newsletter.ts index 99e3d7d..2b1f076 100644 --- a/apps/cli/src/cli/newsletter.ts +++ b/apps/cli/src/cli/newsletter.ts @@ -30,6 +30,7 @@ */ import { readFileSync } from "node:fs"; import { + connectTracking, TRACKING_ID, TRACKING_SECRETS, createNewsletter, @@ -119,6 +120,13 @@ async function runTrack(rest: string[], flags: Flags): Promise { out("Every issue's links, open pixel and unsubscribe link now go through it."); return 0; } + if (sub === "connect") { + if (!id) throw new Error("Usage: myna newsletter track connect (a CrawlProof project, e.g. moshcode.sh; needs myna crawlproof login)"); + const connected = await connectTracking(id); + out(`Tracking through ${trackingBase({ id: connected.trackingId, host: connected.host })} for ${connected.site}${connected.enabled ? " (switched on just now)" : ""}. The secret is in the vault.`); + out("Every issue's links, open pixel and unsubscribe link now go through it."); + return 0; + } if (sub === "off") { settings.newsletter.trackingId = ""; saveSettings(settings); @@ -144,7 +152,7 @@ async function runTrack(rest: string[], flags: Flags): Promise { } return 0; } - throw new Error(`Unknown: myna newsletter track ${sub}. Try set, status or off.`); + throw new Error(`Unknown: myna newsletter track ${sub}. Try connect, set, status or off.`); } function runCta(rest: string[], flags: Flags): number { diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 3309a76..348cebc 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -166,6 +166,8 @@ address on every one, paced by outreach.maxEmailsPerDay): newsletter unsubscribe [--list L] Without --list: opted out for good newsletter subscribers --list | import --list newsletter sync | sync-optouts Pull unsubscribes from myna cloud and crawlproof + newsletter track connect Tracking from your CrawlProof project, on and saved + in one step (needs myna crawlproof login) newsletter track set [--secret hex] | track status [--check] | track off crawlproof tracking: signed links, open pixel, its unsubscribe link; the secret goes in the vault diff --git a/apps/cli/src/tui/newsletter.ts b/apps/cli/src/tui/newsletter.ts index 6f2f777..ca972bd 100644 --- a/apps/cli/src/tui/newsletter.ts +++ b/apps/cli/src/tui/newsletter.ts @@ -12,10 +12,12 @@ * /newsletter schedule the daemon sends it * /newsletter stats per variant, from crawlproof * /newsletter subscribe | unsubscribe | import + * /newsletter track CrawlProof tracking, from your project * /newsletter rm */ import type { Container, Theme } from "@profullstack/hqtui"; import { + connectTracking, createNewsletter, editNewsletter, fetchNewsletterStats, @@ -225,6 +227,19 @@ export async function runNewsletterCommand(state: State, args: string, redraw: ( say([result ? (result.permanent ? `${result.id} is opted out for good.` : `${result.id} is off ${list}.`) : `No subscriber ${who}.`]); return; } + case "track": { + const [site] = rest; + if (!site) throw new Error("Usage: /newsletter track (a CrawlProof project; needs myna crawlproof login)"); + state.busy = `Connecting CrawlProof tracking for ${site}…`; + try { + const connected = await connectTracking(site); + say([`Tracking through CrawlProof ${connected.trackingId} for ${connected.site}${connected.enabled ? " (switched on)" : ""}.`]); + toast(state, `Tracking on for ${connected.site}`, "success"); + } finally { + state.busy = ""; + } + return; + } case "rm": { const id = requireId(rest); state.confirm = { @@ -244,7 +259,7 @@ export async function runNewsletterCommand(state: State, args: string, redraw: ( return; } default: - throw new Error(`Unknown: /newsletter ${sub}. Try new, dry, test, send, schedule, stats, subscribe, import, unsubscribe or rm.`); + throw new Error(`Unknown: /newsletter ${sub}. Try new, dry, test, send, schedule, stats, subscribe, import, unsubscribe, track or rm.`); } } diff --git a/apps/desktop/renderer/app.js b/apps/desktop/renderer/app.js index bf028d2..32d8e87 100644 --- a/apps/desktop/renderer/app.js +++ b/apps/desktop/renderer/app.js @@ -878,6 +878,14 @@ $("#btn-nl-import").addEventListener("click", async () => { renderNewsletter(); }); +$("#btn-nl-track").addEventListener("click", async () => { + const site = nl("track-site").value.trim(); + if (!site) return status("Name the CrawlProof project, e.g. moshcode.sh.", "error"); + const connected = await guard(() => api.newsletter.track(site), `Connecting ${site}…`); + if (connected) status(`Tracking on for ${connected.site} (${connected.trackingId}).`, "success"); + renderNewsletter(); +}); + $("#btn-nl-unsub").addEventListener("click", async () => { const who = nl("unsub").value.trim(); if (!who) return; diff --git a/apps/desktop/renderer/index.html b/apps/desktop/renderer/index.html index d1cf4ec..fdc344f 100644 --- a/apps/desktop/renderer/index.html +++ b/apps/desktop/renderer/index.html @@ -126,6 +126,10 @@ +
+ + +
diff --git a/apps/desktop/src/main.cjs b/apps/desktop/src/main.cjs index e7190fe..4ed565a 100644 --- a/apps/desktop/src/main.cjs +++ b/apps/desktop/src/main.cjs @@ -296,6 +296,7 @@ handle("newsletter:stats", async (id) => { if (!tracking) throw new Error("Tracking is off. Run: myna newsletter track set "); return core.fetchNewsletterStats(id, tracking); }); +handle("newsletter:track", (site) => core.connectTracking(site)); handle("newsletter:subscribers", (list) => core.subscribers(list).map(({ contact, active }) => ({ id: contact.id, email: contact.email, name: contact.name, active })), ); diff --git a/apps/desktop/src/preload.cjs b/apps/desktop/src/preload.cjs index 3625946..c80fad9 100644 --- a/apps/desktop/src/preload.cjs +++ b/apps/desktop/src/preload.cjs @@ -57,6 +57,7 @@ contextBridge.exposeInMainWorld("myna", { subscribe: (list, emails) => call("newsletter:subscribe", list, emails), unsubscribe: (who, list) => call("newsletter:unsubscribe", who, list), import: (list) => call("newsletter:import", list), + track: (site) => call("newsletter:track", site), }, settings: { diff --git a/bun.lock b/bun.lock index 872466c..de96d14 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,7 @@ }, "apps/api": { "name": "@profullstack/myna-api", - "version": "0.31.0", + "version": "0.32.0", "dependencies": { "@profullstack/myna-core": "workspace:*", "@profullstack/myna-mcp": "workspace:*", @@ -24,7 +24,7 @@ }, "apps/cli": { "name": "@profullstack/myna", - "version": "0.31.0", + "version": "0.32.0", "bin": { "myna": "./bin/myna.mjs", }, @@ -58,7 +58,7 @@ }, "packages/core": { "name": "@profullstack/myna-core", - "version": "0.31.0", + "version": "0.32.0", "dependencies": { "@anthropic-ai/sdk": "^0.123.0", "@profullstack/synconfig": "workspace:*", @@ -66,7 +66,7 @@ }, "packages/mcp": { "name": "@profullstack/myna-mcp", - "version": "0.31.0", + "version": "0.32.0", "bin": { "myna-mcp": "./src/server.ts", }, @@ -101,7 +101,7 @@ }, "packages/plugin-dashboard": { "name": "@profullstack/myna-plugin-dashboard", - "version": "0.31.0", + "version": "0.32.0", "devDependencies": { "@profullstack/myna-core": "workspace:*", }, diff --git a/packages/core/package.json b/packages/core/package.json index b211e76..22e973c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/myna-core", - "version": "0.31.0", + "version": "0.32.0", "description": "Network adapters, credential vault, scheduling and the AI writer behind myna.", "license": "MIT", "type": "module", diff --git a/packages/core/src/core/newsletter.ts b/packages/core/src/core/newsletter.ts index fe04996..f9eae09 100644 --- a/packages/core/src/core/newsletter.ts +++ b/packages/core/src/core/newsletter.ts @@ -41,10 +41,10 @@ import { addressOf, type SmtpMessage, type SmtpOptions, type SmtpServer } from " import { resolveSender, sendEach, smtpProvider, type MailMessage, type MailSender } from "./mail/index.ts"; import { escapeHtml, renderMarkdown } from "../util/markdown.ts"; import { getJson, postJson } from "../util/http.ts"; -import { loadSettings, type NewsletterCta } from "../store/settings.ts"; +import { loadSettings, saveSettings, type NewsletterCta } from "../store/settings.ts"; import { addToList, optIn, optOut, readContacts, recipients, upsertContact, removeFromList, contactId, type Contact } from "../store/contacts.ts"; import { outreachSentToday, recordSent } from "../store/outreach.ts"; -import { getPluginSecrets } from "../store/accounts.ts"; +import { getPluginSecrets, setPluginSecrets } from "../store/accounts.ts"; import { session, DEFAULT_SERVER } from "../store/cloud.ts"; import { claimOptChange, @@ -96,6 +96,50 @@ export const TRACKING_ID = /^[0-9a-f]{24}$/i; const DEFAULT_TRACKING_HOST = "https://crawlproof.com"; const CTA_MARK = "{{cta}}"; +export interface TrackingConnection { + site: string; + trackingId: string; + /** True when this call switched tracking on. */ + enabled: boolean; + host: string; +} + +/** + * Set tracking up from a site name, with the CrawlProof token myna already + * holds (`myna crawlproof login`): read the project's tracking id and secret + * from GET /api/v1/email-tracking/?secret=1, switch it on if it is off, + * and keep both (the id in settings, the secret in the vault). No copying a + * secret out of a dashboard. + */ +export async function connectTracking(site: string, options: { fetcher?: Fetch } = {}): Promise { + const fetcher = options.fetcher ?? fetch; + const creds = getPluginSecrets("crawlproof"); + if (!creds.token) throw new Error("No CrawlProof token. Run: myna crawlproof login"); + const host = (creds.url || "https://crawlproof.com").replace(/\/+$/, ""); + const headers = { authorization: `Bearer ${creds.token}`, accept: "application/json" }; + const path = `${host}/api/v1/email-tracking/${encodeURIComponent(site.trim())}`; + const read = async (response: Response): Promise> => { + const json = (await response.json().catch(() => ({}))) as Record; + if (!response.ok) throw new Error(`CrawlProof: ${response.status} ${String(json.error ?? "")}`.trim()); + return json; + }; + const row = await read(await fetcher(`${path}?secret=1`, { headers })); + const trackingId = String(row.tracking_id ?? ""); + const secret = String(row.secret ?? ""); + if (!TRACKING_ID.test(trackingId) || !/^[0-9a-f]{32,}$/i.test(secret)) throw new Error("CrawlProof answered without a tracking id and secret."); + let enabled = false; + if (!row.enabled) { + await read(await fetcher(`${path}/enable`, { method: "POST", headers })); + enabled = true; + } + const settings = loadSettings(); + settings.newsletter.trackingId = trackingId.toLowerCase(); + settings.newsletter.trackingHost = host; + saveSettings(settings); + setPluginSecrets(TRACKING_SECRETS, { ...getPluginSecrets(TRACKING_SECRETS), trackingSecret: secret }); + return { site: String(row.site ?? site), trackingId, enabled, host }; +} + /** The tracking set up on this machine: the id from settings, the secret from the vault. */ export function newsletterTracking(): Tracking | undefined { const settings = loadSettings().newsletter; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 020473b..69fa0fc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -482,6 +482,8 @@ export { type SyncResult, // crawlproof tracking, A/B variants, stats newsletterTracking, + connectTracking, + type TrackingConnection, TRACKING_SECRETS, TRACKING_ID, trackingBase, diff --git a/packages/core/src/version.ts b/packages/core/src/version.ts index c1c232e..cfa8aee 100644 --- a/packages/core/src/version.ts +++ b/packages/core/src/version.ts @@ -6,4 +6,4 @@ * binary reporting the wrong version, and nothing would fail. A test asserts * this matches the package.json it is published under. */ -export const VERSION = "0.31.0"; +export const VERSION = "0.32.0"; diff --git a/packages/core/test/newsletter-connect.test.ts b/packages/core/test/newsletter-connect.test.ts new file mode 100644 index 0000000..740eb3c --- /dev/null +++ b/packages/core/test/newsletter-connect.test.ts @@ -0,0 +1,67 @@ +/** + * `myna newsletter track connect `: tracking set up from a CrawlProof + * project with the token myna already holds, against a faked CrawlProof. + */ +import { test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { connectTracking, newsletterTracking } from "../src/core/newsletter.ts"; +import { setPluginSecrets } from "../src/store/accounts.ts"; +import { loadSettings } from "../src/store/settings.ts"; + +let dir = ""; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "myna-connect-")); + process.env.MYNA_HOME = dir; + process.env.MYNA_PASSPHRASE = "test passphrase"; +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + delete process.env.MYNA_HOME; + delete process.env.MYNA_PASSPHRASE; +}); + +const SECRET = "c17c71958f0649cd9ea5f949a0df64bc72b890b7a4468fb1009c11ce894118cd"; + +function crawlproof(enabled: boolean) { + const calls: string[] = []; + const fetcher = (async (url: string, init?: RequestInit) => { + calls.push(`${init?.method ?? "GET"} ${url}`); + expect(new Headers(init?.headers as Record).get("authorization")).toBe("Bearer crp_test"); + if (url === "https://crawlproof.test/api/v1/email-tracking/moshcode.sh?secret=1") { + return Response.json({ site: "moshcode.sh", tracking_id: "e960e0a69972a7f34ea197bb", enabled, secret: SECRET }); + } + if (url === "https://crawlproof.test/api/v1/email-tracking/moshcode.sh/enable" && init?.method === "POST") { + return Response.json({ site: "moshcode.sh", tracking_id: "e960e0a69972a7f34ea197bb", enabled: true }); + } + return Response.json({ error: "No such project, or more than one matches. Use the project id." }, { status: 404 }); + }) as unknown as typeof fetch; + return { calls, fetcher }; +} + +test("connect reads the id and secret, switches tracking on when off, and keeps both", async () => { + setPluginSecrets("crawlproof", { token: "crp_test", url: "https://crawlproof.test/" }); + const cp = crawlproof(false); + const connected = await connectTracking("moshcode.sh", { fetcher: cp.fetcher }); + expect(connected).toEqual({ site: "moshcode.sh", trackingId: "e960e0a69972a7f34ea197bb", enabled: true, host: "https://crawlproof.test" }); + expect(cp.calls).toEqual([ + "GET https://crawlproof.test/api/v1/email-tracking/moshcode.sh?secret=1", + "POST https://crawlproof.test/api/v1/email-tracking/moshcode.sh/enable", + ]); + expect(newsletterTracking()).toEqual({ id: "e960e0a69972a7f34ea197bb", secret: SECRET, host: "https://crawlproof.test" }); + expect(loadSettings().newsletter.trackingHost).toBe("https://crawlproof.test"); +}); + +test("an already-on project is not switched again, and failures say why", async () => { + setPluginSecrets("crawlproof", { token: "crp_test", url: "https://crawlproof.test" }); + const on = crawlproof(true); + expect((await connectTracking("moshcode.sh", { fetcher: on.fetcher })).enabled).toBe(false); + expect(on.calls).toHaveLength(1); + await expect(connectTracking("nope.dev", { fetcher: on.fetcher })).rejects.toThrow("CrawlProof: 404 No such project"); +}); + +test("without a CrawlProof token it says how to get one", async () => { + setPluginSecrets("crawlproof", {}); + await expect(connectTracking("moshcode.sh", { fetcher: crawlproof(true).fetcher })).rejects.toThrow("myna crawlproof login"); +}); diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 974468b..a739a53 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/myna-mcp", - "version": "0.31.0", + "version": "0.32.0", "description": "MCP server for myna. Lets an agent post, schedule and read across every connected social account.", "license": "MIT", "type": "module", diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index 8192322..de7cd05 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -47,6 +47,7 @@ import { DEFAULT_SOCIAL_TYPE, type ListingInput, readNewsletters, + connectTracking, requireNewsletter, createNewsletter, editNewsletter, @@ -467,6 +468,19 @@ export const TOOLS = [ additionalProperties: false, }, }, + { + name: "myna_newsletter_track_connect", + description: + "Turn on CrawlProof open/click tracking for newsletters from a CrawlProof project (e.g. moshcode.sh), using the " + + "CrawlProof token this machine already has (myna crawlproof login). Switches the project's email tracking on if " + + "it is off and keeps the tracking id and secret. Every later issue's links, open pixel and unsubscribe link go through it.", + inputSchema: { + type: "object", + properties: { site: { type: "string", description: "The CrawlProof project: hostname or project id." } }, + required: ["site"], + additionalProperties: false, + }, + }, { name: "myna_newsletter_subscribers", description: "Everyone on a list and whether they can be mailed (unsubscribed ones are shown, never mailed).", @@ -901,6 +915,9 @@ export async function callTool(name: string, args_: Record = {} return text(result ?? `No subscriber ${args.who}.`); } + case "myna_newsletter_track_connect": + return text(await connectTracking(args.site)); + case "myna_newsletter_subscribers": return text(subscribers(args.list).map(({ contact, active }) => ({ id: contact.id, email: contact.email, name: contact.name, addedAt: contact.addedAt, active, optedOut: Boolean(contact.optedOut) }))); diff --git a/packages/plugin-dashboard/package.json b/packages/plugin-dashboard/package.json index 96a93cb..b1ba44d 100644 --- a/packages/plugin-dashboard/package.json +++ b/packages/plugin-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/myna-plugin-dashboard", - "version": "0.31.0", + "version": "0.32.0", "description": "myna plugin: a local dashboard for what went out, what is queued, and which network is gated.", "license": "MIT", "type": "module",