diff --git a/openspec/architecture/adr-002-shared-mailbox-poller-exception.md b/openspec/architecture/adr-002-shared-mailbox-poller-exception.md new file mode 100644 index 00000000..f4a58669 --- /dev/null +++ b/openspec/architecture/adr-002-shared-mailbox-poller-exception.md @@ -0,0 +1,80 @@ +# ADR-002: Shared-mailbox IMAP poller is an ADR-022 documented exception + +- **Status:** Accepted +- **Date:** 2026-05-25 +- **Deciders:** Procest product + architecture +- **Scope:** procest (zaaksysteem) — case correspondence intake +- **References:** [hydra ADR-022 — Apps consume OpenRegister abstractions](../../../hydra/openspec/architecture/adr-022-apps-consume-or-abstractions.md) (§ Exceptions), [hydra ADR-019 — Integration registry](../../../hydra/openspec/architecture/adr-019-integration-registry.md), [openregister `integration-email` leaf](../../../openregister/openspec/changes/integration-email/specs/integration-email/spec.md) + +## Context + +ADR-022 mandates "integrate, don't build": where OpenRegister provides an +abstraction that fits, the app MUST consume it rather than build a parallel +mechanism. The `email` integration leaf (NC Mail, id `email`, group `comms`, +storage `link-table`) is the canonical abstraction for surfacing email on an +OR object. The `case-email-integration` change consumes that leaf for all +**display, compose, link, and unlink** of correspondence on the `case` detail +page (sidebar tab + `CnEmailCard` widget per ADR-024). + +One requirement does not fit the leaf. Dutch municipalities run **functional +mailboxes** (`zaken@gemeente.nl`, `vergunningen@gemeente.nl`) that receive +citizen correspondence with no human inbox owner. Procest must **ingest** those +messages unattended and **auto-link** them to a case by the `[ZAAK-YYYY-NNNNNN]` +subject tag, so correspondence reaches the case file even when no case worker +is watching the mailbox. + +The `email` leaf cannot satisfy this: + +- The leaf is **link-only** — "Mail owns send/compose" and the tab provides + "Link existing email", a per-user manual affordance. There is no server-side + ingest path. +- `EmailProvider::requiresPermission()` returns `null`; access "inherits from + object RBAC + Mail app access (user sees only emails in accounts they + control)". A shared functional mailbox has no controlling user, so there is + nothing for the leaf to enumerate on a background job. +- The leaf has no auto-link-by-subject-regex contract; linking is a deliberate + user action. + +## Decision + +Per ADR-022 § Exceptions clause 1 (**fundamentally different domain +requirements** — unattended ingest of an owner-less functional mailbox), procest +ships a **server-side IMAP poller** (`InboundEmailJob`, a `TimedJob`) **scoped +strictly to**: + +1. Connecting to the configured **shared/functional** IMAP mailbox. +2. Auto-linking each message to a case by `\[([A-Z]+-\d{4}-\d{6})\]` subject + regex (subject header only, scoped to the current organization). +3. Recording the link **through the email leaf's link-table** via + `POST /api/objects/{register}/{schema}/{id}/email` — NOT a procest-local + `emailMessage` table. + +Everything the leaf can do, procest consumes from the leaf and does NOT rebuild: +per-user compose/send (NC Mail), the sidebar tab, the `CnEmailCard` widget, +manual "link existing email", unlink. There is **no** procest `EmailComposer`, +`EmailThread`, `emailMessage`/`emailThread` schema, SMTP transport, or unlinked +queue. + +## Boundaries of the exception + +The exception covers **only** unattended shared-mailbox ingest + auto-link. +It explicitly does NOT license: + +- A parallel `emailMessage`/`emailThread` data model (use the leaf link-table). +- Bespoke compose/send (NC Mail owns it). +- A bespoke display tab/widget (use the leaf's tab + `CnEmailCard`). +- App-local RBAC or audit for email (OR audit on the linked object). + +## Migration / sunset + +If OpenRegister later adds a shared-mailbox ingest contract to the `email` leaf +(an unattended functional-mailbox provider with subject-based auto-link), procest +MUST migrate `InboundEmailJob` onto it and retire the app-local poller. Tracked +as a follow-up against the openregister integration-registry umbrella. + +## Consequences + +- **Positive:** one narrow, justified piece of app-local code; all email + display/compose/link consumed from the leaf; no duplicate data model. +- **Negative:** procest carries IMAP connection + credential config for the + functional mailbox until OR offers the contract. diff --git a/openspec/changes/case-email-integration/design.md b/openspec/changes/case-email-integration/design.md index 90070b33..5cb01fdf 100644 --- a/openspec/changes/case-email-integration/design.md +++ b/openspec/changes/case-email-integration/design.md @@ -2,67 +2,73 @@ ## Architecture Overview -Email integration adds a correspondence layer on top of the existing case management infrastructure. Outbound mail flows through a `CaseEmailService` that resolves template variables, renders the email, sends via SMTP or Nextcloud Mail, and stores the message as an `emailMessage` object plus a PDF `caseDocument`. Inbound mail flows through an `InboundEmailJob` IMAP poller that auto-links by case number or thread headers, or queues unlinked messages for manual handling. Threading is maintained via RFC 2822 `Message-ID` / `In-Reply-To` headers stored on `emailMessage`, with thread aggregation in `emailThread` objects. +Email correspondence is surfaced on the `case` detail page by **consuming the OpenRegister `email` integration leaf** (NC Mail; id `email`, group `comms`, storage `link-table`). Per ADR-022, procest does NOT build a parallel mail client: display, compose, link, and unlink are the leaf's responsibility. Procest adds only the case-specific pieces the leaf cannot do — per-zaaktype templating, PDF/`caseDocument` archival, and unattended shared-mailbox ingest (a documented ADR-022 exception). ``` -CaseDetail.vue -├── EmailTab (new sidebar tab) -│ ├── EmailThread.vue (chronological message view per thread) -│ └── EmailComposer.vue (send/reply dialog) -└── ActivityTimeline.vue (existing, extended with email_sent/email_received events) - -CaseTypeDetail.vue -└── EmailTemplateAdmin.vue (template CRUD per case type) - -Settings (admin) -└── EmailSettings.vue (SMTP, IMAP, transport choice, test connection) - -Inbound (background) -└── InboundEmailJob (TimedJob, configurable interval/batch) - ├── Auto-link by [ZAAK-YYYY-NNNNNN] subject regex - ├── Auto-link by In-Reply-To header - └── Unlinked queue (manual link via /emails/unlinked) - -EmailPdfRetryJob (TimedJob, every 15 min) -└── Retry pdfStatus:failed → Docudesk → 3× max with 15m/1h/4h backoff +case detail page (CaseDetail.vue) +└── OR integration registry (ADR-019) / app manifest (ADR-024) + ├── email leaf sidebar tab ← NC Mail messages linked to this case (leaf-owned) + └── CnEmailCard widget ← leaf-owned, surface='single-entity'/'detail' + (link/unlink/compose handled by the leaf + NC Mail; NOT procest) + +procest extensions (leaf cannot do these): +├── EmailTemplateService → per-zaaktype templates, version-on-edit, Dutch defaults +│ prefill → opens an NC Mail draft (compose stays in NC Mail) +├── EmailArchivalService → on email-linked: Docudesk PDF → caseDocument (Archiefwet/ZGW) +│ └── EmailPdfRetryJob (TimedJob) → retry pdfStatus:failed 3× backoff +└── InboundEmailJob (TimedJob) → ADR-022 EXCEPTION: shared/functional mailbox ingest + ├── auto-link by [ZAAK-YYYY-NNNNNN] subject regex + └── records link via leaf endpoint POST /api/objects/{register}/{schema}/{id}/email + (NO procest emailMessage/emailThread store) ``` +## How the leaf is consumed + +The `email` leaf (see `openregister/openspec/changes/integration-email`) ships: + +- `EmailProvider` (DI-tagged `IntegrationProvider`, id `email`, `requiredApp: mail`, `storageStrategy: link-table`) — present only when NC Mail is installed. +- A **sidebar tab** listing linked emails by date descending with a "Link existing email" picker. The tab does NOT compose/send. +- `CnEmailCard` widget across surfaces; `referenceType: 'email'` auto-renders it inline. +- Link endpoint: `POST /api/objects/{register}/{schema}/{id}/email` with `{mailAccountId, mailMessageId}`; cached subject/sender/date populated at link time. Unlink deletes the link record only (the Mail message is untouched). +- `requiresPermission()` returns `null` — access inherits from object RBAC + per-user NC Mail access. + +Procest's job is to **register the `case` schema as a host surface** for the leaf (app manifest, ADR-024) so the tab + widget appear on the case detail page, and to consume the link endpoint from its template-prefill and shared-mailbox flows. + ## File Map ### New Backend Files | File | Purpose | |------|---------| -| `lib/Service/CaseEmailService.php` | Send outbound, process inbound, resolve template variables, RFC 2822 threading, Docudesk PDF orchestration | -| `lib/Service/EmailTemplateService.php` | Template CRUD, versioning on edit, variable catalog generation | -| `lib/Controller/CaseEmailController.php` | Authenticated API: send, list threads, list unlinked, link/discard, template CRUD | -| `lib/BackgroundJob/InboundEmailJob.php` | TimedJob: IMAP poll, auto-link, batch size limit, duplicate detection via Message-ID | +| `lib/Service/EmailTemplateService.php` | Per-zaaktype template CRUD, versioning on edit, variable catalog, Dutch defaults, NC Mail draft prefill | +| `lib/Service/EmailArchivalService.php` | On email-linked: Docudesk PDF → `caseDocument`; tracks `pdfStatus`; ZGW informatieobject mapping | +| `lib/Controller/EmailTemplateController.php` | Authenticated API: template CRUD + draft-prefill helper | +| `lib/BackgroundJob/InboundEmailJob.php` | **ADR-022 exception** — TimedJob: shared-mailbox IMAP poll, subject-regex auto-link, records link via leaf endpoint | | `lib/BackgroundJob/EmailPdfRetryJob.php` | TimedJob: retry failed Docudesk conversions up to 3× with exponential backoff | -| `lib/Settings/EmailSettings.php` | Admin settings section registration | +| `lib/Settings/EmailSettings.php` | Admin settings section: shared-mailbox IMAP + transport choice | ### New Frontend Files | File | Purpose | |------|---------| -| `src/views/cases/components/EmailTab.vue` | Case detail tab grouping messages by thread with count badge | -| `src/views/cases/components/EmailComposer.vue` | Modal: recipient/CC/BCC, subject, body (rich text), template selector, attachment picker, send confirmation | -| `src/views/cases/components/EmailThread.vue` | Chronological thread renderer (inbound left, outbound right) with inline expand | -| `src/views/casetypes/components/EmailTemplateAdmin.vue` | Template CRUD per case type with variable sidebar and live preview | -| `src/views/emails/UnlinkedQueue.vue` | Standalone view for manual linking of unlinked inbound emails | -| `src/views/settings/EmailSettings.vue` | SMTP/IMAP/transport config form with connection test | +| `src/views/casetypes/components/EmailTemplateAdmin.vue` | Per-zaaktype template CRUD with variable sidebar + live preview | +| `src/views/settings/EmailSettings.vue` | Shared-mailbox IMAP config + transport choice + test-connection | + +> **Not created** (consumed from the `email` leaf instead): `EmailComposer.vue`, `EmailThread.vue`, `EmailTab.vue`, `UnlinkedQueue.vue`. Compose lives in NC Mail; display + link + unlink come from the leaf tab + `CnEmailCard`. ### Modified Files | File | Changes | |------|---------| -| `lib/Settings/procest_register.json` | Add `emailTemplate`, `emailMessage`, `emailThread` schemas and seed objects | -| `lib/Service/SettingsService.php` | Add `email_*` config keys and SLUG_TO_CONFIG_KEY entries | -| `appinfo/routes.php` | Add email send, template, unlinked queue, and settings routes before SPA catch-all | -| `src/views/cases/CaseDetail.vue` | Add EmailTab to sidebar tabs and "Verstuur email" header action | +| `appinfo/info.xml` / app manifest | Register the `email` leaf tab + `CnEmailCard` widget on the `case` detail surface (ADR-024 manifest entry) | +| `lib/Settings/procest_register.json` | Add ONLY the `emailTemplate` schema + seed templates. Add `referenceType: 'email'` to any `case` property that points at a primary correspondence message so `CnEmailCard` auto-renders | +| `lib/Service/SettingsService.php` | Add `email_template_schema` + shared-mailbox IMAP config keys | +| `appinfo/routes.php` | Add template CRUD + shared-mailbox settings routes before SPA catch-all | +| `src/views/cases/CaseDetail.vue` | Ensure the case detail page mounts the leaf tab + widget; "Verstuur email" action opens an NC Mail draft (prefilled from a template) rather than a bespoke composer | ## Data Model -### emailTemplate Schema +### emailTemplate Schema (the ONLY new schema) **Schema.org type:** `schema:DigitalDocument` @@ -74,91 +80,61 @@ EmailPdfRetryJob (TimedJob, every 15 min) | `caseType` | string | Yes | OpenRegister reference to `caseType` object | | `variables` | array | No | Available variable names scanned from subject + body | | `version` | integer | No | Auto-incremented on edit (starts at 1) | -| `isActive` | boolean | No | Whether template is selectable in composer (default: true) | +| `isActive` | boolean | No | Whether template is selectable (default: true) | -### emailMessage Schema - -**Schema.org type:** `schema:EmailMessage` - -| Property | Type | Required | Description | -|----------|------|----------|-------------| -| `messageId` | string | Yes | RFC 2822 Message-ID (globally unique) | -| `inReplyTo` | string | No | Parent RFC 2822 Message-ID (nullable) | -| `direction` | enum | Yes | `inbound` or `outbound` | -| `from` | string | Yes | Sender email address | -| `to` | array | Yes | Primary recipient email addresses | -| `cc` | array | No | CC recipient addresses | -| `bcc` | array | No | BCC recipient addresses | -| `subject` | string | Yes | Subject line including case prefix | -| `body` | string (HTML) | Yes | Rendered body | -| `case` | string | Yes | OpenRegister reference to `case` object | -| `thread` | string | No | OpenRegister reference to `emailThread` object | -| `pdfPath` | string | No | File path of generated PDF in Nextcloud Files | -| `pdfStatus` | enum | No | `pending`, `completed`, or `failed` (Docudesk conversion state) | -| `sentAt` | datetime | Yes | Send/receive timestamp (ISO 8601) | -| `templateId` | string | No | OpenRegister reference to `emailTemplate` used | -| `templateVersion` | integer | No | Version of template at send time (snapshot) | - -### emailThread Schema - -**Schema.org type:** `schema:Conversation` - -| Property | Type | Required | Description | -|----------|------|----------|-------------| -| `subject` | string | Yes | Canonical thread subject (without RE: prefix) | -| `case` | string | Yes | OpenRegister reference to `case` object | -| `messageCount` | integer | No | Total messages in thread (auto-maintained) | -| `firstMessageAt` | datetime | No | Timestamp of first message | -| `lastMessageAt` | datetime | No | Timestamp of most recent message | +> **No `emailMessage` / `emailThread` schema.** Linked emails are held in the `email` leaf's link-table (cached subject/sender/date + `mailAccountId`/`mailMessageId`). Querying a case's emails goes through the leaf, not a procest schema. This is the ADR-022 "no parallel link table / no parallel data model" rule. ## API Design -### Authenticated Endpoints (CaseEmailController) +### Authenticated Endpoints (EmailTemplateController) | Method | Path | Description | |--------|------|-------------| -| `POST` | `/api/cases/{caseId}/emails` | Send email from case (templateId, attachments, cc/bcc optional) | -| `GET` | `/api/cases/{caseId}/emails` | List threads and messages for a case | -| `GET` | `/api/emails/unlinked` | List unlinked inbound emails for manual handling | -| `POST` | `/api/emails/unlinked/{id}/link` | Link an email to a case | -| `POST` | `/api/emails/unlinked/{id}/discard` | Mark unlinked email as discarded | | `GET` | `/api/casetypes/{caseTypeId}/email-templates` | List templates for case type | | `POST` | `/api/casetypes/{caseTypeId}/email-templates` | Create template | | `PUT` | `/api/email-templates/{templateId}` | Update template (creates new version object) | -| `GET` | `/api/settings/email` | Current email configuration | -| `PUT` | `/api/settings/email` | Save SMTP/IMAP/transport settings | -| `POST` | `/api/settings/email/test-smtp` | Send test email | +| `POST` | `/api/cases/{caseId}/email-templates/{templateId}/draft` | Resolve template variables and open an NC Mail draft (prefill only — send happens in NC Mail) | +| `GET` | `/api/settings/email` | Shared-mailbox IMAP + transport config (passwords masked) | +| `PUT` | `/api/settings/email` | Save shared-mailbox IMAP + transport config | +| `POST` | `/api/settings/email/test-imap` | Test the shared-mailbox IMAP connection | -## Security & Reliability +> **Removed vs prior draft:** `POST/GET /api/cases/{caseId}/emails`, `/api/emails/unlinked`, `.../link`, `.../discard`, `.../test-smtp`. Send/list/link/unlink are the leaf's `POST /api/objects/{register}/{schema}/{id}/email` + NC Mail. No SMTP send endpoint. -- IMAP/SMTP passwords stored via `IAppConfig` with `setSensitive(true)` — never appear in logs or audit trails -- Case-number regex anchored as `\[([A-Z]+-\d{4}-\d{6})\]`; matched against subject header only, never body -- Tenant isolation: case lookup by identifier scoped to current organization via OR's `_multitenancy` filter -- Background job catches all exceptions to prevent Nextcloud job deregistration; logs via `LoggerInterface` -- Duplicate detection: `Message-ID` indexed lookup before inserting `emailMessage` record -- PDF conversion runs async via job for messages > 5 MB; sync for smaller messages -- Poller batch size capped at 50 per run (default) and operator-configurable via `email_poll_batch_size` +### Leaf endpoints consumed (not authored here) -## Reuse Analysis +| Method | Path | Used by | +|--------|------|---------| +| `POST` | `/api/objects/{register}/{schema}/{id}/email` | `InboundEmailJob` (auto-link) + template-draft flow (link the sent draft) | +| `DELETE` | leaf unlink | NC Mail / leaf tab (user action) | -Per ADR-012, the following existing platform services are leveraged — no parallel implementations: - -| Capability needed | Existing service used | What is NOT rebuilt | -|-------------------|----------------------|---------------------| -| Object CRUD for `emailMessage`, `emailTemplate`, `emailThread` | OpenRegister `ObjectService` | No custom Mapper/Entity classes | -| PDF document generation | Docudesk integration (existing in procest) | No custom PDF renderer | -| File storage (PDFs as case documents) | `FileService` + `caseDocument` schema | No custom file upload controller | -| Audit trail for sent/received emails | OpenRegister audit trail (automatic on all OR objects) | No custom `EmailAuditService` | -| Activity feed events | OpenRegister `ActivityService` (existing in procest) | No custom activity log table | -| Admin settings persistence | `IAppConfig` + existing `SettingsService` pattern | No new database table for config | -| Background job scheduling | Nextcloud `IJobList` + `TimedJob` (existing pattern in procest) | No custom scheduler | -| RBAC — who can send email from a case | OpenRegister per-object RBAC (existing) | No custom permission service | +## Security & Reliability -New code in this change is limited to: SMTP/IMAP transport logic, RFC 2822 header parsing, template variable resolution, and the Vue composer/thread UI — all domain-specific business logic without platform equivalents. +- Shared-mailbox IMAP password stored via `IAppConfig` with `setSensitive(true)` — never appears in logs or audit trails. Per-user mail credentials stay in NC Mail. +- Case-number regex anchored as `\[([A-Z]+-\d{4}-\d{6})\]`; matched against subject header only, never body. +- Tenant isolation: case lookup by identifier scoped to current organization via OR's `_multitenancy` filter. +- `InboundEmailJob` catches all exceptions to prevent Nextcloud job deregistration; logs via `LoggerInterface`. +- Duplicate detection: before linking, the job checks the leaf link-table for the `mailMessageId` already linked to the case. +- PDF archival runs async for messages > 5 MB; sync for smaller; failures retried by `EmailPdfRetryJob`. +- Email access RBAC is inherited from the leaf (`requiresPermission()` → null; per-user NC Mail access) and OR object RBAC on the `case` — no app-local email RBAC. + +## Reuse Analysis (ADR-022 + ADR-012) + +| Capability needed | Consumed from | NOT rebuilt | +|-------------------|---------------|-------------| +| Email display on a case (tab + widget) | `email` leaf (NC Mail) — sidebar tab + `CnEmailCard` | No `EmailTab`/`EmailThread` Vue | +| Compose / send an email | NC Mail (the leaf is link-only) | No `EmailComposer`, no SMTP transport | +| Linking an email to a case | leaf `POST .../email` link-table | No `emailMessage`/`emailThread` schema, no parallel link table | +| Manual "link existing email" / unlink | leaf tab affordance | No `UnlinkedQueue.vue`, no link/discard endpoints | +| Email access control | leaf `requiresPermission()` + OR object RBAC | No app-local email RBAC | +| Object CRUD for `emailTemplate` | OpenRegister `ObjectService` | No custom Mapper/Entity | +| PDF generation | Docudesk (existing in procest) | No custom renderer | +| Audit / activity | OR audit trail on the linked `case` object | No custom audit table | + +New code is limited to: per-zaaktype templating + versioning, PDF→`caseDocument` archival, and the **shared-mailbox poller** (ADR-022 exception). All email display/compose/link comes from the leaf. ## Seed Data -Per ADR-001 (seed data requirements), the following objects MUST be included in `procest_register.json` under `components.objects[]` using the `@self` envelope. +Per ADR-001, `procest_register.json` MUST seed `emailTemplate` objects using the `@self` envelope. (No `emailMessage`/`emailThread` seeds — linked emails live in the leaf link-table, populated at runtime.) ### emailTemplate — 3 seed objects @@ -212,156 +188,3 @@ Per ADR-001 (seed data requirements), the following objects MUST be included in "isActive": true } ``` - -### emailThread — 3 seed objects - -```json -{ - "@self": { - "register": "procest", - "schema": "emailThread", - "slug": "email-thread-zaak-2026-000142" - }, - "subject": "Ontvangstbevestiging — Verbouwing woning Keizersgracht 47", - "case": "ref:case:omgevingsvergunning-keizersgracht-47", - "messageCount": 2, - "firstMessageAt": "2026-03-10T09:15:00+01:00", - "lastMessageAt": "2026-03-12T14:32:00+01:00" -} -``` - -```json -{ - "@self": { - "register": "procest", - "schema": "emailThread", - "slug": "email-thread-zaak-2026-000198" - }, - "subject": "Informatieverzoek — Uitbouw achtergevel Hoofdstraat 12", - "case": "ref:case:omgevingsvergunning-hoofdstraat-12", - "messageCount": 3, - "firstMessageAt": "2026-04-01T11:05:00+02:00", - "lastMessageAt": "2026-04-08T16:20:00+02:00" -} -``` - -```json -{ - "@self": { - "register": "procest", - "schema": "emailThread", - "slug": "email-thread-zaak-2026-000073" - }, - "subject": "Besluit op uw aanvraag — Dakkapel Tulpstraat 3", - "case": "ref:case:omgevingsvergunning-tulpstraat-3", - "messageCount": 1, - "firstMessageAt": "2026-02-28T15:00:00+01:00", - "lastMessageAt": "2026-02-28T15:00:00+01:00" -} -``` - -### emailMessage — 4 seed objects - -```json -{ - "@self": { - "register": "procest", - "schema": "emailMessage", - "slug": "email-message-out-keizersgracht-01" - }, - "messageId": "", - "inReplyTo": null, - "direction": "outbound", - "from": "zaken@gemeente-westerhaven.nl", - "to": ["j.de.vries@example.nl"], - "cc": [], - "bcc": [], - "subject": "[ZAAK-2026-000142] Ontvangstbevestiging — Verbouwing woning Keizersgracht 47", - "body": "

