pipework 0.252.0 → 0.253.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/dist/infra/support/handle.d.ts +8 -1
- package/dist/infra/support/handle.d.ts.map +1 -1
- package/dist/infra/support/handle.js +9 -2
- package/dist/infra/support/handle.js.map +1 -1
- package/dist/infra/support/namespace.d.ts.map +1 -1
- package/package.json +4 -3
- package/src/async/jobs/abortable-delay.ts +18 -0
- package/src/async/jobs/claims.ts +52 -0
- package/src/async/jobs/cron.ts +179 -0
- package/src/async/jobs/errors.ts +56 -0
- package/src/async/jobs/exclusion.ts +117 -0
- package/src/async/jobs/execute.ts +219 -0
- package/src/async/jobs/index.ts +10 -0
- package/src/async/jobs/listener.ts +135 -0
- package/src/async/jobs/namespace.ts +20 -0
- package/src/async/jobs/queue-door.ts +44 -0
- package/src/async/jobs/queue.ts +1012 -0
- package/src/async/jobs/run-script.ts +67 -0
- package/src/async/jobs/scheduler.ts +218 -0
- package/src/async/jobs/schedules-table.ts +37 -0
- package/src/async/jobs/serialize-error.ts +48 -0
- package/src/async/jobs/table.ts +191 -0
- package/src/async/pipeline/define.ts +53 -0
- package/src/async/pipeline/errors.ts +35 -0
- package/src/async/pipeline/execute.ts +53 -0
- package/src/async/pipeline/index.ts +13 -0
- package/src/async/pipeline/namespace.ts +12 -0
- package/src/async/pipeline/resume.ts +67 -0
- package/src/async/pipeline/run-steps.ts +98 -0
- package/src/async/pipeline/types.ts +57 -0
- package/src/async/state-machine/define.ts +64 -0
- package/src/async/state-machine/errors.ts +29 -0
- package/src/async/state-machine/index.ts +3 -0
- package/src/async/state-machine/namespace.ts +12 -0
- package/src/async/state-machine/transition.ts +90 -0
- package/src/async/state-machine/types.ts +37 -0
- package/src/async/steps/errors.ts +31 -0
- package/src/async/steps/graph.ts +1214 -0
- package/src/async/steps/hash.ts +40 -0
- package/src/async/steps/index.ts +7 -0
- package/src/async/steps/namespace.ts +7 -0
- package/src/async/steps/table.ts +97 -0
- package/src/async/steps/types.ts +318 -0
- package/src/audit/chain/index.ts +9 -0
- package/src/audit/chain/install.ts +113 -0
- package/src/audit/chain/retire.ts +52 -0
- package/src/audit/chain/verify.ts +257 -0
- package/src/audit/query/cursor.ts +37 -0
- package/src/audit/query/index.ts +9 -0
- package/src/audit/query/query.ts +223 -0
- package/src/audit/schema/audit-record-fields.ts +68 -0
- package/src/audit/schema/audit-record.ts +23 -0
- package/src/auth/auth/audit.ts +31 -0
- package/src/auth/auth/chain.ts +84 -0
- package/src/auth/auth/cookie.ts +136 -0
- package/src/auth/auth/email-links.ts +311 -0
- package/src/auth/auth/guard.ts +128 -0
- package/src/auth/auth/index.ts +18 -0
- package/src/auth/auth/multi-org.ts +337 -0
- package/src/auth/auth/namespace.ts +34 -0
- package/src/auth/auth/parse-expiry.ts +13 -0
- package/src/auth/auth/passwords.ts +110 -0
- package/src/auth/auth/schema.ts +110 -0
- package/src/auth/auth/sessions.ts +565 -0
- package/src/auth/auth/sign-in.ts +156 -0
- package/src/auth/auth/signing-keys.ts +69 -0
- package/src/auth/auth/types.ts +26 -0
- package/src/auth/auth/uuid.ts +10 -0
- package/src/auth/rbac/cache.ts +55 -0
- package/src/auth/rbac/check.ts +30 -0
- package/src/auth/rbac/create.ts +35 -0
- package/src/auth/rbac/index.ts +5 -0
- package/src/auth/rbac/middleware.ts +20 -0
- package/src/auth/rbac/namespace.ts +15 -0
- package/src/auth/rbac/resolve.ts +31 -0
- package/src/auth/rbac/schema.sql +31 -0
- package/src/auth/rbac/seed.ts +67 -0
- package/src/auth/rbac/tables.ts +32 -0
- package/src/auth/rbac/types.ts +65 -0
- package/src/auth/tenant/extract.ts +33 -0
- package/src/auth/tenant/in-tenant.ts +49 -0
- package/src/auth/tenant/index.ts +7 -0
- package/src/auth/tenant/namespace.ts +23 -0
- package/src/auth/tenant/propagate.ts +20 -0
- package/src/auth/tenant/rls.ts +120 -0
- package/src/auth/tenant/scope.ts +33 -0
- package/src/auth/tenant/scoped-db.ts +581 -0
- package/src/auth/tenant/types.ts +14 -0
- package/src/auth/tenant/validate.ts +27 -0
- package/src/cli/commands/add.ts +158 -0
- package/src/cli/commands/alerts.ts +98 -0
- package/src/cli/commands/check.ts +1418 -0
- package/src/cli/commands/codemod.ts +78 -0
- package/src/cli/commands/db.ts +356 -0
- package/src/cli/commands/dev.ts +101 -0
- package/src/cli/commands/generate.ts +75 -0
- package/src/cli/commands/init.ts +36 -0
- package/src/cli/commands/install.ts +28 -0
- package/src/cli/commands/lint.ts +62 -0
- package/src/cli/commands/migrate.ts +132 -0
- package/src/cli/commands/openapi.ts +63 -0
- package/src/cli/commands/remove.ts +72 -0
- package/src/cli/commands/run.ts +50 -0
- package/src/cli/commands/scope.ts +54 -0
- package/src/cli/commands/serve.ts +68 -0
- package/src/cli/commands/shard.ts +31 -0
- package/src/cli/commands/shared/config-pipework-version.ts +28 -0
- package/src/cli/commands/shared/find-config.ts +22 -0
- package/src/cli/commands/shared/load-config.ts +35 -0
- package/src/cli/commands/shared/pnpm-install.ts +95 -0
- package/src/cli/commands/shared/report-removed.ts +8 -0
- package/src/cli/commands/sync.ts +32 -0
- package/src/cli/commands/test.ts +307 -0
- package/src/cli/commands/upgrade.ts +48 -0
- package/src/cli/dev-bootstrap.ts +24 -0
- package/src/cli/errors.ts +10 -0
- package/src/cli/index.ts +303 -0
- package/src/cli/output.ts +17 -0
- package/src/cli/port.ts +15 -0
- package/src/cli/surface-kinds.ts +20 -0
- package/src/client/index.ts +4 -0
- package/src/client/poll.ts +130 -0
- package/src/client/report-fault.ts +119 -0
- package/src/core/config/app-pool.ts +19 -0
- package/src/core/config/aws-env.ts +29 -0
- package/src/core/config/base-dir.ts +24 -0
- package/src/core/config/discover.ts +64 -0
- package/src/core/config/env-resolve.ts +87 -0
- package/src/core/config/env-types.ts +116 -0
- package/src/core/config/env.ts +55 -0
- package/src/core/config/errors.ts +9 -0
- package/src/core/config/import-config.ts +40 -0
- package/src/core/config/index.ts +8 -0
- package/src/core/config/load.ts +417 -0
- package/src/core/config/namespace.ts +14 -0
- package/src/core/config/resolve.ts +195 -0
- package/src/core/config/schema.ts +488 -0
- package/src/core/config/statement-observer.ts +48 -0
- package/src/core/config/ts-inline-hooks.ts +75 -0
- package/src/core/config/ts-register.ts +4 -0
- package/src/core/config/ts-resolve-hooks.ts +101 -0
- package/src/core/errors/base.ts +49 -0
- package/src/core/errors/http.ts +146 -0
- package/src/core/errors/index.ts +2 -0
- package/src/core/errors/namespace.ts +31 -0
- package/src/core/lifecycle/drain.ts +33 -0
- package/src/core/lifecycle/health.ts +72 -0
- package/src/core/lifecycle/index.ts +4 -0
- package/src/core/lifecycle/namespace.ts +7 -0
- package/src/core/lifecycle/startup.ts +102 -0
- package/src/core/lifecycle/types.ts +36 -0
- package/src/core/logging/context.ts +60 -0
- package/src/core/logging/error-chain.ts +68 -0
- package/src/core/logging/index.ts +5 -0
- package/src/core/logging/logger.ts +65 -0
- package/src/core/logging/namespace.ts +16 -0
- package/src/core/logging/proxy.ts +22 -0
- package/src/core/logging/redact-match.ts +24 -0
- package/src/core/logging/types.ts +47 -0
- package/src/core/pipework.ts +566 -0
- package/src/core/runtime/broker.ts +69 -0
- package/src/core/surface/http.ts +133 -0
- package/src/core/surface/index.ts +11 -0
- package/src/core/surface/job-timeout.ts +40 -0
- package/src/core/surface/namespace.ts +13 -0
- package/src/core/surface/script.ts +33 -0
- package/src/core/surface/start.ts +142 -0
- package/src/core/surface/types.ts +225 -0
- package/src/core/surface/validate.ts +88 -0
- package/src/core/surface/worker.ts +528 -0
- package/src/core/validation/connectivity.ts +49 -0
- package/src/core/validation/domain.ts +99 -0
- package/src/core/validation/index.ts +5 -0
- package/src/core/validation/queue-tables.ts +312 -0
- package/src/core/validation/rls-role.ts +138 -0
- package/src/core/validation/startup.ts +51 -0
- package/src/core/validation/surfaces.ts +65 -0
- package/src/core/validation/types.ts +25 -0
- package/src/data/db/advisory-lock.ts +55 -0
- package/src/data/db/aggregates.ts +172 -0
- package/src/data/db/array-elements.ts +111 -0
- package/src/data/db/bulk-set.ts +53 -0
- package/src/data/db/col/index.ts +14 -0
- package/src/data/db/composed.ts +53 -0
- package/src/data/db/db.ts +276 -0
- package/src/data/db/excluded.ts +16 -0
- package/src/data/db/expressions.ts +473 -0
- package/src/data/db/filter.ts +90 -0
- package/src/data/db/function-source.ts +114 -0
- package/src/data/db/identifiers.ts +16 -0
- package/src/data/db/idx/index.ts +9 -0
- package/src/data/db/in-list.ts +49 -0
- package/src/data/db/index.ts +9 -0
- package/src/data/db/insert-split.ts +137 -0
- package/src/data/db/instrumentation.ts +82 -0
- package/src/data/db/maintenance.ts +28 -0
- package/src/data/db/namespace.ts +107 -0
- package/src/data/db/owner-labels.ts +9 -0
- package/src/data/db/pool.ts +187 -0
- package/src/data/db/query-label.ts +13 -0
- package/src/data/db/query-watch.ts +116 -0
- package/src/data/db/raw-client-warning.ts +37 -0
- package/src/data/db/readonly.ts +23 -0
- package/src/data/db/reservation.ts +151 -0
- package/src/data/db/schema-features.ts +14 -0
- package/src/data/db/serializable.ts +83 -0
- package/src/data/db/sql.ts +1 -0
- package/src/data/db/timeouts.ts +76 -0
- package/src/data/db/types.ts +18 -0
- package/src/data/db/unbound.ts +33 -0
- package/src/data/db/values.ts +29 -0
- package/src/data/domain/brand.ts +15 -0
- package/src/data/domain/build-factory.ts +193 -0
- package/src/data/domain/build-schema.ts +161 -0
- package/src/data/domain/build-table.ts +355 -0
- package/src/data/domain/define.ts +239 -0
- package/src/data/domain/external-registry.ts +34 -0
- package/src/data/domain/external.ts +77 -0
- package/src/data/domain/field.ts +279 -0
- package/src/data/domain/index.ts +13 -0
- package/src/data/domain/naming.ts +6 -0
- package/src/data/domain/projections.ts +57 -0
- package/src/data/domain/trace-safe-projection.ts +31 -0
- package/src/data/domain/traits.ts +108 -0
- package/src/data/domain/types.ts +289 -0
- package/src/data/jsonb/composed.ts +18 -0
- package/src/data/jsonb/index.ts +1 -0
- package/src/data/jsonb/namespace.ts +30 -0
- package/src/data/jsonb/query.ts +86 -0
- package/src/data/migrate/append-only-generate.ts +47 -0
- package/src/data/migrate/applied.ts +47 -0
- package/src/data/migrate/apply.ts +309 -0
- package/src/data/migrate/check.ts +119 -0
- package/src/data/migrate/diff.ts +357 -0
- package/src/data/migrate/door-generate.ts +130 -0
- package/src/data/migrate/framework-rls.ts +58 -0
- package/src/data/migrate/generate.ts +201 -0
- package/src/data/migrate/history.ts +215 -0
- package/src/data/migrate/index.ts +9 -0
- package/src/data/migrate/internal-definitions.ts +175 -0
- package/src/data/migrate/post-process.ts +73 -0
- package/src/data/migrate/pragma.ts +73 -0
- package/src/data/migrate/provision-framework.ts +147 -0
- package/src/data/migrate/rls-generate.ts +61 -0
- package/src/data/migrate/run.ts +124 -0
- package/src/data/migrate/safety.ts +167 -0
- package/src/data/migrate/snapshot.ts +368 -0
- package/src/data/migrate/sql-emit.ts +392 -0
- package/src/data/migrate/statements.ts +312 -0
- package/src/data/query/alias.ts +126 -0
- package/src/data/query/batch.ts +8 -0
- package/src/data/query/cache/core/cache.ts +78 -0
- package/src/data/query/cache/core/index.ts +1 -0
- package/src/data/query/cache/core/types.ts +29 -0
- package/src/data/query/cache/readme.md +230 -0
- package/src/data/query/casing.ts +76 -0
- package/src/data/query/collation.ts +11 -0
- package/src/data/query/column-builder.ts +370 -0
- package/src/data/query/column.ts +150 -0
- package/src/data/query/entity.ts +42 -0
- package/src/data/query/errors.ts +33 -0
- package/src/data/query/index.ts +14 -0
- package/src/data/query/logger.ts +47 -0
- package/src/data/query/operations.ts +45 -0
- package/src/data/query/pg-core/alias.ts +12 -0
- package/src/data/query/pg-core/checks.ts +32 -0
- package/src/data/query/pg-core/columns/all.ts +71 -0
- package/src/data/query/pg-core/columns/bigint.ts +110 -0
- package/src/data/query/pg-core/columns/bigserial.ts +128 -0
- package/src/data/query/pg-core/columns/boolean.ts +43 -0
- package/src/data/query/pg-core/columns/char.ts +85 -0
- package/src/data/query/pg-core/columns/cidr.ts +43 -0
- package/src/data/query/pg-core/columns/common.ts +366 -0
- package/src/data/query/pg-core/columns/custom.ts +232 -0
- package/src/data/query/pg-core/columns/date.common.ts +15 -0
- package/src/data/query/pg-core/columns/date.ts +112 -0
- package/src/data/query/pg-core/columns/double-precision.ts +55 -0
- package/src/data/query/pg-core/columns/enum.ts +201 -0
- package/src/data/query/pg-core/columns/index.ts +35 -0
- package/src/data/query/pg-core/columns/inet.ts +43 -0
- package/src/data/query/pg-core/columns/int.common.ts +57 -0
- package/src/data/query/pg-core/columns/integer.ts +53 -0
- package/src/data/query/pg-core/columns/interval.ts +83 -0
- package/src/data/query/pg-core/columns/json.ts +58 -0
- package/src/data/query/pg-core/columns/jsonb.ts +56 -0
- package/src/data/query/pg-core/columns/line.ts +117 -0
- package/src/data/query/pg-core/columns/macaddr.ts +43 -0
- package/src/data/query/pg-core/columns/macaddr8.ts +43 -0
- package/src/data/query/pg-core/columns/numeric.ts +227 -0
- package/src/data/query/pg-core/columns/point.ts +127 -0
- package/src/data/query/pg-core/columns/postgis_extension/geometry.ts +119 -0
- package/src/data/query/pg-core/columns/postgis_extension/utils.ts +47 -0
- package/src/data/query/pg-core/columns/real.ts +58 -0
- package/src/data/query/pg-core/columns/serial.ts +55 -0
- package/src/data/query/pg-core/columns/smallint.ts +53 -0
- package/src/data/query/pg-core/columns/smallserial.ts +60 -0
- package/src/data/query/pg-core/columns/text.ts +67 -0
- package/src/data/query/pg-core/columns/time.ts +72 -0
- package/src/data/query/pg-core/columns/timestamp.ts +163 -0
- package/src/data/query/pg-core/columns/uuid.ts +51 -0
- package/src/data/query/pg-core/columns/varchar.ts +89 -0
- package/src/data/query/pg-core/columns/vector_extension/bit.ts +68 -0
- package/src/data/query/pg-core/columns/vector_extension/halfvec.ts +80 -0
- package/src/data/query/pg-core/columns/vector_extension/sparsevec.ts +67 -0
- package/src/data/query/pg-core/columns/vector_extension/vector.ts +80 -0
- package/src/data/query/pg-core/db.ts +674 -0
- package/src/data/query/pg-core/dialect.ts +1404 -0
- package/src/data/query/pg-core/expressions.ts +25 -0
- package/src/data/query/pg-core/foreign-keys.ts +130 -0
- package/src/data/query/pg-core/index.ts +21 -0
- package/src/data/query/pg-core/indexes.ts +276 -0
- package/src/data/query/pg-core/policies.ts +56 -0
- package/src/data/query/pg-core/primary-keys.ts +62 -0
- package/src/data/query/pg-core/query-builders/count.ts +86 -0
- package/src/data/query/pg-core/query-builders/delete.ts +293 -0
- package/src/data/query/pg-core/query-builders/index.ts +7 -0
- package/src/data/query/pg-core/query-builders/insert.ts +452 -0
- package/src/data/query/pg-core/query-builders/query-builder.ts +123 -0
- package/src/data/query/pg-core/query-builders/query.ts +157 -0
- package/src/data/query/pg-core/query-builders/raw.ts +49 -0
- package/src/data/query/pg-core/query-builders/refresh-materialized-view.ts +108 -0
- package/src/data/query/pg-core/query-builders/select.ts +1346 -0
- package/src/data/query/pg-core/query-builders/select.types.ts +437 -0
- package/src/data/query/pg-core/query-builders/update.ts +626 -0
- package/src/data/query/pg-core/roles.ts +41 -0
- package/src/data/query/pg-core/schema.ts +72 -0
- package/src/data/query/pg-core/sequence.ts +41 -0
- package/src/data/query/pg-core/session.ts +292 -0
- package/src/data/query/pg-core/subquery.ts +46 -0
- package/src/data/query/pg-core/table.ts +252 -0
- package/src/data/query/pg-core/unique-constraint.ts +73 -0
- package/src/data/query/pg-core/utils/array.ts +95 -0
- package/src/data/query/pg-core/utils/index.ts +1 -0
- package/src/data/query/pg-core/utils.ts +104 -0
- package/src/data/query/pg-core/view-base.ts +14 -0
- package/src/data/query/pg-core/view-common.ts +1 -0
- package/src/data/query/pg-core/view.ts +435 -0
- package/src/data/query/pg-core/with-builder.ts +41 -0
- package/src/data/query/postgres-js/README.md +46 -0
- package/src/data/query/postgres-js/array-parser.ts +45 -0
- package/src/data/query/postgres-js/driver-error-boundary.ts +149 -0
- package/src/data/query/postgres-js/driver.ts +163 -0
- package/src/data/query/postgres-js/index.ts +2 -0
- package/src/data/query/postgres-js/session.ts +234 -0
- package/src/data/query/primary-key.ts +11 -0
- package/src/data/query/query-builders/query-builder.ts +19 -0
- package/src/data/query/query-builders/select.types.ts +175 -0
- package/src/data/query/query-promise.ts +35 -0
- package/src/data/query/relations.ts +725 -0
- package/src/data/query/runnable-query.ts +12 -0
- package/src/data/query/selection-proxy.ts +121 -0
- package/src/data/query/session.ts +8 -0
- package/src/data/query/sql/expressions/conditions.ts +741 -0
- package/src/data/query/sql/expressions/index.ts +2 -0
- package/src/data/query/sql/expressions/select.ts +45 -0
- package/src/data/query/sql/functions/aggregate.ts +129 -0
- package/src/data/query/sql/functions/index.ts +2 -0
- package/src/data/query/sql/functions/vector.ts +178 -0
- package/src/data/query/sql/index.ts +3 -0
- package/src/data/query/sql/sql.ts +794 -0
- package/src/data/query/subquery.ts +53 -0
- package/src/data/query/table.ts +208 -0
- package/src/data/query/table.utils.ts +2 -0
- package/src/data/query/tracing-utils.ts +3 -0
- package/src/data/query/tracing.ts +11 -0
- package/src/data/query/utils.ts +357 -0
- package/src/data/query/version.ts +4 -0
- package/src/data/query/view-common.ts +1 -0
- package/src/data/schema/branded.ts +13 -0
- package/src/data/schema/generated/coerce.ts +5 -0
- package/src/data/schema/generated/combinator.ts +6 -0
- package/src/data/schema/generated/encoding.ts +5 -0
- package/src/data/schema/generated/error.ts +6 -0
- package/src/data/schema/generated/format.ts +5 -0
- package/src/data/schema/generated/type-constructor.ts +10 -0
- package/src/data/schema/generated/type.ts +6 -0
- package/src/data/schema/index.ts +6 -0
- package/src/data/schema/json-schema.ts +33 -0
- package/src/data/schema/namespace.ts +55 -0
- package/src/data/schema/parse.ts +39 -0
- package/src/data/schema/types.ts +39 -0
- package/src/data/temporal/append-only.ts +134 -0
- package/src/data/temporal/close.ts +27 -0
- package/src/data/temporal/columns.ts +10 -0
- package/src/data/temporal/definition-queries.ts +70 -0
- package/src/data/temporal/errors.ts +10 -0
- package/src/data/temporal/get-current.ts +22 -0
- package/src/data/temporal/index.ts +9 -0
- package/src/data/temporal/namespace.ts +36 -0
- package/src/data/temporal/query.ts +25 -0
- package/src/data/temporal/resolve.ts +17 -0
- package/src/data/temporal/revise.ts +67 -0
- package/src/data/temporal/types.ts +22 -0
- package/src/data/valve/artifact.ts +33 -0
- package/src/data/valve/contract-diff.ts +21 -0
- package/src/data/valve/cross.ts +57 -0
- package/src/data/valve/crossing-ledger.ts +50 -0
- package/src/data/valve/decision.ts +44 -0
- package/src/data/valve/durable-crossing.ts +52 -0
- package/src/data/valve/execute-crossing.ts +77 -0
- package/src/data/valve/faucet.ts +56 -0
- package/src/data/valve/gate.ts +114 -0
- package/src/data/valve/graph.ts +77 -0
- package/src/data/valve/index.ts +53 -0
- package/src/data/valve/namespace.ts +57 -0
- package/src/data/valve/registry.ts +50 -0
- package/src/data/valve/schema/valve-crossing-fields.ts +23 -0
- package/src/data/valve/schema/valve-crossing.ts +9 -0
- package/src/data/valve/supply.ts +154 -0
- package/src/data/valve/types.ts +143 -0
- package/src/data/vector/columns.ts +27 -0
- package/src/data/vector/index.ts +4 -0
- package/src/data/vector/namespace.ts +33 -0
- package/src/data/vector/query.ts +85 -0
- package/src/data/vector/validate.ts +16 -0
- package/src/data/write/audit/actor.ts +15 -0
- package/src/data/write/audit/audit-columns.ts +18 -0
- package/src/data/write/audit/audit-select.ts +111 -0
- package/src/data/write/audit/bound-transaction.ts +14 -0
- package/src/data/write/audit/cte-compile.ts +32 -0
- package/src/data/write/audit/decode-returning.ts +47 -0
- package/src/data/write/audit/final-select.ts +43 -0
- package/src/data/write/audit/infra-audit.ts +36 -0
- package/src/data/write/audit/primary-key.ts +65 -0
- package/src/data/write/audit/projection.ts +41 -0
- package/src/data/write/audit/read-touches.ts +86 -0
- package/src/data/write/audit/touch-match.ts +78 -0
- package/src/data/write/audit/verb-delete.ts +62 -0
- package/src/data/write/audit/verb-insert.ts +36 -0
- package/src/data/write/audit/verb-select.ts +47 -0
- package/src/data/write/audit/verb-update.ts +60 -0
- package/src/data/write/audit/verb-upsert.ts +104 -0
- package/src/data/write/conflict.ts +43 -0
- package/src/data/write/delete.ts +38 -0
- package/src/data/write/execute.ts +243 -0
- package/src/data/write/index.ts +11 -0
- package/src/data/write/insert.ts +63 -0
- package/src/data/write/state.ts +43 -0
- package/src/data/write/update.ts +54 -0
- package/src/data/write/upsert.ts +58 -0
- package/src/data/write/values.ts +19 -0
- package/src/index.ts +230 -0
- package/src/infra/access-links/code.ts +70 -0
- package/src/infra/access-links/handle.ts +892 -0
- package/src/infra/access-links/index.ts +9 -0
- package/src/infra/access-links/namespace.ts +30 -0
- package/src/infra/access-links/person-check.ts +177 -0
- package/src/infra/access-links/schema.ts +71 -0
- package/src/infra/alerts/async-sources.ts +231 -0
- package/src/infra/alerts/audit.ts +36 -0
- package/src/infra/alerts/beat.ts +123 -0
- package/src/infra/alerts/client-faults.ts +111 -0
- package/src/infra/alerts/faults.ts +156 -0
- package/src/infra/alerts/handle.ts +778 -0
- package/src/infra/alerts/http.ts +192 -0
- package/src/infra/alerts/index.ts +12 -0
- package/src/infra/alerts/infra-sources.ts +180 -0
- package/src/infra/alerts/namespace.ts +81 -0
- package/src/infra/alerts/redact.ts +21 -0
- package/src/infra/alerts/request-faults.ts +41 -0
- package/src/infra/alerts/schema.ts +124 -0
- package/src/infra/alerts/sinks.ts +125 -0
- package/src/infra/alerts/source-options.ts +26 -0
- package/src/infra/alerts/sources.ts +503 -0
- package/src/infra/alerts/subsystem-sources.ts +137 -0
- package/src/infra/audit/emit.ts +87 -0
- package/src/infra/audit/index.ts +2 -0
- package/src/infra/audit/namespace.ts +22 -0
- package/src/infra/audit/schema.sql +35 -0
- package/src/infra/audit/types.ts +18 -0
- package/src/infra/aws/credentials.ts +147 -0
- package/src/infra/aws/sigv4.ts +140 -0
- package/src/infra/cache/create.ts +58 -0
- package/src/infra/cache/entry.ts +81 -0
- package/src/infra/cache/index.ts +4 -0
- package/src/infra/cache/namespace.ts +10 -0
- package/src/infra/cache/tenant.ts +93 -0
- package/src/infra/cache/types.ts +24 -0
- package/src/infra/crawl/audit.ts +35 -0
- package/src/infra/crawl/errors.ts +25 -0
- package/src/infra/crawl/handle.ts +693 -0
- package/src/infra/crawl/index.ts +9 -0
- package/src/infra/crawl/links.ts +160 -0
- package/src/infra/crawl/namespace.ts +36 -0
- package/src/infra/crawl/robots.ts +100 -0
- package/src/infra/crawl/schema.ts +89 -0
- package/src/infra/credit-line/handle.ts +883 -0
- package/src/infra/credit-line/index.ts +6 -0
- package/src/infra/credit-line/namespace.ts +7 -0
- package/src/infra/credit-line/schema.ts +96 -0
- package/src/infra/credits/handle.ts +706 -0
- package/src/infra/credits/index.ts +5 -0
- package/src/infra/credits/namespace.ts +7 -0
- package/src/infra/credits/schema.ts +83 -0
- package/src/infra/email/audit.ts +36 -0
- package/src/infra/email/inbound.ts +622 -0
- package/src/infra/email/index.ts +11 -0
- package/src/infra/email/mail.ts +517 -0
- package/src/infra/email/mime.ts +447 -0
- package/src/infra/email/namespace.ts +22 -0
- package/src/infra/email/providers.ts +111 -0
- package/src/infra/email/schema.ts +96 -0
- package/src/infra/email/sns.ts +200 -0
- package/src/infra/email/unsubscribe.ts +44 -0
- package/src/infra/fetch/address.ts +88 -0
- package/src/infra/fetch/connect-retry.ts +87 -0
- package/src/infra/fetch/handle.ts +329 -0
- package/src/infra/fetch/index.ts +7 -0
- package/src/infra/fetch/namespace.ts +70 -0
- package/src/infra/idempotency/index.ts +3 -0
- package/src/infra/idempotency/middleware.ts +87 -0
- package/src/infra/idempotency/store.ts +92 -0
- package/src/infra/idempotency/types.ts +30 -0
- package/src/infra/join-requests/handle.ts +312 -0
- package/src/infra/join-requests/index.ts +2 -0
- package/src/infra/join-requests/namespace.ts +7 -0
- package/src/infra/join-requests/schema.ts +33 -0
- package/src/infra/outbox/core.ts +165 -0
- package/src/infra/quota/errors.ts +32 -0
- package/src/infra/quota/handle.ts +215 -0
- package/src/infra/quota/index.ts +5 -0
- package/src/infra/quota/namespace.ts +7 -0
- package/src/infra/quota/schema.ts +15 -0
- package/src/infra/storage/backend.ts +34 -0
- package/src/infra/storage/handle.ts +513 -0
- package/src/infra/storage/index.ts +9 -0
- package/src/infra/storage/local.ts +104 -0
- package/src/infra/storage/namespace.ts +13 -0
- package/src/infra/storage/s3.ts +257 -0
- package/src/infra/storage/schema.ts +24 -0
- package/src/infra/support/handle.ts +869 -0
- package/src/infra/support/http.ts +229 -0
- package/src/infra/support/index.ts +7 -0
- package/src/infra/support/namespace.ts +12 -0
- package/src/infra/support/schema.ts +96 -0
- package/src/infra/webhook/inbound.ts +45 -0
- package/src/infra/webhook/index.ts +5 -0
- package/src/infra/webhook/namespace.ts +24 -0
- package/src/infra/webhook/outbound.ts +315 -0
- package/src/infra/webhook/sign.ts +32 -0
- package/src/infra/webhook/verify.ts +82 -0
- package/src/invariants/assert.ts +26 -0
- package/src/invariants/index.ts +1 -0
- package/src/lint/config.ts +56 -0
- package/src/lint/doctrine.ts +163 -0
- package/src/lint/include.ts +97 -0
- package/src/lint/index.ts +3 -0
- package/src/lint/preset.ts +10 -0
- package/src/lint/resolver.ts +218 -0
- package/src/lint/rules/no-reexports.ts +46 -0
- package/src/request/behavior/audited.ts +85 -0
- package/src/request/behavior/cached.ts +62 -0
- package/src/request/behavior/compose.ts +28 -0
- package/src/request/behavior/index.ts +5 -0
- package/src/request/behavior/namespace.ts +16 -0
- package/src/request/behavior/types.ts +26 -0
- package/src/request/behavior/versioned.ts +54 -0
- package/src/request/context/create.ts +103 -0
- package/src/request/context/guard.ts +49 -0
- package/src/request/context/index.ts +4 -0
- package/src/request/context/run-in-transaction.ts +200 -0
- package/src/request/context/store.ts +28 -0
- package/src/request/context/types.ts +50 -0
- package/src/request/di/builder.ts +535 -0
- package/src/request/di/index.ts +8 -0
- package/src/request/di/resolve.ts +271 -0
- package/src/request/di/types.ts +262 -0
- package/src/request/fitting/namespace.ts +7 -0
- package/src/request/fixture/batch.ts +71 -0
- package/src/request/fixture/builder.ts +26 -0
- package/src/request/fixture/errors.ts +10 -0
- package/src/request/fixture/index.ts +8 -0
- package/src/request/fixture/namespace.ts +23 -0
- package/src/request/fixture/paginate.ts +44 -0
- package/src/request/fixture/to-handlers.ts +260 -0
- package/src/request/fixture/types.ts +79 -0
- package/src/request/flow/act-as.ts +78 -0
- package/src/request/flow/namespace.ts +17 -0
- package/src/request/http/error-step.ts +42 -0
- package/src/request/http/file.ts +141 -0
- package/src/request/http/index.ts +4 -0
- package/src/request/http/middleware.ts +141 -0
- package/src/request/http/namespace.ts +5 -0
- package/src/request/http/rate-limit-classes.ts +170 -0
- package/src/request/http/response.ts +82 -0
- package/src/request/http/server.ts +916 -0
- package/src/request/http/sniff.ts +261 -0
- package/src/request/http/types.ts +250 -0
- package/src/request/http/upload.ts +284 -0
- package/src/request/internal/text.ts +6 -0
- package/src/request/openapi/generate.ts +205 -0
- package/src/request/openapi/index.ts +2 -0
- package/src/request/openapi/namespace.ts +7 -0
- package/src/test/auth.ts +13 -0
- package/src/test/auto-setup.ts +45 -0
- package/src/test/context.ts +91 -0
- package/src/test/crawl-fixture.ts +157 -0
- package/src/test/discover-fork.ts +17 -0
- package/src/test/fingerprint.ts +135 -0
- package/src/test/globals.d.ts +7 -0
- package/src/test/index.ts +19 -0
- package/src/test/namespace.ts +87 -0
- package/src/test/plugin.ts +189 -0
- package/src/test/progress.ts +185 -0
- package/src/test/reap-after-run.ts +28 -0
- package/src/test/reap.ts +286 -0
- package/src/test/reset.ts +253 -0
- package/src/test/savepoints.ts +172 -0
- package/src/test/setup.ts +1422 -0
- package/src/test/system-channel.ts +50 -0
- package/src/test/vitest-plugin.ts +142 -0
- package/src/test/vitest-workspace.ts +507 -0
- package/src/test/vitest.ts +333 -0
- package/src/test/watch.ts +105 -0
- package/src/trace/capture/bounded-serializer.ts +146 -0
- package/src/trace/cardinality/analyze.ts +173 -0
- package/src/trace/cardinality/branch-context.ts +214 -0
- package/src/trace/cardinality/check.ts +33 -0
- package/src/trace/cardinality/classify-edge.ts +64 -0
- package/src/trace/cardinality/structural-max.ts +107 -0
- package/src/trace/check/coverage-check.ts +188 -0
- package/src/trace/check/flow-check.ts +103 -0
- package/src/trace/check/flow-step-site.ts +234 -0
- package/src/trace/check/io-pattern.ts +116 -0
- package/src/trace/codemod/add-cardinality.ts +152 -0
- package/src/trace/codemod/run.ts +80 -0
- package/src/trace/entry/child-trace.ts +21 -0
- package/src/trace/entry/close-trace.ts +87 -0
- package/src/trace/entry/finalize-trace.ts +28 -0
- package/src/trace/entry/open-trace.ts +138 -0
- package/src/trace/entry/parent-trace.ts +20 -0
- package/src/trace/entry/parse-traceparent.ts +39 -0
- package/src/trace/entry/process-start.ts +38 -0
- package/src/trace/entry/trace-context.ts +26 -0
- package/src/trace/entry/tracing-enabled.ts +10 -0
- package/src/trace/external/record.ts +195 -0
- package/src/trace/external/report.ts +66 -0
- package/src/trace/namespace.ts +19 -0
- package/src/trace/partition/day.ts +47 -0
- package/src/trace/partition/index.ts +14 -0
- package/src/trace/partition/partition-name.ts +23 -0
- package/src/trace/partition/plan.ts +82 -0
- package/src/trace/partition/schedule.ts +26 -0
- package/src/trace/partition/startup-hook.ts +39 -0
- package/src/trace/partition/sync.ts +154 -0
- package/src/trace/partition/tables.ts +53 -0
- package/src/trace/read/tree.ts +83 -0
- package/src/trace/retain/copy-on-error.ts +76 -0
- package/src/trace/runtime/expire-stale-traces.ts +54 -0
- package/src/trace/runtime/flow-step.ts +147 -0
- package/src/trace/runtime/flush-steps.ts +31 -0
- package/src/trace/runtime/pending-writes.ts +54 -0
- package/src/trace/runtime/step-buffer.ts +298 -0
- package/src/trace/runtime/step-error.ts +21 -0
- package/src/trace/runtime/step-meta.ts +55 -0
- package/src/trace/runtime/thenable.ts +12 -0
- package/src/trace/runtime/truncate-trace.ts +62 -0
- package/src/trace/schema/trace-fields.ts +52 -0
- package/src/trace/schema/trace-retained.ts +22 -0
- package/src/trace/schema/trace-step-fields.ts +44 -0
- package/src/trace/schema/trace-step-retained.ts +25 -0
- package/src/trace/schema/trace-step.ts +18 -0
- package/src/trace/schema/trace.ts +12 -0
- package/src/trace/transformer/apply.ts +79 -0
- package/src/trace/transformer/build-meta-literal.ts +30 -0
- package/src/trace/transformer/find-flow-step-sites.ts +85 -0
- package/src/trace/transformer/inject-flow-step-import.ts +39 -0
- package/src/trace/transformer/transform-error.ts +14 -0
- package/src/trace/transformer/transform-flow-steps.ts +123 -0
- package/src/trace/transformer/wrap-function-declaration.ts +62 -0
- package/src/trace/transformer/wrap-variable-declaration.ts +67 -0
- package/src/workspace/affected.ts +110 -0
- package/src/workspace/changed-files.ts +163 -0
- package/src/workspace/check-deps.ts +213 -0
- package/src/workspace/checksum.ts +20 -0
- package/src/workspace/dependency-graph.ts +67 -0
- package/src/workspace/detect-pipework-copies.ts +93 -0
- package/src/workspace/discover.ts +206 -0
- package/src/workspace/generate.ts +401 -0
- package/src/workspace/index.ts +7 -0
- package/src/workspace/module-imports-pipework.ts +39 -0
- package/src/workspace/module-package-name.ts +12 -0
- package/src/workspace/scrub-git-env.ts +23 -0
- package/src/workspace/source-paths.ts +36 -0
- package/src/workspace/sync.ts +182 -0
- package/dist/REFERENCE.md +0 -1477
- package/dist/infra/access-links/rate-limit.d.ts +0 -10
- package/dist/infra/access-links/rate-limit.d.ts.map +0 -1
- package/dist/infra/access-links/rate-limit.js +0 -28
- package/dist/infra/access-links/rate-limit.js.map +0 -1
- package/dist/infra/email/sigv4.d.ts +0 -24
- package/dist/infra/email/sigv4.d.ts.map +0 -1
- package/dist/infra/email/sigv4.js +0 -55
- package/dist/infra/email/sigv4.js.map +0 -1
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import { sql } from '../../data/db/sql.js'
|
|
2
|
+
import type { DB } from '../../data/db/index.js'
|
|
3
|
+
import { pipeSystem } from '../../data/db/db.js'
|
|
4
|
+
import { insert, remove } from '../../data/write/index.js'
|
|
5
|
+
import { schema } from '../../data/schema/namespace.js'
|
|
6
|
+
import { fitting } from '../../request/di/index.js'
|
|
7
|
+
import { Builder } from '../../request/di/builder.js'
|
|
8
|
+
import { and, eq } from '../../data/query/index.js'
|
|
9
|
+
import { getContext } from '../../request/context/store.js'
|
|
10
|
+
import { ValidationError } from '../../core/errors/index.js'
|
|
11
|
+
import { getBaseLogger } from '../../core/logging/index.js'
|
|
12
|
+
import type { Queue } from '../../async/jobs/queue.js'
|
|
13
|
+
import type { Handler } from '../../request/di/types.js'
|
|
14
|
+
import { __flowStep } from '../../trace/runtime/flow-step.js'
|
|
15
|
+
import { auditEmail } from './audit.js'
|
|
16
|
+
import type { MailProvider } from './providers.js'
|
|
17
|
+
import { unscopedExecutor, requireUnscopedExecutor } from '../../auth/tenant/scoped-db.js'
|
|
18
|
+
import { describeError } from '../../core/logging/error-chain.js'
|
|
19
|
+
import { emailUnsubscribes } from './schema.js'
|
|
20
|
+
import { LIST_RE, MIN_UNSUBSCRIBE_SECRET, signUnsubscribeToken, unsubscribeLink, verifyUnsubscribeToken } from './unsubscribe.js'
|
|
21
|
+
|
|
22
|
+
/** What a template renders: the subject and the text body, optionally an HTML body. */
|
|
23
|
+
export interface RenderedEmail {
|
|
24
|
+
readonly subject: string
|
|
25
|
+
readonly text: string
|
|
26
|
+
readonly html?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What a template learns about the message it renders, as its second argument. */
|
|
30
|
+
export interface EmailRenderMeta {
|
|
31
|
+
/** The message's one-click unsubscribe link (the same one its `List-Unsubscribe` header carries); null for a message with no `list`. */
|
|
32
|
+
readonly unsubscribeUrl: string | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The app's templates: a function per name from the data (and the render meta) to the rendered message. */
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- each template has its own data type; `send` checks it per name
|
|
37
|
+
export type EmailTemplates = Record<string, (data: any, meta: EmailRenderMeta) => RenderedEmail>
|
|
38
|
+
|
|
39
|
+
export interface MailConfig<T extends EmailTemplates> {
|
|
40
|
+
readonly provider: MailProvider
|
|
41
|
+
readonly templates: T
|
|
42
|
+
/** The From address, e.g. `Verity <no-reply@mail.verity.pub>`. */
|
|
43
|
+
readonly from: string
|
|
44
|
+
/** The app's job queue; its worker surface must run `mail.jobs`. */
|
|
45
|
+
readonly queue: Queue
|
|
46
|
+
readonly configurationSet?: string
|
|
47
|
+
/** The app's environment. `devStandIn` is refused when this is `production`. */
|
|
48
|
+
readonly environment?: string
|
|
49
|
+
/** Seconds after which a message left `sending` by a crashed run may be sent again. Default 600. */
|
|
50
|
+
readonly sendingStaleSeconds?: number
|
|
51
|
+
/** The queue priority of this module's delivery jobs; higher is claimed first. Default: the queue's (0). */
|
|
52
|
+
readonly priority?: number
|
|
53
|
+
/**
|
|
54
|
+
* One-click unsubscribe for mail sent with a `list`. `url` is the absolute https URL of the route `unsubscribeHttp()`
|
|
55
|
+
* serves (http is allowed outside production); `secret` signs the links, at least 32 characters and the same in every
|
|
56
|
+
* process. A `send` with a `list` throws when this is unset.
|
|
57
|
+
*/
|
|
58
|
+
readonly unsubscribe?: { readonly url: string; readonly secret: string }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface SendInput<T extends EmailTemplates, K extends keyof T & string> {
|
|
62
|
+
readonly to: string
|
|
63
|
+
readonly template: K
|
|
64
|
+
readonly data: Parameters<T[K]>[0]
|
|
65
|
+
/** Idempotency key: a second send with the same key sends nothing. */
|
|
66
|
+
readonly key: string
|
|
67
|
+
/**
|
|
68
|
+
* Puts the message in a conversation, so a reply to it threads there (the provider's message id
|
|
69
|
+
* is matched against `References`/`In-Reply-To` of received mail). Default: the conversation of `inReplyTo`.
|
|
70
|
+
*/
|
|
71
|
+
readonly conversationId?: string
|
|
72
|
+
/**
|
|
73
|
+
* The id of a received message (`ReceivedEmail.id`) this one answers: the sent message carries
|
|
74
|
+
* `In-Reply-To: <its Message-ID>` and `References` with its references plus its Message-ID (at most 20 ids).
|
|
75
|
+
*/
|
|
76
|
+
readonly inReplyTo?: string
|
|
77
|
+
/** The queue priority of this message's delivery job; higher is claimed first. Default: the module's `priority`. */
|
|
78
|
+
readonly priority?: number
|
|
79
|
+
/**
|
|
80
|
+
* Data computed when the message is delivered, never stored: `name` is a function registered with
|
|
81
|
+
* `registerLateData`, `ref` the reference it receives. Its result is merged over `data` for rendering only.
|
|
82
|
+
* The stored row holds `name` and `ref` under the reserved data key `__late`. Delivery fails if `name` is not registered.
|
|
83
|
+
*/
|
|
84
|
+
readonly lateData?: { readonly name: string; readonly ref: string }
|
|
85
|
+
/**
|
|
86
|
+
* Puts the message on a list, e.g. `'notice-digest'` (letters, digits, `.`, `_`, `-`; at most 64). It then carries
|
|
87
|
+
* RFC 8058 `List-Unsubscribe` and `List-Unsubscribe-Post` headers, and is skipped at delivery (status `unsubscribed`,
|
|
88
|
+
* no provider call) when the address has opted out of that list. Needs `MailConfig.unsubscribe`. Leave it off for
|
|
89
|
+
* transactional mail: that is never affected by an opt-out.
|
|
90
|
+
*/
|
|
91
|
+
readonly list?: string
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Computes the template data a message gets at delivery from the reference stored with it. */
|
|
95
|
+
export type LateData = (db: DB, ref: string) => Promise<Record<string, unknown>>
|
|
96
|
+
|
|
97
|
+
export interface SendResult {
|
|
98
|
+
readonly messageId: string
|
|
99
|
+
/** False when the key was already used; `messageId` is then the earlier message. */
|
|
100
|
+
readonly queued: boolean
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A message as `mail.sentTo` reads it. `rendered` is null when its template is gone or throws. */
|
|
104
|
+
export interface SentMail {
|
|
105
|
+
readonly id: string
|
|
106
|
+
readonly template: string
|
|
107
|
+
readonly status: string
|
|
108
|
+
/** The data stored with the message, without the `__late` reference. */
|
|
109
|
+
readonly data: unknown
|
|
110
|
+
readonly createdAt: Date
|
|
111
|
+
readonly rendered: RenderedEmail | null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface Mail<T extends EmailTemplates> {
|
|
115
|
+
/**
|
|
116
|
+
* Records the message and enqueues its send in `db`, so the caller's rollback drops both.
|
|
117
|
+
* The same `key` twice gives one message, one job and one delivery.
|
|
118
|
+
*/
|
|
119
|
+
send<K extends keyof T & string>(db: DB, input: SendInput<T, K>): Promise<SendResult>
|
|
120
|
+
/**
|
|
121
|
+
* For tests and support tools: the messages to an address (any case), oldest first, optionally of one template,
|
|
122
|
+
* each rendered from its stored data (late data resolved) as delivery would render it.
|
|
123
|
+
*/
|
|
124
|
+
sentTo(db: DB, address: string, options?: { readonly template?: keyof T & string }): Promise<readonly SentMail[]>
|
|
125
|
+
/**
|
|
126
|
+
* Records that `address` (any case) opted out of `list`; a second call changes nothing. Later sends on that list to
|
|
127
|
+
* that address are skipped; mail on other lists and mail with no list is unaffected. Writes through `pipe.insert`
|
|
128
|
+
* (needs a Flow), so it is audited when the calling step declares a touch on `pipework_email_unsubscribes`.
|
|
129
|
+
*/
|
|
130
|
+
unsubscribe(db: DB, address: string, list: string): Promise<void>
|
|
131
|
+
/** True when `address` (any case) has opted out of `list`. */
|
|
132
|
+
unsubscribed(db: DB, address: string, list: string): Promise<boolean>
|
|
133
|
+
/** Removes the opt-out of `address` from `list`, so its mail is delivered again. Writes like `unsubscribe`. */
|
|
134
|
+
resubscribe(db: DB, address: string, list: string): Promise<void>
|
|
135
|
+
/**
|
|
136
|
+
* The routes behind the unsubscribe link, at the path of `MailConfig.unsubscribe.url`: GET shows a confirm page
|
|
137
|
+
* with a form, POST (the mail provider's one-click, form-urlencoded, multipart or empty body, never read) records
|
|
138
|
+
* the opt-out. A token that is not valid for this secret is a 400 page. Public, no tenant; the address is
|
|
139
|
+
* escaped on the page and never logged. Mount it on the HTTP surface like `alerts.http`. Throws without `unsubscribe` config.
|
|
140
|
+
*/
|
|
141
|
+
unsubscribeHttp(): Handler<Record<string, unknown>, unknown>[]
|
|
142
|
+
/** Registers (or replaces) a late-data function by name; see `SendInput.lateData`. Used by other pipework modules. */
|
|
143
|
+
registerLateData(name: string, fn: LateData): void
|
|
144
|
+
/** Job handlers for the send job; spread into the worker surface's `jobs`. */
|
|
145
|
+
readonly jobs: readonly Handler<Record<string, unknown>, unknown>[]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const SEND_JOB = 'pipework.email.send'
|
|
149
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
150
|
+
const MAX_REFERENCES = 20
|
|
151
|
+
const MAX_REFERENCES_CHARS = 900
|
|
152
|
+
const ADDRESS_RE = /^[^\s@<>,;"]+@[^\s@<>,;"]+$/
|
|
153
|
+
|
|
154
|
+
/** The reply headers for a message answering a received one: In-Reply-To, and References capped to 20 ids and the provider's header length. */
|
|
155
|
+
export function replyHeaders(messageId: string, references: readonly string[]): Record<string, string> {
|
|
156
|
+
const ids = [...references, messageId].filter(id => /^[\x21-\x7e]+$/.test(id) && !/[<>]/.test(id))
|
|
157
|
+
const unique = ids.filter((id, i) => ids.lastIndexOf(id) === i).slice(-MAX_REFERENCES)
|
|
158
|
+
while (unique.length > 1 && unique.map(i => `<${i}>`).join(' ').length > MAX_REFERENCES_CHARS) unique.shift()
|
|
159
|
+
return { 'In-Reply-To': `<${messageId}>`, 'References': unique.map(i => `<${i}>`).join(' ') }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function lateReference(data: unknown): { name: string; ref: string } | null {
|
|
163
|
+
if (typeof data !== 'object' || data === null) return null
|
|
164
|
+
const late = (data as Record<string, unknown>)['__late']
|
|
165
|
+
if (typeof late !== 'object' || late === null) return null
|
|
166
|
+
const { name, ref } = late as Record<string, unknown>
|
|
167
|
+
return typeof name === 'string' && typeof ref === 'string' ? { name, ref } : null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function storedData(input: { data?: unknown; lateData?: { name: string; ref: string } }): unknown {
|
|
171
|
+
if (input.lateData === undefined) return input.data ?? null
|
|
172
|
+
const base = typeof input.data === 'object' && input.data !== null ? input.data : {}
|
|
173
|
+
return { ...base, __late: { name: input.lateData.name, ref: input.lateData.ref } }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function normalizeAddress(address: string): string {
|
|
177
|
+
return address.trim().toLowerCase()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Creates the transactional email interface.
|
|
182
|
+
*
|
|
183
|
+
* `send(db, { to, template, data, key })` writes a `pipework_email_messages` row and
|
|
184
|
+
* enqueues a `pipework.email.send` job in the caller's transaction. The job renders the
|
|
185
|
+
* app's template and sends through the provider, at most once per key, skipping an
|
|
186
|
+
* address on the suppression list (status `suppressed`, no provider call), and a message on a `list` whose address opted out of it (status `unsubscribed`, no provider call). The
|
|
187
|
+
* provider call is a trace step and `Email:sent` / `Email:suppressed` / `Email:unsubscribed` / `Email:failed`
|
|
188
|
+
* audit records are written under it, in the trace of the flow that called `send`.
|
|
189
|
+
*
|
|
190
|
+
* Needs blueprint `email: {}` (tables `pipework_email_messages`, `pipework_email_suppressions`, `pipework_email_inbound`, `pipework_email_unsubscribes`).
|
|
191
|
+
* A provider error returns the message to `queued` with `error` set and rethrows, so the
|
|
192
|
+
* queue retries (set `maxAttempts` on the queue); if the queue gives up the row keeps the error.
|
|
193
|
+
* A run claims the message with a conditional update (`status` `queued`, or `sending` for longer than
|
|
194
|
+
* `sendingStaleSeconds`), so a re-run of a job whose first run is still in flight does not call the provider.
|
|
195
|
+
* Residual window: a crash mid-send leaves the message `sending` for the stale window, after which a retry
|
|
196
|
+
* may send it once more; likewise if the provider accepted it and the process died before the row was marked `sent`. SES v2 has no idempotency token to close it.
|
|
197
|
+
* With `conversationId` / `inReplyTo` the message joins a conversation and carries reply headers (see `SendInput`).
|
|
198
|
+
* Message bodies and template data are never logged.
|
|
199
|
+
*/
|
|
200
|
+
export function createMail<T extends EmailTemplates>(config: MailConfig<T>): Mail<T> {
|
|
201
|
+
if (config.provider.name === 'dev-stand-in' && config.environment === 'production') {
|
|
202
|
+
throw new Error(
|
|
203
|
+
'[pipework] mail.devStandIn is refused in production.\n\n' +
|
|
204
|
+
' The stand-in writes messages to files instead of sending them.\n' +
|
|
205
|
+
' Configure a real provider, e.g. mail.ses({ region, credentials }).\n',
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const unsubscribe = config.unsubscribe
|
|
210
|
+
if (unsubscribe !== undefined) validateUnsubscribe(unsubscribe, config.environment)
|
|
211
|
+
|
|
212
|
+
const staleSeconds = config.sendingStaleSeconds ?? 600
|
|
213
|
+
const lateFns = new Map<string, LateData>()
|
|
214
|
+
|
|
215
|
+
async function isUnsubscribed(db: DB, address: string, list: string): Promise<boolean> {
|
|
216
|
+
const rows = await db.execute(sql`SELECT 1 FROM pipework_email_unsubscribes WHERE address = ${normalizeAddress(address)} AND list = ${list}`)
|
|
217
|
+
return rows.length > 0
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** What a template is told: the signed link for a list message, null for any other. */
|
|
221
|
+
function renderMeta(messageId: string, to: string, list: string | null): EmailRenderMeta {
|
|
222
|
+
if (list === null) return { unsubscribeUrl: null }
|
|
223
|
+
if (unsubscribe === undefined) {
|
|
224
|
+
throw new Error(`[pipework] Email message ${messageId} is on list "${list}" but mail config has no unsubscribe: { url, secret }; it is not sent without an unsubscribe link.`)
|
|
225
|
+
}
|
|
226
|
+
return { unsubscribeUrl: unsubscribeLink(unsubscribe.url, signUnsubscribeToken(unsubscribe.secret, normalizeAddress(to), list)) }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The stored data with its late data computed and merged over it; throws when the named function is not registered. */
|
|
230
|
+
async function withLateData(db: DB, stored: unknown, messageId: string): Promise<unknown> {
|
|
231
|
+
const lateRef = lateReference(stored)
|
|
232
|
+
if (lateRef === null) return stored
|
|
233
|
+
const fn = lateFns.get(lateRef.name)
|
|
234
|
+
if (fn === undefined) throw new Error(`[pipework] Email message ${messageId}: no late-data function named "${lateRef.name}" is registered.`)
|
|
235
|
+
const rest = { ...(stored as Record<string, unknown>) }
|
|
236
|
+
delete rest['__late']
|
|
237
|
+
return { ...rest, ...(await fn(db, lateRef.ref)) }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const deliver = __flowStep({ ref: 'pipework/email:deliver' }, async (messageId: string): Promise<{ status: string }> => {
|
|
241
|
+
const db = pipeSystem()
|
|
242
|
+
const rows = await db.execute(sql`SELECT * FROM pipework_email_messages WHERE id = ${messageId}::uuid`)
|
|
243
|
+
const row = rows[0]
|
|
244
|
+
if (row === undefined) throw new Error(`[pipework] Email message ${messageId} does not exist.`)
|
|
245
|
+
const status = row['status'] as string
|
|
246
|
+
if (status === 'sent' || status === 'suppressed' || status === 'unsubscribed' || status === 'failed') return { status }
|
|
247
|
+
|
|
248
|
+
const environment = getContext()?.instance.config.environment
|
|
249
|
+
if (config.provider.name === 'dev-stand-in' && environment === 'production') {
|
|
250
|
+
throw new Error('[pipework] mail.devStandIn is refused in production.')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const to = row['to_address'] as string
|
|
254
|
+
const suppressed = await db.execute(sql`SELECT reason FROM pipework_email_suppressions WHERE address = ${normalizeAddress(to)}`)
|
|
255
|
+
if (suppressed.length > 0) {
|
|
256
|
+
await db.execute(sql`UPDATE pipework_email_messages SET status = 'suppressed' WHERE id = ${messageId}::uuid`)
|
|
257
|
+
await auditEmail('suppressed', messageId, { reason: suppressed[0]!['reason'] })
|
|
258
|
+
getBaseLogger().info({ event: 'email suppressed send', messageId }, 'email skipped: address is suppressed')
|
|
259
|
+
return { status: 'suppressed' }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const list = (row['list'] as string | null | undefined) ?? null
|
|
263
|
+
if (list !== null && await isUnsubscribed(db, to, list)) {
|
|
264
|
+
await db.execute(sql`UPDATE pipework_email_messages SET status = 'unsubscribed' WHERE id = ${messageId}::uuid`)
|
|
265
|
+
await auditEmail('unsubscribed', messageId, { list })
|
|
266
|
+
getBaseLogger().info({ event: 'email unsubscribed send', messageId, list }, 'email skipped: address has opted out of the list')
|
|
267
|
+
return { status: 'unsubscribed' }
|
|
268
|
+
}
|
|
269
|
+
const meta = renderMeta(messageId, to, list)
|
|
270
|
+
|
|
271
|
+
let headers: Record<string, string> | undefined
|
|
272
|
+
const inReplyToInbound = row['in_reply_to_inbound'] as string | null
|
|
273
|
+
if (inReplyToInbound !== null) {
|
|
274
|
+
const parent = (await db.execute(sql`SELECT message_id_header, "references" FROM pipework_email_inbound WHERE id = ${inReplyToInbound}::uuid`))[0]
|
|
275
|
+
const parentId = (parent?.['message_id_header'] as string | null | undefined) ?? null
|
|
276
|
+
if (parentId !== null) headers = replyHeaders(parentId, (parent!['references'] as string[] | null) ?? [])
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (meta.unsubscribeUrl !== null) {
|
|
280
|
+
headers = { ...headers, 'List-Unsubscribe': `<${meta.unsubscribeUrl}>`, 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click' }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const templateName = row['template'] as string
|
|
284
|
+
const lateRef = lateReference(row['data'])
|
|
285
|
+
let templateData: unknown
|
|
286
|
+
try {
|
|
287
|
+
templateData = await withLateData(db, row['data'], messageId)
|
|
288
|
+
} catch (err) {
|
|
289
|
+
if (lateRef !== null) {
|
|
290
|
+
const message = `no late-data function named "${lateRef.name}" is registered`
|
|
291
|
+
getBaseLogger().error({ event: 'email failed', messageId }, `email not sent: ${message}`)
|
|
292
|
+
}
|
|
293
|
+
throw err
|
|
294
|
+
}
|
|
295
|
+
const template = config.templates[templateName]
|
|
296
|
+
let rendered: RenderedEmail | null = null
|
|
297
|
+
let renderError = ''
|
|
298
|
+
if (template === undefined) {
|
|
299
|
+
renderError = `no template named "${templateName}"`
|
|
300
|
+
} else {
|
|
301
|
+
try {
|
|
302
|
+
rendered = template(templateData, meta)
|
|
303
|
+
} catch (err) {
|
|
304
|
+
renderError = `template "${templateName}" threw: ${describeError(err).message}`
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (rendered === null) {
|
|
308
|
+
await db.execute(sql`UPDATE pipework_email_messages SET status = 'failed', failed_at = now(), error = ${renderError} WHERE id = ${messageId}::uuid`)
|
|
309
|
+
await auditEmail('failed', messageId, { error: renderError })
|
|
310
|
+
getBaseLogger().error({ event: 'email failed', messageId }, `email not sent: ${renderError}`)
|
|
311
|
+
return { status: 'failed' }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const claimed = await db.execute(sql`
|
|
315
|
+
UPDATE pipework_email_messages SET status = 'sending', sending_at = now()
|
|
316
|
+
WHERE id = ${messageId}::uuid
|
|
317
|
+
AND (status = 'queued' OR (status = 'sending' AND sending_at < now() - make_interval(secs => ${staleSeconds})))
|
|
318
|
+
RETURNING id`)
|
|
319
|
+
if (claimed.length === 0) {
|
|
320
|
+
getBaseLogger().info({ event: 'email send held elsewhere', messageId }, 'email not sent: another run holds the message or it is finished')
|
|
321
|
+
return { status: 'held' }
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
const sent = await config.provider.send({
|
|
325
|
+
messageId, from: config.from, to,
|
|
326
|
+
subject: rendered.subject, text: rendered.text,
|
|
327
|
+
...(rendered.html !== undefined ? { html: rendered.html } : {}),
|
|
328
|
+
...(config.configurationSet !== undefined ? { configurationSet: config.configurationSet } : {}),
|
|
329
|
+
...(headers !== undefined ? { headers } : {}),
|
|
330
|
+
})
|
|
331
|
+
await db.execute(sql`
|
|
332
|
+
UPDATE pipework_email_messages
|
|
333
|
+
SET status = 'sent', sending_at = NULL, provider_message_id = ${sent.providerMessageId}, sent_at = now(), error = NULL
|
|
334
|
+
WHERE id = ${messageId}::uuid`)
|
|
335
|
+
await auditEmail('sent', messageId, { provider: config.provider.name, providerMessageId: sent.providerMessageId })
|
|
336
|
+
return { status: 'sent' }
|
|
337
|
+
} catch (err) {
|
|
338
|
+
const message = describeError(err).message
|
|
339
|
+
await db.execute(sql`UPDATE pipework_email_messages SET status = 'queued', sending_at = NULL, error = ${message} WHERE id = ${messageId}::uuid`)
|
|
340
|
+
await auditEmail('failed', messageId, { error: message, willRetry: true })
|
|
341
|
+
throw err
|
|
342
|
+
}
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
const sendJob = fitting.use().job(SEND_JOB)
|
|
346
|
+
.input(schema.check.object({ messageId: schema.check.string() }))
|
|
347
|
+
.fit(async ({ input }: { input: { messageId: string } }) => deliver(input.messageId))
|
|
348
|
+
|
|
349
|
+
async function optOut(_db: DB, address: string, list: string): Promise<void> {
|
|
350
|
+
requireList(list)
|
|
351
|
+
await insert(emailUnsubscribes).values({ address: normalizeAddress(address), list }).onConflictDoNothing()
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
async send(handle, input) {
|
|
356
|
+
const db = unscopedExecutor(handle).db
|
|
357
|
+
if (config.templates[input.template] === undefined) {
|
|
358
|
+
throw new ValidationError(
|
|
359
|
+
{ template: [`no template named "${input.template}" (have: ${Object.keys(config.templates).join(', ')})`] },
|
|
360
|
+
`mail.send: unknown template "${input.template}".`,
|
|
361
|
+
)
|
|
362
|
+
}
|
|
363
|
+
if (!ADDRESS_RE.test(input.to.trim())) {
|
|
364
|
+
throw new ValidationError({ to: ['is not an email address'] }, 'mail.send: "to" is not an email address.')
|
|
365
|
+
}
|
|
366
|
+
if (input.key.trim() === '') {
|
|
367
|
+
throw new ValidationError({ key: ['must not be empty'] }, 'mail.send: "key" must not be empty.')
|
|
368
|
+
}
|
|
369
|
+
if (input.conversationId !== undefined && !UUID_RE.test(input.conversationId)) {
|
|
370
|
+
throw new ValidationError({ conversationId: ['must be a UUID'] }, 'mail.send: "conversationId" must be a UUID.')
|
|
371
|
+
}
|
|
372
|
+
if (input.list !== undefined) {
|
|
373
|
+
if (typeof input.list !== 'string' || !LIST_RE.test(input.list)) {
|
|
374
|
+
throw new ValidationError({ list: ['must be letters, digits, ".", "_" or "-", starting with a letter or digit, at most 64 characters'] }, 'mail.send: "list" is not a valid list name.')
|
|
375
|
+
}
|
|
376
|
+
if (unsubscribe === undefined) {
|
|
377
|
+
throw new Error('[pipework] mail.send with a list needs mail config unsubscribe: { url, secret }')
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const priority = input.priority ?? config.priority
|
|
381
|
+
if (priority !== undefined && !Number.isInteger(priority)) {
|
|
382
|
+
throw new ValidationError({ priority: ['must be an integer'] }, 'mail.send: "priority" must be an integer.')
|
|
383
|
+
}
|
|
384
|
+
let conversationId = input.conversationId ?? null
|
|
385
|
+
if (input.inReplyTo !== undefined) {
|
|
386
|
+
if (!UUID_RE.test(input.inReplyTo)) {
|
|
387
|
+
throw new ValidationError({ inReplyTo: ['must be a received message id'] }, 'mail.send: "inReplyTo" must be a received message id.')
|
|
388
|
+
}
|
|
389
|
+
const parent = (await db.execute(sql`SELECT conversation_id FROM pipework_email_inbound WHERE id = ${input.inReplyTo}::uuid`))[0]
|
|
390
|
+
if (parent === undefined) {
|
|
391
|
+
throw new ValidationError({ inReplyTo: ['no received message has this id'] }, `mail.send: no received message ${input.inReplyTo}.`)
|
|
392
|
+
}
|
|
393
|
+
conversationId ??= (parent['conversation_id'] as string | null) ?? null
|
|
394
|
+
}
|
|
395
|
+
const traceId = getContext()?.trace?.id ?? null
|
|
396
|
+
const inserted = await db.execute(sql`
|
|
397
|
+
INSERT INTO pipework_email_messages (key, to_address, template, data, trace_id, conversation_id, in_reply_to_inbound, list, created_at)
|
|
398
|
+
VALUES (${input.key}, ${input.to.trim()}, ${input.template}, ${JSON.stringify(storedData(input))}::jsonb, ${traceId}::uuid,
|
|
399
|
+
${conversationId}::uuid, ${input.inReplyTo ?? null}::uuid, ${input.list ?? null}, clock_timestamp())
|
|
400
|
+
ON CONFLICT (key) DO NOTHING
|
|
401
|
+
RETURNING id`)
|
|
402
|
+
if (inserted.length === 0) {
|
|
403
|
+
const existing = await db.execute(sql`SELECT id FROM pipework_email_messages WHERE key = ${input.key}`)
|
|
404
|
+
return { messageId: existing[0]!['id'] as string, queued: false }
|
|
405
|
+
}
|
|
406
|
+
const messageId = inserted[0]!['id'] as string
|
|
407
|
+
await config.queue.enqueue(handle, { type: SEND_JOB, payload: { messageId }, continueFlow: true, ...(priority !== undefined ? { priority } : {}) })
|
|
408
|
+
return { messageId, queued: true }
|
|
409
|
+
},
|
|
410
|
+
async sentTo(handle, address, options) {
|
|
411
|
+
const db = requireUnscopedExecutor(handle, 'mail.sentTo()', 'it reads every tenant\'s messages to the address')
|
|
412
|
+
const rows = await db.execute(sql`
|
|
413
|
+
SELECT id::text, to_address, template, status, data, list, created_at FROM pipework_email_messages
|
|
414
|
+
WHERE lower(btrim(to_address)) = ${normalizeAddress(address)}${options?.template === undefined ? sql`` : sql` AND template = ${options.template}`}
|
|
415
|
+
ORDER BY created_at, id`)
|
|
416
|
+
const out: SentMail[] = []
|
|
417
|
+
for (const row of rows) {
|
|
418
|
+
const id = row['id'] as string
|
|
419
|
+
const template = row['template'] as string
|
|
420
|
+
const stored = row['data']
|
|
421
|
+
let rendered: RenderedEmail | null = null
|
|
422
|
+
try {
|
|
423
|
+
const fn = config.templates[template]
|
|
424
|
+
if (fn !== undefined) rendered = fn(await withLateData(db, stored, id), renderMeta(id, row['to_address'] as string, (row['list'] as string | null) ?? null))
|
|
425
|
+
} catch { rendered = null }
|
|
426
|
+
let data = stored
|
|
427
|
+
if (lateReference(stored) !== null) {
|
|
428
|
+
const rest = { ...(stored as Record<string, unknown>) }
|
|
429
|
+
delete rest['__late']
|
|
430
|
+
data = rest
|
|
431
|
+
}
|
|
432
|
+
out.push({ id, template, status: row['status'] as string, data, createdAt: new Date(row['created_at'] as string | Date), rendered })
|
|
433
|
+
}
|
|
434
|
+
return out
|
|
435
|
+
},
|
|
436
|
+
unsubscribe: optOut,
|
|
437
|
+
async unsubscribed(handle, address, list) {
|
|
438
|
+
requireList(list)
|
|
439
|
+
return isUnsubscribed(requireUnscopedExecutor(handle, 'mail.unsubscribed()', 'an opt-out is not tenant data'), address, list)
|
|
440
|
+
},
|
|
441
|
+
async resubscribe(_db, address, list) {
|
|
442
|
+
requireList(list)
|
|
443
|
+
await remove(emailUnsubscribes).where(and(eq(emailUnsubscribes.address, normalizeAddress(address)), eq(emailUnsubscribes.list, list))!)
|
|
444
|
+
},
|
|
445
|
+
unsubscribeHttp() {
|
|
446
|
+
if (unsubscribe === undefined) {
|
|
447
|
+
throw new Error('[pipework] mail.unsubscribeHttp needs mail config unsubscribe: { url, secret }')
|
|
448
|
+
}
|
|
449
|
+
return unsubscribeRoutes(unsubscribe, (address, list) => optOut(pipeSystem(), address, list))
|
|
450
|
+
},
|
|
451
|
+
registerLateData(name, fn) { lateFns.set(name, fn) },
|
|
452
|
+
jobs: [sendJob as unknown as Handler<Record<string, unknown>, unknown>],
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function requireList(list: string): void {
|
|
457
|
+
if (typeof list !== 'string' || !LIST_RE.test(list)) {
|
|
458
|
+
throw new ValidationError({ list: ['is not a valid list name'] }, 'mail: "list" is not a valid list name.')
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function validateUnsubscribe(options: { readonly url: string; readonly secret: string }, environment: string | undefined): void {
|
|
463
|
+
let parsed: URL | null = null
|
|
464
|
+
try { parsed = new URL(options.url) } catch { /* reported below */ }
|
|
465
|
+
const allowed = environment === 'production' ? ['https:'] : ['https:', 'http:']
|
|
466
|
+
if (typeof options.url !== 'string' || parsed === null || !allowed.includes(parsed.protocol)) {
|
|
467
|
+
throw new Error(`[pipework] mail.create: unsubscribe.url must be an absolute ${environment === 'production' ? 'https' : 'https (or http outside production)'} URL, got ${JSON.stringify(options.url)}.`)
|
|
468
|
+
}
|
|
469
|
+
if (typeof options.secret !== 'string' || options.secret.length < MIN_UNSUBSCRIBE_SECRET) {
|
|
470
|
+
throw new Error(`[pipework] mail.create: unsubscribe.secret must be at least ${String(MIN_UNSUBSCRIBE_SECRET)} characters; use a long random value, the same in every process.`)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const escapeHtml = (text: string): string =>
|
|
475
|
+
text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
|
476
|
+
|
|
477
|
+
function htmlPage(title: string, body: string): string {
|
|
478
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">` +
|
|
479
|
+
`<meta name="robots" content="noindex"><title>${escapeHtml(title)}</title>` +
|
|
480
|
+
`<style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;line-height:1.5}button{font:inherit;padding:.5rem 1rem}</style>` +
|
|
481
|
+
`</head><body><h1>${escapeHtml(title)}</h1>${body}</body></html>`
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const INVALID_PAGE = htmlPage('Link not valid', '<p>This unsubscribe link is not valid. Use the link in the most recent email.</p>')
|
|
485
|
+
|
|
486
|
+
function unsubscribeRoutes(
|
|
487
|
+
options: { readonly url: string; readonly secret: string },
|
|
488
|
+
record: (address: string, list: string) => Promise<void>,
|
|
489
|
+
): Handler<Record<string, unknown>, unknown>[] {
|
|
490
|
+
const path = new URL(options.url).pathname
|
|
491
|
+
const any = schema.check.unknown()
|
|
492
|
+
const tokenOf = (query: unknown): unknown => (query as Record<string, unknown> | null | undefined)?.['t']
|
|
493
|
+
const base = () => new Builder().public().request().response().query(any).params(any)
|
|
494
|
+
const answer = (response: { header(name: string, value: string): void }, status: number, html: string) => {
|
|
495
|
+
response.header('content-type', 'text/html; charset=utf-8')
|
|
496
|
+
response.header('cache-control', 'no-store')
|
|
497
|
+
response.header('referrer-policy', 'no-referrer')
|
|
498
|
+
response.header('content-security-policy', "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'")
|
|
499
|
+
return { status, body: html }
|
|
500
|
+
}
|
|
501
|
+
const get = base().route('GET', path).fit(({ query, response }) => {
|
|
502
|
+
const named = verifyUnsubscribeToken(options.secret, tokenOf(query))
|
|
503
|
+
if (named === null) return Promise.resolve(answer(response, 400, INVALID_PAGE))
|
|
504
|
+
return Promise.resolve(answer(response, 200, htmlPage('Unsubscribe',
|
|
505
|
+
`<p>Stop receiving <strong>${escapeHtml(named.list)}</strong> email at <strong>${escapeHtml(named.address)}</strong>?</p>` +
|
|
506
|
+
`<form method="post" action="${escapeHtml(path)}?t=${escapeHtml(encodeURIComponent(String(tokenOf(query))))}">` +
|
|
507
|
+
`<input type="hidden" name="List-Unsubscribe" value="One-Click"><button type="submit">Unsubscribe</button></form>`)))
|
|
508
|
+
})
|
|
509
|
+
const post = base().ignoreBody().route('POST', path).fit(async ({ query, response }) => {
|
|
510
|
+
const named = verifyUnsubscribeToken(options.secret, tokenOf(query))
|
|
511
|
+
if (named === null) return answer(response, 400, INVALID_PAGE)
|
|
512
|
+
await record(named.address, named.list)
|
|
513
|
+
return answer(response, 200, htmlPage('Unsubscribed',
|
|
514
|
+
`<p>You will no longer receive <strong>${escapeHtml(named.list)}</strong> email at <strong>${escapeHtml(named.address)}</strong>.</p>`))
|
|
515
|
+
})
|
|
516
|
+
return [get, post] as unknown as Handler<Record<string, unknown>, unknown>[]
|
|
517
|
+
}
|