Files
tessera-ctl/.planning/quick/260929-if2-reminder-widget-mit-benachrichtigung/260929-if2-PLAN.md
T
schalli 8c644de5da
Tessera CI/CD / Lint & Type Check (push) Successful in 57s
Tessera CI/CD / Tests (push) Successful in 1m34s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m46s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m27s
docs(quick-260929-if2): Erinnerungen-Widget, Verifikation und Browser-Pruefung
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 14:17:41 +02:00

70 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
quick-260929-if2 01 execute 1
apps/api/prisma/schema.prisma
apps/api/prisma/migrations/20260929140000_reminder/migration.sql
apps/api/src/app.module.ts
apps/api/src/reminders/reminders.module.ts
apps/api/src/reminders/reminders.controller.ts
apps/api/src/reminders/reminders.controller.spec.ts
apps/api/src/reminders/reminders.service.ts
apps/api/src/reminders/reminders.service.spec.ts
apps/api/src/reminders/dto/reminder.dto.ts
apps/api/src/reminders/reminder-mail.scheduler.ts
apps/api/src/reminders/reminder-mail.scheduler.spec.ts
apps/api/src/mail/mail.service.ts
apps/api/src/mail/mail.service.spec.ts
apps/api/src/prisma/rls-access-inventory.spec.ts
packages/shared/src/index.ts
apps/web/src/lib/reminders-api.ts
apps/web/src/lib/reminders-api.test.ts
apps/web/src/lib/reminder-notify.ts
apps/web/src/lib/reminder-notify.test.ts
apps/web/src/lib/reminder-time.ts
apps/web/src/lib/reminder-time.test.ts
apps/web/src/components/reminders/reminder-notifier.tsx
apps/web/src/components/reminders/reminder-notifier.test.tsx
apps/web/src/components/layout/app-shell.tsx
apps/web/src/components/dashboard/widgets/reminder-widget.tsx
apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx
apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx
apps/web/src/components/dashboard/widget-registry.tsx
apps/web/src/components/dashboard/widgets/widget-icon.tsx
apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
apps/web/src/app/(portal)/page.tsx
apps/web/src/messages/de.json
apps/web/src/messages/en.json
apps/web/src/messages/umlaut-dictionary.ts
apps/desktop/src-tauri/src/lib.rs
docs/mandantentrennung-zugriffsklassifikation.md
docs/anleitung-anwender.md
CHANGELOG.md
true
QUICK-260929-if2
tokens raw_tokens tasks confidence
260000 260000 3 low
truths artifacts key_links
A user adds the dashboard widget 'Erinnerungen', creates a reminder with date, time, title and description in local time, and sees only their own open reminders, sorted by due time (D-05)
At the due time (D-02, no advance warning) an open Tessera browser tab shows a Web Notification once permission was granted. Permission is asked only from the widget on the first creation, never on page load (D-04)
At the due time the desktop app shows a native OS notification, also while the main window is hidden in the tray, through the notification plugin, which the page may call only from the stored server origin
Every client shows each (reminder id, dueAt) at most once: tabs of one browser share the local claim, and the desktop app and the browser each notify once
A due reminder stays in the widget, highlighted, with 'Erledigt' (removes it) and 'Später erinnern' (+10 min, +1 h, tomorrow at the same time). Snoozing sets a new dueAt, so notifications fire again and the e-mail is sent again when enabled (D-01, D-03)
Upcoming reminders can be edited and deleted. Editing a due reminder is rejected with 409, snoozing a reminder that is not due yet is rejected with 409
With 'zusätzlich per E-Mail' on, the server sends exactly one e-mail per due occurrence through the tenant SMTP config (time shown in Europe/Berlin), without any open client and also with several API instances (atomic claim). The toggle is disabled with an explanation when SMTP is not configured or the user has no e-mail
A foreign reminder id always returns 404 (never 403). The Reminder table has a tenant+user RLS policy and a system read policy. rls-coverage and rls-access-inventory stay green, and the classification doc is re-measured
All API and web tests are green, type-check and lint are green, Biome warnings stay at web <= 55 and api <= 82, and cargo test/fmt/clippy are green
path provides contains
apps/api/prisma/migrations/20260929140000_reminder/migration.sql Reminder table, FK to User with cascade, indexes, RLS ENABLE+FORCE, tenant_isolation_policy with user dimension, system_read_policy FOR SELECT system_read_policy
path provides
apps/api/src/reminders/reminders.service.ts Owner-scoped CRUD, snooze, email availability, all via forTenant(prisma, tenantId, userId)
path provides
apps/api/src/reminders/reminders.controller.ts GET /reminders, GET /reminders/email-status, POST /reminders, PATCH /reminders/:id, POST /reminders/:id/snooze, DELETE /reminders/:id
path provides
apps/api/src/reminders/reminder-mail.scheduler.ts 30-second global tick, reads candidates through the system context, then claims and sends each one tenant-bound
path provides
apps/web/src/lib/reminder-notify.ts Tauri-vs-browser notification helper, one-time permission request, local dedup claim with Web Locks
path provides
apps/web/src/components/reminders/reminder-notifier.tsx Global notifier mounted in AppShell, polls and fires on due
path provides
apps/web/src/components/dashboard/widgets/reminder-widget.tsx Erinnerungen widget: list, due highlight, create/edit modal, Erledigt, Später erinnern, e-mail toggle
path provides
apps/desktop/src-tauri/src/lib.rs server_origin_pattern() (host escaped for URLPattern, self-checked with tauri::utils::acl::RemoteUrlPattern, None when it does not parse or match) + grant_server_notifications() runtime remote capability; server_origin_* unit tests pin the exact 3-permission set, the IPv6 and wildcard-host patterns and the match/no-match behavior
from to via pattern
apps/web/src/components/layout/app-shell.tsx apps/web/src/components/reminders/reminder-notifier.tsx <ReminderNotifier /> next to <ReleaseNoticeHost />, so notifications fire on every portal page and not only when the widget is visible ReminderNotifier
from to via pattern
apps/web/src/lib/reminder-notify.ts tauri-plugin-notification window.__TAURI_INTERNALS__.invoke('plugin:notification|notify', { options: { title, body } }) plugin:notification|notify
from to via pattern
apps/desktop/src-tauri/src/lib.rs tauri runtime authority app.add_capability(CapabilityBuilder::new(..).remote(<stored origin>).window("main").permission(notification:*)) in setup() and save_server_url() add_capability
from to via pattern
apps/api/src/reminders/reminder-mail.scheduler.ts apps/api/src/mail/mail.service.ts claim via tenant-bound updateMany(where emailSentAt null, same dueAt) -> sendReminderEmail -> release claim only on transport failure sendReminderEmail
from to via pattern
apps/api/src/reminders/reminders.service.ts (snooze) Reminder.emailSentAt / emailAttempts snooze writes new dueAt AND resets emailSentAt=null, emailAttempts=0 emailAttempts: 0

