better-ship 0.24.0 → 0.26.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.
Files changed (85) hide show
  1. package/dist/application-C_xN_1pa.js.map +1 -1
  2. package/dist/application.d.ts +2 -2
  3. package/dist/auth-secondary-storage-b9ptwPSB.d.ts +37 -0
  4. package/dist/auth-secondary-storage-b9ptwPSB.d.ts.map +1 -0
  5. package/dist/better-auth/secondary-storage.d.ts +1 -1
  6. package/dist/bin/notify-ci.d.ts +1 -0
  7. package/dist/bin/notify-ci.js +24 -0
  8. package/dist/bin/notify-ci.js.map +1 -0
  9. package/dist/client-Dv1Ch7yY.js +20 -0
  10. package/dist/client-Dv1Ch7yY.js.map +1 -0
  11. package/dist/cloudflare/auth-secondary-storage.d.ts +2 -36
  12. package/dist/cloudflare/durable-object-client.js +1 -1
  13. package/dist/cloudflare/durable-object-errors.js +2 -47
  14. package/dist/cloudflare/kv-client.js +1 -1
  15. package/dist/cloudflare/kv-errors.js +2 -143
  16. package/dist/cloudflare/queue.d.ts +1 -1
  17. package/dist/cloudflare/r2-client.js +1 -1
  18. package/dist/cloudflare/r2-errors.js +2 -89
  19. package/dist/cloudflare.d.ts +2 -2
  20. package/dist/durable-object-errors-DkMFXQnK.js +48 -0
  21. package/dist/durable-object-errors-DkMFXQnK.js.map +1 -0
  22. package/dist/{index-tNZHSCpJ.d.ts → index-lS_g1olK.d.ts} +17 -2
  23. package/dist/index-lS_g1olK.d.ts.map +1 -0
  24. package/dist/kv-errors-BUTpFy0t.js +144 -0
  25. package/dist/kv-errors-BUTpFy0t.js.map +1 -0
  26. package/dist/metrics-CWv37yf9.js +30 -0
  27. package/dist/metrics-CWv37yf9.js.map +1 -0
  28. package/dist/metrics-cv9P5nvk.d.ts +19 -0
  29. package/dist/metrics-cv9P5nvk.d.ts.map +1 -0
  30. package/dist/{outbox-repository-options-_B8l8l6l.d.ts → outbox-repository-options-15Sv9WnG.d.ts} +2 -2
  31. package/dist/{outbox-repository-options-_B8l8l6l.d.ts.map → outbox-repository-options-15Sv9WnG.d.ts.map} +1 -1
  32. package/dist/payment-service-DUYd3Itt.d.ts +52 -0
  33. package/dist/payment-service-DUYd3Itt.d.ts.map +1 -0
  34. package/dist/payment-service.error-CqF7GkNp.js +30 -0
  35. package/dist/payment-service.error-CqF7GkNp.js.map +1 -0
  36. package/dist/postgres.d.ts +2 -2
  37. package/dist/posthog/metrics.d.ts +2 -18
  38. package/dist/posthog/metrics.js +2 -29
  39. package/dist/posthog/server.d.ts +2 -2
  40. package/dist/posthog/server.js +1 -1
  41. package/dist/r2-errors-rk7n7rvk.js +90 -0
  42. package/dist/r2-errors-rk7n7rvk.js.map +1 -0
  43. package/dist/request-4vJEWxyZ.js +21 -0
  44. package/dist/request-4vJEWxyZ.js.map +1 -0
  45. package/dist/resend.d.ts +6 -1
  46. package/dist/resend.d.ts.map +1 -1
  47. package/dist/resend.js +35 -0
  48. package/dist/resend.js.map +1 -1
  49. package/dist/sentry/server.d.ts +1 -1
  50. package/dist/slack-webhook-j1FCPeu8.js +270 -0
  51. package/dist/slack-webhook-j1FCPeu8.js.map +1 -0
  52. package/dist/slack.d.ts +102 -0
  53. package/dist/slack.d.ts.map +1 -0
  54. package/dist/slack.js +3 -0
  55. package/dist/stripe/client.js +2 -19
  56. package/dist/stripe/payment-service.d.ts +2 -51
  57. package/dist/stripe/payment-service.error.js +2 -29
  58. package/dist/stripe/payment-service.js +1 -1
  59. package/dist/stripe/request.js +2 -20
  60. package/dist/stripe-fixture-yFUknTJO.js +33 -0
  61. package/dist/stripe-fixture-yFUknTJO.js.map +1 -0
  62. package/dist/testing/outbox-repository.d.ts +1 -1
  63. package/dist/testing/payment-service.d.ts +1 -1
  64. package/dist/testing/payment-service.js +2 -2
  65. package/dist/testing/stripe.js +2 -32
  66. package/package.json +6 -1
  67. package/src/application/email-service.ts +17 -0
  68. package/src/application/index.ts +2 -0
  69. package/src/infrastructure/resend/resend-email-service.ts +39 -0
  70. package/src/infrastructure/slack/ci-message.ts +276 -0
  71. package/src/infrastructure/slack/index.ts +10 -0
  72. package/src/infrastructure/slack/notify-ci.ts +22 -0
  73. package/src/infrastructure/slack/slack-webhook.ts +36 -0
  74. package/dist/cloudflare/auth-secondary-storage.d.ts.map +0 -1
  75. package/dist/cloudflare/durable-object-errors.js.map +0 -1
  76. package/dist/cloudflare/kv-errors.js.map +0 -1
  77. package/dist/cloudflare/r2-errors.js.map +0 -1
  78. package/dist/index-tNZHSCpJ.d.ts.map +0 -1
  79. package/dist/posthog/metrics.d.ts.map +0 -1
  80. package/dist/posthog/metrics.js.map +0 -1
  81. package/dist/stripe/client.js.map +0 -1
  82. package/dist/stripe/payment-service.d.ts.map +0 -1
  83. package/dist/stripe/payment-service.error.js.map +0 -1
  84. package/dist/stripe/request.js.map +0 -1
  85. package/dist/testing/stripe.js.map +0 -1
