diff --git a/AGENTS.md b/AGENTS.md index 58cc041..3bf35fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ repo, which reformats roughly 170 files that have drifted from the formatter. CI does not enforce formatting or linting, so a repo-wide reformat only adds noise to your diff. -When adding or changing a provider adapter, verify it against a live account. These scripts read provider credentials from the repo-root `.env.local` (or your shell): +When adding or changing a provider adapter, verify it against a live account. These scripts read provider credentials from the repo-root `.env.local`, then `.env`, then your shell environment: ```bash bun run live:sequenzy diff --git a/apps/fumadocs/content/docs/components/convex-email.mdx b/apps/fumadocs/content/docs/components/convex-email.mdx index 368b4a8..92628ea 100644 --- a/apps/fumadocs/content/docs/components/convex-email.mdx +++ b/apps/fumadocs/content/docs/components/convex-email.mdx @@ -186,7 +186,7 @@ export const emailStatus = query({ }); ``` -`status` returns the stored email: `status`, `message`, `attemptedAdapters`, `attemptCount`/`maxAttempts`, `providerMessageId`, `lastError`, `deliveryStatus`, and timestamps. `listEvents` returns the append-only history. +`status` returns the stored email: `status`, `message`, `attemptedAdapters`, `attemptCount`/`maxAttempts`, `providerMessageId`, `lastError`, `deliveryStatus`, and timestamps. `listEvents` returns the append-only history. Both are typed: `status` resolves to `ConvexEmailDoc | null` and `listEvents` to `ConvexEmailEventDoc[]`, and the package exports both types (plus `ConvexEmailDeliveryStatus`) for annotating your own query results. | Status | Meaning | | --- | --- | diff --git a/apps/fumadocs/content/docs/getting-started/install.mdx b/apps/fumadocs/content/docs/getting-started/install.mdx index 639847f..7a5e6af 100644 --- a/apps/fumadocs/content/docs/getting-started/install.mdx +++ b/apps/fumadocs/content/docs/getting-started/install.mdx @@ -22,7 +22,7 @@ import { resend } from "@opencoredev/email-sdk/resend"; ## Run the CLI - + Once `@opencoredev/email-sdk` is in your project, the `email-sdk` binary is on your path: @@ -44,6 +44,25 @@ import { resend } from "@opencoredev/email-sdk/resend"; is unrelated. npm users should install first. + + To put `email-sdk` on your path system-wide, without adding it to any project, install the + formula that ships in the repository: + + ```bash + brew install --formula https://raw.githubusercontent.com/opencoredev/email-sdk/main/Formula/email-sdk.rb + email-sdk adapters + ``` + + The formula installs the published npm tarball, uses [Bun](https://bun.sh) as its runtime, + and links the CLI as `email-sdk`. Each release opens an automated pull request that bumps + the formula's version and checksum, so the copy on `main` tracks the latest published + package. + + Homebrew does not track formulas installed from a URL, so `brew upgrade` won't pick up new + versions; re-run the install command to update. A global install covers `adapters`, `doctor`, + and CLI sends. To `import` the SDK in application code, install the npm package as above. + + ## Verify diff --git a/apps/fumadocs/content/docs/meta.json b/apps/fumadocs/content/docs/meta.json index 89c7e64..49aef37 100644 --- a/apps/fumadocs/content/docs/meta.json +++ b/apps/fumadocs/content/docs/meta.json @@ -4,6 +4,7 @@ "index", "getting-started", "authentication", + "telemetry", "concepts", "adapters", "plugins", diff --git a/apps/fumadocs/content/docs/reference/cli.mdx b/apps/fumadocs/content/docs/reference/cli.mdx index ded5467..3c19660 100644 --- a/apps/fumadocs/content/docs/reference/cli.mdx +++ b/apps/fumadocs/content/docs/reference/cli.mdx @@ -22,7 +22,7 @@ Or install the package and use the local binary: npx email-sdk adapters ``` -Node 20+ or Bun 1.1+. The examples below use `npx email-sdk`; substitute the `bunx` form for no-install runs. +Node 20+ or Bun 1.1+. The examples below use `npx email-sdk`; substitute the `bunx` form for no-install runs. For a global `email-sdk` binary outside any project, there is also a [Homebrew formula](/docs/getting-started/install#run-the-cli). ## Commands diff --git a/apps/fumadocs/content/docs/reference/client.mdx b/apps/fumadocs/content/docs/reference/client.mdx index 0774c9d..f7c0814 100644 --- a/apps/fumadocs/content/docs/reference/client.mdx +++ b/apps/fumadocs/content/docs/reference/client.mdx @@ -52,10 +52,15 @@ const email = createEmailClient({ type: "EmailPlugin[]", default: "[]", }, + telemetry: { + description: "Set to false to disable anonymous usage analytics for this client.", + type: "boolean", + default: "true", + }, }} /> -`retry` options are documented in [fallbacks and retries](/docs/concepts/fallbacks-and-retries#retries), `hooks` in [hooks](/docs/concepts/hooks), and `plugins` in the [plugin API](/docs/plugins/api). `providers` and `defaultProvider` are accepted [aliases](#aliases). +`retry` options are documented in [fallbacks and retries](/docs/concepts/fallbacks-and-retries#retries), `hooks` in [hooks](/docs/concepts/hooks), and `plugins` in the [plugin API](/docs/plugins/api). What `telemetry` controls, plus the environment opt-outs, is covered in [telemetry](/docs/telemetry). `providers` and `defaultProvider` are accepted [aliases](#aliases). Construction fails fast with an `EmailValidationError` for a duplicate adapter routing name (`Duplicate email adapter "x".`) or plugin id (`Duplicate email plugin "x".`), and for an empty client (`createEmailClient requires a default adapter.`). A `defaultAdapter` that is not registered throws `EmailProviderNotFoundError`. diff --git a/apps/fumadocs/content/docs/telemetry.mdx b/apps/fumadocs/content/docs/telemetry.mdx new file mode 100644 index 0000000..b660bac --- /dev/null +++ b/apps/fumadocs/content/docs/telemetry.mdx @@ -0,0 +1,87 @@ +--- +title: Telemetry +description: What anonymous usage analytics Email SDK collects, how error reports are redacted, where the anonymous ID lives, and every way to opt out. +icon: Activity +--- + +Email SDK collects anonymous usage analytics so maintainers know which adapters and CLI commands people actually use and how often sends succeed. It is opt-out, tied to a random ID rather than any identity, and it never reads your email. The first run prints a one-time notice to stderr with opt-out instructions. + + + Email content, subjects, addresses, headers, attachments, and API keys are never + collected. Only the metadata described on this page leaves your machine. + + +## What is collected + +Every event carries the SDK version (`sdk_version`), the Node.js version (`node_version`), the OS and CPU family (`platform`, `arch`), whether the run is in CI (`ci`, `ci_vendor`, see [CI detection](#ci-detection)), and a `source` of `sdk` or `cli`. Adapter names are normalized against the built-in list; a custom adapter reports as `custom`, so telemetry never carries a user-defined string. + +| Event | When | Event-specific properties | +| --- | --- | --- | +| `client created` | Once per `createEmailClient` call | `adapters` (normalized list), `adapter_count`, `plugin_count`, `default_adapter` | +| `email sent` | Once per `send()` call | `recipients` (to + cc + bcc count), `has_attachments`, `used_recipient_variables`, `used_send_at`, `adapter`, `delivery_path`, `success`, `duration_ms`, `error_code` on failure | +| `email batch sent` | Once per `sendBatch()` call | `message_count`, `succeeded`, `failed`, `recipients`, `adapter` (or `mixed`), `success`, `duration_ms`, first `error_code` | +| `cli command run` | Once per CLI invocation | `command` (normalized), `adapter`, `dry_run`, `success`, `duration_ms`, `error_code` | + +`delivery_path` records how a send with [recipient variables](/docs/reference/message#recipient-variables) was routed: `single` for a plain message, `bulk_native` when the adapter batched every recipient in one provider call, `bulk_expanded` when the client expanded it to one internal send per recipient. Expanded per-recipient sends are never counted individually; one `email sent` event fires per `send()` call. + +Recipient counts are integers and `has_attachments` is a boolean. No message data of any kind is transmitted. + +## Error reports + +When a send or CLI run fails unexpectedly, the SDK reports a redacted error to PostHog error tracking so maintainers can find real defects. Expected caller mistakes are excluded: `EmailValidationError` (bad message input) and `EmailProviderNotFoundError` (unregistered adapter name) are never reported. + +A report carries the error name, the Email SDK [error code](/docs/reference/errors), and a stack trace, hardened before upload: + +- Stack frame paths are reduced to `node_modules/...`-relative paths or bare filenames, so reports never contain usernames, home directories, or your project layout. Node built-in frames (`node:internal/...`) are kept as-is. +- Error messages are redacted: email addresses, URLs of any scheme (including connection strings with embedded credentials), quoted text, `key=value` secrets such as `password=` or `api_key=`, long tokens, and home-directory paths are all stripped, and messages are truncated to 300 characters. The SDK's own error classes and JavaScript built-in error names pass through verbatim so issue grouping stays stable; any other error name is redacted too. +- Reports are deduplicated per process, once per error object and once per error class, and capped at 5 per process. Cause chains are followed up to 3 levels, stack traces up to 20 frames. + + + Redaction is defense in depth, not a reason to relax. If you handle regulated + data, opt out. + + +## Storage and transport + +A random UUID is generated on first run and stored at `~/.config/email-sdk/telemetry.json` (honoring `XDG_CONFIG_HOME`). It is not linked to an account or machine identifier. On a read-only filesystem, such as some serverless runtimes, the ID cannot persist, so each cold start gets a fresh per-process identity and prints the notice again. + +Events go to PostHog's US cloud (`us.i.posthog.com`) using a public write-only project key that can ingest events but never read them, with person profiles disabled. Captures are fire-and-forget with a 3-second timeout; they can never throw into your send path or slow it down. The CLI flushes in-flight events before it exits. + +## CI detection + +`ci_vendor` comes from environment variables, so CI runs can be counted separately from production traffic: + +| Vendor | Detected via | +| --- | --- | +| `github_actions` | `GITHUB_ACTIONS` | +| `gitlab` | `GITLAB_CI` | +| `circleci` | `CIRCLECI` | +| `jenkins` | `JENKINS_URL` | +| `travis` | `TRAVIS` | +| `buildkite` | `BUILDKITE` | +| `generic` | `CI=true` or `CI=1` | + +`VERCEL=1` is deliberately not treated as CI: Vercel sets it in production serverless runtimes too, which would mislabel live sends. Vercel builds still set `CI=1` and count as `generic`. + +## Opting out + +Any one of these disables telemetry completely. When disabled, no ID is created, no notice is printed, and no network call is made. + +Set an environment variable, which covers both the library and the CLI: + +```bash +export EMAIL_SDK_TELEMETRY=0 # also accepts: false, off +# or the cross-tool standard: +export DO_NOT_TRACK=1 # also accepts: true +``` + +Or disable it per client in code: + +```ts +const email = createEmailClient({ + adapters: [resend({ apiKey: process.env.RESEND_API_KEY! })], + telemetry: false, +}); +``` + +Telemetry is also disabled automatically when `NODE_ENV=test`, so test suites never emit events or touch the network. diff --git a/packages/convex-email/README.md b/packages/convex-email/README.md index e92918f..5de871b 100644 --- a/packages/convex-email/README.md +++ b/packages/convex-email/README.md @@ -103,7 +103,7 @@ export const sendWelcomeEmail = mutation({ }); ``` -`email.send()` returns the Convex document id for the queued email. Query `email.status(ctx, { emailId })` and `email.listEvents(ctx, { emailId })` from app functions when you need delivery state, attempted adapters, provider message ids, or errors. Once webhooks are wired up, the stored email also carries a `deliveryStatus` (`delivered`, `bounced`, or `complained`) and a `deliveredAt` timestamp. +`email.send()` returns the Convex document id for the queued email. Query `email.status(ctx, { emailId })` and `email.listEvents(ctx, { emailId })` from app functions when you need delivery state, attempted adapters, provider message ids, or errors. Once webhooks are wired up, the stored email also carries a `deliveryStatus` (`delivered`, `bounced`, or `complained`) and a `deliveredAt` timestamp. The returns are typed: `status` resolves to `ConvexEmailDoc | null` and `listEvents` to `ConvexEmailEventDoc[]`, both exported from the package root along with `ConvexEmailDeliveryStatus`. ## Provider Coverage