Quick 260929-if2: Reminder widget "Erinnerungen" with notifications (desktop, browser, optional e-mail)

Build a new dashboard widget called "Erinnerungen" (widget type key `reminder`). A user sets personal one-time reminders (date + time + title + description). At the due time Tessera notifies them: a native OS notification in the desktop app (also while the window is hidden in the tray), a Web Notification in the browser, and optionally one e-mail sent by the server. After the due time the reminder stays in the widget, highlighted, until the user clicks "Erledigt" or snoozes it with "Später erinnern".

Purpose: this is the first time-driven feature that reaches the user outside the dashboard, and it reuses the SMTP setup, the desktop notification plugin and the RLS pattern that already exist.

Output: Prisma model + migration with RLS, NestJS module reminders (CRUD, snooze, email status, mail scheduler), web widget + global notifier + helpers, a Tauri runtime capability, tests, re-measured classification doc, CHANGELOG entry and user guide entry.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/STATE.md @./CLAUDE.md

Pattern sources, read before the task that uses them: @apps/api/src/custom-modules/custom-modules.service.ts @apps/api/src/custom-modules/custom-modules.controller.ts @apps/api/src/custom-modules/custom-modules.controller.spec.ts @apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql @apps/api/prisma/migrations/20260914120000_rls_system_context_read/migration.sql @apps/api/src/tenders/tender-digest.scheduler.ts @apps/api/src/mail/mail.service.ts @apps/api/src/prisma/prisma-tenant.extension.ts @apps/web/src/components/dashboard/widget-registry.tsx @apps/web/src/components/layout/app-shell.tsx @apps/web/src/lib/favorites-api.ts @apps/web/src/components/custom-modules/custom-module-form-modal.tsx @apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx @apps/desktop/src-tauri/src/lib.rs

Decisions

Locked (from the user, must be implemented exactly; cited as D-NN in the tasks):

  • D-01 One-time reminders only. No recurrence field and no recurrence UI.
  • D-02 No advance warning. Notifications and the e-mail fire exactly at dueAt, never before.
  • D-03 After the due time the reminder stays in the widget, marked as due, with two actions. "Erledigt" removes it from the list. "Später erinnern" offers +10 min, +1 h and "morgen zur gleichen Uhrzeit"; each option sets a new dueAt, the notifications fire again, and the e-mail is sent again if it is enabled.
  • D-04 Browser notifications: yes. The permission is requested once, from the widget, on the first reminder creation (a user gesture), never on page load.
  • D-05 Reminders are personal. Only the owner sees and edits them, and a foreign id returns 404.