@@ -1,20 +1,3 @@
1
- import Stripe from "stripe";
1
+ import { n as createStripeClient, t as STRIPE_REQUEST_OPTIONS } from "../client-Dv1Ch7yY.js";
2
2
 
3
- //#region src/infrastructure/stripe/client.ts
4
- /** Default SDK retries; client configuration and individual requests can override this value. */
5
- const STRIPE_REQUEST_OPTIONS = Object.freeze({ maxNetworkRetries: 2 });
6
- /**
7
- * Construct a native Stripe client with fetch transport and two SDK retries by default.
8
- * Native configuration overrides these defaults. Inject the client into application services.
9
- */
10
- function createStripeClient(secretKey, options = {}) {
11
- return new Stripe(secretKey, {
12
- httpClient: Stripe.createFetchHttpClient(),
13
- ...STRIPE_REQUEST_OPTIONS,
14
- ...options
15
- });
16
- }
17
-
18
- //#endregion
19
- export { STRIPE_REQUEST_OPTIONS, createStripeClient };
20
- //# sourceMappingURL=client.js.map
3
+ export { STRIPE_REQUEST_OPTIONS, createStripeClient };
@@ -1,51 +1,2 @@
1
- import Stripe from "stripe";
2
- //#region src/infrastructure/stripe/payment-service.d.ts
3
- /** Supply the same key and parameters when repeating one durable payment attempt. */
4
- export type PaymentRequestOptions = Stripe.RequestOptions & Required<Pick<Stripe.RequestOptions, 'idempotencyKey'>>;
5
- /** Native Checkout parameters; the service selects payment or setup mode. */
6
- export type CheckoutInput = Omit<Stripe.Checkout.SessionCreateParams, 'mode'>;
7
- /** A saved customer method; the service confirms the payment off-session. */
8
- export type SavedMethodCharge = Omit<Stripe.PaymentIntentCreateParams, 'confirm' | 'off_session'> & Required<Pick<Stripe.PaymentIntentCreateParams, 'customer' | 'payment_method'>>;
9
- /** Native customer response, including Stripe request metadata. */
10
- export type CustomerResult = Awaited<ReturnType<Stripe['customers']['create']>>;
11
- /** Native Checkout response. Its URL can be null; this does not confirm payment. */
12
- export type CheckoutResult = Awaited<ReturnType<Stripe['checkout']['sessions']['create']>>;
13
- /** Native PaymentIntent response. Applications decide what each status permits. */
14
- export type PaymentResult = Awaited<ReturnType<Stripe['paymentIntents']['create']>>;
15
- /**
16
- * Stripe payment operations, with native types and stable keys for mutations.
17
- * Rejects with PaymentServiceError for Stripe failures; non-Stripe failures propagate unchanged.
18
- */
19
- export interface IPaymentService {
20
- /** Create a customer; the application owns persistence and reuse of the customer link. */
21
- createCustomer(input: Stripe.CustomerCreateParams, options: PaymentRequestOptions): Promise<CustomerResult>;
22
- /** Create one-time payment checkout; application fulfillment requires verified payment confirmation. */
23
- createCheckout(input: CheckoutInput, options: PaymentRequestOptions): Promise<CheckoutResult>;
24
- /** Create checkout in setup mode. The application records consent and verifies the saved method's owner. */
25
- setupPaymentMethod(input: CheckoutInput, options: PaymentRequestOptions): Promise<CheckoutResult>;
26
- /** Confirm a saved method off-session. PaymentServiceError retains card errors in cause. */
27
- chargeSavedMethod(input: SavedMethodCharge, options: PaymentRequestOptions): Promise<PaymentResult>;
28
- /** Retrieve an existing payment without starting another payment attempt. */
29
- getPayment(paymentId: string, params?: Stripe.PaymentIntentRetrieveParams, options?: Stripe.RequestOptions): Promise<PaymentResult>;
30
- }
31
- /**
32
- * Ported from Typist's StripeClient request boundary; the native SDK now owns retries.
33
- * The injected client owns retry and timeout defaults. Native request options can override them.
34
- */
35
- export declare class StripePaymentService implements IPaymentService {
36
- private readonly stripe;
37
- /** Inject a native SDK instance; its transport, credentials, and API version remain caller-owned. */
38
- constructor(stripe: Stripe);
39
- /** Create a customer with a stable application key and preserve the native response. */
40
- createCustomer(input: Stripe.CustomerCreateParams, options: PaymentRequestOptions): Promise<CustomerResult>;
41
- /** Force payment mode after caller parameters; return the native Checkout Session. */
42
- createCheckout(input: CheckoutInput, options: PaymentRequestOptions): Promise<CheckoutResult>;
43
- /** Force setup mode after caller parameters; this operation does not charge the customer. */
44
- setupPaymentMethod(input: CheckoutInput, options: PaymentRequestOptions): Promise<CheckoutResult>;
45
- /** Force confirmation and off-session use; retain native card failures in PaymentServiceError.cause. */
46
- chargeSavedMethod(input: SavedMethodCharge, options: PaymentRequestOptions): Promise<PaymentResult>;
47
- /** Preserve every native status and response field when reconciling an existing payment. */
48
- getPayment(paymentId: string, params?: Stripe.PaymentIntentRetrieveParams, options?: Stripe.RequestOptions): Promise<PaymentResult>;
49
- }
50
- //#endregion
51
- //# sourceMappingURL=payment-service.d.ts.map
1
+ import { a as PaymentRequestOptions, c as StripePaymentService, i as IPaymentService, n as CheckoutResult, o as PaymentResult, r as CustomerResult, s as SavedMethodCharge, t as CheckoutInput } from "../payment-service-DUYd3Itt.js";
2
+ export { CheckoutInput, CheckoutResult, CustomerResult, IPaymentService, PaymentRequestOptions, PaymentResult, SavedMethodCharge, StripePaymentService };
@@ -1,30 +1,3 @@
1
- import { nt as AppError } from "../core-BiN6S_cm.js";
2
- import Stripe from "stripe";
1
+ import { t as PaymentServiceError } from "../payment-service.error-CqF7GkNp.js";
3
2
 