Geachte de heer De Vries,

Hierbij bevestigen wij de ontvangst van uw aanvraag...

", - "case": "ref:case:omgevingsvergunning-keizersgracht-47", - "thread": "ref:emailThread:email-thread-zaak-2026-000142", - "pdfPath": "/Procest/Emails/2026/03/email-out-keizersgracht-01.pdf", - "pdfStatus": "completed", - "sentAt": "2026-03-10T09:15:00+01:00", - "templateId": "ref:emailTemplate:email-template-ontvangstbevestiging", - "templateVersion": 1 -} -``` - -```json -{ - "@self": { - "register": "procest", - "schema": "emailMessage", - "slug": "email-message-in-keizersgracht-02" - }, - "messageId": "", - "inReplyTo": "", - "direction": "inbound", - "from": "j.de.vries@example.nl", - "to": ["zaken@gemeente-westerhaven.nl"], - "cc": [], - "bcc": [], - "subject": "Re: [ZAAK-2026-000142] Ontvangstbevestiging — Verbouwing woning Keizersgracht 47", - "body": "

Geachte behandelaar, hartelijk dank voor de bevestiging. Kunt u mij informeren over de doorlooptijd?

", - "case": "ref:case:omgevingsvergunning-keizersgracht-47", - "thread": "ref:emailThread:email-thread-zaak-2026-000142", - "pdfPath": "/Procest/Emails/2026/03/email-in-keizersgracht-02.pdf", - "pdfStatus": "completed", - "sentAt": "2026-03-12T14:32:00+01:00", - "templateId": null, - "templateVersion": null -} -``` - -```json -{ - "@self": { - "register": "procest", - "schema": "emailMessage", - "slug": "email-message-out-hoofdstraat-01" - }, - "messageId": "", - "inReplyTo": null, - "direction": "outbound", - "from": "zaken@gemeente-westerhaven.nl", - "to": ["m.bakker@bouwbedrijfbakker.nl"], - "cc": ["vergunningen@gemeente-westerhaven.nl"], - "bcc": [], - "subject": "[ZAAK-2026-000198] Verzoek om aanvullende informatie — Uitbouw achtergevel Hoofdstraat 12", - "body": "

