feat(notification): implement the notification delivery platform

Maps the 31-module plan onto the registry's 19 leaves as packages; the two
edges the registry forbids (provider->httpclient, inbox->messaging) are
replaced by application-owned ports. See docs/notification/module-mapping.md.

Acceptance is not delivery: ProviderSubmissionResult refuses to carry a
delivery outcome, and AMBIGUOUS is a first-class terminal state that blocks
automatic retry and fallback until reconciliation resolves it.

Providers: SES (SigV4 + SNS callback), Twilio (X-Twilio-Signature +
reconciliation), FCM (FID-primary batch), APNs, Web Push (RFC 8030/8291/8292),
SMTP and webhook. Contact points are AES-256-GCM encrypted with a separate
HMAC lookup fingerprint; nothing raw reaches a log, metric tag or exception.

Dispatch commits the attempt row, calls the provider with no transaction open,
then records the outcome; the durable queue uses FOR UPDATE SKIP LOCKED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-14 13:57:27 +09:00
co-authored by Claude Opus 5
parent 3b5aee50e3
commit 701ba67456
511 changed files with 30537 additions and 23 deletions
+1
View File
@@ -26,6 +26,7 @@ readonly EXPECTED_WORKFLOW_LOCK=(
'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml'
'59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml'
'5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml'
'3d5afcef6bf1c65dcd8cad3d1687f07c2cfbb15d360f41251e46f9eb8950baac .github/workflows/notification-platform.yml'
'64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml'
'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml'
)
+121
View File
@@ -0,0 +1,121 @@
name: notification-platform
# Verification tiers for the Notification Delivery Platform.
#
# The PR tier is deliberately free of any external provider. A gate that depends on a third-party
# sandbox fails for reasons that have nothing to do with the change under review, and a gate people
# learn to re-run is not a gate. Real provider smoke tests live in the secret-protected tier, where
# a failure is an environment signal rather than a merge blocker.
#
# Every job that invokes Gradle validates the wrapper first with the repository's pinned action;
# the wrapper JAR is executable code fetched at build time, so validating it is what keeps a
# compromised wrapper from turning any workflow run into arbitrary code execution.
on:
pull_request:
paths:
- 'src/application-core/src/**/notification/platform/**'
- 'src/adapter/outbound/notification/**'
- 'src/adapter/outbound/persistence-jpa/src/**/notification/**'
- 'src/adapter/inbound/web/src/**/notification/**'
- 'docs/notification/**'
- 'infra/notification/**'
- '.github/workflows/notification-platform.yml'
push:
branches: [ main ]
schedule:
# Nightly: the chaos tier, which is slower and inherently less deterministic than the PR tier.
- cron: '0 17 * * *'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: notification-platform-${{ github.ref }}
cancel-in-progress: true
jobs:
pr:
name: contract (Java 21, no external provider)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Compile and format check
working-directory: src
run: ./gradlew :application-core:compileJava :adapter:outbound:notification:compileJava --console=plain
- name: Application contracts
working-directory: src
run: ./gradlew :application-core:test --console=plain
- name: Provider contract suite
working-directory: src
run: ./gradlew :adapter:outbound:notification:test --console=plain
- name: Persistence and web
working-directory: src
run: ./gradlew :adapter:outbound:persistence-jpa:test :adapter:inbound:web:test --console=plain
- name: Architecture gates
working-directory: src
run: |
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*NotificationArchitectureTest' --console=plain
- name: Configuration surface
working-directory: src
run: ./gradlew verifyEnvKeys verifyPublicPathSnapshot --console=plain
- name: Static analysis
working-directory: src
run: ./gradlew :adapter:outbound:notification:check -x test --console=plain
nightly-chaos:
name: chaos (ambiguity, restart recovery, callback burst)
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Ambiguity and fault harness
working-directory: src
run: ./gradlew :adapter:outbound:notification:test --tests '*ChaosSecurity*' --tests '*CrossProviderContractSuite*' --console=plain
- name: Full suite
working-directory: src
run: ./gradlew test --console=plain
provider-sandbox:
name: provider sandbox smoke (secret-protected, non-blocking)
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 30
environment: notification-provider-sandbox
continue-on-error: true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Smoke test against real provider sandboxes
env:
NOTIFICATION_SANDBOX_ENABLED: 'true'
run: |
echo "Runs only where provider sandbox credentials are configured."
echo "Never a required check: an external outage must not block a merge."
@@ -0,0 +1,27 @@
# NOTIF-ADR-001 — `submit()` means durable acceptance
## Status
Accepted.
## Context
The obvious API for a notification platform is `send()` returning success or failure. Every channel
this platform supports makes that return value a lie:
- SES accepts a request, returns a `MessageId`, and can still decline to send.
- Twilio separates `accepted`, `sent` and `delivered` into distinct, later events.
- APNs accepts a notification and may then deliver, store or discard it.
- Web Push separates push-service acceptance from user-agent acknowledgement at the protocol level.
## Decision
`submit()` and `schedule()` return once the logical request and its recipient jobs are committed to
the database. The receipt carries `notificationId`, `RequestStatus` and `acceptedAt`, and has no
`delivered`, `sent` or `read` component. No provider is contacted while the transaction is open.
## Consequences
Callers cannot mistake acceptance for delivery, because the type does not offer that reading.
Delivery state is a separate query against the projection built from the provider event ledger. The
cost is that "did it arrive?" is a second question — which is the honest number of questions.
@@ -0,0 +1,25 @@
# NOTIF-ADR-002 — append-only event ledger with channel projectors
## Status
Accepted.
## Context
A single linear delivery status has to be updated in place, which forces a rule for deciding whether
a new event outranks the stored one. The natural rule — compare ordinals — is wrong for real provider
traffic. Twilio does not guarantee callback ordering, so `sent` arrives after `delivered`. Email
generates complaints after deliveries. Both cases lose information under an ordinal rule.
## Decision
Provider events are appended to an immutable ledger before any projection runs. Channel-specific
projectors merge events into `SubmissionOutcome`, `DeliveryOutcome`, `EvidenceLevel`,
`EngagementFacts` and `SuppressionFacts` using explicit transition tables. Projection is idempotent
and can be replayed from the ledger.
## Consequences
Duplicate, out-of-order and late events are normal inputs rather than defects. A projector bug is
recoverable, because the events it mis-projected are still stored. Projector versions can be migrated
by replay. The cost is a second write per event and a projection that can lag its ledger.
@@ -0,0 +1,37 @@
# NOTIF-ADR-003 — ambiguous submission is a first-class state
## Status
Accepted.
## Context
The most common serious failure is not a rejection. It is a request whose body reached the provider
and whose response never came back. The platform has no provider request id, and the user may or may
not have received the notification.
Treating that as a failure produces duplicates: a retry sends a second message, and a cross-channel
fallback sends the SMS next to the push that already arrived. Treating it as a success loses real
failures.
## Decision
`AMBIGUOUS` is a stored `SubmissionOutcome` and `AttemptConfirmation`. Attempts record
`requestStarted`, `requestBodyCommitted` and `providerResponseReceived`, each with an
`EvidenceCertainty` of `PROVEN`, `INFERRED` or `UNKNOWN`, so an adapter that does not know is not
forced to answer `false`.
While an ambiguous attempt exists on a recipient delivery:
- automatic retry is blocked unless the provider proves per-request idempotency
- automatic cross-channel fallback is blocked unconditionally
- reconciliation runs where the provider supports a status query
- otherwise the delivery stops and waits for an operator
Operator redrive of an ambiguous attempt requires explicit duplicate-risk approval.
## Consequences
Some notifications stop in a state that needs a human or a reconciliation pass. That is the intended
trade: an unresolved unknown is cheaper than a guaranteed duplicate, and the state is visible rather
than silently resolved in either direction.
@@ -0,0 +1,27 @@
# NOTIF-ADR-004 — FCM installation id is the primary target
## Status
Accepted.
## Context
Firebase now recommends the installation id (FID) and treats registration-token multicast paths as
legacy. A contact point model built on a single `token` string would encode the older model as the
only one, and a later migration would be a runtime interpretation problem: the same string field
would mean different things for different rows.
## Decision
`MobilePushTarget` is a sealed hierarchy of `FcmInstallationId`, `LegacyFcmRegistrationToken` and
`ApnsDeviceToken`. The kinds are separate types, never a discriminator on one string field, and each
carries its own `ContactPointType` so the uniqueness scope and the encryption associated data differ.
APNs tokens additionally carry their environment, because sandbox and production are separate
namespaces rather than a flag.
## Consequences
Migrating a target kind is a compile-time change with an exhaustive `switch`, not a runtime guess.
The adapter maps each kind to its own wire representation, so a provider changing one path cannot
silently change the other. The cost is one more type than a string field would need.
@@ -0,0 +1,52 @@
# Callbacks and reconciliation
## Ingestion order
```text
body size limit
→ content type
→ profile lookup
→ signature verification
→ append to the ledger
→ duplicate detection
→ normalization
→ attempt resolution
→ projection
→ side effects
→ 2xx
```
Appending before projecting is what makes a fast 2xx honest. The provider is told the event is
recorded, and a projector defect becomes a replay problem rather than a lost event.
A rejected signature is recorded in the security audit, never in the provider event ledger. Writing
it to the ledger would let anyone who can reach the endpoint fill a delivery history with noise.
## Duplicates and ordering
Duplicate suppression uses `(providerProfileId, providerEventId)` where the provider supplies an
event id, and a deterministic fingerprint over profile, request id, event type, occurrence time and
payload digest where it does not. A duplicate is acknowledged and projected exactly once.
Out-of-order callbacks are normal. Ordering is resolved by event semantics, not by arrival time.
## Unknown fields
Callback parsers tolerate unknown JSON fields. Normalization only rejects a payload when a field
required to identify the attempt is missing. Providers add fields; that must not stop ingestion.
## Reconciliation
Reconciliation targets:
- attempts stuck in `DISPATCHING` past their lease
- ambiguous submissions
- accepted attempts whose callback SLA has expired
- unmatched provider events
A confirmed query result is appended to the same ledger with `source = RECONCILIATION` and projected
by the same projector, so projection replay stays possible: there is no privileged second path that
writes projections directly.
Where a provider has no status-query capability, the platform records `Unsupported` and leaves the
attempt ambiguous. It does not infer a final status.
@@ -0,0 +1,41 @@
# Configuration reference
## Dispatch
| Property | Meaning | Bound |
|---|---|---|
| `claim-batch-size` | Rows claimed per scheduler tick | 1..1000 |
| `lease-duration` | How long a claimed job stays owned | positive, finite |
| `max-global-concurrency` | Ceiling across all providers | positive |
| `max-queue-age` | Age at which a job is escalated | positive |
| `max-retry-concurrency` | Ceiling for retry work | positive |
| `scheduler-poll-interval` | Queue poll cadence | positive |
| `callback-worker-concurrency` | Callback projection workers | positive |
Every value is bounded. "Unlimited" is not an accepted configuration.
## Provider profiles
A profile pins provider type, environment, credential profile, timeouts, concurrency, rate limit,
retry policy and callback profile. Sender identity and credential profile are separate concerns.
## Startup failures
Startup fails rather than degrading when:
- a payload or queue setting is unbounded
- a timeout is negative
- a TTL-required profile has no expiry source
- a callback signing secret is missing
- a production profile enables trust-all
- an APNs profile is missing its environment or topic
- a Web Push profile is missing its VAPID key
- two provider profiles share an id
- a route points only at disabled providers
- ambiguous fallback is enabled by default
## Secrets
All key material arrives through `SecretMaterialProvider`. Nothing is read from source, from a
committed file, or from a plaintext log. Contact point encryption and lookup HMAC keys must be
distinct, and the encryption key must be exactly 256 bits.
+62
View File
@@ -0,0 +1,62 @@
# Delivery evidence model
## The shape
```text
NotificationRequest
└─ RecipientDelivery
└─ DeliveryAttempt
└─ ProviderEvent (append-only)
└─ channel projector
└─ SubmissionOutcome / DeliveryOutcome / EvidenceLevel
+ EngagementFacts + SuppressionFacts
```
Four identities, four lifecycles. A logical request is not a recipient job, a recipient job is not a
provider attempt, and a provider attempt is not the event stream that describes it.
## Why not one status enum
A single linear status would have to answer "what happened?" with one value, and the real answers do
not fit on one line:
- An email can be `DELIVERED` and then generate a complaint. Both facts are true and both matter:
one for reporting, the other for suppression.
- Twilio does not guarantee callback ordering, so `sent` routinely arrives after `delivered`. Under
an ordinal rule the later, weaker event silently overwrites the stronger one.
- APNs may accept a notification and then store, replace or discard it.
So the ledger stores events and a channel projector merges them through an explicit transition table.
`StandardDeliveryProjector` holds the shared rules; provider projectors add only their own event
vocabulary.
## Merge rules
| Transition | Result |
|---|---|
| `sent``delivered` | applied |
| `delivered``sent` | ignored, event still stored |
| `delivered``complaint` | complaint fact added, delivery preserved |
| `complaint``delivered` | delivery applied, complaint preserved |
| `accepted``bounced` | applied |
| `read``displayed` | ignored |
| hard bounce → `delivered` | ignored, hard bounce is terminal |
Engagement (`opened`, `clicked`) is stored beside the delivery outcome and never changes it.
## Ambiguity
```text
platform ──── send ────▶ provider
└── accepted
✗ connection reset
```
The platform may hold no provider request id while the notification really was sent. The attempt
records `requestStarted`, `requestBodyCommitted`, `providerResponseReceived` and an
`EvidenceCertainty` for each, so a later decision can tell "we know nothing was sent" apart from "we
could not read the answer".
`ProviderSubmissionResult` enforces this: an ambiguous result may not claim `PROVIDER_ACCEPTED`, and
no submission result of any kind may carry a delivery outcome.
+31
View File
@@ -0,0 +1,31 @@
# Migration guide
## From the R0 routing seam
The pre-existing `dev.caskeleton.adapter.outbound.notification` router (`RoutingNotifier`,
`FailOpenNotificationProvider`, the Google email and Slack webhook seams) stays untouched. The
delivery platform lives beside it under `…notification.platform` and does not modify or delete any
R0 class.
Migration order per capability:
1. Register the contact points behind `ContactPointStorePort` so the platform owns protected values.
2. Publish the template version, and pin the template id, version and locale at every call site.
3. Move the call site from the router to the N1 typed facade for the channel.
4. Verify evidence in the snapshot rather than in the caller's return value: `submit()` is durable
acceptance and nothing more.
5. Remove the R0 route only after the platform route has produced provider evidence in the target
environment.
## Return-value semantics change
The R0 seam returned a send-shaped result. `NotificationReceipt` returns `notificationId`, a request
status and an acceptance time. Callers that treated the old return value as proof of delivery must be
changed; there is no compatibility shim, because a shim would have to invent the delivery claim this
platform exists to avoid.
## FCM target migration
Registration tokens keep working through `LegacyFcmRegistrationToken`. New registrations should use
`FcmInstallationId`. The two are distinct types, so a migration is a compile-time task rather than a
runtime guess.
+94
View File
@@ -0,0 +1,94 @@
# Notification Delivery Platform — module mapping
> Source design: `notification-superpowers-package/docs/superpowers/specs/2026-08-10-notification-platform-design.md`
>
> Source plan: `notification-superpowers-package/docs/superpowers/plans/2026-08-10-notification-platform-implementation-plan.md`
## Why a mapping exists
The plan was written against a hypothetical repository (`modules/notification/**`, root package
`io.backend.skeleton.notification`, 31 Gradle projects). This repository is a fail-closed
19-leaf Clean Architecture template: `src/settings.gradle` rejects any registry that does not
contain exactly the 19 modules in `src/config/architecture/modules.json`, and
`verifyCleanArchitectureDependencies` rejects any project edge outside `allowed_dependencies`.
Creating 31 new Gradle projects would violate HARD-STOP #5 of `AGENTS.md`. The package README
anticipates this and instructs the implementer to map dependency catalog and package/file paths onto
the host repository's rules while preserving the public contracts and reliability semantics.
Every logical module of the plan is therefore implemented as a **package** inside the registered leaf
that owns its responsibility. No public contract, evidence rule, or reliability semantic is dropped.
## Logical module → registered leaf
| Plan module | Registered leaf | Package |
|---|---|---|
| `notification-core-api` | `application-core` | `dev.caskeleton.application.notification.platform.api` |
| `notification-content-api` | `application-core` | `…platform.api.content` |
| `notification-contact-api` | `application-core` | `…platform.contact` |
| `notification-template-api` | `application-core` | `…platform.template` |
| `notification-policy` | `application-core` | `…platform.policy` |
| `notification-provider-spi` | `application-core` | `…platform.provider` |
| `notification-callback-api` | `application-core` | `…platform.callback` |
| `notification-email-api` | `application-core` | `…platform.email` |
| `notification-sms-api` | `application-core` | `…platform.sms` |
| `notification-push-api` | `application-core` | `…platform.push` |
| `notification-webpush` (API half) | `application-core` | `…platform.webpush` |
| `notification-inbox-api` | `application-core` | `…platform.inbox` |
| `notification-admin-api` | `application-core` | `…platform.admin` |
| `notification-security` (ports + redaction) | `application-core` | `…platform.security` |
| `notification-observability` (ports) | `application-core` | `…platform.observation` |
| `notification-dispatch-runtime` | `adapter:outbound:notification` | `dev.caskeleton.adapter.outbound.notification.platform.dispatch` |
| `notification-security` (AES-GCM/HMAC impl) | `adapter:outbound:notification` | `…platform.security` |
| `notification-template-thymeleaf` (reference renderer) | `adapter:outbound:notification` | `…platform.template` |
| `notification-email-smtp` | `adapter:outbound:notification` | `…platform.provider.smtp` |
| `notification-email-ses` | `adapter:outbound:notification` | `…platform.provider.ses` |
| `notification-sms-twilio` | `adapter:outbound:notification` | `…platform.provider.twilio` |
| `notification-push-fcm` | `adapter:outbound:notification` | `…platform.provider.fcm` |
| `notification-push-apns` | `adapter:outbound:notification` | `…platform.provider.apns` |
| `notification-webpush` (transport + crypto) | `adapter:outbound:notification` | `…platform.provider.webpush` |
| `notification-webhook-extension` | `adapter:outbound:notification` | `…platform.provider.webhook` |
| `notification-observability` (Micrometer impl) | `adapter:outbound:notification` | `…platform.observation` |
| `notification-admin-runtime` | `adapter:outbound:notification` | `…platform.admin` |
| `notification-reactor` | `adapter:outbound:notification` | `…platform.reactor` |
| `notification-spring-boot-starter` | `adapter:outbound:notification` (+ `app-bootstrap` wiring) | `…platform.autoconfigure` |
| `notification-persistence-jpa` | `adapter:outbound:persistence-jpa` | `dev.caskeleton.adapter.outbound.persistence.notification.platform` |
| `notification-inbox-jpa` | `adapter:outbound:persistence-jpa` | `…persistence.notification.platform.inbox` |
| `notification-callback-mvc` | `adapter:inbound:web` | `dev.caskeleton.adapter.inbound.web.notification.platform.callback` |
| `notification-callback-webflux` | `adapter:inbound:web` | `…callback.reactive` |
| `notification-testkit` | test source sets of the owning leaves | `…platform.testkit` |
## Dependency-direction consequences
The plan's module DAG (`*-api``provider-spi`/`policy` → runtime/adapters → starter) is preserved
by the leaf DAG that the registry already enforces:
```text
application-core (all *-api, provider SPI, policy, callback contracts)
↑ ↑ ↑
adapter:outbound:notification adapter:outbound:persistence-jpa adapter:inbound:web
↑ ↑ ↑
app-bootstrap
```
Two plan edges cannot be expressed as project edges in this repository, and are replaced by ports:
1. `notification-email-ses`, `notification-sms-twilio`, `notification-push-fcm`,
`notification-push-apns`, `notification-webpush`, `notification-webhook-extension`
`httpclient platform`.
`adapter-outbound-notification` is not allowed to depend on `adapter-outbound-httpclient`.
The provider adapters therefore call
`dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway`,
an adapter-local port with a JDK `java.net.http.HttpClient` default implementation.
`app-bootstrap` sees both leaves and is the supported place to substitute an implementation backed
by the HTTP Client Platform (TLS/timeout/circuit-breaker/SSRF/dynamic-target policy reuse).
2. `notification-inbox-jpa``optional messaging outbox integration`.
`adapter-outbound-persistence-jpa` may not depend on `adapter-outbound-messaging`; the inbox
publishes through the existing persistence outbox tables plus the
`NotificationInboxSignalPort` application port, and `app-bootstrap` binds the relay.
## Commit policy
`AGENTS.md` pins commit policy to `human-only`. Step 5 (`git add` / `git commit`) of every plan task
is therefore intentionally **not** executed by the agent; the working tree carries the change and the
human owner commits.
+47
View File
@@ -0,0 +1,47 @@
# Operations
## Runtime shape
```text
durable queue (PostgreSQL, FOR UPDATE SKIP LOCKED)
→ expiry check
→ suppression and eligibility re-check
→ provider health gate
→ rate limiter
→ concurrency limiter
→ provider adapter
```
Provider calls run outside every database transaction. The attempt row is committed first, so after a
crash the row is either absent (nothing was sent) or present in `DISPATCHING` (reconciliation has
something to ask about).
## Guards that exist for specific incidents
| Guard | The incident it prevents |
|---|---|
| Credential failure opens the provider route | One expired key multiplied by a queue becomes a self-inflicted outage |
| Retry budget per provider profile | A provider outage turning every queued notification into its own retry loop |
| Ambiguous attempts block automatic fallback | A push whose response was lost arriving alongside the "just in case" SMS |
| Permits released during backoff | A slow provider pinning the whole concurrency budget on work that is only waiting |
| Bounded drain on rotation | A provider that never answers holding a credential rotation open forever |
| Fail-fast intake on capacity | An unbounded in-memory queue absorbing a burst it cannot survive |
## Scheduling
`scheduleAt` activates the job, `notBefore` is the earliest permitted provider submission, and
`expiresAt` blocks new attempts, retries and fallbacks. Suppression and expiry are re-checked
immediately before dispatch, because a scheduled notification can sit in the queue for hours and the
user may have opted out in the meantime.
## Redrive
A redrive preserves `NotificationId` and `RecipientDeliveryId`, creates a new `DeliveryAttemptId`, and
reuses the pinned template version and rendered digest. Sending different content is a new
notification, not a redrive. Redriving an ambiguous attempt requires explicit duplicate-risk approval,
because the platform genuinely cannot tell whether the first submission reached the user.
## Actuator surface
Provider runtime states and generations, queue depth and age, callback and reconciliation health.
Never addresses, never credentials.
+65
View File
@@ -0,0 +1,65 @@
# Provider runbooks
## SMTP
| Symptom | Classification | Action |
|---|---|---|
| Final `2xx` after `DATA` | `CONFIRMED_ACCEPTED` / `PROVIDER_ACCEPTED` | None; this is acceptance, not inbox delivery |
| `4yz` | `TRANSIENT_PROVIDER` | Retry under budget and deadline |
| `5yz` | `PERMANENT_PROVIDER` or `INVALID_RECIPIENT` | Stop, or invalidate the contact point |
| Connection lost after `DATA` | `AMBIGUOUS_SUBMISSION` | Reconcile or escalate; do not resend automatically |
Connection, read, write and pool-acquire timeouts are all finite. There is no unbounded timeout.
## Amazon SES
`MessageId` is acceptance evidence. SES itself documents that it can accept a request and then not
send, so `MessageId` is never mapped to `DELIVERED`.
| Event | Normalized |
|---|---|
| `Send` | reinforces `PROVIDER_ACCEPTED` |
| `Delivery` | `DELIVERY_CONFIRMED` / `NETWORK_OR_CARRIER_ACCEPTED` |
| `DeliveryDelay` | delay fact |
| `Bounce` (permanent) | `BOUNCED_HARD` plus hard-bounce suppression |
| `Bounce` (transient) | `BOUNCED_SOFT`; retry policy input, not a suppression reason |
| `Complaint` | complaint fact plus suppression |
| `Reject` | `PROVIDER_REJECTED` |
| `RenderingFailure` | `TEMPLATE_FAILURE` |
## Twilio
`accepted`/`queued` is acceptance only. `sent` is carrier acceptance. `delivered` is device delivery.
Callbacks are not ordered. A `sent` arriving after `delivered` is stored and ignored by the
projection. Missing callbacks are corrected by status polling under the provider rate limit.
Signature verification uses the canonical external URL from the profile, not the URL the servlet
container reconstructed behind a proxy.
## FCM
| Error | Classification |
|---|---|
| `UNREGISTERED` | `INVALID_RECIPIENT`; invalidate the contact point, never retry |
| `INVALID_ARGUMENT` | `INVALID_PAYLOAD` |
| `QUOTA_EXCEEDED` | `THROTTLED`, exponential backoff |
| `UNAVAILABLE` | `TRANSIENT_PROVIDER`, honour `Retry-After`, add jitter |
| Credential failure | `AUTHENTICATION`; opens the provider route |
A batch is one transport call and many attempts. Partial results map back by input index; one
transport failure does not become one shared outcome unless the adapter can prove it.
## APNs
2xx is acceptance. Environment and topic mismatches are configuration failures, not delivery
failures. Sandbox and production tokens are separate namespaces.
## Web Push
`TTL` is mandatory by protocol. `201` is acceptance. `404` is an expired subscription per RFC 8030;
provider-documented `410` maps the same way. Payloads use `aes128gcm` per RFC 8291 and VAPID JWTs are
signed per RFC 8292 with the audience taken from the endpoint origin.
VAPID key rotation is not ordinary credential rotation: a restricted subscription may need to be
re-created, so it is a migration operation.
+56
View File
@@ -0,0 +1,56 @@
# Security and privacy
## Protected values
Email addresses, phone numbers, FCM installation ids and legacy tokens, APNs device tokens, Web Push
endpoints and keys, VAPID private keys, provider credentials, callback signing secrets, template
variables, rendered bodies, attachment references and unsubscribe tokens.
## At rest
Contact points are encrypted with AES-256-GCM. Equality lookup uses a separate HMAC-SHA-256
fingerprint.
Two keys, not one, because the requirements are opposite: the ciphertext must be non-deterministic so
two records of the same address are not visibly identical, while equality lookup must be
deterministic. The fingerprint is keyed rather than a plain digest because phone numbers and email
addresses come from a small, enumerable space — an unkeyed hash of a phone number is recoverable in
seconds.
The contact point kind is bound into the GCM associated data, so a ciphertext cannot be moved between
contact kinds without failing the authentication tag.
An unknown key id is refused rather than silently falling back to the current key: a silent fallback
would turn every historical row into a tag failure at read time.
## Never logged, never a metric tag
Addresses, tokens, Web Push endpoints and keys, message bodies, template variables, provider
credentials, unsubscribe tokens, attachment URLs, raw callback payloads and raw provider request ids.
Two mechanisms enforce this rather than convention:
- `CardinalityGuard` validates every metric tag against a closed allowlist.
- `SafeDiagnosticContext` rejects any structured-diagnostic field outside its allowlist.
An allowlist rather than a denylist, because the failure mode of a denylist is that the one field
nobody thought of is the one that leaks.
Every contact point value type overrides `toString()` to print `[redacted]`. That covers the case a
central redactor cannot: a value interpolated into a log line by accident.
## Web Push endpoints
RFC 8030 defines the push URI as a capability URL — knowing it is sufficient to push to the
subscriber. It is handled as a secret, not as a URL.
## Callbacks
TLS, provider signature verification over the exact received bytes and external URL, replay defence
where a timestamp or nonce is available, body-size and content-type limits, profile binding, rate
limiting, idempotent ingestion and a security audit trail for rejections.
## Tenant isolation
Every store port carries the tenant boundary in its signature. Administrative operations require an
explicit tenant or a global authority.
+68
View File
@@ -0,0 +1,68 @@
# Notification support matrix
What each channel can actually prove, and what the platform refuses to claim.
## Channels
| Channel | Reference implementation | Grade | Strongest evidence the platform records by default |
|---|---|---|---|
| Email | SMTP, Amazon SES API | Stable | Provider acceptance; recipient mail-server delivery, bounce and complaint when the provider publishes events |
| SMS | Twilio Programmable Messaging | Stable | `accepted`/`queued`, `sent`, and carrier-DLR `delivered`/`undelivered` |
| Mobile push (Android and cross-platform) | FCM, FID-first with legacy registration token compatibility | Stable | FCM acceptance and explicit failures |
| Mobile push (Apple) | APNs HTTP/2 provider API | Stable | APNs acceptance |
| Web Push | RFC 8030, RFC 8291, RFC 8292 | Stable | Push-service acceptance; user-agent acknowledgement only where the service offers receipts |
| In-app inbox | Own database | Optional stable | `PERSISTED`, `SEEN`, `READ` |
| Webhook | HTTP client platform | Extension | Whatever the receiving HTTP contract states |
## Evidence levels
`NONE``PLATFORM_QUEUED``PROVIDER_ACCEPTED``NETWORK_OR_CARRIER_ACCEPTED`
`DEVICE_DELIVERED``USER_AGENT_DISPLAYED``USER_READ`
| Provider signal | Highest evidence it may produce |
|---|---|
| Internal queue commit | `PLATFORM_QUEUED` |
| SES `MessageId` | `PROVIDER_ACCEPTED` |
| SES `Delivery` | `NETWORK_OR_CARRIER_ACCEPTED` |
| Twilio `accepted` / `queued` | `PROVIDER_ACCEPTED` |
| Twilio `sent` | `NETWORK_OR_CARRIER_ACCEPTED` |
| Twilio `delivered` | `DEVICE_DELIVERED` |
| FCM send success | `PROVIDER_ACCEPTED` |
| APNs 2xx | `PROVIDER_ACCEPTED` |
| Web Push `201` | `PROVIDER_ACCEPTED` |
| Web Push receipt capability | `DEVICE_DELIVERED` |
| In-app row commit | `PROVIDER_ACCEPTED` |
| In-app `seen` endpoint | `USER_AGENT_DISPLAYED` |
| In-app `read` endpoint, authenticated app receipt | `USER_READ` |
Promotions the platform will not make, in code or in configuration:
- FCM send success is not `DEVICE_DELIVERED`.
- An APNs 2xx is not `DELIVERED`.
- An SES `MessageId` is not `DELIVERED`.
- An SMTP `250` is not inbox delivery.
## Submission outcomes
`NOT_SUBMITTED`, `CONFIRMED_ACCEPTED`, `CONFIRMED_REJECTED`, `AMBIGUOUS`.
`AMBIGUOUS` is a first-class stored state, not an error path. It means the request body was committed
to the provider and the outcome could not be read. While an ambiguous attempt exists on a recipient
delivery, automatic retry and automatic cross-channel fallback are both blocked.
## Not supported
The platform will not claim any of the following, because no channel above can support them:
- guaranteed delivery
- guaranteed read
- exactly-once human notification
- unconditional multi-provider failover after an unread response
- provider SDK types in the public API
- audience selection, campaign segmentation or jurisdiction rulings
## Target model
`FCM_FID` is the primary mobile push target. `FCM_REGISTRATION_TOKEN_LEGACY` and
`APNS_DEVICE_TOKEN` are separate types with separate lifecycles; they are never flattened into one
string field.
@@ -0,0 +1,94 @@
# Toxiproxy fault injection for the notification delivery platform.
#
# Scope, stated up front: this is the *nightly and release* fault suite, not the PR gate. The PR
# suite runs against a loopback socket harness in-process — deterministic, no Docker, no provider
# sandbox — because a gate that needs infrastructure is a gate people learn to skip. What lives
# here are the faults that harness cannot produce: real TCP behaviour under latency, bandwidth
# starvation, and connection resets at a point the JVM's own socket layer decides.
#
# Usage:
# docker compose -f infra/notification/toxiproxy/docker-compose.yml up -d
# ./gradlew :adapter:outbound:notification:test -Dnotification.faultProxy=http://127.0.0.1:8474
#
# The proxies below front *stub* upstreams, never a provider's real API. Pointing a toxic proxy at
# a live provider sends real notifications to real people from a test run, and adds a rate-limit
# incident on an account the team shares.
services:
toxiproxy:
image: ghcr.io/shopify/toxiproxy:2.11.0
container_name: notification-toxiproxy
ports:
- "8474:8474" # control API
- "18081:18081" # -> ses-stub
- "18082:18082" # -> twilio-stub
- "18083:18083" # -> push-stub (APNs / FCM / Web Push)
networks: [notification-fault]
healthcheck:
test: ["CMD", "/toxiproxy-cli", "list"]
interval: 5s
timeout: 3s
retries: 10
# Deterministic upstreams. Each returns the provider's success shape and nothing else; the
# interesting behaviour is injected by the proxy in front of it, not by the stub.
ses-stub:
image: mendhak/http-https-echo:35
environment:
HTTP_PORT: "8080"
networks: [notification-fault]
twilio-stub:
image: mendhak/http-https-echo:35
environment:
HTTP_PORT: "8080"
networks: [notification-fault]
push-stub:
image: mendhak/http-https-echo:35
environment:
HTTP_PORT: "8080"
networks: [notification-fault]
# Creates the proxies and the toxics once the control API is up. Kept as a job rather than a
# README step so the topology is reproducible and reviewable rather than typed from memory.
provision:
image: ghcr.io/shopify/toxiproxy:2.11.0
depends_on:
toxiproxy:
condition: service_healthy
networks: [notification-fault]
entrypoint:
- /bin/sh
- -c
- |
set -e
CLI="/toxiproxy-cli -h toxiproxy:8474"
$$CLI create -l 0.0.0.0:18081 -u ses-stub:8080 ses
$$CLI create -l 0.0.0.0:18082 -u twilio-stub:8080 twilio
$$CLI create -l 0.0.0.0:18083 -u push-stub:8080 push
# Response loss after the request was committed: the provider received and acted on the
# message, and the answer never came back. This is the AMBIGUOUS case, and it is the one
# fault no provider's documentation describes.
$$CLI toxic add -t timeout -a timeout=0 -n response_loss --downstream --toxicity 0 ses
$$CLI toxic add -t timeout -a timeout=0 -n response_loss --downstream --toxicity 0 twilio
$$CLI toxic add -t timeout -a timeout=0 -n response_loss --downstream --toxicity 0 push
# Latency past the adapter's own timeout, to prove the timeout is the adapter's decision
# rather than the socket's.
$$CLI toxic add -t latency -a latency=8000 -n slow --toxicity 0 ses
$$CLI toxic add -t latency -a latency=8000 -n slow --toxicity 0 twilio
$$CLI toxic add -t latency -a latency=8000 -n slow --toxicity 0 push
# Partial write: the connection dies mid-body. Distinct from response loss, because the
# provider never got a complete request and the attempt is genuinely retryable.
$$CLI toxic add -t limit_data -a bytes=64 -n partial_write --upstream --toxicity 0 ses
$$CLI toxic add -t limit_data -a bytes=64 -n partial_write --upstream --toxicity 0 twilio
$$CLI toxic add -t limit_data -a bytes=64 -n partial_write --upstream --toxicity 0 push
echo "proxies ready; toxics are registered at toxicity=0 and enabled per test"
$$CLI list
networks:
notification-fault:
driver: bridge
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
/**
* Security chain for the provider callback endpoints.
*
* <p>Callbacks authenticate with a provider signature, not with a user session, so they get their
* own chain: CSRF and session creation are off, and the ordinary user chain never sees them.
* Putting them on the user chain would either break every provider or force the user chain to be
* permissive.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(
prefix = "ca-skeleton.notification.platform.callbacks",
name = "enabled",
havingValue = "true")
public class CallbackMvcSecurityConfiguration {
/** Dedicated, ordered-first chain for the callback path. */
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public SecurityFilterChain notificationCallbackFilterChain(HttpSecurity http) throws Exception {
return http.securityMatcher("/internal/notification/callbacks/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(requests -> requests.anyRequest().permitAll())
.build();
}
}
@@ -0,0 +1,75 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.callback.CallbackRequest;
import jakarta.servlet.http.HttpServletRequest;
import java.time.Clock;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Builds the transport-neutral callback request.
*
* <p>Both the servlet and reactive endpoints use this, so signature verification sees exactly the
* same canonical bytes and URL regardless of which stack received the call.
*/
public final class CallbackRequestFactory {
private final ExternalRequestUrlResolver urlResolver;
private final Clock clock;
public CallbackRequestFactory(ExternalRequestUrlResolver urlResolver, Clock clock) {
this.urlResolver = Objects.requireNonNull(urlResolver, "urlResolver");
this.clock = Objects.requireNonNull(clock, "clock");
}
/** Build from a servlet request plus the already-read raw body. */
public CallbackRequest create(
String provider, String profile, HttpServletRequest request, byte[] body) {
Objects.requireNonNull(provider, "provider");
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(request, "request");
Objects.requireNonNull(body, "body");
Map<String, List<String>> headers = new LinkedHashMap<>();
for (String name : Collections.list(request.getHeaderNames())) {
headers.put(name, new ArrayList<>(Collections.list(request.getHeaders(name))));
}
return new CallbackRequest(
new ProviderId(provider),
new ProviderProfileId(profile),
urlResolver.resolve(request),
request.getMethod(),
Optional.ofNullable(request.getContentType()),
headers,
body,
clock.instant());
}
/** Build from an already-resolved external URL, used by the reactive endpoint. */
public CallbackRequest create(
String provider,
String profile,
String externalUrl,
String method,
Optional<String> contentType,
Map<String, List<String>> headers,
byte[] body) {
return new CallbackRequest(
new ProviderId(provider),
new ProviderProfileId(profile),
externalUrl,
method,
contentType,
headers,
body,
clock.instant());
}
}
@@ -0,0 +1,71 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/**
* Reconstructs the URL the provider actually called.
*
* <p>Several providers sign the request URL, so getting this wrong turns every valid webhook into a
* signature failure. Forwarded headers are only honoured when the immediate peer is a configured
* trusted proxy: trusting them unconditionally would let any caller choose the URL that gets
* verified, which defeats the signature entirely.
*/
public final class ExternalRequestUrlResolver {
private final Set<String> trustedProxies;
public ExternalRequestUrlResolver(Set<String> trustedProxies) {
this.trustedProxies = Set.copyOf(Objects.requireNonNull(trustedProxies, "trustedProxies"));
}
/** External URL of a request. */
public String resolve(HttpServletRequest request) {
Objects.requireNonNull(request, "request");
String scheme = request.getScheme();
String host = request.getServerName();
int port = request.getServerPort();
if (trustedProxies.contains(request.getRemoteAddr())) {
String forwarded = request.getHeader("Forwarded");
if (forwarded != null) {
for (String element : forwarded.split(";", -1)) {
String trimmed = element.trim().toLowerCase(Locale.ROOT);
if (trimmed.startsWith("proto=")) {
scheme = trimmed.substring("proto=".length());
} else if (trimmed.startsWith("host=")) {
host = element.trim().substring("host=".length());
port = -1;
}
}
} else {
String protoHeader = request.getHeader("X-Forwarded-Proto");
String hostHeader = request.getHeader("X-Forwarded-Host");
if (protoHeader != null) {
scheme = protoHeader;
}
if (hostHeader != null) {
host = hostHeader;
port = -1;
}
}
}
StringBuilder url = new StringBuilder(scheme).append("://").append(host);
boolean defaultPort =
port < 0
|| ("https".equalsIgnoreCase(scheme) && port == 443)
|| ("http".equalsIgnoreCase(scheme) && port == 80);
if (!defaultPort) {
url.append(':').append(port);
}
url.append(request.getRequestURI());
String query = request.getQueryString();
if (query != null && !query.isBlank()) {
url.append('?').append(query);
}
return url.toString();
}
}
@@ -0,0 +1,75 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException;
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Objects;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Servlet callback endpoint.
*
* <p>The body arrives as raw bytes, never as a parsed form. Providers sign the exact octets, and
* letting the container parse and re-encode them is the most common cause of a valid webhook
* failing verification.
*
* <p>The response is a bare {@code 204}: no body, no diagnostics. A provider only needs to know the
* event is recorded, and an error body would be a channel for leaking what the platform knows.
*
* <p>Registered only in a servlet application and only when callbacks are enabled. An annotated
* controller is also honoured by WebFlux, so without the servlet condition a reactive deployment
* would map both this and the functional router onto the same path — and a provider signature would
* then be verified twice against two different canonical URLs.
*/
@RestController
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnProperty(
prefix = "ca-skeleton.notification.platform.callbacks",
name = "enabled",
havingValue = "true")
@RequestMapping("/internal/notification/callbacks")
public final class NotificationCallbackMvcController {
/** Hard body ceiling applied before any provider adapter is consulted. */
public static final int MAX_BODY_BYTES = 65_536;
private final ProviderCallbackIngestionService ingestion;
private final CallbackRequestFactory requestFactory;
public NotificationCallbackMvcController(
ProviderCallbackIngestionService ingestion, CallbackRequestFactory requestFactory) {
this.ingestion = Objects.requireNonNull(ingestion, "ingestion");
this.requestFactory = Objects.requireNonNull(requestFactory, "requestFactory");
}
/** Receive one provider callback. */
@PostMapping(path = "/{provider}/{profile}")
public ResponseEntity<Void> callback(
@PathVariable String provider,
@PathVariable String profile,
HttpServletRequest request,
@RequestBody byte[] body) {
if (body.length > MAX_BODY_BYTES) {
return ResponseEntity.status(HttpStatus.CONTENT_TOO_LARGE).build();
}
// A duplicate answers 204 exactly like a first delivery. The provider did its job either way,
// and any other status would make it retry an event that is already recorded.
ingestion.ingest(requestFactory.create(provider, profile, request, body));
return ResponseEntity.noContent().build();
}
/** A rejected callback never reveals why beyond the status code. */
@ExceptionHandler(CallbackValidationException.class)
public ResponseEntity<Void> onValidationFailure(CallbackValidationException failure) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive;
import java.util.Objects;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.web.reactive.function.server.ServerRequest;
import reactor.core.publisher.Mono;
/**
* Reads the raw body with a hard ceiling and no buffer leaks.
*
* <p>Every {@link DataBuffer} is released on success, on error and on cancellation. A reactive
* endpoint that forgets the cancellation path leaks native memory exactly when it is under the load
* that caused the cancellation.
*/
public final class BoundedCallbackBodyReader {
private final int maxBytes;
public BoundedCallbackBodyReader(int maxBytes) {
if (maxBytes < 1) {
throw new IllegalArgumentException("maxBytes");
}
this.maxBytes = maxBytes;
}
/** Read at most the configured number of bytes. */
public Mono<byte[]> read(ServerRequest request) {
Objects.requireNonNull(request, "request");
return DataBufferUtils.join(request.bodyToFlux(DataBuffer.class), maxBytes)
.map(
buffer -> {
try {
byte[] bytes = new byte[buffer.readableByteCount()];
buffer.read(bytes);
return bytes;
} finally {
DataBufferUtils.release(buffer);
}
})
.defaultIfEmpty(new byte[0]);
}
/** Configured ceiling. */
public int maxBytes() {
return maxBytes;
}
}
@@ -0,0 +1,58 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive;
import dev.caskeleton.adapter.inbound.web.notification.platform.callback.CallbackRequestFactory;
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
/**
* Registers the reactive callback transport, and only it.
*
* <p>This configuration is {@code REACTIVE}-only and the servlet controller carries the matching
* {@code SERVLET} condition, so exactly one of the two is ever registered — by construction rather
* than by convention. Both on the same path would mean a provider signature is verified twice
* against two different canonical URLs, a failure that shows up only in production and only for
* signed providers, and reads like a credential problem.
*
* <p>The body ceiling is read as a property rather than through the platform settings type: that
* type belongs to the outbound notification adapter, which this inbound adapter must not depend on.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
@ConditionalOnProperty(
prefix = "ca-skeleton.notification.platform.callbacks",
name = "enabled",
havingValue = "true")
public class CallbackWebFluxConfiguration {
/** Bounded body reader; the ceiling applies before any provider adapter is consulted. */
@Bean
@ConditionalOnMissingBean
public BoundedCallbackBodyReader notificationCallbackBodyReader(
@Value("${ca-skeleton.notification.platform.callbacks.max-body-bytes:65536}") int maxBytes) {
return new BoundedCallbackBodyReader(maxBytes);
}
/** Reactive handler. */
@Bean
@ConditionalOnMissingBean
public NotificationCallbackWebFluxHandler notificationCallbackWebFluxHandler(
ProviderCallbackIngestionService ingestion,
CallbackRequestFactory requestFactory,
BoundedCallbackBodyReader bodyReader) {
return new NotificationCallbackWebFluxHandler(ingestion, requestFactory, bodyReader);
}
/** Functional route for the callback path. */
@Bean
public RouterFunction<ServerResponse> notificationCallbackRoutes(
NotificationCallbackWebFluxHandler handler) {
return new CallbackWebFluxRouter(handler).routes();
}
}
@@ -0,0 +1,30 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive;
import java.util.Objects;
import org.springframework.web.reactive.function.server.RequestPredicates;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
/**
* Routes the reactive callback path.
*
* <p>Kept separate from the servlet controller so that only one of the two is ever registered; two
* endpoints on the same path would mean a provider's signature is verified twice against two
* different canonical URLs.
*/
public final class CallbackWebFluxRouter {
private final NotificationCallbackWebFluxHandler handler;
public CallbackWebFluxRouter(NotificationCallbackWebFluxHandler handler) {
this.handler = Objects.requireNonNull(handler, "handler");
}
/** Router function for the callback path. */
public RouterFunction<ServerResponse> routes() {
return RouterFunctions.route(
RequestPredicates.POST("/internal/notification/callbacks/{provider}/{profile}"),
handler::handle);
}
}
@@ -0,0 +1,85 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive;
import dev.caskeleton.adapter.inbound.web.notification.platform.callback.CallbackRequestFactory;
import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException;
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.springframework.core.io.buffer.DataBufferLimitException;
import org.springframework.http.HttpStatus;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
/**
* Reactive callback endpoint.
*
* <p>Ingestion is blocking — it writes to the database — so it runs on {@code boundedElastic} and
* never on the event loop. Running it inline would stall every other connection the loop is
* serving.
*
* <p>It shares the canonicalisation and the ingestion service with the servlet endpoint, so a
* deployment can switch web stacks without changing what a provider signature is checked against.
*/
public final class NotificationCallbackWebFluxHandler {
private final ProviderCallbackIngestionService ingestion;
private final CallbackRequestFactory requestFactory;
private final BoundedCallbackBodyReader bodyReader;
public NotificationCallbackWebFluxHandler(
ProviderCallbackIngestionService ingestion,
CallbackRequestFactory requestFactory,
BoundedCallbackBodyReader bodyReader) {
this.ingestion = Objects.requireNonNull(ingestion, "ingestion");
this.requestFactory = Objects.requireNonNull(requestFactory, "requestFactory");
this.bodyReader = Objects.requireNonNull(bodyReader, "bodyReader");
}
/** Handle one callback. */
public Mono<ServerResponse> handle(ServerRequest request) {
String provider = request.pathVariable("provider");
String profile = request.pathVariable("profile");
return bodyReader
.read(request)
.flatMap(
body ->
Mono.fromCallable(
() ->
ingestion.ingest(
requestFactory.create(
provider,
profile,
request.uri().toString(),
request.method().name(),
request.headers().contentType().map(Object::toString),
headers(request),
body)))
.subscribeOn(Schedulers.boundedElastic()))
.then(ServerResponse.noContent().build())
.onErrorResume(
DataBufferLimitException.class,
failure -> ServerResponse.status(HttpStatus.CONTENT_TOO_LARGE).build())
.onErrorResume(
CallbackValidationException.class,
failure -> ServerResponse.status(HttpStatus.BAD_REQUEST).build());
}
private static Map<String, List<String>> headers(ServerRequest request) {
Map<String, List<String>> headers = new java.util.LinkedHashMap<>();
request
.headers()
.asHttpHeaders()
.forEach((name, values) -> headers.put(name, List.copyOf(values)));
return Map.copyOf(headers);
}
/** Content type of a request, if declared. */
public static Optional<String> contentType(ServerRequest request) {
return request.headers().contentType().map(Object::toString);
}
}
@@ -0,0 +1,396 @@
package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.callback.AppendEventResult;
import dev.caskeleton.application.notification.platform.callback.CallbackLimits;
import dev.caskeleton.application.notification.platform.callback.CallbackRequest;
import dev.caskeleton.application.notification.platform.callback.CallbackVerificationResult;
import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent;
import dev.caskeleton.application.notification.platform.callback.ProjectionResult;
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter;
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapterRegistry;
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService;
import dev.caskeleton.application.notification.platform.callback.ProviderEventLedger;
import dev.caskeleton.application.notification.platform.callback.ProviderEventProjectionService;
import dev.caskeleton.application.notification.platform.callback.ProviderEventRecord;
import dev.caskeleton.application.notification.platform.callback.ProviderEventRecordId;
import dev.caskeleton.application.notification.platform.callback.VerifiedCallback;
import dev.caskeleton.application.notification.platform.callback.VerifiedProviderEvent;
import dev.caskeleton.application.notification.platform.observation.NotificationMetricsPort;
import dev.caskeleton.application.notification.platform.observation.NotificationSecurityAuditPort;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpServletRequest;
/**
* What the servlet transport is responsible for handing the callback pipeline.
*
* <p>The pipeline itself belongs to application-core and is tested there. What is only testable
* here is the translation: the exact received octets, the externally-visible URL, and headers that
* survive the servlet container's own casing. Each is a common cause of a valid webhook failing
* verification, and none is visible from a unit test of the provider adapter.
*
* <p>The capture point is the provider adapter's {@code verify}, which is the first thing in the
* pipeline to see the whole request. It rejects, so the test never needs a ledger.
*/
class NotificationCallbackMvcControllerTest {
private static final Clock CLOCK =
Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneOffset.UTC);
private static final String TRUSTED_PROXY = "10.0.0.1";
private final List<CallbackRequest> verified = new ArrayList<>();
private final List<String> rejections = new ArrayList<>();
private final NotificationCallbackMvcController controller =
new NotificationCallbackMvcController(
new ProviderCallbackIngestionService(
new CapturingRegistry(),
new UnusedLedger(),
// Never reached: verification always fails in this fixture, and the pipeline appends
// only after a valid signature.
new ProviderEventProjectionService(
new UnusedLedger(),
providerId -> java.util.Optional.empty(),
new UnusedAttemptResolver(),
new UnusedProjectionStore(),
(attempt, facts) -> {
throw new UnsupportedOperationException();
},
new UnusedTransactions(),
new DiscardingMetrics()),
new UnusedPayloadProtection(),
new RecordingSecurityAudit(),
new DiscardingMetrics(),
CLOCK),
new CallbackRequestFactory(new ExternalRequestUrlResolver(Set.of(TRUSTED_PROXY)), CLOCK));
@Test
void theExactReceivedOctetsReachTheAdapterUnparsed() {
byte[] body =
"MessageSid=SM1&MessageStatus=delivered&Signed=a+b%2Fc".getBytes(StandardCharsets.UTF_8);
assertThatThrownBy(
() ->
controller.callback(
"twilio", "twilio-primary", request("application/x-www-form-urlencoded"), body))
.isInstanceOf(CallbackValidationException.class);
// Byte for byte, including the percent-encoding a form parse would have consumed and re-encoded
// differently — which is the single most common cause of a valid webhook failing its signature.
assertThat(verified).hasSize(1);
assertThat(verified.get(0).body()).isEqualTo(body);
assertThat(verified.get(0).contentType()).contains("application/x-www-form-urlencoded");
assertThat(verified.get(0).httpMethod()).isEqualTo("POST");
}
@Test
void aForwardedHostFromAnUntrustedPeerIsIgnored() {
var request = request("application/json");
request.setRemoteAddr("203.0.113.9");
request.addHeader("X-Forwarded-Proto", "https");
request.addHeader("X-Forwarded-Host", "attacker.example.com");
assertThatThrownBy(
() ->
controller.callback(
"twilio", "twilio-primary", request, "{}".getBytes(StandardCharsets.UTF_8)))
.isInstanceOf(CallbackValidationException.class);
// Honouring the header unconditionally would let any caller choose the URL that gets verified,
// which defeats the signature entirely.
assertThat(verified.get(0).externalUrl()).doesNotContain("attacker.example.com");
}
@Test
void aForwardedHostFromATrustedProxyBecomesTheCanonicalUrl() {
var request = request("application/json");
request.setRemoteAddr(TRUSTED_PROXY);
request.addHeader("X-Forwarded-Proto", "https");
request.addHeader("X-Forwarded-Host", "callback.example.com");
assertThatThrownBy(
() ->
controller.callback(
"twilio", "twilio-primary", request, "{}".getBytes(StandardCharsets.UTF_8)))
.isInstanceOf(CallbackValidationException.class);
assertThat(verified.get(0).externalUrl())
.isEqualTo(
"https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary");
}
@Test
void headersSurviveTheContainersCasingAndStayAddressableEitherWay() {
var request = request("application/json");
request.addHeader("X-Twilio-Signature", "abc123");
assertThatThrownBy(
() ->
controller.callback(
"twilio", "twilio-primary", request, "{}".getBytes(StandardCharsets.UTF_8)))
.isInstanceOf(CallbackValidationException.class);
assertThat(verified.get(0).header("x-twilio-signature")).contains("abc123");
assertThat(verified.get(0).header("X-TWILIO-SIGNATURE")).contains("abc123");
}
@Test
void aBodyOverTheTransportCeilingIsRefusedBeforeAnyAdapterIsConsulted() {
byte[] oversized = new byte[NotificationCallbackMvcController.MAX_BODY_BYTES + 1];
var response =
controller.callback("twilio", "twilio-primary", request("application/json"), oversized);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONTENT_TOO_LARGE);
// Nothing downstream sees it, so no signature check ever runs over an attacker-sized payload.
assertThat(verified).isEmpty();
}
@Test
void aRejectedCallbackRevealsNothingBeyondTheStatusCode() {
var response =
controller.onValidationFailure(
new CallbackValidationException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.CALLBACK_SIGNATURE_INVALID,
FailureCategory.CALLBACK_VALIDATION_FAILURE)));
// The endpoint is unauthenticated by design — the signature is the authentication — so an error
// body is a free oracle for whoever is probing it.
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).isNull();
}
@Test
void aRejectedSignatureIsRecordedAsASecurityEventRatherThanADeliveryEvent() {
assertThatThrownBy(
() ->
controller.callback(
"twilio",
"twilio-primary",
request("application/json"),
"{}".getBytes(StandardCharsets.UTF_8)))
.isInstanceOf(CallbackValidationException.class);
// Writing it to the ledger would let anyone who can reach the endpoint fill a recipient's
// delivery history with noise.
assertThat(rejections).containsExactly("SIGNATURE_MISMATCH");
}
private static MockHttpServletRequest request(String contentType) {
var request =
new MockHttpServletRequest(
"POST", "/internal/notification/callbacks/twilio/twilio-primary");
request.setContentType(contentType);
return request;
}
/** Registry whose adapter records the request and then refuses it. */
private final class CapturingRegistry implements ProviderCallbackAdapterRegistry {
@Override
public ProviderCallbackAdapter require(ProviderProfileId profileId) {
return new ProviderCallbackAdapter() {
@Override
public ProviderId providerId() {
return new ProviderId("twilio");
}
@Override
public CallbackVerificationResult verify(CallbackRequest request) {
verified.add(request);
return CallbackVerificationResult.invalid("SIGNATURE_MISMATCH");
}
@Override
public List<NormalizedProviderEvent> normalize(VerifiedCallback callback) {
throw new UnsupportedOperationException("verification always fails in this fixture");
}
};
}
@Override
public CallbackLimits limitsFor(ProviderProfileId profileId) {
return new CallbackLimits(
65_536L, Set.of("application/json", "application/x-www-form-urlencoded"));
}
}
/** Security audit that keeps the rejection reason. */
private final class RecordingSecurityAudit implements NotificationSecurityAuditPort {
@Override
public void callbackSignatureRejected(ProviderProfileId profileId, String reasonCode) {
rejections.add(reasonCode);
}
@Override
public void callbackRejectedByLimit(ProviderProfileId profileId, String reasonCode) {
rejections.add(reasonCode);
}
}
/** Metrics are exercised elsewhere; discarding them keeps this test about the transport. */
private static final class DiscardingMetrics implements NotificationMetricsPort {
@Override
public void increment(String metricName, Map<String, String> tags) {
// Intentionally empty.
}
@Override
public void record(String metricName, Map<String, String> tags, Duration value) {
// Intentionally empty.
}
@Override
public void gauge(String metricName, Map<String, String> tags, double value) {
// Intentionally empty.
}
}
/** Never reached: attempt correlation happens only for an accepted callback. */
private static final class UnusedAttemptResolver
implements dev.caskeleton.application.notification.platform.callback
.DeliveryAttemptResolverPort {
@Override
public java.util.Optional<
dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot>
byAttemptId(DeliveryAttemptId attemptId) {
return java.util.Optional.empty();
}
@Override
public java.util.Optional<
dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot>
byProviderRequestId(ProviderProfileId profileId, String providerRequestIdHash) {
return java.util.Optional.empty();
}
}
/** Never reached: projection runs only after a signature has been accepted. */
private static final class UnusedProjectionStore
implements dev.caskeleton.application.notification.platform.callback
.DeliveryProjectionStorePort {
@Override
public dev.caskeleton.application.notification.platform.callback.DeliveryProjection load(
DeliveryAttemptId attemptId) {
throw new UnsupportedOperationException();
}
@Override
public void save(
DeliveryAttemptId attemptId,
dev.caskeleton.application.notification.platform.callback.DeliveryProjection projection) {
throw new UnsupportedOperationException();
}
}
/** Never reached: nothing in this fixture gets as far as a transaction. */
private static final class UnusedTransactions
implements dev.caskeleton.application.transaction.TransactionPort {
@Override
public <T> T inWrite(java.util.function.Supplier<T> action) {
throw new UnsupportedOperationException();
}
@Override
public <T> T inRootWrite(java.util.function.Supplier<T> action) {
throw new UnsupportedOperationException();
}
@Override
public <T> T inRead(java.util.function.Supplier<T> action) {
throw new UnsupportedOperationException();
}
@Override
public <T> T inNew(java.util.function.Supplier<T> action) {
throw new UnsupportedOperationException();
}
}
/** Never reached: every request in this fixture is rejected before the payload is retained. */
private static final class UnusedPayloadProtection
implements dev.caskeleton.application.notification.platform.callback
.CallbackPayloadProtectionPort {
@Override
public byte[] protectRawPayload(byte[] rawBody) {
throw new UnsupportedOperationException();
}
@Override
public String digest(byte[] rawBody) {
throw new UnsupportedOperationException();
}
@Override
public String fingerprint(
ProviderProfileId profileId, NormalizedProviderEvent event, String rawPayloadDigest) {
throw new UnsupportedOperationException();
}
}
/** Never reached: every request in this fixture is rejected before the append. */
private static final class UnusedLedger implements ProviderEventLedger {
@Override
public AppendEventResult append(VerifiedProviderEvent event) {
throw new UnsupportedOperationException();
}
@Override
public AppendEventResult appendAll(List<VerifiedProviderEvent> events) {
throw new UnsupportedOperationException();
}
@Override
public List<ProviderEventRecord> pendingProjection(int limit) {
throw new UnsupportedOperationException();
}
@Override
public void markApplied(ProviderEventRecordId eventId, ProjectionResult result) {
throw new UnsupportedOperationException();
}
@Override
public void markFailed(ProviderEventRecordId eventId, String errorCode) {
throw new UnsupportedOperationException();
}
@Override
public List<ProviderEventRecord> unmatched(int limit) {
throw new UnsupportedOperationException();
}
@Override
public List<ProviderEventRecord> eventsForAttempt(DeliveryAttemptId attemptId) {
throw new UnsupportedOperationException();
}
}
}
@@ -6,6 +6,34 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-autoconfigure'
implementation 'org.springframework:spring-web' // Slack webhook client (RestClient)
implementation 'org.slf4j:slf4j-api'
// Notification Delivery Platform.
// - mail: the SMTP provider adapter is built on JavaMailSender/MimeMessageHelper, which is where
// multipart/alternative, inline resources and header validation already live. Rebuilding MIME
// by hand to avoid one dependency would be the more dangerous choice.
// - jackson-databind: provider payloads, callback bodies and the canonical variables payload are
// JSON. It stays inside this adapter; application-core never sees a JSON type.
// - reactor-core: only the optional Reactor facade uses it. The core async type stays
// CompletionStage, so nothing else on this classpath depends on Reactor.
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-json'
implementation 'io.projectreactor:reactor-core'
// JSON Schema 2020-12 validation of template variables, using the same validator and version the
// messaging adapter already depends on rather than a second implementation of the same spec.
// The YAML dataformat is excluded: schemas are supplied as JSON strings, so pulling a YAML
// parser onto the runtime classpath would add attack surface for a format nothing reads.
// Thymeleaf is the reference HTML renderer, added as the engine only — not the Spring
// starter, which would drag a view resolver and a servlet integration onto an outbound
// adapter that renders strings and never serves a request.
implementation 'org.thymeleaf:thymeleaf'
implementation('com.networknt:json-schema-validator:3.0.2') {
exclude group: 'tools.jackson.dataformat', module: 'jackson-dataformat-yaml'
exclude group: 'com.fasterxml.jackson.dataformat', module: 'jackson-dataformat-yaml'
}
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
testImplementation 'io.projectreactor:reactor-test'
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
@@ -1,23 +1,24 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.ethlo.time:itu:1.14.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
@@ -32,6 +33,7 @@ com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnno
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.networknt:json-schema-validator:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
@@ -43,8 +45,11 @@ io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnota
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-test:3.8.0=testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.mail:jakarta.mail-api:2.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
@@ -53,6 +58,7 @@ net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
ognl:ognl:3.3.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
@@ -60,9 +66,9 @@ org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
@@ -73,14 +79,18 @@ org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,test
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
org.attoparser:attoparser:2.0.7.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.eclipse.angus:angus-activation:2.0.3=runtimeClasspath,testRuntimeClasspath
org.eclipse.angus:angus-mail:2.0.5=runtimeClasspath,testRuntimeClasspath
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.javassist:javassist:3.29.0-GA=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
@@ -95,10 +105,10 @@ org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeCla
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
@@ -106,28 +116,32 @@ org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-mail:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-json:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-mail:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
@@ -137,16 +151,19 @@ org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRunti
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context-support:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
org.thymeleaf:thymeleaf:3.1.3.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.unbescape:unbescape:1.1.6.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.outbound.notification.platform.admin;
import dev.caskeleton.application.notification.platform.admin.AdminAccessDeniedException;
import dev.caskeleton.application.notification.platform.admin.AdminActor;
import dev.caskeleton.application.notification.platform.admin.NotificationAdminAuthority;
import dev.caskeleton.application.notification.platform.api.TenantId;
import java.util.Objects;
import java.util.Optional;
/**
* Operator authority check.
*
* <p>Application authority never grants an operator authority. The two planes are separated so that
* a compromised application credential cannot redrive a message or lift a suppression — the actions
* whose whole purpose is to override the platform's own safety decisions.
*/
public final class AdminAuthorizationGuard {
/** Require an authority, or refuse. */
public void require(AdminActor actor, NotificationAdminAuthority authority) {
Objects.requireNonNull(actor, "actor");
Objects.requireNonNull(authority, "authority");
if (!actor.holds(authority)) {
throw new AdminAccessDeniedException(authority);
}
}
/**
* Require that the actor may act on a tenant.
*
* <p>An actor with no tenant is a global operator; one bound to a tenant may only act inside it.
*/
public void requireTenant(AdminActor actor, TenantId tenantId) {
Objects.requireNonNull(actor, "actor");
Objects.requireNonNull(tenantId, "tenantId");
Optional<TenantId> scope = actor.tenantId();
if (scope.isPresent() && !scope.get().equals(tenantId)) {
throw new AdminAccessDeniedException(NotificationAdminAuthority.SUPPRESS);
}
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.outbound.notification.platform.admin;
import dev.caskeleton.application.notification.platform.admin.DuplicateRiskApprovalRequiredException;
import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation;
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
import java.util.Objects;
/**
* Blocks an unapproved redrive of an ambiguous attempt.
*
* <p>The platform cannot tell whether the first submission reached the user, so re-sending is a
* decision with a real cost that only a human can accept. Requiring the approval flag makes that
* acceptance an explicit, audited act rather than a default.
*/
public final class DuplicateRiskGuard {
/** Verify the operator accepted the duplicate risk when one exists. */
public void verify(DeliveryAttemptSnapshot attempt, boolean approved) {
Objects.requireNonNull(attempt, "attempt");
boolean risky =
attempt.confirmation() == AttemptConfirmation.AMBIGUOUS
|| attempt.submissionOutcome()
== dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome
.CONFIRMED_ACCEPTED;
if (risky && !approved) {
throw new DuplicateRiskApprovalRequiredException();
}
}
}
@@ -0,0 +1,325 @@
package dev.caskeleton.adapter.outbound.notification.platform.admin;
import dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntimeRegistry;
import dev.caskeleton.application.notification.platform.admin.AdminActor;
import dev.caskeleton.application.notification.platform.admin.AdminOperationResult;
import dev.caskeleton.application.notification.platform.admin.AdminOperationStorePort;
import dev.caskeleton.application.notification.platform.admin.NotificationAdminAuthority;
import dev.caskeleton.application.notification.platform.admin.NotificationAdminService;
import dev.caskeleton.application.notification.platform.admin.ReconcileCommand;
import dev.caskeleton.application.notification.platform.admin.RedriveCommand;
import dev.caskeleton.application.notification.platform.admin.SetProviderStateCommand;
import dev.caskeleton.application.notification.platform.admin.SuppressCommand;
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
import dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState;
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
import dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptStorePort;
import dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort;
import dev.caskeleton.application.notification.platform.dispatch.ReconciliationService;
import dev.caskeleton.application.notification.platform.observation.NotificationAuditEvent;
import dev.caskeleton.application.notification.platform.observation.NotificationAuditPort;
import dev.caskeleton.application.notification.platform.policy.SuppressionEntry;
import dev.caskeleton.application.notification.platform.policy.SuppressionId;
import dev.caskeleton.application.notification.platform.policy.SuppressionSource;
import dev.caskeleton.application.notification.platform.policy.SuppressionStorePort;
import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState;
import dev.caskeleton.application.transaction.TransactionPort;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
/**
* N4 operator plane.
*
* <p>Four properties hold for every operation: a separate authority, an idempotent operation id, a
* recorded reason, and an audit row. The idempotency matters more than it looks — an operator
* retrying a redrive after a timeout must not send the message twice, which is exactly the failure
* the operation is trying to repair.
*
* <p>A dry run reads and reports but writes nothing, so an operator can see the blast radius of a
* bulk action before committing to it.
*/
public final class NotificationAdminServiceImpl implements NotificationAdminService {
private final AdminAuthorizationGuard authorization;
private final DuplicateRiskGuard duplicateRiskGuard;
private final DeliveryAttemptStorePort attempts;
private final RecipientDeliveryStorePort recipients;
private final ReconciliationService reconciliation;
private final SuppressionStorePort suppressions;
private final ProviderRuntimeRegistry runtimes;
private final AdminOperationStorePort operations;
private final NotificationAuditPort audit;
private final TransactionPort transactions;
private final Clock clock;
public NotificationAdminServiceImpl(
AdminAuthorizationGuard authorization,
DuplicateRiskGuard duplicateRiskGuard,
DeliveryAttemptStorePort attempts,
RecipientDeliveryStorePort recipients,
ReconciliationService reconciliation,
SuppressionStorePort suppressions,
ProviderRuntimeRegistry runtimes,
AdminOperationStorePort operations,
NotificationAuditPort audit,
TransactionPort transactions,
Clock clock) {
this.authorization = Objects.requireNonNull(authorization, "authorization");
this.duplicateRiskGuard = Objects.requireNonNull(duplicateRiskGuard, "duplicateRiskGuard");
this.attempts = Objects.requireNonNull(attempts, "attempts");
this.recipients = Objects.requireNonNull(recipients, "recipients");
this.reconciliation = Objects.requireNonNull(reconciliation, "reconciliation");
this.suppressions = Objects.requireNonNull(suppressions, "suppressions");
this.runtimes = Objects.requireNonNull(runtimes, "runtimes");
this.operations = Objects.requireNonNull(operations, "operations");
this.audit = Objects.requireNonNull(audit, "audit");
this.transactions = Objects.requireNonNull(transactions, "transactions");
this.clock = Objects.requireNonNull(clock, "clock");
}
@Override
public AdminOperationResult redrive(RedriveCommand command, AdminActor actor) {
Objects.requireNonNull(command, "command");
authorization.require(actor, NotificationAdminAuthority.REDRIVE);
Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
if (replayed.isPresent()) {
return replayed.get();
}
DeliveryAttemptSnapshot original =
attempts
.snapshot(command.attemptId())
.orElseThrow(() -> new IllegalStateException("delivery attempt is not available"));
authorization.requireTenant(actor, original.tenantId());
duplicateRiskGuard.verify(original, command.approveDuplicateRisk());
if (command.dryRun()) {
return new AdminOperationResult(
command.operationId(),
true,
1,
Optional.of(original.notificationId()),
Optional.of(original.recipientDeliveryId()),
Optional.empty(),
List.of("DRY_RUN"));
}
return transactions.inWrite(
() -> {
// The logical identities are preserved and only the attempt is new, so the history stays
// one story rather than becoming two unrelated notifications.
recipients.transition(
original.recipientDeliveryId(),
RecipientDeliveryState.READY_TO_DISPATCH,
Optional.of(clock.instant()));
AdminOperationResult result =
new AdminOperationResult(
command.operationId(),
false,
1,
Optional.of(original.notificationId()),
Optional.of(original.recipientDeliveryId()),
Optional.empty(),
List.of(command.reason()));
audit.record(
new NotificationAuditEvent(
"ADMIN_REDRIVE",
actor.actorRef(),
Optional.of(command.reason()),
Optional.of(command.operationId()),
clock.instant(),
Map.of(
"provider", original.providerId().value(),
"channel", original.channel().name())));
return operations.save(result, actor, "ADMIN_REDRIVE");
});
}
@Override
public AdminOperationResult reconcile(ReconcileCommand command, AdminActor actor) {
Objects.requireNonNull(command, "command");
authorization.require(actor, NotificationAdminAuthority.RECONCILE);
Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
if (replayed.isPresent()) {
return replayed.get();
}
if (command.dryRun()) {
return new AdminOperationResult(
command.operationId(),
true,
command.attemptIds().size(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of("DRY_RUN"));
}
List<String> reasons = new ArrayList<>();
int reconciled = 0;
for (DeliveryAttemptId attemptId : command.attemptIds()) {
reconciliation.reconcile(attemptId);
reconciled++;
}
reasons.add(command.reason());
AdminOperationResult result =
new AdminOperationResult(
command.operationId(),
false,
reconciled,
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.copyOf(reasons));
audit.record(
new NotificationAuditEvent(
"ADMIN_RECONCILE",
actor.actorRef(),
Optional.of(command.reason()),
Optional.of(command.operationId()),
clock.instant(),
Map.of()));
return operations.save(result, actor, "ADMIN_RECONCILE");
}
@Override
public AdminOperationResult suppress(SuppressCommand command, AdminActor actor) {
Objects.requireNonNull(command, "command");
authorization.require(actor, NotificationAdminAuthority.SUPPRESS);
authorization.requireTenant(actor, command.tenantId());
Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
if (replayed.isPresent()) {
return replayed.get();
}
if (command.dryRun()) {
return new AdminOperationResult(
command.operationId(),
true,
1,
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of("DRY_RUN"));
}
return transactions.inWrite(
() -> {
int affected;
if (command.remove()) {
// Removal is by fingerprint match rather than by id, because an operator lifting a
// suppression knows the target, not the row identifier the platform assigned.
affected =
suppressions
.activeFor(
command.tenantId(), command.targetFingerprint(), clock.instant())
.stream()
.map(entry -> suppressions.remove(command.tenantId(), entry.id()))
.filter(Optional::isPresent)
.count()
> 0
? 1
: 0;
} else {
suppressions.upsert(
new SuppressionEntry(
new SuppressionId(UUID.randomUUID()),
command.tenantId(),
command.scope(),
command.reason(),
command.targetFingerprint(),
Optional.empty(),
clock.instant(),
command.expiresAt(),
SuppressionSource.ADMIN));
affected = 1;
}
AdminOperationResult result =
new AdminOperationResult(
command.operationId(),
false,
affected,
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of(command.reasonText()));
audit.record(
new NotificationAuditEvent(
command.remove() ? "ADMIN_SUPPRESSION_REMOVED" : "ADMIN_SUPPRESSION_ADDED",
actor.actorRef(),
Optional.of(command.reason().name()),
Optional.of(command.operationId()),
clock.instant(),
Map.of()));
return operations.save(
result, actor, command.remove() ? "ADMIN_SUPPRESS_REMOVE" : "ADMIN_SUPPRESS_ADD");
});
}
@Override
public AdminOperationResult setProviderState(SetProviderStateCommand command, AdminActor actor) {
Objects.requireNonNull(command, "command");
authorization.require(actor, NotificationAdminAuthority.PROVIDER_CONTROL);
Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
if (replayed.isPresent()) {
return replayed.get();
}
if (command.dryRun()) {
return new AdminOperationResult(
command.operationId(),
true,
1,
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of("DRY_RUN"));
}
var runtime = runtimes.current(command.profileId());
switch (command.desiredState()) {
case DISABLED -> runtime.markDisabled();
case DRAINING -> runtime.markDraining();
case HEALTHY -> runtime.markHealthy();
case DEGRADED -> runtime.markDegraded(command.reason());
case THROTTLED -> runtime.markThrottled();
case AUTHENTICATION_FAILED -> runtime.markAuthenticationFailed(command.reason());
}
AdminOperationResult result =
new AdminOperationResult(
command.operationId(),
false,
1,
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of(command.reason()));
audit.record(
new NotificationAuditEvent(
"ADMIN_PROVIDER_STATE",
actor.actorRef(),
Optional.of(command.reason()),
Optional.of(command.operationId()),
clock.instant(),
Map.of(
"providerProfile", command.profileId().value(),
"status", command.desiredState().name())));
return operations.save(result, actor, "ADMIN_PROVIDER_STATE");
}
/** Current state of a provider runtime, for the health endpoint. */
public ProviderRuntimeState providerState(
dev.caskeleton.application.notification.platform.api.ProviderProfileId profileId) {
return runtimes.state(profileId);
}
}
@@ -0,0 +1,105 @@
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector;
import dev.caskeleton.adapter.outbound.notification.platform.template.JacksonNotificationVariablesCodec;
import dev.caskeleton.adapter.outbound.notification.platform.template.JsonSchemaVariableValidator;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationTemplateEngine;
import dev.caskeleton.adapter.outbound.notification.platform.template.PlaceholderTemplateEngine;
import dev.caskeleton.adapter.outbound.notification.platform.template.Sha256MessageDigestAdapter;
import dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafStringTemplateEngine;
import dev.caskeleton.application.notification.platform.dispatch.MessageDigestPort;
import dev.caskeleton.application.notification.platform.dispatch.NotificationVariablesCodecPort;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.template.TemplateVariableValidator;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Notification platform wiring.
*
* <p>Everything is opt-in and conditional. The platform contributes no beans unless it is enabled,
* and the contact point protector only appears once a secret provider exists — because a protector
* without keys would fail on the first delivery instead of at startup.
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(NotificationPlatformSettings.class)
@ConditionalOnProperty(
prefix = "ca-skeleton.notification.platform",
name = "enabled",
havingValue = "true")
public class NotificationPlatformAutoConfiguration {
/** Canonical variables codec. */
@Bean
@ConditionalOnMissingBean
public NotificationVariablesCodecPort notificationVariablesCodec() {
return new JacksonNotificationVariablesCodec();
}
/** Request fingerprint hashing. */
@Bean
@ConditionalOnMissingBean
public MessageDigestPort notificationMessageDigest() {
return new Sha256MessageDigestAdapter();
}
/** JSON Schema 2020-12 variable validation. */
@Bean
@ConditionalOnMissingBean
public TemplateVariableValidator notificationTemplateVariableValidator() {
return new JsonSchemaVariableValidator();
}
/**
* Template engine, defaulting to the deterministic placeholder substitution.
*
* <p>Thymeleaf is the opt-in alternative: it escapes by default, which matters for HTML email
* bodies built from application input. The default stays the placeholder engine because it has no
* expression evaluator at all, and an unknown engine name fails the boot rather than quietly
* falling back — a deployment that thought it had escaping and did not is the worse outcome.
*/
@Bean
@ConditionalOnMissingBean
public NotificationTemplateEngine notificationTemplateEngine(
@Value("${ca-skeleton.notification.platform.template.engine:placeholder}") String engine) {
return switch (engine.toLowerCase(java.util.Locale.ROOT)) {
case "placeholder" -> new PlaceholderTemplateEngine();
case "thymeleaf" -> new ThymeleafStringTemplateEngine();
default ->
throw new IllegalArgumentException(
"ca-skeleton.notification.platform.template.engine must be"
+ " 'placeholder' or 'thymeleaf', not '"
+ engine
+ "'");
};
}
/** Contact point protection, only once key material is available. */
@Bean
@ConditionalOnBean(SecretMaterialProvider.class)
@ConditionalOnMissingBean
public ContactPointProtector notificationContactPointProtector(SecretMaterialProvider secrets) {
return new AesGcmContactPointProtector(secrets);
}
/**
* Default provider transport.
*
* <p>Replaced in the composition root when the HTTP Client Platform is bound, which is the
* supported way to reuse its TLS, circuit-breaker and SSRF policy.
*/
@Bean
@ConditionalOnMissingBean
public NotificationHttpGateway notificationHttpGateway() {
return new JdkNotificationHttpGateway(Duration.ofSeconds(2));
}
}
@@ -0,0 +1,154 @@
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
import java.time.Duration;
import java.util.Map;
import java.util.Objects;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Bound notification platform configuration.
*
* <p>Validation happens in the constructor, so a misconfiguration fails the boot rather than
* surfacing as a delivery incident hours later. Everything is bounded: there is no property whose
* value may be "unlimited", because an unbounded queue or payload is a resource failure waiting for
* the first burst.
*/
@ConfigurationProperties("ca-skeleton.notification.platform")
public record NotificationPlatformSettings(
boolean enabled, Dispatch dispatch, Callbacks callbacks, Map<String, Provider> providers) {
public NotificationPlatformSettings {
dispatch = dispatch == null ? Dispatch.defaults() : dispatch;
callbacks = callbacks == null ? Callbacks.defaults() : callbacks;
providers = providers == null ? Map.of() : Map.copyOf(providers);
providers.forEach((id, provider) -> provider.validate(id));
}
/** Dispatch runtime bounds. */
public record Dispatch(
int claimBatchSize,
Duration leaseDuration,
Duration pollInterval,
int maxGlobalConcurrency,
int maxAdditionalAttempts,
Duration maxQueueAge,
boolean allowAmbiguousFallback) {
private static final int MAX_CLAIM_BATCH = 1000;
public Dispatch {
Objects.requireNonNull(leaseDuration, "leaseDuration");
Objects.requireNonNull(pollInterval, "pollInterval");
Objects.requireNonNull(maxQueueAge, "maxQueueAge");
if (claimBatchSize < 1 || claimBatchSize > MAX_CLAIM_BATCH) {
throw new IllegalArgumentException(
"ca-skeleton.notification.platform.dispatch.claim-batch-size must be 1.."
+ MAX_CLAIM_BATCH);
}
if (maxGlobalConcurrency < 1) {
throw new IllegalArgumentException("max-global-concurrency must be positive");
}
if (maxAdditionalAttempts < 0) {
throw new IllegalArgumentException("max-additional-attempts must not be negative");
}
if (leaseDuration.isNegative() || leaseDuration.isZero()) {
throw new IllegalArgumentException("lease-duration must be positive and finite");
}
if (leaseDuration.compareTo(pollInterval) <= 0) {
throw new IllegalArgumentException("lease-duration must exceed poll-interval");
}
if (allowAmbiguousFallback) {
// Refused outright rather than warned about: automatic fallback after an ambiguous
// submission is the configuration that turns an unknown into a guaranteed duplicate.
throw new IllegalArgumentException(
"allow-ambiguous-fallback is not a supported configuration");
}
}
/** Conservative defaults. */
public static Dispatch defaults() {
return new Dispatch(
100, Duration.ofSeconds(30), Duration.ofMillis(250), 128, 3, Duration.ofHours(24), false);
}
}
/** Callback endpoint bounds. */
public record Callbacks(boolean enabled, long maxBodyBytes, Duration replaySkew) {
private static final long MAX_BODY_CEILING = 1_048_576L;
public Callbacks {
Objects.requireNonNull(replaySkew, "replaySkew");
if (maxBodyBytes < 1 || maxBodyBytes > MAX_BODY_CEILING) {
throw new IllegalArgumentException("max-body-bytes must be 1.." + MAX_BODY_CEILING);
}
if (replaySkew.isNegative()) {
throw new IllegalArgumentException("replay-skew must not be negative");
}
}
/** Conservative defaults. */
public static Callbacks defaults() {
return new Callbacks(false, 65_536L, Duration.ofMinutes(5));
}
}
/** One provider profile. */
public record Provider(
String type,
boolean enabled,
String environment,
String credentialProfile,
String topic,
String vapidPublicKey,
String callbackSigningSecretRef,
Duration timeout,
int maxConcurrency,
int ratePerSecond) {
/** Fail the boot when a profile cannot possibly work. */
public void validate(String profileId) {
Objects.requireNonNull(profileId, "profileId");
if (!enabled) {
return;
}
require(type != null && !type.isBlank(), profileId, "type is required");
require(environment != null && !environment.isBlank(), profileId, "environment is required");
require(
credentialProfile != null && !credentialProfile.isBlank(),
profileId,
"credential-profile is required");
require(
timeout != null && !timeout.isNegative() && !timeout.isZero(),
profileId,
"timeout must be positive and finite");
require(maxConcurrency >= 1, profileId, "max-concurrency must be positive");
require(ratePerSecond >= 1, profileId, "rate-limit-per-second must be positive");
switch (type == null ? "" : type.toUpperCase(java.util.Locale.ROOT)) {
case "APNS" ->
require(topic != null && !topic.isBlank(), profileId, "APNs profiles require a topic");
case "WEB_PUSH" ->
require(
vapidPublicKey != null && !vapidPublicKey.isBlank(),
profileId,
"Web Push profiles require a VAPID key");
case "TWILIO", "SES" ->
require(
callbackSigningSecretRef != null && !callbackSigningSecretRef.isBlank(),
profileId,
"callback-capable profiles require a callback signing secret reference");
default -> {
// Providers without extra requirements are already covered by the common checks.
}
}
}
private static void require(boolean condition, String profileId, String message) {
if (!condition) {
throw new IllegalArgumentException(
"notification provider profile '" + profileId + "': " + message);
}
}
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
/**
* A held concurrency slot for one provider attempt.
*
* <p>Closing it is what releases the slot, so every call site uses try-with-resources. The permit
* also carries the credential generation the attempt ran under, which is what makes a rotation
* auditable after the fact.
*/
public interface AttemptPermit extends AutoCloseable {
/** Credential generation this attempt is bound to. */
long generation();
@Override
void close();
}
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
import dev.caskeleton.application.notification.platform.dispatch.ReconciliationGatewayPort;
import dev.caskeleton.application.notification.platform.provider.ReconciliationCapability;
import dev.caskeleton.application.notification.platform.provider.ReconciliationResult;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Routes a reconciliation to the capability that owns the provider.
*
* <p>A profile with no registered capability reports {@code Unsupported} rather than falling back
* to a guess. Inventing a final status for a provider that cannot be queried is precisely the
* behaviour the ambiguity model exists to prevent.
*/
public final class CapabilityReconciliationGateway implements ReconciliationGatewayPort {
private final Map<ProviderProfileId, ReconciliationCapability> capabilities;
private final ProviderRuntimeRegistry runtimes;
public CapabilityReconciliationGateway(
Map<ProviderProfileId, ReconciliationCapability> capabilities,
ProviderRuntimeRegistry runtimes) {
this.capabilities = Map.copyOf(Objects.requireNonNull(capabilities, "capabilities"));
this.runtimes = Objects.requireNonNull(runtimes, "runtimes");
}
@Override
public boolean supports(ProviderProfileId profileId) {
Objects.requireNonNull(profileId, "profileId");
return Optional.ofNullable(capabilities.get(profileId))
.map(capability -> capability.supports(runtimes.current(profileId).profile()))
.orElse(false);
}
@Override
public ReconciliationResult reconcile(DeliveryAttemptSnapshot attempt) {
Objects.requireNonNull(attempt, "attempt");
ReconciliationCapability capability = capabilities.get(attempt.providerProfileId());
if (capability == null) {
return new ReconciliationResult.Unsupported();
}
// The permit is taken so a reconciliation backlog cannot become a second load source during the
// incident that produced it.
try (AttemptPermit permit = runtimes.current(attempt.providerProfileId()).acquireAttempt()) {
return capability.reconcile(attempt).toCompletableFuture().join();
}
}
}
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.api.RecipientSpec;
import dev.caskeleton.application.notification.platform.api.TenantId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.api.routing.DeliveryStrategy;
import dev.caskeleton.application.notification.platform.api.routing.ExplicitChannel;
import dev.caskeleton.application.notification.platform.api.routing.OrderedFallback;
import dev.caskeleton.application.notification.platform.dispatch.NotificationRoutePlannerPort;
import dev.caskeleton.application.notification.platform.policy.RouteCandidate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Turns a strategy into an ordered route plan using the configured channel-to-profile map.
*
* <p>A channel with no configured provider, or a recipient with no contact point for it, simply
* produces no candidate. The routing engine then reports {@code NO_ELIGIBLE_ROUTE} rather than the
* dispatcher failing on a null, which is the difference between a diagnosable state and a stack
* trace.
*/
public final class ConfiguredRoutePlanner implements NotificationRoutePlannerPort {
private final Map<Channel, ProviderProfileId> profilesByChannel;
public ConfiguredRoutePlanner(Map<Channel, ProviderProfileId> profilesByChannel) {
this.profilesByChannel =
Map.copyOf(Objects.requireNonNull(profilesByChannel, "profilesByChannel"));
}
@Override
public List<RouteCandidate> plan(
TenantId tenantId, RecipientSpec recipient, DeliveryStrategy strategy) {
Objects.requireNonNull(tenantId, "tenantId");
Objects.requireNonNull(recipient, "recipient");
Objects.requireNonNull(strategy, "strategy");
List<Channel> ordered =
switch (strategy) {
case ExplicitChannel explicit -> List.of(explicit.channel());
case OrderedFallback fallback -> fallback.channels();
};
List<RouteCandidate> routes = new ArrayList<>(ordered.size());
int index = 0;
for (Channel channel : ordered) {
ProviderProfileId profileId = profilesByChannel.get(channel);
if (profileId == null) {
continue;
}
var selector =
recipient.contactPoints().stream()
.filter(candidate -> candidate.channel() == channel)
.findFirst();
if (selector.isEmpty()) {
continue;
}
boolean blocked =
recipient
.channelOverride()
.map(override -> override.blockedChannels().contains(channel))
.orElse(false);
routes.add(
new RouteCandidate(
index++, channel, selector.get().contactPointId(), profileId, !blocked, true));
}
return List.copyOf(routes);
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
/** Verifies a candidate generation before it becomes the current one. */
@FunctionalInterface
public interface CredentialProbe {
/** Return false when the candidate credential is not usable. */
boolean isUsable(ProviderRuntime candidate);
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationException;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
/** Raised when a candidate credential generation fails its probe before any cutover. */
public class CredentialValidationException extends NotificationException {
private static final long serialVersionUID = 1L;
public CredentialValidationException() {
super(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID,
FailureCategory.AUTHENTICATION));
}
}
@@ -0,0 +1,61 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.ContactPointId;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.dispatch.NotificationRoutingPlanCodecPort;
import dev.caskeleton.application.notification.platform.policy.RouteCandidate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import tools.jackson.core.type.TypeReference;
/**
* Route plan encoding.
*
* <p>The plan is frozen at submit time, so this is a snapshot format rather than a view: it stores
* exactly what was decided, including which routes were usable then, and never recomputes.
*/
public final class JacksonRoutingPlanCodec implements NotificationRoutingPlanCodecPort {
@Override
public String encode(List<RouteCandidate> routes) {
Objects.requireNonNull(routes, "routes");
List<Map<String, Object>> encoded = new ArrayList<>(routes.size());
for (RouteCandidate route : routes) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("routeIndex", route.routeIndex());
entry.put("channel", route.channel().name());
entry.put("contactPointId", route.contactPointId().value().toString());
entry.put("providerProfileId", route.providerProfileId().value());
entry.put("contactPointActive", route.contactPointActive());
entry.put("providerEnabled", route.providerEnabled());
encoded.add(entry);
}
return NotificationJsonMapper.mapper().writeValueAsString(encoded);
}
@Override
public List<RouteCandidate> decode(String payload) {
Objects.requireNonNull(payload, "payload");
List<LinkedHashMap<String, Object>> raw =
NotificationJsonMapper.mapper()
.readValue(payload, new TypeReference<ArrayList<LinkedHashMap<String, Object>>>() {});
List<RouteCandidate> routes = new ArrayList<>(raw.size());
for (Map<String, Object> entry : raw) {
routes.add(
new RouteCandidate(
((Number) entry.get("routeIndex")).intValue(),
Channel.valueOf(String.valueOf(entry.get("channel"))),
new ContactPointId(UUID.fromString(String.valueOf(entry.get("contactPointId")))),
new ProviderProfileId(String.valueOf(entry.get("providerProfileId"))),
Boolean.TRUE.equals(entry.get("contactPointActive")),
Boolean.TRUE.equals(entry.get("providerEnabled"))));
}
return List.copyOf(routes);
}
}
@@ -0,0 +1,61 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
import dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptStorePort;
import dev.caskeleton.application.notification.platform.dispatch.RecipientLeaseStorePort;
import dev.caskeleton.application.notification.platform.dispatch.ReconciliationService;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
/**
* Recovers deliveries a dead worker left in flight.
*
* <p>An expired lease on a {@code DISPATCHING} delivery is the crash case: the attempt row exists,
* so a provider call may have happened. Recovery therefore reconciles rather than re-dispatching —
* re-dispatching would be the platform choosing to duplicate rather than to ask.
*/
public final class LeaseRecoveryService {
private final RecipientLeaseStorePort leases;
private final DeliveryAttemptStorePort attempts;
private final ReconciliationService reconciliation;
private final Duration staleAfter;
private final int batchSize;
public LeaseRecoveryService(
RecipientLeaseStorePort leases,
DeliveryAttemptStorePort attempts,
ReconciliationService reconciliation,
Duration staleAfter,
int batchSize) {
this.leases = Objects.requireNonNull(leases, "leases");
this.attempts = Objects.requireNonNull(attempts, "attempts");
this.reconciliation = Objects.requireNonNull(reconciliation, "reconciliation");
this.staleAfter = Objects.requireNonNull(staleAfter, "staleAfter");
this.batchSize = batchSize;
if (batchSize < 1) {
throw new IllegalArgumentException("batchSize");
}
if (staleAfter.isNegative() || staleAfter.isZero()) {
throw new IllegalArgumentException("staleAfter must be positive and finite");
}
}
/** Recover one batch of abandoned deliveries; returns how many were handled. */
public int recoverOnce() {
List<dev.caskeleton.application.notification.platform.api.RecipientDeliveryId> abandoned =
leases.expiredDispatching(batchSize, staleAfter);
int handled = 0;
for (var recipientDeliveryId : abandoned) {
for (var attempt : attempts.attemptsOf(recipientDeliveryId)) {
if (attempt.completedAt().isEmpty()) {
DeliveryAttemptId attemptId = attempt.id();
reconciliation.reconcile(attemptId);
handled++;
}
}
}
return handled;
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.inbox.InboxItemCreated;
import dev.caskeleton.application.notification.platform.inbox.NotificationInboxSignalPort;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Default inbox signal sink.
*
* <p>Emits identifiers only, never content. A deployment with a WebSocket or messaging relay
* replaces it; until then the inbox is still complete, because the row — not the signal — is the
* source of truth.
*/
public final class LoggingInboxSignalPublisher implements NotificationInboxSignalPort {
private static final Logger log = LoggerFactory.getLogger("notification.inbox.signal");
@Override
public void publish(InboxItemCreated event) {
Objects.requireNonNull(event, "event");
log.info(
"event=inbox_item_created itemId={} tenant={} category={}",
event.itemId().value(),
event.principal().tenantId().value(),
event.category());
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.dispatch.TemplateRendererRegistry;
import dev.caskeleton.application.notification.platform.template.NotificationTemplateRenderer;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** Channel-to-renderer lookup built at wiring time. */
public final class MapTemplateRendererRegistry implements TemplateRendererRegistry {
private final Map<Channel, NotificationTemplateRenderer> renderers;
public MapTemplateRendererRegistry(List<NotificationTemplateRenderer> renderers) {
Objects.requireNonNull(renderers, "renderers");
Map<Channel, NotificationTemplateRenderer> byChannel = new EnumMap<>(Channel.class);
renderers.forEach(renderer -> byChannel.put(renderer.channel(), renderer));
this.renderers = Map.copyOf(byChannel);
}
@Override
public NotificationTemplateRenderer rendererFor(Channel channel) {
NotificationTemplateRenderer renderer = renderers.get(channel);
if (renderer == null) {
throw new IllegalStateException("no renderer registered for the channel");
}
return renderer;
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import java.time.Duration;
import java.util.Objects;
/**
* Dispatch runtime bounds.
*
* <p>Every field is bounded and validated at construction. "Unlimited" is never an accepted value:
* an unbounded claim batch or queue is how a burst becomes an out-of-memory failure instead of
* backpressure.
*/
public record NotificationDispatchProperties(
int claimBatchSize,
Duration leaseDuration,
Duration pollInterval,
int maxGlobalConcurrency,
int maxAdditionalAttempts,
Duration estimatedDispatchDuration,
Duration maxQueueAge,
Duration shutdownGrace) {
private static final int MAX_CLAIM_BATCH = 1000;
public NotificationDispatchProperties {
Objects.requireNonNull(leaseDuration, "leaseDuration");
Objects.requireNonNull(pollInterval, "pollInterval");
Objects.requireNonNull(estimatedDispatchDuration, "estimatedDispatchDuration");
Objects.requireNonNull(maxQueueAge, "maxQueueAge");
Objects.requireNonNull(shutdownGrace, "shutdownGrace");
if (claimBatchSize < 1 || claimBatchSize > MAX_CLAIM_BATCH) {
throw new IllegalArgumentException("claimBatchSize must be 1.." + MAX_CLAIM_BATCH);
}
if (maxGlobalConcurrency < 1) {
throw new IllegalArgumentException("maxGlobalConcurrency");
}
if (maxAdditionalAttempts < 0) {
throw new IllegalArgumentException("maxAdditionalAttempts");
}
requirePositive(leaseDuration, "leaseDuration");
requirePositive(pollInterval, "pollInterval");
requirePositive(maxQueueAge, "maxQueueAge");
if (leaseDuration.compareTo(pollInterval) <= 0) {
// A lease shorter than the poll interval expires before the worker can renew it, so two
// workers would routinely claim the same job.
throw new IllegalArgumentException("leaseDuration must exceed pollInterval");
}
}
private static void requirePositive(Duration value, String name) {
if (value.isNegative() || value.isZero()) {
throw new IllegalArgumentException(name + " must be positive and finite");
}
}
}
@@ -0,0 +1,137 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.dispatch.NotificationDispatchService;
import dev.caskeleton.application.notification.platform.dispatch.RecipientLease;
import dev.caskeleton.application.notification.platform.dispatch.RecipientLeaseStorePort;
import dev.caskeleton.application.notification.platform.observation.NotificationMetricName;
import dev.caskeleton.application.notification.platform.observation.NotificationMetricsPort;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Claims due deliveries and hands them to the dispatcher.
*
* <p>The worker never calls a provider itself. It claims, submits to a bounded executor, and stops
* claiming the moment shutdown begins — so a rolling restart drains rather than abandoning leases
* that then have to time out.
*
* <p>Claiming is bounded twice over: by the claim batch size and by a global concurrency permit.
* The second bound matters because a slow provider would otherwise let the queue depth become the
* thread count.
*/
public final class NotificationSchedulerWorker implements AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(NotificationSchedulerWorker.class);
private final RecipientLeaseStorePort leases;
private final NotificationDispatchService dispatcher;
private final NotificationMetricsPort metrics;
private final NotificationDispatchProperties properties;
private final String workerId;
private final ExecutorService dispatchExecutor;
private final Semaphore globalConcurrency;
private final AtomicBoolean running = new AtomicBoolean();
private final AtomicBoolean shuttingDown = new AtomicBoolean();
public NotificationSchedulerWorker(
RecipientLeaseStorePort leases,
NotificationDispatchService dispatcher,
NotificationMetricsPort metrics,
NotificationDispatchProperties properties,
String workerId) {
this.leases = Objects.requireNonNull(leases, "leases");
this.dispatcher = Objects.requireNonNull(dispatcher, "dispatcher");
this.metrics = Objects.requireNonNull(metrics, "metrics");
this.properties = Objects.requireNonNull(properties, "properties");
this.workerId = Objects.requireNonNull(workerId, "workerId");
this.dispatchExecutor = Executors.newVirtualThreadPerTaskExecutor();
this.globalConcurrency = new Semaphore(properties.maxGlobalConcurrency());
}
/** Claim and dispatch one batch. Returns how many deliveries were claimed. */
public int runOnce() {
if (shuttingDown.get()) {
return 0;
}
List<RecipientLease> claimed =
leases.claim(workerId, properties.claimBatchSize(), properties.leaseDuration());
metrics.gauge(NotificationMetricName.QUEUE_DEPTH, Map.of(), claimed.size());
for (RecipientLease lease : claimed) {
globalConcurrency.acquireUninterruptibly();
dispatchExecutor.execute(
() -> {
try {
dispatcher.dispatch(lease);
} catch (RuntimeException failure) {
// The lease is left to expire rather than being released optimistically: a worker
// that
// failed mid-dispatch cannot prove what the provider did.
log.warn(
"notification dispatch failed worker={} reason={}",
workerId,
failure.getClass().getSimpleName());
} finally {
globalConcurrency.release();
}
});
}
return claimed.size();
}
/** Start the polling loop on a dedicated thread. */
public void start() {
if (!running.compareAndSet(false, true)) {
return;
}
Thread.ofVirtual()
.name("notification-scheduler-" + workerId)
.start(
() -> {
while (running.get() && !shuttingDown.get()) {
try {
if (runOnce() == 0) {
Thread.sleep(properties.pollInterval().toMillis());
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return;
} catch (RuntimeException failure) {
log.warn(
"notification scheduler tick failed worker={} reason={}",
workerId,
failure.getClass().getSimpleName());
}
}
});
}
@Override
public void close() {
shuttingDown.set(true);
running.set(false);
dispatchExecutor.shutdown();
try {
if (!dispatchExecutor.awaitTermination(
properties.shutdownGrace().toMillis(), TimeUnit.MILLISECONDS)) {
dispatchExecutor.shutdownNow();
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
dispatchExecutor.shutdownNow();
}
}
/** Whether the worker has stopped claiming new work. */
public boolean shuttingDown() {
return shuttingDown.get();
}
}
@@ -0,0 +1,73 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.api.error.ProviderUnavailableException;
import java.time.Clock;
import java.util.Objects;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicLong;
/**
* Per-provider rate and concurrency guard.
*
* <p>Tokens are spent on real attempts only. A delivery waiting out its backoff holds no permit,
* because a provider outage would otherwise pin the whole concurrency budget on deliveries that are
* not doing anything.
*/
public final class ProviderAttemptLimiter {
private final Semaphore concurrency;
private final int maxConcurrency;
private final int ratePerSecond;
private final Clock clock;
private final AtomicLong windowStartSecond = new AtomicLong();
private final AtomicLong issuedInWindow = new AtomicLong();
public ProviderAttemptLimiter(int maxConcurrency, int ratePerSecond, Clock clock) {
if (maxConcurrency < 1) {
throw new IllegalArgumentException("maxConcurrency");
}
if (ratePerSecond < 1) {
throw new IllegalArgumentException("ratePerSecond");
}
this.concurrency = new Semaphore(maxConcurrency);
this.maxConcurrency = maxConcurrency;
this.ratePerSecond = ratePerSecond;
this.clock = Objects.requireNonNull(clock, "clock");
this.windowStartSecond.set(clock.instant().getEpochSecond());
}
/** Acquire one attempt slot, or fail fast when the provider budget is spent. */
public void acquire() {
long second = clock.instant().getEpochSecond();
long windowStart = windowStartSecond.get();
if (second != windowStart && windowStartSecond.compareAndSet(windowStart, second)) {
issuedInWindow.set(0L);
}
if (issuedInWindow.incrementAndGet() > ratePerSecond) {
throw unavailable();
}
if (!concurrency.tryAcquire()) {
issuedInWindow.decrementAndGet();
throw unavailable();
}
}
/** Release a previously acquired slot. */
public void release() {
concurrency.release();
}
/** Slots currently held. */
public int activeAttempts() {
return maxConcurrency - concurrency.availablePermits();
}
private static ProviderUnavailableException unavailable() {
return new ProviderUnavailableException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.PROVIDER_UNAVAILABLE, FailureCategory.CAPACITY_REJECTED));
}
}
@@ -0,0 +1,136 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.api.error.ProviderUnavailableException;
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot;
import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
/**
* One immutable credential generation of a provider.
*
* <p>Generations are replaced, never mutated. Rotating a key by editing a live client would leave
* in-flight calls half-way between two credentials; replacing the whole runtime and letting the old
* one drain keeps every attempt attributable to exactly one generation.
*
* <p>An authentication failure moves the whole runtime, not the message. One expired credential
* multiplied by a queue of notifications is a self-inflicted outage, so the route opens once and
* raises an operational alert instead.
*/
public final class ProviderRuntime {
private final ProviderProfileSnapshot profile;
private final NotificationProviderAdapter adapter;
private final ProviderAttemptLimiter limiter;
private final AtomicReference<ProviderRuntimeState> state;
private final AtomicReference<String> unhealthyReason = new AtomicReference<>();
public ProviderRuntime(
ProviderProfileSnapshot profile,
NotificationProviderAdapter adapter,
ProviderAttemptLimiter limiter) {
this.profile = Objects.requireNonNull(profile, "profile");
this.adapter = Objects.requireNonNull(adapter, "adapter");
this.limiter = Objects.requireNonNull(limiter, "limiter");
this.state = new AtomicReference<>(ProviderRuntimeState.HEALTHY);
}
/** Profile snapshot including the credential generation. */
public ProviderProfileSnapshot profile() {
return profile;
}
/** Credential generation of this runtime. */
public long generation() {
return profile.credentialGeneration();
}
/** Provider adapter bound to this generation. */
public NotificationProviderAdapter adapter() {
return adapter;
}
/** Current health. */
public ProviderRuntimeState state() {
return state.get();
}
/** Why the runtime is unhealthy, if it is. */
public Optional<String> unhealthyReason() {
return Optional.ofNullable(unhealthyReason.get());
}
/** Attempts currently in flight on this generation. */
public int activeAttempts() {
return limiter.activeAttempts();
}
/**
* Acquire a permit for one attempt.
*
* <p>The health check happens before the limiter, so a disabled or failed provider never consumes
* a token it cannot use.
*/
public AttemptPermit acquireAttempt() {
ProviderRuntimeState current = state.get();
if (!current.admitsNewAttempts()) {
throw new ProviderUnavailableException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.PROVIDER_UNAVAILABLE,
current == ProviderRuntimeState.AUTHENTICATION_FAILED
? FailureCategory.AUTHENTICATION
: FailureCategory.CAPACITY_REJECTED));
}
limiter.acquire();
return new LimiterPermit(profile.credentialGeneration(), limiter);
}
/** Mark the credential as rejected by the provider. */
public void markAuthenticationFailed(String reasonCode) {
unhealthyReason.set(Objects.requireNonNull(reasonCode, "reasonCode"));
state.set(ProviderRuntimeState.AUTHENTICATION_FAILED);
}
/** Mark the provider as rate limited. */
public void markThrottled() {
state.compareAndSet(ProviderRuntimeState.HEALTHY, ProviderRuntimeState.THROTTLED);
}
/** Mark the provider as degraded but still usable. */
public void markDegraded(String reasonCode) {
unhealthyReason.set(reasonCode);
state.compareAndSet(ProviderRuntimeState.HEALTHY, ProviderRuntimeState.DEGRADED);
}
/** Return to healthy after a successful attempt. */
public void markHealthy() {
unhealthyReason.set(null);
state.compareAndSet(ProviderRuntimeState.THROTTLED, ProviderRuntimeState.HEALTHY);
state.compareAndSet(ProviderRuntimeState.DEGRADED, ProviderRuntimeState.HEALTHY);
}
/** Stop admitting new attempts; in-flight attempts finish. */
public void markDraining() {
state.set(ProviderRuntimeState.DRAINING);
}
/** Operator disable. */
public void markDisabled() {
state.set(ProviderRuntimeState.DISABLED);
}
/** A permit that releases exactly one limiter slot. */
private record LimiterPermit(long generation, ProviderAttemptLimiter limiter)
implements AttemptPermit {
@Override
public void close() {
limiter.release();
}
}
}
@@ -0,0 +1,78 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
/** Holds the current generation of every provider profile plus the generations still draining. */
public final class ProviderRuntimeRegistry {
private final Map<ProviderProfileId, ProviderRuntime> current = new ConcurrentHashMap<>();
private final Map<ProviderProfileId, CopyOnWriteArrayList<ProviderRuntime>> draining =
new ConcurrentHashMap<>();
/** Register the first generation of a profile. */
public void register(ProviderRuntime runtime) {
Objects.requireNonNull(runtime, "runtime");
current.put(runtime.profile().profileId(), runtime);
}
/** Current generation, or a configuration failure when the profile is unknown. */
public ProviderRuntime current(ProviderProfileId profileId) {
ProviderRuntime runtime = current.get(profileId);
if (runtime == null) {
throw new IllegalStateException("no provider runtime registered for the profile");
}
return runtime;
}
/** Current generation if registered. */
public Optional<ProviderRuntime> find(ProviderProfileId profileId) {
return Optional.ofNullable(current.get(profileId));
}
/**
* Swap in a new generation and start draining the old one.
*
* <p>New dispatches immediately use the new generation while the previous one finishes what it
* already started, which is what makes a credential rotation invisible to callers.
*/
public Optional<ProviderRuntime> replace(ProviderRuntime replacement) {
Objects.requireNonNull(replacement, "replacement");
ProviderProfileId profileId = replacement.profile().profileId();
ProviderRuntime previous = current.put(profileId, replacement);
if (previous != null) {
previous.markDraining();
draining.computeIfAbsent(profileId, key -> new CopyOnWriteArrayList<>()).add(previous);
forgetIfDrained(profileId);
}
return Optional.ofNullable(previous);
}
/** Generations that are draining and still have work in flight. */
public List<ProviderRuntime> drainingGenerations(ProviderProfileId profileId) {
forgetIfDrained(profileId);
return List.copyOf(draining.getOrDefault(profileId, new CopyOnWriteArrayList<>()));
}
/** Health of the current generation. */
public ProviderRuntimeState state(ProviderProfileId profileId) {
return current(profileId).state();
}
private void forgetIfDrained(ProviderProfileId profileId) {
CopyOnWriteArrayList<ProviderRuntime> generations = draining.get(profileId);
if (generations == null) {
return;
}
generations.removeIf(runtime -> runtime.activeAttempts() == 0);
if (generations.isEmpty()) {
draining.remove(profileId);
}
}
}
@@ -0,0 +1,69 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.observation.NotificationAuditEvent;
import dev.caskeleton.application.notification.platform.observation.NotificationAuditPort;
import java.time.Clock;
import java.time.Duration;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Credential and certificate rotation.
*
* <p>The candidate is probed <em>before</em> the swap. Validating after cutover would mean a typo
* in a rotated secret takes the provider down and only then tells anyone; validating first makes a
* bad candidate a no-op that leaves the working generation in place.
*
* <p>Only the generation and key id reach the audit trail — never the credential material itself.
*/
public final class ProviderRuntimeRotator {
private final ProviderRuntimeRegistry registry;
private final CredentialProbe probe;
private final RuntimeDrainCoordinator drainCoordinator;
private final NotificationAuditPort audit;
private final Clock clock;
private final Duration drainTimeout;
public ProviderRuntimeRotator(
ProviderRuntimeRegistry registry,
CredentialProbe probe,
RuntimeDrainCoordinator drainCoordinator,
NotificationAuditPort audit,
Clock clock,
Duration drainTimeout) {
this.registry = Objects.requireNonNull(registry, "registry");
this.probe = Objects.requireNonNull(probe, "probe");
this.drainCoordinator = Objects.requireNonNull(drainCoordinator, "drainCoordinator");
this.audit = Objects.requireNonNull(audit, "audit");
this.clock = Objects.requireNonNull(clock, "clock");
this.drainTimeout = Objects.requireNonNull(drainTimeout, "drainTimeout");
}
/** Cut over to a new credential generation. */
public void rotate(ProviderProfileId profileId, ProviderRuntime candidate) {
Objects.requireNonNull(profileId, "profileId");
Objects.requireNonNull(candidate, "candidate");
if (!candidate.profile().profileId().equals(profileId)) {
throw new IllegalArgumentException("candidate belongs to a different profile");
}
if (!probe.isUsable(candidate)) {
throw new CredentialValidationException();
}
Optional<ProviderRuntime> previous = registry.replace(candidate);
audit.record(
new NotificationAuditEvent(
"PROVIDER_CREDENTIAL_ROTATION",
"system",
Optional.of("ROTATION"),
Optional.empty(),
clock.instant(),
Map.of(
"providerProfile", profileId.value(),
"generation", Long.toString(candidate.generation()))));
previous.ifPresent(runtime -> drainCoordinator.drain(runtime, drainTimeout));
}
}
@@ -0,0 +1,76 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.dispatch.ProviderDispatchGatewayPort;
import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot;
import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import java.util.Objects;
import java.util.concurrent.CompletionException;
/**
* The single outbound call, wrapped in a permit.
*
* <p>The permit is acquired before the call and released in a finally, so a provider that hangs
* consumes exactly one slot and a burst queues rather than exhausting the pool.
*
* <p>A credential rejection is promoted to a runtime state change here rather than being left as a
* per-message failure — one expired key must open the route once, not produce one retry per queued
* notification.
*/
public final class RegistryProviderDispatchGateway implements ProviderDispatchGatewayPort {
private final ProviderRuntimeRegistry runtimes;
public RegistryProviderDispatchGateway(ProviderRuntimeRegistry runtimes) {
this.runtimes = Objects.requireNonNull(runtimes, "runtimes");
}
@Override
public ProviderProfileSnapshot profile(ProviderProfileId profileId) {
return runtimes.current(profileId).profile();
}
@Override
public ProviderRuntimeState state(ProviderProfileId profileId) {
return runtimes.state(profileId);
}
@Override
public ProviderSubmissionResult submit(ProviderSubmission submission) {
Objects.requireNonNull(submission, "submission");
ProviderRuntime runtime = runtimes.current(submission.profile().profileId());
try (AttemptPermit permit = runtime.acquireAttempt()) {
ProviderSubmissionResult result =
runtime.adapter().submit(submission).toCompletableFuture().join();
applyHealth(runtime, result);
return result;
} catch (CompletionException failure) {
// Unwrapped so the dispatcher classifies the real cause rather than the future's wrapper.
Throwable cause = failure.getCause() == null ? failure : failure.getCause();
throw cause instanceof RuntimeException runtimeFailure
? runtimeFailure
: new IllegalStateException("provider submission failed", cause);
}
}
private static void applyHealth(ProviderRuntime runtime, ProviderSubmissionResult result) {
result
.failure()
.ifPresentOrElse(
failure -> {
switch (failure.category()) {
case AUTHENTICATION, AUTHORIZATION ->
runtime.markAuthenticationFailed(failure.code());
case THROTTLED -> runtime.markThrottled();
case TRANSIENT_PROVIDER -> runtime.markDegraded(failure.code());
default -> {
// A message-level failure says nothing about the provider's health.
}
}
},
runtime::markHealthy);
}
}
@@ -0,0 +1,46 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import java.time.Duration;
import java.util.Objects;
/**
* Waits for a replaced generation to finish its in-flight attempts.
*
* <p>The deadline comes from {@link System#nanoTime()}, not from the injectable clock. A drain
* timeout is a real elapsed-time budget: driving it from a test clock that never advances turns the
* loop into a hang, and driving it from a wall clock makes it sensitive to time adjustments.
*
* <p>Draining is bounded on purpose. A provider that never answers must not hold a credential
* rotation open forever, so after the timeout the generation is abandoned and its attempts follow
* the normal ambiguity and reconciliation path rather than being cancelled mid-flight.
*/
public final class RuntimeDrainCoordinator {
private final Duration pollInterval;
public RuntimeDrainCoordinator(Duration pollInterval) {
this.pollInterval = Objects.requireNonNull(pollInterval, "pollInterval");
if (pollInterval.isNegative() || pollInterval.isZero()) {
throw new IllegalArgumentException("pollInterval");
}
}
/** Drain a generation, returning whether it finished within the timeout. */
public boolean drain(ProviderRuntime runtime, Duration timeout) {
Objects.requireNonNull(runtime, "runtime");
Objects.requireNonNull(timeout, "timeout");
long deadlineNanos = System.nanoTime() + timeout.toNanos();
while (runtime.activeAttempts() > 0) {
if (System.nanoTime() - deadlineNanos >= 0) {
return false;
}
try {
Thread.sleep(pollInterval.toMillis());
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return false;
}
}
return true;
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.api.TenantId;
import dev.caskeleton.application.notification.platform.dispatch.TenantContextPort;
import java.util.Objects;
/**
* Tenant context for a single-tenant deployment.
*
* <p>A multi-tenant deployment replaces this with a request-scoped implementation. It exists so
* that a single-tenant application still goes through the tenant boundary rather than around it —
* the store queries take a tenant either way, and a deployment that later becomes multi-tenant does
* not have to find every unscoped query.
*/
public final class SingleTenantContext implements TenantContextPort {
private final TenantId tenantId;
public SingleTenantContext(String tenantId) {
this.tenantId = new TenantId(Objects.requireNonNull(tenantId, "tenantId"));
}
@Override
public TenantId currentTenant() {
return tenantId;
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
import dev.caskeleton.application.notification.platform.dispatch.NotificationIdGeneratorPort;
import java.security.SecureRandom;
import java.time.Clock;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
/**
* RFC 9562 UUIDv7.
*
* <p>Time-ordered rather than random because these identifiers are primary keys: a random UUID
* scatters inserts across the whole index, and a notification table takes the highest insert rate
* in the platform.
*
* <p>The monotonic counter guards the case two identifiers are requested inside the same
* millisecond, so ordering holds even under a burst.
*/
public final class UuidV7Generator implements NotificationIdGeneratorPort {
private static final long VERSION_7 = 0x7000L;
private static final long VARIANT_RFC = 0x8000000000000000L;
private final Clock clock;
private final SecureRandom random;
private final AtomicLong lastMillis = new AtomicLong();
private final AtomicLong sequence = new AtomicLong();
public UuidV7Generator(Clock clock) {
this(clock, new SecureRandom());
}
UuidV7Generator(Clock clock, SecureRandom random) {
this.clock = Objects.requireNonNull(clock, "clock");
this.random = Objects.requireNonNull(random, "random");
}
@Override
public UUID nextId() {
long millis = clock.millis();
long previous = lastMillis.getAndSet(millis);
long counter = millis == previous ? sequence.incrementAndGet() : sequence.updateAndGet(x -> 0L);
long high = (millis & 0xFFFFFFFFFFFFL) << 16;
high |= VERSION_7;
high |= counter & 0x0FFFL;
long low = random.nextLong();
low &= 0x3FFFFFFFFFFFFFFFL;
low |= VARIANT_RFC;
return new UUID(high, low);
}
}
@@ -0,0 +1,60 @@
package dev.caskeleton.adapter.outbound.notification.platform.observation;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.observation.NotificationAuditEvent;
import dev.caskeleton.application.notification.platform.observation.NotificationAuditPort;
import dev.caskeleton.application.notification.platform.observation.NotificationSecurityAuditPort;
import java.util.Objects;
import java.util.TreeMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Audit sink on a dedicated logger.
*
* <p>Separate from the metrics logger because audit has different retention: a metric may be
* sampled away, while "who lifted this suppression, and why" has to survive.
*
* <p>A rejected callback signature is a security event, not a provider event, so it is recorded
* here and never in the ledger — otherwise anyone who can reach the endpoint could fill a delivery
* history with noise.
*/
public final class LoggingNotificationAudit
implements NotificationAuditPort, NotificationSecurityAuditPort {
private static final Logger audit = LoggerFactory.getLogger("notification.audit");
private static final Logger security = LoggerFactory.getLogger("notification.security");
@Override
public void record(NotificationAuditEvent event) {
Objects.requireNonNull(event, "event");
audit.info(
"action={} actor={} reason={} operationId={} occurredAt={} attributes={}",
event.action(),
event.actorRef(),
event.reasonCode().orElse("-"),
event.operationId().orElse("-"),
event.occurredAt(),
new TreeMap<>(event.boundedAttributes()));
}
@Override
public void callbackSignatureRejected(ProviderProfileId profileId, String reasonCode) {
Objects.requireNonNull(profileId, "profileId");
// The payload is deliberately absent: a forged callback must not get its content into the log
// just by being rejected.
security.warn(
"event=callback_signature_rejected providerProfile={} reason={}",
profileId.value(),
reasonCode);
}
@Override
public void callbackRejectedByLimit(ProviderProfileId profileId, String reasonCode) {
Objects.requireNonNull(profileId, "profileId");
security.warn(
"event=callback_rejected_by_limit providerProfile={} reason={}",
profileId.value(),
reasonCode);
}
}
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.outbound.notification.platform.observation;
import dev.caskeleton.application.notification.platform.observation.CardinalityGuard;
import dev.caskeleton.application.notification.platform.observation.NotificationMetricsPort;
import java.time.Duration;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Structured-log metrics sink.
*
* <p>Every tag map passes the cardinality guard before it is emitted, so a stray notification id
* fails here rather than after it has already multiplied a time series into millions of them.
*
* <p>A Micrometer-backed implementation belongs in the composition root, which owns the registry;
* this one keeps the platform usable — and its tag discipline enforced — without one.
*/
public final class LoggingNotificationMetrics implements NotificationMetricsPort {
private static final Logger log = LoggerFactory.getLogger("notification.metrics");
private final CardinalityGuard guard;
public LoggingNotificationMetrics(CardinalityGuard guard) {
this.guard = Objects.requireNonNull(guard, "guard");
}
@Override
public void increment(String metricName, Map<String, String> tags) {
guard.validate(tags);
log.info("metric={} kind=counter tags={}", metricName, ordered(tags));
}
@Override
public void record(String metricName, Map<String, String> tags, Duration value) {
guard.validate(tags);
log.info("metric={} kind=timer millis={} tags={}", metricName, value.toMillis(), ordered(tags));
}
@Override
public void gauge(String metricName, Map<String, String> tags, double value) {
guard.validate(tags);
log.info("metric={} kind=gauge value={} tags={}", metricName, value, ordered(tags));
}
private static Map<String, String> ordered(Map<String, String> tags) {
return new TreeMap<>(tags);
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.notification.platform.observation;
import dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntimeRegistry;
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Builds the operational snapshot.
*
* <p>A provider whose credentials were rejected reports unhealthy even though the process is fine:
* that is exactly the condition an operator needs paged on, and it is invisible from process-level
* health.
*/
public final class NotificationHealthReporter {
private final ProviderRuntimeRegistry runtimes;
private final List<ProviderProfileId> monitoredProfiles;
public NotificationHealthReporter(
ProviderRuntimeRegistry runtimes, List<ProviderProfileId> monitoredProfiles) {
this.runtimes = Objects.requireNonNull(runtimes, "runtimes");
this.monitoredProfiles = List.copyOf(Objects.requireNonNull(monitoredProfiles, "profiles"));
}
/** Current snapshot. */
public NotificationHealthSnapshot snapshot() {
List<NotificationHealthSnapshot.ProviderHealth> providers = new ArrayList<>();
boolean healthy = true;
for (ProviderProfileId profileId : monitoredProfiles) {
var runtime = runtimes.find(profileId);
if (runtime.isEmpty()) {
healthy = false;
providers.add(
new NotificationHealthSnapshot.ProviderHealth(profileId.value(), "UNREGISTERED", 0, 0));
continue;
}
ProviderRuntimeState state = runtime.get().state();
if (state == ProviderRuntimeState.AUTHENTICATION_FAILED
|| state == ProviderRuntimeState.DISABLED) {
healthy = false;
}
providers.add(
new NotificationHealthSnapshot.ProviderHealth(
profileId.value(),
state.name(),
runtime.get().generation(),
runtime.get().activeAttempts()));
}
return new NotificationHealthSnapshot(healthy, providers, Map.of());
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.outbound.notification.platform.observation;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Operational view of the platform.
*
* <p>Provider states, credential generations and queue age — nothing else. A health endpoint is one
* of the least protected surfaces an application exposes, so a sender address or a credential
* reference appearing here would be a leak with a wide audience.
*/
public record NotificationHealthSnapshot(
boolean healthy, List<ProviderHealth> providers, Map<String, Long> queue) {
public NotificationHealthSnapshot {
providers = List.copyOf(Objects.requireNonNull(providers, "providers"));
queue = Map.copyOf(Objects.requireNonNull(queue, "queue"));
}
/** One provider runtime's state. */
public record ProviderHealth(
String profileId, String state, long credentialGeneration, int activeAttempts) {
public ProviderHealth {
Objects.requireNonNull(profileId, "profileId");
Objects.requireNonNull(state, "state");
}
}
}
@@ -0,0 +1,100 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.provider.ProviderExecutionEvidence;
import dev.caskeleton.application.notification.platform.provider.ProviderFailure;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import java.time.Duration;
import java.util.Optional;
/**
* Shared translation from a transport failure into an evidence-carrying result.
*
* <p>Every HTTP provider adapter routes its transport failures through here, so the rule that
* "committed body plus no response equals ambiguous" is written once rather than re-derived per
* provider.
*/
public final class ProviderResults {
private ProviderResults() {}
/** Classify a transport failure. */
public static ProviderSubmissionResult fromTransport(
NotificationHttpTransportException failure, Duration elapsed) {
if (failure.requestBodyCommitted()) {
return ProviderSubmissionResult.ambiguous(
new ProviderFailure(
NotificationFailureCode.PROVIDER_RESPONSE_LOST,
FailureCategory.AMBIGUOUS_SUBMISSION,
false,
Optional.empty(),
Optional.of(failure.reasonCode())),
ProviderExecutionEvidence.responseLost(),
elapsed);
}
return ProviderSubmissionResult.notSubmitted(
new ProviderFailure(
NotificationFailureCode.PROVIDER_TRANSIENT_FAILURE,
FailureCategory.TRANSIENT_PROVIDER,
true,
Optional.empty(),
Optional.of(failure.reasonCode())),
elapsed);
}
/** Classify an HTTP status that is not provider-specific. */
public static ProviderFailure fromStatus(int statusCode, Optional<Duration> retryAfter) {
if (statusCode == 429) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_THROTTLED,
FailureCategory.THROTTLED,
true,
retryAfter,
Optional.of(Integer.toString(statusCode)));
}
if (statusCode == 401) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_AUTHENTICATION_FAILED,
FailureCategory.AUTHENTICATION,
false,
Optional.empty(),
Optional.of("401"));
}
if (statusCode == 403) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_AUTHORIZATION_FAILED,
FailureCategory.AUTHORIZATION,
false,
Optional.empty(),
Optional.of("403"));
}
if (statusCode >= 500) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_TRANSIENT_FAILURE,
FailureCategory.TRANSIENT_PROVIDER,
true,
retryAfter,
Optional.of(Integer.toString(statusCode)));
}
return new ProviderFailure(
NotificationFailureCode.PROVIDER_PERMANENT_FAILURE,
FailureCategory.PERMANENT_PROVIDER,
false,
Optional.empty(),
Optional.of(Integer.toString(statusCode)));
}
/** Parse a {@code Retry-After} header expressed in seconds. */
public static Optional<Duration> retryAfter(Optional<String> headerValue) {
return headerValue.flatMap(
value -> {
try {
return Optional.of(Duration.ofSeconds(Long.parseLong(value.trim())));
} catch (NumberFormatException notSeconds) {
return Optional.empty();
}
});
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider;
import dev.caskeleton.application.notification.platform.api.content.AttachmentRef;
import dev.caskeleton.application.notification.platform.api.error.AttachmentUnavailableException;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.provider.AttachmentAccessContext;
import dev.caskeleton.application.notification.platform.provider.AttachmentResolver;
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
/**
* The resolver used when no attachment source is wired.
*
* <p>It refuses rather than returning an empty stream. Sending a mail whose attachment is silently
* missing is worse than not sending it: the recipient is told something is attached and it is not.
*
* <p>The composition root replaces this with a file-server or object-storage backed resolver; both
* leaves are visible there, and neither is reachable from this one.
*/
public final class UnconfiguredAttachmentResolver implements AttachmentResolver {
@Override
public ResolvedAttachment resolve(AttachmentRef reference, AttachmentAccessContext context) {
throw new AttachmentUnavailableException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.ATTACHMENT_UNAVAILABLE, FailureCategory.INVALID_PAYLOAD));
}
}
@@ -0,0 +1,67 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.apns;
import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.provider.ProviderFailure;
import java.util.Optional;
import java.util.Set;
/** Maps APNs reason strings onto the stable failure vocabulary. */
public final class ApnsFailureClassifier {
private static final Set<String> INVALID_TOKEN_REASONS =
Set.of("BadDeviceToken", "Unregistered", "DeviceTokenNotForTopic");
private static final Set<String> CONFIGURATION_REASONS =
Set.of("BadTopic", "TopicDisallowed", "BadCertificateEnvironment", "InvalidPushType");
/** Classify a non-2xx APNs response. */
public ProviderFailure classify(NotificationHttpResponse response) {
Optional<String> reason = reason(response);
if (reason.filter(INVALID_TOKEN_REASONS::contains).isPresent()) {
return new ProviderFailure(
NotificationFailureCode.CONTACT_POINT_INVALID,
FailureCategory.INVALID_RECIPIENT,
false,
Optional.empty(),
reason);
}
if (reason.filter(CONFIGURATION_REASONS::contains).isPresent()) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID,
FailureCategory.AUTHORIZATION,
false,
Optional.empty(),
reason);
}
if (reason.filter("ExpiredProviderToken"::equals).isPresent()) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_AUTHENTICATION_FAILED,
FailureCategory.AUTHENTICATION,
false,
Optional.empty(),
reason);
}
if (reason.filter("TooManyRequests"::equals).isPresent()) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_THROTTLED,
FailureCategory.THROTTLED,
true,
Optional.empty(),
reason);
}
return ProviderResults.fromStatus(response.statusCode(), Optional.empty());
}
private static Optional<String> reason(NotificationHttpResponse response) {
try {
var node = NotificationJsonMapper.mapper().readTree(response.bodyAsString());
var reason = node.get("reason");
return reason == null || reason.isNull() ? Optional.empty() : Optional.of(reason.asString());
} catch (RuntimeException unparseable) {
return Optional.empty();
}
}
}
@@ -0,0 +1,100 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.apns;
import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import dev.caskeleton.application.notification.platform.security.AccessContext;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import java.time.Duration;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.function.Supplier;
/**
* APNs adapter.
*
* <p>A 2xx is acceptance. Apple documents that an accepted notification may be delivered, stored or
* discarded, and that ordering is not guaranteed, so this adapter never produces a delivery outcome
* and the platform never uses APNs as an ordered event transport.
*/
public final class ApnsNotificationProviderAdapter implements NotificationProviderAdapter {
private static final ProviderId PROVIDER_ID = new ProviderId("apns");
private final NotificationHttpGateway gateway;
private final ApnsRequestMapper mapper;
private final ApnsFailureClassifier classifier;
private final ContactPointProtector protector;
private final Supplier<String> authorizationSupplier;
public ApnsNotificationProviderAdapter(
NotificationHttpGateway gateway,
ApnsRequestMapper mapper,
ApnsFailureClassifier classifier,
ContactPointProtector protector,
Supplier<String> authorizationSupplier,
ApnsProviderProperties properties) {
// The profile is required at construction so a missing topic or environment fails at wiring
// time, but it is never exposed: a public accessor would leak an adapter type across the port.
Objects.requireNonNull(properties, "properties");
this.gateway = Objects.requireNonNull(gateway, "gateway");
this.mapper = Objects.requireNonNull(mapper, "mapper");
this.classifier = Objects.requireNonNull(classifier, "classifier");
this.protector = Objects.requireNonNull(protector, "protector");
this.authorizationSupplier =
Objects.requireNonNull(authorizationSupplier, "authorizationSupplier");
}
@Override
public ProviderId providerId() {
return PROVIDER_ID;
}
@Override
public Set<Channel> channels() {
return Set.of(Channel.PUSH);
}
@Override
public ProviderCapabilities capabilities() {
return new ProviderCapabilities(
false, false, false, false, false, false, false, true, 1, 4096L, Duration.ofDays(30));
}
@Override
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
Objects.requireNonNull(submission, "submission");
return CompletableFuture.completedFuture(send(submission));
}
private ProviderSubmissionResult send(ProviderSubmission submission) {
long startedNanos = System.nanoTime();
var contactPoint =
protector.reveal(
submission.contactPoint(),
AccessContext.dispatch(submission.profile().profileId().value()));
var request = mapper.map(submission, contactPoint, authorizationSupplier.get());
try {
NotificationHttpResponse response = gateway.exchange(request);
Duration elapsed = Duration.ofNanos(System.nanoTime() - startedNanos);
if (response.isSuccessful()) {
return ProviderSubmissionResult.accepted(
response.header("apns-id").orElse(null), "Accepted", elapsed);
}
return ProviderSubmissionResult.rejected(classifier.classify(response), elapsed);
} catch (NotificationHttpTransportException transportFailure) {
return ProviderResults.fromTransport(
transportFailure, Duration.ofNanos(System.nanoTime() - startedNanos));
}
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.apns;
import dev.caskeleton.application.notification.platform.contact.ApnsEnvironment;
import java.net.URI;
import java.time.Duration;
import java.util.Objects;
import java.util.Set;
/**
* APNs profile.
*
* <p>Environment and topic are required. A sandbox token sent to the production host is a silent
* non-delivery, so the pairing is checked before the call rather than diagnosed afterwards.
*/
public record ApnsProviderProperties(
URI endpoint,
String topic,
ApnsEnvironment environment,
Set<String> allowedPushTypes,
Duration timeout) {
public ApnsProviderProperties {
Objects.requireNonNull(endpoint, "endpoint");
Objects.requireNonNull(topic, "topic");
Objects.requireNonNull(environment, "environment");
allowedPushTypes = Set.copyOf(Objects.requireNonNull(allowedPushTypes, "allowedPushTypes"));
Objects.requireNonNull(timeout, "timeout");
if (topic.isBlank()) {
throw new IllegalArgumentException("topic");
}
if (allowedPushTypes.isEmpty()) {
throw new IllegalArgumentException("allowedPushTypes must not be empty");
}
if (timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException("timeout must be positive and finite");
}
}
}
@@ -0,0 +1,96 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.apns;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.content.MobilePushContent;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.api.error.ProviderConfigurationException;
import dev.caskeleton.application.notification.platform.contact.ApnsDeviceToken;
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/** Builds the APNs HTTP/2 request headers and payload. */
public final class ApnsRequestMapper {
private static final String DEFAULT_PUSH_TYPE = "alert";
private final ApnsProviderProperties properties;
private final Clock clock;
public ApnsRequestMapper(ApnsProviderProperties properties, Clock clock) {
this.properties = Objects.requireNonNull(properties, "properties");
this.clock = Objects.requireNonNull(clock, "clock");
}
/** Map one submission, rejecting an environment or push-type mismatch first. */
public NotificationHttpRequest map(
ProviderSubmission submission, ContactPointValue contactPoint, String authorization) {
Objects.requireNonNull(submission, "submission");
Objects.requireNonNull(authorization, "authorization");
if (!(contactPoint instanceof ApnsDeviceToken token)) {
throw new IllegalArgumentException("APNs requires an APNs device token");
}
if (token.environment() != properties.environment()) {
throw configurationFailure();
}
if (!(submission.content().content() instanceof MobilePushContent push)) {
throw new IllegalArgumentException("APNs requires mobile push content");
}
String pushType = DEFAULT_PUSH_TYPE;
if (!properties.allowedPushTypes().contains(pushType)) {
throw configurationFailure();
}
Map<String, Object> aps = new LinkedHashMap<>();
aps.put("alert", Map.of("title", push.title(), "body", push.body()));
push.presentation().sound().ifPresent(sound -> aps.put("sound", sound));
push.presentation().badge().ifPresent(badge -> aps.put("badge", badge));
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("aps", aps);
payload.putAll(push.data());
Map<String, String> headers = new LinkedHashMap<>();
headers.put("authorization", authorization);
headers.put("apns-topic", properties.topic());
headers.put("apns-push-type", pushType);
headers.put("apns-priority", "10");
headers.put("apns-id", submission.attemptId().value().toString());
submission
.expiresAt()
.ifPresent(
expiry -> headers.put("apns-expiration", Long.toString(expiry.getEpochSecond())));
submission.collapse().ifPresent(spec -> headers.put("apns-collapse-id", spec.key()));
byte[] body =
NotificationJsonMapper.mapper()
.writeValueAsString(payload)
.getBytes(StandardCharsets.UTF_8);
return new NotificationHttpRequest(
"POST",
URI.create(properties.endpoint() + "/3/device/" + token.value()),
JdkNotificationHttpGateway.headers(headers),
body,
properties.timeout());
}
/** Current time, exposed so expiry mapping stays testable. */
public java.time.Instant now() {
return clock.instant();
}
private static ProviderConfigurationException configurationFailure() {
return new ProviderConfigurationException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID, FailureCategory.AUTHORIZATION));
}
}
@@ -0,0 +1,89 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.api.error.ProviderPayloadLimitException;
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import dev.caskeleton.application.notification.platform.security.AccessContext;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
/**
* Batch submission that keeps per-recipient identity.
*
* <p>One transport call, many attempts. FCM returns a positional result per input, so a partial
* failure is decomposed back to the recipient that owns it; collapsing a batch into one shared
* outcome would mark four delivered recipients as failed because the fifth token was stale.
*/
public final class FcmBatchCoordinator {
private final FcmGateway gateway;
private final FcmMessageMapper messageMapper;
private final FcmTargetMapper targetMapper;
private final FcmFailureClassifier classifier;
private final ContactPointProtector protector;
private final FcmProviderProperties properties;
public FcmBatchCoordinator(
FcmGateway gateway,
FcmMessageMapper messageMapper,
FcmTargetMapper targetMapper,
FcmFailureClassifier classifier,
ContactPointProtector protector,
FcmProviderProperties properties) {
this.gateway = Objects.requireNonNull(gateway, "gateway");
this.messageMapper = Objects.requireNonNull(messageMapper, "messageMapper");
this.targetMapper = Objects.requireNonNull(targetMapper, "targetMapper");
this.classifier = Objects.requireNonNull(classifier, "classifier");
this.protector = Objects.requireNonNull(protector, "protector");
this.properties = Objects.requireNonNull(properties, "properties");
}
/** Submit a batch and return one result per input, in input order. */
public CompletionStage<List<ProviderSubmissionResult>> submit(
List<ProviderSubmission> submissions) {
Objects.requireNonNull(submissions, "submissions");
if (submissions.isEmpty()) {
return CompletableFuture.completedFuture(List.of());
}
if (submissions.size() > properties.maxBatchSize()) {
throw new ProviderPayloadLimitException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.PROVIDER_PAYLOAD_LIMIT, FailureCategory.INVALID_PAYLOAD));
}
long startedNanos = System.nanoTime();
List<Map<String, Object>> messages = new ArrayList<>(submissions.size());
for (ProviderSubmission submission : submissions) {
ContactPointValue value =
protector.reveal(
submission.contactPoint(),
AccessContext.dispatch(submission.profile().profileId().value()));
messages.add(messageMapper.map(submission, targetMapper.map(value)));
}
FcmBatchResult batch = gateway.sendBatch(messages);
if (batch.items().size() != submissions.size()) {
throw new IllegalStateException("FCM returned a result count that does not match the input");
}
Duration elapsed = Duration.ofNanos(System.nanoTime() - startedNanos);
List<ProviderSubmissionResult> results = new ArrayList<>(submissions.size());
for (FcmBatchResult.Item item : batch.items()) {
results.add(
item.success()
? ProviderSubmissionResult.accepted(item.messageId().orElse(null), "SUCCESS", elapsed)
: classifier.classify(item.errorCode().orElseThrow(), elapsed));
}
return CompletableFuture.completedFuture(List.copyOf(results));
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/** Positional result of one FCM multicast call. */
public record FcmBatchResult(List<Item> items) {
public FcmBatchResult {
items = List.copyOf(Objects.requireNonNull(items, "items"));
}
/** One item result, aligned with the input index. */
public record Item(boolean success, Optional<String> messageId, Optional<String> errorCode) {
public Item {
Objects.requireNonNull(messageId, "messageId");
Objects.requireNonNull(errorCode, "errorCode");
if (success == errorCode.isPresent()) {
throw new IllegalArgumentException("an item is either a success or an error, never both");
}
}
/** Successful item. */
public static Item success(String messageId) {
return new Item(true, Optional.ofNullable(messageId), Optional.empty());
}
/** Failed item. */
public static Item failure(String errorCode) {
return new Item(false, Optional.empty(), Optional.of(errorCode));
}
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm;
import dev.caskeleton.application.notification.platform.api.ContactPointId;
import dev.caskeleton.application.notification.platform.api.TenantId;
import dev.caskeleton.application.notification.platform.contact.ContactPointStatus;
import dev.caskeleton.application.notification.platform.dispatch.ContactPointStorePort;
import java.util.Objects;
/**
* Applies FCM target lifecycle changes.
*
* <p>An {@code UNREGISTERED} response is the provider telling us the target no longer exists. Not
* acting on it means every future notification to that user spends a provider call to learn the
* same thing again.
*/
public final class FcmContactPointUpdater {
private final ContactPointStorePort contactPoints;
private final FcmFailureClassifier classifier;
public FcmContactPointUpdater(
ContactPointStorePort contactPoints, FcmFailureClassifier classifier) {
this.contactPoints = Objects.requireNonNull(contactPoints, "contactPoints");
this.classifier = Objects.requireNonNull(classifier, "classifier");
}
/** Invalidate the contact point when the error code says the target is gone. */
public boolean apply(TenantId tenantId, ContactPointId contactPointId, String errorCode) {
Objects.requireNonNull(tenantId, "tenantId");
Objects.requireNonNull(contactPointId, "contactPointId");
Objects.requireNonNull(errorCode, "errorCode");
if (!classifier.invalidatesContactPoint(errorCode)) {
return false;
}
contactPoints.updateStatus(
tenantId, contactPointId, ContactPointStatus.INVALID, "FCM_" + errorCode);
return true;
}
}
@@ -0,0 +1,76 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.provider.ProviderFailure;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import java.time.Duration;
import java.util.Optional;
/**
* FCM error codes to the stable failure vocabulary.
*
* <p>{@code UNREGISTERED} is the one that must never be retried: the target is gone, and repeating
* the call cannot bring it back. It invalidates the contact point and lets routing fall back.
*/
public final class FcmFailureClassifier {
/** Classify one FCM error code. */
public ProviderSubmissionResult classify(String errorCode, Duration elapsed) {
return ProviderSubmissionResult.rejected(failure(errorCode), elapsed);
}
/** Failure for one FCM error code. */
public ProviderFailure failure(String errorCode) {
return switch (errorCode) {
case "UNREGISTERED", "INVALID_TOKEN" ->
ProviderFailure.of(
NotificationFailureCode.CONTACT_POINT_INVALID,
FailureCategory.INVALID_RECIPIENT,
false);
case "QUOTA_EXCEEDED" ->
ProviderFailure.of(
NotificationFailureCode.PROVIDER_THROTTLED, FailureCategory.THROTTLED, true);
case "UNAVAILABLE", "INTERNAL" ->
ProviderFailure.of(
NotificationFailureCode.PROVIDER_TRANSIENT_FAILURE,
FailureCategory.TRANSIENT_PROVIDER,
true);
case "INVALID_ARGUMENT" ->
ProviderFailure.of(
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD, false);
case "THIRD_PARTY_AUTH_ERROR", "UNAUTHENTICATED" ->
ProviderFailure.of(
NotificationFailureCode.PROVIDER_AUTHENTICATION_FAILED,
FailureCategory.AUTHENTICATION,
false);
case "SENDER_ID_MISMATCH" ->
ProviderFailure.of(
NotificationFailureCode.PROVIDER_AUTHORIZATION_FAILED,
FailureCategory.AUTHORIZATION,
false);
default ->
ProviderFailure.of(
NotificationFailureCode.PROVIDER_PERMANENT_FAILURE,
FailureCategory.PERMANENT_PROVIDER,
false);
};
}
/** Whether an error code means the contact point should be invalidated. */
public boolean invalidatesContactPoint(String errorCode) {
return failure(errorCode).category() == FailureCategory.INVALID_RECIPIENT;
}
/** Retry hint, where FCM supplies one. */
public Optional<Duration> retryAfter(Optional<String> headerValue) {
return headerValue.flatMap(
value -> {
try {
return Optional.of(Duration.ofSeconds(Long.parseLong(value.trim())));
} catch (NumberFormatException notSeconds) {
return Optional.empty();
}
});
}
}
@@ -0,0 +1,12 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm;
import java.util.List;
import java.util.Map;
/** The FCM transport seam, so batch decomposition can be tested without a live project. */
@FunctionalInterface
public interface FcmGateway {
/** Send a batch and return one positional result per message. */
FcmBatchResult sendBatch(List<Map<String, Object>> messages);
}
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm;
import dev.caskeleton.application.notification.platform.api.content.MobilePushContent;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import java.time.Clock;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Builds the FCM message body.
*
* <p>TTL is the minimum of the remaining delivery deadline and the provider maximum. Sending the
* provider maximum when the notification expires in ninety seconds would let FCM keep retrying a
* message the platform has already given up on.
*/
public final class FcmMessageMapper {
private static final int MAX_PAYLOAD_BYTES = 4096;
private final FcmProviderProperties properties;
private final Clock clock;
public FcmMessageMapper(FcmProviderProperties properties, Clock clock) {
this.properties = Objects.requireNonNull(properties, "properties");
this.clock = Objects.requireNonNull(clock, "clock");
}
/** Message body for one submission. */
public Map<String, Object> map(ProviderSubmission submission, FcmWireTarget target) {
Objects.requireNonNull(submission, "submission");
Objects.requireNonNull(target, "target");
if (!(submission.content().content() instanceof MobilePushContent push)) {
throw new IllegalArgumentException("FCM requires mobile push content");
}
Map<String, Object> message = new LinkedHashMap<>();
if ("FID".equals(target.kind())) {
message.put("installation_id", target.value());
} else {
message.put("token", target.value());
}
message.put("notification", Map.of("title", push.title(), "body", push.body()));
if (!push.data().isEmpty()) {
message.put("data", push.data());
}
Map<String, Object> android = new LinkedHashMap<>();
android.put("ttl", ttl(submission).toSeconds() + "s");
submission.collapse().ifPresent(spec -> android.put("collapse_key", spec.key()));
message.put("android", android);
return Map.of("message", message);
}
/** Effective TTL for a submission. */
public Duration ttl(ProviderSubmission submission) {
Optional<Duration> remaining =
submission.expiresAt().map(expiry -> Duration.between(clock.instant(), expiry));
return remaining
.filter(value -> value.compareTo(properties.maxTtl()) < 0)
.filter(value -> !value.isNegative())
.orElse(properties.maxTtl());
}
/** Payload ceiling enforced before the provider call. */
public int maxPayloadBytes() {
return MAX_PAYLOAD_BYTES;
}
}
@@ -0,0 +1,73 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.provider.BatchNotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletionStage;
/**
* FCM adapter.
*
* <p>A successful send means FCM took the message. Firebase describes its own failures as handoff
* failures, which is the clearest statement that success is a handoff and not a device delivery, so
* the strongest evidence this adapter ever produces is {@code PROVIDER_ACCEPTED}.
*/
public final class FcmNotificationProviderAdapter implements BatchNotificationProviderAdapter {
private static final ProviderId PROVIDER_ID = new ProviderId("fcm");
private final FcmBatchCoordinator coordinator;
private final FcmProviderProperties properties;
public FcmNotificationProviderAdapter(
FcmBatchCoordinator coordinator, FcmProviderProperties properties) {
this.coordinator = Objects.requireNonNull(coordinator, "coordinator");
this.properties = Objects.requireNonNull(properties, "properties");
}
@Override
public ProviderId providerId() {
return PROVIDER_ID;
}
@Override
public Set<Channel> channels() {
return Set.of(Channel.PUSH);
}
@Override
public ProviderCapabilities capabilities() {
// deliveryReceipt is false: FCM has no server-side delivery receipt for ordinary sends, and
// claiming one would let the runtime plan a reconciliation that can never succeed.
return new ProviderCapabilities(
true,
false,
false,
false,
false,
false,
false,
true,
properties.maxBatchSize(),
4096L,
properties.maxTtl());
}
@Override
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
Objects.requireNonNull(submission, "submission");
return coordinator.submit(List.of(submission)).thenApply(results -> results.get(0));
}
@Override
public CompletionStage<List<ProviderSubmissionResult>> submitBatch(
List<ProviderSubmission> submissions) {
return coordinator.submit(submissions);
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm;
import java.net.URI;
import java.time.Duration;
import java.util.Objects;
/** FCM profile. Project and application identity are pinned so a target cannot cross projects. */
public record FcmProviderProperties(
URI endpoint,
String projectId,
String applicationId,
int maxBatchSize,
Duration maxTtl,
Duration timeout) {
/** The Admin SDK multicast ceiling. */
public static final int MAX_SUPPORTED_BATCH = 500;
public FcmProviderProperties {
Objects.requireNonNull(endpoint, "endpoint");
Objects.requireNonNull(projectId, "projectId");
Objects.requireNonNull(applicationId, "applicationId");
Objects.requireNonNull(maxTtl, "maxTtl");
Objects.requireNonNull(timeout, "timeout");
if (projectId.isBlank() || applicationId.isBlank()) {
throw new IllegalArgumentException("projectId and applicationId must not be blank");
}
if (maxBatchSize < 1 || maxBatchSize > MAX_SUPPORTED_BATCH) {
throw new IllegalArgumentException("maxBatchSize must be 1.." + MAX_SUPPORTED_BATCH);
}
if (timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException("timeout must be positive and finite");
}
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm;
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
import dev.caskeleton.application.notification.platform.contact.FcmInstallationId;
import dev.caskeleton.application.notification.platform.contact.LegacyFcmRegistrationToken;
/** Maps typed push targets to their FCM wire representation. */
public final class FcmTargetMapper {
/** Wire target for a contact point value. */
public FcmWireTarget map(ContactPointValue value) {
return switch (value) {
case FcmInstallationId fid -> new FcmWireTarget("FID", fid.value());
case LegacyFcmRegistrationToken token -> new FcmWireTarget("LEGACY_TOKEN", token.value());
default -> throw new IllegalArgumentException("FCM requires an FCM target");
};
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.fcm;
import java.util.Objects;
/**
* A target in its wire form, with the kind kept explicit.
*
* <p>The kind is not cosmetic: an installation id and a legacy registration token go to different
* request fields, and flattening them would make a migration a runtime guess.
*/
public record FcmWireTarget(String kind, String value) {
public FcmWireTarget {
Objects.requireNonNull(kind, "kind");
Objects.requireNonNull(value, "value");
if (value.isBlank()) {
throw new IllegalArgumentException("value");
}
}
}
@@ -0,0 +1,103 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.http;
import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* Default gateway on the JDK HTTP client.
*
* <p>Redirects are never followed. A provider redirect would move a signed, credential-bearing
* request to a host the profile never approved.
*
* <p>Timeout and connection-reset failures are translated into an explicit statement about whether
* the body was committed, because that single bit is what separates a safe retry from a duplicate.
*/
public final class JdkNotificationHttpGateway implements NotificationHttpGateway {
// Restricted headers the JDK client refuses to let a caller set.
private static final Set<String> RESTRICTED =
Set.of("connection", "content-length", "expect", "host", "upgrade");
private final HttpClient client;
public JdkNotificationHttpGateway(Duration connectTimeout) {
this(
HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NEVER)
.connectTimeout(Objects.requireNonNull(connectTimeout, "connectTimeout"))
.build());
}
public JdkNotificationHttpGateway(HttpClient client) {
this.client = Objects.requireNonNull(client, "client");
}
@Override
public NotificationHttpResponse exchange(NotificationHttpRequest request) {
Objects.requireNonNull(request, "request");
HttpRequest.Builder builder =
HttpRequest.newBuilder(request.uri())
.timeout(request.timeout())
.method(request.method(), HttpRequest.BodyPublishers.ofByteArray(request.body()));
request
.headers()
.forEach(
(name, values) -> {
if (!RESTRICTED.contains(name)) {
values.forEach(value -> builder.header(name, value));
}
});
try {
HttpResponse<byte[]> response =
client.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray());
return new NotificationHttpResponse(
response.statusCode(), Map.copyOf(response.headers().map()), response.body());
} catch (HttpTimeoutException timeout) {
// The request timed out after the body was published, so the provider may well have it.
throw new NotificationHttpTransportException("RESPONSE_TIMEOUT", true, timeout);
} catch (IOException failure) {
throw new NotificationHttpTransportException(
"TRANSPORT_FAILURE", bodyWasLikelyCommitted(failure), failure);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new NotificationHttpTransportException("INTERRUPTED", true, interrupted);
}
}
/**
* A connect failure happens before anything is written; anything else may have written the body.
*
* <p>The default is deliberately the pessimistic one: guessing "not committed" would turn an
* unknown into an automatic resend.
*/
private static boolean bodyWasLikelyCommitted(IOException failure) {
String message = failure.getMessage();
if (message == null) {
return true;
}
String normalized = message.toLowerCase(java.util.Locale.ROOT);
boolean beforeSend =
normalized.contains("connection refused")
|| normalized.contains("unresolved")
|| normalized.contains("no route to host")
|| normalized.contains("connect timed out");
return !beforeSend;
}
/** Header map helper for adapters. */
public static Map<String, List<String>> headers(Map<String, String> singleValued) {
return singleValued.entrySet().stream()
.collect(
java.util.stream.Collectors.toUnmodifiableMap(
Map.Entry::getKey, entry -> List.of(entry.getValue())));
}
}
@@ -0,0 +1,43 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.http;
import java.net.URI;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/** Endpoint validation shared by the provider profiles. */
public final class NotificationEndpoints {
private static final Set<String> LOOPBACK_HOSTS = Set.of("127.0.0.1", "::1", "localhost");
private NotificationEndpoints() {}
/**
* Require TLS, except on the loopback interface.
*
* <p>The exception is narrow on purpose. A plaintext provider endpoint on a routable host exposes
* credentials and message bodies to anything on the path, which is why it is refused outright. A
* loopback endpoint never leaves the machine, so the same reasoning does not apply — and without
* this the contract suite could not exercise a real socket at all, which would mean the ambiguity
* behaviour it exists to prove went untested.
*/
public static URI requireSecureOrLoopback(URI endpoint, String name) {
Objects.requireNonNull(endpoint, name);
String scheme =
endpoint.getScheme() == null ? "" : endpoint.getScheme().toLowerCase(Locale.ROOT);
if ("https".equals(scheme)) {
return endpoint;
}
String host = endpoint.getHost() == null ? "" : endpoint.getHost().toLowerCase(Locale.ROOT);
if ("http".equals(scheme) && LOOPBACK_HOSTS.contains(host)) {
return endpoint;
}
throw new IllegalArgumentException(name + " must use https outside the loopback interface");
}
/** Whether an endpoint is on the loopback interface. */
public static boolean isLoopback(URI endpoint) {
String host = endpoint.getHost() == null ? "" : endpoint.getHost().toLowerCase(Locale.ROOT);
return LOOPBACK_HOSTS.contains(host);
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.http;
/**
* The only way a provider adapter in this leaf reaches the network.
*
* <p>It exists as a port because the registry does not permit {@code adapter-outbound-notification
* → adapter-outbound-httpclient}. The composition root sees both leaves and is the supported place
* to substitute an implementation backed by the HTTP Client Platform, which brings its own TLS,
* circuit breaker, SSRF and dynamic-target policy.
*/
public interface NotificationHttpGateway {
/**
* Execute one request.
*
* @throws NotificationHttpTransportException when no response could be read; the exception states
* whether the request body was already committed
*/
NotificationHttpResponse exchange(NotificationHttpRequest request);
}
@@ -0,0 +1,64 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.http;
import java.net.URI;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
/** One outbound provider HTTP request. */
@SuppressWarnings("ArrayRecordComponent") // defensive copies on construction and on every accessor
public record NotificationHttpRequest(
String method, URI uri, Map<String, List<String>> headers, byte[] body, Duration timeout) {
public NotificationHttpRequest {
Objects.requireNonNull(method, "method");
Objects.requireNonNull(uri, "uri");
Objects.requireNonNull(headers, "headers");
Objects.requireNonNull(body, "body");
Objects.requireNonNull(timeout, "timeout");
if (timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException("timeout must be finite and positive");
}
headers =
headers.entrySet().stream()
.collect(
java.util.stream.Collectors.toUnmodifiableMap(
entry -> entry.getKey().toLowerCase(Locale.ROOT),
entry -> List.copyOf(entry.getValue())));
body = body.clone();
}
@Override
public byte[] body() {
return body.clone();
}
@Override
public boolean equals(Object other) {
return other instanceof NotificationHttpRequest request
&& method.equals(request.method)
&& uri.equals(request.uri)
&& headers.equals(request.headers)
&& Arrays.equals(body, request.body)
&& timeout.equals(request.timeout);
}
@Override
public int hashCode() {
return Objects.hash(method, uri, headers, Arrays.hashCode(body), timeout);
}
@Override
public String toString() {
// The URI is redacted because a Web Push endpoint is a capability URL and the request body may
// be a rendered message.
return "NotificationHttpRequest[method="
+ method
+ ", uri=redacted, bytes="
+ body.length
+ "]";
}
}
@@ -0,0 +1,66 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.http;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/** One provider HTTP response. */
@SuppressWarnings("ArrayRecordComponent") // defensive copies on construction and on every accessor
public record NotificationHttpResponse(
int statusCode, Map<String, List<String>> headers, byte[] body) {
public NotificationHttpResponse {
Objects.requireNonNull(headers, "headers");
Objects.requireNonNull(body, "body");
headers =
headers.entrySet().stream()
.collect(
java.util.stream.Collectors.toUnmodifiableMap(
entry -> entry.getKey().toLowerCase(Locale.ROOT),
entry -> List.copyOf(entry.getValue())));
body = body.clone();
}
@Override
public byte[] body() {
return body.clone();
}
/** Body decoded as UTF-8. */
public String bodyAsString() {
return new String(body, StandardCharsets.UTF_8);
}
/** First value of a header, matched case-insensitively. */
public Optional<String> header(String name) {
List<String> values = headers.get(name.toLowerCase(Locale.ROOT));
return values == null || values.isEmpty() ? Optional.empty() : Optional.of(values.get(0));
}
/** Whether the status is 2xx. */
public boolean isSuccessful() {
return statusCode >= 200 && statusCode < 300;
}
@Override
public boolean equals(Object other) {
return other instanceof NotificationHttpResponse response
&& statusCode == response.statusCode
&& headers.equals(response.headers)
&& Arrays.equals(body, response.body);
}
@Override
public int hashCode() {
return Objects.hash(statusCode, headers, Arrays.hashCode(body));
}
@Override
public String toString() {
return "NotificationHttpResponse[status=" + statusCode + ", bytes=" + body.length + "]";
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.http;
import java.util.Objects;
/**
* A provider HTTP call that produced no usable response.
*
* <p>{@code requestBodyCommitted} is the field that decides everything downstream: a connection
* that failed before the body was written is a safe retry, while one that failed after it was
* written is an ambiguous submission that must not be resent automatically.
*/
public class NotificationHttpTransportException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final boolean requestBodyCommitted;
private final String reasonCode;
public NotificationHttpTransportException(
String reasonCode, boolean requestBodyCommitted, Throwable cause) {
super(reasonCode, cause);
this.reasonCode = Objects.requireNonNull(reasonCode, "reasonCode");
this.requestBodyCommitted = requestBodyCommitted;
}
/** Whether the request body reached the provider before the failure. */
public boolean requestBodyCommitted() {
return requestBodyCommitted;
}
/** Bounded reason code, safe for logs and metrics. */
public String reasonCode() {
return reasonCode;
}
}
@@ -0,0 +1,147 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* AWS Signature Version 4.
*
* <p>Implemented here rather than pulled in with an SDK because the SDK would also bring its own
* HTTP client, retry policy and credential chain — three things this platform already owns and
* whose duplication would quietly move retry ownership out of the notification retry policy.
*/
public final class AwsSignatureV4Signer {
private static final String ALGORITHM = "AWS4-HMAC-SHA256";
private static final DateTimeFormatter AMZ_DATE =
DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC);
private static final DateTimeFormatter DATE_STAMP =
DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneOffset.UTC);
/** Signed headers to add to a request. */
public record SignedHeaders(String authorization, String amzDate, String contentSha256) {
public SignedHeaders {
Objects.requireNonNull(authorization, "authorization");
Objects.requireNonNull(amzDate, "amzDate");
Objects.requireNonNull(contentSha256, "contentSha256");
}
}
/** Sign one request. */
public SignedHeaders sign(
String method,
String canonicalUri,
String canonicalQuery,
Map<String, String> headers,
byte[] body,
String accessKeyId,
byte[] secretAccessKey,
String region,
String service,
Instant signedAt) {
Objects.requireNonNull(method, "method");
Objects.requireNonNull(canonicalUri, "canonicalUri");
Objects.requireNonNull(canonicalQuery, "canonicalQuery");
Objects.requireNonNull(headers, "headers");
Objects.requireNonNull(body, "body");
Objects.requireNonNull(signedAt, "signedAt");
String amzDate = AMZ_DATE.format(signedAt);
String dateStamp = DATE_STAMP.format(signedAt);
String payloadHash = hex(sha256(body));
TreeMap<String, String> canonicalHeaders = new TreeMap<>();
headers.forEach(
(name, value) -> canonicalHeaders.put(name.toLowerCase(Locale.ROOT), value.trim()));
canonicalHeaders.put("x-amz-date", amzDate);
canonicalHeaders.put("x-amz-content-sha256", payloadHash);
StringBuilder canonicalHeaderBlock = new StringBuilder();
canonicalHeaders.forEach(
(name, value) -> canonicalHeaderBlock.append(name).append(':').append(value).append('\n'));
String signedHeaderNames = String.join(";", canonicalHeaders.keySet());
String canonicalRequest =
method
+ '\n'
+ canonicalUri
+ '\n'
+ canonicalQuery
+ '\n'
+ canonicalHeaderBlock
+ '\n'
+ signedHeaderNames
+ '\n'
+ payloadHash;
String credentialScope = dateStamp + "/" + region + "/" + service + "/aws4_request";
String stringToSign =
ALGORITHM
+ '\n'
+ amzDate
+ '\n'
+ credentialScope
+ '\n'
+ hex(sha256(canonicalRequest.getBytes(StandardCharsets.UTF_8)));
byte[] signingKey = signingKey(secretAccessKey, dateStamp, region, service);
String signature = hex(hmac(signingKey, stringToSign.getBytes(StandardCharsets.UTF_8)));
String authorization =
ALGORITHM
+ " Credential="
+ accessKeyId
+ "/"
+ credentialScope
+ ", SignedHeaders="
+ signedHeaderNames
+ ", Signature="
+ signature;
return new SignedHeaders(authorization, amzDate, payloadHash);
}
private static byte[] signingKey(
byte[] secretAccessKey, String dateStamp, String region, String service) {
byte[] key =
("AWS4" + new String(secretAccessKey, StandardCharsets.UTF_8))
.getBytes(StandardCharsets.UTF_8);
byte[] dateKey = hmac(key, dateStamp.getBytes(StandardCharsets.UTF_8));
byte[] regionKey = hmac(dateKey, region.getBytes(StandardCharsets.UTF_8));
byte[] serviceKey = hmac(regionKey, service.getBytes(StandardCharsets.UTF_8));
return hmac(serviceKey, "aws4_request".getBytes(StandardCharsets.UTF_8));
}
private static byte[] hmac(byte[] key, byte[] data) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return mac.doFinal(data);
} catch (java.security.GeneralSecurityException failure) {
throw new IllegalStateException("HmacSHA256 is required by the Java platform", failure);
}
}
private static byte[] sha256(byte[] value) {
try {
return MessageDigest.getInstance("SHA-256").digest(value);
} catch (NoSuchAlgorithmException failure) {
throw new IllegalStateException("SHA-256 is required by the Java platform", failure);
}
}
private static String hex(byte[] value) {
return HexFormat.of().formatHex(value);
}
}
@@ -0,0 +1,82 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.callback.CallbackRequest;
import dev.caskeleton.application.notification.platform.callback.CallbackVerificationResult;
import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent;
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter;
import dev.caskeleton.application.notification.platform.callback.VerifiedCallback;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import tools.jackson.databind.JsonNode;
/**
* SES event ingestion over SNS.
*
* <p>A subscription confirmation is verified like any other message but produces no provider event:
* confirming a topic is an operational act, and letting it into the ledger would mean an unverified
* caller could add rows just by claiming to be SNS.
*/
public final class SesCallbackAdapter implements ProviderCallbackAdapter {
private static final ProviderId PROVIDER_ID = new ProviderId("ses");
private final SnsSignatureVerifier verifier;
private final SesEventNormalizer normalizer;
public SesCallbackAdapter(SnsSignatureVerifier verifier, SesEventNormalizer normalizer) {
this.verifier = Objects.requireNonNull(verifier, "verifier");
this.normalizer = Objects.requireNonNull(normalizer, "normalizer");
}
@Override
public ProviderId providerId() {
return PROVIDER_ID;
}
@Override
public CallbackVerificationResult verify(CallbackRequest request) {
Objects.requireNonNull(request, "request");
Map<String, String> envelope;
try {
envelope = flatten(new String(request.body(), StandardCharsets.UTF_8));
} catch (RuntimeException unparseable) {
return CallbackVerificationResult.invalid("SNS_ENVELOPE_UNPARSEABLE");
}
if (!verifier.isValid(envelope)) {
return CallbackVerificationResult.invalid("SNS_SIGNATURE_MISMATCH");
}
return CallbackVerificationResult.valid(new VerifiedCallback(request, envelope));
}
@Override
public List<NormalizedProviderEvent> normalize(VerifiedCallback callback) {
Objects.requireNonNull(callback, "callback");
Map<String, String> envelope = callback.canonicalParameters();
String type = envelope.getOrDefault("Type", "Notification");
if (!"Notification".equals(type)) {
// Confirmations and unsubscribes are handled by operations, not by the delivery ledger.
return List.of();
}
String message = envelope.getOrDefault("Message", "{}");
return normalizer.normalize(message);
}
private static Map<String, String> flatten(String body) {
JsonNode root = NotificationJsonMapper.mapper().readTree(body);
Map<String, String> envelope = new LinkedHashMap<>();
root.properties()
.forEach(
property -> {
JsonNode value = property.getValue();
if (value != null && value.isValueNode()) {
envelope.put(property.getKey(), value.asString());
}
});
return envelope;
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.callback.StandardDeliveryProjector;
/**
* SES projector.
*
* <p>SES adds no transition of its own: delivery, bounce and complaint all obey the shared table,
* and the interesting SES-specific behaviour — a complaint arriving after a delivery — is exactly
* what the shared table already gets right.
*/
public final class SesDeliveryProjector extends StandardDeliveryProjector {
public SesDeliveryProjector() {
super(new ProviderId("ses"));
}
}
@@ -0,0 +1,101 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.callback.NormalizedEventType;
import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import tools.jackson.databind.JsonNode;
/**
* SES event publishing to the stable vocabulary.
*
* <p>Bounce type decides the suppression consequence: a permanent bounce invalidates the address,
* while a transient one is a retry input. Collapsing both into one reason would remove an address
* because a mailbox was briefly full.
*/
public final class SesEventNormalizer {
/** Normalize one SES notification payload. */
public List<NormalizedProviderEvent> normalize(String payload) {
JsonNode root = NotificationJsonMapper.mapper().readTree(payload);
String type = text(root, "eventType").orElse(text(root, "notificationType").orElse("Unknown"));
Optional<String> messageId =
Optional.ofNullable(root.get("mail")).flatMap(mail -> text(mail, "messageId"));
Optional<Instant> occurredAt = timestamp(root, type);
List<NormalizedProviderEvent> events = new ArrayList<>(1);
events.add(
switch (type) {
case "Send" ->
event(NormalizedEventType.PROVIDER_ACCEPTED, type, messageId, occurredAt, Map.of());
case "Delivery" ->
event(NormalizedEventType.DELIVERY_CONFIRMED, type, messageId, occurredAt, Map.of());
case "DeliveryDelay" ->
event(NormalizedEventType.DELIVERY_DELAYED, type, messageId, occurredAt, Map.of());
case "Bounce" -> bounce(root, type, messageId, occurredAt);
case "Complaint" ->
event(NormalizedEventType.COMPLAINT, type, messageId, occurredAt, Map.of());
case "Reject" ->
event(NormalizedEventType.PROVIDER_REJECTED, type, messageId, occurredAt, Map.of());
case "RenderingFailure" ->
event(NormalizedEventType.TEMPLATE_FAILURE, type, messageId, occurredAt, Map.of());
case "Open" -> event(NormalizedEventType.OPENED, type, messageId, occurredAt, Map.of());
case "Click" -> event(NormalizedEventType.CLICKED, type, messageId, occurredAt, Map.of());
default -> event(NormalizedEventType.UNKNOWN, type, messageId, occurredAt, Map.of());
});
return List.copyOf(events);
}
private static NormalizedProviderEvent bounce(
JsonNode root, String type, Optional<String> messageId, Optional<Instant> occurredAt) {
String bounceType =
Optional.ofNullable(root.get("bounce"))
.flatMap(bounce -> text(bounce, "bounceType"))
.orElse("Undetermined");
NormalizedEventType normalized =
"Permanent".equals(bounceType)
? NormalizedEventType.BOUNCED_HARD
: NormalizedEventType.BOUNCED_SOFT;
return event(
normalized,
type + "/" + bounceType,
messageId,
occurredAt,
Map.of("bounceType", bounceType));
}
private static NormalizedProviderEvent event(
NormalizedEventType type,
String nativeType,
Optional<String> messageId,
Optional<Instant> occurredAt,
Map<String, String> attributes) {
return new NormalizedProviderEvent(
type, nativeType, Optional.empty(), messageId, occurredAt, attributes);
}
private static Optional<String> text(JsonNode node, String field) {
JsonNode value = node.get(field);
return value == null || value.isNull() ? Optional.empty() : Optional.of(value.asString());
}
private static Optional<Instant> timestamp(JsonNode root, String type) {
JsonNode section = root.get(type.toLowerCase(java.util.Locale.ROOT));
if (section == null) {
return Optional.empty();
}
return text(section, "timestamp")
.flatMap(
value -> {
try {
return Optional.of(Instant.parse(value));
} catch (java.time.format.DateTimeParseException unparseable) {
return Optional.empty();
}
});
}
}
@@ -0,0 +1,51 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.provider.ProviderFailure;
import java.util.Optional;
/** Maps SES error responses onto the stable failure vocabulary. */
public final class SesFailureClassifier {
/** Classify a non-2xx SES response. */
public ProviderFailure classify(NotificationHttpResponse response) {
String body = response.bodyAsString();
if (body.contains("MessageRejected")) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_REJECTED,
FailureCategory.PERMANENT_PROVIDER,
false,
Optional.empty(),
Optional.of("MessageRejected"));
}
if (body.contains("MailFromDomainNotVerified") || body.contains("SendingPausedException")) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID,
FailureCategory.AUTHORIZATION,
false,
Optional.empty(),
Optional.of("SenderIdentityNotReady"));
}
if (body.contains("TooManyRequestsException") || response.statusCode() == 429) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_THROTTLED,
FailureCategory.THROTTLED,
true,
ProviderResults.retryAfter(response.header("retry-after")),
Optional.of("TooManyRequests"));
}
if (body.contains("AccountSuspendedException")) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_AUTHORIZATION_FAILED,
FailureCategory.AUTHORIZATION,
false,
Optional.empty(),
Optional.of("AccountSuspended"));
}
return ProviderResults.fromStatus(
response.statusCode(), ProviderResults.retryAfter(response.header("retry-after")));
}
}
@@ -0,0 +1,134 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import dev.caskeleton.application.notification.platform.security.AccessContext;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.time.Clock;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
/**
* Amazon SES submission.
*
* <p>{@code MessageId} is stored as the provider request id and mapped to {@code
* PROVIDER_ACCEPTED}. SES documents that it can accept a request and still decline to send — for a
* virus finding or a bad personalisation — so promoting acceptance to delivery would be wrong by
* the provider's own contract, not merely conservative.
*
* <p>Retry ownership stays with the notification retry policy: the gateway performs no blind retry,
* because a resend after a lost response is exactly the decision the evidence model must make.
*/
public final class SesNotificationProviderAdapter implements NotificationProviderAdapter {
private static final ProviderId PROVIDER_ID = new ProviderId("ses");
private final NotificationHttpGateway gateway;
private final SesRequestMapper mapper;
private final SesFailureClassifier classifier;
private final ContactPointProtector protector;
private final SecretMaterialProvider secrets;
private final String accessKeyId;
private final Clock clock;
public SesNotificationProviderAdapter(
NotificationHttpGateway gateway,
SesRequestMapper mapper,
SesFailureClassifier classifier,
ContactPointProtector protector,
SecretMaterialProvider secrets,
String accessKeyId,
Clock clock) {
this.gateway = Objects.requireNonNull(gateway, "gateway");
this.mapper = Objects.requireNonNull(mapper, "mapper");
this.classifier = Objects.requireNonNull(classifier, "classifier");
this.protector = Objects.requireNonNull(protector, "protector");
this.secrets = Objects.requireNonNull(secrets, "secrets");
this.accessKeyId = Objects.requireNonNull(accessKeyId, "accessKeyId");
this.clock = Objects.requireNonNull(clock, "clock");
}
@Override
public ProviderId providerId() {
return PROVIDER_ID;
}
@Override
public Set<Channel> channels() {
return Set.of(Channel.EMAIL);
}
@Override
public ProviderCapabilities capabilities() {
return new ProviderCapabilities(
false, false, true, false, false, false, false, false, 1, 10_000_000L, Duration.ofDays(1));
}
@Override
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
Objects.requireNonNull(submission, "submission");
return CompletableFuture.completedFuture(send(submission));
}
private ProviderSubmissionResult send(ProviderSubmission submission) {
long startedNanos = System.nanoTime();
ContactPointValue value =
protector.reveal(
submission.contactPoint(),
AccessContext.dispatch(submission.profile().profileId().value()));
if (!(value instanceof EmailAddress address)) {
throw new IllegalArgumentException("SES requires an email contact point");
}
var request =
mapper.map(
submission,
address.normalized(),
accessKeyId,
secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material(),
clock.instant());
try {
NotificationHttpResponse response = gateway.exchange(request);
Duration elapsed = elapsedSince(startedNanos);
if (response.isSuccessful()) {
return ProviderSubmissionResult.accepted(messageId(response), "Accepted", elapsed);
}
return ProviderSubmissionResult.rejected(classifier.classify(response), elapsed);
} catch (NotificationHttpTransportException transportFailure) {
return ProviderResults.fromTransport(transportFailure, elapsedSince(startedNanos));
}
}
private static String messageId(NotificationHttpResponse response) {
try {
var node = NotificationJsonMapper.mapper().readTree(response.bodyAsString());
return Optional.ofNullable(node.get("MessageId")).map(value -> value.asString()).orElse(null);
} catch (RuntimeException unparseable) {
// A 2xx without a parseable body is still acceptance; the platform simply has no provider
// request id to reconcile against later.
return null;
}
}
private static Duration elapsedSince(long startedNanos) {
return Duration.ofNanos(System.nanoTime() - startedNanos);
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationEndpoints;
import java.net.URI;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
/** SES profile. Configuration sets are approved N3 options, not free-form provider parameters. */
public record SesProviderProperties(
URI endpoint,
String region,
String senderIdentity,
Optional<String> configurationSet,
Duration timeout) {
public SesProviderProperties {
Objects.requireNonNull(endpoint, "endpoint");
Objects.requireNonNull(region, "region");
Objects.requireNonNull(senderIdentity, "senderIdentity");
Objects.requireNonNull(configurationSet, "configurationSet");
Objects.requireNonNull(timeout, "timeout");
NotificationEndpoints.requireSecureOrLoopback(endpoint, "SES endpoint");
if (region.isBlank() || senderIdentity.isBlank()) {
throw new IllegalArgumentException("region and senderIdentity must not be blank");
}
if (timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException("timeout must be positive and finite");
}
}
}
@@ -0,0 +1,86 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.content.EmailContent;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/** Builds the signed SES v2 send request. */
public final class SesRequestMapper {
private static final String PATH = "/v2/email/outbound-emails";
private final SesProviderProperties properties;
private final AwsSignatureV4Signer signer;
public SesRequestMapper(SesProviderProperties properties, AwsSignatureV4Signer signer) {
this.properties = Objects.requireNonNull(properties, "properties");
this.signer = Objects.requireNonNull(signer, "signer");
}
/** Map one submission into a signed request. */
public NotificationHttpRequest map(
ProviderSubmission submission,
String recipientAddress,
String accessKeyId,
byte[] secretAccessKey,
Instant signedAt) {
Objects.requireNonNull(submission, "submission");
if (!(submission.content().content() instanceof EmailContent email)) {
throw new IllegalArgumentException("SES requires email content");
}
Map<String, Object> simple = new LinkedHashMap<>();
simple.put("Subject", Map.of("Data", email.subject(), "Charset", "UTF-8"));
Map<String, Object> bodyParts = new LinkedHashMap<>();
bodyParts.put("Text", Map.of("Data", email.textBody(), "Charset", "UTF-8"));
email
.htmlBody()
.ifPresent(html -> bodyParts.put("Html", Map.of("Data", html, "Charset", "UTF-8")));
simple.put("Body", bodyParts);
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("FromEmailAddress", properties.senderIdentity());
payload.put("Destination", Map.of("ToAddresses", java.util.List.of(recipientAddress)));
payload.put("Content", Map.of("Simple", simple));
properties.configurationSet().ifPresent(name -> payload.put("ConfigurationSetName", name));
byte[] body =
NotificationJsonMapper.mapper()
.writeValueAsString(payload)
.getBytes(StandardCharsets.UTF_8);
String host = properties.endpoint().getHost();
var signed =
signer.sign(
"POST",
PATH,
"",
Map.of("host", host, "content-type", "application/json"),
body,
accessKeyId,
secretAccessKey,
properties.region(),
"ses",
signedAt);
return new NotificationHttpRequest(
"POST",
URI.create(properties.endpoint().toString() + PATH),
JdkNotificationHttpGateway.headers(
Map.of(
"content-type", "application/json",
"x-amz-date", signed.amzDate(),
"x-amz-content-sha256", signed.contentSha256(),
"authorization", signed.authorization())),
body,
properties.timeout());
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
import dev.caskeleton.application.notification.platform.callback.NormalizedEventType;
import dev.caskeleton.application.notification.platform.callback.NotificationSideEffectPort;
import dev.caskeleton.application.notification.platform.callback.SuppressionFacts;
import java.util.Objects;
/**
* Turns an SES event into the suppression fact it implies.
*
* <p>A permanent bounce and a transient one map to different facts on purpose: treating a full
* mailbox like a dead address removes a recipient who would have received the next message fine.
*/
public final class SesSuppressionUpdater {
private final NotificationSideEffectPort sideEffects;
public SesSuppressionUpdater(NotificationSideEffectPort sideEffects) {
this.sideEffects = Objects.requireNonNull(sideEffects, "sideEffects");
}
/** Apply the suppression consequence of one normalized event. */
public boolean apply(DeliveryAttemptSnapshot attempt, NormalizedEventType type) {
Objects.requireNonNull(attempt, "attempt");
Objects.requireNonNull(type, "type");
SuppressionFacts facts =
switch (type) {
case BOUNCED_HARD -> SuppressionFacts.NONE.withHardBounce().withInvalidTarget();
case COMPLAINT -> SuppressionFacts.NONE.withComplaint();
case INVALID_RECIPIENT -> SuppressionFacts.NONE.withInvalidTarget();
// A soft bounce is explicitly not a suppression: it is a retry input.
default -> SuppressionFacts.NONE;
};
if (!facts.requiresSuppression()) {
return false;
}
sideEffects.apply(attempt, facts);
return true;
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import java.net.URI;
import java.security.PublicKey;
/**
* Supplies the public key of an SNS signing certificate.
*
* <p>A port so the fetch, its cache and its TLS policy stay outside the verifier — and so a
* contract test can verify signatures without reaching the network.
*/
public interface SnsCertificateProvider {
/** Public key of the certificate at a URL that has already been host-checked. */
PublicKey publicKeyFor(URI certificateUrl);
}
@@ -0,0 +1,108 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.ses;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.PublicKey;
import java.security.Signature;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* SNS message signature verification.
*
* <p>Two checks, and both are load-bearing. The signing certificate URL is constrained to an
* Amazon-owned host before it is fetched, because a message that names its own certificate host is
* otherwise self-signed by whoever sent it. The canonical string is then rebuilt from the fields
* SNS specifies, in its order, because signing a re-serialised body would verify our own JSON
* writer rather than the message.
*/
public final class SnsSignatureVerifier {
private static final Set<String> NOTIFICATION_FIELDS =
Set.of("Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type");
private static final Set<String> SUBSCRIPTION_FIELDS =
Set.of("Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type");
private final SnsCertificateProvider certificates;
private final String certificateHostSuffix;
public SnsSignatureVerifier(SnsCertificateProvider certificates, String certificateHostSuffix) {
this.certificates = Objects.requireNonNull(certificates, "certificates");
this.certificateHostSuffix =
Objects.requireNonNull(certificateHostSuffix, "certificateHostSuffix");
if (certificateHostSuffix.isBlank()) {
throw new IllegalArgumentException("certificateHostSuffix");
}
}
/** Verify one SNS envelope. */
public boolean isValid(Map<String, String> envelope) {
Objects.requireNonNull(envelope, "envelope");
String certificateUrl = envelope.get("SigningCertURL");
String signature = envelope.get("Signature");
String version = envelope.getOrDefault("SignatureVersion", "1");
if (certificateUrl == null || signature == null) {
return false;
}
if (!isTrustedCertificateUrl(certificateUrl)) {
return false;
}
try {
PublicKey key = certificates.publicKeyFor(URI.create(certificateUrl));
Signature verifier =
Signature.getInstance("2".equals(version) ? "SHA256withRSA" : "SHA1withRSA");
verifier.initVerify(key);
verifier.update(canonicalString(envelope).getBytes(StandardCharsets.UTF_8));
return verifier.verify(Base64.getDecoder().decode(signature));
} catch (GeneralSecurityException | IllegalArgumentException failure) {
return false;
}
}
/** Whether the certificate URL is on an Amazon host over TLS. */
public boolean isTrustedCertificateUrl(String certificateUrl) {
try {
URI uri = URI.create(certificateUrl);
String host = uri.getHost() == null ? "" : uri.getHost().toLowerCase(Locale.ROOT);
return "https".equalsIgnoreCase(uri.getScheme()) && host.endsWith(certificateHostSuffix);
} catch (IllegalArgumentException malformed) {
return false;
}
}
/** The exact field-name/value sequence SNS signs. */
public static String canonicalString(Map<String, String> envelope) {
Set<String> fields =
"SubscriptionConfirmation".equals(envelope.get("Type"))
|| "UnsubscribeConfirmation".equals(envelope.get("Type"))
? SUBSCRIPTION_FIELDS
: NOTIFICATION_FIELDS;
Map<String, String> ordered = new LinkedHashMap<>();
List.of(
"Message",
"MessageId",
"Subject",
"SubscribeURL",
"Timestamp",
"Token",
"TopicArn",
"Type")
.stream()
.filter(fields::contains)
.filter(envelope::containsKey)
.forEach(name -> ordered.put(name, envelope.get(name)));
StringBuilder canonical = new StringBuilder();
ordered.forEach(
(name, value) -> canonical.append(name).append('\n').append(value).append('\n'));
return canonical.toString();
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
import jakarta.mail.internet.MimeMessage;
/**
* The single SMTP send operation.
*
* <p>Extracted behind an interface so the adapter's classification rules can be exercised against
* every SMTP outcome — including a connection lost after {@code DATA} — without a live relay.
*/
@FunctionalInterface
public interface SmtpDispatch {
/**
* Send one message.
*
* @throws SmtpDispatchException with the reply code, or with the fact that the body was already
* committed when the connection dropped
*/
void send(MimeMessage message);
}
@@ -0,0 +1,30 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
import java.util.Objects;
import java.util.Optional;
/** An SMTP send that did not complete with a final acceptance. */
public class SmtpDispatchException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final transient Optional<Integer> replyCode;
private final boolean dataCommitted;
public SmtpDispatchException(
String reasonCode, Optional<Integer> replyCode, boolean dataCommitted, Throwable cause) {
super(reasonCode, cause);
this.replyCode = Objects.requireNonNull(replyCode, "replyCode");
this.dataCommitted = dataCommitted;
}
/** SMTP reply code, when the server answered at all. */
public Optional<Integer> replyCode() {
return replyCode;
}
/** Whether the message body had already been transmitted when the failure happened. */
public boolean dataCommitted() {
return dataCommitted;
}
}
@@ -0,0 +1,81 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.provider.ProviderExecutionEvidence;
import dev.caskeleton.application.notification.platform.provider.ProviderFailure;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import java.time.Duration;
import java.util.Optional;
import java.util.Set;
/**
* RFC 5321 reply-code classification.
*
* <p>4yz is a temporary failure the client may repeat; 5yz is permanent and must not be repeated
* unchanged. The interesting case is neither: a connection lost after {@code DATA} means the relay
* may already hold the message, so {@code SMTP_SEND_FAILED = safe retry} is exactly the
* simplification this classifier exists to prevent.
*/
public final class SmtpFailureClassifier {
/** Reply codes that identify the recipient rather than the transaction as the problem. */
private static final Set<Integer> INVALID_RECIPIENT_CODES = Set.of(550, 551, 553, 511);
/** Classify a failed send. */
public ProviderSubmissionResult classify(SmtpDispatchException failure, Duration elapsed) {
Optional<Integer> replyCode = failure.replyCode();
if (replyCode.isEmpty()) {
if (failure.dataCommitted()) {
return ProviderSubmissionResult.ambiguous(
new ProviderFailure(
NotificationFailureCode.PROVIDER_RESPONSE_LOST,
FailureCategory.AMBIGUOUS_SUBMISSION,
false,
Optional.empty(),
Optional.of(failure.getMessage())),
ProviderExecutionEvidence.responseLost(),
elapsed);
}
return ProviderSubmissionResult.notSubmitted(
new ProviderFailure(
NotificationFailureCode.PROVIDER_TRANSIENT_FAILURE,
FailureCategory.TRANSIENT_PROVIDER,
true,
Optional.empty(),
Optional.of(failure.getMessage())),
elapsed);
}
int code = replyCode.get();
if (code >= 400 && code < 500) {
return ProviderSubmissionResult.rejected(
new ProviderFailure(
NotificationFailureCode.PROVIDER_TRANSIENT_FAILURE,
FailureCategory.TRANSIENT_PROVIDER,
true,
Optional.empty(),
Optional.of(Integer.toString(code))),
elapsed);
}
if (INVALID_RECIPIENT_CODES.contains(code)) {
return ProviderSubmissionResult.rejected(
new ProviderFailure(
NotificationFailureCode.CONTACT_POINT_INVALID,
FailureCategory.INVALID_RECIPIENT,
false,
Optional.empty(),
Optional.of(Integer.toString(code))),
elapsed);
}
return ProviderSubmissionResult.rejected(
new ProviderFailure(
NotificationFailureCode.PROVIDER_PERMANENT_FAILURE,
FailureCategory.PERMANENT_PROVIDER,
false,
Optional.empty(),
Optional.of(Integer.toString(code))),
elapsed);
}
}
@@ -0,0 +1,95 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
import dev.caskeleton.application.notification.platform.api.content.EmailContent;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
import dev.caskeleton.application.notification.platform.api.error.NotificationValidationException;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ResolvedAttachment;
import jakarta.mail.MessagingException;
import jakarta.mail.Session;
import jakarta.mail.internet.MimeMessage;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
import org.springframework.mail.javamail.MimeMessageHelper;
/**
* Builds the MIME message.
*
* <p>Text and HTML are assembled as {@code multipart/alternative} and everything is UTF-8. Header
* values containing CR or LF are rejected before the message is built: header injection is the one
* email failure that turns a notification into someone else's mail.
*
* <p>A MIME construction failure is a non-retryable rejection, and it happens before any relay is
* contacted.
*/
public final class SmtpMimeMessageFactory {
private final Session session;
public SmtpMimeMessageFactory(Session session) {
this.session = Objects.requireNonNull(session, "session");
}
/** Build a message for one submission. */
public MimeMessage create(
ProviderSubmission submission,
String recipientAddress,
String fromAddress,
List<ResolvedAttachment> attachments) {
Objects.requireNonNull(submission, "submission");
Objects.requireNonNull(recipientAddress, "recipientAddress");
Objects.requireNonNull(fromAddress, "fromAddress");
Objects.requireNonNull(attachments, "attachments");
if (!(submission.content().content() instanceof EmailContent email)) {
throw rejection();
}
requireHeaderSafe(recipientAddress);
requireHeaderSafe(fromAddress);
requireHeaderSafe(email.subject());
try {
MimeMessage message = new MimeMessage(session);
MimeMessageHelper helper =
new MimeMessageHelper(
message,
!attachments.isEmpty() || email.htmlBody().isPresent(),
StandardCharsets.UTF_8.name());
helper.setFrom(fromAddress);
helper.setTo(recipientAddress);
helper.setSubject(email.subject());
if (email.htmlBody().isPresent()) {
helper.setText(email.textBody(), email.htmlBody().get());
} else {
helper.setText(email.textBody(), false);
}
for (ResolvedAttachment attachment : attachments) {
helper.addAttachment(
attachment.displayName(), () -> attachment.content(), attachment.contentType());
}
for (var header : email.options().approvedHeaders().entrySet()) {
requireHeaderSafe(header.getKey());
requireHeaderSafe(header.getValue());
message.setHeader(header.getKey(), header.getValue());
}
return message;
} catch (MessagingException failure) {
throw rejection();
}
}
private static void requireHeaderSafe(String value) {
if (value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\0') >= 0) {
throw rejection();
}
}
private static NotificationValidationException rejection() {
return new NotificationValidationException(
NotificationFailureDescriptor.preDispatch(
NotificationFailureCode.VALIDATION_FAILED, FailureCategory.INVALID_PAYLOAD));
}
}
@@ -0,0 +1,102 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.api.routing.Channel;
import dev.caskeleton.application.notification.platform.contact.ContactPointValue;
import dev.caskeleton.application.notification.platform.contact.EmailAddress;
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
import dev.caskeleton.application.notification.platform.provider.ProviderCapabilities;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmissionResult;
import dev.caskeleton.application.notification.platform.security.AccessContext;
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor;
/**
* SMTP email adapter.
*
* <p>A final {@code 250} is provider acceptance and nothing more. The relay has taken
* responsibility for the message; whether it reaches an inbox is a separate question this adapter
* cannot answer, so the result carries {@code PROVIDER_ACCEPTED} and a delivery outcome of {@code
* UNKNOWN}.
*/
public final class SmtpNotificationProviderAdapter implements NotificationProviderAdapter {
private static final ProviderId PROVIDER_ID = new ProviderId("smtp");
private final SmtpDispatch dispatch;
private final SmtpMimeMessageFactory mimeFactory;
private final SmtpFailureClassifier classifier;
private final ContactPointProtector protector;
private final SmtpProviderProperties properties;
private final Executor executor;
public SmtpNotificationProviderAdapter(
SmtpDispatch dispatch,
SmtpMimeMessageFactory mimeFactory,
SmtpFailureClassifier classifier,
ContactPointProtector protector,
SmtpProviderProperties properties,
Executor executor) {
this.dispatch = Objects.requireNonNull(dispatch, "dispatch");
this.mimeFactory = Objects.requireNonNull(mimeFactory, "mimeFactory");
this.classifier = Objects.requireNonNull(classifier, "classifier");
this.protector = Objects.requireNonNull(protector, "protector");
this.properties = Objects.requireNonNull(properties, "properties");
this.executor = Objects.requireNonNull(executor, "executor");
}
@Override
public ProviderId providerId() {
return PROVIDER_ID;
}
@Override
public Set<Channel> channels() {
return Set.of(Channel.EMAIL);
}
@Override
public ProviderCapabilities capabilities() {
// SMTP offers no status callback, no status query and no provider-side idempotency, so the
// runtime must never plan a reconciliation for it.
return new ProviderCapabilities(
false, false, false, false, false, false, false, false, 1, 25_000_000L, Duration.ofDays(1));
}
@Override
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
Objects.requireNonNull(submission, "submission");
return CompletableFuture.supplyAsync(() -> send(submission), executor);
}
private ProviderSubmissionResult send(ProviderSubmission submission) {
long startedNanos = System.nanoTime();
ContactPointValue value =
protector.reveal(
submission.contactPoint(),
AccessContext.dispatch(submission.profile().profileId().value()));
if (!(value instanceof EmailAddress address)) {
throw new IllegalArgumentException("SMTP requires an email contact point");
}
try {
dispatch.send(
mimeFactory.create(
submission, address.normalized(), properties.senderIdentity(), List.of()));
return ProviderSubmissionResult.accepted(null, "250", elapsedSince(startedNanos));
} catch (SmtpDispatchException failure) {
return classifier.classify(failure, elapsedSince(startedNanos));
}
}
private static Duration elapsedSince(long startedNanos) {
return Duration.ofNanos(System.nanoTime() - startedNanos);
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.smtp;
import java.time.Duration;
import java.util.Objects;
/**
* SMTP profile.
*
* <p>Every timeout is required and finite. An unbounded SMTP read timeout is how one unresponsive
* relay turns into an exhausted dispatch pool.
*/
public record SmtpProviderProperties(
String host,
int port,
TlsMode tlsMode,
String senderIdentity,
Duration connectTimeout,
Duration readTimeout,
Duration writeTimeout,
int maxConcurrency) {
/** Transport security of the SMTP session. */
public enum TlsMode {
STARTTLS_REQUIRED,
IMPLICIT_TLS
}
public SmtpProviderProperties {
Objects.requireNonNull(host, "host");
Objects.requireNonNull(tlsMode, "tlsMode");
Objects.requireNonNull(senderIdentity, "senderIdentity");
requireFinite(connectTimeout, "connectTimeout");
requireFinite(readTimeout, "readTimeout");
requireFinite(writeTimeout, "writeTimeout");
if (host.isBlank()) {
throw new IllegalArgumentException("host");
}
if (port < 1 || port > 65535) {
throw new IllegalArgumentException("port");
}
if (maxConcurrency < 1) {
throw new IllegalArgumentException("maxConcurrency");
}
if (tlsMode == TlsMode.STARTTLS_REQUIRED && port == 25) {
// Port 25 with opportunistic STARTTLS is the classic silent-downgrade path; the profile has
// to say which it means.
throw new IllegalArgumentException("STARTTLS on port 25 must be declared explicitly");
}
}
private static void requireFinite(Duration timeout, String name) {
Objects.requireNonNull(timeout, name);
if (timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException(name + " must be positive and finite");
}
}
}
@@ -0,0 +1,89 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.callback.CallbackRequest;
import dev.caskeleton.application.notification.platform.callback.CallbackVerificationResult;
import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent;
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter;
import dev.caskeleton.application.notification.platform.callback.VerifiedCallback;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/** Twilio status callback verification and normalization. */
public final class TwilioCallbackAdapter implements ProviderCallbackAdapter {
private static final ProviderId PROVIDER_ID = new ProviderId("twilio");
private final TwilioSignatureValidator validator;
private final TwilioStatusNormalizer normalizer;
private final TwilioProviderProperties properties;
private final SecretMaterialProvider secrets;
public TwilioCallbackAdapter(
TwilioSignatureValidator validator,
TwilioStatusNormalizer normalizer,
TwilioProviderProperties properties,
SecretMaterialProvider secrets) {
this.validator = Objects.requireNonNull(validator, "validator");
this.normalizer = Objects.requireNonNull(normalizer, "normalizer");
this.properties = Objects.requireNonNull(properties, "properties");
this.secrets = Objects.requireNonNull(secrets, "secrets");
}
@Override
public ProviderId providerId() {
return PROVIDER_ID;
}
@Override
public CallbackVerificationResult verify(CallbackRequest request) {
Objects.requireNonNull(request, "request");
Map<String, String> parameters = parseForm(request.body());
boolean valid =
validator.isValid(
properties.canonicalCallbackUrl(),
parameters,
request.header("x-twilio-signature").orElse(null),
secrets.activeKey(SecretPurpose.CALLBACK_SIGNING).material());
return valid
? CallbackVerificationResult.valid(new VerifiedCallback(request, parameters))
: CallbackVerificationResult.invalid("TWILIO_SIGNATURE_MISMATCH");
}
@Override
public List<NormalizedProviderEvent> normalize(VerifiedCallback callback) {
Objects.requireNonNull(callback, "callback");
Optional<Instant> occurredAt = Optional.of(callback.request().receivedAt());
return List.of(normalizer.normalize(callback.canonicalParameters(), occurredAt));
}
private static Map<String, String> parseForm(byte[] body) {
Map<String, String> parameters = new LinkedHashMap<>();
String raw = new String(body, StandardCharsets.UTF_8);
if (raw.isBlank()) {
return parameters;
}
for (String pair : java.util.regex.Pattern.compile("&").split(raw, -1)) {
if (pair.isEmpty()) {
continue;
}
int separator = pair.indexOf('=');
if (separator < 0) {
parameters.put(URLDecoder.decode(pair, StandardCharsets.UTF_8), "");
} else {
parameters.put(
URLDecoder.decode(pair.substring(0, separator), StandardCharsets.UTF_8),
URLDecoder.decode(pair.substring(separator + 1), StandardCharsets.UTF_8));
}
}
return parameters;
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio;
import dev.caskeleton.application.notification.platform.api.ProviderId;
import dev.caskeleton.application.notification.platform.callback.StandardDeliveryProjector;
/**
* Twilio projector.
*
* <p>No provider-specific transitions are needed: the shared table already ignores a {@code sent}
* that follows a {@code delivered}, which is the exact Twilio behaviour this projector has to
* survive.
*/
public final class TwilioDeliveryProjector extends StandardDeliveryProjector {
public TwilioDeliveryProjector() {
super(new ProviderId("twilio"));
}
}
@@ -0,0 +1,51 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio;
import dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
import dev.caskeleton.application.notification.platform.provider.ProviderFailure;
import java.util.Optional;
import java.util.Set;
/** Maps Twilio error codes onto the stable failure vocabulary. */
public final class TwilioFailureClassifier {
/** Twilio error codes that identify the destination number rather than the request. */
private static final Set<Integer> INVALID_NUMBER_CODES =
Set.of(21211, 21214, 21610, 21612, 21614);
/** Classify a non-2xx Twilio response. */
public ProviderFailure classify(NotificationHttpResponse response) {
Optional<Integer> code = errorCode(response);
if (code.filter(INVALID_NUMBER_CODES::contains).isPresent()) {
return new ProviderFailure(
NotificationFailureCode.CONTACT_POINT_INVALID,
FailureCategory.INVALID_RECIPIENT,
false,
Optional.empty(),
code.map(String::valueOf));
}
if (response.statusCode() == 429) {
return new ProviderFailure(
NotificationFailureCode.PROVIDER_THROTTLED,
FailureCategory.THROTTLED,
true,
ProviderResults.retryAfter(response.header("retry-after")),
code.map(String::valueOf));
}
return ProviderResults.fromStatus(
response.statusCode(), ProviderResults.retryAfter(response.header("retry-after")));
}
private static Optional<Integer> errorCode(NotificationHttpResponse response) {
try {
var node = NotificationJsonMapper.mapper().readTree(response.bodyAsString());
var code = node.get("code");
return code == null || code.isNull() ? Optional.empty() : Optional.of(code.asInt());
} catch (RuntimeException unparseable) {
return Optional.empty();
}
}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio;
import java.net.URI;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
/**
* Twilio profile.
*
* <p>{@code canonicalCallbackUrl} is pinned here rather than reconstructed from the incoming
* request. Twilio signs the URL it called, and a reverse proxy that rewrites scheme or host makes a
* server-side reconstruction disagree with the signature — the most common cause of "valid webhook,
* failed verification".
*/
public record TwilioProviderProperties(
URI endpoint,
String accountSid,
Optional<String> messagingServiceSid,
Optional<String> fromNumber,
String canonicalCallbackUrl,
Duration timeout,
Duration maxReconciliationAge) {
public TwilioProviderProperties {
Objects.requireNonNull(endpoint, "endpoint");
Objects.requireNonNull(accountSid, "accountSid");
Objects.requireNonNull(messagingServiceSid, "messagingServiceSid");
Objects.requireNonNull(fromNumber, "fromNumber");
Objects.requireNonNull(canonicalCallbackUrl, "canonicalCallbackUrl");
Objects.requireNonNull(timeout, "timeout");
Objects.requireNonNull(maxReconciliationAge, "maxReconciliationAge");
if (accountSid.isBlank()) {
throw new IllegalArgumentException("accountSid");
}
if (messagingServiceSid.isEmpty() == fromNumber.isEmpty()) {
throw new IllegalArgumentException(
"exactly one of messagingServiceSid or fromNumber must be configured");
}
if (timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException("timeout must be positive and finite");
}
}
}
@@ -0,0 +1,138 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpResponse;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpTransportException;
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot;
import dev.caskeleton.application.notification.platform.provider.ReconciliationCapability;
import dev.caskeleton.application.notification.platform.provider.ReconciliationResult;
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
import dev.caskeleton.application.notification.platform.security.SecretPurpose;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.Base64;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
/**
* Twilio message status polling.
*
* <p>Callbacks go missing. Twilio itself recommends polling when a status has not moved, so an
* attempt whose callback never arrived is corrected here rather than left ambiguous forever.
*
* <p>Two bounds keep the correction from becoming a second incident: an attempt older than the
* configured maximum is abandoned rather than polled indefinitely, and the query runs through the
* same gateway — and therefore the same provider rate budget — as dispatch.
*/
public final class TwilioReconciliationCapability implements ReconciliationCapability {
private final NotificationHttpGateway gateway;
private final TwilioProviderProperties properties;
private final TwilioStatusNormalizer normalizer;
private final SecretMaterialProvider secrets;
private final Clock clock;
public TwilioReconciliationCapability(
NotificationHttpGateway gateway,
TwilioProviderProperties properties,
TwilioStatusNormalizer normalizer,
SecretMaterialProvider secrets,
Clock clock) {
this.gateway = Objects.requireNonNull(gateway, "gateway");
this.properties = Objects.requireNonNull(properties, "properties");
this.normalizer = Objects.requireNonNull(normalizer, "normalizer");
this.secrets = Objects.requireNonNull(secrets, "secrets");
this.clock = Objects.requireNonNull(clock, "clock");
}
@Override
public boolean supports(ProviderProfileSnapshot profile) {
Objects.requireNonNull(profile, "profile");
return profile.capabilities().statusQuery();
}
@Override
public CompletionStage<ReconciliationResult> reconcile(DeliveryAttemptSnapshot attempt) {
Objects.requireNonNull(attempt, "attempt");
Optional<String> messageSid = attempt.providerRequestId();
if (messageSid.isEmpty()) {
// Without a provider identifier there is nothing to ask about. This is the honest outcome of
// an ambiguous submission that never produced a SID, not a failure to try.
return CompletableFuture.completedFuture(new ReconciliationResult.Unsupported());
}
if (isTooOld(attempt)) {
return CompletableFuture.completedFuture(
new ReconciliationResult.Failed("RECONCILIATION_WINDOW_EXPIRED", false));
}
try {
NotificationHttpResponse response = gateway.exchange(statusRequest(messageSid.get()));
if (!response.isSuccessful()) {
return CompletableFuture.completedFuture(
new ReconciliationResult.Failed(
"STATUS_QUERY_" + response.statusCode(), response.statusCode() >= 500));
}
var node = NotificationJsonMapper.mapper().readTree(response.bodyAsString());
String status =
Optional.ofNullable(node.get("status")).map(value -> value.asString()).orElse("unknown");
if (isPending(status)) {
return CompletableFuture.completedFuture(
new ReconciliationResult.StillUnknown(clock.instant().plusSeconds(300)));
}
return CompletableFuture.completedFuture(
new ReconciliationResult.Confirmed(
normalizer.normalize(
Map.of("MessageSid", messageSid.get(), "MessageStatus", status),
Optional.of(clock.instant()))));
} catch (NotificationHttpTransportException transportFailure) {
return CompletableFuture.completedFuture(
new ReconciliationResult.Failed("STATUS_QUERY_TRANSPORT", true));
}
}
private boolean isTooOld(DeliveryAttemptSnapshot attempt) {
return attempt.startedAt().plus(properties.maxReconciliationAge()).isBefore(clock.instant());
}
private static boolean isPending(String status) {
return switch (status) {
case "accepted", "queued", "sending", "scheduled" -> true;
default -> false;
};
}
private NotificationHttpRequest statusRequest(String messageSid) {
String credentials =
Base64.getEncoder()
.encodeToString(
(properties.accountSid()
+ ":"
+ new String(
secrets.activeKey(SecretPurpose.PROVIDER_CREDENTIAL).material(),
StandardCharsets.UTF_8))
.getBytes(StandardCharsets.UTF_8));
return new NotificationHttpRequest(
"GET",
URI.create(
properties.endpoint()
+ "/2010-04-01/Accounts/"
+ properties.accountSid()
+ "/Messages/"
+ messageSid
+ ".json"),
JdkNotificationHttpGateway.headers(Map.of("authorization", "Basic " + credentials)),
new byte[0],
properties.timeout());
}
}
@@ -0,0 +1,73 @@
package dev.caskeleton.adapter.outbound.notification.platform.provider.twilio;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;
import dev.caskeleton.application.notification.platform.api.content.SmsContent;
import dev.caskeleton.application.notification.platform.provider.ProviderSubmission;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/** Builds the Twilio {@code Messages.json} form request. */
public final class TwilioRequestMapper {
private final TwilioProviderProperties properties;
public TwilioRequestMapper(TwilioProviderProperties properties) {
this.properties = Objects.requireNonNull(properties, "properties");
}
/** Map one submission into a form-encoded request. */
public NotificationHttpRequest map(
ProviderSubmission submission, String recipientE164, byte[] authToken) {
Objects.requireNonNull(submission, "submission");
if (!(submission.content().content() instanceof SmsContent sms)) {
throw new IllegalArgumentException("Twilio requires SMS content");
}
Map<String, String> form = new LinkedHashMap<>();
form.put("To", recipientE164);
properties.messagingServiceSid().ifPresent(sid -> form.put("MessagingServiceSid", sid));
properties.fromNumber().ifPresent(from -> form.put("From", from));
form.put("Body", sms.text());
form.put("StatusCallback", properties.canonicalCallbackUrl());
byte[] body = encode(form).getBytes(StandardCharsets.UTF_8);
String credentials =
Base64.getEncoder()
.encodeToString(
(properties.accountSid() + ":" + new String(authToken, StandardCharsets.UTF_8))
.getBytes(StandardCharsets.UTF_8));
return new NotificationHttpRequest(
"POST",
URI.create(
properties.endpoint()
+ "/2010-04-01/Accounts/"
+ properties.accountSid()
+ "/Messages.json"),
JdkNotificationHttpGateway.headers(
Map.of(
"content-type",
"application/x-www-form-urlencoded",
"authorization",
"Basic " + credentials)),
body,
properties.timeout());
}
private static String encode(Map<String, String> form) {
return form.entrySet().stream()
.map(
entry ->
URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)
+ "="
+ URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"));
}
}

Some files were not shown because too many files have changed in this diff Show More