4
- //#region src/infrastructure/stripe/payment-service.error.ts
5
- /** A final Stripe request failure. The caller logs once; native details remain in cause. */
6
- var PaymentServiceError = class extends AppError {
7
- operation;
8
- _tag = "PaymentServiceError";
9
- classification;
10
- /** Classify the final SDK error without scheduling another payment attempt. */
11
- constructor(operation, cause) {
12
- super(`Stripe request failed: ${operation}`, { cause });
13
- this.operation = operation;
14
- this.classification = classifyStripeError(cause);
15
- }
16
- };
17
- function classifyStripeError(error) {
18
- if (error.headers?.["stripe-should-retry"] === "false") return "terminal";
19
- const status = error.statusCode;
20
- if (status !== void 0 && status >= 400 && status < 500 && status !== 409 && status !== 429) return "terminal";
21
- if (error instanceof Stripe.errors.StripeAuthenticationError || error instanceof Stripe.errors.StripePermissionError || error instanceof Stripe.errors.StripeInvalidRequestError || error instanceof Stripe.errors.StripeIdempotencyError || error instanceof Stripe.errors.StripeCardError) return "terminal";
22
- if (error.headers?.["stripe-should-retry"] === "true") return "transient";
23
- if (status === 409 || status === 429 || status !== void 0 && status >= 500) return "transient";
24
- if (error instanceof Stripe.errors.StripeConnectionError || error instanceof Stripe.errors.StripeAPIError || error instanceof Stripe.errors.StripeRateLimitError) return "transient";
25
- return "unknown";
26
- }
27
-
28
- //#endregion
29
- export { PaymentServiceError };
30
- //# sourceMappingURL=payment-service.error.js.map
3
+ export { PaymentServiceError };
@@ -1,4 +1,4 @@
1
- import { stripeRequest } from "./request.js";
1
+ import { t as stripeRequest } from "../request-4vJEWxyZ.js";
2
2
 
3
3
  //#region src/infrastructure/stripe/payment-service.ts
