Status: accepted · Date: 2026-11-01
Enforcement: packages/api/src/platform/kim/ ships the KimTransport interface and both implementations; createKimTransport() picks fake or smtp by env.KIM_TRANSPORT; packages/api/src/features/reports/reports.jobs.ts calls the transport only through this interface; reports.deliver.test.ts asserts the fake delivery path, the lease retry on failure, the KEK-wrapped credentials and the print-in-memory invariant.
Context
Ticket 56 ships the one send path a nurse touches in v1 (US-31, F4, C5): a report leaves the device over KIM, or as a share link, or straight to the printer. KIM is the Telematikinfrastruktur's secure mail: delivery over a service-run SMTP bridge with STARTTLS 587 to a specialist operator, authenticated with the service's SMC-B certificate. There are two things we cannot do from the Cloudflare Workers / Expo runtime:
- Open a raw SMTP connection from the device or a Worker — neither environment exposes raw TCP + STARTTLS, and we never want the device holding KIM credentials.
- Call a vendor-hosted REST API from either side — none of the KIM bridges we evaluated expose one as of this writing.
A third option is a service-run KIM bridge (self-hosted container, Cloudflare Tunnel to the bridge, or a managed KIM-SMTP relay) addressed by a per-organization address pair + credentials. The bridge speaks SMTP + STARTTLS + S/MIME, which Workers can reach over fetch-less transports is still [TBD], but a worker-mailer SMTP client over an outbound TCP connection is the current working plan (plan §1.8). The bridge is not provisioned in v1; the API ships the interface so the owning team can plug the bridge in without touching the feature code.
Dev and tests must still exercise the whole flow without hitting a bridge.
Decision
Reports feature talks to KIM through a tiny internal KimTransport interface implemented in two places:
kim.fake.ts— selected only byKIM_TRANSPORT=fakewithWUNDKLAR_DEV_KIM_INBOX=1, a localBETTER_AUTH_URL, and a non-production runtime. It reportsdeliveredwith the supplied Message-ID and keeps the decrypted PDF in memory./dev/kim-inboxadditionally requiresVITE_WUNDKLAR_DEV_KIM_INBOX=1in a development build.kim.smtp.ts— the default transport (KIM_TRANSPORT=smtp): a placeholder that reports a retryable failure code until the service-run bridge is wired in. The leasedreportsKimjob retries with backoff — nothing is lost, the operator sees the failure inkim_outbox.last_error.
Per-organization credentials (SMTP host, port, user, password) are KEK-wrapped under the organisation's ODK with purpose kim and stored in care_service.kim_config_ct. reports.kimConfig returns only the public KIM address + a hasCredentials boolean; the ciphertext never leaves the server. reports.kimTest runs a single KimTransport.send and reports a status code only.
Uncertain delivery and retries
Before invoking the transport, the job records an attempt delivery event under its fenced lease and supplies a stable Message-ID derived from the delivery ID. The bridge does not yet promise Message-ID deduplication. If a send may have been accepted but its outcome is lost, or a lease is reclaimed with an unfinished attempt, the job records unknown and stops. The nurse must check the recipient's inbox before another send. An explicit KIM_BRIDGE_UNCONFIGURED response confirms that no send was attempted; it remains queued with backoff without an attempt limit. Lease-token checks fence all settlement and release writes.
Printing
The device decrypts the stored PDF in memory. Native builds pass a PDF data URI to the Expo print dialog; web uses a short-lived blob URL. The native module is patched and built from source so PDF bytes cannot become the print job name or an error message. A runtime marker refuses printing with an older or unpatched native binary. The operating system manages its own print spool. A report.print_requested trail event records the dialog handoff: Android does not report whether the user actually printed or cancelled.
Why not route KIM through a shared email package
packages/email is the Mailchimp-driven marketing / transactional path (ticket 76). KIM is a different lifecycle:
- Credentials are per-organization and KEK-wrapped, not a single global secret.
- Delivery writes
delivery_event+ areport.deliveredtrail event + schedules areport_no_replyreminder — none of whichpackages/emailknows about. - The job leases through
kim_outboxand respectsclinicalJobAccess("write"); emails do not.
A shared package would collapse these semantics into one surface and surface the KIM state in the wrong feature. The ticket's acceptance criteria name the KIM lifecycle fields directly.
Consequences
- Adding a KIM provider is a single file in
packages/api/src/platform/kim/+ a two-line change inindex.ts. The job, the trail receipt and the reminder stay the same. - The device never holds KIM credentials and never speaks SMTP. The transport is strictly a server-side concern.
- Credentials never cross the API boundary:
kimConfig.getreports presence,kimConfig.setwraps and stores,kimTestruns the transport without echoing the credentials. /dev/kim-inboxis a dev-only route: the backend returns 404 unlessKIM_TRANSPORT=fakeand the Vite build strips the component chunk out of a production bundle (same shape as/dev/enrol, ticket 29).- The bridge is still [TBD]; the production transport reports
KIM_BRIDGE_UNCONFIGUREDand the row stays queued. This is intentional — the acceptance of a KIM send against a real bridge is deferred to ticket 78.
Alternatives considered
- Direct SMTP from a Worker — rejected: no raw outbound SMTP from the current runtime without a bridge.
- A per-tenant HTTP KIM gateway — rejected for v1: none of the surveyed providers expose one; adding our own gateway duplicates the bridge work we already need.
- Vendor email library embedded on the device — rejected on principle: credentials never belong on the device, and signed-sender SMC-B is a server identity.