From 7d8e562091881dfcb42e27d115ddd534b1f0f50f Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 6 May 2026 15:15:12 +0200 Subject: [PATCH] feat(forms): M10c auto-scheduler + mana-mail bulk-send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headless wave-send während die Mana-Tab offen ist (M10c). Echter Server-Cron (mana-ai oder mana-notify) bleibt M10d. - lib/wave-mail.ts: sendWaveViaBulkMail POSTet an /api/v1/mail/bulk-send mit form-derived payload (subject = "{title} — {cohort}", htmlBody mit Inline-CSS + share-link-Button + Impressum + Abmelden-Footer, textBody plain). campaignId = form-{formId}-{cohort} (idempotent über Retries). Wirft WavePreconditionError wenn fromEmail/fromName/legalAddress fehlen oder Empfänger leer sind — Caller fällt auf mailto-Bridge zurück. - lib/wave-scheduler.ts: singleton setInterval (5 min, Page-visibility-aware — pausiert bei hidden), Tick scant formTable, dekrypt-aware, filtert published+token+recurrence+recipients+due, ruft sendWaveViaBulkMail + markWaveSent. Wirft nicht — per-form errors werden als console.warn geloggt, Schedule läuft weiter. Initial-tick 30s nach start damit Montag-Morgen-Welle nicht 5 Minuten warten muss. start/stop idempotent. - BuilderView.sendWave: versucht erst bulk-send (wenn broadcasts-Settings configured = defaultFromEmail + legalAddress), fällt auf mailto-Bridge zurück (M10b) wenn precondition fehlt. waveError-state für non-precondition-Fehler. Confirm-Dialog hat jetzt zwei Texte (confirmBulk vs confirmSend). - (app)/+layout.svelte: startWaveScheduler() neben startMissionTick() beim Auth-Ready, stopWaveScheduler() im onDestroy. - 5 neue i18n-Keys × 5 Locales (forms.builder.recurrence.confirmBulk). Parity 6495. Trade-offs: - Auto-Tick nur während Tab offen — headless Cron via mana-ai-Mission oder mana-notify-Worker bleibt M10d. - Bulk-send bypasst die Campaign-Pipeline der broadcasts (kein Audience-Filter, kein Rich-Editor) — ist Absicht für Forms-Wellen als kurze transactional notifications. - DSGVO: Impressum + Abmelden-Footer ({{unsubscribe_url}} wird vom Orchestrator pro Empfänger ersetzt) sind Pflicht via WavePreconditionError; Mailto-Fallback hat das nicht — User-Risk. Forms-Tests 61/61 unverändert. svelte-check 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/src/lib/i18n/locales/forms/de.json | 3 +- .../web/src/lib/i18n/locales/forms/en.json | 3 +- .../web/src/lib/i18n/locales/forms/es.json | 3 +- .../web/src/lib/i18n/locales/forms/fr.json | 3 +- .../web/src/lib/i18n/locales/forms/it.json | 3 +- .../apps/web/src/lib/modules/forms/index.ts | 3 + .../src/lib/modules/forms/lib/wave-mail.ts | 181 ++++++++++++++++++ .../lib/modules/forms/lib/wave-scheduler.ts | 163 ++++++++++++++++ .../modules/forms/views/BuilderView.svelte | 66 +++++++ .../apps/web/src/routes/(app)/+layout.svelte | 7 + 10 files changed, 430 insertions(+), 5 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/forms/lib/wave-mail.ts create mode 100644 apps/mana/apps/web/src/lib/modules/forms/lib/wave-scheduler.ts diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json index 5c85c8d4d..cadcdadca 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json @@ -91,7 +91,8 @@ "needsRecipients": "Trage Empfänger-Emails in den Settings ein.", "mailSubject": "Bitte ausfüllen: {title}", "mailBody": "{description}Hier kannst du antworten:\n{url}\n\nDanke!", - "confirmSend": "Welle an {n} Empfänger senden? Dein Mail-Programm öffnet sich mit BCC-Liste und Link. Nach dem Versand stempeln wir den Zeitpunkt." + "confirmSend": "Welle an {n} Empfänger senden? Dein Mail-Programm öffnet sich mit BCC-Liste und Link. Nach dem Versand stempeln wir den Zeitpunkt.", + "confirmBulk": "Welle an {n} Empfänger via Mana-Mail senden? Antworten landen direkt in deinem Forms-Inbox." }, "autoSync": { "title": "Auto-Sync — bei Antwort erzeugen", diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json index 13c9017c6..769fb87cd 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json @@ -91,7 +91,8 @@ "needsRecipients": "Add recipient emails in the settings.", "mailSubject": "Please fill in: {title}", "mailBody": "{description}Reply here:\n{url}\n\nThanks!", - "confirmSend": "Send wave to {n} recipients? Your mail client opens with the BCC list and link prefilled. We'll stamp the timestamp after." + "confirmSend": "Send wave to {n} recipients? Your mail client opens with the BCC list and link prefilled. We'll stamp the timestamp after.", + "confirmBulk": "Send wave to {n} recipients via Mana Mail? Replies land directly in your forms inbox." }, "autoSync": { "title": "Auto-sync — create on submit", diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json index bec3d3ec6..3260f52c9 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json @@ -91,7 +91,8 @@ "needsRecipients": "Añade emails de destinatarios en los ajustes.", "mailSubject": "Por favor rellena: {title}", "mailBody": "{description}Responde aquí:\n{url}\n\n¡Gracias!", - "confirmSend": "¿Enviar ola a {n} destinatarios? Tu cliente de correo se abrirá con la lista BCC y el enlace prellenado. Marcamos la marca de tiempo después." + "confirmSend": "¿Enviar ola a {n} destinatarios? Tu cliente de correo se abrirá con la lista BCC y el enlace prellenado. Marcamos la marca de tiempo después.", + "confirmBulk": "¿Enviar ola a {n} destinatarios vía Mana-Mail? Las respuestas llegan directamente a tu bandeja de Forms." }, "autoSync": { "title": "Auto-sync — crear al recibir respuesta", diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json index fbdffdab2..ff1b63cf4 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json @@ -91,7 +91,8 @@ "needsRecipients": "Ajoute des emails de destinataires dans les paramètres.", "mailSubject": "Merci de remplir : {title}", "mailBody": "{description}Réponds ici :\n{url}\n\nMerci !", - "confirmSend": "Envoyer la vague à {n} destinataires ? Ton client mail s'ouvre avec la liste BCC et le lien préremplis. On marque l'horodatage ensuite." + "confirmSend": "Envoyer la vague à {n} destinataires ? Ton client mail s'ouvre avec la liste BCC et le lien préremplis. On marque l'horodatage ensuite.", + "confirmBulk": "Envoyer la vague à {n} destinataires via Mana-Mail ? Les réponses arrivent directement dans ta boîte Forms." }, "autoSync": { "title": "Auto-sync — créer à la soumission", diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json index e8e07d9ef..a012fc6b0 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json @@ -91,7 +91,8 @@ "needsRecipients": "Aggiungi email dei destinatari nelle impostazioni.", "mailSubject": "Per favore compila: {title}", "mailBody": "{description}Rispondi qui:\n{url}\n\nGrazie!", - "confirmSend": "Invia l'ondata a {n} destinatari? Il tuo client di posta si apre con la lista BCC e il link precompilati. Marchiamo l'orario dopo." + "confirmSend": "Invia l'ondata a {n} destinatari? Il tuo client di posta si apre con la lista BCC e il link precompilati. Marchiamo l'orario dopo.", + "confirmBulk": "Invia l'ondata a {n} destinatari via Mana-Mail? Le risposte arrivano direttamente nella tua casella Forms." }, "autoSync": { "title": "Auto-sync — crea al ricevere risposta", diff --git a/apps/mana/apps/web/src/lib/modules/forms/index.ts b/apps/mana/apps/web/src/lib/modules/forms/index.ts index db04b4448..4c1532cac 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/index.ts +++ b/apps/mana/apps/web/src/lib/modules/forms/index.ts @@ -29,6 +29,9 @@ export { export { computeCohort, cohortLabel, sortCohortsDesc } from './lib/cohort'; export type { RecurrenceFrequency } from './lib/cohort'; export { nextWaveDueAt, isWaveDue, buildWaveMailto, parseRecipientEmails } from './lib/wave'; +export { sendWaveViaBulkMail, WavePreconditionError } from './lib/wave-mail'; +export type { WaveBulkSendResult } from './lib/wave-mail'; +export { startWaveScheduler, stopWaveScheduler } from './lib/wave-scheduler'; // ─── Types ─────────────────────────────────────────────── export { diff --git a/apps/mana/apps/web/src/lib/modules/forms/lib/wave-mail.ts b/apps/mana/apps/web/src/lib/modules/forms/lib/wave-mail.ts new file mode 100644 index 000000000..9926b95f9 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/forms/lib/wave-mail.ts @@ -0,0 +1,181 @@ +/** + * Wave bulk-send via mana-mail (M10c). + * + * Headless wave-send for recurring forms: instead of opening a + * mailto: link (the M10b bridge, which requires a user gesture and + * caps at ~50 BCC recipients), we POST directly to mana-mail's + * /v1/mail/bulk-send with a minimal HTML/text payload built from + * the form's title + description + share-link. + * + * Pre-conditions: + * - The form has `recurrence` configured + non-empty + * `recipientEmails` + a published unlisted share-link. + * - The user has filled BroadcastSettings.defaultFromEmail and + * legalAddress in /broadcasts/settings (DSGVO mandate). + * + * If those preconditions aren't met, the caller falls back to the + * M10b mailto bridge. + */ + +import { browser } from '$app/environment'; +import type { BroadcastSettings } from '$lib/modules/broadcasts/types'; +import type { Form } from '../types'; + +export interface WaveBulkSendResult { + campaignId: string; + accepted: number; + delivered: number; + failed: number; + errors: Array<{ email: string; reason: string }>; +} + +export class WavePreconditionError extends Error { + constructor(message: string) { + super(message); + this.name = 'WavePreconditionError'; + } +} + +function getMailUrl(): string { + if (browser) { + const fromWindow = (window as unknown as { __PUBLIC_MANA_MAIL_URL__?: string }) + .__PUBLIC_MANA_MAIL_URL__; + if (fromWindow) return fromWindow; + } + return import.meta.env.PUBLIC_MANA_MAIL_URL || 'http://localhost:3042'; +} + +/** + * Render a minimal HTML body for the wave email. Inline-styled because + * email clients ignore