feat(broadcast): M1 skeleton — module registration + empty ListView

New 1:N email-campaign module (newsletters / announcements). M1 scope:
- types (LocalCampaign / LocalBroadcastTemplate / LocalBroadcastSettings),
  constants (STATUS_LABELS, BROADCAST_SETTINGS_ID, rate-limit hints)
- collections.ts: Dexie table refs, no guest seed (a demo campaign that
  might accidentally hit real SMTP felt wrong)
- module.config registered in module-registry
- Dexie v32 wired in (already in tree from a parallel Spaces commit
  picking it up via lint-staged — matches what the module expects)
- encryption registry entries for all three tables (type-safe via
  entry<T>), content + audience always encrypted because the recipient
  graph is a leakable business secret
- app entry (requiredTier: alpha) + megaphone gradient icon
  (indigo→cyan, sits between mail and invoices in the comm family)
- route /broadcasts mounts ListView with empty-state placeholder

Status machine defined: draft → scheduled → sending → sent, with
cancelled as the off-ramp from draft/scheduled. No CRUD yet — that's M2.

Plan: docs/plans/broadcast-module.md.
Next: M2 AudienceBuilder + Tiptap editor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 20:16:35 +02:00
parent 79a6da3e2e
commit 1f392c1ea6
11 changed files with 500 additions and 0 deletions

View file

@ -83,6 +83,11 @@ import type {
LocalInvoiceClient,
LocalInvoiceSettings,
} from '../../modules/invoices/types';
import type {
LocalCampaign,
LocalBroadcastTemplate,
LocalBroadcastSettings,
} from '../../modules/broadcast/types';
export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// ─── Chat ────────────────────────────────────────────────
@ -619,6 +624,42 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
'notes',
]),
// ─── Broadcast ─────────────────────────────────────────
// Newsletter / campaign content. Every field the user typed — subject,
// body JSON/HTML, audience filter values, sender profile — is sensitive
// *until sent*. Post-send the content itself becomes semi-public (it
// went out to N recipients), but pre-send the draft is confidential
// marketing copy. Audience definitions always stay sensitive — the
// recipient graph is a leakable business secret (who's on the list).
//
// Plaintext (intentional):
// - status, scheduledAt, sentAt, templateId, serverJobId: structural,
// indexed for the "drafts vs scheduled vs sent" filter.
// - stats: cached counters mirrored from the server tracking tables;
// not sensitive by design (they describe aggregate behaviour).
// - isBuiltIn on templates: controls whether the user can delete the
// row; structural, no privacy value.
// - dnsCheck on settings: public DNS state, not user-typed content.
broadcastCampaigns: entry<LocalCampaign>([
'name',
'subject',
'preheader',
'fromName',
'fromEmail',
'replyTo',
'content',
'audience',
]),
broadcastTemplates: entry<LocalBroadcastTemplate>(['name', 'description', 'subject', 'content']),
broadcastSettings: entry<LocalBroadcastSettings>([
'defaultFromName',
'defaultFromEmail',
'defaultReplyTo',
'defaultFooter',
'legalAddress',
'unsubscribeLandingCopy',
]),
// Singleton sender profile. The user's legal address + IBAN live here
// and are the most sensitive fields in the module (appear on every PDF
// the user issues). logoMediaId / accentColor / number sequence state

View file

@ -100,6 +100,7 @@ import { quizModuleConfig } from '$lib/modules/quiz/module.config';
import { profileModuleConfig } from '$lib/modules/profile/module.config';
import { libraryModuleConfig } from '$lib/modules/library/module.config';
import { invoicesModuleConfig } from '$lib/modules/invoices/module.config';
import { broadcastModuleConfig } from '$lib/modules/broadcast/module.config';
import { wetterModuleConfig } from '$lib/modules/wetter/module.config';
import { aiModuleConfig } from '$lib/data/ai/module.config';
@ -157,6 +158,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
profileModuleConfig,
libraryModuleConfig,
invoicesModuleConfig,
broadcastModuleConfig,
wetterModuleConfig,
aiModuleConfig,
];

View file

