@pilely/simple-email 0.4.3 → 0.5.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/README.md +112 -62
- package/dist/api.d.ts +63 -101
- package/dist/api.js +61 -95
- package/dist/hooks.d.ts +45 -37
- package/dist/hooks.js +46 -58
- package/dist/index.d.ts +7 -4
- package/dist/index.js +7 -2
- package/dist/mock.d.ts +7 -0
- package/dist/mock.js +165 -202
- package/dist/send.d.ts +75 -0
- package/dist/send.js +94 -0
- package/dist/types.d.ts +32 -52
- package/package.json +7 -3
package/dist/send.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// `send` — the freestyle send, `POST /accounts/{app_id}/send`, for an app's
|
|
2
|
+
// BACKEND only. The service admits exactly one caller on that route: the
|
|
3
|
+
// account's app's own backend, calling with its backend token (`app:<A>` on
|
|
4
|
+
// app A). A page, a signed-in user, the owner and every other app get the
|
|
5
|
+
// uniform 404, so this function has no browser half: no `window.pilely`, no
|
|
6
|
+
// `client.js`, no React. It takes the backend token and the app id
|
|
7
|
+
// explicitly and makes one plain `fetch` with the token as bearer.
|
|
8
|
+
//
|
|
9
|
+
// This module imports nothing at runtime — not `@pilely/core`, not React —
|
|
10
|
+
// so a server imports it from the `@pilely/simple-email/send` subpath
|
|
11
|
+
// without any of the package's peers loaded. It is also re-exported from the
|
|
12
|
+
// main entry. Mock mode does not reach it: it calls the global `fetch`
|
|
13
|
+
// directly.
|
|
14
|
+
/** Where the service answers. A testing-tier lane passes its own origin. */
|
|
15
|
+
export const SIMPLE_EMAIL_ORIGIN = "https://simple-email.pilely.app";
|
|
16
|
+
/** A refused `send`. `code` is `null` for a bodiless answer — an oversize
|
|
17
|
+
* body refused before any handler runs, an unknown path — and on a missing
|
|
18
|
+
* `appId` or `backendToken` (`status` 0, nothing was sent). Never carries
|
|
19
|
+
* the token. */
|
|
20
|
+
export class SendError extends Error {
|
|
21
|
+
status;
|
|
22
|
+
code;
|
|
23
|
+
reason;
|
|
24
|
+
name = "SendError";
|
|
25
|
+
constructor(status, code, reason) {
|
|
26
|
+
super(reason);
|
|
27
|
+
this.status = status;
|
|
28
|
+
this.code = code;
|
|
29
|
+
this.reason = reason;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function isObject(value) {
|
|
33
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Sends one message from the app's account to every address in `to`:
|
|
37
|
+
* `POST <origin>/accounts/<appId>/send` with the backend token as bearer.
|
|
38
|
+
* Resolves to the service's answer; throws `SendError` on any refusal — a
|
|
39
|
+
* caller that is not this app's backend, an unknown account or template, or
|
|
40
|
+
* the owner out of balance (all one 404), a bad body (400), an
|
|
41
|
+
* `idempotency_key` conflict (409), the app's 500-recipient UTC-day
|
|
42
|
+
* freestyle cap (`429 daily_cap_exceeded`).
|
|
43
|
+
*
|
|
44
|
+
* ```ts
|
|
45
|
+
* import { send } from "@pilely/simple-email/send";
|
|
46
|
+
*
|
|
47
|
+
* await send(
|
|
48
|
+
* { to: [row.email], template_id: WELCOME, variables: { name: row.name }, idempotency_key: `welcome:${row.id}` },
|
|
49
|
+
* { appId: process.env.APP_ID!, backendToken: process.env.PILELY_BACKEND_TOKEN! },
|
|
50
|
+
* );
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export async function send(input, options) {
|
|
54
|
+
const { appId, backendToken } = options;
|
|
55
|
+
if (!appId)
|
|
56
|
+
throw new SendError(0, null, "send needs an appId (the account's app uuid)");
|
|
57
|
+
if (!backendToken)
|
|
58
|
+
throw new SendError(0, null, "send needs a backendToken (PILELY_BACKEND_TOKEN is not set)");
|
|
59
|
+
const origin = (options.origin ?? SIMPLE_EMAIL_ORIGIN).replace(/\/+$/, "");
|
|
60
|
+
const { to, idempotency_key } = input;
|
|
61
|
+
const body = "template_id" in input
|
|
62
|
+
? { to, template_id: input.template_id, variables: input.variables, idempotency_key }
|
|
63
|
+
: { to, subject: input.subject, html: input.html, idempotency_key };
|
|
64
|
+
const res = await fetch(`${origin}/accounts/${encodeURIComponent(appId)}/send`, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: {
|
|
67
|
+
authorization: `Bearer ${backendToken}`,
|
|
68
|
+
accept: "application/json",
|
|
69
|
+
"content-type": "application/json",
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify(body),
|
|
72
|
+
});
|
|
73
|
+
let json = null;
|
|
74
|
+
try {
|
|
75
|
+
json = await res.json();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// a bare answer has no body
|
|
79
|
+
}
|
|
80
|
+
const envelope = isObject(json) ? json : {};
|
|
81
|
+
if (res.status !== 200 || envelope.ok === false) {
|
|
82
|
+
const code = typeof envelope.code === "string" ? envelope.code : null;
|
|
83
|
+
const reason = typeof envelope.reason === "string" ? envelope.reason : `simple-email answered ${res.status}`;
|
|
84
|
+
throw new SendError(res.status, code, reason);
|
|
85
|
+
}
|
|
86
|
+
if (typeof envelope.send_id !== "string") {
|
|
87
|
+
throw new SendError(res.status, null, "simple-email answered 200 without a send_id");
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
send_id: envelope.send_id,
|
|
91
|
+
recipient_count: Number(envelope.recipient_count),
|
|
92
|
+
replayed: envelope.replayed === true,
|
|
93
|
+
};
|
|
94
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* refusal, including the two `idempotency_key_*` conflicts, checked
|
|
3
3
|
* against the service's `response_json.rs` and `render.rs`.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Status alone does not say what to do: `daily_cap_exceeded` and
|
|
6
|
+
* `rate_limited` BOTH answer 429, so a consumer cannot tell a spent UTC-day
|
|
7
|
+
* allowance (retry tomorrow) from a limiter policy (retry after
|
|
8
|
+
* `Retry-After`) by status alone — only by matching this code. */
|
|
9
9
|
export type SimpleEmailErrorCode = "unauthenticated" | "bad_forwarded_identity" | "bad_request" | "not_found" | "account_exists" | "account_limit_reached" | "template_limit_reached"
|
|
10
10
|
/** 409 — this `idempotency_key` already belongs to a different request
|
|
11
11
|
* (another body or another caller) on this app in the last 24 hours;
|
|
@@ -14,31 +14,27 @@ export type SimpleEmailErrorCode = "unauthenticated" | "bad_forwarded_identity"
|
|
|
14
14
|
/** 409 — an earlier request with this `idempotency_key` is still being
|
|
15
15
|
* handed to the provider; retry in a few seconds. */
|
|
16
16
|
| "idempotency_key_in_progress"
|
|
17
|
-
/** 429 —
|
|
18
|
-
|
|
17
|
+
/** 429 — a per-app UTC-day cap: the daily send cap on a template send,
|
|
18
|
+
* the 500-recipient freestyle cap on `send()`. */
|
|
19
|
+
| "daily_cap_exceeded" | "out_of_traffic_credits"
|
|
19
20
|
/** 429 — over a `@simple_limiter` policy the app's owner set on this
|
|
20
|
-
* app's sends; root answers it with a `Retry-After` header. */
|
|
21
|
-
| "rate_limited"
|
|
22
|
-
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
*
|
|
30
|
-
|
|
31
|
-
export type ToAccount = "self" | "owner";
|
|
21
|
+
* app's template sends; root answers it with a `Retry-After` header. */
|
|
22
|
+
| "rate_limited"
|
|
23
|
+
/** 403 — root's challenge check: a challenge policy the app's owner set
|
|
24
|
+
* on this route (for example the template send,
|
|
25
|
+
* `POST /accounts/{app_id}/templates/{template_id}/send`) matches the
|
|
26
|
+
* call, and it carried no `challenge`. Nothing was sent. */
|
|
27
|
+
| "challenge_required"
|
|
28
|
+
/** 403 — root's challenge check refused the `challenge` sent: malformed,
|
|
29
|
+
* wrong, expired, started through another app, or already used. Get and
|
|
30
|
+
* solve a new one (`@pilely/simple-limiter`) and send the call again. */
|
|
31
|
+
| "challenge_failed" | "internal";
|
|
32
32
|
/** `address` is derived at read time, never stored, and `null` when the
|
|
33
33
|
* app it names is gone. */
|
|
34
34
|
export interface Account {
|
|
35
35
|
app_id: string;
|
|
36
36
|
address: string | null;
|
|
37
37
|
display_name: string | null;
|
|
38
|
-
/** `null` means owner-only — the opposite polarity from simple_db's
|
|
39
|
-
* `read_group`/`write_group`, where `null` means everyone. */
|
|
40
|
-
send_group: string | null;
|
|
41
|
-
account_senders: AccountSenders;
|
|
42
38
|
/** The address recipients' replies go to: every send from the account
|
|
43
39
|
* carries it as its `Reply-To`. `null` means none — replies go nowhere. */
|
|
44
40
|
reply_to: string | null;
|
|
@@ -51,16 +47,25 @@ export interface AccountInfoEnvelope {
|
|
|
51
47
|
daily_cap: number;
|
|
52
48
|
sent_today: number | null;
|
|
53
49
|
credit_blocked: boolean | null;
|
|
54
|
-
phone_verified: boolean | null;
|
|
55
50
|
}
|
|
56
|
-
/** What `templates/create` and `templates/
|
|
51
|
+
/** What `templates/create`, `templates/update` and `templates/list` answer
|
|
52
|
+
* with — no `html`. */
|
|
57
53
|
export interface TemplateMeta {
|
|
58
54
|
template_id: string;
|
|
59
|
-
/** Not updatable through `updateTemplate
|
|
60
|
-
*
|
|
61
|
-
*
|
|
55
|
+
/** Not updatable through `updateTemplate`. `null` when the template was
|
|
56
|
+
* created without one: the service accepts a nameless template and
|
|
57
|
+
* returns the name back as `null`. */
|
|
62
58
|
name: string | null;
|
|
63
59
|
subject: string;
|
|
60
|
+
/** The ONE address every template send of this template goes to. The
|
|
61
|
+
* owner reads it here; no template-send answer ever carries it. */
|
|
62
|
+
recipient: string;
|
|
63
|
+
/** Who may make a template send of it: an owner group or one of the two
|
|
64
|
+
* platform-wide groups. `null` is empty — nobody may. */
|
|
65
|
+
send_group: string | null;
|
|
66
|
+
/** An owner group whose members are refused whatever `send_group` says.
|
|
67
|
+
* `null` is empty — nobody is refused. */
|
|
68
|
+
deny_group: string | null;
|
|
64
69
|
created_time_stamp: number;
|
|
65
70
|
updated_time_stamp: number;
|
|
66
71
|
}
|
|
@@ -74,31 +79,6 @@ export interface TemplateMeta {
|
|
|
74
79
|
export interface Template extends TemplateMeta {
|
|
75
80
|
html?: string;
|
|
76
81
|
}
|
|
77
|
-
/** One send-log row. An account send's row carries its `to_account`: for
|
|
78
|
-
* `"self"`, `recipients` is `[]` and `recipient_count` is `1` — the owner
|
|
79
|
-
* never sees a user's account email through this service; for `"owner"`,
|
|
80
|
-
* `recipients` holds the owner's own address. */
|
|
81
|
-
export interface SendRow {
|
|
82
|
-
send_id: string;
|
|
83
|
-
requester_user_id: string;
|
|
84
|
-
via_app: boolean;
|
|
85
|
-
kind: string;
|
|
86
|
-
template_id: string | null;
|
|
87
|
-
subject: string;
|
|
88
|
-
recipients: unknown;
|
|
89
|
-
recipient_count: number;
|
|
90
|
-
outcome: string;
|
|
91
|
-
outcome_reason: string | null;
|
|
92
|
-
/** `null` for a raw send. */
|
|
93
|
-
to_account: ToAccount | null;
|
|
94
|
-
/** The caller's `idempotency_key`, or `null` when the send had none. */
|
|
95
|
-
idempotency_key: string | null;
|
|
96
|
-
fired_at: number;
|
|
97
|
-
}
|
|
98
|
-
export interface SendCursor {
|
|
99
|
-
after_created_time_stamp: number;
|
|
100
|
-
after_send_id: string;
|
|
101
|
-
}
|
|
102
82
|
export interface AccountCursor {
|
|
103
83
|
after_created_time_stamp: number;
|
|
104
84
|
after_app_id: string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pilely/simple-email",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "React hooks for the simple_email service.",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "React hooks and the template send for the simple_email service, and send() for an app's backend.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
".": {
|
|
11
11
|
"types": "./dist/index.d.ts",
|
|
12
12
|
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./send": {
|
|
15
|
+
"types": "./dist/send.d.ts",
|
|
16
|
+
"default": "./dist/send.js"
|
|
13
17
|
}
|
|
14
18
|
},
|
|
15
19
|
"sideEffects": false,
|
|
@@ -21,7 +25,7 @@
|
|
|
21
25
|
"directory": "npm/simple-email"
|
|
22
26
|
},
|
|
23
27
|
"peerDependencies": {
|
|
24
|
-
"@pilely/core": "^0.4.
|
|
28
|
+
"@pilely/core": "^0.4.4",
|
|
25
29
|
"@tanstack/react-query": "^5.0.0",
|
|
26
30
|
"react": "^18.3.0 || ^19.0.0"
|
|
27
31
|
},
|