4
4
  /**
@@ -1,21 +1,3 @@
1
- import { PaymentServiceError } from "./payment-service.error.js";
2
- import Stripe from "stripe";
1
+ import { t as stripeRequest } from "../request-4vJEWxyZ.js";
3
2
 
4
- //#region src/infrastructure/stripe/request.ts
5
- /**
6
- * Invoke one SDK operation; the SDK owns retries configured on its client or request.
7
- * Convert every Stripe error to PaymentServiceError without logging; preserve the original error in cause.
8
- * Non-Stripe failures propagate unchanged.
9
- */
10
- async function stripeRequest(operation, work) {
11
- try {
12
- return await work();
13
- } catch (cause) {
14
- if (!(cause instanceof Stripe.errors.StripeError)) throw cause;
15
- throw new PaymentServiceError(operation, cause);
16
- }
17
- }
18
-
19
- //#endregion
20
- export { stripeRequest };
21
- //# sourceMappingURL=request.js.map
3
+ export { stripeRequest };
@@ -0,0 +1,33 @@
1
+ import { n as createStripeClient } from "./client-Dv1Ch7yY.js";
2
+ import Stripe from "stripe";
3
+
4
+ //#region src/infrastructure/testing/stripe-fixture.ts
5
+ /**
6
+ * Construct the real SDK with a fake fetch transport and an ordered record of HTTP attempts.
7
+ * The responder receives an unread request. The shared client factory supplies retry defaults.
8
+ */
9
+ function stripeFixture(respond) {
10
+ const requests = [];
11
+ return {
12
+ stripe: createStripeClient("sk_test_fixture", { httpClient: Stripe.createFetchHttpClient(async (input, init) => {
13
+ const request = new Request(input, init);
14
+ const url = new URL(request.url);
15
+ const call = {
16
+ method: request.method,
17
+ path: url.pathname,
18
+ query: url.searchParams,
19
+ body: new URLSearchParams(),
20
+ headers: new Headers(request.headers)
21
+ };
22
+ requests.push(call);
23
+ const body = new TextDecoder().decode(await request.clone().arrayBuffer());
24
+ for (const [key, value] of new URLSearchParams(body)) call.body.append(key, value);
25
+ return respond(request);
26
+ }) }),
27
+ requests
28
+ };
29
+ }
30
+
31
+ //#endregion
32
+ export { stripeFixture as t };
33
+ //# sourceMappingURL=stripe-fixture-yFUknTJO.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stripe-fixture-yFUknTJO.js","names":[],"sources":["../src/infrastructure/testing/stripe-fixture.ts"],"sourcesContent":["import Stripe from 'stripe'\n\nimport { createStripeClient } from '../stripe/client.ts'\n\n/** One SDK HTTP attempt; query and body expose Stripe's URL-encoded parameters. */\nexport type StripeCall = {\n readonly method: string\n readonly path: string\n readonly query: URLSearchParams\n readonly body: URLSearchParams\n readonly headers: Headers\n}\n\ntype StripeFixture = { stripe: Stripe; requests: readonly StripeCall[] }\n\n/**\n * Construct the real SDK with a fake fetch transport and an ordered record of HTTP attempts.\n * The responder receives an unread request. The shared client factory supplies retry defaults.\n */\nexport function stripeFixture(\n respond: (request: Request) => Response | Promise<Response>,\n): StripeFixture {\n const requests: StripeCall[] = []\n const stripe = createStripeClient('sk_test_fixture', {\n httpClient: Stripe.createFetchHttpClient(async (input, init) => {\n const request = new Request(input, init)\n const url = new URL(request.url)\n const call: StripeCall = {\n method: request.method,\n path: url.pathname,\n query: url.searchParams,\n body: new URLSearchParams(),\n headers: new Headers(request.headers),\n }\n requests.push(call)\n const body = new TextDecoder().decode(await request.clone().arrayBuffer())\n for (const [key, value] of new URLSearchParams(body)) call.body.append(key, value)\n return respond(request)\n }),\n })\n return { stripe, requests }\n}\n"],"mappings":";;;;;;;;AAmBA,SAAgB,cACd,SACe;CACf,MAAM,WAAyB,CAAC;CAkBhC,OAAO;EAAE,QAjBM,mBAAmB,mBAAmB,EACnD,YAAY,OAAO,sBAAsB,OAAO,OAAO,SAAS;GAC9D,MAAM,UAAU,IAAI,QAAQ,OAAO,IAAI;GACvC,MAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;GAC/B,MAAM,OAAmB;IACvB,QAAQ,QAAQ;IAChB,MAAM,IAAI;IACV,OAAO,IAAI;IACX,MAAM,IAAI,gBAAgB;IAC1B,SAAS,IAAI,QAAQ,QAAQ,OAAO;GACtC;GACA,SAAS,KAAK,IAAI;GAClB,MAAM,OAAO,IAAI,YAAY,CAAC,CAAC,OAAO,MAAM,QAAQ,MAAM,CAAC,CAAC,YAAY,CAAC;GACzE,KAAK,MAAM,CAAC,KAAK,UAAU,IAAI,gBAAgB,IAAI,GAAG,KAAK,KAAK,OAAO,KAAK,KAAK;GACjF,OAAO,QAAQ,OAAO;EACxB,CAAC,EACH,CACc;EAAG;CAAS;AAC5B"}
@@ -1,5 +1,5 @@
1
1
  import { W as DomainEvent } from "../index-DUFS0SJ_.js";
2
- import { A as IOutboxRepository } from "../index-tNZHSCpJ.js";
2
+ import { A as IOutboxRepository } from "../index-lS_g1olK.js";
3
3
  //#region src/infrastructure/testing/outbox-repository-contract.d.ts
4
4
  /** Stored fields observed by the adapter contract, independent of a database library. */
