@fungi.computer/carrier 0.0.0-stage → 0.0.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 +21 -0
- package/README.md +457 -2
- package/dist/cloudflare.d.ts +32 -0
- package/dist/cloudflare.js +80 -0
- package/dist/email.d.ts +2 -0
- package/dist/email.js +2 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +28 -0
- package/dist/inspection.d.ts +108 -0
- package/dist/inspection.js +188 -0
- package/dist/internal/cloudflare-send.d.ts +9 -0
- package/dist/internal/cloudflare-send.js +79 -0
- package/dist/internal/contracts.d.ts +94 -0
- package/dist/internal/contracts.js +7 -0
- package/dist/internal/owner.d.ts +24 -0
- package/dist/internal/owner.js +419 -0
- package/dist/internal/policy.d.ts +25 -0
- package/dist/internal/policy.js +18 -0
- package/dist/internal/post-contract.d.ts +27 -0
- package/dist/internal/post-contract.js +22 -0
- package/dist/internal/post-execution.d.ts +45 -0
- package/dist/internal/post-execution.js +73 -0
- package/dist/internal/post-sql.d.ts +51 -0
- package/dist/internal/post-sql.js +255 -0
- package/dist/internal/receipt-schema.d.ts +3 -0
- package/dist/internal/receipt-schema.js +12 -0
- package/dist/post-d1.d.ts +36 -0
- package/dist/post-d1.js +55 -0
- package/dist/post-outbox.d.ts +40 -0
- package/dist/post-outbox.js +99 -0
- package/dist/post-postgres.d.ts +25 -0
- package/dist/post-postgres.js +79 -0
- package/dist/post.d.ts +189 -0
- package/dist/post.js +436 -0
- package/dist/testing.d.ts +16 -0
- package/dist/testing.js +20 -0
- package/docs/api/README.md +20 -0
- package/docs/api/cloudflare.md +100 -0
- package/docs/api/email.md +87 -0
- package/docs/api/index.md +343 -0
- package/docs/api/inspection.md +281 -0
- package/docs/api/post-d1.md +184 -0
- package/docs/api/post-postgres.md +108 -0
- package/docs/api/post.md +620 -0
- package/docs/api/testing.md +46 -0
- package/package.json +94 -3
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
import type { WatchdogSqliteOwner } from "@fungi.computer/watchdog";
|
|
3
|
+
/** One bounded scan; an empty filtered page can still have a continuation. */
|
|
4
|
+
export declare const DeliveryInspectionPageQuerySchema: Schema.Struct<{
|
|
5
|
+
readonly limit: Schema.withDecodingDefaultType<Schema.Int, never>;
|
|
6
|
+
readonly after: Schema.optional<Schema.String>;
|
|
7
|
+
}>;
|
|
8
|
+
/** Post can filter dead letters while scanning at most one bounded page. */
|
|
9
|
+
export declare const DeliveryInspectionQuerySchema: Schema.Struct<{
|
|
10
|
+
readonly view: Schema.Literals<readonly ["receipts", "deadLetters"]>;
|
|
11
|
+
readonly limit: Schema.withDecodingDefaultType<Schema.Int, never>;
|
|
12
|
+
readonly after: Schema.optional<Schema.String>;
|
|
13
|
+
}>;
|
|
14
|
+
/** An email receipt page uses the same producer-key pagination. */
|
|
15
|
+
export type DeliveryInspectionPageQuery = Schema.Codec.Encoded<typeof DeliveryInspectionPageQuerySchema>;
|
|
16
|
+
/** Input to one producer-local inspection. Cursors are producer keys, without storage prefixes. */
|
|
17
|
+
export type DeliveryInspectionQuery = Schema.Codec.Encoded<typeof DeliveryInspectionQuerySchema>;
|
|
18
|
+
/** Dispatch evidence deliberately omits the address and event. */
|
|
19
|
+
export declare const PostReceiptSummarySchema: Schema.Union<readonly [Schema.Struct<{
|
|
20
|
+
readonly key: Schema.String;
|
|
21
|
+
readonly state: Schema.Literal<"delivered">;
|
|
22
|
+
}>, Schema.Struct<{
|
|
23
|
+
readonly key: Schema.String;
|
|
24
|
+
readonly state: Schema.Literal<"pending">;
|
|
25
|
+
readonly kind: Schema.String;
|
|
26
|
+
readonly attempts: Schema.Int;
|
|
27
|
+
readonly nextAttemptAt: Schema.Number;
|
|
28
|
+
}>, Schema.Struct<{
|
|
29
|
+
readonly key: Schema.String;
|
|
30
|
+
readonly state: Schema.Literal<"dead">;
|
|
31
|
+
readonly kind: Schema.String;
|
|
32
|
+
readonly attempts: Schema.Int;
|
|
33
|
+
readonly reason: Schema.Literals<readonly ["refused", "exhausted"]>;
|
|
34
|
+
}>]>;
|
|
35
|
+
/** Metadata for one retained Post row. */
|
|
36
|
+
export type PostReceiptSummary = Schema.Schema.Type<typeof PostReceiptSummarySchema>;
|
|
37
|
+
/** Bounded evidence with an explicit distinction between empty and uninitialized storage. */
|
|
38
|
+
export declare const PostInspectionPageSchema: Schema.Union<readonly [Schema.Struct<{
|
|
39
|
+
readonly status: Schema.Literal<"uninitialized">;
|
|
40
|
+
}>, Schema.Struct<{
|
|
41
|
+
readonly status: Schema.Literal<"available">;
|
|
42
|
+
readonly records: Schema.$Array<Schema.Union<readonly [Schema.Struct<{
|
|
43
|
+
readonly key: Schema.String;
|
|
44
|
+
readonly state: Schema.Literal<"delivered">;
|
|
45
|
+
}>, Schema.Struct<{
|
|
46
|
+
readonly key: Schema.String;
|
|
47
|
+
readonly state: Schema.Literal<"pending">;
|
|
48
|
+
readonly kind: Schema.String;
|
|
49
|
+
readonly attempts: Schema.Int;
|
|
50
|
+
readonly nextAttemptAt: Schema.Number;
|
|
51
|
+
}>, Schema.Struct<{
|
|
52
|
+
readonly key: Schema.String;
|
|
53
|
+
readonly state: Schema.Literal<"dead">;
|
|
54
|
+
readonly kind: Schema.String;
|
|
55
|
+
readonly attempts: Schema.Int;
|
|
56
|
+
readonly reason: Schema.Literals<readonly ["refused", "exhausted"]>;
|
|
57
|
+
}>]>>;
|
|
58
|
+
readonly nextCursor: Schema.NullOr<Schema.String>;
|
|
59
|
+
}>]>;
|
|
60
|
+
/** One read of producer-local Post dispatch evidence. */
|
|
61
|
+
export type PostInspectionPage = Schema.Schema.Type<typeof PostInspectionPageSchema>;
|
|
62
|
+
/** Aggregate email evidence without bodies, headers or recipient addresses. */
|
|
63
|
+
export declare const CarrierReceiptSummarySchema: Schema.Struct<{
|
|
64
|
+
readonly id: Schema.String;
|
|
65
|
+
readonly status: Schema.Literals<readonly ["queued", "dispatching", "provider_accepted", "outcome_unknown", "rejected", "refused", "expired", "superseded"]>;
|
|
66
|
+
readonly attempts: Schema.Int;
|
|
67
|
+
readonly retryAt: Schema.NullOr<Schema.Number>;
|
|
68
|
+
readonly providerMessageId: Schema.optional<Schema.NonEmptyString>;
|
|
69
|
+
readonly providerRejection: Schema.optional<Schema.Struct<{
|
|
70
|
+
readonly code: Schema.NonEmptyString;
|
|
71
|
+
readonly class: Schema.Literals<readonly ["permanent", "temporary"]>;
|
|
72
|
+
}>>;
|
|
73
|
+
}>;
|
|
74
|
+
/** Carrier's bounded metadata page; unavailable storage is never reported as empty. */
|
|
75
|
+
export declare const CarrierInspectionPageSchema: Schema.Union<readonly [Schema.Struct<{
|
|
76
|
+
readonly status: Schema.Literal<"uninitialized">;
|
|
77
|
+
}>, Schema.Struct<{
|
|
78
|
+
readonly status: Schema.Literal<"available">;
|
|
79
|
+
readonly records: Schema.$Array<Schema.Struct<{
|
|
80
|
+
readonly id: Schema.String;
|
|
81
|
+
readonly status: Schema.Literals<readonly ["queued", "dispatching", "provider_accepted", "outcome_unknown", "rejected", "refused", "expired", "superseded"]>;
|
|
82
|
+
readonly attempts: Schema.Int;
|
|
83
|
+
readonly retryAt: Schema.NullOr<Schema.Number>;
|
|
84
|
+
readonly providerMessageId: Schema.optional<Schema.NonEmptyString>;
|
|
85
|
+
readonly providerRejection: Schema.optional<Schema.Struct<{
|
|
86
|
+
readonly code: Schema.NonEmptyString;
|
|
87
|
+
readonly class: Schema.Literals<readonly ["permanent", "temporary"]>;
|
|
88
|
+
}>>;
|
|
89
|
+
}>>;
|
|
90
|
+
readonly nextCursor: Schema.NullOr<Schema.String>;
|
|
91
|
+
}>]>;
|
|
92
|
+
/** One read of aggregate email dispatch evidence. */
|
|
93
|
+
export type CarrierInspectionPage = Schema.Schema.Type<typeof CarrierInspectionPageSchema>;
|
|
94
|
+
/** Stable inspection errors never include stored cargo or raw SQL failures. */
|
|
95
|
+
export declare class DeliveryInspectionError extends Error {
|
|
96
|
+
readonly code: "invalid_query" | "unavailable";
|
|
97
|
+
constructor(code: "invalid_query" | "unavailable");
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Read existing storage without opening a sender, initializing tables, repairing
|
|
101
|
+
* jobs or arming a wake. The trusted host must admit an operator before calling.
|
|
102
|
+
*/
|
|
103
|
+
export declare function createDeliveryInspection(sql: WatchdogSqliteOwner["sql"]): {
|
|
104
|
+
/** Read aggregate dispatch evidence without email cargo or recipient addresses. */
|
|
105
|
+
email(input: DeliveryInspectionPageQuery): CarrierInspectionPage;
|
|
106
|
+
/** Read Post metadata; filtered pages retain their continuation through other states. */
|
|
107
|
+
post(input: DeliveryInspectionQuery): PostInspectionPage;
|
|
108
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Exit from "effect/Exit";
|
|
3
|
+
import * as Schema from "effect/Schema";
|
|
4
|
+
import { PostRowSchema } from "./post.js";
|
|
5
|
+
import { CarrierStatusSchema } from "./internal/receipt-schema.js";
|
|
6
|
+
const Key = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256));
|
|
7
|
+
/** One bounded scan; an empty filtered page can still have a continuation. */
|
|
8
|
+
export const DeliveryInspectionPageQuerySchema = Schema.Struct({
|
|
9
|
+
limit: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 64 })).pipe(Schema.withDecodingDefaultType(Effect.succeed(32))),
|
|
10
|
+
after: Schema.optional(Key),
|
|
11
|
+
});
|
|
12
|
+
/** Post can filter dead letters while scanning at most one bounded page. */
|
|
13
|
+
export const DeliveryInspectionQuerySchema = Schema.Struct({
|
|
14
|
+
...DeliveryInspectionPageQuerySchema.fields,
|
|
15
|
+
view: Schema.Literals(["receipts", "deadLetters"]),
|
|
16
|
+
});
|
|
17
|
+
/** Dispatch evidence deliberately omits the address and event. */
|
|
18
|
+
export const PostReceiptSummarySchema = Schema.Union([
|
|
19
|
+
Schema.Struct({ key: Key, state: Schema.Literal("delivered") }),
|
|
20
|
+
Schema.Struct({
|
|
21
|
+
key: Key,
|
|
22
|
+
state: Schema.Literal("pending"),
|
|
23
|
+
kind: Key,
|
|
24
|
+
attempts: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
25
|
+
nextAttemptAt: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
26
|
+
}),
|
|
27
|
+
Schema.Struct({
|
|
28
|
+
key: Key,
|
|
29
|
+
state: Schema.Literal("dead"),
|
|
30
|
+
kind: Key,
|
|
31
|
+
attempts: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
32
|
+
reason: Schema.Literals(["refused", "exhausted"]),
|
|
33
|
+
}),
|
|
34
|
+
]);
|
|
35
|
+
/** Bounded evidence with an explicit distinction between empty and uninitialized storage. */
|
|
36
|
+
export const PostInspectionPageSchema = Schema.Union([
|
|
37
|
+
Schema.Struct({ status: Schema.Literal("uninitialized") }),
|
|
38
|
+
Schema.Struct({
|
|
39
|
+
status: Schema.Literal("available"),
|
|
40
|
+
records: Schema.Array(PostReceiptSummarySchema).check(Schema.isMaxLength(64)),
|
|
41
|
+
nextCursor: Schema.NullOr(Key),
|
|
42
|
+
}),
|
|
43
|
+
]);
|
|
44
|
+
/** Aggregate email evidence without bodies, headers or recipient addresses. */
|
|
45
|
+
export const CarrierReceiptSummarySchema = Schema.Struct({
|
|
46
|
+
id: Key,
|
|
47
|
+
status: CarrierStatusSchema,
|
|
48
|
+
attempts: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
49
|
+
retryAt: Schema.NullOr(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))),
|
|
50
|
+
providerMessageId: Schema.optional(Schema.NonEmptyString.check(Schema.isMaxLength(1024))),
|
|
51
|
+
providerRejection: Schema.optional(Schema.Struct({
|
|
52
|
+
code: Schema.NonEmptyString.check(Schema.isMaxLength(64)),
|
|
53
|
+
class: Schema.Literals(["permanent", "temporary"]),
|
|
54
|
+
})),
|
|
55
|
+
});
|
|
56
|
+
/** Carrier's bounded metadata page; unavailable storage is never reported as empty. */
|
|
57
|
+
export const CarrierInspectionPageSchema = Schema.Union([
|
|
58
|
+
Schema.Struct({ status: Schema.Literal("uninitialized") }),
|
|
59
|
+
Schema.Struct({
|
|
60
|
+
status: Schema.Literal("available"),
|
|
61
|
+
records: Schema.Array(CarrierReceiptSummarySchema).check(Schema.isMaxLength(64)),
|
|
62
|
+
nextCursor: Schema.NullOr(Key),
|
|
63
|
+
}),
|
|
64
|
+
]);
|
|
65
|
+
/** Stable inspection errors never include stored cargo or raw SQL failures. */
|
|
66
|
+
export class DeliveryInspectionError extends Error {
|
|
67
|
+
code;
|
|
68
|
+
constructor(code) {
|
|
69
|
+
super(`Delivery inspection ${code}`);
|
|
70
|
+
this.code = code;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const StoredPost = Schema.Struct({ key: Schema.String, value: PostRowSchema });
|
|
74
|
+
const StoredEmail = Schema.Struct({
|
|
75
|
+
id: Key,
|
|
76
|
+
status: CarrierStatusSchema,
|
|
77
|
+
attempts: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
78
|
+
retry_at: Schema.NullOr(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))),
|
|
79
|
+
provider_id: Schema.NullOr(Schema.NonEmptyString.check(Schema.isMaxLength(1024))),
|
|
80
|
+
error_code: Schema.NullOr(Schema.NonEmptyString.check(Schema.isMaxLength(64))),
|
|
81
|
+
error_class: Schema.NullOr(Schema.Literals(["permanent", "temporary"])),
|
|
82
|
+
});
|
|
83
|
+
const hasTable = (sql, name) => sql
|
|
84
|
+
.exec("SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1", name)
|
|
85
|
+
.toArray().length === 1;
|
|
86
|
+
/**
|
|
87
|
+
* Read existing storage without opening a sender, initializing tables, repairing
|
|
88
|
+
* jobs or arming a wake. The trusted host must admit an operator before calling.
|
|
89
|
+
*/
|
|
90
|
+
export function createDeliveryInspection(sql) {
|
|
91
|
+
return {
|
|
92
|
+
/** Read aggregate dispatch evidence without email cargo or recipient addresses. */
|
|
93
|
+
email(input) {
|
|
94
|
+
const parsed = Schema.decodeUnknownExit(DeliveryInspectionPageQuerySchema, { onExcessProperty: "error" })(input);
|
|
95
|
+
if (Exit.isFailure(parsed))
|
|
96
|
+
throw new DeliveryInspectionError("invalid_query");
|
|
97
|
+
const query = parsed.value;
|
|
98
|
+
try {
|
|
99
|
+
if (!hasTable(sql, "carrier_emails"))
|
|
100
|
+
return { status: "uninitialized" };
|
|
101
|
+
const rows = Schema.decodeUnknownSync(Schema.Array(StoredEmail))(sql
|
|
102
|
+
.exec("SELECT id,status,attempts,retry_at,provider_id,error_code,error_class FROM carrier_emails WHERE id>? ORDER BY id LIMIT ?", query.after ?? "", query.limit)
|
|
103
|
+
.toArray());
|
|
104
|
+
return Schema.decodeUnknownSync(CarrierInspectionPageSchema)({
|
|
105
|
+
status: "available",
|
|
106
|
+
records: rows.map((row) => {
|
|
107
|
+
const receipt = {
|
|
108
|
+
id: row.id,
|
|
109
|
+
status: row.status,
|
|
110
|
+
attempts: row.attempts,
|
|
111
|
+
retryAt: row.retry_at,
|
|
112
|
+
};
|
|
113
|
+
if (row.provider_id !== null)
|
|
114
|
+
return { ...receipt, providerMessageId: row.provider_id };
|
|
115
|
+
if (row.error_code !== null && row.error_class !== null)
|
|
116
|
+
return {
|
|
117
|
+
...receipt,
|
|
118
|
+
providerRejection: {
|
|
119
|
+
code: row.error_code,
|
|
120
|
+
class: row.error_class,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
return receipt;
|
|
124
|
+
}),
|
|
125
|
+
nextCursor: rows.length === query.limit ? rows.at(-1)?.id : null,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new DeliveryInspectionError("unavailable");
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
/** Read Post metadata; filtered pages retain their continuation through other states. */
|
|
133
|
+
post(input) {
|
|
134
|
+
const parsed = Schema.decodeUnknownExit(DeliveryInspectionQuerySchema, {
|
|
135
|
+
onExcessProperty: "error",
|
|
136
|
+
})(input);
|
|
137
|
+
if (Exit.isFailure(parsed))
|
|
138
|
+
throw new DeliveryInspectionError("invalid_query");
|
|
139
|
+
const query = parsed.value;
|
|
140
|
+
try {
|
|
141
|
+
if (!hasTable(sql, "carrier_post_rows"))
|
|
142
|
+
return { status: "uninitialized" };
|
|
143
|
+
const rows = Schema.decodeUnknownSync(Schema.Array(StoredPost))(sql
|
|
144
|
+
.exec("SELECT key,value FROM carrier_post_rows WHERE key LIKE 'post:%' AND key>? ORDER BY key LIMIT ?", `post:${query.after ?? ""}`, query.limit)
|
|
145
|
+
.toArray());
|
|
146
|
+
const records = rows.flatMap(({ key, value }) => {
|
|
147
|
+
if (query.view === "deadLetters" && value.state !== "dead")
|
|
148
|
+
return [];
|
|
149
|
+
const receiptKey = key.slice("post:".length);
|
|
150
|
+
switch (value.state) {
|
|
151
|
+
case "delivered":
|
|
152
|
+
return [{ key: receiptKey, state: value.state }];
|
|
153
|
+
case "pending":
|
|
154
|
+
return [
|
|
155
|
+
{
|
|
156
|
+
key: receiptKey,
|
|
157
|
+
state: value.state,
|
|
158
|
+
kind: value.envelope.kind,
|
|
159
|
+
attempts: value.attempts,
|
|
160
|
+
nextAttemptAt: value.nextAttemptAt,
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
case "dead":
|
|
164
|
+
return [
|
|
165
|
+
{
|
|
166
|
+
key: receiptKey,
|
|
167
|
+
state: value.state,
|
|
168
|
+
kind: value.envelope.kind,
|
|
169
|
+
attempts: value.attempts,
|
|
170
|
+
reason: value.reason,
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
return Schema.decodeUnknownSync(PostInspectionPageSchema)({
|
|
176
|
+
status: "available",
|
|
177
|
+
records,
|
|
178
|
+
nextCursor: rows.length === query.limit
|
|
179
|
+
? rows.at(-1)?.key.slice("post:".length)
|
|
180
|
+
: null,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
throw new DeliveryInspectionError("unavailable");
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { EmailMessageBuilder, SendEmail } from "@cloudflare/workers-types";
|
|
2
|
+
import type { SendEvidence } from "./contracts.js";
|
|
3
|
+
/**
|
|
4
|
+
* Invoke the native structured binding once, preserving its recipient envelope.
|
|
5
|
+
* Cloudflare supplies no application idempotency key here. Only a documented
|
|
6
|
+
* whole-request rejection is reported as such; any other exception cannot
|
|
7
|
+
* authorize another send.
|
|
8
|
+
*/
|
|
9
|
+
export declare function sendCloudflareEmail(binding: SendEmail, email: EmailMessageBuilder): Promise<SendEvidence>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as Option from "effect/Option";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
// Cloudflare Email Service documents that the Workers binding throws a standard
|
|
4
|
+
// Error carrying a string `code` (Email Service, Workers API, error codes). The
|
|
5
|
+
// installed @cloudflare/workers-types declares no error type, so the documented
|
|
6
|
+
// codes are decoded here. Only codes naming a refusal of the whole request are
|
|
7
|
+
// classified; everything else remains an unknown outcome.
|
|
8
|
+
const Temporary = Schema.Literals([
|
|
9
|
+
// Quota or temporary service unavailability; documented as retryable.
|
|
10
|
+
"E_RATE_LIMIT_EXCEEDED",
|
|
11
|
+
"E_DAILY_LIMIT_EXCEEDED",
|
|
12
|
+
"E_INTERNAL_SERVER_ERROR",
|
|
13
|
+
]);
|
|
14
|
+
const isTemporary = Schema.is(Temporary);
|
|
15
|
+
const Rejection = Schema.Struct({
|
|
16
|
+
code: Schema.Union([
|
|
17
|
+
Temporary,
|
|
18
|
+
// Request, sender, recipient or header validity; retrying cannot help.
|
|
19
|
+
Schema.Literals([
|
|
20
|
+
"E_VALIDATION_ERROR",
|
|
21
|
+
"E_FIELD_MISSING",
|
|
22
|
+
"E_TOO_MANY_RECIPIENTS",
|
|
23
|
+
"E_TOO_MANY_ATTACHMENTS",
|
|
24
|
+
"E_SENDER_NOT_VERIFIED",
|
|
25
|
+
"E_RECIPIENT_NOT_ALLOWED",
|
|
26
|
+
"E_RECIPIENT_SUPPRESSED",
|
|
27
|
+
"E_SENDER_DOMAIN_NOT_AVAILABLE",
|
|
28
|
+
"E_CONTENT_TOO_LARGE",
|
|
29
|
+
"E_HEADER_NOT_ALLOWED",
|
|
30
|
+
"E_HEADER_USE_API_FIELD",
|
|
31
|
+
"E_HEADER_VALUE_INVALID",
|
|
32
|
+
"E_HEADER_VALUE_TOO_LONG",
|
|
33
|
+
"E_HEADER_NAME_INVALID",
|
|
34
|
+
"E_HEADERS_TOO_LARGE",
|
|
35
|
+
"E_HEADERS_TOO_MANY",
|
|
36
|
+
]),
|
|
37
|
+
]),
|
|
38
|
+
});
|
|
39
|
+
const decodeRejection = Schema.decodeUnknownOption(Rejection);
|
|
40
|
+
// E_DELIVERY_FAILED and undocumented codes stay unknown: an SMTP-stage failure
|
|
41
|
+
// may follow acceptance of some recipients, so it cannot authorize a resend.
|
|
42
|
+
// Their code is kept only as a sanitized log cause; messages are never kept
|
|
43
|
+
// because they may contain mail data.
|
|
44
|
+
const decodeCode = Schema.decodeUnknownOption(Schema.Struct({
|
|
45
|
+
code: Schema.String.check(Schema.isPattern(/^E_[A-Z_]{1,62}$/u)),
|
|
46
|
+
}));
|
|
47
|
+
/**
|
|
48
|
+
* Invoke the native structured binding once, preserving its recipient envelope.
|
|
49
|
+
* Cloudflare supplies no application idempotency key here. Only a documented
|
|
50
|
+
* whole-request rejection is reported as such; any other exception cannot
|
|
51
|
+
* authorize another send.
|
|
52
|
+
*/
|
|
53
|
+
export async function sendCloudflareEmail(binding, email) {
|
|
54
|
+
try {
|
|
55
|
+
const receipt = await binding.send(email);
|
|
56
|
+
return {
|
|
57
|
+
kind: "provider_accepted",
|
|
58
|
+
providerMessageId: receipt.messageId,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
const rejection = decodeRejection(error);
|
|
63
|
+
if (Option.isSome(rejection)) {
|
|
64
|
+
const { code } = rejection.value;
|
|
65
|
+
return {
|
|
66
|
+
kind: "provider_rejected",
|
|
67
|
+
code,
|
|
68
|
+
class: isTemporary(code) ? "temporary" : "permanent",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return Option.match(decodeCode(error), {
|
|
72
|
+
onNone: () => ({
|
|
73
|
+
kind: "outcome_unknown",
|
|
74
|
+
cause: error instanceof Error ? "provider_exception" : "non_error_thrown",
|
|
75
|
+
}),
|
|
76
|
+
onSome: ({ code }) => ({ kind: "outcome_unknown", cause: code }),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { WatchdogSqliteOwner } from "@fungi.computer/watchdog";
|
|
2
|
+
import type * as Schema from "effect/Schema";
|
|
3
|
+
import type { CarrierStatusSchema } from "./receipt-schema.js";
|
|
4
|
+
/** One recipient address; display names remain part of the requested envelope. */
|
|
5
|
+
export type Address = string | Readonly<{
|
|
6
|
+
email: string;
|
|
7
|
+
name: string;
|
|
8
|
+
}>;
|
|
9
|
+
/** Common content and envelope for one Email. */
|
|
10
|
+
export type Email = Readonly<{
|
|
11
|
+
from: Address;
|
|
12
|
+
to?: readonly Address[];
|
|
13
|
+
cc?: readonly Address[];
|
|
14
|
+
bcc?: readonly Address[];
|
|
15
|
+
subject: string;
|
|
16
|
+
html?: string;
|
|
17
|
+
text?: string;
|
|
18
|
+
headers?: Readonly<Record<string, string>>;
|
|
19
|
+
}>;
|
|
20
|
+
/** Source identity and expiry belong to the notification's producer. */
|
|
21
|
+
export type Submission = Email & Readonly<{
|
|
22
|
+
id: string;
|
|
23
|
+
sendBefore?: number;
|
|
24
|
+
}>;
|
|
25
|
+
/** A recipient Message is distinct from its Email and provider request. */
|
|
26
|
+
export type Message = Readonly<{
|
|
27
|
+
id: string;
|
|
28
|
+
recipient: string;
|
|
29
|
+
roles: readonly ("to" | "cc" | "bcc")[];
|
|
30
|
+
status: "unconfirmed";
|
|
31
|
+
}>;
|
|
32
|
+
/** A provider's definite refusal of a whole request: retryable or final. */
|
|
33
|
+
export type ProviderRejection = Readonly<{
|
|
34
|
+
code: string;
|
|
35
|
+
class: "permanent" | "temporary";
|
|
36
|
+
}>;
|
|
37
|
+
/**
|
|
38
|
+
* Durable submission and aggregate dispatch evidence, without credential content.
|
|
39
|
+
* `refused` means Carrier's per-recipient cap declined the submission before
|
|
40
|
+
* any provider attempt; `rejected` means the provider definitely refused it
|
|
41
|
+
* (a permanent rejection, or temporary rejections until retries ran out).
|
|
42
|
+
* `providerRejection` is the last provider refusal, also while a temporary
|
|
43
|
+
* rejection waits `queued` for its retry.
|
|
44
|
+
*/
|
|
45
|
+
export type Receipt = Readonly<{
|
|
46
|
+
id: string;
|
|
47
|
+
status: Schema.Schema.Type<typeof CarrierStatusSchema>;
|
|
48
|
+
providerMessageId?: string;
|
|
49
|
+
providerRejection?: ProviderRejection;
|
|
50
|
+
messages: readonly Message[];
|
|
51
|
+
}>;
|
|
52
|
+
/**
|
|
53
|
+
* One transport invocation reports only the evidence it obtained. Report
|
|
54
|
+
* `provider_rejected` only for a documented refusal of the whole request before
|
|
55
|
+
* acceptance; `cause` is an optional sanitized provider code for logs.
|
|
56
|
+
*/
|
|
57
|
+
export type SendEvidence = Readonly<{
|
|
58
|
+
kind: "provider_accepted";
|
|
59
|
+
providerMessageId: string;
|
|
60
|
+
}> | (Readonly<{
|
|
61
|
+
kind: "provider_rejected";
|
|
62
|
+
}> & ProviderRejection) | Readonly<{
|
|
63
|
+
kind: "outcome_unknown";
|
|
64
|
+
cause?: string;
|
|
65
|
+
}>;
|
|
66
|
+
/** Mechanical host capabilities; commit must durably arm a wake with its mutation. */
|
|
67
|
+
export type Host = Readonly<{
|
|
68
|
+
/** Commit the synchronous mutation and a durable recovery wake atomically. */
|
|
69
|
+
commit: <A>(mutation: () => A) => Promise<A>;
|
|
70
|
+
/** Durably schedule another dispatch/recovery step. */
|
|
71
|
+
requestWake: () => Promise<void>;
|
|
72
|
+
}>;
|
|
73
|
+
/** Configure a durable owner; business validity remains with the source. */
|
|
74
|
+
export type Options = Readonly<{
|
|
75
|
+
storage: WatchdogSqliteOwner;
|
|
76
|
+
host: Host;
|
|
77
|
+
/** Return the current Unix timestamp in milliseconds for source expiry checks. */
|
|
78
|
+
now: () => number;
|
|
79
|
+
/** Return false only for a definitely obsolete source; throw when validity is unavailable. */
|
|
80
|
+
canSend?: (emailId: string) => Promise<boolean>;
|
|
81
|
+
/** Make one provider attempt and report only observed evidence; Carrier owns retries. */
|
|
82
|
+
transport: (email: Email) => Promise<SendEvidence>;
|
|
83
|
+
}>;
|
|
84
|
+
declare const CarrierError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
85
|
+
readonly _tag: "CarrierError";
|
|
86
|
+
} & Readonly<A>;
|
|
87
|
+
/** Stable failures omit email bodies, tokens and raw provider errors. */
|
|
88
|
+
export declare class CarrierError extends CarrierError_base<{
|
|
89
|
+
readonly code: "invalid_input" | "conflict" | "unavailable";
|
|
90
|
+
readonly message: string;
|
|
91
|
+
}> {
|
|
92
|
+
constructor(code: "invalid_input" | "conflict" | "unavailable");
|
|
93
|
+
}
|
|
94
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { CarrierError, type Options, type Submission as CarrierSubmission } from "./contracts.js";
|
|
4
|
+
import { CarrierStatusSchema } from "./receipt-schema.js";
|
|
5
|
+
/** One private lifecycle; the public Promise entry only runs these effects. */
|
|
6
|
+
export declare function openOwner(options: Options): {
|
|
7
|
+
initialize: Effect.Effect<void, CarrierError, never>;
|
|
8
|
+
send: (input: CarrierSubmission) => Effect.Effect<Readonly<{
|
|
9
|
+
id: string;
|
|
10
|
+
status: Schema.Schema.Type<typeof CarrierStatusSchema>;
|
|
11
|
+
providerMessageId?: string;
|
|
12
|
+
providerRejection?: import("./contracts.js").ProviderRejection;
|
|
13
|
+
messages: readonly import("./contracts.js").Message[];
|
|
14
|
+
}>, CarrierError, never>;
|
|
15
|
+
tick: Effect.Effect<void, CarrierError, never>;
|
|
16
|
+
get: (id: string) => Effect.Effect<Readonly<{
|
|
17
|
+
id: string;
|
|
18
|
+
status: Schema.Schema.Type<typeof CarrierStatusSchema>;
|
|
19
|
+
providerMessageId?: string;
|
|
20
|
+
providerRejection?: import("./contracts.js").ProviderRejection;
|
|
21
|
+
messages: readonly import("./contracts.js").Message[];
|
|
22
|
+
}> | undefined, CarrierError, never>;
|
|
23
|
+
hasPendingWork: Effect.Effect<boolean, CarrierError, never>;
|
|
24
|
+
};
|