Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/anonymous-usage-telemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@opencoredev/email-sdk": minor
---

Add anonymous usage telemetry to the SDK and CLI via PostHog. The client now reports `client created` (configured adapter names), `email sent` (adapter, success/failure, error code, duration, recipient count), and the CLI reports `cli command run` (command, adapter, success). No email content, addresses, headers, or credentials are ever collected, and custom adapter names are masked as `custom`. A one-time notice with opt-out instructions is printed on first use. Opt out with `EMAIL_SDK_TELEMETRY=0`, `DO_NOT_TRACK=1`, or `createEmailClient({ telemetry: false })`; telemetry is disabled automatically when `NODE_ENV=test`.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,26 @@ npx skills add opencoredev/email-sdk --skill email-sdk

The skill is stored in `skills/email-sdk/SKILL.md`. It tells agents to refresh the current README, Fumadocs pages, package exports, and TypeScript declarations before implementing, so the guidance stays useful as the SDK evolves without needing every new adapter or option copied into the skill.

## Telemetry

Email SDK collects anonymous usage analytics so we can see which adapters and CLI commands get used and how often sends succeed. The first run prints a notice with opt-out instructions.

What is collected: built-in adapter names (custom adapters are reported as `custom`), CLI command names, success/failure and error codes, send duration, total recipient counts (`to` + `cc` + `bcc`), whether a message includes attachments (a boolean only, never the files themselves), SDK version, OS, and Node.js version — tied to a random anonymous ID stored in `~/.config/email-sdk/telemetry.json`. What is never collected: email content, subjects, addresses, headers, attachments, API keys, or any other message data.

Opt out at any time with an environment variable:

```bash
export EMAIL_SDK_TELEMETRY=0 # or DO_NOT_TRACK=1
```

or per client in code:

```ts
const client = createEmailClient({ adapters: [resend({ apiKey })], telemetry: false });
```

Telemetry is also disabled automatically when `NODE_ENV=test`.

## Development

```bash
Expand Down
2 changes: 1 addition & 1 deletion bun.lock

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

20 changes: 20 additions & 0 deletions packages/email-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,26 @@ npx email-sdk send --dry-run --adapter resend --from hello@example.com --to user

The CLI can read provider credentials from environment variables or matching credential flags. Run `bunx --bun --package @opencoredev/email-sdk email-sdk adapters` for a one-off adapter list, or `npx email-sdk adapters` after installing the scoped package in a project. `--dry-run` validates the message and selected adapter field support without sending email.

## Telemetry

Email SDK collects anonymous usage analytics so we can see which adapters and CLI commands get used and how often sends succeed. The first run prints a notice with opt-out instructions.

What is collected: built-in adapter names (custom adapters are reported as `custom`), CLI command names, success/failure and error codes, send duration, total recipient counts (`to` + `cc` + `bcc`), whether a message includes attachments (a boolean only, never the files themselves), SDK version, OS, and Node.js version — tied to a random anonymous ID stored in `~/.config/email-sdk/telemetry.json`. What is never collected: email content, subjects, addresses, headers, attachments, API keys, or any other message data.

Opt out at any time with an environment variable:

```bash
export EMAIL_SDK_TELEMETRY=0 # or DO_NOT_TRACK=1
```

or per client in code:

```ts
const client = createEmailClient({ adapters: [resend({ apiKey })], telemetry: false });
```

Telemetry is also disabled automatically when `NODE_ENV=test`.

## Provider Reality

Email providers differ in domain verification, sandbox modes, rate limits, region settings, API scopes, and field support. Email SDK tests the normalized payloads and fail-fast validation locally, but the final live send still depends on provider account configuration.
Expand Down
74 changes: 65 additions & 9 deletions packages/email-sdk/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
assertMessage,
assertSupportedMessageFields,
} from "./utils.js";
import { getTelemetry, normalizeAdapterName } from "./telemetry.js";
import { assertUnosendMessage, unosend } from "./unosend.js";
import { zeptomail } from "./zeptomail.js";

Expand Down Expand Up @@ -213,10 +214,7 @@ const envFlagNames: Record<string, string> = {
SMTP_HOST: "host",
};

async function main() {
const [command, ...args] = process.argv.slice(2);
const flags = parseFlags(args);

async function main(command: string | undefined, flags: CliFlags) {
if (!command || command === "help" || command === "--help" || command === "-h") {
printHelp();
return;
Expand Down Expand Up @@ -669,16 +667,74 @@ SMTP options:
`);
}

class CliFailure extends Error {}

function fail(message: string): never {
console.error(message);
process.exit(1);
throw new CliFailure(message);
}