Geachte mevrouw Bakker,

In het kader van uw aanvraag verzoeken wij u...

", - "case": "ref:case:omgevingsvergunning-hoofdstraat-12", - "thread": "ref:emailThread:email-thread-zaak-2026-000198", - "pdfPath": "/Procest/Emails/2026/04/email-out-hoofdstraat-01.pdf", - "pdfStatus": "completed", - "sentAt": "2026-04-01T11:05:00+02:00", - "templateId": "ref:emailTemplate:email-template-informatieverzoek", - "templateVersion": 1 -} -``` - -```json -{ - "@self": { - "register": "procest", - "schema": "emailMessage", - "slug": "email-message-out-tulpstraat-besluit" - }, - "messageId": "", - "inReplyTo": null, - "direction": "outbound", - "from": "zaken@gemeente-westerhaven.nl", - "to": ["p.smit@example.com"], - "cc": [], - "bcc": [], - "subject": "[ZAAK-2026-000073] Besluit op uw aanvraag — Dakkapel Tulpstraat 3", - "body": "

Geachte de heer Smit,

Op uw aanvraag heeft het college op 28 februari 2026 een besluit genomen...

", - "case": "ref:case:omgevingsvergunning-tulpstraat-3", - "thread": "ref:emailThread:email-thread-zaak-2026-000073", - "pdfPath": "/Procest/Emails/2026/02/email-out-tulpstraat-besluit.pdf", - "pdfStatus": "completed", - "sentAt": "2026-02-28T15:00:00+01:00", - "templateId": "ref:emailTemplate:email-template-besluit", - "templateVersion": 1 -} -``` diff --git a/openspec/changes/case-email-integration/proposal.md b/openspec/changes/case-email-integration/proposal.md index b788ca47..3b928599 100644 --- a/openspec/changes/case-email-integration/proposal.md +++ b/openspec/changes/case-email-integration/proposal.md @@ -17,54 +17,57 @@ chain: [] Email remains the primary correspondence channel between municipalities and citizens, applicants, and partner organizations. Today, that correspondence sits in personal mailboxes outside the case system, breaking the audit trail and making it impossible to reconstruct a case after the fact. ZGW and Archiefwet require case correspondence to be archived as informatieobjecten. -This change closes the gap: every sent and received email is captured, classified, threaded, and surfaced in the case detail view and the activity timeline. Email becomes a first-class case interaction rather than a side channel invisible to the system. +This change closes the gap: every relevant email is **linked to its case**, surfaced on the case detail page, and (for archival) captured as a `caseDocument` — without procest building a parallel mail client. + +## Leaf-first decision (ADR-022) + +Per **hydra ADR-022** ("integrate, don't build"), a feature that maps to an OpenRegister integration leaf MUST consume the leaf rather than build a parallel data model / UI / service. The email concept maps to the **`email` leaf** (NC Mail; id `email`, group `comms`, storage `link-table`). The leaf is **link-only** — NC Mail owns send/compose — and ships a sidebar tab + `CnEmailCard` widget that procest surfaces on the `case` detail page per **ADR-024** / **ADR-019**. + +Therefore this change **consumes the `email` leaf** for all email **display, compose, link, and unlink**, and keeps only the genuinely case-specific pieces the leaf cannot do (per-zaaktype templating, PDF/`caseDocument` archival with retention, ZGW audit mapping). One requirement — unattended ingest of a **shared functional mailbox** with case-number auto-link — is a documented ADR-022 exception (see `openspec/architecture/adr-002-shared-mailbox-poller-exception.md`). + +This **supersedes** an earlier draft of this change that specced a bespoke IMAP poller, SMTP composer, `emailMessage`/`emailThread` schemas, and a custom thread/compose UI. Those are removed here in favour of the leaf. ## What changes -1. **Three new OpenRegister schemas** added to `procest_register.json`: - - `emailTemplate` — reusable message templates per case type with `{{variable}}` placeholders and versioning - - `emailMessage` — individual sent/received messages with RFC 2822 threading metadata and Docudesk PDF status - - `emailThread` — conversation grouping object linking messages to a case +1. **Consume the `email` leaf on the `case` detail page** (no new email-display code): + - Surface the leaf's **email sidebar tab** and **`CnEmailCard` widget** on `case` objects via the app manifest (ADR-024) / integration registry (ADR-019). + - Compose/reply happens in **NC Mail**; procest only links the resulting message to the case via the leaf endpoint `POST /api/objects/{register}/{schema}/{id}/email`. + - Manual "link existing email" and unlink are the leaf's own affordances — no procest unlinked-queue UI. -2. **Backend services and background jobs**: - - `CaseEmailService` — outbound send, inbound processing, template variable resolution, RFC 2822 threading, Docudesk PDF orchestration - - `EmailTemplateService` — template CRUD with version-on-edit (no overwrite) - - `CaseEmailController` — authenticated REST API for compose, templates, and unlinked-queue endpoints - - `InboundEmailJob` — `TimedJob` IMAP poller with auto-linking by case-number regex and `In-Reply-To` header - - `EmailPdfRetryJob` — `TimedJob` retrying failed Docudesk conversions up to 3× with exponential backoff +2. **One app-local schema** (`emailTemplate`) — NOT email message storage: + - `emailTemplate` — reusable per-zaaktype message templates with `{{variable}}` placeholders and versioning. NC Mail has no per-zaaktype templating bound to case data; this is a procest extension that prefills an NC Mail draft. No `emailMessage`/`emailThread` schema (the leaf link-table holds linked emails). -3. **Frontend components** (new Vue 2 components): - - `EmailComposer.vue` — modal compose dialog with template selector, attachment picker, CC/BCC - - `EmailThread.vue` + `EmailTab.vue` — chronological thread view in case detail sidebar - - `EmailTemplateAdmin.vue` — template CRUD per case type with variable sidebar and live preview - - `UnlinkedQueue.vue` — manual linking of unmatched inbound emails - - `EmailSettings.vue` — admin SMTP/IMAP configuration with test-connection +3. **Case-specific extensions of the leaf** (not replacements): + - `EmailTemplateService` — template CRUD with version-on-edit (no overwrite) + default Dutch templates (`Ontvangstbevestiging`, `Informatieverzoek`, `Besluit`). + - **Email → PDF → `caseDocument`** archival via the existing Docudesk integration, with retry, for Archiefwet/ZGW informatieobject compliance. The leaf does not archive; this runs when an email is linked. + - ZGW audit-trail mapping of `email_linked` events. -4. **Settings extension**: `email_*` config keys added to `SettingsService`; `EmailSettings.php` registers a new admin settings section. +4. **Documented ADR-022 exception — shared-mailbox poller**: + - `InboundEmailJob` (`TimedJob`) polls a **functional/shared mailbox** (`zaken@gemeente.nl`) that has no per-user NC Mail account, auto-links by `[ZAAK-YYYY-NNNNNN]` subject regex, and records the link **through the leaf link-table**. Justified in `adr-002-shared-mailbox-poller-exception.md`. There is no bespoke message store; the poller writes via the leaf endpoint. -5. **CaseDetail integration**: new "Email" sidebar tab and "Verstuur email" header action wired into existing `CaseDetail.vue`. +5. **Settings**: a small `EmailSettings` section for the **shared-mailbox IMAP** connection + transport choice (which NC Mail account / functional mailbox is the case-correspondence source). Per-user SMTP/IMAP is NOT configured here — NC Mail owns user accounts. ## Impact -- **Entities added:** 3 new OpenRegister schemas (`emailTemplate`, `emailMessage`, `emailThread`) -- **Entities modified:** none — `case` and `caseDocument` are consumed read-only -- **API routes added:** 10 new endpoints under `/index.php/apps/procest/api/` -- **Background jobs added:** 2 (`InboundEmailJob`, `EmailPdfRetryJob`) -- **Admin settings section added:** Email (SMTP / IMAP / transport / polling) -- **No breaking changes** to existing case-management, case-types, or admin-settings surfaces +- **Entities added:** 1 new OpenRegister schema (`emailTemplate`). No `emailMessage`/`emailThread` — linked emails live in the leaf link-table. +- **Entities consumed:** `case` (leaf tab/widget host), `caseDocument` (PDF archival). +- **Leaf consumed:** `email` (NC Mail) — tab + `CnEmailCard` widget + `POST .../email` link endpoint. +- **Background jobs added:** 2 — `InboundEmailJob` (shared-mailbox ingest, ADR-022 exception), `EmailPdfRetryJob` (archival retry). +- **Admin settings section added:** shared-mailbox IMAP + transport choice only. +- **Removed vs prior draft:** `emailMessage`/`emailThread` schemas, `CaseEmailService` send/transport, `EmailComposer.vue`, `EmailThread.vue`, `EmailTab.vue`, `UnlinkedQueue.vue`, SMTP send config, send/list/link/unlink controller endpoints — all replaced by the leaf. ## Out of scope -- Real-time push notifications from IMAP IDLE (polling only in this change) -- Calendar/iCalendar handling of meeting invitations -- Encrypted email (S/MIME, PGP) -- DMARC/SPF/DKIM authentication of inbound mail (delegated to mail server) +- Per-user mailbox compose/send (NC Mail / the email leaf own this) +- A bespoke email message data model, thread UI, or compose dialog (the leaf provides display + link) +- Real-time push notifications from IMAP IDLE (polling only) +- Calendar/iCalendar, encrypted email (S/MIME, PGP), DMARC/SPF/DKIM (delegated to mail server) ## Risks | Risk | Mitigation | |------|-----------| -| IMAP credentials exposure | Stored via `IAppConfig` with `setSensitive(true)`; never logged | +| Shared-mailbox IMAP credentials exposure | Stored via `IAppConfig` with `setSensitive(true)`; never logged | | Subject-regex abuse by attacker-controlled mail | Regex anchored; case lookup scoped to current organization (tenant isolation) | -| Docudesk failures block email reception | Conversion runs async; failures set `pdfStatus: failed` and trigger retry job | -| Memory exhaustion on large attachments | Messages > 5 MB deferred to background conversion; batch size configurable and capped at 50 | +| Docudesk failures block linking | Linking via the leaf is independent of PDF; archival runs async; failures set `pdfStatus: failed` and trigger retry job | +| Re-building what the leaf already does | Reviewer gate: no `emailMessage`/`emailThread` schema, no compose/thread Vue; display + compose + link come from the `email` leaf | diff --git a/openspec/changes/case-email-integration/specs/case-email-integration/spec.md b/openspec/changes/case-email-integration/specs/case-email-integration/spec.md index b9a4f4c8..9d509179 100644 --- a/openspec/changes/case-email-integration/specs/case-email-integration/spec.md +++ b/openspec/changes/case-email-integration/specs/case-email-integration/spec.md @@ -6,23 +6,46 @@ status: proposed **Status:** proposed **Scope:** procest -**Depends on:** case-management, case-types, admin-settings, openregister (ObjectService + audit + RBAC per ADR-022), docudesk (PDF conversion) +**Depends on:** case-management, case-types, admin-settings, openregister (`email` integration leaf + ObjectService + audit + RBAC per ADR-022 / ADR-019 / ADR-024), docudesk (PDF conversion) ## ADDED Requirements ---- +### Requirement: Email display and linking on the case consume the `email` integration leaf + +Email correspondence on a `case` MUST be displayed and linked through the OpenRegister `email` integration leaf (NC Mail; provider id `email`, group `comms`, storage `link-table`), per hydra ADR-022 (integrate, don't build), ADR-019 (integration registry), and ADR-024 (app manifest). Procest MUST NOT build a parallel email message store, compose dialog, thread view, or link table. + +- The `case` schema MUST be registered as a host surface so the leaf's email sidebar tab and `CnEmailCard` widget appear on the case detail page. +- Linking an email to a case MUST use the leaf endpoint `POST /api/objects/{register}/{schema}/{id}/email` with `{mailAccountId, mailMessageId}`; unlink is the leaf's own action. +- Composing/sending an email MUST happen in NC Mail (the leaf is link-only). Procest MAY prefill an NC Mail draft from a template, but MUST NOT send mail itself. +- No `emailMessage` or `emailThread` schema, no `EmailComposer.vue`, `EmailThread.vue`, `EmailTab.vue`, or `UnlinkedQueue.vue` MAY be created. + +#### Scenario: Linked emails appear via the leaf tab on the case -### REQ-CEI-001: The system SHALL store email entities as OpenRegister objects — no parallel storage +- **GIVEN** NC Mail is installed and the `email` leaf is registered on the `case` surface +- **WHEN** a case worker opens the case detail page +- **THEN** the leaf's email sidebar tab MUST list emails linked to that case (subject, sender, date) without any procest-authored email display component -Three schemas MUST be declared in `lib/Settings/procest_register.json`: +#### Scenario: Reviewer confirms no parallel email storage or UI -- `emailTemplate` (`schema:DigitalDocument`) — reusable templates per case type with `{{variable}}` placeholders and versioning -- `emailMessage` (`schema:EmailMessage`) — individual sent/received messages with RFC 2822 threading metadata and Docudesk PDF status -- `emailThread` (`schema:Conversation`) — conversation group linking messages to a case +- **GIVEN** the procest codebase after this change +- **WHEN** scanned for `emailMessage`/`emailThread` schemas, `lib/Db/*email*`, `lib/Mapper/*Email*`, or `EmailComposer`/`EmailThread`/`EmailTab`/`UnlinkedQueue` Vue files +- **THEN** no such files SHALL exist; email display, compose, and link flow through the `email` leaf and NC Mail + +#### Scenario: Linking uses the leaf link endpoint -No custom PHP Entity, Mapper, or database table MAY be created for these entities. All storage flows through OpenRegister `ObjectService` (ADR-022 anti-pattern: no parallel storage). +- **GIVEN** an email selected for linking to case `ZAAK-2026-000142` +- **WHEN** the link is recorded +- **THEN** it MUST be persisted via `POST /api/objects/{register}/{schema}/{id}/email`, NOT a procest-local table + +--- -**emailTemplate fields** (all others from ADR-000 built-ins): +### Requirement: The system SHALL provide per-zaaktype email templates as a leaf extension + +`emailTemplate` (`schema:DigitalDocument`) MUST be declared in `lib/Settings/procest_register.json` as the ONLY new email schema. It is a procest extension because NC Mail has no per-zaaktype templating bound to case data. Templates prefill an NC Mail draft; they do NOT introduce a send path. + +No custom PHP Entity, Mapper, or database table MAY be created for `emailTemplate`; storage flows through OpenRegister `ObjectService`. + +**emailTemplate fields:** | Field | Type | Required | Description | |-------|------|----------|-------------| @@ -32,282 +55,169 @@ No custom PHP Entity, Mapper, or database table MAY be created for these entitie | `caseType` | string | Yes | OR reference to `caseType` | | `variables` | array | No | Variable names present in subject + body | | `version` | integer | No | Incremented on each edit (starts at 1) | -| `isActive` | boolean | No | Whether selectable in composer (default: true) | - -**emailMessage fields:** - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `messageId` | string | Yes | RFC 2822 globally unique identifier | -| `inReplyTo` | string | No | Parent RFC 2822 Message-ID | -| `direction` | enum | Yes | `inbound` or `outbound` | -| `from` | string | Yes | Sender address | -| `to` | array | Yes | Primary recipient addresses | -| `cc` | array | No | CC addresses | -| `bcc` | array | No | BCC addresses | -| `subject` | string | Yes | Subject including case prefix | -| `body` | string (HTML) | Yes | Rendered body | -| `case` | string | Yes | OR reference to `case` (null for unlinked inbound) | -| `thread` | string | No | OR reference to `emailThread` | -| `pdfPath` | string | No | Path of generated PDF in Nextcloud Files | -| `pdfStatus` | enum | No | `pending`, `completed`, or `failed` | -| `sentAt` | datetime | Yes | Send/receive timestamp (ISO 8601) | -| `templateId` | string | No | OR reference to `emailTemplate` used | -| `templateVersion` | integer | No | Version of template at send time (snapshot) | - -**emailThread fields:** +| `isActive` | boolean | No | Whether selectable (default: true) | -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `subject` | string | Yes | Canonical subject (RE: prefix stripped) | -| `case` | string | Yes | OR reference to `case` | -| `messageCount` | integer | No | Total messages in thread | -| `firstMessageAt` | datetime | No | Timestamp of first message | -| `lastMessageAt` | datetime | No | Timestamp of most recent message | +#### Scenario: Template schema loads without errors -#### Scenario: Schemas load without errors - -- **GIVEN** procest is installed and `procest_register.json` contains the three new schemas +- **GIVEN** procest is installed and `procest_register.json` contains the `emailTemplate` schema - **WHEN** `openregister:load-register` is executed -- **THEN** all three schemas MUST be created without validation errors and be accessible via the OR object API +- **THEN** the schema MUST be created without validation errors and be accessible via the OR object API -#### Scenario: Reviewer confirms no parallel storage +#### Scenario: No emailMessage or emailThread schema is declared -- **GIVEN** the procest codebase after this change -- **WHEN** scanned for files matching `lib/Db/*email*`, `lib/Entity/*Email*`, or `lib/Mapper/*Email*` -- **THEN** no such files SHALL exist; all email data flows through OpenRegister +- **GIVEN** `procest_register.json` after this change +- **WHEN** its schemas are enumerated +- **THEN** `emailMessage` and `emailThread` MUST NOT be present; linked emails are held in the leaf link-table --- -### REQ-CEI-002: The system SHALL send outbound email via `CaseEmailService` with template variable resolution +### Requirement: The system SHALL prefill an NC Mail draft from a template — it SHALL NOT send mail itself -`lib/Service/CaseEmailService.php` MUST implement `sendEmail(caseId, templateId, subject, body, recipients, cc, bcc, attachmentIds)`. The method MUST: +`EmailTemplateService` MUST resolve `{{variable}}` placeholders from case, contact, and caseType data and hand the rendered subject + body to NC Mail as a **draft** (via the configured Mail account). Procest MUST NOT operate an SMTP transport. -1. Resolve `{{variable}}` placeholders from case, contact, and caseType data -2. Generate a unique RFC 2822 `Message-ID` header -3. Dispatch via the configured transport (standalone SMTP or Nextcloud Mail account) -4. Store the sent message as an `emailMessage` OR object with `direction: outbound` -5. Find or create an `emailThread` object for the conversation -6. Append an `email_sent` activity entry to the case's `activity` field -7. Trigger Docudesk PDF conversion (async for messages > 5 MB, sync otherwise) +The method MUST return the list of unresolved variable names so the frontend can highlight them in red; a draft MUST NOT be created containing raw `{{...}}` tokens. -All controller methods MUST remain thin (<10 lines per ADR-003); business logic lives in the service. - -#### Scenario: Outbound email is sent and stored - -- **GIVEN** case `ZAAK-2026-000142` with contact `j.de.vries@example.nl` -- **WHEN** a handler calls `sendEmail()` with template `Ontvangstbevestiging` -- **THEN** an `emailMessage` MUST be stored with `direction: outbound` and `sentAt` set -- **AND** an `email_sent` event MUST appear in the case `activity` array with recipient address and timestamp - -#### Scenario: Template variables resolve before sending +#### Scenario: Template variables resolve before prefilling a draft - **GIVEN** template body `Geachte {{contact.salutation}}, zaaknummer {{case.identifier}}` -- **WHEN** `resolveTemplateVariables()` is called for case `ZAAK-2026-000142` +- **WHEN** the draft-prefill flow runs for case `ZAAK-2026-000142` - **THEN** the rendered body MUST contain the actual salutation and identifier — never raw `{{...}}` tokens +- **AND** the email MUST be opened as an NC Mail draft, NOT dispatched by procest #### Scenario: Unresolved variables are returned, not sent blind - **GIVEN** a template containing `{{case.nonExistentField}}` -- **WHEN** `resolveTemplateVariables()` processes it -- **THEN** the method MUST return the list of unresolved names so the frontend highlights them in red; the email MUST NOT be dispatched with raw placeholder tokens to the recipient +- **WHEN** the draft-prefill flow processes it +- **THEN** the method MUST return the list of unresolved names so the frontend highlights them; no draft with raw placeholder tokens MUST be created -#### Scenario: Composer is disabled for a final-status case +#### Scenario: Final-status case blocks the compose action - **GIVEN** a case with `isFinal: true` on its current status -- **WHEN** a handler views the email tab -- **THEN** `EmailComposer.vue` MUST be fully disabled with an explanatory message; `sendEmail()` MUST reject the call server-side as well +- **WHEN** a handler views the case detail page +- **THEN** the "Verstuur email" action MUST be disabled with an explanatory message; the draft-prefill endpoint MUST reject the call server-side as well --- -### REQ-CEI-003: The system SHALL poll inbound email and auto-link messages to cases via `InboundEmailJob` +### Requirement: The system SHALL version email templates on edit — old versions are retained, not overwritten -`lib/BackgroundJob/InboundEmailJob.php` MUST be a `TimedJob` (interval from `email_poll_interval`, default 300 s). Per run: +`EmailTemplateService::updateTemplate(templateId, data)` MUST create a **new** `emailTemplate` OR object with `version` incremented. The previous version MUST remain. Overwriting the existing object is forbidden. -1. Connect to configured IMAP server (or Nextcloud Mail account API) -2. Fetch up to `email_poll_batch_size` (default 50) unread messages from the configured folder -3. Skip messages whose `Message-ID` already exists as an `emailMessage` object (duplicate detection) -4. Auto-link by matching `\[([A-Z]+-\d{4}-\d{6})\]` in the **subject header only** against cases scoped to the current organization -5. Auto-link by matching `In-Reply-To` against existing `emailMessage.messageId` values -6. Store each message as `emailMessage` with `direction: inbound`; update or create `emailThread` -7. Move processed messages to the "Processed" IMAP folder -8. Queue unlinked messages (no case match) with `case: null` for manual handling -9. Catch all exceptions without rethrowing; log via `LoggerInterface` to prevent job deregistration - -#### Scenario: Subject-tagged inbound email auto-links +#### Scenario: Template update creates new version, old version retained -- **GIVEN** an email with subject `[ZAAK-2026-000142] Vraag over mijn vergunning` -- **WHEN** `InboundEmailJob` runs and the regex matches case `ZAAK-2026-000142` -- **THEN** an `emailMessage` MUST be stored with `case` referencing that case and `direction: inbound` -- **AND** an `email_received` event MUST appear in the case activity array +- **GIVEN** template `Ontvangstbevestiging` at `version: 1` +- **WHEN** an admin updates the body via `updateTemplate()` +- **THEN** a new `emailTemplate` with `version: 2` MUST be created +- **AND** the `version: 1` object MUST still exist with unchanged content -#### Scenario: Reply email threads via `In-Reply-To` +#### Scenario: Default Dutch templates are seeded per case type -- **GIVEN** an inbound email with `In-Reply-To: ` -- **WHEN** that Message-ID matches an existing `emailMessage.messageId` -- **THEN** the new `emailMessage` MUST reference the same `thread` as the parent message +- **GIVEN** a newly created case type with no custom templates +- **WHEN** the admin views the templates tab +- **THEN** `Ontvangstbevestiging`, `Informatieverzoek`, and `Besluit` MUST be offered -#### Scenario: Unmatched email is queued for manual handling +--- -- **GIVEN** an inbound email with no recognizable case tag and no matching `In-Reply-To` -- **WHEN** `InboundEmailJob` processes it -- **THEN** an `emailMessage` MUST be stored with `case: null` and appear in `GET /api/emails/unlinked` +### Requirement: The system SHALL ingest a shared functional mailbox and auto-link to cases — a documented ADR-022 exception -#### Scenario: Duplicate message is skipped +`lib/BackgroundJob/InboundEmailJob.php` MUST be a `TimedJob` (interval from `email_poll_interval`, default 300 s) that ingests a **shared/functional mailbox** (e.g. `zaken@gemeente.nl`) with no per-user NC Mail account owner. This is an explicit ADR-022 § Exceptions case, justified in `openspec/architecture/adr-002-shared-mailbox-poller-exception.md`, because the link-only `email` leaf inherits per-user Mail access and cannot ingest an owner-less mailbox unattended. -- **GIVEN** `emailMessage` with `messageId: ` already exists in OpenRegister -- **WHEN** `InboundEmailJob` encounters the same RFC 2822 Message-ID during polling -- **THEN** the message MUST NOT be stored again; the job MUST continue processing remaining messages +The job MUST be scoped strictly to ingest + auto-link, and MUST record every link **through the leaf link endpoint** — NOT a procest-local message store. Per run: ---- +1. Connect to the configured shared IMAP mailbox +2. Fetch up to `email_poll_batch_size` (default 50) unread messages from the configured folder +3. Skip messages already linked (check the leaf link-table for the `mailMessageId`) +4. Auto-link by matching `\[([A-Z]+-\d{4}-\d{6})\]` in the **subject header only** against cases scoped to the current organization +5. Record the link via `POST /api/objects/{register}/{schema}/{id}/email` +6. Move processed messages to the "Processed" IMAP folder +7. Leave unmatched messages in the mailbox (manual linking remains a leaf affordance — no procest queue) +8. Catch all exceptions without rethrowing; log via `LoggerInterface` -### REQ-CEI-004: The system SHALL maintain `emailThread` aggregation on every message store or update +#### Scenario: Subject-tagged inbound email auto-links via the leaf -When any `emailMessage` is stored, `CaseEmailService` MUST find or create an `emailThread` linked to the same case. The thread MUST be updated: +- **GIVEN** a shared-mailbox email with subject `[ZAAK-2026-000142] Vraag over mijn vergunning` +- **WHEN** `InboundEmailJob` runs and the regex matches case `ZAAK-2026-000142` +- **THEN** the email MUST be linked to that case via the leaf link endpoint, NOT stored in a procest `emailMessage` object -- `messageCount` incremented by 1 -- `lastMessageAt` set to the current message's `sentAt` -- `firstMessageAt` set only on thread creation +#### Scenario: Already-linked message is skipped -Thread `subject` is the canonical subject with `Re:`, `Fw:`, and case-tag prefixes stripped. +- **GIVEN** a `mailMessageId` already linked to a case in the leaf link-table +- **WHEN** `InboundEmailJob` encounters the same message during polling +- **THEN** it MUST NOT create a duplicate link; the job MUST continue processing remaining messages -#### Scenario: Thread created on first message +#### Scenario: Unmatched email is left for the leaf's manual link affordance -- **GIVEN** no thread exists for case `ZAAK-2026-000142` -- **WHEN** the first outbound email is sent -- **THEN** an `emailThread` MUST be created with `messageCount: 1` and `firstMessageAt` equal to `sentAt` +- **GIVEN** a shared-mailbox email with no recognizable case tag +- **WHEN** `InboundEmailJob` processes it +- **THEN** procest MUST NOT create an app-local unlinked queue; the message remains linkable via the leaf tab's "Link existing email" -#### Scenario: Thread count updated on reply +#### Scenario: Exception is documented per ADR-022 -- **GIVEN** a thread with `messageCount: 2` -- **WHEN** a new inbound reply is processed -- **THEN** the thread MUST update to `messageCount: 3` and `lastMessageAt` set to the new `sentAt` +- **GIVEN** this requirement ships a server-side poller +- **WHEN** a reviewer checks the ADR-022 exception discipline +- **THEN** `openspec/architecture/adr-002-shared-mailbox-poller-exception.md` MUST exist, reference ADR-022, and scope the exception to shared-mailbox ingest + auto-link only --- -### REQ-CEI-005: The system SHALL convert every email to PDF via Docudesk and store as `caseDocument` +### Requirement: The system SHALL archive linked emails as PDF `caseDocument` via Docudesk -Every `emailMessage` (inbound and outbound) MUST be converted to PDF by the existing Docudesk integration. The PDF is stored at `pdfPath` and registered as a `caseDocument` linked to the case. `pdfStatus` tracks state: `pending` → `completed` or `failed`. +When an email is linked to a case (by the shared-mailbox poller or manually via the leaf), `EmailArchivalService` MUST convert it to PDF via the existing Docudesk integration and register the PDF as a `caseDocument` linked to the case, for Archiefwet / ZGW informatieobject compliance. The leaf does not archive; this is a procest extension that reads the linked message's metadata via NC Mail. -Conversion is synchronous for messages ≤ 5 MB; asynchronous (set `pdfStatus: pending`) for larger messages. `EmailPdfRetryJob` (every 15 min) retries `pdfStatus: failed` objects up to 3× with exponential backoff (15 min, 1 h, 4 h). +`pdfStatus` tracks state: `pending` → `completed` or `failed`. Conversion is synchronous for messages ≤ 5 MB; asynchronous for larger. `EmailPdfRetryJob` (every 15 min) retries `pdfStatus: failed` up to 3× with exponential backoff (15 min, 1 h, 4 h). -#### Scenario: Docudesk failure does not block email storage or delivery +#### Scenario: Docudesk failure does not block linking - **GIVEN** Docudesk is temporarily unavailable -- **WHEN** `sendEmail()` dispatches an outbound message -- **THEN** the `emailMessage` MUST be stored with `pdfStatus: failed` -- **AND** the email MUST still be delivered to the recipient via SMTP +- **WHEN** an email is linked to a case +- **THEN** the leaf link MUST still be recorded +- **AND** the archival MUST be marked `pdfStatus: failed` and queued for retry #### Scenario: Retry job re-attempts failed conversions -- **GIVEN** three `emailMessage` objects with `pdfStatus: failed` +- **GIVEN** three archival records with `pdfStatus: failed` - **WHEN** `EmailPdfRetryJob` runs and Docudesk is available -- **THEN** all three MUST be retried; successful conversions MUST set `pdfStatus: completed` +- **THEN** all three MUST be retried; successful conversions MUST set `pdfStatus: completed` and register a `caseDocument` --- -### REQ-CEI-006: The system SHALL version email templates on edit — old versions are retained, not overwritten - -`EmailTemplateService::updateTemplate(templateId, data)` MUST create a **new** `emailTemplate` OR object with `version` incremented. The previous version MUST remain so that existing `emailMessage` objects can reference their original `templateVersion`. Overwriting the existing object is forbidden. - -#### Scenario: Template update creates new version, old version retained - -- **GIVEN** template `Ontvangstbevestiging` at `version: 1` used in 5 sent emails -- **WHEN** an admin updates the body via `updateTemplate()` -- **THEN** a new `emailTemplate` with `version: 2` MUST be created -- **AND** the `version: 1` object MUST still exist with unchanged content - -#### Scenario: Sent messages retain their template version snapshot +### Requirement: The system SHALL expose template and shared-mailbox-settings operations through a controller, before the SPA catch-all -- **GIVEN** an `emailMessage` sent with `templateVersion: 1` -- **WHEN** the template is updated to `version: 2` -- **THEN** the `emailMessage.templateVersion` MUST still read `1` - ---- - -### REQ-CEI-007: The system SHALL expose all email operations through `CaseEmailController` with routes before the SPA catch-all - -`lib/Controller/CaseEmailController.php` is an authenticated Nextcloud controller (`@NoAdminRequired` on all methods). Endpoints: +`lib/Controller/EmailTemplateController.php` is an authenticated Nextcloud controller (`@NoAdminRequired` on all methods). It MUST expose ONLY template CRUD, draft-prefill, and shared-mailbox settings — and MUST NOT expose email send/list/link/unlink (those are the leaf's). Endpoints: | Method | Path | Handler | |--------|------|---------| -| `POST` | `/api/cases/{caseId}/emails` | `sendEmail` | -| `GET` | `/api/cases/{caseId}/emails` | `listEmails` | -| `GET` | `/api/emails/unlinked` | `listUnlinked` | -| `POST` | `/api/emails/unlinked/{id}/link` | `linkEmail` | -| `POST` | `/api/emails/unlinked/{id}/discard` | `discardEmail` | | `GET` | `/api/casetypes/{caseTypeId}/email-templates` | `listTemplates` | | `POST` | `/api/casetypes/{caseTypeId}/email-templates` | `createTemplate` | | `PUT` | `/api/email-templates/{templateId}` | `updateTemplate` | +| `POST` | `/api/cases/{caseId}/email-templates/{templateId}/draft` | `prefillDraft` | | `GET` | `/api/settings/email` | `getSettings` | | `PUT` | `/api/settings/email` | `saveSettings` | -| `POST` | `/api/settings/email/test-smtp` | `testSmtp` | +| `POST` | `/api/settings/email/test-imap` | `testImap` | -All routes MUST be registered in `appinfo/routes.php` BEFORE the Vue SPA catch-all route per ADR-003. +All routes MUST be registered in `appinfo/routes.php` BEFORE the Vue SPA catch-all per ADR-003. #### Scenario: API routes resolve before SPA catch-all -- **GIVEN** `GET /index.php/apps/procest/api/cases/{caseId}/emails` is requested +- **GIVEN** `GET /index.php/apps/procest/api/casetypes/{caseTypeId}/email-templates` is requested - **WHEN** Nextcloud dispatches the request -- **THEN** it MUST be handled by `CaseEmailController::listEmails()`, not the Vue SPA fallback +- **THEN** it MUST be handled by `EmailTemplateController::listTemplates()`, not the Vue SPA fallback -#### Scenario: Unauthenticated request is rejected +#### Scenario: No bespoke email send/link endpoints are registered -- **GIVEN** an unauthenticated HTTP request -- **WHEN** `POST /api/cases/{caseId}/emails` is called -- **THEN** the response MUST be `401 Unauthorized` +- **GIVEN** `appinfo/routes.php` after this change +- **WHEN** its routes are enumerated +- **THEN** there MUST be no `POST /api/cases/{caseId}/emails`, `/api/emails/unlinked`, `.../link`, `.../discard`, or `.../test-smtp` routes; those are the leaf's responsibility --- -### REQ-CEI-008: The system SHALL provide `EmailComposer.vue`, `EmailThread.vue`, and `EmailTab.vue` in the case detail - -**`EmailComposer.vue`** — Modal compose dialog: -- Recipient pre-filled from case contact; CC and BCC fields -- Subject pre-filled with `[{case.identifier}]` prefix -- Rich-text body editor -- Template selector dropdown (from `listTemplates()`) -- Case-document attachment picker with running size counter and validation against `email_max_attachment_size` -- Send confirmation step before dispatch -- Fully disabled (non-interactive) when case status `isFinal` - -**`EmailThread.vue`** — Thread renderer: -- Messages in chronological ascending order -- Inbound left-aligned, outbound right-aligned -- Inline expand/collapse per message -- PDF download link; per-message Reply button opens `EmailComposer` pre-populated with `In-Reply-To` - -**`EmailTab.vue`** — Sidebar tab in `CaseDetail.vue`: -- Groups messages by thread, most recent thread first -- Collapsible thread groups; message-count badge on the tab header - -All components MUST import from `@conduction/nextcloud-vue`, not `@nextcloud/vue` directly (ADR-004). All user-visible strings via `t(appName, 'text')`. - -#### Scenario: `EmailComposer` disabled for final-status case - -- **GIVEN** a case with `isFinal: true` -- **WHEN** a handler opens the email tab -- **THEN** `EmailComposer` MUST be visually disabled and show a message explaining why - -#### Scenario: Thread renders messages in chronological order with direction styling - -- **GIVEN** a thread: outbound at 09:00, inbound at 14:00, outbound at 16:00 -- **WHEN** the handler views `EmailThread.vue` -- **THEN** messages MUST appear oldest-first; outbound right, inbound left - ---- +### Requirement: The system SHALL provide `EmailTemplateAdmin.vue` for per-case-type template CRUD -### REQ-CEI-009: The system SHALL provide `EmailTemplateAdmin.vue` for per-case-type template CRUD and `UnlinkedQueue.vue` for manual linking +`EmailTemplateAdmin.vue` (in `CaseTypeDetail.vue`) MUST: -**`EmailTemplateAdmin.vue`** (in `CaseTypeDetail.vue`): -- Lists templates for the current case type -- Create and edit form with subject/body fields, variable sidebar grouped by source (case/contact/caseType) -- Live preview panel with unresolved variables highlighted in red +- List templates for the current case type +- Provide a create/edit form with subject/body fields and a variable sidebar grouped by source (case/contact/caseType) with click-to-insert +- Show a live preview with unresolved variables highlighted in red -**`UnlinkedQueue.vue`** (standalone view): -- Lists `emailMessage` objects with `case: null` -- Per-row: sender, subject, received timestamp, body preview (≤ 200 chars) -- Search-and-link UI (search case by identifier or title), link on click -- Discard action with confirmation dialog (uses `NcDialog`, not `window.confirm()`) +It MUST import from `@conduction/nextcloud-vue` (ADR-004) and route all user-visible strings via `t(appName, 'text')`. No bespoke compose/thread/queue components are introduced. #### Scenario: Unresolved variable highlighted in live preview @@ -315,51 +225,50 @@ All components MUST import from `@conduction/nextcloud-vue`, not `@nextcloud/vue - **WHEN** the admin views the live preview in `EmailTemplateAdmin.vue` - **THEN** the placeholder MUST be rendered with a red background highlight and a warning listing unresolved names -#### Scenario: Manually linked email removed from queue +#### Scenario: Composer is the leaf / NC Mail, not a procest component -- **GIVEN** an unlinked email in `UnlinkedQueue.vue` -- **WHEN** the handler links it to case `ZAAK-2026-000142` -- **THEN** the email MUST be removed from the queue view and appear in that case's email tab +- **GIVEN** a handler clicks "Verstuur email" on a case +- **WHEN** the compose flow opens +- **THEN** it MUST open an NC Mail draft (optionally prefilled from a template), NOT a procest-authored `EmailComposer.vue` --- -### REQ-CEI-010: The system SHALL provide admin email settings with SMTP/IMAP configuration and a test-connection action +### Requirement: The system SHALL provide admin settings for the shared mailbox only -`lib/Settings/EmailSettings.php` registers a Nextcloud admin settings section. `src/views/settings/EmailSettings.vue` renders: +`lib/Settings/EmailSettings.php` registers a Nextcloud admin settings section. `src/views/settings/EmailSettings.vue` MUST render ONLY: -- **SMTP**: host, port, encryption (none/starttls/ssl), username, password (masked), from-address -- **IMAP**: host, port, encryption, username, password (masked), folder (default: INBOX) -- **Transport selector**: standalone SMTP or Nextcloud Mail account picker -- **"Send test email" button** calling `POST /api/settings/email/test-smtp`; shows success or specific error +- **Shared-mailbox IMAP**: host, port, encryption, username, password (masked), folder (default: INBOX) +- **Transport / source selector**: which NC Mail account or functional mailbox is the case-correspondence source +- **"Test connection" button** calling `POST /api/settings/email/test-imap` -All password fields stored via `IAppConfig` with `setSensitive(true)`. Passwords MUST NOT appear in API responses in plaintext (return `***` placeholder after save). Layout follows ADR-004 admin pattern: `CnVersionInfoCard` first, then `CnSettingsSection` per feature group. +Per-user SMTP/IMAP is NOT configured here — NC Mail owns user accounts. The shared-mailbox password is stored via `IAppConfig` with `setSensitive(true)` and MUST NOT appear in API responses in plaintext (return `***`). Layout follows ADR-004: `CnVersionInfoCard` first, then `CnSettingsSection`. -#### Scenario: Saved SMTP password not returned in plaintext +#### Scenario: Saved shared-mailbox password not returned in plaintext -- **GIVEN** an admin saves SMTP credentials +- **GIVEN** an admin saves shared-mailbox IMAP credentials - **WHEN** `GET /api/settings/email` is called -- **THEN** the response MUST contain `"smtp_password": "***"`, not the actual password +- **THEN** the response MUST contain `"imap_password": "***"`, not the actual password -#### Scenario: Test-email button returns specific error on misconfiguration +#### Scenario: No per-user SMTP send configuration is exposed -- **GIVEN** an invalid SMTP host is configured -- **WHEN** the admin clicks "Send test email" -- **THEN** `POST /api/settings/email/test-smtp` MUST return a non-2xx response with an error describing the failure (hostname not found / connection refused / authentication failed) +- **GIVEN** the admin email settings page +- **WHEN** the admin views the form +- **THEN** there MUST be no SMTP-send credential fields; outbound mail is sent via NC Mail --- -### REQ-CEI-011: The system SHALL include seed data for all three new schemas in `procest_register.json` +### Requirement: The system SHALL include seed data for the `emailTemplate` schema in `procest_register.json` -Per ADR-001 seed data requirements, `procest_register.json` MUST include realistic seed objects using the `@self` envelope (3 `emailTemplate`, 3 `emailThread`, 4 `emailMessage` objects as defined in `design.md`). Dutch realistic values required. Seed loading MUST be idempotent — re-import with `force: false` MUST NOT create duplicates; objects matched by slug. +Per ADR-001, `procest_register.json` MUST include realistic seed `emailTemplate` objects using the `@self` envelope (3 templates: `Ontvangstbevestiging`, `Informatieverzoek`, `Besluit` as defined in `design.md`). No `emailMessage`/`emailThread` seeds — linked emails live in the leaf link-table, populated at runtime. Seed loading MUST be idempotent — slug-matched objects are not duplicated. -#### Scenario: Seed data loads idempotently +#### Scenario: Seed templates load idempotently - **GIVEN** `openregister:load-register` has already run once - **WHEN** it runs again with `force: false` -- **THEN** no duplicate `emailTemplate`, `emailMessage`, or `emailThread` objects MUST be created; slug-matched objects are skipped +- **THEN** no duplicate `emailTemplate` objects MUST be created; slug-matched objects are skipped -#### Scenario: Seed templates appear in composer dropdown +#### Scenario: Seed templates appear in the draft-prefill selector - **GIVEN** the seed data is loaded and a case of the matching `caseType` is open -- **WHEN** a handler opens `EmailComposer.vue` and clicks the template selector -- **THEN** `Ontvangstbevestiging`, `Informatieverzoek`, and `Besluit` MUST appear in the dropdown +- **WHEN** a handler opens the template selector to prefill a draft +- **THEN** `Ontvangstbevestiging`, `Informatieverzoek`, and `Besluit` MUST appear diff --git a/openspec/changes/case-email-integration/tasks.md b/openspec/changes/case-email-integration/tasks.md index 12d25e1f..c8e27b65 100644 --- a/openspec/changes/case-email-integration/tasks.md +++ b/openspec/changes/case-email-integration/tasks.md @@ -2,161 +2,140 @@ ## Deduplication Check -- [ ] **D01**: Verify no existing procest service or OpenRegister platform capability duplicates any logic introduced in this change. - - Scan `openspec/specs/` for any existing email-related spec (none found — no prior email integration exists in procest). - - Confirm `ObjectService` (OpenRegister) is reused for CRUD on `emailTemplate`, `emailMessage`, `emailThread` — no custom Mapper/Entity classes. - - Confirm Docudesk integration is the existing PDF generation path — no custom renderer is introduced. - - Confirm `FileService` + `caseDocument` schema handle PDF file storage — no custom file upload controller. - - Confirm `ActivityService` (OpenRegister, existing in procest) handles `email_sent`/`email_received` activity entries — no custom audit table. - - Confirm `IAppConfig` + existing `SettingsService` pattern handles config persistence — no new DB table for email settings. - - Confirm `IJobList` + `TimedJob` (existing Nextcloud pattern, already used in procest) handles background job scheduling. - - findings: All capabilities reused from platform. New code limited to SMTP/IMAP transport, RFC 2822 parsing, template variable resolution, and Vue composer/thread UI — no platform equivalents exist. +- [ ] **D01**: Confirm leaf-first compliance per ADR-022 — email display/compose/link map to the `email` integration leaf and are NOT rebuilt in procest. + - Confirm the `email` leaf (NC Mail; id `email`, group `comms`, storage `link-table`) provides: sidebar tab, `CnEmailCard` widget, and link endpoint `POST /api/objects/{register}/{schema}/{id}/email`. + - Confirm compose/send is owned by NC Mail (leaf is link-only) — procest builds no `EmailComposer`, SMTP transport, or send endpoint. + - Confirm NO `emailMessage`/`emailThread` schema, no parallel link table, no `EmailThread`/`EmailTab`/`UnlinkedQueue` Vue. + - Confirm the only new schema is `emailTemplate` (per-zaaktype templating — no leaf equivalent). + - Confirm the shared-mailbox poller is documented as an ADR-022 exception (clause 1, owner-less functional mailbox), scoped to ingest + auto-link, recording links via the leaf endpoint. + - findings: leaf consumed for display/compose/link; procest adds only templating, PDF archival, and the documented shared-mailbox poller. ## Implementation Tasks +### Leaf consumption (ADR-022 / ADR-019 / ADR-024) + +- [ ] **T01**: Register the `case` schema as a host surface for the `email` leaf so its sidebar tab + `CnEmailCard` widget render on the case detail page. + - Add the manifest entry (ADR-024) / integration-registry wiring (ADR-019) that surfaces provider id `email` on `case` objects. + - Where a `case` property points at a primary correspondence message, set `referenceType: 'email'` in `procest_register.json` so `CnEmailCard` auto-renders inline (ADR per leaf spec). + - Verify the tab + widget appear only when NC Mail is installed (leaf hides when `mail` app missing). + - spec_ref: REQ — leaf display/linking + ### Schema & Configuration -- [ ] **T01**: Add `emailTemplate`, `emailMessage`, `emailThread` schemas to `lib/Settings/procest_register.json`. - - Fields per `design.md` data model tables; Schema.org annotations `schema:DigitalDocument`, `schema:EmailMessage`, `schema:Conversation`. - - Add config keys to `SettingsService.php` `CONFIG_KEYS` and `SLUG_TO_CONFIG_KEY`: - `email_template_schema`, `email_message_schema`, `email_thread_schema`, - `email_smtp_host`, `email_smtp_port`, `email_smtp_encryption`, `email_smtp_username`, `email_smtp_password`, - `email_from_address`, `email_imap_host`, `email_imap_port`, `email_imap_folder`, - `email_transport`, `email_poll_interval`, `email_poll_batch_size`, `email_max_attachment_size`. - - spec_ref: REQ-CEI-001 - -- [ ] **T02**: Add seed data for all three schemas to `procest_register.json` using `@self` envelope with Dutch realistic values. - - 3 `emailTemplate` objects: `Ontvangstbevestiging`, `Informatieverzoek`, `Besluit` (slugs from `design.md`). - - 3 `emailThread` objects with Dutch case references (slugs from `design.md`). - - 4 `emailMessage` objects with outbound/inbound mix (slugs from `design.md`). - - Idempotency: existing objects matched by slug, not duplicated on re-import. - - spec_ref: REQ-CEI-011 +- [ ] **T02**: Add ONLY the `emailTemplate` schema to `lib/Settings/procest_register.json` (Schema.org `schema:DigitalDocument`), fields per `design.md`. + - Do NOT add `emailMessage` or `emailThread` schemas. + - Add config keys to `SettingsService.php` `CONFIG_KEYS` / `SLUG_TO_CONFIG_KEY`: `email_template_schema`, plus shared-mailbox keys `email_imap_host`, `email_imap_port`, `email_imap_encryption`, `email_imap_username`, `email_imap_password`, `email_imap_folder`, `email_transport`, `email_poll_interval`, `email_poll_batch_size`, `email_max_attachment_size`. + - Do NOT add `email_smtp_*` send keys — NC Mail owns send. + - spec_ref: REQ — emailTemplate schema -### Backend Services +- [ ] **T03**: Add 3 `emailTemplate` seed objects (`Ontvangstbevestiging`, `Informatieverzoek`, `Besluit`) via `@self` envelope with Dutch values; idempotent by slug. + - No `emailMessage`/`emailThread` seeds. + - spec_ref: REQ — seed data -- [ ] **T03**: Create `lib/Service/CaseEmailService.php`. - - `sendEmail(caseId, templateId, subject, body, recipients, cc, bcc, attachmentIds)`: resolve template variables → generate RFC 2822 `Message-ID` → dispatch via transport → store `emailMessage` → find/create `emailThread` → append `email_sent` activity → trigger PDF conversion. - - `processInboundEmail(rawMessage)`: parse headers → auto-link by `\[([A-Z]+-\d{4}-\d{6})\]` subject regex (subject header only, scoped to organization) → auto-link by `In-Reply-To` → store `emailMessage` + update/create `emailThread` → queue unlinked with `case: null`. - - `resolveTemplateVariables(template, case)`: returns rendered subject + body; lists unresolved variable names. - - `linkUnlinkedEmail(emailId, caseId)`: updates `emailMessage.case` reference. - - `discardUnlinkedEmail(emailId, reason)`: marks message as discarded. - - Uses OpenRegister `ObjectService`; Docudesk for PDF; `@spec openspec/changes/case-email-integration/tasks.md#T03` PHPDoc tag. - - spec_ref: REQ-CEI-002, REQ-CEI-003, REQ-CEI-004 +### Backend Services - [ ] **T04**: Create `lib/Service/EmailTemplateService.php`. - `createTemplate(caseTypeId, data)`: saves with `version: 1`. - `updateTemplate(templateId, data)`: creates a NEW object with `version + 1` — NEVER overwrites. - `listTemplates(caseTypeId)`: returns `isActive: true` templates for case type. - - `getAvailableVariables(caseTypeId)`: returns variable catalog grouped by source (case/contact/caseType). - - `seedDefaultTemplates(caseTypeId)`: creates `Ontvangstbevestiging`, `Informatieverzoek`, `Besluit` if absent. - - spec_ref: REQ-CEI-006 + - `getAvailableVariables(caseTypeId)`: variable catalog grouped by source (case/contact/caseType). + - `prefillDraft(caseId, templateId)`: resolves `{{variable}}` placeholders from case/contact/caseType data, returns rendered subject+body + list of unresolved names, and opens an NC Mail draft via the configured Mail account. MUST NOT send mail. MUST reject when the case status `isFinal`. + - `seedDefaultTemplates(caseTypeId)`: creates the three Dutch defaults if absent. + - Uses OpenRegister `ObjectService`. `@spec openspec/changes/case-email-integration/tasks.md#T04` PHPDoc tag. + - spec_ref: REQ — draft prefill, REQ — versioning + +- [ ] **T05**: Create `lib/Service/EmailArchivalService.php`. + - On email-linked (poller or manual leaf link), read the linked message metadata via NC Mail, convert to PDF via Docudesk, register the PDF as a `caseDocument` linked to the case (ZGW informatieobject). + - Track `pdfStatus` (`pending`/`completed`/`failed`); sync for ≤ 5 MB, async otherwise. + - Map an `email_linked` event into the case audit trail (OR audit on the `case` object — no app-local audit table). + - spec_ref: REQ — PDF archival ### Controllers & Routes -- [ ] **T05**: Create `lib/Controller/CaseEmailController.php`. - - `@NoAdminRequired` on all methods. All methods thin (<10 lines per ADR-003); delegate to services. - - Methods: `sendEmail`, `listEmails`, `listUnlinked`, `linkEmail`, `discardEmail`, `listTemplates`, `createTemplate`, `updateTemplate`, `getSettings`, `saveSettings`, `testSmtp`. - - Returns `JSONResponse`; `saveSettings` stores SMTP/IMAP passwords as sensitive `IAppConfig` keys; `getSettings` masks passwords with `***`. - - spec_ref: REQ-CEI-007, REQ-CEI-010 - -- [ ] **T06**: Add routes to `appinfo/routes.php` BEFORE the SPA catch-all. - - `POST /api/cases/{caseId}/emails` - - `GET /api/cases/{caseId}/emails` - - `GET /api/emails/unlinked` - - `POST /api/emails/unlinked/{id}/link` - - `POST /api/emails/unlinked/{id}/discard` +- [ ] **T06**: Create `lib/Controller/EmailTemplateController.php`. + - `@NoAdminRequired` on all methods; thin (<10 lines per ADR-003); delegate to services. + - Methods: `listTemplates`, `createTemplate`, `updateTemplate`, `prefillDraft`, `getSettings`, `saveSettings`, `testImap`. + - NO `sendEmail`/`listEmails`/`linkEmail`/`discardEmail`/`testSmtp` — those are the leaf's / NC Mail's. + - `saveSettings` stores the shared-mailbox IMAP password as a sensitive `IAppConfig` key; `getSettings` masks with `***`. + - spec_ref: REQ — controller, REQ — settings + +- [ ] **T07**: Add routes to `appinfo/routes.php` BEFORE the SPA catch-all. - `GET /api/casetypes/{caseTypeId}/email-templates` - `POST /api/casetypes/{caseTypeId}/email-templates` - `PUT /api/email-templates/{templateId}` + - `POST /api/cases/{caseId}/email-templates/{templateId}/draft` - `GET /api/settings/email` - `PUT /api/settings/email` - - `POST /api/settings/email/test-smtp` - - spec_ref: REQ-CEI-007 + - `POST /api/settings/email/test-imap` + - Do NOT add email send/list/link/discard/test-smtp routes. + - spec_ref: REQ — controller -### Background Jobs +### Background Jobs (shared-mailbox poller is an ADR-022 exception) -- [ ] **T07**: Create `lib/BackgroundJob/InboundEmailJob.php`. +- [ ] **T08**: Create `lib/BackgroundJob/InboundEmailJob.php` (ADR-022 exception — see `openspec/architecture/adr-002-shared-mailbox-poller-exception.md`). - `TimedJob` with interval from `email_poll_interval` (default 300 s). - - Connects to IMAP via `imap_open()` or Nextcloud Mail account API. + - Connects to the configured SHARED/functional IMAP mailbox only. - Fetches ≤ `email_poll_batch_size` (default 50) unread messages per run. - - Skips messages whose `messageId` already exists (duplicate detection before any other processing). - - Delegates to `CaseEmailService::processInboundEmail()` per message. - - Moves processed messages to "Processed" IMAP folder; catches + logs all exceptions without rethrowing. - - spec_ref: REQ-CEI-003 - -- [ ] **T08**: Create `lib/BackgroundJob/EmailPdfRetryJob.php`. - - `TimedJob` running every 15 min. - - Finds `emailMessage` objects with `pdfStatus: failed`; retries Docudesk conversion. - - Exponential backoff: retry 1 after 15 min, retry 2 after 1 h, retry 3 after 4 h. After 3 failures, leaves `pdfStatus: failed` for operator investigation. - - Register both jobs via `IJobList` in `Application::register()` or `appinfo/info.xml` background-jobs section. - - spec_ref: REQ-CEI-005 + - Skips messages already linked (check the leaf link-table for `mailMessageId`). + - Auto-links by `\[([A-Z]+-\d{4}-\d{6})\]` subject regex (subject header only, scoped to organization) and records the link via the leaf endpoint `POST /api/objects/{register}/{schema}/{id}/email`. + - Triggers `EmailArchivalService` for the newly linked message. + - Moves processed messages to a "Processed" IMAP folder; leaves unmatched in the mailbox (manual link stays a leaf affordance — no procest queue). + - Catches + logs all exceptions without rethrowing. + - spec_ref: REQ — shared-mailbox ingest + +- [ ] **T09**: Create `lib/BackgroundJob/EmailPdfRetryJob.php`. + - `TimedJob` every 15 min; retries archival records with `pdfStatus: failed`. + - Exponential backoff (15 min, 1 h, 4 h); after 3 failures leaves `failed` for operator investigation. + - Register both jobs via `IJobList` in `Application::register()` or `appinfo/info.xml`. + - spec_ref: REQ — PDF archival ### Settings & Admin -- [ ] **T09**: Create `lib/Settings/EmailSettings.php` and `src/views/settings/EmailSettings.vue`. - - `EmailSettings.php`: registers admin settings section in Nextcloud. - - `EmailSettings.vue`: SMTP fields (host/port/encryption/username/password/from-address), IMAP fields (host/port/encryption/username/password/folder), transport selector (Nextcloud Mail account picker or standalone), "Send test email" button. - - Layout: `CnVersionInfoCard` first, then `CnSettingsSection` per group (per ADR-004 admin pattern). - - Passwords masked in UI and in `GET /api/settings/email` response (`***`); stored as sensitive `IAppConfig` keys. - - Register settings section in `appinfo/info.xml`. - - spec_ref: REQ-CEI-010 +- [ ] **T10**: Create `lib/Settings/EmailSettings.php` and `src/views/settings/EmailSettings.vue`. + - `EmailSettings.php`: registers the admin settings section. + - `EmailSettings.vue`: SHARED-mailbox IMAP fields (host/port/encryption/username/password/folder) + transport/source selector (which NC Mail account / functional mailbox) + "Test connection" button → `POST /api/settings/email/test-imap`. + - NO per-user SMTP-send fields. + - Layout: `CnVersionInfoCard` then `CnSettingsSection` (ADR-004). Password masked in UI + API (`***`); stored sensitive. + - spec_ref: REQ — settings ### Frontend Components -- [ ] **T10**: Create `src/views/cases/components/EmailComposer.vue`. - - Modal compose dialog; recipient pre-filled from case contact; CC/BCC; subject pre-filled with `[{identifier}]` prefix. - - Rich-text body editor; template selector dropdown; attachment picker from case `caseDocument` objects. - - Running attachment size display; validation against `email_max_attachment_size`. - - Send confirmation step; entire dialog disabled when `isFinal`. - - Import from `@conduction/nextcloud-vue` only; all strings via `t(appName, ...)`. - - spec_ref: REQ-CEI-008 - -- [ ] **T11**: Create `src/views/cases/components/EmailThread.vue` and `src/views/cases/components/EmailTab.vue`. - - `EmailThread.vue`: chronological render (oldest first); inbound left-aligned, outbound right; inline expand/collapse; PDF download link; Reply button opens `EmailComposer` pre-populated with `In-Reply-To`. - - `EmailTab.vue`: groups messages by thread (most recent first); collapsible groups; message-count badge in tab header. - - spec_ref: REQ-CEI-008 - -- [ ] **T12**: Create `src/views/casetypes/components/EmailTemplateAdmin.vue` and `src/views/emails/UnlinkedQueue.vue`. - - `EmailTemplateAdmin.vue`: template list per case type; create/edit form with subject/body; variable sidebar grouped by source (case/contact/caseType) with click-to-insert; live preview with red-highlighted unresolved variables. - - `UnlinkedQueue.vue`: lists `emailMessage` with `case: null`; per-row: sender/subject/timestamp/body preview; search-and-link UI; discard action uses `NcDialog` (not `window.confirm()`). - - spec_ref: REQ-CEI-009 - -- [ ] **T13**: Update `src/views/cases/CaseDetail.vue`. - - Add `EmailTab` to the sidebar tabs in `sidebarProps`. - - Add "Verstuur email" header action that opens `EmailComposer` (disabled when `isFinal`). - - Subscribe to email events to refresh `ActivityTimeline` after send. - - spec_ref: REQ-CEI-008 +- [ ] **T11**: Create `src/views/casetypes/components/EmailTemplateAdmin.vue`. + - Template list per case type; create/edit form (subject/body); variable sidebar grouped by source (case/contact/caseType) with click-to-insert; live preview with red-highlighted unresolved variables. + - Import from `@conduction/nextcloud-vue`; strings via `t(appName, ...)`. + - Do NOT create `EmailComposer.vue`, `EmailThread.vue`, `EmailTab.vue`, or `UnlinkedQueue.vue` — display/compose/link come from the leaf + NC Mail. + - spec_ref: REQ — template admin + +- [ ] **T12**: Update `src/views/cases/CaseDetail.vue`. + - Ensure the case detail page mounts the `email` leaf tab + `CnEmailCard` widget (via the manifest/registry wiring from T01). + - Add a "Verstuur email" header action that opens an NC Mail draft prefilled from a template (calls `prefillDraft`), disabled when `isFinal`. It MUST NOT open a procest composer. + - spec_ref: REQ — leaf display, REQ — draft prefill ## Verification Tasks -- [ ] **V01**: Schemas and routes load correctly. - - `procest_register.json` valid JSON; `openregister:load-register` succeeds with no validation errors. - - All 11 routes resolve under `/index.php/apps/procest/api/` before the SPA catch-all. - - spec_ref: REQ-CEI-001, REQ-CEI-007 +- [ ] **V01**: Leaf-first compliance. + - Codebase contains NO `emailMessage`/`emailThread` schema, no `lib/Db/*email*`/`lib/Mapper/*Email*`, no `EmailComposer`/`EmailThread`/`EmailTab`/`UnlinkedQueue` Vue, no `email_smtp_*` send config, no send/link/discard routes. + - The `email` leaf tab + `CnEmailCard` render on the case detail page when NC Mail is installed. + - spec_ref: REQ — leaf display/linking -- [ ] **V02**: Outbound email end-to-end. - - Send produces a stored `emailMessage` with `direction: outbound` and `sentAt` set. - - PDF generated and `pdfStatus: completed`; `email_sent` appears in case activity timeline. - - Docudesk failure: `pdfStatus: failed`; retry job re-attempts up to 3×. - - spec_ref: REQ-CEI-002, REQ-CEI-005 +- [ ] **V02**: Template prefill + versioning. + - `prefillDraft` resolves variables and opens an NC Mail draft; unresolved variables are returned and highlighted; no draft created with raw tokens; rejected when `isFinal`. + - Template edit creates a new version object; old version retained. + - spec_ref: REQ — draft prefill, REQ — versioning -- [ ] **V03**: Inbound email and threading. - - Subject-tagged inbound `[ZAAK-2026-000142]` auto-links to the matching case. - - `In-Reply-To` inbound appended to the existing thread; `messageCount` incremented. - - Unrecognised mail surfaces in `GET /api/emails/unlinked`; manual link removes it from the queue. - - spec_ref: REQ-CEI-003, REQ-CEI-004 +- [ ] **V03**: Shared-mailbox ingest (ADR-022 exception). + - `adr-002-shared-mailbox-poller-exception.md` exists, references ADR-022, scopes the exception to shared-mailbox ingest + auto-link. + - Subject-tagged `[ZAAK-2026-000142]` shared-mailbox email auto-links to the matching case via the leaf endpoint; an already-linked `mailMessageId` is skipped; unmatched mail leaves no procest queue. + - spec_ref: REQ — shared-mailbox ingest -- [ ] **V04**: Template versioning and variable resolution. - - Template variables resolve from case data; unresolved variables highlighted red in live preview. - - Template edit creates a new version object; previously sent `emailMessage` objects retain original `templateVersion`. - - spec_ref: REQ-CEI-006 +- [ ] **V04**: PDF archival. + - Linking an email produces a `caseDocument` PDF via Docudesk; Docudesk failure does not block the link and sets `pdfStatus: failed`; `EmailPdfRetryJob` re-attempts up to 3×. + - spec_ref: REQ — PDF archival - [ ] **V05**: Seed data idempotency. - - Run `openregister:load-register` twice; confirm no duplicate `emailTemplate`, `emailMessage`, or `emailThread` objects are created. - - Seed templates appear in `EmailComposer` dropdown for the matching case type. - - spec_ref: REQ-CEI-011 - -- [ ] **V06**: Admin settings security. - - SMTP/IMAP passwords stored as sensitive `IAppConfig` keys; `GET /api/settings/email` returns `***` not plaintext. - - "Send test email" returns a descriptive error on misconfiguration. - - spec_ref: REQ-CEI-010 + - Run `openregister:load-register` twice; no duplicate `emailTemplate` objects; seeds appear in the prefill selector for the matching case type. + - spec_ref: REQ — seed data + +- [ ] **V06**: Settings security. + - Shared-mailbox IMAP password stored sensitive; `GET /api/settings/email` returns `***`; no SMTP-send fields present; "Test connection" returns a descriptive error on misconfiguration. + - spec_ref: REQ — settings