mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +02:00
Echter Server-Cron für recurring forms — wave-send läuft jetzt
unabhängig von Owner-Tab-State. Bisheriger M10c webapp-side scheduler
bleibt als Belt-and-suspenders aktiv (idempotent).
Architektur:
1. **Owner-private internal_meta auf unlisted snapshots**
- Drizzle: neue jsonb-column `internal_meta` (Drizzle migration
0001_internal_meta.sql).
- public-routes.ts strippt sie strukturell — die explicit select()-
projection enthält sie nicht (recipients + sender würden sonst
via share-link leaken).
- publish-route akzeptiert sie im Body, persistiert auf insert +
update.
- ALLOWED_COLLECTIONS um 'lasts' und 'forms' erweitert (war ein
latenter Bug — formsStore.setVisibility('unlisted') hätte ohne
diese Ergänzung 400 zurückbekommen; M4b lief vermutlich nie
end-to-end durch).
2. **shared-privacy publishUnlistedSnapshot**
- PublishUnlistedOptions erweitert um optionales `internalMeta`.
Forwarded an /api/v1/unlisted/:collection/:recordId body.
3. **Webapp formsStore**
- lib/wave-mail.ts: buildFormInternalMeta(form, broadcastSettings)
baut den Owner-Private-Blob: { kind, recurrence: {frequency,
recipientEmails, lastSentAt}, sender: {fromEmail, fromName,
replyTo, legalAddress}, formMeta: {title, description} }.
Returns null wenn Voraussetzungen fehlen (kein recurrence, keine
recipients, fehlende broadcast-settings).
- stores/forms.svelte.ts: setVisibility / regenerateUnlistedToken /
setUnlistedExpiry laden broadcastSettings via Dexie + decrypt,
bauen internalMeta, übergeben an publishUnlistedSnapshot. Form
wird vor dem buildFormInternalMeta-Call dekrypted.
4. **mana-mail internal bulk-send route**
- createInternalRoutes(accountService, broadcastOrchestrator,
maxRecipients) — Signature erweitert.
- Neue POST /api/v1/internal/mail/bulk-send: gleicher Payload-shape
wie user-facing /v1/mail/bulk-send aber userId aus Body statt
JWT. X-Service-Key-gate sitzt bei /api/v1/internal/* prefix.
Audit-trail trägt principalId aus Body. Cap = 5000 (gleicher
Wert wie user-facing).
5. **apps/api forms wave-worker**
- 5-min setInterval, advisory-lock-gated (key 0x464f5257 'FORW').
- Tick: select snapshots WHERE collection='forms' AND
internal_meta IS NOT NULL AND revoked_at IS NULL. Filter auf
kind='forms-recurrence' + isWaveDue (lastSentAt + period <= now,
never-sent fires sofort). Pro fälligem snapshot: build HTML/text
mailbody (mirror webapp wave-mail-render), POST an mana-mail
internal-bulk-send mit X-Service-Key + userId, dann jsonb_set
auf internal_meta.recurrence.lastSentAt. Per-snapshot errors
werden als console.warn geloggt, Tick läuft weiter.
- Disable via FORMS_WAVE_WORKER_DISABLED=true (tests / multi-
replica deployments).
- Wired in apps/api/src/index.ts neben startArticleImportWorker().
Trade-offs:
- internal_meta wird beim setVisibility/regenerate/setExpiry frisch
aus broadcast-settings gebaut — wenn der User später broadcast-
settings ändert (zB neuer fromEmail) muss er das Form re-publishen
damit die snapshot-internal_meta aktualisiert wird. Doc-it: zukünftiger
Patch könnte ein "settings drift"-Warning ins UI surfacen.
- Worker-Update von lastSentAt geht NICHT zurück in den webapp-form
(settings.recurrence.lastSentAt ist verschlüsselt, server kann
nicht schreiben). Owner-UI zeigt ältere lastSentAt von manuellen
Sends; auto-cron-sends sind in den Server-Logs sichtbar. Future
patch: GET /api/v1/forms/:id/recurrence-status (auth) gibt das
snapshot.internal_meta zurück, UI rendert Auto-Cron-State.
- Webapp-side wave-scheduler (M10c) läuft parallel weiter — wenn
Owner-Tab offen ist, kann beides feuern. Idempotent durch
lastSentAt-check (weekly/monthly buckets), aber theoretisch könnte
double-fire passieren wenn die Calls innerhalb 1ms versetzt sind.
Real-world ignorierbar; future patch: scheduler liest jetzt
internal_meta.lastSentAt vom server-side state.
apps/api buildet (1776 modules). mana-mail buildet (523 modules).
svelte-check 0 errors in forms/. Forms-Tests 70/70 unverändert.
DB-Migration 0001_internal_meta.sql muss manuell appliziert werden
(siehe feedback memory: hand-authored SQL migrations sind nicht in
pnpm setup:db).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
85 lines
2.8 KiB
TypeScript
85 lines
2.8 KiB
TypeScript
/**
|
|
* Internal routes — service-to-service (X-Service-Key auth).
|
|
*/
|
|
|
|
import { Hono } from 'hono';
|
|
import { z } from 'zod';
|
|
import type { AccountService } from '../services/account-service';
|
|
import type { BroadcastOrchestrator } from '../services/broadcast-orchestrator';
|
|
import { onUserCreatedSchema } from '../lib/validation';
|
|
|
|
const recipientSchema = z.object({
|
|
email: z.string().email(),
|
|
name: z.string().optional(),
|
|
contactId: z.string().optional(),
|
|
});
|
|
|
|
/**
|
|
* Internal bulk-send (M10d, headless wave-cron):
|
|
* Same payload-shape as the user-facing /api/v1/mail/bulk-send, but
|
|
* the userId comes from the body instead of a JWT — the caller is a
|
|
* trusted Mana service (apps/api forms wave-worker). The X-Service-Key
|
|
* gate sits at the route prefix in index.ts; we additionally require
|
|
* the body to name a userId so audit-logs always carry a principal.
|
|
*/
|
|
const internalBulkSendSchema = z.object({
|
|
userId: z.string().min(1),
|
|
campaignId: z.string().min(1),
|
|
subject: z.string().min(1),
|
|
fromName: z.string().min(1),
|
|
fromEmail: z.string().email(),
|
|
replyTo: z.string().email().optional(),
|
|
htmlBody: z.string().min(1),
|
|
textBody: z.string().min(1),
|
|
recipients: z.array(recipientSchema).min(1).max(5000),
|
|
});
|
|
|
|
export function createInternalRoutes(
|
|
accountService: AccountService,
|
|
broadcastOrchestrator: BroadcastOrchestrator,
|
|
maxBroadcastRecipients: number
|
|
) {
|
|
return new Hono()
|
|
.post('/mail/on-user-created', async (c) => {
|
|
const body = onUserCreatedSchema.parse(await c.req.json());
|
|
try {
|
|
const account = await accountService.provisionAccount(body.userId, body.email, body.name);
|
|
console.log(`[mana-mail] Provisioned ${account.email} for user ${body.userId}`);
|
|
return c.json({ success: true, email: account.email });
|
|
} catch (err) {
|
|
console.error(`[mana-mail] Failed to provision account for ${body.userId}:`, err);
|
|
return c.json(
|
|
{ success: false, error: err instanceof Error ? err.message : 'Unknown error' },
|
|
500
|
|
);
|
|
}
|
|
})
|
|
.post('/mail/on-user-deleted', async (c) => {
|
|
// Phase 2: Deactivate Stalwart account
|
|
return c.json({ success: true, message: 'Not yet implemented' });
|
|
})
|
|
.post('/mail/bulk-send', async (c) => {
|
|
const body = internalBulkSendSchema.parse(await c.req.json());
|
|
if (body.recipients.length > maxBroadcastRecipients) {
|
|
return c.json(
|
|
{
|
|
error: `Recipient count ${body.recipients.length} exceeds configured cap ${maxBroadcastRecipients}`,
|
|
},
|
|
400
|
|
);
|
|
}
|
|
const result = await broadcastOrchestrator.run({
|
|
userId: body.userId,
|
|
campaignId: body.campaignId,
|
|
subject: body.subject,
|
|
fromName: body.fromName,
|
|
fromEmail: body.fromEmail,
|
|
replyTo: body.replyTo,
|
|
htmlBody: body.htmlBody,
|
|
textBody: body.textBody,
|
|
recipients: body.recipients,
|
|
maxRecipients: maxBroadcastRecipients,
|
|
});
|
|
return c.json(result);
|
|
});
|
|
}
|