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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <trackingId> --secret <hex> # 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 <trackingId> --secret <hex> # 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
Expand Down Expand Up @@ -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`,
Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/myna-api",
"version": "0.31.0",
"version": "0.32.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 9 additions & 1 deletion apps/cli/src/cli/newsletter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
*/
import { readFileSync } from "node:fs";
import {
connectTracking,
TRACKING_ID,
TRACKING_SECRETS,
createNewsletter,
Expand Down Expand Up @@ -119,6 +120,13 @@ async function runTrack(rest: string[], flags: Flags): Promise<number> {
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 <site> (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);
Expand All @@ -144,7 +152,7 @@ async function runTrack(rest: string[], flags: Flags): Promise<number> {
}
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 {
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ address on every one, paced by outreach.maxEmailsPerDay):
newsletter unsubscribe <email|token> [--list L] Without --list: opted out for good
newsletter subscribers --list <L> | import <file.csv|.json> --list <L>
newsletter sync | sync-optouts Pull unsubscribes from myna cloud and crawlproof
newsletter track connect <site> Tracking from your CrawlProof project, on and saved
in one step (needs myna crawlproof login)
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
Expand Down
17 changes: 16 additions & 1 deletion apps/cli/src/tui/newsletter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@
* /newsletter schedule <id> <when> the daemon sends it
* /newsletter stats <id> per variant, from crawlproof
* /newsletter subscribe <list> <email...> | unsubscribe <email> | import <list> <file>
* /newsletter track <site> CrawlProof tracking, from your project
* /newsletter rm <id>
*/
import type { Container, Theme } from "@profullstack/hqtui";
import {
connectTracking,
createNewsletter,
editNewsletter,
fetchNewsletterStats,
Expand Down Expand Up @@ -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 <site> (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 = {
Expand All @@ -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.`);
}
}

Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/renderer/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@
<button id="btn-nl-sub" class="ghost">Subscribe</button>
<button id="btn-nl-import" class="ghost">Import CSV/JSON</button>
</div>
<div class="writer">
<input id="nl-track-site" placeholder="CrawlProof project, e.g. moshcode.sh">
<button id="btn-nl-track" class="ghost">Connect tracking</button>
</div>
<div class="writer">
<input id="nl-unsub" placeholder="Email to unsubscribe">
<button id="btn-nl-unsub" class="ghost">Unsubscribe</button>
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ handle("newsletter:stats", async (id) => {
if (!tracking) throw new Error("Tracking is off. Run: myna newsletter track set <id>");
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 })),
);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
10 changes: 5 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
48 changes: 46 additions & 2 deletions packages/core/src/core/newsletter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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/<site>?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<TrackingConnection> {
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<Record<string, unknown>> => {
const json = (await response.json().catch(() => ({}))) as Record<string, unknown>;
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;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,8 @@ export {
type SyncResult,
// crawlproof tracking, A/B variants, stats
newsletterTracking,
connectTracking,
type TrackingConnection,
TRACKING_SECRETS,
TRACKING_ID,
trackingBase,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
67 changes: 67 additions & 0 deletions packages/core/test/newsletter-connect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* `myna newsletter track connect <site>`: 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<string, string>).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");
});
2 changes: 1 addition & 1 deletion packages/mcp/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading