@freecorps/ember-email 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +101 -2
- package/dist/address.d.ts +36 -0
- package/dist/address.js +121 -0
- package/dist/client.d.ts +273 -0
- package/dist/client.js +36 -0
- package/dist/cloudflare/api.d.ts +64 -0
- package/dist/cloudflare/api.js +146 -0
- package/dist/cloudflare/index.d.ts +18 -0
- package/dist/cloudflare/index.js +38 -0
- package/dist/cloudflare/onboard.d.ts +45 -0
- package/dist/cloudflare/onboard.js +114 -0
- package/dist/component.d.ts +1774 -0
- package/dist/component.js +1125 -0
- package/dist/events.d.ts +26 -0
- package/dist/events.js +76 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/mime.d.ts +53 -0
- package/dist/mime.js +114 -0
- package/dist/schema.d.ts +264 -0
- package/dist/schema.js +159 -0
- package/dist/template.d.ts +44 -0
- package/dist/template.js +194 -0
- package/dist/transport.d.ts +57 -0
- package/dist/transport.js +128 -0
- package/package.json +58 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FreeCorps
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,102 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @freecorps/ember-email
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Email as an [Ember](https://github.com/freecorps/ember) component, on Cloudflare Email Service (sending) and Email
|
|
4
|
+
Routing (receiving). It runs in your own Cloudflare account.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
// ember/ember.config.ts
|
|
8
|
+
import { defineApp, createFunctionHandle } from "@freecorps/ember-server";
|
|
9
|
+
import { email } from "@freecorps/ember-email";
|
|
10
|
+
|
|
11
|
+
export default defineApp().use(email, {
|
|
12
|
+
config: {
|
|
13
|
+
domains: ["acme.com"], // inbound domains routed to this Worker
|
|
14
|
+
rateLimit: { rate: 100, period: 60_000 }, // per project
|
|
15
|
+
hooks: { onInbound: createFunctionHandle("support:onInbound") },
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// ember/users.ts
|
|
22
|
+
import { Email } from "@freecorps/ember-email";
|
|
23
|
+
import { components, mutation } from "./_generated/server";
|
|
24
|
+
const email = new Email(components.email);
|
|
25
|
+
|
|
26
|
+
export const signup = mutation({
|
|
27
|
+
args: { address: v.string(), name: v.string() },
|
|
28
|
+
handler: (ctx, a) => {
|
|
29
|
+
const id = ctx.db.insert("users", a);
|
|
30
|
+
// Commits (or rolls back) with the insert; delivered by an action right after.
|
|
31
|
+
email.send(ctx, { from: "Acme <hello@acme.com>", to: a.address, template: { name: "welcome", data: { name: a.name } }, idempotencyKey: `welcome:${id}` });
|
|
32
|
+
return id;
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## What it does
|
|
38
|
+
|
|
39
|
+
- **Send.** `lib:send` / `email.send(ctx, ...)` is a mutation: atomic with the caller, optionally `sendAt`. It also
|
|
40
|
+
handles idempotency keys per project, suppressions per project (hard bounces and complaints are added
|
|
41
|
+
automatically), and a per-project token-bucket rate limit (nested `rateLimit` component, same transaction).
|
|
42
|
+
Delivery goes through the `send_email` binding (the deploy adds it) or the REST API, with retries. Headers the
|
|
43
|
+
provider refuses are dropped, keeping threading and `X-` headers.
|
|
44
|
+
- **Templates** are stored in the shard and use a small Mustache-like engine (`{{var}}`, `{{{raw}}}`, `#if`/`#each`)
|
|
45
|
+
with a responsive layout. React Email works too: render it in an action and pass `html`.
|
|
46
|
+
- **Delivery events.** The component owns a Queue (`ember-<project>-<env>-email-events`). Point an Email Service
|
|
47
|
+
event subscription for your sending domain at it. Events are deduplicated by `eventId`, the status only moves
|
|
48
|
+
forward, and `lib:get` is reactive.
|
|
49
|
+
- **Inbound.** The component hooks the Worker's `email()` handler:
|
|
50
|
+
- It routes mailbox addresses (`support@`, `privacy@`) and plus-addressed thread tokens. Unknown addresses are
|
|
51
|
+
rejected.
|
|
52
|
+
- It stores the raw `.eml` and the attachments in files (R2), and parses the message with `postal-mime`.
|
|
53
|
+
- It reads DKIM, DMARC and SPF from Cloudflare's `Authentication-Results`.
|
|
54
|
+
- It threads messages by token, then by `In-Reply-To` (DMARC pass, same contact), then by subject.
|
|
55
|
+
- It handles spam per sender per hour and auto-replies without loops.
|
|
56
|
+
- Your `onInbound` hook runs in the same transaction (tickets, push).
|
|
57
|
+
- **Inbox queries** for dashboards and mobile apps: `threads`, `thread`, `unread` (aggregate), `search`
|
|
58
|
+
(`searchIndex`), `reply`, `note`, `markRead`, `markUnread`, `setStatus`, `assign`, `compose` (a new conversation
|
|
59
|
+
from the mailbox, answers join it by token) and `recent` (latest threads with their messages, for offline replicas).
|
|
60
|
+
|
|
61
|
+
## Domains: `@freecorps/ember-email/cloudflare`
|
|
62
|
+
|
|
63
|
+
Puts a domain on Cloudflare for the component and removes it again. It only needs `fetch`, so it runs in a Worker
|
|
64
|
+
(with its own API token secret) or on an operator's machine (no token in the Worker). It has no storage, HTTP API
|
|
65
|
+
or keys: you keep the returned state.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { destroyDomain, emailResources, resolveAccountId, syncDomain } from "@freecorps/ember-email/cloudflare";
|
|
69
|
+
|
|
70
|
+
const c = { apiToken, accountId: await resolveAccountId(apiToken, { env: process.env }) };
|
|
71
|
+
const { worker, eventsQueue } = emailResources("acme", "prod"); // ember-acme-prod, ember-acme-prod-email-events
|
|
72
|
+
let d = await syncDomain(c, { name: "mail.acme.com", enableRouting: true }, { prev: null, mailboxes: ["support@mail.acme.com"], worker, queue: eventsQueue });
|
|
73
|
+
// d.status: "pending" until Email Service finishes onboarding (a minute or two): sync again with { prev: d }.
|
|
74
|
+
// d.records: the DNS records involved; d.notes: what is missing or needs the zone owner.
|
|
75
|
+
await destroyDomain(c, d); // rules, event subscription, and the sending domain if this created it
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
What it does:
|
|
79
|
+
|
|
80
|
+
- **Sending:** it onboards the domain in Email Service. Cloudflare manages its records (bounce MX/SPF, DKIM, DMARC).
|
|
81
|
+
- **Receiving:** it checks Email Routing. For the zone apex, routing must already be on, because turning it on
|
|
82
|
+
rewrites the zone's MX. For a subdomain, `enableRouting: true` creates the routing subdomain. No API removes a
|
|
83
|
+
routing subdomain, so removal is a dashboard step.
|
|
84
|
+
- **Mailboxes:** it adds one literal rule per mailbox address on the domain → your Worker. It never changes a rule
|
|
85
|
+
that routes somewhere else.
|
|
86
|
+
- **Events:** it adds an `email.sending` event subscription → the component's queue.
|
|
87
|
+
|
|
88
|
+
## Offline replicas: `@freecorps/ember-email/schema`
|
|
89
|
+
|
|
90
|
+
`emailSchema`: the component's tables only (no postal-mime), for clients composing a local replica schema.
|
|
91
|
+
|
|
92
|
+
## Example
|
|
93
|
+
|
|
94
|
+
[`examples/email-lite`](https://github.com/freecorps/ember/tree/main/examples/email-lite) is the smallest app on the
|
|
95
|
+
component (mailboxes, send, inbound). [freecorps/mail](https://github.com/freecorps/mail) is a full email service
|
|
96
|
+
built on it (Resend-style HTTP API, inbox, mobile app).
|
|
97
|
+
|
|
98
|
+
All the component's state lives in one shard (`config.shard`, default `global:main`).
|
|
99
|
+
|
|
100
|
+
See `docs/PLAN.md` §16 and `docs/design/contracts.md` ("Email") in the repository.
|
|
101
|
+
|
|
102
|
+
License: MIT.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type EmailAddress = {
|
|
2
|
+
email: string;
|
|
3
|
+
name?: string;
|
|
4
|
+
};
|
|
5
|
+
/** Is `s` a bare `local@domain.tld` address (no display name)? Deliberately strict: rejects header injection. */
|
|
6
|
+
export declare function isEmail(s: unknown): s is string;
|
|
7
|
+
/**
|
|
8
|
+
* `"Name" <a@b.c>`, `Name <a@b.c>`, `a@b.c` or `{ email, name }` → `{ email, name? }`. Parsed by position (a long
|
|
9
|
+
* hostile display name must not make a regex backtrack). Throws on anything else.
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseAddress(input: string | EmailAddress): EmailAddress;
|
|
12
|
+
/** `{ email, name }` → `"Name" <email>` (or the bare address). */
|
|
13
|
+
export declare function formatAddress(a: EmailAddress): string;
|
|
14
|
+
/** A list of `string | {email,name}` (or one) → parsed addresses, duplicates removed. */
|
|
15
|
+
export declare function parseAddressList(x: string | EmailAddress | ReadonlyArray<string | EmailAddress> | undefined): EmailAddress[];
|
|
16
|
+
export declare const domainOf: (email: string) => string;
|
|
17
|
+
/**
|
|
18
|
+
* Splits plus-addressing: `support+t_abc123@x.com` → `{ base: "support@x.com", tag: "t_abc123" }`. Email Routing
|
|
19
|
+
* hands the Worker the envelope `to` with the `+detail` kept (the zone needs subaddressing on to route it).
|
|
20
|
+
*/
|
|
21
|
+
export declare function splitPlus(email: string): {
|
|
22
|
+
base: string;
|
|
23
|
+
tag: string | null;
|
|
24
|
+
};
|
|
25
|
+
/** `support@x.com` + tag → `support+tag@x.com`. */
|
|
26
|
+
export declare function withPlus(email: string, tag: string): string;
|
|
27
|
+
/** "Re: Fwd: [#12] Hello" → "hello": the key used to match a reply to its thread by subject. */
|
|
28
|
+
export declare function subjectKey(subject: string | undefined): string;
|
|
29
|
+
/** "Hello" → "Re: Hello" (once). */
|
|
30
|
+
export declare function replySubject(subject: string): string;
|
|
31
|
+
/** A `Message-ID` value without the angle brackets, lowercased (header lookups). */
|
|
32
|
+
export declare function normalizeMessageId(id: string | undefined | null): string | null;
|
|
33
|
+
/** All Message-IDs in a `References` / `In-Reply-To` header value. */
|
|
34
|
+
export declare function messageIdList(h: string | undefined | null): string[];
|
|
35
|
+
/** Addresses that must never get an automatic reply. */
|
|
36
|
+
export declare function isNoReplySender(email: string): boolean;
|
package/dist/address.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Addresses, plus-addressing and subjects. Pure functions (no Cloudflare APIs): used in mutations, actions and the
|
|
2
|
+
// mail app's HTTP API.
|
|
3
|
+
const BARE = /^[^\s<>@",;:()[\]\\]+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
|
|
4
|
+
/** Is `s` a bare `local@domain.tld` address (no display name)? Deliberately strict: rejects header injection. */
|
|
5
|
+
export function isEmail(s) {
|
|
6
|
+
return typeof s === "string" && s.length <= 254 && BARE.test(s);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* `"Name" <a@b.c>`, `Name <a@b.c>`, `a@b.c` or `{ email, name }` → `{ email, name? }`. Parsed by position (a long
|
|
10
|
+
* hostile display name must not make a regex backtrack). Throws on anything else.
|
|
11
|
+
*/
|
|
12
|
+
export function parseAddress(input) {
|
|
13
|
+
if (typeof input === "object" && input !== null) {
|
|
14
|
+
if (!isEmail(input.email))
|
|
15
|
+
throw new Error(`Invalid email address ${JSON.stringify(input.email)}`);
|
|
16
|
+
const name = cleanName(input.name);
|
|
17
|
+
return name ? { email: input.email.toLowerCase(), name } : { email: input.email.toLowerCase() };
|
|
18
|
+
}
|
|
19
|
+
if (typeof input !== "string")
|
|
20
|
+
throw new Error("Invalid email address");
|
|
21
|
+
const s = input.trim();
|
|
22
|
+
const open = s.lastIndexOf("<");
|
|
23
|
+
if (open >= 0 && s.endsWith(">")) {
|
|
24
|
+
const email = s.slice(open + 1, -1).trim();
|
|
25
|
+
if (!isEmail(email))
|
|
26
|
+
throw new Error(`Invalid email address ${JSON.stringify(input)}`);
|
|
27
|
+
const name = cleanName(s.slice(0, open));
|
|
28
|
+
return name ? { email: email.toLowerCase(), name } : { email: email.toLowerCase() };
|
|
29
|
+
}
|
|
30
|
+
if (!isEmail(s))
|
|
31
|
+
throw new Error(`Invalid email address ${JSON.stringify(input)}`);
|
|
32
|
+
return { email: s.toLowerCase() };
|
|
33
|
+
}
|
|
34
|
+
function cleanName(n) {
|
|
35
|
+
if (n === undefined)
|
|
36
|
+
return undefined;
|
|
37
|
+
let x = n.trim();
|
|
38
|
+
if (x.startsWith('"') && x.endsWith('"') && x.length >= 2)
|
|
39
|
+
x = x.slice(1, -1);
|
|
40
|
+
x = x.replace(/[\r\n<>"]/g, "").trim();
|
|
41
|
+
return x ? x.slice(0, 200) : undefined;
|
|
42
|
+
}
|
|
43
|
+
/** `{ email, name }` → `"Name" <email>` (or the bare address). */
|
|
44
|
+
export function formatAddress(a) {
|
|
45
|
+
return a.name ? `"${a.name.replace(/["\\]/g, "")}" <${a.email}>` : a.email;
|
|
46
|
+
}
|
|
47
|
+
/** A list of `string | {email,name}` (or one) → parsed addresses, duplicates removed. */
|
|
48
|
+
export function parseAddressList(x) {
|
|
49
|
+
if (x === undefined)
|
|
50
|
+
return [];
|
|
51
|
+
const arr = Array.isArray(x) ? x : [x];
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const a of arr) {
|
|
55
|
+
const p = parseAddress(a);
|
|
56
|
+
if (seen.has(p.email))
|
|
57
|
+
continue;
|
|
58
|
+
seen.add(p.email);
|
|
59
|
+
out.push(p);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
export const domainOf = (email) => email.slice(email.lastIndexOf("@") + 1).toLowerCase();
|
|
64
|
+
/**
|
|
65
|
+
* Splits plus-addressing: `support+t_abc123@x.com` → `{ base: "support@x.com", tag: "t_abc123" }`. Email Routing
|
|
66
|
+
* hands the Worker the envelope `to` with the `+detail` kept (the zone needs subaddressing on to route it).
|
|
67
|
+
*/
|
|
68
|
+
export function splitPlus(email) {
|
|
69
|
+
const at = email.lastIndexOf("@");
|
|
70
|
+
const local = email.slice(0, at);
|
|
71
|
+
const plus = local.indexOf("+");
|
|
72
|
+
if (plus < 0)
|
|
73
|
+
return { base: email.toLowerCase(), tag: null };
|
|
74
|
+
return { base: `${local.slice(0, plus)}${email.slice(at)}`.toLowerCase(), tag: local.slice(plus + 1) || null };
|
|
75
|
+
}
|
|
76
|
+
/** `support@x.com` + tag → `support+tag@x.com`. */
|
|
77
|
+
export function withPlus(email, tag) {
|
|
78
|
+
const at = email.lastIndexOf("@");
|
|
79
|
+
return `${email.slice(0, at)}+${tag}${email.slice(at)}`;
|
|
80
|
+
}
|
|
81
|
+
/** "Re: Fwd: [#12] Hello" → "hello": the key used to match a reply to its thread by subject. */
|
|
82
|
+
export function subjectKey(subject) {
|
|
83
|
+
let s = (subject ?? "").trim();
|
|
84
|
+
for (;;) {
|
|
85
|
+
const next = s.replace(/^\s*((re|fw|fwd|aw|wg|sv|vs|res|enc|tr)\s*(\[\d+\])?\s*:\s*|\[#\d+\]\s*)/i, "");
|
|
86
|
+
if (next === s)
|
|
87
|
+
break;
|
|
88
|
+
s = next;
|
|
89
|
+
}
|
|
90
|
+
return s.replace(/\s+/g, " ").trim().toLowerCase().slice(0, 300);
|
|
91
|
+
}
|
|
92
|
+
/** "Hello" → "Re: Hello" (once). */
|
|
93
|
+
export function replySubject(subject) {
|
|
94
|
+
return /^\s*re\s*:/i.test(subject) ? subject : `Re: ${subject}`;
|
|
95
|
+
}
|
|
96
|
+
/** A `Message-ID` value without the angle brackets, lowercased (header lookups). */
|
|
97
|
+
export function normalizeMessageId(id) {
|
|
98
|
+
if (!id)
|
|
99
|
+
return null;
|
|
100
|
+
const m = /<([^<>]+)>/.exec(id);
|
|
101
|
+
const v = (m ? m[1] : id).trim();
|
|
102
|
+
return v ? v.toLowerCase() : null;
|
|
103
|
+
}
|
|
104
|
+
/** All Message-IDs in a `References` / `In-Reply-To` header value. */
|
|
105
|
+
export function messageIdList(h) {
|
|
106
|
+
if (!h)
|
|
107
|
+
return [];
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const m of h.matchAll(/<([^<>]+)>/g))
|
|
110
|
+
out.push(m[1].trim().toLowerCase());
|
|
111
|
+
if (!out.length)
|
|
112
|
+
for (const t of h.split(/\s+/))
|
|
113
|
+
if (t.includes("@"))
|
|
114
|
+
out.push(t.toLowerCase());
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
/** Addresses that must never get an automatic reply. */
|
|
118
|
+
export function isNoReplySender(email) {
|
|
119
|
+
const local = email.slice(0, email.lastIndexOf("@")).toLowerCase();
|
|
120
|
+
return /^(no-?reply|do-?not-?reply|mailer-daemon|postmaster|bounces?|notifications?)([+.-]|$)/.test(local);
|
|
121
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import type { FunctionReference } from "@freecorps/ember-server";
|
|
2
|
+
import type { SendInput, SendResult } from "./component.ts";
|
|
3
|
+
import type { EmailStatus } from "./events.ts";
|
|
4
|
+
type Ref<T extends "query" | "mutation" | "action", A, R> = FunctionReference<T, "public", A, R>;
|
|
5
|
+
type Page<T> = {
|
|
6
|
+
page: T[];
|
|
7
|
+
isDone: boolean;
|
|
8
|
+
continueCursor: string;
|
|
9
|
+
};
|
|
10
|
+
type PageOpts = {
|
|
11
|
+
numItems: number;
|
|
12
|
+
cursor: string | null;
|
|
13
|
+
};
|
|
14
|
+
export type EmailRecord = SendInput & {
|
|
15
|
+
_id: string;
|
|
16
|
+
_creationTime: number;
|
|
17
|
+
project: string;
|
|
18
|
+
status: EmailStatus;
|
|
19
|
+
sendAt: number;
|
|
20
|
+
attempts: number;
|
|
21
|
+
messageId?: string;
|
|
22
|
+
sentAt?: number;
|
|
23
|
+
lastEventAt?: number;
|
|
24
|
+
error?: {
|
|
25
|
+
code: string;
|
|
26
|
+
message: string;
|
|
27
|
+
};
|
|
28
|
+
suppressed?: string[];
|
|
29
|
+
};
|
|
30
|
+
export type ThreadRecord = {
|
|
31
|
+
_id: string;
|
|
32
|
+
mailbox: string;
|
|
33
|
+
number: number;
|
|
34
|
+
subject: string;
|
|
35
|
+
contact: string;
|
|
36
|
+
contactName?: string;
|
|
37
|
+
status: "open" | "waiting" | "closed" | "spam";
|
|
38
|
+
messageCount: number;
|
|
39
|
+
unreadCount: number;
|
|
40
|
+
lastMessageAt: number;
|
|
41
|
+
snippet: string;
|
|
42
|
+
assignee?: string;
|
|
43
|
+
assigneeName?: string;
|
|
44
|
+
};
|
|
45
|
+
/** What `components.<name>` of the email component looks like (public functions). */
|
|
46
|
+
export interface EmailComponentApi {
|
|
47
|
+
lib: {
|
|
48
|
+
send: Ref<"mutation", SendInput, SendResult>;
|
|
49
|
+
sendBatch: Ref<"mutation", {
|
|
50
|
+
emails: SendInput[];
|
|
51
|
+
}, SendResult[]>;
|
|
52
|
+
cancel: Ref<"mutation", {
|
|
53
|
+
id: string;
|
|
54
|
+
}, {
|
|
55
|
+
ok: boolean;
|
|
56
|
+
status: string;
|
|
57
|
+
}>;
|
|
58
|
+
retry: Ref<"mutation", {
|
|
59
|
+
id: string;
|
|
60
|
+
}, {
|
|
61
|
+
ok: boolean;
|
|
62
|
+
status: string;
|
|
63
|
+
}>;
|
|
64
|
+
get: Ref<"query", {
|
|
65
|
+
id: string;
|
|
66
|
+
}, EmailRecord | null>;
|
|
67
|
+
list: Ref<"query", {
|
|
68
|
+
project?: string;
|
|
69
|
+
paginationOpts?: PageOpts;
|
|
70
|
+
}, Page<EmailRecord>>;
|
|
71
|
+
events: Ref<"query", {
|
|
72
|
+
id: string;
|
|
73
|
+
}, Array<Record<string, unknown>>>;
|
|
74
|
+
eventsByMessage: Ref<"query", {
|
|
75
|
+
messageId: string;
|
|
76
|
+
}, Array<Record<string, unknown>>>;
|
|
77
|
+
stats: Ref<"query", {
|
|
78
|
+
project?: string;
|
|
79
|
+
}, Record<string, number>>;
|
|
80
|
+
suppress: Ref<"mutation", {
|
|
81
|
+
project?: string;
|
|
82
|
+
email: string;
|
|
83
|
+
reason?: string;
|
|
84
|
+
}, string>;
|
|
85
|
+
unsuppress: Ref<"mutation", {
|
|
86
|
+
project?: string;
|
|
87
|
+
email: string;
|
|
88
|
+
}, boolean>;
|
|
89
|
+
suppressions: Ref<"query", {
|
|
90
|
+
project?: string;
|
|
91
|
+
paginationOpts?: PageOpts;
|
|
92
|
+
}, Page<{
|
|
93
|
+
email: string;
|
|
94
|
+
reason: string;
|
|
95
|
+
at: number;
|
|
96
|
+
}>>;
|
|
97
|
+
upsertTemplate: Ref<"mutation", {
|
|
98
|
+
project?: string;
|
|
99
|
+
name: string;
|
|
100
|
+
subject: string;
|
|
101
|
+
html: string;
|
|
102
|
+
text?: string;
|
|
103
|
+
layout?: unknown;
|
|
104
|
+
}, string>;
|
|
105
|
+
removeTemplate: Ref<"mutation", {
|
|
106
|
+
project?: string;
|
|
107
|
+
name: string;
|
|
108
|
+
}, boolean>;
|
|
109
|
+
templates: Ref<"query", {
|
|
110
|
+
project?: string;
|
|
111
|
+
}, Array<{
|
|
112
|
+
name: string;
|
|
113
|
+
subject: string;
|
|
114
|
+
html: string;
|
|
115
|
+
text?: string;
|
|
116
|
+
updatedAt: number;
|
|
117
|
+
}>>;
|
|
118
|
+
render: Ref<"query", {
|
|
119
|
+
project?: string;
|
|
120
|
+
name: string;
|
|
121
|
+
data?: unknown;
|
|
122
|
+
}, {
|
|
123
|
+
subject: string;
|
|
124
|
+
html: string;
|
|
125
|
+
text: string;
|
|
126
|
+
} | null>;
|
|
127
|
+
upsertMailbox: Ref<"mutation", {
|
|
128
|
+
name: string;
|
|
129
|
+
address: string;
|
|
130
|
+
displayName?: string;
|
|
131
|
+
autoReply?: {
|
|
132
|
+
subject: string;
|
|
133
|
+
text: string;
|
|
134
|
+
html?: string;
|
|
135
|
+
} | null;
|
|
136
|
+
}, string>;
|
|
137
|
+
removeMailbox: Ref<"mutation", {
|
|
138
|
+
name: string;
|
|
139
|
+
}, boolean>;
|
|
140
|
+
mailboxes: Ref<"query", {}, Array<{
|
|
141
|
+
_id: string;
|
|
142
|
+
name: string;
|
|
143
|
+
address: string;
|
|
144
|
+
displayName?: string;
|
|
145
|
+
autoReply?: unknown;
|
|
146
|
+
}>>;
|
|
147
|
+
route: Ref<"query", {
|
|
148
|
+
to: string;
|
|
149
|
+
}, {
|
|
150
|
+
mailbox: string;
|
|
151
|
+
tag: string | null;
|
|
152
|
+
} | null>;
|
|
153
|
+
threads: Ref<"query", {
|
|
154
|
+
mailbox: string;
|
|
155
|
+
status?: string;
|
|
156
|
+
paginationOpts?: PageOpts;
|
|
157
|
+
}, Page<ThreadRecord>>;
|
|
158
|
+
thread: Ref<"query", {
|
|
159
|
+
threadId: string;
|
|
160
|
+
}, {
|
|
161
|
+
thread: ThreadRecord;
|
|
162
|
+
messages: Array<Record<string, any>>;
|
|
163
|
+
} | null>;
|
|
164
|
+
unread: Ref<"query", {
|
|
165
|
+
mailboxes?: string[];
|
|
166
|
+
}, Record<string, {
|
|
167
|
+
unread: number;
|
|
168
|
+
threads: number;
|
|
169
|
+
}>>;
|
|
170
|
+
search: Ref<"query", {
|
|
171
|
+
query: string;
|
|
172
|
+
mailbox?: string;
|
|
173
|
+
limit?: number;
|
|
174
|
+
}, Array<{
|
|
175
|
+
messageId: string;
|
|
176
|
+
threadId: string;
|
|
177
|
+
mailbox: string;
|
|
178
|
+
direction: string;
|
|
179
|
+
subject: string;
|
|
180
|
+
from: {
|
|
181
|
+
email: string;
|
|
182
|
+
name?: string;
|
|
183
|
+
};
|
|
184
|
+
snippet: string;
|
|
185
|
+
at: number;
|
|
186
|
+
}>>;
|
|
187
|
+
markRead: Ref<"mutation", {
|
|
188
|
+
threadId: string;
|
|
189
|
+
}, boolean>;
|
|
190
|
+
setStatus: Ref<"mutation", {
|
|
191
|
+
threadId: string;
|
|
192
|
+
status: "open" | "waiting" | "closed" | "spam";
|
|
193
|
+
}, string>;
|
|
194
|
+
reply: Ref<"mutation", {
|
|
195
|
+
threadId: string;
|
|
196
|
+
text: string;
|
|
197
|
+
html?: string;
|
|
198
|
+
author?: string;
|
|
199
|
+
cc?: string[];
|
|
200
|
+
sendAt?: number;
|
|
201
|
+
}, {
|
|
202
|
+
emailId: string;
|
|
203
|
+
messageId: string;
|
|
204
|
+
status: string;
|
|
205
|
+
}>;
|
|
206
|
+
note: Ref<"mutation", {
|
|
207
|
+
threadId: string;
|
|
208
|
+
text: string;
|
|
209
|
+
author?: string;
|
|
210
|
+
}, string>;
|
|
211
|
+
markUnread: Ref<"mutation", {
|
|
212
|
+
threadId: string;
|
|
213
|
+
}, boolean>;
|
|
214
|
+
assign: Ref<"mutation", {
|
|
215
|
+
threadId: string;
|
|
216
|
+
assignee: string | null;
|
|
217
|
+
assigneeName?: string;
|
|
218
|
+
}, string | null>;
|
|
219
|
+
compose: Ref<"mutation", {
|
|
220
|
+
mailbox: string;
|
|
221
|
+
to: string[];
|
|
222
|
+
cc?: string[];
|
|
223
|
+
subject: string;
|
|
224
|
+
text: string;
|
|
225
|
+
html?: string;
|
|
226
|
+
author?: string;
|
|
227
|
+
}, {
|
|
228
|
+
threadId: string;
|
|
229
|
+
emailId: string;
|
|
230
|
+
messageId: string;
|
|
231
|
+
status: string;
|
|
232
|
+
number: number;
|
|
233
|
+
}>;
|
|
234
|
+
recent: Ref<"query", {
|
|
235
|
+
mailboxes: string[];
|
|
236
|
+
threads?: number;
|
|
237
|
+
}, {
|
|
238
|
+
threads: ThreadRecord[];
|
|
239
|
+
messages: Array<Record<string, any>>;
|
|
240
|
+
}>;
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
type SyncCtx = {
|
|
244
|
+
runQuery(fn: any, args?: any): any;
|
|
245
|
+
runMutation?(fn: any, args?: any): any;
|
|
246
|
+
};
|
|
247
|
+
type AsyncCtx = {
|
|
248
|
+
runQuery(fn: any, args?: any): Promise<any>;
|
|
249
|
+
runMutation(fn: any, args?: any): Promise<any>;
|
|
250
|
+
};
|
|
251
|
+
export declare class Email {
|
|
252
|
+
readonly component: EmailComponentApi;
|
|
253
|
+
constructor(component: EmailComponentApi);
|
|
254
|
+
private mut;
|
|
255
|
+
/** Queues an email; in a mutation it commits (or rolls back) with it. */
|
|
256
|
+
send(ctx: SyncCtx, input: SendInput): SendResult;
|
|
257
|
+
send(ctx: AsyncCtx, input: SendInput): Promise<SendResult>;
|
|
258
|
+
sendBatch(ctx: SyncCtx, emails: SendInput[]): SendResult[];
|
|
259
|
+
sendBatch(ctx: AsyncCtx, emails: SendInput[]): Promise<SendResult[]>;
|
|
260
|
+
cancel(ctx: SyncCtx, id: string): {
|
|
261
|
+
ok: boolean;
|
|
262
|
+
status: string;
|
|
263
|
+
};
|
|
264
|
+
cancel(ctx: AsyncCtx, id: string): Promise<{
|
|
265
|
+
ok: boolean;
|
|
266
|
+
status: string;
|
|
267
|
+
}>;
|
|
268
|
+
get(ctx: SyncCtx, id: string): EmailRecord | null;
|
|
269
|
+
get(ctx: AsyncCtx, id: string): Promise<EmailRecord | null>;
|
|
270
|
+
/** Delivery status of an email (reactive in a query). */
|
|
271
|
+
status(ctx: SyncCtx, id: string): EmailStatus | null;
|
|
272
|
+
}
|
|
273
|
+
export {};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Typed helper over an installed email component (like `PushNotifications`): synchronous inside queries and
|
|
2
|
+
// mutations (same transaction), Promises in actions.
|
|
3
|
+
//
|
|
4
|
+
// const email = new Email(components.email);
|
|
5
|
+
// export const signup = mutation({ args: {...}, handler: (ctx, a) => {
|
|
6
|
+
// const userId = ctx.db.insert("users", a);
|
|
7
|
+
// email.send(ctx, { from: "Acme <hello@acme.com>", to: a.email, template: { name: "welcome", data: { name: a.name } } });
|
|
8
|
+
// return userId;
|
|
9
|
+
// }});
|
|
10
|
+
export class Email {
|
|
11
|
+
component;
|
|
12
|
+
constructor(component) {
|
|
13
|
+
this.component = component;
|
|
14
|
+
}
|
|
15
|
+
mut(ctx, what) {
|
|
16
|
+
if (!ctx.runMutation)
|
|
17
|
+
throw new Error(`email.${what} needs a mutation or action ctx`);
|
|
18
|
+
return ctx.runMutation.bind(ctx);
|
|
19
|
+
}
|
|
20
|
+
send(ctx, input) {
|
|
21
|
+
return this.mut(ctx, "send")(this.component.lib.send, input);
|
|
22
|
+
}
|
|
23
|
+
sendBatch(ctx, emails) {
|
|
24
|
+
return this.mut(ctx, "sendBatch")(this.component.lib.sendBatch, { emails });
|
|
25
|
+
}
|
|
26
|
+
cancel(ctx, id) {
|
|
27
|
+
return this.mut(ctx, "cancel")(this.component.lib.cancel, { id });
|
|
28
|
+
}
|
|
29
|
+
get(ctx, id) {
|
|
30
|
+
return ctx.runQuery(this.component.lib.get, { id });
|
|
31
|
+
}
|
|
32
|
+
/** Delivery status of an email (reactive in a query). */
|
|
33
|
+
status(ctx, id) {
|
|
34
|
+
return ctx.runQuery(this.component.lib.get, { id })?.status ?? null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export interface CfCreds {
|
|
2
|
+
accountId: string;
|
|
3
|
+
apiToken: string;
|
|
4
|
+
fetch?: typeof fetch;
|
|
5
|
+
}
|
|
6
|
+
export declare class CfError extends Error {
|
|
7
|
+
readonly status: number;
|
|
8
|
+
readonly codes: number[];
|
|
9
|
+
name: string;
|
|
10
|
+
constructor(message: string, status: number, codes: number[]);
|
|
11
|
+
}
|
|
12
|
+
export type DnsRecord = {
|
|
13
|
+
type: string;
|
|
14
|
+
name: string;
|
|
15
|
+
value: string;
|
|
16
|
+
priority?: number;
|
|
17
|
+
ttl?: number;
|
|
18
|
+
purpose: "sending" | "receiving";
|
|
19
|
+
};
|
|
20
|
+
/** The zone of `domain` in this account (walks up the labels: `a.b.example.com` → `example.com`). */
|
|
21
|
+
export declare function findZone(c: CfCreds, domain: string): Promise<{
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
} | null>;
|
|
25
|
+
type SendingSubdomain = {
|
|
26
|
+
tag: string;
|
|
27
|
+
id?: string;
|
|
28
|
+
name: string;
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
};
|
|
31
|
+
export declare function sendingDomain(c: CfCreds, zoneId: string, name: string): Promise<SendingSubdomain | null>;
|
|
32
|
+
/** Onboards `name` for sending (idempotent) and returns its DNS records. */
|
|
33
|
+
export declare function ensureSending(c: CfCreds, zoneId: string, name: string): Promise<{
|
|
34
|
+
sending: SendingSubdomain;
|
|
35
|
+
records: DnsRecord[];
|
|
36
|
+
created: boolean;
|
|
37
|
+
}>;
|
|
38
|
+
export declare function deleteSending(c: CfCreds, zoneId: string, tag: string): Promise<void>;
|
|
39
|
+
export type RoutingState = {
|
|
40
|
+
enabled: boolean;
|
|
41
|
+
status: string;
|
|
42
|
+
records: DnsRecord[];
|
|
43
|
+
note?: string;
|
|
44
|
+
};
|
|
45
|
+
/** Whether Email Routing receives mail for `name` (apex or routing subdomain), and the records it needs. */
|
|
46
|
+
export declare function routingState(c: CfCreds, zone: {
|
|
47
|
+
id: string;
|
|
48
|
+
name: string;
|
|
49
|
+
}, name: string): Promise<RoutingState>;
|
|
50
|
+
/** Adds `name` as a routing subdomain (records on the subdomain only). There is no API to remove it again. */
|
|
51
|
+
export declare function enableRoutingSubdomain(c: CfCreds, zoneId: string, name: string): Promise<void>;
|
|
52
|
+
/** A literal rule `address` → `worker` (reused when one already points there; never changes a rule routed elsewhere). */
|
|
53
|
+
export declare function ensureWorkerRule(c: CfCreds, zoneId: string, address: string, worker: string): Promise<{
|
|
54
|
+
id: string;
|
|
55
|
+
created: boolean;
|
|
56
|
+
}>;
|
|
57
|
+
export declare function deleteRule(c: CfCreds, zoneId: string, id: string): Promise<void>;
|
|
58
|
+
/** Queues event subscription: Email Service delivery events of `domain` → `queue` (by name). */
|
|
59
|
+
export declare function ensureEventSubscription(c: CfCreds, zoneId: string, domain: string, queue: string): Promise<{
|
|
60
|
+
id: string;
|
|
61
|
+
created: boolean;
|
|
62
|
+
}>;
|
|
63
|
+
export declare function deleteEventSubscription(c: CfCreds, id: string): Promise<void>;
|
|
64
|
+
export {};
|