5
5
  export type OutboxContractRow = {
@@ -1,4 +1,4 @@
1
- import { IPaymentService } from "../stripe/payment-service.js";
1
+ import { i as IPaymentService } from "../payment-service-DUYd3Itt.js";
2
2
  import Stripe from "stripe";
3
3
  //#region src/infrastructure/testing/payment-service-contract.d.ts
4
4
  /**
@@ -1,5 +1,5 @@
1
- import { PaymentServiceError } from "../stripe/payment-service.error.js";
2
- import { stripeFixture } from "./stripe.js";
1
+ import { t as PaymentServiceError } from "../payment-service.error-CqF7GkNp.js";
2
+ import { t as stripeFixture } from "../stripe-fixture-yFUknTJO.js";
3
3
  import Stripe from "stripe";
4
4
  import { deepStrictEqual, equal, ok, rejects } from "node:assert/strict";
5
5
 
@@ -1,33 +1,3 @@
1
- import { createStripeClient } from "../stripe/client.js";
2
- import Stripe from "stripe";
1
+ import { t as stripeFixture } from "../stripe-fixture-yFUknTJO.js";
3
2
 
4
- //#region src/infrastructure/testing/stripe-fixture.ts
5
- /**
6
- * Construct the real SDK with a fake fetch transport and an ordered record of HTTP attempts.
7
- * The responder receives an unread request. The shared client factory supplies retry defaults.
8
- */
9
- function stripeFixture(respond) {
10
- const requests = [];
11
- return {
12
- stripe: createStripeClient("sk_test_fixture", { httpClient: Stripe.createFetchHttpClient(async (input, init) => {
13
- const request = new Request(input, init);
14
- const url = new URL(request.url);
15
- const call = {
16
- method: request.method,
17
- path: url.pathname,
18
- query: url.searchParams,
19
- body: new URLSearchParams(),
20
- headers: new Headers(request.headers)
21
- };
22
- requests.push(call);
23
- const body = new TextDecoder().decode(await request.clone().arrayBuffer());
24
- for (const [key, value] of new URLSearchParams(body)) call.body.append(key, value);
25
- return respond(request);
26
- }) }),
27
- requests
28
- };
29
- }
30
-
31
- //#endregion
32
- export { stripeFixture };
33
- //# sourceMappingURL=stripe.js.map
3
+ export { stripeFixture };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://www.schemastore.org/package.json",
3
3
  "name": "better-ship",
4
- "version": "0.24.0",
4
+ "version": "0.26.0",
5
5
  "description": "Foundation for applications on Cloudflare Workers: logger, errors, message bus, infrastructure adapters, and UI primitives",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -9,6 +9,9 @@
9
9
  "url": "git+https://github.com/alexander-zuev/better-ship.git",
10
10
  "directory": "packages/better-ship"
11
11
  },
