@firedrill-tools/resend 0.1.1
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 +201 -0
- package/README.md +198 -0
- package/firedrill/agent.target.json +16 -0
- package/firedrill/baseline.scenario.json +1687 -0
- package/firedrill/conformance.suite.json +19 -0
- package/firedrill/contacts-outage.scenario.json +11 -0
- package/firedrill/quota-exhausted.scenario.json +17 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/resend-audience.drill.json +407 -0
- package/firedrill/resend-contacts-outage.drill.json +63 -0
- package/firedrill/resend-denied.drill.json +68 -0
- package/firedrill/resend-domains-and-keys.drill.json +307 -0
- package/firedrill/resend-email-flow.drill.json +286 -0
- package/firedrill/resend-fresh-install.drill.json +73 -0
- package/firedrill/resend-invalid-key.drill.json +803 -0
- package/firedrill/resend-large-page.drill.json +53 -0
- package/firedrill/resend-quota-exhausted.drill.json +82 -0
- package/firedrill/resend-rate-limited.drill.json +81 -0
- package/firedrill/resend-restricted-key.drill.json +441 -0
- package/firedrill/resend-tight-limits.drill.json +163 -0
- package/firedrill/tight-limits.scenario.json +16 -0
- package/firedrill/tools/resend/app/assets/ATTRIBUTION.md +30 -0
- package/firedrill/tools/resend/app/assets/fonts/Inter-OFL.txt +93 -0
- package/firedrill/tools/resend/app/assets/fonts/JetBrainsMono-OFL.txt +93 -0
- package/firedrill/tools/resend/app/assets/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/firedrill/tools/resend/app/assets/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/firedrill/tools/resend/app/assets/resend-icon-white.svg +3 -0
- package/firedrill/tools/resend/app/assets/resend-wordmark-white.svg +3 -0
- package/firedrill/tools/resend/app/assets/resend.svg +3 -0
- package/firedrill/tools/resend/app/site/app.js +90 -0
- package/firedrill/tools/resend/app/site/assets/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/firedrill/tools/resend/app/site/assets/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/firedrill/tools/resend/app/site/assets/resend-icon-white.svg +3 -0
- package/firedrill/tools/resend/app/site/assets/resend-wordmark-white.svg +3 -0
- package/firedrill/tools/resend/app/site/assets/resend.svg +3 -0
- package/firedrill/tools/resend/app/site/contact-panel.js +86 -0
- package/firedrill/tools/resend/app/site/icons.js +70 -0
- package/firedrill/tools/resend/app/site/index.html +56 -0
- package/firedrill/tools/resend/app/site/list.js +41 -0
- package/firedrill/tools/resend/app/site/styles.css +67 -0
- package/firedrill/tools/resend/app/site/ui.js +259 -0
- package/firedrill/tools/resend/app/site/view-audience.js +160 -0
- package/firedrill/tools/resend/app/site/view-domains.js +106 -0
- package/firedrill/tools/resend/app/site/view-email-detail.js +126 -0
- package/firedrill/tools/resend/app/site/view-emails.js +83 -0
- package/firedrill/tools/resend/app/site/view-keys.js +91 -0
- package/firedrill/tools/resend/app/site/views.css +141 -0
- package/firedrill/tools/resend/behavior.mjs +114 -0
- package/firedrill/tools/resend/lib/check.mjs +75 -0
- package/firedrill/tools/resend/lib/core.mjs +112 -0
- package/firedrill/tools/resend/lib/json-depth.mjs +45 -0
- package/firedrill/tools/resend/lib/page.mjs +66 -0
- package/firedrill/tools/resend/lib/time.mjs +60 -0
- package/firedrill/tools/resend/lib/wire.mjs +98 -0
- package/firedrill/tools/resend/ops/contacts.mjs +199 -0
- package/firedrill/tools/resend/ops/domains.mjs +125 -0
- package/firedrill/tools/resend/ops/email-model.mjs +100 -0
- package/firedrill/tools/resend/ops/emails.mjs +139 -0
- package/firedrill/tools/resend/ops/keys-segments.mjs +97 -0
- package/firedrill/tools/resend/resend.tool.json +4804 -0
- package/firedrill/world.json +2148 -0
- package/firedrill.json +5 -0
- package/package.json +62 -0
- package/starter.json +1686 -0
- package/test/conformance.mjs +480 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Pure HTTP wire helpers: snake_case → canonical argument mapping, Idempotency-Key handling and Resend's error
|
|
2
|
+
// envelope `{ statusCode, message, name }`. No state, no clock. HTTP statuses are declared per route in the manifest.
|
|
3
|
+
|
|
4
|
+
const ERRORS = new Map([
|
|
5
|
+
["VALIDATION_ERROR", [422, "validation_error"]],
|
|
6
|
+
["MISSING_REQUIRED_FIELD", [422, "missing_required_field"]],
|
|
7
|
+
["INVALID_PARAMETER", [422, "invalid_parameter"]],
|
|
8
|
+
["INVALID_IDEMPOTENCY_KEY", [400, "invalid_idempotency_key"]],
|
|
9
|
+
["RESTRICTED_API_KEY", [401, "restricted_api_key"]],
|
|
10
|
+
["INVALID_API_KEY", [403, "invalid_api_key"]],
|
|
11
|
+
["DOMAIN_NOT_VERIFIED", [403, "validation_error"]],
|
|
12
|
+
["NOT_FOUND", [404, "not_found"]],
|
|
13
|
+
["DAILY_QUOTA_EXCEEDED", [429, "daily_quota_exceeded"]],
|
|
14
|
+
["RATE_LIMIT_EXCEEDED", [429, "rate_limit_exceeded"]],
|
|
15
|
+
["APPLICATION_ERROR", [500, "application_error"]],
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export const ERROR_STATUS = ERRORS;
|
|
19
|
+
|
|
20
|
+
function last(values) {
|
|
21
|
+
return Array.isArray(values) && values.length > 0 ? values[values.length - 1] : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function header(request, name) {
|
|
25
|
+
return last(request.headers[name]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function query(request, name) {
|
|
29
|
+
return last(request.query[name]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isPlainObject(value) {
|
|
33
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function jsonObject(request) {
|
|
37
|
+
return request.body.kind === "json" && isPlainObject(request.body.value) ? request.body.value : {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Copy `[wireName, argumentName]` pairs that are present in `source` (own properties only) into `target`. */
|
|
41
|
+
export function pick(source, pairs, target = {}) {
|
|
42
|
+
for (const [wire, name] of pairs) {
|
|
43
|
+
if (Object.hasOwn(source, wire) && source[wire] !== undefined) target[name] = source[wire];
|
|
44
|
+
}
|
|
45
|
+
return target;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Pagination query parameters: raw strings; the handler validates them and answers Resend's 422. */
|
|
49
|
+
export function pageArguments(request, target = {}) {
|
|
50
|
+
for (const name of ["limit", "after", "before"]) {
|
|
51
|
+
const value = query(request, name);
|
|
52
|
+
if (value !== undefined) target[name] = value;
|
|
53
|
+
}
|
|
54
|
+
return target;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `Idempotency-Key`: 1–128 characters become the framework idempotency key; 129–256 characters are accepted without
|
|
59
|
+
* deduplication (the framework's evidence records keys up to 128 characters); an empty or longer key travels as an
|
|
60
|
+
* argument so the handler answers 400 `invalid_idempotency_key`.
|
|
61
|
+
*/
|
|
62
|
+
export function operationInput(request, args) {
|
|
63
|
+
const key = header(request, "idempotency-key");
|
|
64
|
+
if (key === undefined) return { arguments: args };
|
|
65
|
+
if (key.length >= 1 && key.length <= 128) return { arguments: args, idempotencyKey: key };
|
|
66
|
+
if (key.length >= 1 && key.length <= 256) return { arguments: args };
|
|
67
|
+
return { arguments: { ...args, idempotencyKey: key } };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Contacts accept a UUID or an e-mail address in the same path position. */
|
|
71
|
+
export function contactRef(value, idName, target = {}) {
|
|
72
|
+
if (typeof value === "string" && value.includes("@")) target.email = value;
|
|
73
|
+
else target[idName] = value;
|
|
74
|
+
return target;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function resendError(outcome) {
|
|
78
|
+
const error = outcome.error ?? {};
|
|
79
|
+
const message = typeof error.message === "string" && error.message.length > 0 ? error.message : "Internal server error";
|
|
80
|
+
if (outcome.status === "denied") {
|
|
81
|
+
return { statusCode: 403, message: "This operation is not granted to the calling actor in this Firedrill world.", name: "invalid_access" };
|
|
82
|
+
}
|
|
83
|
+
if (outcome.status === "unsupported") return { statusCode: 404, message: "Not found", name: "not_found" };
|
|
84
|
+
// HTTP status of `invalid` outcomes is framework-owned (400), so the body keeps 400 to match it.
|
|
85
|
+
if (outcome.status === "invalid") return { statusCode: 400, message: `Invalid request: ${message}`, name: "validation_error" };
|
|
86
|
+
const code = String(error.code ?? "").replace(/^tool\./, "");
|
|
87
|
+
const [statusCode, name] = ERRORS.get(code) ?? [500, "application_error"];
|
|
88
|
+
return { statusCode, message, name };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function encodeOutcome({ outcome }) {
|
|
92
|
+
if (outcome.status === "ok") return { body: { kind: "json", value: outcome.value } };
|
|
93
|
+
const body = resendError(outcome);
|
|
94
|
+
const headers = body.name === "rate_limit_exceeded"
|
|
95
|
+
? { "ratelimit-limit": "10", "ratelimit-remaining": "0", "ratelimit-reset": "1", "retry-after": "1" }
|
|
96
|
+
: undefined;
|
|
97
|
+
return { ...(headers === undefined ? {} : { headers }), body: { kind: "json", value: body } };
|
|
98
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Team-wide contacts with segment membership (pairs in `segment-members` and `contact-segments`).
|
|
2
|
+
import { checkIdempotencyKey, fail, newId, normalizeUuid, requireUuid, resolveKey, scanAll } from "../lib/core.mjs";
|
|
3
|
+
import { parseAddress, present, safeObject } from "../lib/check.mjs";
|
|
4
|
+
import { paginate } from "../lib/page.mjs";
|
|
5
|
+
import { wireTime } from "../lib/time.mjs";
|
|
6
|
+
import { renderSegment } from "./keys-segments.mjs";
|
|
7
|
+
|
|
8
|
+
const PROPERTY_KEY = /^[a-z][a-z0-9_]{0,49}$/;
|
|
9
|
+
|
|
10
|
+
/** A bare e-mail address (no display name), lowercased, or `null`. */
|
|
11
|
+
function plainAddress(value) {
|
|
12
|
+
if (typeof value !== "string") return null;
|
|
13
|
+
const parsed = parseAddress(value);
|
|
14
|
+
return parsed === null || parsed.address !== value.trim() ? null : parsed.address.toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function nameField(context, input, field, label) {
|
|
18
|
+
if (!Object.hasOwn(input, field)) return undefined;
|
|
19
|
+
const value = input[field];
|
|
20
|
+
if (value === null) return null;
|
|
21
|
+
if (typeof value !== "string" || value.length > 100) fail(context, "VALIDATION_ERROR", `The \`${label}\` field must be at most 100 characters.`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mergeProperties(context, base, patch) {
|
|
26
|
+
const source = safeObject(context, patch, "properties", 20);
|
|
27
|
+
const merged = { ...base };
|
|
28
|
+
for (const key of Object.keys(source)) {
|
|
29
|
+
const value = source[key];
|
|
30
|
+
const ok = PROPERTY_KEY.test(key) && (value === null || (typeof value === "number" && Number.isFinite(value)) || (typeof value === "string" && value.length <= 500));
|
|
31
|
+
if (!ok) fail(context, "VALIDATION_ERROR", "Contact properties need lowercase keys and string (≤ 500 characters), number or null values.");
|
|
32
|
+
merged[key] = value;
|
|
33
|
+
}
|
|
34
|
+
if (Object.keys(merged).length > 20) fail(context, "VALIDATION_ERROR", "A contact can hold at most 20 properties.");
|
|
35
|
+
return merged;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function locate(context, input, idField) {
|
|
39
|
+
const hasId = present(input[idField]);
|
|
40
|
+
const hasEmail = present(input.email);
|
|
41
|
+
if (hasId && hasEmail) fail(context, "INVALID_PARAMETER", `Provide either \`${idField === "id" ? "id" : "contact_id"}\` or \`email\`, not both.`);
|
|
42
|
+
if (!hasId && !hasEmail) fail(context, "MISSING_REQUIRED_FIELD", "Missing `id` or `email` field.");
|
|
43
|
+
let id;
|
|
44
|
+
if (hasId) {
|
|
45
|
+
id = requireUuid(context, input[idField], idField === "id" ? "id" : "contact_id");
|
|
46
|
+
} else {
|
|
47
|
+
const email = plainAddress(input.email);
|
|
48
|
+
if (email === null) fail(context, "INVALID_PARAMETER", "The `email` must be a valid email address.");
|
|
49
|
+
const index = context.state.get("contact-emails", email);
|
|
50
|
+
if (index === null) fail(context, "NOT_FOUND", "Contact not found");
|
|
51
|
+
id = index.contactId;
|
|
52
|
+
}
|
|
53
|
+
const contact = context.state.get("contacts", id);
|
|
54
|
+
if (contact === null) fail(context, "NOT_FOUND", "Contact not found");
|
|
55
|
+
return contact;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function locateSegment(context, raw) {
|
|
59
|
+
if (!present(raw)) fail(context, "MISSING_REQUIRED_FIELD", "Missing `segment_id` field.");
|
|
60
|
+
const id = requireUuid(context, raw, "segment_id");
|
|
61
|
+
const segment = context.state.get("segments", id);
|
|
62
|
+
if (segment === null) fail(context, "NOT_FOUND", "Segment not found");
|
|
63
|
+
return segment;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function join(context, contactId, segmentId, nowUs) {
|
|
67
|
+
context.state.put("segment-members", `${segmentId}/${contactId}`, { segmentId, contactId, addedAtUs: nowUs });
|
|
68
|
+
context.state.put("contact-segments", `${contactId}/${segmentId}`, { segmentId, contactId });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function summary(contact) {
|
|
72
|
+
return { id: contact.id, email: contact.email, first_name: contact.firstName, last_name: contact.lastName, created_at: wireTime(contact.createdAtUs), unsubscribed: contact.unsubscribed };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function contactsCreate(input, context) {
|
|
76
|
+
resolveKey(context);
|
|
77
|
+
checkIdempotencyKey(context, input);
|
|
78
|
+
if (!present(input.email)) fail(context, "MISSING_REQUIRED_FIELD", "Missing `email` field.");
|
|
79
|
+
const email = plainAddress(input.email);
|
|
80
|
+
if (email === null) fail(context, "VALIDATION_ERROR", "Invalid `email` field. The email address needs to follow the `email@example.com` format.");
|
|
81
|
+
for (const [field, label] of [["topics", "topics"], ["audienceId", "audience_id"]]) {
|
|
82
|
+
if (present(input[field])) fail(context, "VALIDATION_ERROR", `The \`${label}\` field is not supported by this simulated service.`);
|
|
83
|
+
}
|
|
84
|
+
const firstName = nameField(context, input, "firstName", "first_name") ?? null;
|
|
85
|
+
const lastName = nameField(context, input, "lastName", "last_name") ?? null;
|
|
86
|
+
const properties = present(input.properties) ? mergeProperties(context, {}, input.properties) : {};
|
|
87
|
+
const segmentIds = [];
|
|
88
|
+
if (present(input.segmentIds)) {
|
|
89
|
+
if (!Array.isArray(input.segmentIds) || input.segmentIds.length > 20) fail(context, "VALIDATION_ERROR", "The `segments` field accepts at most 20 segments.");
|
|
90
|
+
for (const raw of input.segmentIds) {
|
|
91
|
+
const id = normalizeUuid(raw);
|
|
92
|
+
if (id === null || context.state.get("segments", id) === null) fail(context, "VALIDATION_ERROR", "Every entry of `segments` must name an existing segment.");
|
|
93
|
+
if (!segmentIds.includes(id)) segmentIds.push(id);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (context.state.get("contact-emails", email) !== null) fail(context, "VALIDATION_ERROR", "Contact already exists");
|
|
97
|
+
const id = newId(context, "contacts");
|
|
98
|
+
const nowUs = context.clock.nowUs();
|
|
99
|
+
context.state.put("contacts", id, { id, email, firstName, lastName, unsubscribed: input.unsubscribed === true, properties, createdAtUs: nowUs });
|
|
100
|
+
context.state.put("contact-emails", email, { contactId: id });
|
|
101
|
+
for (const segmentId of segmentIds) join(context, id, segmentId, nowUs);
|
|
102
|
+
return { object: "contact", id };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function contactsList(input, context) {
|
|
106
|
+
resolveKey(context);
|
|
107
|
+
let query = null;
|
|
108
|
+
if (present(input.query)) {
|
|
109
|
+
if (input.query.length > 200 || input.query.includes("�")) fail(context, "VALIDATION_ERROR", "The `query` parameter is invalid.");
|
|
110
|
+
query = input.query.trim().toLowerCase();
|
|
111
|
+
}
|
|
112
|
+
let rows;
|
|
113
|
+
if (present(input.segmentId)) {
|
|
114
|
+
const segment = locateSegment(context, input.segmentId);
|
|
115
|
+
rows = scanAll(context, "segment-members", `${segment.id}/`).map((member) => context.state.get("contacts", member.value.contactId)).filter((contact) => contact !== null);
|
|
116
|
+
} else {
|
|
117
|
+
rows = scanAll(context, "contacts").map((record) => record.value);
|
|
118
|
+
}
|
|
119
|
+
if (query !== null) {
|
|
120
|
+
rows = rows.filter((contact) => [contact.email, contact.firstName ?? "", contact.lastName ?? ""].some((text) => text.toLowerCase().includes(query)));
|
|
121
|
+
}
|
|
122
|
+
return paginate(context, input, rows, summary);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function contactsGet(input, context) {
|
|
126
|
+
resolveKey(context);
|
|
127
|
+
const contact = locate(context, input, "id");
|
|
128
|
+
return { object: "contact", ...summary(contact), properties: contact.properties };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function contactsUpdate(input, context) {
|
|
132
|
+
resolveKey(context);
|
|
133
|
+
checkIdempotencyKey(context, input);
|
|
134
|
+
const contact = locate(context, input, "id");
|
|
135
|
+
const next = { ...contact };
|
|
136
|
+
const firstName = nameField(context, input, "firstName", "first_name");
|
|
137
|
+
const lastName = nameField(context, input, "lastName", "last_name");
|
|
138
|
+
if (firstName !== undefined) next.firstName = firstName;
|
|
139
|
+
if (lastName !== undefined) next.lastName = lastName;
|
|
140
|
+
if (Object.hasOwn(input, "unsubscribed") && input.unsubscribed !== null) next.unsubscribed = input.unsubscribed === true;
|
|
141
|
+
if (present(input.properties)) next.properties = mergeProperties(context, contact.properties, input.properties);
|
|
142
|
+
if (present(input.newEmail)) {
|
|
143
|
+
const email = plainAddress(input.newEmail);
|
|
144
|
+
if (email === null) fail(context, "VALIDATION_ERROR", "Invalid `email` field. The email address needs to follow the `email@example.com` format.");
|
|
145
|
+
if (email !== contact.email) {
|
|
146
|
+
if (context.state.get("contact-emails", email) !== null) fail(context, "VALIDATION_ERROR", "Contact already exists");
|
|
147
|
+
context.state.delete("contact-emails", contact.email);
|
|
148
|
+
context.state.put("contact-emails", email, { contactId: contact.id });
|
|
149
|
+
next.email = email;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
context.state.put("contacts", contact.id, next);
|
|
153
|
+
return { object: "contact", id: contact.id };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function contactsRemove(input, context) {
|
|
157
|
+
resolveKey(context);
|
|
158
|
+
checkIdempotencyKey(context, input);
|
|
159
|
+
const contact = locate(context, input, "id");
|
|
160
|
+
for (const link of scanAll(context, "contact-segments", `${contact.id}/`)) {
|
|
161
|
+
context.state.delete("contact-segments", link.rowId);
|
|
162
|
+
context.state.delete("segment-members", `${link.value.segmentId}/${contact.id}`);
|
|
163
|
+
}
|
|
164
|
+
context.state.delete("contact-emails", contact.email);
|
|
165
|
+
context.state.delete("contacts", contact.id);
|
|
166
|
+
return { object: "contact", id: contact.id, deleted: true };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function contactsAddSegment(input, context) {
|
|
170
|
+
resolveKey(context);
|
|
171
|
+
checkIdempotencyKey(context, input);
|
|
172
|
+
const contact = locate(context, input, "contactId");
|
|
173
|
+
const segment = locateSegment(context, input.segmentId);
|
|
174
|
+
if (context.state.get("contact-segments", `${contact.id}/${segment.id}`) === null) {
|
|
175
|
+
join(context, contact.id, segment.id, context.clock.nowUs());
|
|
176
|
+
}
|
|
177
|
+
return { object: "contact_segment", contact_id: contact.id, segment_id: segment.id };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function contactsRemoveSegment(input, context) {
|
|
181
|
+
resolveKey(context);
|
|
182
|
+
checkIdempotencyKey(context, input);
|
|
183
|
+
const contact = locate(context, input, "contactId");
|
|
184
|
+
const segment = locateSegment(context, input.segmentId);
|
|
185
|
+
if (!context.state.delete("contact-segments", `${contact.id}/${segment.id}`)) {
|
|
186
|
+
fail(context, "NOT_FOUND", "Contact is not a member of this segment");
|
|
187
|
+
}
|
|
188
|
+
context.state.delete("segment-members", `${segment.id}/${contact.id}`);
|
|
189
|
+
return { object: "contact_segment", contact_id: contact.id, segment_id: segment.id, deleted: true };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function contactsListSegments(input, context) {
|
|
193
|
+
resolveKey(context);
|
|
194
|
+
const contact = locate(context, input, "contactId");
|
|
195
|
+
const rows = scanAll(context, "contact-segments", `${contact.id}/`)
|
|
196
|
+
.map((link) => context.state.get("segments", link.value.segmentId))
|
|
197
|
+
.filter((segment) => segment !== null);
|
|
198
|
+
return paginate(context, input, rows, renderSegment);
|
|
199
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Domains: create with generated DNS records, list, get, instant synthetic verification, remove.
|
|
2
|
+
import { checkIdempotencyKey, fail, newId, randomChars, requireUuid, resolveKey, scanAll } from "../lib/core.mjs";
|
|
3
|
+
import { present, safeObject } from "../lib/check.mjs";
|
|
4
|
+
import { paginate } from "../lib/page.mjs";
|
|
5
|
+
import { wireTime } from "../lib/time.mjs";
|
|
6
|
+
|
|
7
|
+
const REGIONS = ["us-east-1", "eu-west-1", "sa-east-1", "ap-northeast-1"];
|
|
8
|
+
const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
9
|
+
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
10
|
+
|
|
11
|
+
function oneOf(context, value, allowed, field, fallback) {
|
|
12
|
+
if (value === undefined || value === null) return fallback;
|
|
13
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
14
|
+
fail(context, "VALIDATION_ERROR", `The \`${field}\` field must be one of: ${allowed.join(", ")}.`);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function validHostname(name) {
|
|
20
|
+
if (name.length < 4 || name.length > 253) return false;
|
|
21
|
+
const labels = name.split(".");
|
|
22
|
+
return labels.length >= 2 && labels.every((label) => LABEL.test(label)) && !/^[0-9]+$/.test(labels[labels.length - 1]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getDomain(context, rawId) {
|
|
26
|
+
const id = requireUuid(context, rawId);
|
|
27
|
+
const domain = context.state.get("domains", id);
|
|
28
|
+
if (domain === null) fail(context, "NOT_FOUND", "Domain not found");
|
|
29
|
+
return domain;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function renderRecord(record) {
|
|
33
|
+
const out = { record: record.record, name: record.name, type: record.type, ttl: record.ttl, status: record.status, value: record.value };
|
|
34
|
+
if (record.priority !== null) out.priority = record.priority;
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function summary(domain) {
|
|
39
|
+
return {
|
|
40
|
+
id: domain.id,
|
|
41
|
+
name: domain.name,
|
|
42
|
+
status: domain.status,
|
|
43
|
+
created_at: wireTime(domain.createdAtUs),
|
|
44
|
+
region: domain.region,
|
|
45
|
+
open_tracking: domain.openTracking,
|
|
46
|
+
click_tracking: domain.clickTracking,
|
|
47
|
+
capabilities: domain.capabilities,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function domainsCreate(input, context) {
|
|
52
|
+
resolveKey(context);
|
|
53
|
+
checkIdempotencyKey(context, input);
|
|
54
|
+
if (!present(input.name)) fail(context, "MISSING_REQUIRED_FIELD", "Missing `name` field.");
|
|
55
|
+
const name = input.name.trim().toLowerCase();
|
|
56
|
+
if (!validHostname(name)) fail(context, "VALIDATION_ERROR", "The `name` field must be a valid domain name.");
|
|
57
|
+
if (present(input.trackingSubdomain)) fail(context, "VALIDATION_ERROR", "The `tracking_subdomain` field is not supported by this simulated service.");
|
|
58
|
+
const region = oneOf(context, input.region, REGIONS, "region", "us-east-1");
|
|
59
|
+
const tls = oneOf(context, input.tls, ["opportunistic", "enforced"], "tls", "opportunistic");
|
|
60
|
+
let customReturnPath = "send";
|
|
61
|
+
if (present(input.customReturnPath)) {
|
|
62
|
+
customReturnPath = String(input.customReturnPath).toLowerCase();
|
|
63
|
+
if (customReturnPath.length > 63 || !LABEL.test(customReturnPath)) fail(context, "VALIDATION_ERROR", "The `custom_return_path` field must be a single DNS label.");
|
|
64
|
+
}
|
|
65
|
+
const capabilities = { sending: "enabled", receiving: "disabled" };
|
|
66
|
+
if (present(input.capabilities)) {
|
|
67
|
+
const source = safeObject(context, input.capabilities, "capabilities", 2);
|
|
68
|
+
for (const key of Object.keys(source)) {
|
|
69
|
+
if (key !== "sending" && key !== "receiving") fail(context, "VALIDATION_ERROR", "The `capabilities` field accepts only `sending` and `receiving`.");
|
|
70
|
+
capabilities[key] = oneOf(context, source[key], ["enabled", "disabled"], `capabilities.${key}`, capabilities[key]);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (context.state.get("domain-names", name) !== null) fail(context, "VALIDATION_ERROR", `The ${name} domain has been registered already`);
|
|
74
|
+
const id = newId(context, "domains");
|
|
75
|
+
const host = `${customReturnPath}.${name}`;
|
|
76
|
+
const records = [
|
|
77
|
+
{ record: "SPF", name: host, type: "MX", ttl: "Auto", status: "not_started", value: `feedback-smtp.${region}.amazonses.com`, priority: 10 },
|
|
78
|
+
{ record: "SPF", name: host, type: "TXT", ttl: "Auto", status: "not_started", value: '"v=spf1 include:amazonses.com ~all"', priority: null },
|
|
79
|
+
{ record: "DKIM", name: `resend._domainkey.${name}`, type: "TXT", ttl: "Auto", status: "not_started", value: `p=${randomChars(context, B64, 216)}`, priority: null },
|
|
80
|
+
];
|
|
81
|
+
if (capabilities.receiving === "enabled") {
|
|
82
|
+
records.push({ record: "Receiving", name, type: "MX", ttl: "Auto", status: "not_started", value: `inbound-smtp.${region}.amazonaws.com`, priority: 10 });
|
|
83
|
+
}
|
|
84
|
+
const domain = {
|
|
85
|
+
id, name, region, status: "not_started",
|
|
86
|
+
openTracking: input.openTracking === true, clickTracking: input.clickTracking === true,
|
|
87
|
+
tls, customReturnPath, capabilities, records, createdAtUs: context.clock.nowUs(),
|
|
88
|
+
};
|
|
89
|
+
context.state.put("domains", id, domain);
|
|
90
|
+
context.state.put("domain-names", name, { domainId: id });
|
|
91
|
+
const { capabilities: caps, ...rest } = summary(domain);
|
|
92
|
+
return { ...rest, capabilities: caps, records: records.map(renderRecord) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function domainsList(input, context) {
|
|
96
|
+
resolveKey(context);
|
|
97
|
+
const rows = scanAll(context, "domains").map((record) => record.value);
|
|
98
|
+
return paginate(context, input, rows, summary);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function domainsGet(input, context) {
|
|
102
|
+
resolveKey(context);
|
|
103
|
+
const domain = getDomain(context, input.id);
|
|
104
|
+
return { object: "domain", ...summary(domain), records: domain.records.map(renderRecord) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function domainsVerify(input, context) {
|
|
108
|
+
resolveKey(context);
|
|
109
|
+
checkIdempotencyKey(context, input);
|
|
110
|
+
const domain = getDomain(context, input.id);
|
|
111
|
+
if (domain.status !== "verified") {
|
|
112
|
+
const status = domain.name.endsWith(".invalid") ? "failed" : "verified";
|
|
113
|
+
context.state.put("domains", domain.id, { ...domain, status, records: domain.records.map((record) => ({ ...record, status })) });
|
|
114
|
+
}
|
|
115
|
+
return { object: "domain", id: domain.id };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function domainsRemove(input, context) {
|
|
119
|
+
resolveKey(context);
|
|
120
|
+
checkIdempotencyKey(context, input);
|
|
121
|
+
const domain = getDomain(context, input.id);
|
|
122
|
+
context.state.delete("domains", domain.id);
|
|
123
|
+
context.state.delete("domain-names", domain.name);
|
|
124
|
+
return { object: "domain", id: domain.id, deleted: true };
|
|
125
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Email validation, the synthetic delivery rule and wire rendering shared by the email operations.
|
|
2
|
+
import { fail } from "../lib/core.mjs";
|
|
3
|
+
import { addressList, boundedString, parseAddress, present, rejectPresent, safeObject, utf8Bytes } from "../lib/check.mjs";
|
|
4
|
+
import { parseTimestamp, US_PER_DAY, wireTime } from "../lib/time.mjs";
|
|
5
|
+
|
|
6
|
+
const TAG = /^[A-Za-z0-9_-]{1,256}$/;
|
|
7
|
+
const MAX_BODY_BYTES = 512_000;
|
|
8
|
+
export const MAX_SCHEDULE_US = 30 * US_PER_DAY;
|
|
9
|
+
|
|
10
|
+
/** A recipient in the reserved `.invalid` TLD makes the synthetic delivery bounce. */
|
|
11
|
+
export function bounces(record) {
|
|
12
|
+
return [...record.to, ...record.cc, ...record.bcc].some((entry) => {
|
|
13
|
+
const parsed = parseAddress(entry);
|
|
14
|
+
return parsed !== null && (parsed.domain === "invalid" || parsed.domain.endsWith(".invalid"));
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `last_event` as a reader sees it at `nowUs`: a due scheduled email renders its synthetic outcome. */
|
|
19
|
+
export function lastEvent(record, nowUs) {
|
|
20
|
+
if (record.status === "scheduled" && record.scheduledAtUs !== null && record.scheduledAtUs <= nowUs) {
|
|
21
|
+
return bounces(record) ? "bounced" : "delivered";
|
|
22
|
+
}
|
|
23
|
+
return record.status;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function scheduleTime(context, raw, field = "scheduled_at") {
|
|
27
|
+
const at = parseTimestamp(raw);
|
|
28
|
+
const now = context.clock.nowUs();
|
|
29
|
+
if (at === null) fail(context, "VALIDATION_ERROR", `The \`${field}\` field must be an ISO 8601 timestamp with a time zone.`);
|
|
30
|
+
if (at <= now) fail(context, "VALIDATION_ERROR", `The \`${field}\` field must be in the future.`);
|
|
31
|
+
if (at > now + MAX_SCHEDULE_US) fail(context, "VALIDATION_ERROR", `The \`${field}\` field must be within 30 days.`);
|
|
32
|
+
return at;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Validate one send input (canonical camelCase). `where` prefixes messages inside a batch. */
|
|
36
|
+
export function validateSend(context, input, { batch = false, index = 0 } = {}) {
|
|
37
|
+
const at = batch ? `emails[${index}]: ` : "";
|
|
38
|
+
const scoped = (code, message) => fail(context, code, at + message);
|
|
39
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) scoped("VALIDATION_ERROR", "Each email must be an object.");
|
|
40
|
+
rejectPresent({ fail: (o) => scoped(o.code, o.message) }, input, ["attachments", "template", "topicId"], (field) => `The \`${field}\` field is not supported by this simulated service.`);
|
|
41
|
+
if (batch && present(input.scheduledAt)) scoped("VALIDATION_ERROR", "`scheduled_at` is not supported in batch");
|
|
42
|
+
if (!present(input.from)) scoped("MISSING_REQUIRED_FIELD", "Missing `from` field.");
|
|
43
|
+
const sender = parseAddress(input.from);
|
|
44
|
+
if (sender === null) scoped("VALIDATION_ERROR", "Invalid `from` field. The email address needs to follow the `email@example.com` or `Name <email@example.com>` format.");
|
|
45
|
+
const wrap = { fail: (o) => scoped(o.code, o.message) };
|
|
46
|
+
const to = addressList(wrap, input.to, "to", { required: true });
|
|
47
|
+
const cc = addressList(wrap, input.cc, "cc");
|
|
48
|
+
const bcc = addressList(wrap, input.bcc, "bcc");
|
|
49
|
+
const replyTo = addressList(wrap, input.replyTo, "reply_to");
|
|
50
|
+
if (!present(input.subject)) scoped("MISSING_REQUIRED_FIELD", "Missing `subject` field.");
|
|
51
|
+
boundedString(wrap, input.subject, "subject", { max: 998 });
|
|
52
|
+
const html = typeof input.html === "string" && input.html.length > 0 ? input.html : null;
|
|
53
|
+
const text = typeof input.text === "string" && input.text.length > 0 ? input.text : null;
|
|
54
|
+
if (html === null && text === null) scoped("MISSING_REQUIRED_FIELD", "Missing `html` or `text` field.");
|
|
55
|
+
for (const [name, body] of [["html", html], ["text", text]]) {
|
|
56
|
+
if (body !== null && utf8Bytes(body) > MAX_BODY_BYTES) scoped("VALIDATION_ERROR", `The \`${name}\` field exceeds ${MAX_BODY_BYTES} bytes.`);
|
|
57
|
+
}
|
|
58
|
+
const headers = {};
|
|
59
|
+
if (present(input.headers)) {
|
|
60
|
+
const source = safeObject(wrap, input.headers, "headers", 30);
|
|
61
|
+
for (const name of Object.keys(source)) {
|
|
62
|
+
if (!/^[!-9;-~]{1,256}$/.test(name) || typeof source[name] !== "string" || source[name].length > 998) {
|
|
63
|
+
scoped("VALIDATION_ERROR", "The `headers` field must map header names to string values.");
|
|
64
|
+
}
|
|
65
|
+
headers[name] = source[name];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const tags = [];
|
|
69
|
+
if (present(input.tags)) {
|
|
70
|
+
if (!Array.isArray(input.tags) || input.tags.length > 10) scoped("VALIDATION_ERROR", "The `tags` field must be an array of at most 10 tags.");
|
|
71
|
+
for (const tag of input.tags) {
|
|
72
|
+
const ok = typeof tag === "object" && tag !== null && typeof tag.name === "string" && typeof tag.value === "string" && TAG.test(tag.name) && TAG.test(tag.value);
|
|
73
|
+
if (!ok) scoped("VALIDATION_ERROR", "Tags should only contain ASCII letters, numbers, underscores, or dashes.");
|
|
74
|
+
tags.push({ name: tag.name, value: tag.value });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const scheduledAtUs = !batch && present(input.scheduledAt) ? scheduleTime(context, input.scheduledAt) : null;
|
|
78
|
+
return { from: input.from.trim(), fromDomain: sender.domain, to, cc, bcc, replyTo, subject: input.subject, html, text, headers, tags, scheduledAtUs };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function renderListItem(record, nowUs) {
|
|
82
|
+
return {
|
|
83
|
+
object: "email",
|
|
84
|
+
id: record.id,
|
|
85
|
+
to: record.to,
|
|
86
|
+
from: record.from,
|
|
87
|
+
created_at: wireTime(record.createdAtUs),
|
|
88
|
+
subject: record.subject,
|
|
89
|
+
bcc: record.bcc,
|
|
90
|
+
cc: record.cc,
|
|
91
|
+
reply_to: record.replyTo,
|
|
92
|
+
last_event: lastEvent(record, nowUs),
|
|
93
|
+
scheduled_at: wireTime(record.scheduledAtUs),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function renderEmail(record, nowUs) {
|
|
98
|
+
const base = renderListItem(record, nowUs);
|
|
99
|
+
return { ...base, html: record.html, text: record.text, tags: record.tags };
|
|
100
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Emails: synthetic send and batch send, list, get, reschedule and cancel.
|
|
2
|
+
import { checkIdempotencyKey, fail, newId, randomChars, requireUuid, resolveKey, scanAll } from "../lib/core.mjs";
|
|
3
|
+
import { present } from "../lib/check.mjs";
|
|
4
|
+
import { paginate } from "../lib/page.mjs";
|
|
5
|
+
import { utcDay, wireTime } from "../lib/time.mjs";
|
|
6
|
+
import { bounces, lastEvent, renderEmail, renderListItem, scheduleTime, validateSend } from "./email-model.mjs";
|
|
7
|
+
|
|
8
|
+
const STATUSES = ["delivered", "bounced", "scheduled", "canceled", "sent"];
|
|
9
|
+
|
|
10
|
+
function sendingDomain(context, key, fromDomain) {
|
|
11
|
+
if (key.permission === "sending_access" && key.domainId !== null) {
|
|
12
|
+
const allowed = context.state.get("domains", key.domainId);
|
|
13
|
+
if (allowed === null || allowed.name !== fromDomain) {
|
|
14
|
+
const label = allowed === null ? "its configured domain" : allowed.name;
|
|
15
|
+
fail(context, "DOMAIN_NOT_VERIFIED", `This API key is restricted to send emails from ${label} only`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const index = context.state.get("domain-names", fromDomain);
|
|
19
|
+
const domain = index === null ? null : context.state.get("domains", index.domainId);
|
|
20
|
+
if (domain === null || domain.status !== "verified" || domain.capabilities.sending !== "enabled") {
|
|
21
|
+
fail(context, "DOMAIN_NOT_VERIFIED", `The ${fromDomain} domain is not verified. Please, add and verify your domain.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function reserveQuota(context, team, count) {
|
|
26
|
+
const now = context.clock.nowUs();
|
|
27
|
+
const today = utcDay(now);
|
|
28
|
+
const usage = context.state.get("meta", "usage");
|
|
29
|
+
const sent = usage !== null && usage.day === today ? usage.sent : 0;
|
|
30
|
+
if (sent + count > team.dailyQuota) fail(context, "DAILY_QUOTA_EXCEEDED", "You have reached your daily email sending quota.");
|
|
31
|
+
context.state.put("meta", "usage", { day: today, sent: sent + count });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function accept(context, key, email, batchId) {
|
|
35
|
+
const id = newId(context, "emails");
|
|
36
|
+
const nowUs = context.clock.nowUs();
|
|
37
|
+
const { fromDomain, ...fields } = email;
|
|
38
|
+
const immediate = email.scheduledAtUs === null;
|
|
39
|
+
const status = immediate ? (bounces(email) ? "bounced" : "delivered") : "scheduled";
|
|
40
|
+
const record = { id, ...fields, createdAtUs: nowUs, status, apiKeyId: key.id, batchId };
|
|
41
|
+
context.state.put("emails", id, record);
|
|
42
|
+
if (immediate) {
|
|
43
|
+
const createdAt = wireTime(nowUs);
|
|
44
|
+
context.events.emit("email.sent", { email_id: id, from: email.from, to: email.to, subject: email.subject, created_at: createdAt, batch_id: batchId });
|
|
45
|
+
if (status === "bounced") {
|
|
46
|
+
context.events.emit("email.bounced", { email_id: id, to: email.to, created_at: createdAt, bounce: { type: "Permanent", message: "Recipient address does not exist" } });
|
|
47
|
+
} else {
|
|
48
|
+
context.events.emit("email.delivered", { email_id: id, to: email.to, created_at: createdAt });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return id;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function touchKey(context, key) {
|
|
55
|
+
context.state.put("api-keys", key.id, { ...key, lastUsedAtUs: context.clock.nowUs() });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function emailsSend(input, context) {
|
|
59
|
+
const { key, team } = resolveKey(context, { send: true });
|
|
60
|
+
checkIdempotencyKey(context, input);
|
|
61
|
+
const email = validateSend(context, input);
|
|
62
|
+
sendingDomain(context, key, email.fromDomain);
|
|
63
|
+
reserveQuota(context, team, 1);
|
|
64
|
+
const id = accept(context, key, email, null);
|
|
65
|
+
touchKey(context, key);
|
|
66
|
+
return { id };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function emailsSendBatch(input, context) {
|
|
70
|
+
const { key, team } = resolveKey(context, { send: true });
|
|
71
|
+
checkIdempotencyKey(context, input);
|
|
72
|
+
if (present(input.batchValidation)) fail(context, "VALIDATION_ERROR", "The `x-batch-validation: permissive` mode is not supported by this simulated service.");
|
|
73
|
+
if (!Array.isArray(input.emails)) fail(context, "VALIDATION_ERROR", "The request body must be an array of emails.");
|
|
74
|
+
if (input.emails.length === 0 || input.emails.length > 100) fail(context, "VALIDATION_ERROR", "A batch must contain between 1 and 100 emails.");
|
|
75
|
+
const emails = input.emails.map((entry, index) => validateSend(context, entry, { batch: true, index }));
|
|
76
|
+
for (const email of emails) sendingDomain(context, key, email.fromDomain);
|
|
77
|
+
reserveQuota(context, team, emails.length);
|
|
78
|
+
const hex = randomChars(context, "0123456789abcdef", 32);
|
|
79
|
+
const batchId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
80
|
+
const data = emails.map((email) => ({ id: accept(context, key, email, batchId) }));
|
|
81
|
+
touchKey(context, key);
|
|
82
|
+
return { data };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function emailsList(input, context) {
|
|
86
|
+
resolveKey(context);
|
|
87
|
+
const nowUs = context.clock.nowUs();
|
|
88
|
+
let query = null;
|
|
89
|
+
if (present(input.query)) {
|
|
90
|
+
if (input.query.length > 200 || input.query.includes("�")) fail(context, "VALIDATION_ERROR", "The `query` parameter is invalid.");
|
|
91
|
+
query = input.query.trim().toLowerCase();
|
|
92
|
+
}
|
|
93
|
+
if (present(input.status) && !STATUSES.includes(input.status)) {
|
|
94
|
+
fail(context, "VALIDATION_ERROR", `The \`status\` parameter must be one of: ${STATUSES.join(", ")}.`);
|
|
95
|
+
}
|
|
96
|
+
const rows = scanAll(context, "emails")
|
|
97
|
+
.map((record) => record.value)
|
|
98
|
+
.filter((email) => !present(input.status) || lastEvent(email, nowUs) === input.status)
|
|
99
|
+
.filter((email) => query === null || email.subject.toLowerCase().includes(query) || email.to.some((to) => to.toLowerCase().includes(query)));
|
|
100
|
+
return paginate(context, input, rows, (email) => renderListItem(email, nowUs));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getEmail(context, rawId) {
|
|
104
|
+
const id = requireUuid(context, rawId);
|
|
105
|
+
const email = context.state.get("emails", id);
|
|
106
|
+
if (email === null) fail(context, "NOT_FOUND", "Email not found");
|
|
107
|
+
return email;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function emailsGet(input, context) {
|
|
111
|
+
resolveKey(context);
|
|
112
|
+
return renderEmail(getEmail(context, input.id), context.clock.nowUs());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function pendingScheduled(context, email, verb) {
|
|
116
|
+
if (lastEvent(email, context.clock.nowUs()) !== "scheduled") {
|
|
117
|
+
fail(context, "VALIDATION_ERROR", `Email cannot be ${verb} because it is not scheduled`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function emailsUpdate(input, context) {
|
|
122
|
+
resolveKey(context);
|
|
123
|
+
checkIdempotencyKey(context, input);
|
|
124
|
+
const email = getEmail(context, input.id);
|
|
125
|
+
if (!present(input.scheduledAt)) fail(context, "MISSING_REQUIRED_FIELD", "Missing `scheduled_at` field.");
|
|
126
|
+
pendingScheduled(context, email, "updated");
|
|
127
|
+
const scheduledAtUs = scheduleTime(context, input.scheduledAt);
|
|
128
|
+
context.state.put("emails", email.id, { ...email, scheduledAtUs });
|
|
129
|
+
return { object: "email", id: email.id };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function emailsCancel(input, context) {
|
|
133
|
+
resolveKey(context);
|
|
134
|
+
checkIdempotencyKey(context, input);
|
|
135
|
+
const email = getEmail(context, input.id);
|
|
136
|
+
pendingScheduled(context, email, "canceled");
|
|
137
|
+
context.state.put("emails", email.id, { ...email, status: "canceled" });
|
|
138
|
+
return { object: "email", id: email.id };
|
|
139
|
+
}
|