mailchannels-sdk 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/mailchannels-js/SKILL.md +91 -0
- package/.agents/skills/mailchannels-js/resources/attachments.md +113 -0
- package/.agents/skills/mailchannels-js/resources/clients-and-transport.md +98 -0
- package/.agents/skills/mailchannels-js/resources/custom-headers.md +64 -0
- package/.agents/skills/mailchannels-js/resources/custom-tracking-domains.md +83 -0
- package/.agents/skills/mailchannels-js/resources/dkim.md +148 -0
- package/.agents/skills/mailchannels-js/resources/domain-checks.md +100 -0
- package/.agents/skills/mailchannels-js/resources/error-handling.md +143 -0
- package/.agents/skills/mailchannels-js/resources/metrics-and-usage.md +114 -0
- package/.agents/skills/mailchannels-js/resources/overview.md +91 -0
- package/.agents/skills/mailchannels-js/resources/sending.md +221 -0
- package/.agents/skills/mailchannels-js/resources/sub-accounts.md +129 -0
- package/.agents/skills/mailchannels-js/resources/suppressions.md +91 -0
- package/.agents/skills/mailchannels-js/resources/templates.md +97 -0
- package/.agents/skills/mailchannels-js/resources/testing.md +95 -0
- package/.agents/skills/mailchannels-js/resources/unsubscribe.md +73 -0
- package/.agents/skills/mailchannels-js/resources/webhooks.md +174 -0
- package/README.md +70 -4
- package/dist/_chunks/simulator.mjs +111 -23
- package/dist/mailchannels.d.mts +853 -561
- package/dist/mailchannels.mjs +861 -457
- package/package.json +10 -9
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mailchannels-js
|
|
3
|
+
description: JavaScript/TypeScript SDK for the MailChannels Email API (npm `mailchannels-sdk`; `import { MailChannels } from 'mailchannels-sdk'`). Use when sending or queueing email, working with attachments, mustache templates, unsubscribe, custom headers, DKIM keys, sub-accounts, domain checks, metrics, usage, suppressions, or webhooks (incl. signature verification); also configuring clients, testing with the built-in simulator, and error handling. Skip when (a) developing the SDK itself — defer to the repo's root AGENTS.md; (b) the user is in a non-JavaScript/TypeScript language — MailChannels publishes separate SDKs for other ecosystems and the JS types/patterns here don't apply.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# MailChannels JavaScript SDK
|
|
7
|
+
|
|
8
|
+
This skill helps you write **JavaScript/TypeScript** code that uses the `mailchannels-sdk`
|
|
9
|
+
package (npm: `mailchannels-sdk`, importable as `import { MailChannels } from 'mailchannels-sdk'`).
|
|
10
|
+
It is specific to the official JavaScript SDK — MailChannels publishes separate SDKs for
|
|
11
|
+
other languages and their APIs differ.
|
|
12
|
+
|
|
13
|
+
> **Scope.** This skill is for *consuming* the JS SDK from application code. Working **on**
|
|
14
|
+
> the SDK (adding features, fixing bugs, releasing the package) is a different job.
|
|
15
|
+
> If the user is working in a Python, Go, Ruby, PHP, Rust, or shell context, this skill does not apply.
|
|
16
|
+
|
|
17
|
+
## How To Use This Skill
|
|
18
|
+
|
|
19
|
+
The body of each topic lives in [`resources/`](resources/). Read this file for context and
|
|
20
|
+
the decision tree, then load only the resource files that match the task. Don't preload
|
|
21
|
+
everything.
|
|
22
|
+
|
|
23
|
+
### Decision Tree
|
|
24
|
+
|
|
25
|
+
Start at the top; descend until you hit a leaf, then read the linked resource. If multiple
|
|
26
|
+
branches apply (e.g. "send + attachments + templates"), read each leaf.
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
You're about to write JS/TS SDK code. What does it need to do?
|
|
30
|
+
|
|
31
|
+
├── Get oriented — what is the SDK, what are the entry points?
|
|
32
|
+
│ → resources/overview.md
|
|
33
|
+
│
|
|
34
|
+
├── Set up the client (API keys, base URL, options, lifecycle)
|
|
35
|
+
│ → resources/clients-and-transport.md
|
|
36
|
+
│
|
|
37
|
+
├── Send email
|
|
38
|
+
│ → resources/sending.md
|
|
39
|
+
│ ├── …with file / bytes / URL / inline-image attachments?
|
|
40
|
+
│ │ → resources/attachments.md
|
|
41
|
+
│ ├── …with a mustache template + per-recipient data?
|
|
42
|
+
│ │ → resources/templates.md
|
|
43
|
+
│ ├── …with an unsubscribe link or List-Unsubscribe header?
|
|
44
|
+
│ │ → resources/unsubscribe.md
|
|
45
|
+
│ └── …with custom X-… headers?
|
|
46
|
+
│ → resources/custom-headers.md
|
|
47
|
+
│
|
|
48
|
+
├── Manage hosted DKIM keys (create / list / rotate / revoke)
|
|
49
|
+
│ → resources/dkim.md
|
|
50
|
+
│
|
|
51
|
+
├── Validate a sender domain's auth posture (DKIM/SPF/Lockdown/DNS)
|
|
52
|
+
│ → resources/domain-checks.md
|
|
53
|
+
│
|
|
54
|
+
├── Multi-tenant work — sub-accounts, their credentials, limits, usage
|
|
55
|
+
│ → resources/sub-accounts.md
|
|
56
|
+
│
|
|
57
|
+
├── Query analytics (volume, engagement, performance, senders) or
|
|
58
|
+
│ current-period usage
|
|
59
|
+
│ → resources/metrics-and-usage.md
|
|
60
|
+
│
|
|
61
|
+
├── Manage the suppression list (list, create, delete)
|
|
62
|
+
│ → resources/suppressions.md
|
|
63
|
+
│
|
|
64
|
+
├── Receive or manage delivery-event webhooks (enroll, validate,
|
|
65
|
+
│ inspect batches, verify signatures)
|
|
66
|
+
│ → resources/webhooks.md
|
|
67
|
+
│
|
|
68
|
+
├── Catch / log / retry SDK or API errors
|
|
69
|
+
│ → resources/error-handling.md
|
|
70
|
+
│
|
|
71
|
+
└── Write tests without hitting the real API
|
|
72
|
+
→ resources/testing.md
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Style Conventions In The Resources
|
|
76
|
+
|
|
77
|
+
- The example code in the resources are largely compatible with both JavaScript and TypeScript.
|
|
78
|
+
Double check before copying that you don't need to strip type annotations or alter imports.
|
|
79
|
+
- The JS SDK uses a **result-based** error style: every method returns `{ data, error }`
|
|
80
|
+
(`DataResponse<T>`) or `{ success, error }` (`SuccessResponse`). Check `error` before
|
|
81
|
+
using `data`. The SDK never throws for API errors — only for missing API key at
|
|
82
|
+
construction time.
|
|
83
|
+
- All methods are `async` and return Promises. There are no sync variants.
|
|
84
|
+
- All recipient and sender addresses use the IANA-reserved `example.com` / `example.net`
|
|
85
|
+
domains. Replace before running.
|
|
86
|
+
|
|
87
|
+
## Beyond This Skill
|
|
88
|
+
|
|
89
|
+
When a resource leaves something ambiguous, the SDK source itself is the final authority.
|
|
90
|
+
The published `mailchannels-sdk` package on npm also ships with its own README and TypeScript
|
|
91
|
+
type definitions — hover-docs in your editor are authoritative for parameter shapes.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Attachments
|
|
2
|
+
|
|
3
|
+
MailChannels expects every attachment to be Base64-encoded with a `filename` and ideally a
|
|
4
|
+
MIME `type`. Use the `Attachment` static helpers — they encode for you and infer the MIME
|
|
5
|
+
type from the filename.
|
|
6
|
+
|
|
7
|
+
### From In-Memory Bytes
|
|
8
|
+
|
|
9
|
+
`fromBytes` is synchronous and accepts any `ArrayBuffer` or `Uint8Array`:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { MailChannels, Attachment } from 'mailchannels-sdk'
|
|
13
|
+
|
|
14
|
+
const mc = new MailChannels('YOUR-API-KEY')
|
|
15
|
+
|
|
16
|
+
const report = Attachment.fromBytes(
|
|
17
|
+
new Uint8Array([...csvBytes]),
|
|
18
|
+
{
|
|
19
|
+
filename: 'report.csv',
|
|
20
|
+
type: 'text/csv' // optional; inferred from filename when omitted
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
// Also accepts ArrayBuffer:
|
|
25
|
+
const pdf = Attachment.fromBytes(arrayBuffer, { filename: 'doc.pdf' })
|
|
26
|
+
|
|
27
|
+
const { data, error } = await mc.emails.queue({
|
|
28
|
+
from: 'billing@example.com',
|
|
29
|
+
to: 'recipient@example.net',
|
|
30
|
+
subject: 'Your invoice',
|
|
31
|
+
text: 'See attached.',
|
|
32
|
+
attachments: [report, pdf]
|
|
33
|
+
})
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### From A Blob
|
|
37
|
+
|
|
38
|
+
`fromBlob` is async and accepts any Web API `Blob`. The Blob's own `type` property is used
|
|
39
|
+
as the MIME type unless you override it in options:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
const blob = new Blob(['Hello world'], { type: 'text/plain' })
|
|
43
|
+
const attachment = await Attachment.fromBlob(blob, { filename: 'hello.txt' })
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Inline Images (CID References)
|
|
47
|
+
|
|
48
|
+
Pass `disposition: 'inline'` and a `contentId` to embed an image inside the HTML body:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
const logo = Attachment.fromBytes(bytes, {
|
|
52
|
+
filename: 'logo.png',
|
|
53
|
+
disposition: 'inline',
|
|
54
|
+
contentId: 'company-logo'
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const { data, error } = await mc.emails.queue({
|
|
58
|
+
from: 'sender@example.com',
|
|
59
|
+
to: 'recipient@example.net',
|
|
60
|
+
subject: 'Inline image',
|
|
61
|
+
html: "<img src='cid:company-logo' alt='Company logo'>",
|
|
62
|
+
attachments: [logo]
|
|
63
|
+
})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Attachment Options
|
|
67
|
+
|
|
68
|
+
Both `fromBytes` and `fromBlob` accept an `AttachmentOptions` object:
|
|
69
|
+
|
|
70
|
+
| Field | Type | Notes |
|
|
71
|
+
| --- | --- | --- |
|
|
72
|
+
| `filename` | `string` | Required. MIME type is inferred from it when `type` is omitted. |
|
|
73
|
+
| `type` | `string` | MIME type. Inferred from `filename` if omitted. For `fromBlob`, defaults to the Blob's own `type`. |
|
|
74
|
+
| `contentId` | `string` | For `cid:` inline image references. |
|
|
75
|
+
| `disposition` | `'attachment' \| 'inline'` | Defaults to `'attachment'`. |
|
|
76
|
+
|
|
77
|
+
### Awaiting Attachments Lazily
|
|
78
|
+
|
|
79
|
+
The `attachments` field accepts `(EmailsSendAttachment | Promise<EmailsSendAttachment>)[]`,
|
|
80
|
+
so you can pass unresolved promises and the SDK will await them before sending:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
const { data, error } = await mc.emails.queue({
|
|
84
|
+
from: 'sender@example.com',
|
|
85
|
+
to: 'recipient@example.net',
|
|
86
|
+
subject: 'Reports',
|
|
87
|
+
text: 'Reports attached.',
|
|
88
|
+
attachments: [
|
|
89
|
+
Attachment.fromBlob(blob1, { filename: 'q1.pdf' }), // Promise<...>
|
|
90
|
+
Attachment.fromBlob(blob2, { filename: 'q2.pdf' }) // Promise<...>
|
|
91
|
+
]
|
|
92
|
+
})
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Limits
|
|
96
|
+
|
|
97
|
+
- Up to **1000 attachments** per send.
|
|
98
|
+
- Combined message + headers + attachments must stay under **30 MB**. Exceeding this
|
|
99
|
+
returns `payload_too_large_error` (HTTP 413) in the `error` field.
|
|
100
|
+
|
|
101
|
+
### Raw Attachment Shape
|
|
102
|
+
|
|
103
|
+
If you need to construct one by hand:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
const attachment: EmailsSendAttachment = {
|
|
107
|
+
content: Buffer.from(new Uint8Array(bytes)).toString("base64"),
|
|
108
|
+
filename: 'report.pdf',
|
|
109
|
+
type: 'application/pdf' // recommended but optional
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`content` and `filename` are required. `type` is recommended.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Clients and Transport
|
|
2
|
+
|
|
3
|
+
The JS SDK requires explicit client instantiation — there is no module-level singleton or
|
|
4
|
+
environment-variable auto-configuration. Create one `MailChannels` instance per credential
|
|
5
|
+
set and reuse it for the lifetime of the process.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { MailChannels } from 'mailchannels-sdk'
|
|
9
|
+
import process from 'node:process'
|
|
10
|
+
|
|
11
|
+
const mc = new MailChannels(process.env.MAILCHANNELS_API_KEY)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
A missing or empty API key throws synchronously at construction:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
new MailChannels('') // throws Error: "Missing MailChannels API key."
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Constructor Options
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// new MailChannels(apiKey: string, options?: MailChannelsClientOptions)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
| Option | Type | Default | Notes |
|
|
27
|
+
| --- | --- | --- | --- |
|
|
28
|
+
| `baseUrl` | `string` | `https://api.mailchannels.net` | Only override if MailChannels gives you a specific alternate endpoint (sandbox, regional URL). |
|
|
29
|
+
| `timeout` | `number \| false` | `120000` (ms) | Pass `false` to disable the timeout entirely. |
|
|
30
|
+
| `retry` | `boolean \| number \| RetryOptions` | `false` | `ofetch` retry options. |
|
|
31
|
+
| `signal` | `AbortSignal` | `undefined` | Propagated to every fetch call made by this instance. |
|
|
32
|
+
|
|
33
|
+
### Multi-Tenant: One Client Per Credential
|
|
34
|
+
|
|
35
|
+
Never share a parent key and a sub-account key on the same instance. Create a separate
|
|
36
|
+
`MailChannels` for each credential:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
const parent = new MailChannels(process.env.PARENT_API_KEY)
|
|
40
|
+
const tenantA = new MailChannels(process.env.TENANT_A_API_KEY)
|
|
41
|
+
const tenantB = new MailChannels(process.env.TENANT_B_API_KEY)
|
|
42
|
+
|
|
43
|
+
const { data: accounts, error: listErr } = await parent.subAccounts.list()
|
|
44
|
+
if (listErr) { /*...*/ }
|
|
45
|
+
|
|
46
|
+
const { data: queueA, error: queueErrA } = await tenantA.emails.queue({ ... })
|
|
47
|
+
if (queueErrA) { /*...*/ }
|
|
48
|
+
|
|
49
|
+
const { data: queueB, error: queueErrB } = await tenantB.emails.queue({ ... })
|
|
50
|
+
if (queueErrB) { /*...*/ }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Request Cancellation
|
|
54
|
+
|
|
55
|
+
Pass an `AbortSignal` to cancel in-flight requests:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const controller = new AbortController()
|
|
59
|
+
|
|
60
|
+
const mc = new MailChannels(apiKey, { signal: controller.signal })
|
|
61
|
+
|
|
62
|
+
// Later:
|
|
63
|
+
controller.abort()
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Headers Sent On Every Request
|
|
67
|
+
|
|
68
|
+
The SDK sets these on every request and they cannot be overridden via `MailChannelsClientOptions`:
|
|
69
|
+
|
|
70
|
+
- `X-API-Key: <your key>`
|
|
71
|
+
- `Accept: application/json`
|
|
72
|
+
- `Content-Type: application/json`
|
|
73
|
+
- `User-Agent: mailchannels-node/<version>`
|
|
74
|
+
|
|
75
|
+
### Extending The Client
|
|
76
|
+
|
|
77
|
+
The `MailChannels` class extends the public `MailChannelsClient` base. If you need to
|
|
78
|
+
intercept, proxy, or instrument requests you can subclass `MailChannelsClient` and override
|
|
79
|
+
`_fetch<T>()` (a `protected` method).
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { MailChannelsClient } from 'mailchannels-sdk'
|
|
83
|
+
|
|
84
|
+
class InstrumentedClient extends MailChannelsClient {
|
|
85
|
+
protected override async _fetch<T>(path: string, options: FetchOptions<'json'>) {
|
|
86
|
+
const start = performance.now()
|
|
87
|
+
try {
|
|
88
|
+
const result = await super._fetch<T>(path, options)
|
|
89
|
+
recordLatency(path, performance.now() - start)
|
|
90
|
+
return result
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
recordError(path)
|
|
94
|
+
throw err
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Custom Headers
|
|
2
|
+
|
|
3
|
+
Use `headers` for application-specific metadata, tracking IDs, campaign tags, or any
|
|
4
|
+
custom `X-…` header.
|
|
5
|
+
|
|
6
|
+
### Root vs Per-Personalization
|
|
7
|
+
|
|
8
|
+
- **Root `headers`**: applied to every personalization.
|
|
9
|
+
- **Per-personalization `headers`**: applied only to that personalization.
|
|
10
|
+
- **If the same header appears in both, the personalization value wins.**
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
const { data, error } = await mc.emails.queue({
|
|
14
|
+
from: 'sender@example.com',
|
|
15
|
+
subject: 'Hello',
|
|
16
|
+
text: 'Hello',
|
|
17
|
+
headers: {
|
|
18
|
+
'X-Campaign-ID': 'newsletter-2026-05',
|
|
19
|
+
'X-Project': 'growth'
|
|
20
|
+
},
|
|
21
|
+
personalizations: [
|
|
22
|
+
{
|
|
23
|
+
to: 'alice@example.net',
|
|
24
|
+
headers: { 'X-Tier': 'pro' }
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
to: 'bob@example.net',
|
|
28
|
+
headers: { 'X-Tier': 'free', 'X-Campaign-ID': 'growth-override' }
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Reserved Headers — Never Set These
|
|
35
|
+
|
|
36
|
+
MailChannels controls a fixed set of message headers. The SDK pre-validates and returns a
|
|
37
|
+
`validation_error` client-side before any request that tries to set one. Header names are
|
|
38
|
+
checked case-insensitively, so `From`, `from`, and `FROM` are all rejected.
|
|
39
|
+
|
|
40
|
+
| Reserved header | Set via payload field |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `From` | `from` |
|
|
43
|
+
| `To` | `personalizations[].to` |
|
|
44
|
+
| `CC` | `personalizations[].cc` or root `cc` |
|
|
45
|
+
| `BCC` | `personalizations[].bcc` or root `bcc` |
|
|
46
|
+
| `Subject` | `subject` (root) or `personalizations[].subject` |
|
|
47
|
+
| `Reply-To` | `replyTo` (root) or `personalizations[].replyTo` |
|
|
48
|
+
| `DKIM-Signature` | `dkim.domain` / `dkim.selector` / `dkim.privateKey` |
|
|
49
|
+
| `Content-Type`, `Content-Transfer-Encoding` | `content[].type` and encoding the API chooses |
|
|
50
|
+
| `Message-ID` | Returned in `results[].messageId` |
|
|
51
|
+
| `Authentication-Results`, `Received` | Generated by the API |
|
|
52
|
+
|
|
53
|
+
This list mirrors the SDK's `RESERVED_HEADER_NAMES` set — keep them in sync if MailChannels
|
|
54
|
+
changes the list upstream.
|
|
55
|
+
|
|
56
|
+
### Case Sensitivity
|
|
57
|
+
|
|
58
|
+
Header names are treated case-insensitively by MailChannels. If two keys differ only by
|
|
59
|
+
case, only one is used and the choice is unspecified. Pick one canonical casing per header.
|
|
60
|
+
|
|
61
|
+
### Values
|
|
62
|
+
|
|
63
|
+
Both keys and values must be strings. A non-string value returns a `validation_error`
|
|
64
|
+
before the request leaves the client.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Custom Tracking Domains
|
|
2
|
+
|
|
3
|
+
Register your own hostname for click tracking, open tracking, or unsubscribe
|
|
4
|
+
links instead of MailChannels' shared domains. Once a domain is `active`,
|
|
5
|
+
select it by `name` via `customDomainName` on the send payload — see
|
|
6
|
+
[sending.md](sending.md#custom-tracking-domains).
|
|
7
|
+
|
|
8
|
+
## Register A Domain
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { MailChannels } from 'mailchannels-sdk'
|
|
12
|
+
|
|
13
|
+
const mc = new MailChannels('YOUR-API-KEY')
|
|
14
|
+
|
|
15
|
+
const { data, error } = await mc.domains.customTracking.create(
|
|
16
|
+
'newsletter-clicks', // label used at send time, ^[a-z0-9-]+$, max 64
|
|
17
|
+
'click.example.com',
|
|
18
|
+
'click' // click | open | unsubscribe
|
|
19
|
+
)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Before this can complete, `hostname` needs a CNAME record pointing to
|
|
23
|
+
`links.mailchannels.net`.
|
|
24
|
+
|
|
25
|
+
## DNS Verification Is A Two-Step, Non-Error Flow
|
|
26
|
+
|
|
27
|
+
`create()` and `update()` (when re-activating a disabled domain) return one
|
|
28
|
+
of two shapes in `data` — both are 2xx responses, not errors:
|
|
29
|
+
|
|
30
|
+
- **Registered**: `dnsSetupRequired: false` with `name`, `hostname`, `scope`,
|
|
31
|
+
`status`, `createdAt` are all present..
|
|
32
|
+
- **Pending**: `dnsSetupRequired: true` with `token`, `txtRecordName`,
|
|
33
|
+
`txtRecordValue`, `instructions` is present (a CNAME-only failure can omit the TXT
|
|
34
|
+
fields; a fresh registration includes all of them).
|
|
35
|
+
|
|
36
|
+
Add the TXT record alongside the CNAME, wait for propagation, then call
|
|
37
|
+
`create()` again with the same `name`/`hostname`/`scope`.
|
|
38
|
+
|
|
39
|
+
If DNS still isn't in place on retry, MailChannels responds `422` instead of
|
|
40
|
+
`202` — that's a real error status, so the SDK returns an `error` object.
|
|
41
|
+
The same pending-verification body is on `error.response`:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
const { data, error } = await mc.domains.customTracking.create(
|
|
45
|
+
'newsletter-clicks',
|
|
46
|
+
'click.example.com',
|
|
47
|
+
'click'
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if (error) {
|
|
51
|
+
if (error.statusCode === 422 && error.response) {
|
|
52
|
+
console.log(error.response.instructions)
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## List, Update, Delete
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// List
|
|
62
|
+
const { data: listData, error: listError } = await mc.domains.customTracking.list({
|
|
63
|
+
scope: 'click', // optional filter
|
|
64
|
+
status: 'active', // optional filter: active | disabled
|
|
65
|
+
limit: 100,
|
|
66
|
+
offset: 0
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// Update
|
|
70
|
+
const { data: updateData, error: updateError } = await mc.domains.customTracking.update(
|
|
71
|
+
'click.example.com',
|
|
72
|
+
'click',
|
|
73
|
+
{ status: 'disabled' } // or { name: 'new-label' } to rename
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
// Delete
|
|
77
|
+
const { success, error: deleteError } = await mc.domains.customTracking.delete('click.example.com', 'click')
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Deleting a domain takes effect immediately — tracking links and unsubscribe
|
|
81
|
+
URLs already sent using that domain stop working right away. Re-activating a
|
|
82
|
+
domain whose DNS verification has since lapsed goes through the same
|
|
83
|
+
pending-result flow as `create()`.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# DKIM
|
|
2
|
+
|
|
3
|
+
MailChannels can host the **private** DKIM key for you. You publish the **public** key in
|
|
4
|
+
the domain's DNS as a TXT record. The public DNS record is **not** hosted by MailChannels —
|
|
5
|
+
you must publish it yourself in whatever DNS provider holds the zone.
|
|
6
|
+
|
|
7
|
+
### Create A Hosted Key
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { MailChannels } from 'mailchannels-sdk'
|
|
11
|
+
|
|
12
|
+
const mc = new MailChannels('YOUR-API-KEY')
|
|
13
|
+
|
|
14
|
+
const { data, error } = await mc.domains.dkim.create('example.com', {
|
|
15
|
+
selector: 'mcdkim',
|
|
16
|
+
algorithm: 'rsa', // only 'rsa' is currently supported
|
|
17
|
+
length: 2048 // 1024 or 2048; default 2048; multiples of 1024
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
if (error) throw new Error(error.message)
|
|
21
|
+
|
|
22
|
+
for (const record of data?.dnsRecords ?? []) {
|
|
23
|
+
console.log(record.name, record.type, record.value)
|
|
24
|
+
// e.g. mcdkim._domainkey.example.com TXT "v=DKIM1; k=rsa; p=MIIBIj..."
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The returned `data.dnsRecords` contains TXT records you must publish in DNS.
|
|
29
|
+
|
|
30
|
+
### List, Filter, And Include DNS Records
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
const { data, error } = await mc.domains.dkim.list('example.com', {
|
|
34
|
+
selector: 'mcdkim', // optional; returns at most one
|
|
35
|
+
status: 'active', // 'active' | 'retired' | 'revoked' | 'rotated'
|
|
36
|
+
includeDnsRecord: true, // include suggested DNS record per key
|
|
37
|
+
limit: 10,
|
|
38
|
+
offset: 0
|
|
39
|
+
})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Key Lifecycle
|
|
43
|
+
|
|
44
|
+
| Status | Meaning |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| `active` | Currently used for signing. |
|
|
47
|
+
| `rotated` | Being rotated out. Still valid for signing for a 3-day grace period; auto-changes to `retired` 2 weeks after rotation. |
|
|
48
|
+
| `retired` | No longer in use. |
|
|
49
|
+
| `revoked` | Marked compromised. Stop using immediately. |
|
|
50
|
+
|
|
51
|
+
#### Update status directly
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
const { success, error } = await mc.domains.dkim.updateStatus('example.com', {
|
|
55
|
+
selector: 'mcdkim',
|
|
56
|
+
status: 'revoked' // 'revoked' | 'retired' | 'rotated'
|
|
57
|
+
})
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Only `active` keys can move to `rotated`. Only `revoked`, `retired`, and `rotated` are
|
|
61
|
+
valid update targets.
|
|
62
|
+
|
|
63
|
+
#### Rotate (recommended for routine rollover)
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const { data, error } = await mc.domains.dkim.rotate('example.com', 'mcdkim', {
|
|
67
|
+
newKey: { selector: 'mcdkim2' }
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// data.new is the new key info; data.rotated is the old one.
|
|
71
|
+
// data.rotated.gracePeriodExpiresAt says when signing with it stops.
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Rotation:
|
|
75
|
+
|
|
76
|
+
1. Marks the existing key `rotated` (still valid for **3 days** — cut-off in
|
|
77
|
+
`gracePeriodExpiresAt`).
|
|
78
|
+
2. Creates a new active key with the new selector, reusing the same algorithm and key
|
|
79
|
+
length.
|
|
80
|
+
3. The rotated key is auto-retired **2 weeks** after rotation.
|
|
81
|
+
|
|
82
|
+
**Publish the new DNS record before `gracePeriodExpiresAt`** or emails signed with the new
|
|
83
|
+
key will fail DKIM at receiving providers.
|
|
84
|
+
|
|
85
|
+
### Sending With A Hosted Key
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const { data, error } = await mc.emails.queue({
|
|
89
|
+
from: 'sender@example.com',
|
|
90
|
+
to: 'recipient@example.net',
|
|
91
|
+
subject: 'Signed',
|
|
92
|
+
text: 'Signed by hosted DKIM.',
|
|
93
|
+
dkim: {
|
|
94
|
+
domain: 'example.com',
|
|
95
|
+
selector: 'mcdkim'
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
If `dkim.selector` is set without `dkim.domain`, MailChannels takes the domain from the
|
|
101
|
+
`from` address.
|
|
102
|
+
|
|
103
|
+
### Sending With A Customer-Managed Key
|
|
104
|
+
|
|
105
|
+
If you keep the private key yourself, pass it Base64-encoded (PEM headers are stripped
|
|
106
|
+
automatically):
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
const { data, error } = await mc.emails.queue({
|
|
110
|
+
from: 'sender@example.com',
|
|
111
|
+
to: 'recipient@example.net',
|
|
112
|
+
subject: 'Signed',
|
|
113
|
+
text: 'Signed by my own DKIM key.',
|
|
114
|
+
dkim: {
|
|
115
|
+
domain: 'example.com',
|
|
116
|
+
selector: 'mcdkim',
|
|
117
|
+
privateKey: '<base64-encoded-or-PEM-private-key>'
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`dkim` can also be set per-personalization to override the root value.
|
|
123
|
+
|
|
124
|
+
### DNS Publication
|
|
125
|
+
|
|
126
|
+
MailChannels does not host the public DKIM DNS record — you do. The SDK returns the exact
|
|
127
|
+
record to publish in `data.dnsRecords`:
|
|
128
|
+
|
|
129
|
+
| Field | Value |
|
|
130
|
+
| --- | --- |
|
|
131
|
+
| `name` | DNS host. Always `{selector}._domainkey.{domain}`. |
|
|
132
|
+
| `type` | Always `TXT`. |
|
|
133
|
+
| `value` | Public key material. **Publish verbatim** — do not re-wrap, strip quotes, or split lines. |
|
|
134
|
+
|
|
135
|
+
#### Required Steps
|
|
136
|
+
|
|
137
|
+
1. **Create** with `mc.domains.dkim.create(domain, { selector })`. Capture `dnsRecords`.
|
|
138
|
+
2. **Resolve the DNS zone** in your provider's API.
|
|
139
|
+
3. **Create or update** the TXT record at `record.name` with `record.value`. Use a short
|
|
140
|
+
TTL (300 s) so future rotations propagate quickly.
|
|
141
|
+
4. **Wait for propagation**, then verify with `mc.domains.check(domain, { dkim: [{ selector }] })`.
|
|
142
|
+
The `dkim[].verdict` must be `'passed'` before sending real traffic.
|
|
143
|
+
5. **For rotations**, leave the old TXT record in place until `gracePeriodExpiresAt`.
|
|
144
|
+
|
|
145
|
+
### Selector Format
|
|
146
|
+
|
|
147
|
+
- 1–63 characters.
|
|
148
|
+
- Lowercase letters, numbers, and `-` work everywhere. Avoid `_` in the selector itself.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Domain Checks
|
|
2
|
+
|
|
3
|
+
Use `mc.domains.check()` to confirm a sender domain is properly authenticated before
|
|
4
|
+
sending from it. The check covers four things:
|
|
5
|
+
|
|
6
|
+
- **DKIM** — does the domain publish a DKIM public key matching the selectors MailChannels
|
|
7
|
+
signs with?
|
|
8
|
+
- **SPF** — is MailChannels in the domain's SPF record?
|
|
9
|
+
- **Sender-domain DNS** — does the domain have at least one `A` or `MX` record? Receivers
|
|
10
|
+
reject mail without either as **SDNF** ("Sender Domain Not Found").
|
|
11
|
+
- **Domain Lockdown** — a MailChannels feature that ties a sending domain to your account
|
|
12
|
+
so other MailChannels customers cannot spoof it.
|
|
13
|
+
|
|
14
|
+
### Basic Check
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { MailChannels } from 'mailchannels-sdk'
|
|
18
|
+
|
|
19
|
+
const mc = new MailChannels('YOUR-API-KEY')
|
|
20
|
+
|
|
21
|
+
const { data, error } = await mc.domains.check('example.com')
|
|
22
|
+
|
|
23
|
+
if (error) throw new Error(error.message)
|
|
24
|
+
|
|
25
|
+
console.log(data?.spf?.verdict) // 'passed' | 'failed' | …
|
|
26
|
+
console.log(data?.domainLockdown?.verdict) // 'passed' | 'failed'
|
|
27
|
+
console.log(data?.senderDomain?.verdict) // 'passed' | 'failed'
|
|
28
|
+
console.log(data?.dkim) // array of per-selector results
|
|
29
|
+
console.log(data?.references) // support links if anything failed
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### With Specific DKIM Settings
|
|
33
|
+
|
|
34
|
+
If you don't pass `dkim`, MailChannels uses all stored keys for the domain. To target
|
|
35
|
+
specific selectors:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const { data, error } = await mc.domains.check('example.com', {
|
|
39
|
+
dkim: [
|
|
40
|
+
{
|
|
41
|
+
domain: 'example.com',
|
|
42
|
+
selector: 'mcdkim-2025'
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
domain: 'example.com',
|
|
46
|
+
selector: 'mcdkim-2026'
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
})
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
You can pass up to **10** DKIM settings per call.
|
|
53
|
+
|
|
54
|
+
If you only need to provide a single DKIM setting, you may pass it as an object instead
|
|
55
|
+
of an array:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const { data, error } = await mc.domains.check('example.com', {
|
|
59
|
+
dkim: {
|
|
60
|
+
domain: 'example.com',
|
|
61
|
+
selector: 'mcdkim'
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
#### DKIM Settings Resolution Rules
|
|
67
|
+
|
|
68
|
+
| Provided fields | Behavior |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| `domain`, `selector`, `privateKey` all present | Verify with the provided key. |
|
|
71
|
+
| `domain`, `selector` | Use the stored private key for that domain + selector. |
|
|
72
|
+
| `domain` only | Use **all** stored keys for that domain. |
|
|
73
|
+
| `selector` only | Use the `domain` from the request body. |
|
|
74
|
+
| `privateKey` set | `selector` is required too. |
|
|
75
|
+
| `dkim` empty / absent | Use all stored keys for the request domain. |
|
|
76
|
+
|
|
77
|
+
### With A Sender ID (Domain Lockdown)
|
|
78
|
+
|
|
79
|
+
If your lockdown record uses `senderid=` or `sidw=` fields, pass the sender identity:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
const { data, error } = await mc.domains.check('example.com', {
|
|
83
|
+
senderId: 'example|domain|example.com'
|
|
84
|
+
})
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
If your lockdown record uses `auth=` (account-wide authorization), omit `senderId`.
|
|
88
|
+
|
|
89
|
+
### Verdicts Reference
|
|
90
|
+
|
|
91
|
+
- DKIM, Domain Lockdown, A, and MX verdicts: `'passed'` or `'failed'`.
|
|
92
|
+
- SPF has a richer set: `'passed'`, `'failed'`, `'soft failed'`, `'temporary error'`,
|
|
93
|
+
`'permanent error'`, `'neutral'`, `'none'`, `'unknown'`.
|
|
94
|
+
- `senderDomain` passes if **either** the A or MX check passes.
|
|
95
|
+
|
|
96
|
+
### When To Use
|
|
97
|
+
|
|
98
|
+
- During onboarding for every new sending domain.
|
|
99
|
+
- In CI after rotating DKIM keys, to verify the new DNS record propagated.
|
|
100
|
+
- In a periodic monitoring job (verdicts can drift if DNS changes).
|