12
+ "bin": {
13
+ "better-ship-notify-ci": "./dist/bin/notify-ci.js"
14
+ },
12
15
  "files": [
13
16
  "dist",
14
17
  "src"
@@ -23,6 +26,7 @@
23
26
  "./better-auth/secondary-storage": "./dist/better-auth/secondary-storage.js",
24
27
  "./better-auth/stripe": "./dist/better-auth/stripe.js",
25
28
  "./billing/client": "./dist/billing/client.js",
29
+ "./bin/notify-ci": "./dist/bin/notify-ci.js",
26
30
  "./cloudflare": "./dist/cloudflare.js",
27
31
  "./cloudflare/auth-secondary-storage": "./dist/cloudflare/auth-secondary-storage.js",
28
32
  "./cloudflare/durable-object-client": "./dist/cloudflare/durable-object-client.js",
@@ -46,6 +50,7 @@
46
50
  "./sentry/server": "./dist/sentry/server.js",
47
51
  "./sentry/tanstack": "./dist/sentry/tanstack.js",
48
52
  "./sentry/vite": "./dist/sentry/vite.js",
53
+ "./slack": "./dist/slack.js",
49
54
  "./stripe/client": "./dist/stripe/client.js",
50
55
  "./stripe/event-map": "./dist/stripe/event-map.js",
51
56
  "./stripe/payment-service": "./dist/stripe/payment-service.js",
@@ -35,6 +35,23 @@ export type OutboundEmail = {
35
35
  /** Received email headers, without the body or attachments. */
36
36
  export type ReceivedEmail = { readonly headers: Readonly<Record<string, string>> }
37
37
 
38
+ /** One attachment of a received email. `downloadUrl` is a signed link that stops working at `expiresAt`. */
39
+ export type ReceivedAttachment = {
40
+ readonly filename: string | null
41
+ readonly contentType: string
42
+ readonly size: number
43
+ readonly downloadUrl: string
44
+ readonly expiresAt: string
45
+ }
46
+
47
+ /** A received email's sender, plain text body, and attachments, for an application that reads mail it receives. */
48
+ export type ReceivedContent = {
49
+ /** The sender as the email states it, for example "Display Name <address@domain.com>". */
50
+ readonly from: string
51
+ readonly text: string | null
52
+ readonly attachments: readonly ReceivedAttachment[]
53
+ }
54
+
38
55
  /** The provider accepted the email and assigned its id. */
39
56
  export type EmailAccepted = { readonly providerId: string }
40
57
 
@@ -20,6 +20,8 @@ export {
20
20
  type MimeType,
21
21
  type EmailAccepted,
22
22
  type ReceivedEmail,
23
+ type ReceivedContent,
24
+ type ReceivedAttachment,
23
25
  } from './email-service.ts'
24
26
  export {
25
27
  recordEmailEvent,
@@ -7,6 +7,7 @@ import type {
7
7
  EmailAttachment,
8
8
  IEmailService,
9
9
  OutboundEmail,
10
+ ReceivedContent,
10
11
  ReceivedEmail,
11
12
  } from '@/application'
12
13
  import { EmailServiceError, type EmailSendFailureReason } from '@/core'
@@ -62,6 +63,20 @@ function retryAfterSeconds(value: string | undefined): number | null {
62
63
  return Number.isSafeInteger(seconds) ? seconds : null
63
64
  }
64
65
 
66
+ function receivingError(
67
+ error: ErrorResponse,
68
+ headers: Record<string, string> | null,
69
+ ): EmailServiceError {
70
+ return new EmailServiceError({
71
+ reason: reasonFromResend(error),
72
+ provider: 'resend',
73
+ providerCode: error.name,
74
+ statusCode: error.statusCode,
75
+ retryAfter: retryAfterSeconds(headers?.['retry-after']),
76
+ cause: error,
77
+ })
78
+ }
79
+
65
80
  function toAttachment(attachment: EmailAttachment): Attachment {
66
81
  const result: Attachment = {
67
82
  filename: attachment.filename,
@@ -139,6 +154,30 @@ export class ResendEmailService implements IEmailService {
139
154
  return { headers: result.data.headers ?? {} }
140
155
  }
141
156
 
157
+ /**
158
+ * Retrieve the sender, plain text body, and attachments by the received email's provider ID.
159
+ * Each attachment carries a signed download link. Provider failures throw EmailServiceError.
160
+ */
161
+ async getReceivedContent(emailId: string): Promise<ReceivedContent> {
162
+ const client = await this.getClient()
163
+ const email = await client.emails.receiving.get(emailId)
164
+ if (email.error !== null) throw receivingError(email.error, email.headers)
165
+ // One page of 100, the API maximum, holds the attachments of any received email.
166
+ const files = await client.emails.receiving.attachments.list({ emailId, limit: 100 })
167
+ if (files.error !== null) throw receivingError(files.error, files.headers)
168
+ return {
169
+ from: email.data.from,
170
+ text: email.data.text,
171
+ attachments: files.data.data.map((file) => ({
172
+ filename: file.filename ?? null,
173
+ contentType: file.content_type,
174
+ size: file.size,
175
+ downloadUrl: file.download_url,
176
+ expiresAt: file.expires_at,
177
+ })),
178
+ }
179
+ }
180
+
142
181
  private async getClient(): Promise<Resend> {
143
182
  if (this.client === undefined) {
144
183
  const { Resend } = await import('resend')
@@ -0,0 +1,276 @@
1
+ import { z } from 'zod'
2
+
3
+ /** The result of one CI job or step, in GitHub Actions terms. */
4
+ export type CiOutcome = 'success' | 'failure' | 'cancelled' | 'skipped'
5
+
6
+ /** What the run delivers after its checks pass. */
7
+ export type CiTarget =
8
+ | { readonly kind: 'check' }
9
+ | {
10
+ readonly kind: 'deploy'
11
+ readonly accountId: string
12
+ readonly worker: string
13
+ readonly siteUrl: string
14
+ /** Absent when the run reached no deploy, such as a pull request. */
15
+ readonly versionId: string | undefined
16
+ }
17
+ | {
18
+ readonly kind: 'publish'
19
+ readonly packageName: string
20
+ /** Absent when npm already has the package version. */
21
+ readonly version: string | undefined
22
+ }
23
+
24
+ /** One CI run, parsed from the environment that the workflow sets. */
25
+ export interface CiRun {
26
+ readonly repository: string
27
+ readonly commitSha: string
28
+ readonly ref:
29
+ | { readonly kind: 'branch'; readonly name: string }
30
+ | {
31
+ readonly kind: 'pullRequest'
32
+ readonly number: number
33
+ readonly url: string
34
+ readonly title: string
35
+ }
36
+ readonly runUrl: string
37
+ readonly jobs: ReadonlyArray<CiStepResult>
38
+ readonly steps: ReadonlyArray<CiStepResult>
39
+ readonly target: CiTarget
40
+ }
41
+
42
+ /** A named job or step and its outcome. */
43
+ export interface CiStepResult {
44
+ readonly name: string
45
+ readonly outcome: CiOutcome
46
+ }
47
+
48
+ /** The commit facts that only git knows. */
49
+ export interface CiCommit {
50
+ readonly subject: string
51
+ readonly author: string
52
+ }
53
+
54
+ /** A Slack incoming-webhook payload. */
55
+ export interface SlackMessage {
56
+ readonly text: string
57
+ readonly blocks: ReadonlyArray<SlackBlock>
58
+ }
59
+
60
+ type SlackElement =
61
+ | { readonly type: 'mrkdwn'; readonly text: string }
62
+ | { readonly type: 'image'; readonly image_url: string; readonly alt_text: string }
63
+
64
+ type SlackBlock =
65
+ | { readonly type: 'section'; readonly text: { readonly type: 'mrkdwn'; readonly text: string } }
66
+ | { readonly type: 'context'; readonly elements: ReadonlyArray<SlackElement> }
67
+
68
+ /** The CI environment could not be parsed. `issues` names each invalid variable. */
69
+ export class CiRunParseError extends Error {
70
+ readonly _tag = 'CiRunParseError' as const
71
+
72
+ constructor(
73
+ readonly issues: ReadonlyArray<string>,
74
+ override readonly cause: unknown,
75
+ ) {
76
+ super(`Invalid CI environment: ${issues.join(', ')}`)
77
+ }
78
+ }
79
+
80
+ const OutcomeSchema = z.enum(['success', 'failure', 'cancelled', 'skipped'])
81
+
82
+ const jsonRecord = <T extends z.ZodType>(value: T) =>
83
+ z
84
+ .string()
85
+ .transform((text, context) => {
86
+ try {
87
+ const value: unknown = JSON.parse(text)
88
+ return value
89
+ } catch {
90
+ context.addIssue({ code: 'custom', message: 'Invalid JSON' })
91
+ return z.NEVER
92
+ }
93
+ })
94
+ .pipe(z.record(z.string(), value))
95
+
96
+ const CiEnvSchema = z.object({
97
+ REPO: z.string().regex(/^[\w.-]+\/[\w.-]+$/),
98
+ COMMIT_SHA: z.string().regex(/^[0-9a-f]{40}$/),
99
+ BRANCH: z.string().min(1),
100
+ RUN_URL: z.httpUrl(),
101
+ JOBS_JSON: jsonRecord(z.object({ result: OutcomeSchema })).optional(),
102
+ STEPS_JSON: jsonRecord(z.object({ outcome: OutcomeSchema })).optional(),
103
+ PR_NUMBER: z.coerce.number().int().positive().optional(),
104
+ PR_URL: z.httpUrl().optional(),
105
+ PR_TITLE: z.string().min(1).optional(),
106
+ ACTION: z.enum(['check', 'deploy', 'publish']).default('check'),
107
+ CLOUDFLARE_ACCOUNT_ID: z.string().min(1).optional(),
108
+ CLOUDFLARE_WORKER: z.string().min(1).optional(),
109
+ SITE_URL: z.httpUrl().optional(),
110
+ DEPLOY_VERSION_ID: z.string().min(1).optional(),
111
+ NPM_PACKAGE: z.string().min(1).optional(),
112
+ NPM_VERSION: z.string().min(1).optional(),
113
+ })
114
+
115
+ type CiEnv = z.infer<typeof CiEnvSchema>
116
+
117
+ /**
118
+ * Parse the variables that a workflow passes to the notifier. GitHub expressions render a
119
+ * missing value as an empty string, so an empty variable counts as unset.
120
+ * @throws {CiRunParseError} when a variable is missing or invalid.
121
+ */
122
+ export function parseCiRun(env: Readonly<Record<string, string | undefined>>): CiRun {
123
+ const present = Object.fromEntries(Object.entries(env).filter(([, value]) => value !== ''))
124
+ const parsed = CiEnvSchema.safeParse(present)
125
+ if (!parsed.success) {
126
+ const issues = parsed.error.issues.map((issue) => issue.path.join('.'))
127
+ throw new CiRunParseError(issues, parsed.error)
128
+ }
129
+ return toCiRun(parsed.data)
130
+ }
131
+
132
+ function toCiRun(env: CiEnv): CiRun {
133
+ const ref: CiRun['ref'] =
134
+ env.PR_NUMBER !== undefined && env.PR_URL !== undefined && env.PR_TITLE !== undefined
135
+ ? { kind: 'pullRequest', number: env.PR_NUMBER, url: env.PR_URL, title: env.PR_TITLE }
136
+ : { kind: 'branch', name: env.BRANCH }
137
+ return {
138
+ repository: env.REPO,
139
+ commitSha: env.COMMIT_SHA,
140
+ ref,
141
+ runUrl: env.RUN_URL,
142
+ jobs: Object.entries(env.JOBS_JSON ?? {}).map(([id, job]) => ({
143
+ name: displayName(id),
144
+ outcome: job.result,
145
+ })),
146
+ steps: Object.entries(env.STEPS_JSON ?? {}).map(([id, step]) => ({
147
+ name: displayName(id),
148
+ outcome: step.outcome,
149
+ })),
150
+ target: toTarget(env),
151
+ }
152
+ }
153
+
154
+ function toTarget(env: CiEnv): CiTarget {
155
+ switch (env.ACTION) {
156
+ case 'check':
157
+ return { kind: 'check' }
158
+ case 'deploy': {
159
+ const { CLOUDFLARE_ACCOUNT_ID: accountId, CLOUDFLARE_WORKER: worker, SITE_URL: siteUrl } = env
160
+ if (accountId === undefined || worker === undefined || siteUrl === undefined) {
161
+ throw new CiRunParseError(
162
+ ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_WORKER', 'SITE_URL'],
163
+ undefined,
164
+ )
165
+ }
166
+ return { kind: 'deploy', accountId, worker, siteUrl, versionId: env.DEPLOY_VERSION_ID }
167
+ }
168
+ case 'publish':
169
+ if (env.NPM_PACKAGE === undefined) throw new CiRunParseError(['NPM_PACKAGE'], undefined)
170
+ return { kind: 'publish', packageName: env.NPM_PACKAGE, version: env.NPM_VERSION }
171
+ }
172
+ }
173
+
174
+ // A job or step id such as `check-and-build` reads as "Check and build".
175
+ function displayName(id: string): string {
176
+ const words = id.replaceAll(/[-_]/g, ' ')
177
+ return words.charAt(0).toUpperCase() + words.slice(1)
178
+ }
179
+
180
+ const logos = {
181
+ depot: 'https://avatars.githubusercontent.com/u/68166601?s=64',
182
+ github: 'https://avatars.githubusercontent.com/u/9919?s=64',
183
+ cloudflare: 'https://avatars.githubusercontent.com/u/314135?s=64',
184
+ npm: 'https://avatars.githubusercontent.com/u/6078720?s=64',
185
+ } as const
186
+
187
+ /** Build the four-line CI message: result, change, links, and identity. */
188
+ export function buildCiMessage(run: CiRun, commit: CiCommit): SlackMessage {
189
+ const headline = headlineFor(run)
190
+ const change = run.ref.kind === 'pullRequest' ? run.ref.title : commit.subject
191
+ return {
192
+ text: `${headline} — ${change}`,
193
+ blocks: [
194
+ { type: 'section', text: { type: 'mrkdwn', text: `*${headline}*\n${escape(change)}` } },
195
+ { type: 'context', elements: linkElements(run) },
196
+ { type: 'context', elements: [{ type: 'mrkdwn', text: identityLine(run, commit) }] },
197
+ ],
198
+ }
199
+ }
200
+
201
+ function headlineFor(run: CiRun): string {
202
+ const failedJobs = namesWith(run.jobs, 'failure')
203
+ if (failedJobs) return `:x: CI failed: ${failedJobs}`
204
+ const cancelledJobs = namesWith(run.jobs, 'cancelled')
205
+ if (cancelledJobs) return `:white_circle: CI cancelled: ${cancelledJobs}`
206
+ const failedSteps = namesWith(run.steps, 'failure')
207
+ const { target } = run
208
+ switch (target.kind) {
209
+ case 'check':
210
+ return failedSteps ? `:x: CI failed: ${failedSteps}` : ':white_check_mark: CI passed'
211
+ case 'deploy':
212
+ if (failedSteps) return `:x: Deploy failed: ${failedSteps}`
213
+ return target.versionId === undefined
214
+ ? ':white_check_mark: CI passed'
215
+ : ':white_check_mark: Deployed to production'
216
+ case 'publish':
217
+ if (failedSteps) return `:x: Publish failed: ${failedSteps}`
218
+ return target.version === undefined
219
+ ? ':white_check_mark: CI passed, nothing to publish'
220
+ : `:white_check_mark: Published ${target.packageName} ${target.version}`
221
+ }
222
+ }
223
+
224
+ function namesWith(results: ReadonlyArray<CiStepResult>, outcome: CiOutcome): string {
225
+ return results
226
+ .filter((result) => result.outcome === outcome)
227
+ .map((result) => result.name)
228
+ .join(', ')
229
+ }
230
+
231
+ function linkElements(run: CiRun): ReadonlyArray<SlackElement> {
232
+ const runHost = new URL(run.runUrl).hostname
233
+ const runLogo = runHost.endsWith('github.com') ? logos.github : logos.depot
234
+ const elements: SlackElement[] = [
235
+ { type: 'image', image_url: runLogo, alt_text: runHost },
236
+ { type: 'mrkdwn', text: `<${run.runUrl}|CI run>` },
237
+ ]
238
+ const { target } = run
239
+ if (target.kind === 'deploy' && target.versionId !== undefined) {
240
+ const dashboard = `https://dash.cloudflare.com/${target.accountId}/workers/services/view/${target.worker}/production/deployments`
241
+ const site = new URL(target.siteUrl)
242
+ elements.push(
243
+ { type: 'mrkdwn', text: '·' },
244
+ { type: 'image', image_url: logos.cloudflare, alt_text: 'Cloudflare' },
245
+ { type: 'mrkdwn', text: `<${dashboard}|Version \`${target.versionId.slice(0, 8)}\`>` },
246
+ { type: 'mrkdwn', text: `· :globe_with_meridians: <${site.origin}|${site.host}>` },
247
+ )
248
+ }
249
+ if (target.kind === 'publish' && target.version !== undefined) {
250
+ const npm = `https://www.npmjs.com/package/${target.packageName}/v/${target.version}`
251
+ elements.push(
252
+ { type: 'mrkdwn', text: '·' },
253
+ { type: 'image', image_url: logos.npm, alt_text: 'npm' },
254
+ { type: 'mrkdwn', text: `<${npm}|${target.packageName}@${target.version}>` },
255
+ )
256
+ }
257
+ return elements
258
+ }
259
+
260
+ function identityLine(run: CiRun, commit: CiCommit): string {
261
+ const repository = run.repository.split('/').at(-1) ?? run.repository
262
+ const ref =
263
+ run.ref.kind === 'pullRequest' ? `<${run.ref.url}|PR #${run.ref.number}>` : run.ref.name
264
+ const commitUrl = `https://github.com/${run.repository}/commit/${run.commitSha}`
265
+ return [
266
+ repository,
267
+ ref,
268
+ `<${commitUrl}|\`${run.commitSha.slice(0, 7)}\`>`,
269
+ escape(commit.author),
270
+ ].join(' · ')
271
+ }
272
+
273
+ // Slack mrkdwn treats these three characters as markup.
274
+ function escape(text: string): string {
275
+ return text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
276
+ }
@@ -0,0 +1,10 @@
1
+ export { buildCiMessage, CiRunParseError, parseCiRun } from './ci-message.ts'
2
+ export type {
3
+ CiCommit,
4
+ CiOutcome,
5
+ CiRun,
6
+ CiStepResult,
7
+ CiTarget,
8
+ SlackMessage,
9
+ } from './ci-message.ts'
10
+ export { postSlackMessage, SlackWebhookError } from './slack-webhook.ts'