function normalizeCliCommand(command: string | undefined) {
if (!command || command === "help" || command === "--help" || command === "-h") {
return "help";
}

if (command === "version" || command === "--version" || command === "-v") {
return "version";
}

if (command === "adapters" || command === "providers") {
return "adapters";
}

if (command === "doctor" || command === "send") {
return command;
}

return "unknown";
}

async function captureCliRun(input: {
command: string | undefined;
flags: CliFlags;
success: boolean;
startedAt: number;
error?: unknown;
}) {
const adapter = selectedAdapter(input.flags);
const telemetry = getTelemetry();

await telemetry.capture("cli command run", {
command: normalizeCliCommand(input.command),
adapter: adapter ? normalizeAdapterName(adapter) : undefined,
dry_run: truthyFlag(input.flags, "dry-run"),
success: input.success,
duration_ms: Date.now() - input.startedAt,
error_code:
input.error instanceof EmailSdkError
? input.error.code
: input.success
? undefined
: "cli_error",
});

// Settle the fire-and-forget captures from core.ts before process.exit(1)
// can tear down the event loop and silently drop them.
await telemetry.flush();
}

const startedAt = Date.now();
const [cliCommand, ...cliArgs] = process.argv.slice(2);
const cliFlags = parseFlags(cliArgs);

try {
await main();
await main(cliCommand, cliFlags);
await captureCliRun({ command: cliCommand, flags: cliFlags, success: true, startedAt });
} catch (error) {
if (error instanceof EmailSdkError) {
fail(error.message);
await captureCliRun({ command: cliCommand, flags: cliFlags, success: false, startedAt, error });

if (error instanceof CliFailure || error instanceof EmailSdkError) {
console.error(error.message);
process.exit(1);
}

throw error;
Expand Down
66 changes: 53 additions & 13 deletions packages/email-sdk/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import type {
SendBatchResult,
SendOptions,
} from "./types.js";
import { assertMessage, toProviderError } from "./utils.js";
import { getTelemetry, normalizeAdapterName } from "./telemetry.js";
import { arrayify, assertMessage, toProviderError } from "./utils.js";

const defaultDelay = (attempt: number) => Math.min(100 * 2 ** (attempt - 1), 2_000);

Expand Down Expand Up @@ -83,6 +84,15 @@ export function createEmailClient<
throw new EmailProviderNotFoundError(defaultProvider);
}

const telemetry = options.telemetry === false ? undefined : getTelemetry();

void telemetry?.capture("client created", {
adapters: [...adapters.keys()].map(normalizeAdapterName),
adapter_count: adapters.size,
plugin_count: options.plugins?.length ?? 0,
default_adapter: normalizeAdapterName(defaultProvider),
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.

const hooks = [...pluginHooks, ...(options.hooks ? [options.hooks] : [])];
const client: EmailClient = {
adapters,
Expand All @@ -102,18 +112,48 @@ export function createEmailClient<
return client.adapter<TProvider>(name);
},
async send(message, sendOptions) {
return sendWithAdapters({
adapters,
message,
options: {
hookList: hooks,
middleware,
retry: options.retry,
defaultProvider,
fallback: options.fallback,
},
sendOptions,
});
const startedAt = Date.now();
const messageFacts = {
recipients:
arrayify(message.to).length + arrayify(message.cc).length + arrayify(message.bcc).length,
has_attachments: (message.attachments?.length ?? 0) > 0,
Comment on lines +116 to +119

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 has_attachments collected but not disclosed in README

has_attachments is sent with every email sent event but is not mentioned anywhere in either README under "What is collected". Both READMEs list "recipient counts, SDK version, OS, and Node.js version" but omit this field entirely. Users who read the documentation to understand what is tracked will not see this field. Given this is opt-in-by-default telemetry, every collected property should be explicitly listed. Score: 4/5

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex

};

try {
const response = await sendWithAdapters({
adapters,
message,
options: {
hookList: hooks,
middleware,
retry: options.retry,
defaultProvider,
fallback: options.fallback,
},
sendOptions,
});

void telemetry?.capture("email sent", {
...messageFacts,
adapter: normalizeAdapterName(response.provider),
success: true,
duration_ms: Date.now() - startedAt,
});

return response;
} catch (error) {
void telemetry?.capture("email sent", {
...messageFacts,
adapter: normalizeAdapterName(
sendOptions?.adapter ?? sendOptions?.provider ?? defaultProvider,
),
success: false,
duration_ms: Date.now() - startedAt,
error_code: error instanceof EmailSdkError ? error.code : "unknown",
});

throw error;
}
},
async sendBatch(messages, sendOptions) {
const results: SendBatchResult[] = [];
Expand Down
Loading