feat: Implement admin-only specialties management interface
Playwright Tests / test (push) Canceled after 0s
Playwright Tests / test (push) Canceled after 0s
- Added MailerAttachmentPolicy for validating email attachments against server rules. - Introduced fis.specialties.js for managing hidden maintenance actions via an admin interface. - Created fis.specialties.scss for styling the specialties management UI. - Added German localization for specialties management in fis.specialties_txt_de.js. - Developed CSS for specialties management in fis.specialties.css. - Implemented server-side stored procedure fds__admin_backupAndClearInvoiceFile for backing up and clearing invoice files. - Created fds__invoice_file_backups table to store backup records of invoice files.
This commit is contained in:
@@ -196,8 +196,8 @@ Cookie-based authentication (`CookieAuthenticationDefaults`) with custom claims
|
||||
|
||||
> **Status: DONE.** The services below are implemented and DI-registered in
|
||||
> `Program.cs`. The original extraction rationale is retained for reference /
|
||||
> history. `FuchsFdsEmail` → `IComService` (ProcessWeb Mailer API, inline
|
||||
> base64 attachments), `FuchsWidgets` → `IWidgetService`, `FuchsPdf` →
|
||||
> history. `FuchsFdsEmail` → `IComService` (ProcessWeb Mailer API, multipart
|
||||
> attachments — see `Docs/Concepts/processweb-mailer-interface.md`), `FuchsWidgets` → `IWidgetService`, `FuchsPdf` →
|
||||
> `IPdfService`, `Banking` → `IBankingService` (now MT940 **and** CAMT),
|
||||
> `FdsInvoiceData`/`FdsReminderData` → `IInvoiceService`/`IReminderService`
|
||||
> (data classes are now pure POCOs), `FuchsReports` → `IReportService`
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-09-17
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.Specialties.cs"
|
||||
- "Fuchs/js/intranet/modules/fis.specialties*"
|
||||
- "Fuchs/js/intranet/fis_main_menu.js"
|
||||
- "Fuchs/js/intranet/fis_main_go.js"
|
||||
relatedDecisions:
|
||||
- "0027-admin-invoice-file-backup-and-clear.md"
|
||||
---
|
||||
|
||||
# Admin "Specialties" page (`/specialties`)
|
||||
|
||||
## Summary
|
||||
|
||||
An admin-only page that surfaces the otherwise hidden maintenance actions as
|
||||
cards, each a small form. It exists so support does not need SSMS or a
|
||||
hand-crafted POST for actions like resetting an invoice's stored PDF. It is
|
||||
a regular lazily loaded frontend module (`fn = specialties`), gated like the
|
||||
rest of the Administration menu (`fds_sys > 4`), and reachable both from the
|
||||
menu (Administration → Spezialfunktionen) and by opening the URL
|
||||
`/specialties` directly.
|
||||
|
||||
## How it works
|
||||
|
||||
- **Module load** follows the standard contract (see
|
||||
`admin-system-status.md`): the menu item `init:specialties` →
|
||||
`$ocms.init('specialties')` → `POST /do/specialties/auth` → when
|
||||
`manage > 0`, import `web/fis.specialties.de.js` + `fis.specialties.css`
|
||||
and call `init2`. `IntranetController.Specialties.cs` answers only that
|
||||
auth probe (same `GetSystemAdminAuthAsync` gate as the Admin module) and
|
||||
401s everything else.
|
||||
- **Deep link.** `IntranetController.Index` renders the same SPA shell for
|
||||
every `/{fn}` URL and ignores `fn`; there is no client router. `fis_main_go.js`
|
||||
therefore calls `$fis.openModuleFromPath()` once the admin menu has settled:
|
||||
it takes the first path segment and calls `$ocms.init(fn)` **only if the
|
||||
user's menu offers an `init:<fn>` item** (admin dropdown included). A
|
||||
non-admin opening `/specialties` sees the dashboard, as before; arbitrary
|
||||
paths never trigger a module load.
|
||||
- **Cards.** `fis.specialties.js` keeps an array `cards` of builder
|
||||
functions. Each card is `title + description + <form> + result box`;
|
||||
`$spc.submit(c, { button, confirm, run, report })` wires HTML5 validation,
|
||||
a `confirm()`, the `postXT` call (`run(values)` returns the request) and
|
||||
the inline result (`report(response)` → `{ ok, lines }`). The card calls
|
||||
the action's **real** endpoint — every such endpoint enforces the admin
|
||||
gate itself; the page adds no authority.
|
||||
- **Current cards**
|
||||
- *Rechnungsdatei zurücksetzen (Soft-Delete)* → `POST
|
||||
/do/admin/backupclearinvoicefile/<internal Id>` (ADR 0027). Shows the
|
||||
outcome and the blob backup URI, or the German text for the service's
|
||||
`reason` (`invoice_not_found`, `not_final`, `external`, `no_file`,
|
||||
`db_error`). An invoice without a stored file (`hasFile` from
|
||||
`fds__getInvoice`) is answered `no_file` before any blob copy, so
|
||||
repeated clicks leave no spare `backup_*` blobs.
|
||||
- **Adding a card:** append a builder to `cards`, add its texts to
|
||||
`fis.specialties_txt_de.js`, run `npx gulp min`. No server change is
|
||||
needed unless the action itself is new.
|
||||
- **Build:** bundle entries in `Fuchs/bdlconfig.json`
|
||||
(`intranet:specialties`); generated files under `wwwroot/web/`.
|
||||
|
||||
## Key files
|
||||
|
||||
- `Fuchs/Controllers/IntranetController.Specialties.cs` — auth probe.
|
||||
- `Fuchs/Controllers/IntranetController.cs` — `"specialties"` case in `Do`.
|
||||
- `Fuchs/js/intranet/modules/fis.specialties.js` / `_txt_de.js` / `.scss`.
|
||||
- `Fuchs/js/intranet/fis_main_menu.js` — menu item + `openModuleFromPath`.
|
||||
- `Fuchs/js/intranet/fis_main_go.js` — deep link after menu settles.
|
||||
- `Fuchs/js/intranet/fis_texts_gui_de.js` — `m_specialties` label.
|
||||
|
||||
## Related decisions
|
||||
|
||||
- [0027 — Admin invoice-file backup-and-clear action](../Decisions/0027-admin-invoice-file-backup-and-clear.md)
|
||||
@@ -47,7 +47,7 @@ click "Administration" → $ocms.init('admin')
|
||||
$ocms.admin.init2() → init3() renders the page
|
||||
POST /do/admin/status → { info, probes } full snapshot + all probes
|
||||
per-card "Aktualisieren" → POST /do/admin/probe/<component> → { probe }
|
||||
"Test-E-Mail senden" → POST /do/admin/testmail (to/subject/body) → { result }
|
||||
"Test-E-Mail senden" → POST /do/admin/testmail (to/subject/body/attachpdf) → { result }
|
||||
```
|
||||
|
||||
### Backend (`ISystemStatusService` / `SystemStatusService`, scoped)
|
||||
@@ -60,7 +60,7 @@ click "Administration" → $ocms.init('admin')
|
||||
(`status` = `ok`/`error`/`disabled`/`unconfigured`, `ok`, `message`, `detail`, `durationMs`,
|
||||
optional `metrics`). They never throw — failures are captured in the result. `database` runs
|
||||
`SELECT @@SERVERNAME, DB_NAME(), SUSER_SNAME()`; `email` calls
|
||||
`IComService.VerifyCredentialsAsync` (GET `{BaseUrl}/mailer/fc?fn=verify` with the configured
|
||||
`IComService.VerifyCredentialsAsync` (GET `{BaseUrl}/api/mailer/verify` with the configured
|
||||
Basic-auth account/token — see the Mailer API contract) and only turns `ok` when the Mailer API
|
||||
itself confirms the credentials (`ok:true`); when `ProcessWebComSettings.ServerId` is configured
|
||||
it is sent along as `&serverid=...` so the API also confirms this server belongs to the account
|
||||
@@ -74,9 +74,18 @@ click "Administration" → $ocms.init('admin')
|
||||
count** for the invoice/reminder containers, surfaced as `metrics` and totalled in the message);
|
||||
`mfr` calls `IMfrClientFactory.Create().GetEntities()`. The generic `metrics` (label/value pairs)
|
||||
is how a probe reports extra facts for display — the blob probe uses it for the file counts.
|
||||
- `SendTestEmailAsync(to, subject, body)` HTML-encodes the body and sends via `IComService`, so
|
||||
the `Fuchs:Email:OverrideRecipient` redirect applies exactly as for any other mail; the result
|
||||
reports the requested recipient and, when active, the override target.
|
||||
- `SendTestEmailAsync(to, subject, body, attachPdf)` HTML-encodes the body and sends via
|
||||
`IComService`, so the `Fuchs:Email:OverrideRecipient` redirect applies exactly as for any other
|
||||
mail; the result reports the requested recipient and, when active, the override target.
|
||||
With `attachPdf` (dialog select "PDF-Anhang mitsenden", default *Ja*; absent form value ⇒ on)
|
||||
a one-page letter PDF (`Fuchs-Intranet-Testmail.pdf`) is rendered in memory via
|
||||
`IPdfService.WriteLetterAsync` + `DocToPdfBytes` — standard Fuchs letterhead, the recipient in
|
||||
the address window, `Datum`/`Umgebung`/`Nummer=admin_test` in the admin block, the mail's own
|
||||
subject and body as the letter text; no DB or stored document is touched. This exercises the
|
||||
Mailer's multipart/attachment path, so per ADR 0028 `sent` is only `true` when the Mailer also
|
||||
confirmed the attachment (`attachmentsProcessed == 1`); the result's `attachmentName` says
|
||||
whether an attachment was part of the send. A PDF rendering failure is reported in the result
|
||||
(`sent=false`, message names the error) and nothing is sent.
|
||||
- Every probe emits the `fuchs.systemstatus.probes` counter tagged by component + status and an
|
||||
`systemstatus.probe` activity span.
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-09-17
|
||||
applyTo:
|
||||
- "Fuchs/Services/IComService.cs"
|
||||
- "Fuchs/Services/ProcessWebComService.cs"
|
||||
- "Fuchs/Services/ProcessWebComSettings.cs"
|
||||
- "Fuchs/Services/FuchsMailerClient.cs"
|
||||
- "Fuchs/Services/MailerAttachmentPolicy.cs"
|
||||
- "Fuchs.Tests/ProcessWebComServiceTests.cs"
|
||||
- "Fuchs.Tests/FuchsMailerClientTests.cs"
|
||||
relatedDecisions:
|
||||
- "0028-mailer-push-com-multipart-and-attachment-confirmation.md"
|
||||
- "0029-outbound-mail-via-ocore-pwmailer-client.md"
|
||||
---
|
||||
|
||||
# ProcessWeb Mailer interface (outbound email/SMS)
|
||||
|
||||
## Summary
|
||||
|
||||
All outbound email and SMS leaves Fuchs through `IComService` →
|
||||
`ProcessWebComService`, which is a thin adapter over the OCORE_web
|
||||
**`IPWMailerClientService`** (`OCORE_web/comm/pwmailer/`). The client owns
|
||||
the HTTP contract of the ProcessWeb Mailer API; Fuchs owns what is specific
|
||||
to Fuchs — the recipient-override safety net, the signature, the attachment
|
||||
limits, the `fds__emaillog` audit row and telemetry.
|
||||
|
||||
The authoritative, server-side contract is the Mailer's own function catalog:
|
||||
|
||||
> **https://processweb.de/api/mailer/fc** — subject to change as features are
|
||||
> added, but kept backward compatible. The OCORE_web client is kept in sync
|
||||
> with it (its header says so and names the server-side files); read both
|
||||
> before changing anything here.
|
||||
|
||||
## How it works
|
||||
|
||||
### Layering
|
||||
|
||||
| Layer | File | Responsibility |
|
||||
|-------|------|----------------|
|
||||
| Adapter | `Fuchs/Services/ProcessWebComService.cs` | `IComService`: validate recipient, apply `Fuchs:Email:OverrideRecipient`, append signature, build a `PWMailerMessage`, interpret the `PWMailerResult`, write `fds__emaillog`, emit metrics |
|
||||
| Fuchs client | `Fuchs/Services/FuchsMailerClient.cs` | `PWMailerClientService` subclass; overrides `ValidateMessage` to add the attachment limits (`MailerAttachmentPolicy`) on top of the contract's base rules |
|
||||
| OCORE client | `OCORE_web/comm/pwmailer/PWMailerClientService*.cs` | endpoints, envelope, JSON vs multipart (unnamed envelope field + one file part per attachment), `attachmentCount`, Basic auth, token resolution, `x-pcw-excd` error headers, retries/timeouts, typed results |
|
||||
|
||||
### Configuration
|
||||
|
||||
`Fuchs:Mailer` (`ProcessWebComSettings`) stays the single config section. In
|
||||
`Program.cs` it is mapped onto `PWMailerClientOptions`:
|
||||
|
||||
- `BaseUrl` (`https://processweb.de`) + `/api/mailer` → `PWMailerClientOptions.BaseUrl`
|
||||
- `AccountId` → `Account`, `ServerId` → `ServerId`, `Token` → `Token`
|
||||
- `TokenSecretName = "Fuchs--Mailer--Token"` — the same Key Vault secret
|
||||
OCORE secret management already maps into `Fuchs:Mailer:Token`. In
|
||||
production the configuration value is therefore already the plain token;
|
||||
the name only matters when the `MANAGED_BY_KEYVAULT` placeholder is still
|
||||
in place (e.g. locally), where the client loads it on demand.
|
||||
- `Attachments` (`MaxFileBytes` 10 MB, `MaxMessageBytes` 20 MB, `MaxCount`
|
||||
10 — the contract defaults) feed `FuchsMailerClient`'s pre-validation.
|
||||
|
||||
Registration: `AddPWMailerClient<FuchsMailerClient>()` (singleton, named
|
||||
`HttpClient` `PWMailerClient`). `ProcessWebComService` stays scoped.
|
||||
|
||||
### Sending an email
|
||||
|
||||
1. `SendEmailAsync` validates the address, applies the override, appends the
|
||||
signature, and builds `PWMailerMessage { To, Cc, Subject, Body, Reference,
|
||||
ComType = "e", Attachments }`. `AttachmentCount` is left `null`, so the
|
||||
client sends the real count and the Mailer cross-checks it (400 on
|
||||
mismatch).
|
||||
2. `FuchsMailerClient.ValidateMessage` runs *inside* `PushAsync` before any
|
||||
bytes go out: base rules (server id, recipient, subject, body, empty file,
|
||||
missing extension) plus Fuchs limits (count, per-file size, total size,
|
||||
forbidden extensions — `MailerAttachmentPolicy.ForbiddenExtensions` is the
|
||||
contract's quoted subset; the server's list is longer and authoritative).
|
||||
A violation returns a result with `StatusCode 0`, `Attempts 0` and the
|
||||
`"; "`-joined rule messages as `Error`.
|
||||
3. The client posts JSON (no attachments) or multipart (with) to
|
||||
`POST /api/mailer/push_com`, never retrying a push.
|
||||
4. The adapter interprets the `PWMailerResult<PWMailerPushReceipt>`:
|
||||
|
||||
| Result | `SendEmailAsync` | audit `fds__emaillog` | `fuchs.emails.failed{reason}` |
|
||||
|--------|------------------|-----------------------|-------------------------------|
|
||||
| `Success` and (no attachments or `AttachmentsProcessed == sent`) | `true` | `guid = comStackID` | — (`fuchs.emails.sent`) |
|
||||
| `Success` but `AttachmentsProcessed != sent` | `false` | counts in log, `guid = comStackID` | `attachments-mismatch` |
|
||||
| `IsConflict` (409: identical message still pending at the Mailer — it will be sent, nothing new queued) | `true` | "Duplicate of a still-pending entry (409) …" | — (`fuchs.emails.sent`) |
|
||||
| `StatusCode 0`, `Attempts 0`, `Exception is ArgumentException` (refused by pre-validation) | `false` | one line per rule | `validation` |
|
||||
| `StatusCode 0`, `Attempts 0`, no exception (options unusable / token not resolvable, e.g. Key Vault outage) | `false` | `Mailer client not usable: …` (Error log) | `client-config` |
|
||||
| any other failure | `false` | `API error {status}: {error}` | `api-error` |
|
||||
| client threw | `false` | exception detail | `api-error` |
|
||||
|
||||
`reference` (`inv_<Id>` etc.) is the audit key and part of the Mailer's
|
||||
idempotency key (server + reference + content, while the first entry is still
|
||||
pending). A resend of the identical mail within that window answers 409 and
|
||||
is reported as **success** — the first entry is delivered; nothing is sent
|
||||
twice. A resend after delivery started is a new entry. Callers keep reusing
|
||||
`inv_<Id>` (e.g. `req/resend`); no per-attempt reference is needed.
|
||||
|
||||
### SMS and verification
|
||||
|
||||
- `SendSmsAsync` pushes `PWMailerMessage { ComType = "s", To = mobile, Body }`
|
||||
— the base validation requires no subject for `comType "s"`.
|
||||
- `VerifyCredentialsAsync` → `IPWMailerClientService.VerifyAsync(serverId)`
|
||||
(`GET /api/mailer/verify[?serverid=…]`), mapped to
|
||||
`MailerCredentialsCheckResult` for the Admin system-status view.
|
||||
|
||||
### Observability
|
||||
|
||||
`fuchs.emails.sent` / `fuchs.emails.failed{reason}` with reasons
|
||||
`invalid-email`, `disabled`, `validation`, `client-config`,
|
||||
`attachments-mismatch`, `api-error`; `fuchs.email.send.duration{success}`. The OCORE
|
||||
client additionally logs every failed call at Warning and exposes
|
||||
`RequestSending`/`ResponseReceived`/`CallCompleted`/`CallFailed` events (not
|
||||
subscribed by Fuchs today).
|
||||
|
||||
## Key files
|
||||
|
||||
- `Fuchs/Services/IComService.cs` — interface + `MailerCredentialsCheckResult`.
|
||||
- `Fuchs/Services/ProcessWebComService.cs` — the adapter.
|
||||
- `Fuchs/Services/FuchsMailerClient.cs` — `ValidateMessage` override.
|
||||
- `Fuchs/Services/MailerAttachmentPolicy.cs` — limit rules (pure).
|
||||
- `Fuchs/Services/ProcessWebComSettings.cs` — `Fuchs:Mailer` incl. `Attachments`.
|
||||
- `Fuchs/Program.cs` — options mapping + `AddPWMailerClient<FuchsMailerClient>()`.
|
||||
- `OCORE_web/OCORE_web/comm/pwmailer/` — the client (submodule).
|
||||
- `Fuchs.Tests/ProcessWebComServiceTests.cs`, `Fuchs.Tests/FuchsMailerClientTests.cs`;
|
||||
the wire format is tested in `OCORE_web/OCORE_webTests/comm/PWMailerClientServiceTests.cs`.
|
||||
|
||||
## Related decisions
|
||||
|
||||
- [0028 — push_com multipart attachments and attachment confirmation](../Decisions/0028-mailer-push-com-multipart-and-attachment-confirmation.md)
|
||||
- [0029 — Outbound mail via the OCORE_web PWMailer client](../Decisions/0029-outbound-mail-via-ocore-pwmailer-client.md)
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-17
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.Admin.cs"
|
||||
- "Fuchs/Services/IInvoiceFileBackupService.cs"
|
||||
- "Fuchs/Services/InvoiceFileBackupService.cs"
|
||||
- "Fuchs/Services/IBlobStorageService.cs"
|
||||
- "Fuchs/Services/AzureBlobStorageService.cs"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__admin_backupAndClearInvoiceFile.sql"
|
||||
- "Fuchs_Database/dbo/Tables/fds__invoice_file_backups.sql"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0027 — Admin invoice-file backup-and-clear action
|
||||
|
||||
## Context
|
||||
|
||||
Support occasionally needs to force a finalised invoice's PDF to be regenerated
|
||||
(e.g. it was rendered with wrong data before a fix, or is otherwise corrupt).
|
||||
Since [0025](0025-invoice-document-regenerate-on-miss.md), clearing
|
||||
`fds__invoices.[file]` on a finalised, non-external invoice is enough to make
|
||||
the next `req/idoc`/`inv/rdoc` read regenerate and re-persist it.
|
||||
|
||||
A manual maintenance proc for this already exists,
|
||||
`[dbo].[_BackupAndClearInvoiceFile]`, but its own header comment states it is
|
||||
"Manual-only maintenance utility. Never called from application code", and
|
||||
0025's "Alternatives considered" explicitly rejected relying on it from the
|
||||
app, partly because it leaves no application log entry. We still want this
|
||||
capability reachable from the app (a hidden, admin-gated action) rather than
|
||||
requiring direct SSMS access, without contradicting that standing decision.
|
||||
|
||||
Additionally, `IBlobStorageService` archives invoice PDFs to Azure Blob
|
||||
Storage under a fixed per-invoice blob name (`{invoiceId}/{fileName}`) and
|
||||
always uploads with `overwrite: true`. If a cleared invoice's document is
|
||||
later regenerated (0025), `StoreInvoiceDocumentFileAsync` re-uploads under
|
||||
that same blob name — silently overwriting the very copy this action is
|
||||
meant to preserve as a backup, unless it is moved first.
|
||||
|
||||
## Decision
|
||||
|
||||
- A **new** proc, `[dbo].[fds__admin_backupAndClearInvoiceFile]`, is added
|
||||
rather than calling `_BackupAndClearInvoiceFile`. It copies the invoice's
|
||||
current `[file]` bytes into a new table, `[dbo].[fds__invoice_file_backups]`
|
||||
(one row per backup, not a timestamp-named ad-hoc table per call), then
|
||||
clears `[file]` in the same transaction (`XACT_ABORT` + TRY/CATCH with
|
||||
rollback and re-raise, so a clear can never commit without its backup row).
|
||||
It logs through the normal
|
||||
`fds__admin_activity` insert (matching `fds__setInvoiceFile`'s pattern) and
|
||||
every call is also logged through the app's `ILogger`/DB-log-sink pipeline
|
||||
(ADR 0024) — unlike the manual proc, this path is fully auditable.
|
||||
`_BackupAndClearInvoiceFile` is left completely untouched as the manual
|
||||
break-glass tool for cases the app can't reach (e.g. a corrupt row the
|
||||
admin UI can't load).
|
||||
- The action applies 0025's regeneration precondition **before** touching
|
||||
anything: only a **finalised, non-external** invoice qualifies. Both the
|
||||
service (`IsDraft`/`IsExternal`) and the proc (`[DateFinalized] IS NOT NULL
|
||||
AND ISNULL([IsExternal],0) = 0`) enforce it. Clearing a draft's `[file]`
|
||||
would let the next read persist a draft render through
|
||||
`fds__setInvoiceFile` (`WHERE [file] IS NULL`), which then blocks the real
|
||||
final PDF forever — the exact defect 0025 calls load-bearing; an external
|
||||
(MFR) document has no template and would just become a permanent 404.
|
||||
- `IBlobStorageService` gains `CopyInvoicePdfToBackupAsync`: a
|
||||
**server-side copy** (`StartCopyFromUriAsync`, bytes never pass through
|
||||
the app) of the existing blob to a timestamped backup name in the same
|
||||
container — the original blob is **kept**, not renamed. A private source in
|
||||
the same storage account is authorised by the request's own Shared Key
|
||||
credential, so no SAS is generated. The copy runs *before* the DB clear,
|
||||
so the archived original is safe before any regenerate-on-read (ADR 0025)
|
||||
could overwrite the original name; because the original stays in place, a
|
||||
DB error or no-op afterwards leaves the canonical archive exactly as it
|
||||
was (with a harmless spare copy). Keeping the original also removes the
|
||||
half-state a copy-then-delete would have ("copied but couldn't delete").
|
||||
Best-effort: a copy failure is logged and counted
|
||||
(`fuchs.blobstorage.backupcopies.failed`), the DB backup row stays the
|
||||
authoritative copy, and the DB step still runs.
|
||||
- The action is exposed as a `Do_Process_Admin` case,
|
||||
`admin/backupclearinvoicefile/<invoiceId>`, gated by the existing
|
||||
`fds_sys > 4` check and restricted to `POST` (a GET must never trigger it,
|
||||
since GETs can be issued by crawlers/prefetchers without user intent). It
|
||||
is not part of any regular workflow UI; its only frontend is a card on the
|
||||
admin-only "Specialties" page (`/specialties`, see
|
||||
`Docs/Concepts/admin-specialties.md`).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Two invoice-file "backup and clear" code paths now exist for different
|
||||
audiences: `_BackupAndClearInvoiceFile` (manual, SSMS, no app log) and
|
||||
`fds__admin_backupAndClearInvoiceFile` (app-triggered, fully logged). Keep
|
||||
them separate — do not merge them, and do not point the manual proc's
|
||||
printed recovery links at the new admin action.
|
||||
- `fds__invoice_file_backups` accumulates one row per backup with no
|
||||
automatic retention/cleanup; if this ever becomes a storage concern, that
|
||||
is a separate decision.
|
||||
- Blob backup-copy is best-effort: if it fails (or storage is
|
||||
disabled/unconfigured), the invoice's DB row is still backed up and
|
||||
cleared — the archived blob is simply left as-is (and will be overwritten
|
||||
by the next regeneration). The DB backup table is the only *guaranteed*
|
||||
copy this action makes.
|
||||
- The blob container accumulates one `backup_<timestamp>_…` copy per action
|
||||
next to the current blob; like `fds__invoice_file_backups`, no retention
|
||||
is applied.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Call `_BackupAndClearInvoiceFile` directly from the app.** Rejected: its
|
||||
own header comment and ADR 0025 both state it must never be
|
||||
application-triggered, and it produces no application log entry — using it
|
||||
here would need updating that comment and (since decisions are immutable)
|
||||
superseding 0025's "Alternatives considered" note, for no real benefit over
|
||||
a small dedicated proc.
|
||||
- **Skip the blob rename and let the next regeneration overwrite the archived
|
||||
blob.** Rejected: the user asked for the blob copy to also survive as a
|
||||
backup, and a silent overwrite on next read would defeat that.
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-17
|
||||
applyTo:
|
||||
- "Fuchs/Services/IComService.cs"
|
||||
- "Fuchs/Services/ProcessWebComService.cs"
|
||||
- "Fuchs/Services/ProcessWebComSettings.cs"
|
||||
- "Fuchs/Services/MailerAttachmentPolicy.cs"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0028 — Mailer push_com: multipart attachments, count confirmation, contract pre-validation
|
||||
|
||||
## Context
|
||||
|
||||
The ProcessWeb Mailer API (`push_com`) was extended to accept attachments.
|
||||
Its contract — https://processweb.de/api/mailer/fc — specifies
|
||||
`multipart/form-data` with the JSON envelope in an unnamed form field and one
|
||||
file part per attachment, an optional `attachmentCount` safety check, and a
|
||||
200 response reporting `attachmentsReceived`/`attachmentsProcessed`.
|
||||
|
||||
`ProcessWebComService` predated that: it base64-inlined attachments as
|
||||
`communication.attachments[]` in a JSON body. That property is not part of
|
||||
the contract, so the Mailer accepted the message and dropped the files — a
|
||||
200 for an invoice mail whose PDF never arrived, with no error anywhere. The
|
||||
client also fabricated the audit `guid` (`Guid.NewGuid()`), treated a 409
|
||||
(idempotency duplicate) as a generic API error, and called the legacy
|
||||
`/mailer/fc?fn=verify` and `api.processweb.de` paths.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Request shape follows the contract exactly.** No attachments →
|
||||
`application/json` envelope. With attachments → `multipart/form-data`:
|
||||
envelope in an unnamed field (`name=""`, set via an explicit
|
||||
`Content-Disposition` because `MultipartFormDataContent.Add` rejects an
|
||||
empty name), then one file part per attachment with real bytes. Field
|
||||
names as in the contract (`comType`, not `comtype`). Base URL
|
||||
`https://processweb.de`, paths `/api/mailer/push_com` and
|
||||
`/api/mailer/verify`.
|
||||
2. **`attachmentCount` is always sent** so the Mailer itself rejects a
|
||||
request whose file parts don't match.
|
||||
3. **A 200 is not success when attachments were sent unless
|
||||
`attachmentsProcessed` equals the number sent.** Anything else — fewer
|
||||
processed, or a body that isn't the contract JSON — is reported to the
|
||||
caller as a failed send (`false`, reason `attachments-mismatch`, counts
|
||||
in the audit log). Callers gate "mark as sent" on that boolean, so an
|
||||
invoice whose PDF was not processed is never flagged as sent.
|
||||
4. **Contract limits are pre-validated client-side** (`MailerAttachmentPolicy`:
|
||||
count, per-file and per-message size, empty file, missing/forbidden
|
||||
extension) with the contract's defaults configurable under
|
||||
`Fuchs:Mailer:Attachments`. A violation fails before any bytes are sent,
|
||||
with the exact rule in the audit log. Empty files are a violation, not
|
||||
silently skipped as before.
|
||||
5. **`comStackID` is the audit key**, falling back to the `Message-Id`
|
||||
header; a local id only when the Mailer returned neither.
|
||||
6. **409 is a distinct failure** (`duplicate`): the Mailer already holds a
|
||||
message under this `reference` and did not queue this one.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `IComService.SendEmailAsync` returning `true` now means "accepted *and*
|
||||
every attachment confirmed processed" — strictly stronger than before.
|
||||
Existing callers (`req/sconf`, reminders, admin test mail) need no change;
|
||||
they already treat `false` as not-sent.
|
||||
- A caller passing a 0-byte attachment now gets `false` instead of a mail
|
||||
without the file. That is intended: an empty PDF is an upstream failure
|
||||
that should surface, not be hidden by a successful-looking send.
|
||||
- The forbidden-extension list in the client is the contract's quoted subset;
|
||||
the server's longer list stays authoritative and still answers 400 for the
|
||||
rest.
|
||||
- The concept doc `Docs/Concepts/processweb-mailer-interface.md` describes
|
||||
the current client behaviour and must be kept in sync when the contract
|
||||
evolves (it is declared backward compatible, so additions rather than
|
||||
breaks are expected).
|
||||
- The verify response is still parsed as `{ ok, account, serverid }`; if the
|
||||
`/api/mailer/verify` endpoint's body differs from the legacy `fc?fn=verify`
|
||||
one, only `VerifyCredentialsAsync`'s DTO needs adjusting.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep inline base64 and ask the Mailer to accept it too.** Rejected: the
|
||||
contract is the Mailer's; two accepted shapes would just be a second
|
||||
thing to keep compatible, and base64 inflates every PDF by a third.
|
||||
- **Treat 200 as success regardless of `attachmentsProcessed`.** Rejected —
|
||||
that is exactly the silent-drop failure mode this change exists to close.
|
||||
- **Treat 409 as success ("already sent").** Rejected: a resend under the
|
||||
same reference would then report success without anything being queued;
|
||||
the caller must see it and decide (e.g. use a new reference).
|
||||
- **Skip client-side validation and rely on the server's 400.** Rejected:
|
||||
the 400 body is an opaque string in the audit log; a named rule is what
|
||||
support needs, and it costs nothing.
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-17
|
||||
applyTo:
|
||||
- "Fuchs/Services/ProcessWebComService.cs"
|
||||
- "Fuchs/Services/FuchsMailerClient.cs"
|
||||
- "Fuchs/Services/MailerAttachmentPolicy.cs"
|
||||
- "Fuchs/Services/ProcessWebComSettings.cs"
|
||||
- "Fuchs/Program.cs"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0029 — Outbound mail goes through the OCORE_web PWMailer client
|
||||
|
||||
## Context
|
||||
|
||||
Right after [0028](0028-mailer-push-com-multipart-and-attachment-confirmation.md)
|
||||
re-implemented the Mailer API's `push_com` contract by hand in
|
||||
`ProcessWebComService`, OCORE_web gained a complete typed client for that
|
||||
API (`OCORE_web/comm/pwmailer/`, `IPWMailerClientService`): every operation,
|
||||
the JSON/multipart request shapes, `attachmentCount`, typed receipts,
|
||||
Basic auth with Key Vault/encrypted token resolution, `x-pcw-excd` error
|
||||
headers, retries and timeouts — declared to be kept in sync with the
|
||||
server-side controllers. Keeping a second implementation of the same
|
||||
contract in Fuchs would mean two things to update on every API change.
|
||||
|
||||
The client validated a message's required fields and empty/extension-less
|
||||
attachments, but had no hook for application rules (size limits, a
|
||||
file-type deny-list) — `PWMailerMessage` is sealed and `PushAsync` called
|
||||
`Validate` directly.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **`ProcessWebComService` is an adapter over `IPWMailerClientService`.**
|
||||
It keeps only what is Fuchs-specific: recipient validation and the
|
||||
`Fuchs:Email:OverrideRecipient` safety net, the signature, building the
|
||||
`PWMailerMessage`, interpreting the `PWMailerResult`, the `fds__emaillog`
|
||||
audit row and telemetry. All HTTP transport lives in the OCORE client;
|
||||
the hand-rolled `PostPushComAsync`/MIME guessing from 0028 is deleted.
|
||||
2. **Pre-validation is an overridable hook in the client.** OCORE_web's
|
||||
`PWMailerClientService` gains `protected virtual ValidateMessage(PWMailerMessage)`
|
||||
(default: `PWMailerMessage.Validate`), called by `PushAsync` and
|
||||
`PushBulkAsync` before anything is sent. Fuchs overrides it in
|
||||
`FuchsMailerClient` to add the contract's attachment limits
|
||||
(`MailerAttachmentPolicy`: count, per-file/per-message size, forbidden
|
||||
extensions, configurable under `Fuchs:Mailer:Attachments`). A refusal is a
|
||||
`PWMailerResult` with `StatusCode 0` / `Attempts 0`, which the adapter
|
||||
reports as reason `validation` with one audit line per rule.
|
||||
3. **One configuration section.** `Fuchs:Mailer` (`ProcessWebComSettings`)
|
||||
remains the source; `Program.cs` maps it onto `PWMailerClientOptions`
|
||||
(`BaseUrl + /api/mailer`, `AccountId → Account`, `Token`, `ServerId`) and
|
||||
sets `TokenSecretName = "Fuchs--Mailer--Token"`, i.e. the Key Vault secret
|
||||
OCORE secret management already maps into `Fuchs:Mailer:Token`. No second
|
||||
`PWMailer` section, no second secret.
|
||||
4. **0028's behavioural rules stand**, with one correction: multipart with
|
||||
`attachmentCount`, a 200 counts as success only when
|
||||
`attachmentsProcessed` equals the number sent, `comStackID` is the audit
|
||||
key. **409 is now a success, not a `duplicate` failure** (this replaces
|
||||
0028's rule 6): the client documents 409 as "duplicate of a still-pending
|
||||
entry with the same server/reference/content — nothing was enqueued, the
|
||||
earlier entry will be sent". The mail is therefore on its way; the resend
|
||||
paths (`req/resend`, reminders) reuse `inv_<Id>` as the reference, and a
|
||||
double click must not surface as a send error. The deduplication is
|
||||
recorded in the audit row. They are enforced partly by the client (shape,
|
||||
count) and partly by the adapter (processed check, audit, 409 mapping).
|
||||
5. Two small OCORE_web fixes made in the same change: `PWMailerMessage.Validate`
|
||||
no longer demands a subject for `comType "s"` (SMS), and a call refused
|
||||
by validation reports `Attempts = 0` as its documentation already stated.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Fuchs no longer asserts the wire format in its own tests; that lives in
|
||||
`OCORE_web/OCORE_webTests/comm/PWMailerClientServiceTests.cs`. Fuchs tests
|
||||
assert the `PWMailerMessage` handed to the client and the interpretation of
|
||||
each `PWMailerResult` shape, plus `FuchsMailerClient`'s refusals.
|
||||
- OCORE_web is a submodule owned by another repository: the hook, the SMS
|
||||
rule and the `Attempts` fix are a commit there and a pointer bump here.
|
||||
- The client is a singleton with a cached token; after a token rotation
|
||||
`IPWMailerClientService.ResetToken()` (on the concrete type) is the way to
|
||||
pick it up without a restart — not wired to any admin action yet.
|
||||
- Empty attachments are still refused (now by the client's base rule), so
|
||||
an upstream render failure never produces a mail without its PDF. The
|
||||
flip side: a caller attaching an *optional* file must skip an empty byte
|
||||
array itself — the reminder's invoice copy (`IntranetController.Reminder.cs`)
|
||||
does, otherwise a never-stored invoice file would fail the whole reminder
|
||||
instead of sending it without the copy.
|
||||
- The client's `getCom`/`getComStatus`/`abortCom` and inbox operations are
|
||||
available to Fuchs for free (e.g. a delivery-status column keyed by the
|
||||
audited `comStackID`) — not used yet.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep the 0028 hand-rolled transport.** Rejected: duplicate contract
|
||||
implementation, and the OCORE client is the one declared to track the
|
||||
server.
|
||||
- **Pre-validate in the adapter only (before `PushAsync`).** Rejected: the
|
||||
rules belong with the client's own validation so every OCORE consumer can
|
||||
apply them the same way, and the user asked for an overridable hook.
|
||||
- **Bind a separate `PWMailer` config section.** Rejected: `Fuchs:Mailer` is
|
||||
already Key Vault-managed and read by the Admin status view; mapping in
|
||||
code avoids a second token secret and drift between two sections.
|
||||
@@ -216,7 +216,9 @@ flowchart TD
|
||||
## 6. Sending the invoice by email
|
||||
|
||||
Email delivery is handled by `IComService` / `ProcessWebComService`, which
|
||||
talks to the **ProcessWeb Mailer API** (`push_com`, `comType=email`).
|
||||
talks to the **ProcessWeb Mailer API** (`POST /api/mailer/push_com`,
|
||||
`comType="e"`; contract: https://processweb.de/api/mailer/fc, client side
|
||||
described in `Docs/Concepts/processweb-mailer-interface.md`).
|
||||
|
||||
- `HandleRequestSconf` builds the HTML body via `BuildInvoiceBody(balance,
|
||||
paymentTerms)` — a fixed German thank-you text, the amount (if non-zero),
|
||||
@@ -234,8 +236,12 @@ talks to the **ProcessWeb Mailer API** (`push_com`, `comType=email`).
|
||||
- A signature is appended to the HTML body.
|
||||
- If the mailer is disabled (`_settings.Enabled == false`), sending is a
|
||||
documented no-op.
|
||||
- Attachments are base64-encoded inline (`{ filename, mimeType,
|
||||
contentBase64 }`) and POSTed to the Mailer API.
|
||||
- Attachments are pre-validated against the Mailer's limits (size, count,
|
||||
no empty file, extension rules) and POSTed as `multipart/form-data` —
|
||||
envelope JSON in an unnamed field plus one file part per attachment, with
|
||||
`attachmentCount` in the envelope.
|
||||
- The Mailer's 200 response must confirm `attachmentsProcessed` equal to
|
||||
the number sent; otherwise the send is reported as failed (ADR 0028).
|
||||
- Only on a **successful** send does the controller call
|
||||
`fds__setInvoiceSent @auto=true`, which sets `IsSent = 1` — so a failed
|
||||
mailer call leaves the invoice finalised but *not marked sent* (visible via
|
||||
@@ -252,8 +258,10 @@ sequenceDiagram
|
||||
C->>Com: SendEmailAsync("inv_<Id>", subject, body, email, "", {pdf})
|
||||
Com->>Com: validate email, apply OverrideRecipient (dev), append signature
|
||||
alt mailer enabled
|
||||
Com->>API: POST push_com {comType:"email", attachments:[base64 pdf]}
|
||||
API-->>Com: success/failure
|
||||
Com->>Com: MailerAttachmentPolicy.Validate(pdf)
|
||||
Com->>API: POST /api/mailer/push_com (multipart: envelope{comType:"e", attachmentCount:1} + pdf part)
|
||||
API-->>Com: 200 {comStackID, attachmentsProcessed} / 4xx/5xx
|
||||
Com->>Com: attachmentsProcessed == 1 ? success : failure
|
||||
else mailer disabled
|
||||
Com-->>Com: no-op (logged)
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user