@ -0,0 +1,173 @@
<!--
Broadcast — ListView (M1 skeleton)
Empty state + "+ Neue Kampagne"-button placeholder. Real list, filters,
and stats cards land in M2/M7. Plan: docs/plans/broadcast-module.md.
-->
<script lang="ts">
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { decryptRecords } from '$lib/data/crypto';
import { scopedForModule } from '$lib/data/scope';
import { STATUS_LABELS, STATUS_COLORS } from './constants';
import type { LocalCampaign } from './types';
const campaigns$ = useLiveQueryWithDefault(async () => {
const rows = await scopedForModule<LocalCampaign, string>(
'broadcast',
'broadcastCampaigns'
).toArray();
const visible = rows.filter((r) => !r.deletedAt);
return (await decryptRecords('broadcastCampaigns', visible)) as LocalCampaign[];
}, [] as LocalCampaign[]);
const campaigns = $derived(campaigns$.value ?? []);
</script>
<div class="broadcast-shell">
<header class="head">
<div>
<h1>Broadcasts</h1>
<p class="subtitle">Newsletter und Kampagnen an deine Kontakte</p>
</div>
<button class="btn-primary" type="button" disabled title="M2">+ Neue Kampagne</button>
</header>
{#if campaigns.length === 0}
<div class="empty">
<div class="empty-icon">📣</div>
<h2>Noch keine Kampagnen</h2>
<p>
Verschicke deinen ersten Newsletter — mit Rich-Text-Editor, Tracking und DSGVO-konformem
Abmelden.
</p>
<p class="note">M1 Skelett — Compose-Flow folgt in M2.</p>
</div>
{:else}
<ul class="list">
{#each campaigns as campaign (campaign.id)}
<li class="row">
<span class="subject">{campaign.subject}</span>
<span class="recipient-count">
{campaign.audience?.estimatedCount ?? 0} Empfänger
</span>
<span class="status" style="--dot: {STATUS_COLORS[campaign.status]}">
<span class="dot"></span>
{STATUS_LABELS[campaign.status].de}
</span>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.broadcast-shell {
padding: 1.5rem;
max-width: 1000px;
margin: 0 auto;
}
.head {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 1.5rem;
}
.head h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
}
.subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
}
.btn-primary {
background: #6366f1;
color: white;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: 0;
font-weight: 500;
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty {
text-align: center;
padding: 4rem 1rem;
color: var(--color-text-muted, #64748b);
}
.empty-icon {
font-size: 3rem;
margin-bottom: 0.75rem;
}
.empty h2 {
margin: 0 0 0.5rem;
font-size: 1.15rem;
font-weight: 500;
color: var(--color-text, #0f172a);
}
.empty p {
margin: 0.25rem 0;
font-size: 0.9rem;
}
.note {
margin-top: 1rem !important;
font-size: 0.8rem;
opacity: 0.7;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.row {
display: grid;
grid-template-columns: 1fr auto 8rem;
gap: 1rem;
align-items: center;
padding: 0.75rem 1rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.5rem;
}
.subject {
font-weight: 500;
}
.recipient-count {
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
}
.status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
}
.status .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--dot);
}
</style>

View file

@ -0,0 +1,16 @@
/**
* Broadcast module Dexie accessors.
*
* No guest seed: shipping a demo campaign feels wrong for a module that
* will eventually hit real SMTP. The empty state is the onboarding.
*/
import { db } from '$lib/data/database';
import type { LocalCampaign, LocalBroadcastTemplate, LocalBroadcastSettings } from './types';
export const campaignTable = db.table<LocalCampaign>('broadcastCampaigns');
export const templateTable = db.table<LocalBroadcastTemplate>('broadcastTemplates');
export const settingsTable = db.table<LocalBroadcastSettings>('broadcastSettings');
/** Explicitly empty so module-registry loaders still get a valid export. */
export const BROADCAST_GUEST_SEED = {};

View file

@ -0,0 +1,27 @@
import type { CampaignStatus } from './types';
export const STATUS_LABELS: Record<CampaignStatus, { de: string; en: string }> = {
draft: { de: 'Entwurf', en: 'Draft' },
scheduled: { de: 'Geplant', en: 'Scheduled' },
sending: { de: 'Versand läuft', en: 'Sending' },
sent: { de: 'Versendet', en: 'Sent' },
cancelled: { de: 'Abgebrochen', en: 'Cancelled' },
};
export const STATUS_COLORS: Record<CampaignStatus, string> = {
draft: '#64748b', // slate-500
scheduled: '#6366f1', // indigo-500
sending: '#f59e0b', // amber-500
sent: '#22c55e', // green-500
cancelled: '#94a3b8', // slate-400
};
/** Stable sentinel so the singleton settings row dedupes across devices. */
export const BROADCAST_SETTINGS_ID = 'broadcast-settings';
/** Conservative per-user cap until we talk to the SMTP provider. Mirrors
* the mana-mail env var BROADCAST_MAX_RECIPIENTS_PER_CAMPAIGN. */
export const MAX_RECIPIENTS_PER_CAMPAIGN = 5000;
/** Rate-limit hint for the UI — server-side limit is the authoritative one. */
export const MAX_RECIPIENTS_PER_HOUR = 500;

View file

@ -0,0 +1,31 @@
/**
* Broadcast module barrel exports.
*/
export { campaignTable, templateTable, settingsTable, BROADCAST_GUEST_SEED } from './collections';
export {
STATUS_LABELS,
STATUS_COLORS,
BROADCAST_SETTINGS_ID,
MAX_RECIPIENTS_PER_CAMPAIGN,
MAX_RECIPIENTS_PER_HOUR,
} from './constants';
export type {
LocalCampaign,
LocalBroadcastTemplate,
LocalBroadcastSettings,
Campaign,
BroadcastTemplate,
BroadcastSettings,
CampaignStatus,
CampaignContent,
CampaignStats,
AudienceDefinition,
AudienceFilter,
AudienceField,
AudienceOp,
DnsCheck,
DnsRecordStatus,
} from './types';

View file

@ -0,0 +1,10 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const broadcastModuleConfig: ModuleConfig = {
appId: 'broadcast',
tables: [
{ name: 'broadcastCampaigns' },
{ name: 'broadcastTemplates' },
{ name: 'broadcastSettings' },
],
};

View file

@ -0,0 +1,168 @@
/**
* Broadcast module types.
*
* 1:N email campaigns (newsletters + announcements) sent via mana-mail's
* bulk-send endpoint. Plan: `docs/plans/broadcast-module.md`.
*
* Critical invariants:
* - `content.html` + `content.plainText` are **derived** from
* `content.tiptap`; they get regenerated on every save so the editor
* JSON stays the source of truth.
* - `status` can only advance forward: draft scheduled sending
* sent (cancelled is the only off-ramp, allowed from draft/scheduled).
* - `stats` is a plaintext cache of the server-side events table it
* can lag; liveQuery consumers should tolerate missing / stale data.
*/
import type { BaseRecord } from '@mana/local-store';
// ─── Discriminators & Enums ──────────────────────────────
export type CampaignStatus = 'draft' | 'scheduled' | 'sending' | 'sent' | 'cancelled';
// ─── Audience segment ────────────────────────────────────
export type AudienceField = 'tag' | 'email' | 'custom';
export type AudienceOp = 'has' | 'not-has' | 'eq' | 'contains';
export interface AudienceFilter {
field: AudienceField;
op: AudienceOp;
value: string;
}
export interface AudienceDefinition {
filters: AudienceFilter[];
/** Cached count so the list doesn't have to recompute on every render.
* Not authoritative the send-time resolver always re-runs the query. */
estimatedCount: number;
}
// ─── Campaign content ────────────────────────────────────
/**
* Tiptap JSON + derived outputs. HTML + plainText are regenerated on
* save; we persist them so the PDF-less send path doesn't need an
* editor round-trip just to re-render.
*/
export interface CampaignContent {
tiptap: object;
html?: string;
plainText?: string;
}
// ─── Campaign stats (mirror of server events) ────────────
export interface CampaignStats {
totalRecipients: number;
sent: number;
delivered: number;
bounced: number;
opened: number;
clicked: number;
unsubscribed: number;
/** ISO timestamp — how fresh is this snapshot? */
lastSyncedAt: string;
}
// ─── DNS check ───────────────────────────────────────────
export type DnsRecordStatus = 'ok' | 'missing' | 'wrong' | 'weak';
export interface DnsCheck {
domain: string;
spf: DnsRecordStatus;
dkim: DnsRecordStatus;
dmarc: DnsRecordStatus;
checkedAt: string;
}
// ─── Local Records (Dexie) ───────────────────────────────
export interface LocalCampaign extends BaseRecord {
/** Internal working title; not sent. */
name: string;
/** What lands in the recipient's mailbox subject line. */
subject: string;
/** "Preheader" — the grey excerpt shown next to the subject in Gmail. */
preheader?: string | null;
fromName: string;
fromEmail: string;
replyTo?: string | null;
content: CampaignContent;
templateId?: string | null;
audience: AudienceDefinition;
/** For scheduled sends — when should mana-mail pick this up? */
scheduledAt?: string | null;
/** When was the send actually fired. */
sentAt?: string | null;
status: CampaignStatus;
/** Server-side orchestrator job id (mana-mail). */
serverJobId?: string | null;
stats?: CampaignStats | null;
}
export interface LocalBroadcastTemplate extends BaseRecord {
name: string;
description?: string | null;
subject?: string | null;
content: CampaignContent;
/** Built-ins ship with the app; users can't delete them, only clone. */
isBuiltIn: boolean;
thumbnailUrl?: string | null;
}
export interface LocalBroadcastSettings extends BaseRecord {
defaultFromName: string;
defaultFromEmail: string;
defaultReplyTo?: string | null;
defaultFooter?: string | null;
dnsCheck?: DnsCheck | null;
/** Impressumspflicht — lands at the bottom of every campaign HTML. */
legalAddress: string;
unsubscribeLandingCopy?: string | null;
}
// ─── Domain Types (plaintext, UI) ────────────────────────
export interface Campaign {
id: string;
name: string;
subject: string;
preheader: string | null;
fromName: string;
fromEmail: string;
replyTo: string | null;
content: CampaignContent;
templateId: string | null;
audience: AudienceDefinition;
scheduledAt: string | null;
sentAt: string | null;
status: CampaignStatus;
serverJobId: string | null;
stats: CampaignStats | null;
createdAt: string;
updatedAt: string;
}
export interface BroadcastTemplate {
id: string;
name: string;
description: string | null;
subject: string | null;
content: CampaignContent;
isBuiltIn: boolean;
thumbnailUrl: string | null;
createdAt: string;
}
export interface BroadcastSettings {
id: string;
defaultFromName: string;
defaultFromEmail: string;
defaultReplyTo: string | null;
defaultFooter: string | null;
dnsCheck: DnsCheck | null;
legalAddress: string;
unsubscribeLandingCopy: string | null;
}

View file

@ -0,0 +1,9 @@
<script lang="ts">
import ListView from '$lib/modules/broadcast/ListView.svelte';
</script>
<svelte:head>
<title>Broadcasts - Mana</title>
</svelte:head>
<ListView />

View file

@ -236,6 +236,12 @@ export const APP_ICONS = {
// Emerald→teal sits next to finance green in the Arbeit & Finanzen row.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="iv" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#059669"/><stop offset="100%" style="stop-color:#14b8a6"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#iv)"/><path d="M28 22h34l14 14v42a4 4 0 0 1-4 4H28a4 4 0 0 1-4-4V26a4 4 0 0 1 4-4z" fill="white" fill-opacity="0.95"/><path d="M62 22v10a4 4 0 0 0 4 4h10" fill="none" stroke="#059669" stroke-width="2" stroke-opacity="0.35"/><rect x="32" y="44" width="24" height="3" rx="1" fill="#059669" fill-opacity="0.6"/><rect x="32" y="52" width="20" height="3" rx="1" fill="#059669" fill-opacity="0.45"/><rect x="32" y="60" width="28" height="3" rx="1" fill="#059669" fill-opacity="0.6"/><rect x="60" y="58" width="14" height="14" rx="1" fill="#059669"/><rect x="62" y="60" width="3" height="3" fill="white"/><rect x="69" y="60" width="3" height="3" fill="white"/><rect x="62" y="67" width="3" height="3" fill="white"/><rect x="66" y="64" width="2" height="2" fill="white"/><rect x="69" y="67" width="3" height="3" fill="white"/></svg>`
),
broadcast: svgToDataUrl(
// Megaphone / loudspeaker with three radiating sound arcs.
// Indigo→cyan gradient sets it apart from mail (blue) and invoices
// (emerald) while staying in the "communication" colour family.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="bc" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366f1"/><stop offset="100%" style="stop-color:#06b6d4"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#bc)"/><path d="M32 42v16l14 4v-24l-14 4z" fill="white" fill-opacity="0.95"/><path d="M46 38l26-10v44l-26-10z" fill="white"/><path d="M42 62v14a3 3 0 0 0 3 3h4a3 3 0 0 0 3-3V64" fill="white" fill-opacity="0.85"/><path d="M78 40c4 2 6 5 6 10s-2 8-6 10" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.8"/><path d="M84 32c6 3 10 9 10 18s-4 15-10 18" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.5"/></svg>`
),
} as const;
export type AppIconId = keyof typeof APP_ICONS;

View file

@ -1020,6 +1020,23 @@ export const MANA_APPS: ManaApp[] = [
status: 'development',
requiredTier: 'guest',
},
{
id: 'broadcast',
name: 'Broadcasts',
description: {
de: 'Newsletter & Kampagnen',
en: 'Newsletters & campaigns',
},
longDescription: {
de: 'Newsletter und Ankündigungen an Kontaktgruppen versenden — mit Rich-Text-Editor, Open/Click-Tracking, DSGVO-konformem Unsubscribe und Kampagnen-Statistik.',
en: 'Send newsletters and announcements to contact segments — with a rich-text editor, open/click tracking, GDPR-compliant unsubscribe, and per-campaign stats.',
},
icon: APP_ICONS.broadcast,
color: '#6366f1',
comingSoon: false,
status: 'development',
requiredTier: 'alpha',
},
{
id: 'invoices',
name: 'Rechnungen',