Chosen by the planner (Claude's discretion). Each choice is documented in code comments where it applies:

  • E-01 Desktop mechanism: the page-side notifier plus a runtime remote capability. The global web notifier (it runs in the Tauri webview as well) calls the notification plugin through window.__TAURI_INTERNALS__.invoke('plugin:notification|notify', …). The static capabilities/default.json has no remote block on purpose (T-JN2-01, see the doc comment on get_server_url), so Tauri rejects plugin calls from the server page. The Rust side therefore adds, at runtime, one capability bound to exactly the stored server origin (scheme://host[:port]), limited to window main, and granting only notification:allow-notify, notification:allow-is-permission-granted and notification:allow-request-permission. App commands such as save_server_url stay local-only. Tauri 2.11.3 has dynamic-acl in its default features, and tauri::ipc::CapabilityBuilder::remote() plus Manager::add_capability() exist. Two alternatives were rejected. A Rust-side poll of the API fails because Basic-Auth in front of alpha returns 401 to reqwest (the same problem the updater has) and because the session cookie lives only in the webview. The Web Notification API inside the webview is not an option either: the plugin's init script replaces window.Notification with a polyfill that makes the same IPC call. On Windows that polyfill also reports permission = "denied" on every page load until requestPermission() runs. So the helper never uses Notification.permission inside Tauri and calls invoke directly. Timers in a hidden webview are throttled by Chromium (at most once per minute after 5 minutes hidden), so a notification from the tray can arrive up to about 1 minute late. That is accepted.
  • E-02 "Erledigt" deletes the row. No history UI was requested, and deleting avoids any retention question. The same DELETE /reminders/:id backs both "Löschen" (offered before due) and "Erledigt" (offered after due).
  • E-03 Catch-up window of 24 h. When a client opens late, it still notifies once for reminders that became due within the last 24 h. Older due reminders are only shown, highlighted, in the widget. The e-mail scheduler also only picks reminders due within the last 24 h. That covers API restarts and downtime, and it keeps a late SMTP setup from sending mails about old reminders.
  • E-04 E-mail delivery semantics. The scheduler claims before sending (emailSentAt = now, emailAttempts + 1, only where emailSentAt IS NULL, dueAt unchanged and emailAttempts < 3). It releases the claim (emailSentAt = null) only when the transport throws, so a failed send retries at most 3 times. When the tenant has no SmtpConfig or the user has no e-mail address at send time, the claim is kept: the occurrence counts as handled and is logged, with no send and no retry loop. A snooze resets both fields.
  • E-05 "Morgen zur gleichen Uhrzeit". Computed on the client in local time: take the original dueAt, add one calendar day (setDate(+1), which is DST-safe), and repeat until the result is in the future. Typical case: due today 14:00, snoozed at 14:05, new time tomorrow 14:00. +10 min and +1 h count from now, not from the old dueAt. The client sends the computed ISO dueAt, and the server only validates it.
  • E-06 Limits. At most 100 reminders per user (create returns 409 above that). Title 1–200 characters, description 0–2000 characters. dueAt must be after now and at most 5 years ahead (both return 400).
  • E-07 E-mail language and time zone. The mail is in German with the time formatted in Europe/Berlin (de-DE, dateStyle: 'full', timeStyle: 'short', followed by " Uhr"). No per-user locale is stored in User. The mail is text-only (no HTML), and CR/LF are stripped from the subject.
  • E-08 Scheduler tick. One global 30-second interval registered through SchedulerRegistry.addInterval in onApplicationBootstrap (lifecycle choice as in TenderSchedulerService). It does not use the require('cron') + cast workaround, which would add a Biome warning. An in-process running flag skips overlapping ticks.
  • E-09 SMTP "configured" means the tenant has a SmtpConfig row (SettingsService.getSmtpConfig(tenantId) !== null), the same rule as TenderMailService. The environment fallback of MailService does not count.

Interfaces (contract the three tasks share)

API (all routes need authentication; tenantId comes from req.tenantId and the user from @CurrentUser(); never from the body):

Route Body Result Errors
GET /reminders – Reminder[] of the caller, dueAt ascending –
GET /reminders/email-status (Task 3) – { smtpConfigured: boolean, hasEmail: boolean } –
POST /reminders { title, description?, dueAt (ISO 8601), emailEnabled? (Task 3) } Reminder 400 invalid/past/>5 y, 400 emailEnabled while unavailable, 409 limit
PATCH /reminders/:id (Task 2) partial create body Reminder 404 foreign/unknown, 409 already due, 400 as above
POST /reminders/:id/snooze (Task 2) { dueAt } Reminder 404, 409 not due yet, 400 past/>5 y
DELETE /reminders/:id (Task 2) – { deleted: true } 404

Reminder response = exactly { id, title, description, dueAt, emailEnabled, createdAt, updatedAt } through a REMINDER_SELECT constant (pattern CUSTOM_MODULE_SELECT). tenantId, userId, emailSentAt and emailAttempts never leave the service.

Prisma model Reminder: id String @id @default(uuid()), tenantId String, userId String, user User @relation(fields: [userId], references: [id], onDelete: Cascade), title String, description String @default(""), dueAt DateTime, emailEnabled Boolean @default(false), emailSentAt DateTime?, emailAttempts Int @default(0), createdAt DateTime @default(now()), updatedAt DateTime @updatedAt, @@index([tenantId, userId, dueAt]), @@index([dueAt]). User gets reminders Reminder[]. There is no doneAt column (E-02) and no recurrence column (D-01).

Web: apps/web/src/lib/reminders-api.ts exports the type Reminder (dates as ISO strings), ReminderRequestError (carries status: number), listReminders(), createReminder(input), updateReminder(id, patch), snoozeReminder(id, dueAt), deleteReminder(id), getReminderEmailStatus(). It follows the favorites-api.ts pattern: NEXT_PUBLIC_API_URL, credentials: 'include', and a non-2xx status throws ReminderRequestError(status). After every successful mutation the widget dispatches window.dispatchEvent(new Event('tessera:reminders-changed')) (constant REMINDERS_CHANGED_EVENT, exported from reminders-api.ts).

Execution segments (context budget)

The plan-level estimate (260k tokens raw, calibration factor 1 with 0 samples, confidence low) is above workflow.smart_zone_tokens (100k, measured with config-get). Quick mode runs exactly one 260929-if2-PLAN.md per task directory, so this plan is not split into separate plan files. The three task commits are the cut points instead, and each segment is sized on its own:

Segment Ends with commit subject Raw projection
Task 1 (tracer) feat(260929-if2): Erinnerungen anlegen und zur Faelligkeit benachrichtigen (Tracer) ~100k
Task 2 feat(260929-if2): faellige Erinnerungen erledigen, spaeter erinnern, bearbeiten und loeschen ~65k
Task 3 feat(260929-if2): Erinnerung zusaetzlich per E-Mail, Doku und Aenderungsliste ~95k

Rules for the executor:

  • Resume rule. Before the first task, run git log --format=%s -n 50 --grep='^feat(260929-if2): ' on the current branch. Start at the first task whose commit subject is missing. For every task that is already committed, re-run only its vitest and cargo <automated> commands (not the curl end-to-end command, which creates data) before continuing. Nothing is carried over from an earlier conversation: each task's <read_first> names everything it needs, and the committed code is the handoff.
  • Stop rule. Stop only directly after a task commit, never in the middle of a task. When the context is past roughly half of the budget after a commit, do not start the next task: write 260929-if2-SUMMARY.md with status: halted, the commit hashes and the measured gate results so far, plus the line "Fortsetzen bei Task N", and return. A new dispatch of the same plan continues through the resume rule and finally rewrites the SUMMARY with status: complete.
Task 1 (tracer): create a reminder, see it in the widget, get notified at due time (browser and desktop) apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260929140000_reminder/migration.sql, apps/api/src/reminders/reminders.module.ts, apps/api/src/reminders/reminders.controller.ts, apps/api/src/reminders/reminders.controller.spec.ts, apps/api/src/reminders/reminders.service.ts, apps/api/src/reminders/reminders.service.spec.ts, apps/api/src/reminders/dto/reminder.dto.ts, apps/api/src/app.module.ts, packages/shared/src/index.ts, apps/web/src/lib/reminders-api.ts, apps/web/src/lib/reminders-api.test.ts, apps/web/src/lib/reminder-notify.ts, apps/web/src/lib/reminder-notify.test.ts, apps/web/src/components/reminders/reminder-notifier.tsx, apps/web/src/components/reminders/reminder-notifier.test.tsx, apps/web/src/components/layout/app-shell.tsx, apps/web/src/components/dashboard/widgets/reminder-widget.tsx, apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx, apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widgets/widget-icon.tsx, apps/web/src/components/dashboard/widgets/widget-wrapper.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/desktop/src-tauri/src/lib.rs, docs/mandantentrennung-zugriffsklassifikation.md apps/api/src/custom-modules/custom-modules.service.ts, apps/api/src/custom-modules/custom-modules.controller.spec.ts, apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql, apps/api/prisma/migrations/20260929130000_custom_module_owner/migration.sql (header comment style), apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx (mock style for next-intl and the api module), apps/web/src/components/custom-modules/custom-module-form-modal.tsx, apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx (createPortal into document.body), apps/desktop/src-tauri/src/lib.rs lines 630-810 (commands, setup, window builder), docs/mandantentrennung-zugriffsklassifikation.md sections "Übersicht je Bereich" and "Bestandsaufnahme" Build ONE thin path through every layer: DB, API, web widget, global notifier, desktop Rust. Only create and list are in this task. Due highlight, edit, delete, snooze and e-mail come later, but the schema and the migration are final now because an applied migration can no longer be changed.
  1. DB (D-05). Add the Reminder model exactly as in "Interfaces" and reminders Reminder[] on User in apps/api/prisma/schema.prisma, with a German comment above the model ("quick-260929-if2: persoenliche Erinnerungen, einmalig (D-01)…"). Write the migration apps/api/prisma/migrations/20260929140000_reminder/migration.sql by hand:

    • Start with the mandatory German header comment in the style of 20260929130000_custom_module_owner: purpose, owner semantics, both policies, the note that app-role grants come through ALTER DEFAULT PRIVILEGES, and the "switch is OFF" note.
    • Generate the CREATE TABLE, index and FK statements with prisma migrate diff --from-url <local db url> --to-schema-datamodel prisma/schema.prisma --script, so that the names match Prisma (Reminder_pkey, Reminder_tenantId_userId_dueAt_idx, Reminder_dueAt_idx, Reminder_userId_fkey with ON DELETE CASCADE).
    • Then add ENABLE and FORCE ROW LEVEL SECURITY, plus tenant_isolation_policy in the user-dimension form of DashboardImage ("tenantId" = current_tenant_id() AND (current_user_id() IS NULL OR "userId" = current_user_id())).
    • Also add CREATE POLICY system_read_policy ON "Reminder" FOR SELECT USING (is_system_context());. The comment must say it serves the e-mail scheduler's candidate query (Task 3) and that it is read-only, as in migration 20260914120000.
    • Run pnpm --filter @tessera/api exec prisma generate.
  2. API module apps/api/src/reminders/, registered in apps/api/src/app.module.ts:

    • dto/reminder.dto.ts: CreateReminderDto with title (@IsString @IsNotEmpty @MaxLength(200), trimmed via @Transform), description (@IsOptional @IsString @MaxLength(2000)) and dueAt (@IsISO8601({ strict: true })). Do not add emailEnabled yet (Task 3).
    • reminders.service.ts: list(tenantId, userId) and create(tenantId, userId, dto).
      • Every method uses its own const tenantPrisma = forTenant(this.prisma, tenantId, userId). Always use that assignment form and always the name tenantPrisma, because rls-access-inventory.spec.ts and the doc's gate loop detect it by exactly that form.
      • Every where also carries tenantId and userId, as an app-level check while the RLS switch is off.
      • list returns the caller's rows ordered by dueAt ascending through REMINDER_SELECT, with dueAt serialized as ISO.
      • create rejects a dueAt that is not after now or more than 5 years ahead with BadRequestException, and returns 409 ConflictException once the user already has 100 rows (E-06). It sets tenantId and userId from the arguments only.
      • Add a German class comment explaining ownership (404, never 403, D-05) and the RLS binding.
    • reminders.controller.ts at path reminders. Build requireTenantId as in CustomModulesController, with @Get() list and @Post() create. Put a German ROUTE-ORDER comment at the top: every static GET route (Task 3 adds email-status) must stand above any :id route.
    • reminders.module.ts: controller + service (PrismaModule is global).
    • Specs:
      • reminders.service.spec.ts: list is scoped to tenant+user and sorted; create stores the ids from the arguments, not from the body; past dueAt gives 400; more than 5 years gives 400; the 101st reminder gives 409.
      • reminders.controller.spec.ts: tenantId is passed through; ForbiddenException without tenantId; the global ValidationPipe (whitelist) strips tenantId/userId from the body; there is a route-order assertion like in the custom-modules spec.
  3. Shared type: append 'reminder' at the end of WIDGET_TYPES in packages/shared/src/index.ts. It is a platform widget, so there is no entry in WIDGET_MODULE_SLUGS.

  4. Web data and notify helpers.

    • apps/web/src/lib/reminders-api.ts: the type Reminder, ReminderRequestError, REMINDERS_CHANGED_EVENT, listReminders and createReminder, as specified in "Interfaces". Test file reminders-api.test.ts: URLs, credentials: 'include', a non-2xx status throws with that status.
    • apps/web/src/lib/reminder-notify.ts, pure functions without React:
      • isTauriWebview(): true when window.__TAURI_INTERNALS__ has an invoke function. Narrow through unknown; no any and no non-null assertions, because of the Biome baseline.
      • requestBrowserPermissionOnce(): does nothing inside Tauri, when Notification is missing, when the permission is not 'default', or when the localStorage flag tessera.reminders.permissionAsked is already set. Otherwise it sets the flag and calls Notification.requestPermission() (D-04).
      • browserPermissionState(): returns 'desktop' | 'granted' | 'default' | 'denied' | 'unsupported'.
      • showReminderNotification({ title, body, tag }): inside Tauri it calls invoke plugin:notification|notify with { options: { title, body } } and catches errors with a single console.warn('[reminders] …'). Outside Tauri it creates new Notification(title, { body, tag }) only when the permission is 'granted', inside try/catch.
      • claimNotification(key, nowMs): a localStorage record tessera.reminders.notified mapping key to ms. It returns true only on the first claim of a key and prunes entries older than 7 days.
      • withNotifyLock(fn): runs fn under navigator.locks.request('tessera-reminder-notify', …) when available, otherwise calls it directly.
      • remindersToNotify(reminders, nowMs): returns the reminders with dueAt <= now and dueAt > now - 24 h (E-03, D-02: never before dueAt).
      • Comment in German why Tauri never uses Notification.permission (the polyfill reports "denied" on Windows until requestPermission, see E-01).
    • reminder-notify.test.ts covers: dedup (same key twice gives true, then false; the key ${id}|${dueAt} changes after a snooze), pruning, the Tauri branch calls invoke with the exact command and payload, the browser branch only with 'granted', the permission is asked at most once and never in Tauri, and the 24 h window.
  5. Global notifier apps/web/src/components/reminders/reminder-notifier.tsx ('use client', renders null). It is mounted in apps/web/src/components/layout/app-shell.tsx right after <ReleaseNoticeHost />, with a comment that it is global so notifications fire on every portal page, not only when the widget is visible.

    • It loads listReminders() on mount, every 60 s, on the REMINDERS_CHANGED_EVENT, on visibilitychange to visible, and on window focus.
    • A local 10-second tick against the cached list runs withNotifyLock → claimNotification('${id}|${dueAt}') → showReminderNotification.
    • Notification title: widgets.reminder.notificationTitle ("Erinnerung: {title}"). Body: the description, cut to 200 characters, or the due time formatted locally when the description is empty. Tag: reminder-${id}-${dueAt}.
    • On ReminderRequestError with status 401 it stops polling until the next focus. Other errors are ignored silently until the next tick.
    • reminder-notifier.test.tsx uses fake timers and a mocked api module: a due reminder gives exactly one notification across several ticks; a reminder that is not due yet gives none; after dueAt changes it notifies again; the change event triggers a refetch.
  6. Widget, minimal:

    • apps/web/src/components/dashboard/widgets/reminder-widget.tsx (WidgetProps) lists the user's reminders (title, due time via Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }), description clamped to 2 lines) and shows an empty state. A button "Neue Erinnerung" opens reminder-form-modal.tsx.
    • The modal is rendered with createPortal into document.body, because react-grid-layout transforms would break position: fixed (precedent: picture-frame-lightbox). It follows the dialog markup of custom-module-form-modal (role="dialog", aria-modal, Escape closes). Fields: date (type="date"), time (type="time"), title, description. The defaults are today and the next full hour.
    • Local inputs become ISO via new Date(${date}T${time}) → toISOString() in a small exported function (Task 2 moves it into reminder-time.ts). The client check "must be in the future" mirrors the server rule.
    • On submit, call requestBrowserPermissionOnce() synchronously first (a user gesture, D-04), then createReminder, then refetch and dispatch REMINDERS_CHANGED_EVENT.
    • Registration:
      • apps/web/src/components/dashboard/widget-registry.tsx: WIDGET_CONSTRAINTS.reminder = { minW: 8, minH: 4, defaultW: 12, defaultH: 10 } with a German comment giving the reason in 48-column units (like the note/favorites widgets: list plus button row; 8 columns ≈ 230 px is the smallest usable width). Add a ReminderIcon (bell) and the registry entry nameKey: 'reminder.name' / descriptionKey: 'reminder.description'.
      • apps/web/src/components/dashboard/widgets/widget-icon.tsx: bell path under reminder.
      • apps/web/src/components/dashboard/widgets/widget-wrapper.tsx: add 'reminder' to FRAME_HEADER_TYPES (the unified header supplies icon + name and the hide-title toggle).
      • apps/web/src/app/(portal)/page.tsx: registerWidget('reminder', ReminderWidget).
    • Texts under widgets.reminder in apps/web/src/messages/de.json and en.json: German uses "Sie" and real umlauts; English mirrors the keys. Keys for this task: name "Erinnerungen", description "Termine und Aufgaben mit Benachrichtigung zur gewünschten Zeit", add, empty, dateLabel, timeLabel, titleLabel, descriptionLabel, save, cancel, pastError, saveError, loadError, limitReached, notificationTitle.
    • reminder-widget.test.tsx: the list renders sorted; create calls createReminder with the ISO built from the local inputs; rendering does NOT call Notification.requestPermission; the first create calls it once; a second create does not call it again.
  7. Desktop (E-01) in apps/desktop/src-tauri/src/lib.rs. Facts measured during planning, which the code must respect: in tauri 2.11.3 add_capability runs Resolved::resolve(..).unwrap() while it holds the runtime-authority mutex (src/ipc/authority.rs, src/lib.rs), and tauri-utils 2.9.3 resolve_command panics with "invalid URL pattern for remote URL" on a pattern it cannot parse. So an unparsable pattern or an unknown permission does NOT come back as Err; it crashes setup(), and a catch_unwind around it would leave a poisoned mutex that breaks every later IPC call. The only safe guard is to validate the inputs before the call. Do not use catch_unwind.

    • Add fn server_origin_pattern(url: &str) -> Option<String>:
      • Reuse parse_server_url (http and https only) and take host_str().
      • Put a backslash in front of every host character outside ASCII A-Z, a-z, 0-9, . and - (URLPattern escaping). Why: the url crate accepts http://*.example.com/ and returns the host *.example.com, which unescaped would become a wildcard pattern for every subdomain. IPv6 hosts come back in brackets ([::1]), and the URLPattern tokenizer (urlpattern 0.3.0) rejects http://[::1]:8080 with Tokenizer(InvalidName, 1), while the escaped form http://\[\:\:1\]:8080 parses and matches only [::1]:8080. Both were measured.
      • Append :port only when the port is explicit and not the default. Path, query and fragment are dropped.
      • Self-check before returning: parse the pattern with tauri::utils::acl::RemoteUrlPattern (its FromStr is the parser Tauri uses for remote.urls) and require .test(&parsed_url) to be true. Return None otherwise.
    • Add const SERVER_NOTIFICATION_PERMISSIONS: [&str; 3] with exactly notification:allow-notify, notification:allow-is-permission-granted and notification:allow-request-permission. These identifiers exist in tauri-plugin-notification 2.3.3 (permissions/autogenerated/commands/notify.toml, is_permission_granted.toml, request_permission.toml). An unknown identifier would panic inside add_capability as well, which is one reason the exact-set test below exists.
    • Add fn grant_server_notifications(app: &AppHandle, url: &str):
      • When server_origin_pattern returns None, write one eprintln! and add no capability. Desktop toasts are then off for that address, while the browser notifications and the e-mail keep working.
      • Otherwise build tauri::ipc::CapabilityBuilder::new("server-notifications").remote(pattern).local(false).window("main") plus each permission, call app.add_capability(...), and on Err write one eprintln!. It must never fail startup.
    • Call it in setup() once the stored server URL is known, before the first navigate, and in save_server_url right after the store is saved, before navigate.
    • Doc comment in German: why a runtime capability and not the static default.json; exactly the stored origin, escaped and self-checked, never a wildcard; why the self-check is required (panic inside add_capability, see above); only notification permissions, no app commands; T-JN2-01 remains in force for get_server_url and the other commands; after a server change the old origin keeps notification rights until the app restarts (accepted, T-IF2-03). Also explain the rejected alternatives: the Rust poll (Basic-Auth 401 as with the updater, the session cookie lives in the webview) and the native Web Notification (plugin polyfill).
    • Unit tests in mod tests. Every new test name starts with server_origin_ followed by a German description, like the existing server_host_* tests, so that the verify command can count them. There are at least 10:
      • https://alpha.tessera.ctl.de/dashboard?x=1#h gives exactly https://alpha.tessera.ctl.de (path, query and fragment removed).
      • http://192.168.13.12:8080/ gives exactly http://192.168.13.12:8080.
      • https://alpha.tessera.ctl.de:443/ gives exactly https://alpha.tessera.ctl.de (the default port is omitted).
      • With and without a trailing slash, the result is the same.
      • ftp://… gives None, and unparsable input gives None.
      • IPv6: http://[::1]:8080/ gives exactly the raw string r"http://\[\:\:1\]:8080".
      • Wildcard host: http://*.example.com/ gives exactly r"http://\*.example.com", with no unescaped *.
      • Match behavior through tauri::utils::acl::RemoteUrlPattern: every pattern above parses. It matches its own origin with a different path and query. It does NOT match the other scheme, another port, the subdomain x.alpha.tessera.ctl.de, or a different host. The wildcard pattern does not match http://a.example.com/. The IPv6 pattern matches http://[::1]:8080/dashboard but not http://[::2]:8080/ and not http://[::1]/.
      • Exact permission set: assert_eq! of SERVER_NOTIFICATION_PERMISSIONS against the three identifiers above, in that order. This pins the set (T-IF2-03), so an added notification:default or a wildcard permission fails the test.
    • Run cargo fmt.
  8. RLS docs (the inventory spec enforces this now): add rows to the "Bestandsaufnahme" table of docs/mandantentrennung-zugriffsklassifikation.md for apps/api/src/reminders/reminders.service.ts / reminder (class muss-mandantengebunden, stand gebunden), with a reason that names quick-260929-if2, the user dimension and 404. Add an area row reminders in "Übersicht je Bereich", update the "Summe" row and the pair count in "Klassen-Verteilung". Re-measure these with the gate loop the doc describes (per area: this.prisma. / tenantPrisma. / systemPrisma. raw hits, .ts without spec). Write down measured numbers, not copied ones.

  9. Migrate locally and rebuild:

    • Read the DB container IP with docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1 (currently 172.19.0.2).
    • Run DATABASE_URL=postgresql://tessera:tessera_dev@$DB_IP:5432/tessera pnpm --filter @tessera/api exec prisma migrate deploy, then migrate diff --exit-code must be empty.
    • Run docker compose up -d --build web api. If the disk fills up, run docker builder prune -f (only the build cache).
    • The curl end-to-end command in <verify> creates one "Tracer-Test" reminder for testuser each time it runs and leaves it in place. Task 2 deletes every row with that title.
    • Commit locally: feat(260929-if2): Erinnerungen anlegen und zur Faelligkeit benachrichtigen (Tracer), message ending with the Co-Authored-By line. NEVER push. pnpm --filter @tessera/api exec vitest run src/reminders src/prisma/rls-coverage.spec.ts src/prisma/rls-access-inventory.spec.ts src/dashboard/widget-module-map.spec.ts <fails_when>non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found"</fails_when> pnpm --filter @tessera/web exec vitest run src/lib/reminder-notify.test.ts src/lib/reminders-api.test.ts src/components/reminders src/components/dashboard src/messages <fails_when>non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found"</fails_when> cd /home/vicolab/projects/tessera-ctl && cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib && cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib server_origin_ 2>&1 | grep -E 'test result: ok. [1-9][0-9]+ passed' && cargo fmt --manifest-path apps/desktop/src-tauri/Cargo.toml --check && cargo clippy --manifest-path apps/desktop/src-tauri/Cargo.toml -- -D warnings <fails_when>non-zero exit: "test result: FAILED" in the full run, no "test result: ok. N passed" line with N of at least 10 under the server_origin_ filter (grep prints nothing), a diff printed by cargo fmt --check, or an "error:" line from clippy</fails_when> DB_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1) && cd /home/vicolab/projects/tessera-ctl/apps/api && DATABASE_URL=postgresql://tessera:tessera_dev@$DB_IP:5432/tessera pnpm exec prisma migrate diff --from-url postgresql://tessera:tessera_dev@$DB_IP:5432/tessera --to-schema-datamodel prisma/schema.prisma --exit-code <fails_when>non-zero exit (2 when the database and schema.prisma differ, printing diff statements instead of "No difference detected."; 1 on a Prisma error such as an unreachable database)</fails_when> T=$(mktemp) && A=$(mktemp) && curl -sf -c "$T" -H 'Content-Type: application/json' -d '{"username":"testuser","password":"Test1234!test"}' http://localhost:3001/auth/login >/dev/null && curl -sf -c "$A" -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' http://localhost:3001/auth/login >/dev/null && DUE=$(date -u -d '+2 minutes' +%Y-%m-%dT%H:%M:00.000Z) && curl -sf -b "$T" -H 'Content-Type: application/json' -d "{"title":"Tracer-Test","dueAt":"$DUE"}" http://localhost:3001/reminders | grep -q '"id"' && curl -sf -b "$T" http://localhost:3001/reminders | grep -q 'Tracer-Test' && ADM=$(curl -sf -b "$A" http://localhost:3001/reminders) && echo "$ADM" | grep -q '^[' && ! echo "$ADM" | grep -q 'Tracer-Test' && echo "tracer e2e ok" <fails_when>non-zero exit and no "tracer e2e ok" line: a login, the POST or a GET answered with a non-2xx status (curl -f), the POST response has no "id", testuser's list lacks "Tracer-Test", admin's answer is not a JSON array, or admin's list contains "Tracer-Test"</fails_when>
  • The migration is applied locally and migrate diff is empty.
  • POST+GET work for testuser, and admin does not see testuser's reminder (D-05).
  • The widget is in the catalog and lists and creates reminders; the permission is asked only on the first create (D-04).
  • The notifier fires once per (id, dueAt) at or after dueAt, never before (D-02).
  • The Tauri branch invokes plugin:notification|notify; lib.rs grants the runtime capability for the stored origin only (E-01). The origin pattern is escaped and self-checked, so an IPv6 or wildcard-looking host can neither crash startup nor widen the grant. At least 10 server_origin_* tests pass, including the exact 3-permission set, and cargo test/fmt/clippy are green.
  • rls-coverage and rls-access-inventory are green, and the doc is re-measured.
  • Committed locally, not pushed.
Task 2: due state, "Erledigt", "Später erinnern", edit and delete before due apps/api/src/reminders/reminders.controller.ts, apps/api/src/reminders/reminders.controller.spec.ts, apps/api/src/reminders/reminders.service.ts, apps/api/src/reminders/reminders.service.spec.ts, apps/api/src/reminders/dto/reminder.dto.ts, apps/web/src/lib/reminders-api.ts, apps/web/src/lib/reminders-api.test.ts, apps/web/src/lib/reminder-time.ts, apps/web/src/lib/reminder-time.test.ts, apps/web/src/components/dashboard/widgets/reminder-widget.tsx, apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx, apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, docs/mandantentrennung-zugriffsklassifikation.md apps/api/src/reminders/reminders.service.ts (from Task 1), apps/api/src/custom-modules/custom-modules.service.ts (loadVisible → 404 pattern), apps/web/src/components/dashboard/widgets/reminder-widget.tsx (from Task 1), apps/web/src/app/globals.css (tokens --color-status-warn / --color-status-warn-fg), apps/web/src/messages/umlaut-guard.spec.ts - Service: PATCH on a foreign or unknown id gives 404; PATCH on a due reminder (dueAt <= now) gives 409; PATCH with a past dueAt gives 400; a valid PATCH changes only the given fields. - Service: snooze on a reminder that is not due yet gives 409; snooze with a past dueAt gives 400; a valid snooze writes the new dueAt AND emailSentAt=null AND emailAttempts=0; snooze on a foreign id gives 404. - Service: DELETE on a foreign id gives 404, on the own id it deletes and returns { deleted: true } (serves both "Löschen" and "Erledigt", E-02). - reminder-time: snoozeTarget('10m') = now+10 min, '1h' = now+1 h, 'tomorrow' = original local time on the next calendar day, repeated until it is in the future (E-05); localInputsToIso/isoToLocalInputs round-trip. - Widget: a due row gets a highlight + "Fällig" badge + "Erledigt" + "Später erinnern" (three options), without edit/delete; an upcoming row gets edit + delete, without Erledigt/Später; "Erledigt" calls deleteReminder and removes the row; each snooze option calls snoozeReminder with the dueAt from snoozeTarget; a row becomes due through the local 10 s tick without reloading. Expand the tracer so the full lifecycle of D-01/D-03 works end to end.
  1. API in apps/api/src/reminders/:

    • UpdateReminderDto = PartialType(CreateReminderDto) and SnoozeReminderDto { dueAt: IsISO8601 strict } in dto/reminder.dto.ts.
    • In the service, a private loadOwn(tenantPrisma, tenantId, userId, id) returns the row or throws NotFoundException for unknown, foreign-tenant and foreign-user rows alike (D-05, never 403).
    • update: 409 ConflictException when row.dueAt <= now ("Die Erinnerung ist bereits fällig"). A new dueAt passes the same future/5-year check as create, via a shared private assertValidDueAt.
    • snooze: 409 when row.dueAt > now (not due yet); validates dueAt; writes { dueAt, emailSentAt: null, emailAttempts: 0 } (D-03, so the e-mail fires again).
    • remove: deletes the row (E-02).
    • All of them use the const tenantPrisma = forTenant(this.prisma, tenantId, userId) assignment form, and the where clauses carry tenantId and userId.
    • Controller: @Patch(':id'), @Post(':id/snooze'), @Delete(':id'), all below the static routes.
    • Extend both specs with the behaviors above, and extend the route-order spec.
  2. Web helpers:

    • apps/web/src/lib/reminder-time.ts holds pure functions: snoozeTarget(preset: '10m' | '1h' | 'tomorrow', originalDueAt: Date, now: Date): Date per E-05, localInputsToIso(date, time): string | null (moved here from the Task 1 widget), isoToLocalInputs(iso): { date, time }, defaultNewReminderInputs(now) (the next full hour).
    • apps/web/src/lib/reminder-time.test.ts has the cases from <behavior>, including one across the end of a month.
    • Extend apps/web/src/lib/reminders-api.ts with updateReminder, snoozeReminder and deleteReminder, plus tests.
  3. Widget apps/web/src/components/dashboard/widgets/reminder-widget.tsx:

    • now state refreshed every 10 s decides due vs. upcoming. The sort stays dueAt ascending.
    • Due rows (D-03): border/background from the status-warn token (border-status-warn, bg-status-warn/10, readable in dark mode) and a "Fällig" badge (bg-status-warn text-status-warn-fg). Buttons "Erledigt" (calls deleteReminder) and "Später erinnern", which toggles an inline option row: "In 10 Minuten", "In 1 Stunde", "Morgen um {time}", where {time} is the original local time. It calls snoozeReminder(id, snoozeTarget(...).toISOString()).
    • Upcoming rows: edit (pencil) opens reminder-form-modal.tsx prefilled through isoToLocalInputs and saves with updateReminder. Delete (trash) uses an inline two-step confirm ("Löschen?" Ja/Nein).
    • On 409 the widget shows the matching text (edit → alreadyDue, snooze → notDue, create → limitReached) and refetches.
    • After every successful mutation: refetch + dispatch REMINDERS_CHANGED_EVENT, so the global notifier picks up a new dueAt immediately.
    • Buttons are compact and allowed to wrap at minW 8.
    • Browser hint: when browserPermissionState() is 'denied', show a muted line saying that the browser blocks notifications and that due reminders then only appear here. Show nothing in Tauri.
  4. New texts under widgets.reminder in de/en: due, done, snooze, snooze10m, snooze1h, snoozeTomorrow (with {time}), edit, delete, deleteConfirm, yes, no, alreadyDue, notDue, permissionDenied. Run umlaut-guard.spec.ts. Extend apps/web/src/messages/umlaut-dictionary.ts only if the guard flags a correct German token.

  5. Re-measure the reminders area row and the "Summe" row in docs/mandantentrennung-zugriffsklassifikation.md with the gate loop, since the service has new bound raw hits.

  6. Rebuild with docker compose up -d --build web api. Then, as testuser: DELETE every "Tracer-Test" row from Task 1 (the tracer verify may have run more than once), so GET no longer lists that title. Check that admin gets 404 for PATCH, snooze and DELETE on a testuser id. Commit feat(260929-if2): faellige Erinnerungen erledigen, spaeter erinnern, bearbeiten und loeschen (Co-Authored-By line; NEVER push). pnpm --filter @tessera/api exec vitest run src/reminders src/prisma/rls-access-inventory.spec.ts <fails_when>non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found"</fails_when> pnpm --filter @tessera/web exec vitest run src/lib/reminder-time.test.ts src/lib/reminders-api.test.ts src/components/dashboard/widgets/reminder-widget.test.tsx src/components/reminders src/messages <fails_when>non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found"</fails_when>

  • API: foreign ids give 404 on PATCH, snooze and DELETE; editing a due reminder gives 409; snoozing a reminder that is not due gives 409; snooze resets emailSentAt and emailAttempts.
  • Widget: due rows are highlighted with Erledigt and the three snooze options (D-03); upcoming rows are editable and deletable.
  • The Tracer-Test row is removed; the doc is re-measured; committed locally.
Task 3: optional e-mail at due time (exactly once), toggle in the widget, docs, full gates, manual check list apps/api/src/reminders/reminder-mail.scheduler.ts, apps/api/src/reminders/reminder-mail.scheduler.spec.ts, apps/api/src/reminders/reminders.service.ts, apps/api/src/reminders/reminders.service.spec.ts, apps/api/src/reminders/reminders.controller.ts, apps/api/src/reminders/reminders.controller.spec.ts, apps/api/src/reminders/reminders.module.ts, apps/api/src/reminders/dto/reminder.dto.ts, apps/api/src/mail/mail.service.ts, apps/api/src/mail/mail.service.spec.ts, apps/api/src/prisma/rls-access-inventory.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/reminders-api.ts, apps/web/src/lib/reminders-api.test.ts, apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx, apps/web/src/components/dashboard/widgets/reminder-widget.tsx, apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md, docs/anleitung-anwender.md apps/api/src/tenders/tender-digest.scheduler.ts (the forSystem candidates → forTenant loop, per-candidate try/catch), apps/api/src/tenders/tender-digest.scheduler.spec.ts (how forSystem/forTenant are mocked), apps/api/src/tenders/tender-scheduler.service.ts (onApplicationBootstrap), apps/api/src/mail/mail.service.ts (deliver, sendBugReport), apps/api/src/settings/settings.service.ts (getSmtpConfig), apps/api/src/prisma/rls-access-inventory.spec.ts lines 130-192 (FORSYSTEM_ALLOWED_CALL_SITES with its history comment), docs/mandantentrennung-zugriffsklassifikation.md section "Der Hintergrunddienst als Falle", CHANGELOG.md head, docs/anleitung-anwender.md section "Dashboard" (table "Verfügbare Widgets") and "Fenster, Infobereich und Beenden" - Claim-once: two scheduler instances (or two overlapping ticks) against the same fake store, where updateMany returns count 1 for the first claim and 0 afterwards, lead to exactly one sendReminderEmail call. - Transport failure (sendReminderEmail returns false) releases the claim (emailSentAt=null only where emailSentAt equals the claimed timestamp), so the next tick retries; after 3 attempts (emailAttempts >= 3) the reminder is no longer a candidate. - No SmtpConfig or no user e-mail at send time: the claim is kept, nothing is sent, one log line appears, and nothing is retried (E-04). - The candidate query selects only emailEnabled=true, emailSentAt=null, emailAttempts<3, dueAt <= now AND dueAt >= now-24h (E-03), and only scalar fields (no relation include on the system client). - One failing candidate does not stop the others; an overlapping tick is skipped while `running` is true. - After a snooze (Task 2 reset) the same reminder is a candidate again and gets exactly one more e-mail (D-03). - MailService.sendReminderEmail: subject "Erinnerung: