+ {#if audience.filters.length === 0}
+ Ohne Filter: alle {contactsWithEmail} Kontakte mit E-Mail-Adresse.
+ {:else}
+ {audience.filters.length} Filter — nur Kontakte, die ALLE erfüllen.
+ {/if}
+
+
+ {#if audience.filters.length > 0}
+
+ {#each audience.filters as f, i (i)}
+
+ {filterLabel(f)}
+
+
+ {/each}
+
+ {/if}
+
+
+
Nach Tag filtern
+ {#if allTags.length === 0}
+
+ Keine Tags vorhanden. Lege in Kontakten Tags an, um nach ihnen zu segmentieren.
+
+ {:else}
+
+ {#each allTags as tag (tag.id)}
+ {@const used = usedTagIds.has(tag.id)}
+
+
+ {tag.name}
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+
diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/audience/segment-builder.test.ts b/apps/mana/apps/web/src/lib/modules/broadcast/audience/segment-builder.test.ts
new file mode 100644
index 000000000..d6b9ec006
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/broadcast/audience/segment-builder.test.ts
@@ -0,0 +1,170 @@
+import { describe, it, expect } from 'vitest';
+import type { Contact } from '$lib/modules/contacts/types';
+import { matchContact, filterAudience, countAudience, describeAudience } from './segment-builder';
+import type { AudienceDefinition, AudienceFilter } from '../types';
+
+function makeContact(overrides: Partial = {}): Contact {
+ return {
+ id: 'c1',
+ firstName: 'Test',
+ lastName: 'Kontakt',
+ displayName: 'Test Kontakt',
+ email: 'test@example.com',
+ phone: null,
+ mobile: null,
+ company: null,
+ jobTitle: null,
+ street: null,
+ city: null,
+ postalCode: null,
+ country: null,
+ latitude: null,
+ longitude: null,
+ notes: null,
+ photoUrl: null,
+ birthday: null,
+ website: null,
+ linkedin: null,
+ twitter: null,
+ instagram: null,
+ github: null,
+ isFavorite: false,
+ isArchived: false,
+ tags: [],
+ tagIds: [],
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-01T00:00:00.000Z',
+ ...overrides,
+ } as Contact;
+}
+
+const f = (
+ field: AudienceFilter['field'],
+ op: AudienceFilter['op'],
+ value: string
+): AudienceFilter => ({
+ field,
+ op,
+ value,
+});
+
+const audience = (filters: AudienceFilter[]): AudienceDefinition => ({
+ filters,
+ estimatedCount: 0,
+});
+
+describe('matchContact', () => {
+ it('tag has: returns true when the contact has the tag', () => {
+ expect(matchContact(makeContact({ tagIds: ['t1'] }), f('tag', 'has', 't1'))).toBe(true);
+ });
+
+ it('tag has: returns false when the contact lacks the tag', () => {
+ expect(matchContact(makeContact({ tagIds: [] }), f('tag', 'has', 't1'))).toBe(false);
+ });
+
+ it('tag not-has: returns true when absent', () => {
+ expect(matchContact(makeContact({ tagIds: [] }), f('tag', 'not-has', 't1'))).toBe(true);
+ });
+
+ it('tag not-has: returns false when present', () => {
+ expect(matchContact(makeContact({ tagIds: ['t1'] }), f('tag', 'not-has', 't1'))).toBe(false);
+ });
+
+ it('email contains: case-insensitive', () => {
+ expect(matchContact(makeContact({ email: 'foo@BAR.CH' }), f('email', 'contains', 'bar'))).toBe(
+ true
+ );
+ });
+
+ it('email eq: exact match required (after lowercasing both)', () => {
+ expect(matchContact(makeContact({ email: 'a@b.ch' }), f('email', 'eq', 'A@B.CH'))).toBe(true);
+ expect(matchContact(makeContact({ email: 'a@b.ch' }), f('email', 'eq', 'x@b.ch'))).toBe(false);
+ });
+
+ it('email has: returns true when a usable email exists', () => {
+ expect(matchContact(makeContact({ email: 'x@y.z' }), f('email', 'has', ''))).toBe(true);
+ expect(matchContact(makeContact({ email: null }), f('email', 'has', ''))).toBe(false);
+ });
+});
+
+describe('filterAudience', () => {
+ const contacts = [
+ makeContact({ id: '1', email: 'a@x.ch', tagIds: ['kunde'] }),
+ makeContact({ id: '2', email: 'b@x.ch', tagIds: ['kunde', 'trial'] }),
+ makeContact({ id: '3', email: 'c@x.ch', tagIds: [] }),
+ makeContact({ id: '4', email: null, tagIds: ['kunde'] }), // no email → excluded
+ ];
+
+ it('no filters: returns all contacts with valid email', () => {
+ const result = filterAudience(contacts, audience([]));
+ expect(result.map((c) => c.id)).toEqual(['1', '2', '3']);
+ });
+
+ it('single tag filter: matches only contacts with the tag', () => {
+ const result = filterAudience(contacts, audience([f('tag', 'has', 'kunde')]));
+ expect(result.map((c) => c.id)).toEqual(['1', '2']);
+ });
+
+ it('AND semantics: all filters must match', () => {
+ // Kunden OHNE trial-tag = nur Contact 1
+ const result = filterAudience(
+ contacts,
+ audience([f('tag', 'has', 'kunde'), f('tag', 'not-has', 'trial')])
+ );
+ expect(result.map((c) => c.id)).toEqual(['1']);
+ });
+
+ it('drops contacts without usable email even if filters match', () => {
+ // Contact 4 has 'kunde' tag but no email → excluded from result
+ const result = filterAudience(contacts, audience([f('tag', 'has', 'kunde')]));
+ expect(result.find((c) => c.id === '4')).toBeUndefined();
+ });
+
+ it('returns a new array (no input mutation)', () => {
+ const before = contacts.slice();
+ filterAudience(contacts, audience([f('tag', 'has', 'kunde')]));
+ expect(contacts).toEqual(before);
+ });
+});
+
+describe('countAudience', () => {
+ it('matches filterAudience().length', () => {
+ const contacts = [
+ makeContact({ id: '1', email: 'a@x.ch', tagIds: ['kunde'] }),
+ makeContact({ id: '2', email: 'b@x.ch', tagIds: [] }),
+ ];
+ const def = audience([f('tag', 'has', 'kunde')]);
+ expect(countAudience(contacts, def)).toBe(filterAudience(contacts, def).length);
+ });
+});
+
+describe('describeAudience', () => {
+ const resolver = (id: string) => {
+ const names: Record = { t1: 'Kunden', t2: 'Newsletter' };
+ return names[id] ?? null;
+ };
+
+ it('no filters → "Alle Kontakte mit E-Mail"', () => {
+ expect(describeAudience(audience([]), resolver)).toBe('Alle Kontakte mit E-Mail');
+ });
+
+ it('resolves tag names via the resolver', () => {
+ const result = describeAudience(audience([f('tag', 'has', 't1')]), resolver);
+ expect(result).toContain('Kunden');
+ });
+
+ it('falls back to the raw value when resolver returns null', () => {
+ const result = describeAudience(audience([f('tag', 'has', 'unknown')]), resolver);
+ expect(result).toContain('unknown');
+ });
+
+ it('joins multiple filters with · separator', () => {
+ const result = describeAudience(
+ audience([f('tag', 'has', 't1'), f('tag', 'not-has', 't2')]),
+ resolver
+ );
+ expect(result).toContain('·');
+ expect(result).toContain('Kunden');
+ expect(result).toContain('Newsletter');
+ });
+});
diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/audience/segment-builder.ts b/apps/mana/apps/web/src/lib/modules/broadcast/audience/segment-builder.ts
new file mode 100644
index 000000000..fd12e79db
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/broadcast/audience/segment-builder.ts
@@ -0,0 +1,96 @@
+/**
+ * Pure audience-segment matcher.
+ *
+ * Given a contact list and a set of filters, return only the contacts
+ * that satisfy ALL filters (AND semantics). Filter `value` meanings:
+ * - field='tag' → value = tag ID (not name). UI resolves names.
+ * - field='email' → value = substring or exact email
+ * - field='custom' → reserved for per-contact custom fields later
+ *
+ * Semantics per op:
+ * has → the tag/value is present
+ * not-has → the tag/value is absent
+ * eq → exact match (for email: case-insensitive)
+ * contains → substring match (for email: case-insensitive)
+ *
+ * Empty filter list matches every contact — "keine Filter" means
+ * "alle Empfänger", not "niemand". Callers that need the opposite
+ * (default-deny) should gate the send separately.
+ */
+
+import type { Contact } from '$lib/modules/contacts/types';
+import type { AudienceFilter, AudienceDefinition } from '../types';
+
+export function matchContact(contact: Contact, filter: AudienceFilter): boolean {
+ switch (filter.field) {
+ case 'tag': {
+ const has = (contact.tagIds ?? []).includes(filter.value);
+ if (filter.op === 'has') return has;
+ if (filter.op === 'not-has') return !has;
+ // eq / contains don't apply to tags — graceful fail to `has`.
+ return has;
+ }
+
+ case 'email': {
+ const email = (contact.email ?? '').toLowerCase();
+ const v = filter.value.toLowerCase();
+ if (filter.op === 'has') return email.length > 0;
+ if (filter.op === 'not-has') return email.length === 0;
+ if (filter.op === 'eq') return email === v;
+ if (filter.op === 'contains') return email.includes(v);
+ return false;
+ }
+
+ case 'custom':
+ // Placeholder — M2+ ships tags + email. Custom fields land in
+ // Phase 2 when contact schema grows a free-form fields map.
+ return true;
+
+ default:
+ return true;
+ }
+}
+
+/**
+ * Run all filters against a contact list. Returns a copy — never mutates
+ * the input. Contacts without a usable email address are dropped even if
+ * they match the filters; you can't send a newsletter to someone without
+ * an email, and silently counting them would inflate the estimated count.
+ */
+export function filterAudience(contacts: Contact[], audience: AudienceDefinition): Contact[] {
+ const filtered = audience.filters.length
+ ? contacts.filter((c) => audience.filters.every((f) => matchContact(c, f)))
+ : contacts.slice();
+ return filtered.filter((c) => typeof c.email === 'string' && c.email.includes('@'));
+}
+
+/** Fast count without materialising the full array (same filter logic). */
+export function countAudience(contacts: Contact[], audience: AudienceDefinition): number {
+ return filterAudience(contacts, audience).length;
+}
+
+/**
+ * Human-friendly summary of a filter AST. Used in the ListView row and
+ * the Preflight step to show "an: Kunden ohne trial-tag (23 Empfänger)"
+ * instead of just the raw number.
+ */
+export function describeAudience(
+ audience: AudienceDefinition,
+ tagNameResolver: (tagId: string) => string | null
+): string {
+ if (audience.filters.length === 0) return 'Alle Kontakte mit E-Mail';
+ const parts = audience.filters.map((f) => {
+ if (f.field === 'tag') {
+ const name = tagNameResolver(f.value) ?? f.value;
+ return f.op === 'has' ? `Tag "${name}"` : `ohne Tag "${name}"`;
+ }
+ if (f.field === 'email') {
+ if (f.op === 'eq') return `E-Mail = ${f.value}`;
+ if (f.op === 'contains') return `E-Mail enthält "${f.value}"`;
+ if (f.op === 'has') return `mit E-Mail`;
+ if (f.op === 'not-has') return `ohne E-Mail`;
+ }
+ return `${f.field} ${f.op} ${f.value}`;
+ });
+ return parts.join(' · ');
+}
diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/editor/Editor.svelte b/apps/mana/apps/web/src/lib/modules/broadcast/editor/Editor.svelte
new file mode 100644
index 000000000..b2505edde
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/broadcast/editor/Editor.svelte
@@ -0,0 +1,392 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if showLinkInput}
+
+ e.key === 'Enter' && applyLink()}
+ />
+
+
+
+ {/if}
+
+ {#if uploadError}
+
{uploadError}
+ {/if}
+
+
+
+
+
diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/index.ts b/apps/mana/apps/web/src/lib/modules/broadcast/index.ts
index 2a1551014..0804cba47 100644
--- a/apps/mana/apps/web/src/lib/modules/broadcast/index.ts
+++ b/apps/mana/apps/web/src/lib/modules/broadcast/index.ts
@@ -4,6 +4,28 @@
export { campaignTable, templateTable, settingsTable, BROADCAST_GUEST_SEED } from './collections';
+export {
+ useAllCampaigns,
+ useAllTemplates,
+ toCampaign,
+ toTemplate,
+ toSettings,
+ filterByStatus,
+ searchCampaigns,
+ computeStats,
+ formatRate,
+} from './queries';
+
+export {
+ matchContact,
+ filterAudience,
+ countAudience,
+ describeAudience,
+} from './audience/segment-builder';
+
+export { broadcastCampaignsStore } from './stores/campaigns.svelte';
+export { broadcastSettingsStore, ensureSettings } from './stores/settings.svelte';
+
export {
STATUS_LABELS,
STATUS_COLORS,
diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/queries.ts b/apps/mana/apps/web/src/lib/modules/broadcast/queries.ts
new file mode 100644
index 000000000..03ce59bb7
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/broadcast/queries.ts
@@ -0,0 +1,181 @@
+/**
+ * Reactive queries and pure helpers for the Broadcast module.
+ *
+ * Live queries decrypt + map to domain types, consistent with the
+ * invoices / library modules. Scope-wrapping via `scopedForModule`
+ * matches the post-Spaces convention so lists respect the active space.
+ */
+
+import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
+import { decryptRecords } from '$lib/data/crypto';
+import { scopedForModule } from '$lib/data/scope';
+import { campaignTable, templateTable, settingsTable } from './collections';
+import { BROADCAST_SETTINGS_ID } from './constants';
+import type {
+ LocalCampaign,
+ LocalBroadcastTemplate,
+ LocalBroadcastSettings,
+ Campaign,
+ BroadcastTemplate,
+ BroadcastSettings,
+ CampaignStatus,
+} from './types';
+
+// ─── Type Converters ─────────────────────────────────────
+
+export function toCampaign(local: LocalCampaign): Campaign {
+ const now = new Date().toISOString();
+ return {
+ id: local.id,
+ name: local.name,
+ subject: local.subject,
+ preheader: local.preheader ?? null,
+ fromName: local.fromName,
+ fromEmail: local.fromEmail,
+ replyTo: local.replyTo ?? null,
+ content: local.content,
+ templateId: local.templateId ?? null,
+ audience: local.audience,
+ scheduledAt: local.scheduledAt ?? null,
+ sentAt: local.sentAt ?? null,
+ status: local.status,
+ serverJobId: local.serverJobId ?? null,
+ stats: local.stats ?? null,
+ createdAt: local.createdAt ?? now,
+ updatedAt: local.updatedAt ?? now,
+ };
+}
+
+export function toTemplate(local: LocalBroadcastTemplate): BroadcastTemplate {
+ const now = new Date().toISOString();
+ return {
+ id: local.id,
+ name: local.name,
+ description: local.description ?? null,
+ subject: local.subject ?? null,
+ content: local.content,
+ isBuiltIn: local.isBuiltIn,
+ thumbnailUrl: local.thumbnailUrl ?? null,
+ createdAt: local.createdAt ?? now,
+ };
+}
+
+export function toSettings(local: LocalBroadcastSettings): BroadcastSettings {
+ return {
+ id: local.id,
+ defaultFromName: local.defaultFromName ?? '',
+ defaultFromEmail: local.defaultFromEmail ?? '',
+ defaultReplyTo: local.defaultReplyTo ?? null,
+ defaultFooter: local.defaultFooter ?? null,
+ dnsCheck: local.dnsCheck ?? null,
+ legalAddress: local.legalAddress ?? '',
+ unsubscribeLandingCopy: local.unsubscribeLandingCopy ?? null,
+ };
+}
+
+// ─── Live Queries ────────────────────────────────────────
+
+/** All campaigns in the active space, newest first by updatedAt. */
+export function useAllCampaigns() {
+ return useLiveQueryWithDefault(async () => {
+ const rows = await scopedForModule(
+ 'broadcast',
+ 'broadcastCampaigns'
+ ).toArray();
+ const visible = rows.filter((r) => !r.deletedAt);
+ const decrypted = (await decryptRecords('broadcastCampaigns', visible)) as LocalCampaign[];
+ return decrypted.map(toCampaign).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
+ }, [] as Campaign[]);
+}
+
+export function useAllTemplates() {
+ return useLiveQueryWithDefault(async () => {
+ const rows = await scopedForModule(
+ 'broadcast',
+ 'broadcastTemplates'
+ ).toArray();
+ const visible = rows.filter((r) => !r.deletedAt);
+ const decrypted = (await decryptRecords(
+ 'broadcastTemplates',
+ visible
+ )) as LocalBroadcastTemplate[];
+ return decrypted.map(toTemplate);
+ }, [] as BroadcastTemplate[]);
+}
+
+// ─── Pure Helpers ────────────────────────────────────────
+
+export function filterByStatus(campaigns: Campaign[], status: CampaignStatus): Campaign[] {
+ return campaigns.filter((c) => c.status === status);
+}
+
+export function searchCampaigns(campaigns: Campaign[], query: string): Campaign[] {
+ const q = query.toLowerCase();
+ return campaigns.filter(
+ (c) => c.name.toLowerCase().includes(q) || c.subject.toLowerCase().includes(q)
+ );
+}
+
+// ─── Stats ───────────────────────────────────────────────
+
+export interface BroadcastStats {
+ totalByStatus: Record;
+ sentThisYear: number;
+ avgOpenRate: number | null;
+ avgClickRate: number | null;
+ totalSubscribers: number;
+}
+
+export function computeStats(campaigns: Campaign[], year: number): BroadcastStats {
+ const totalByStatus: Record = {
+ draft: 0,
+ scheduled: 0,
+ sending: 0,
+ sent: 0,
+ cancelled: 0,
+ };
+ let sentThisYear = 0;
+ let openRateSum = 0;
+ let openRateCount = 0;
+ let clickRateSum = 0;
+ let clickRateCount = 0;
+ const yearPrefix = String(year);
+
+ for (const c of campaigns) {
+ totalByStatus[c.status]++;
+ if (c.status === 'sent' && c.sentAt?.startsWith(yearPrefix)) {
+ sentThisYear++;
+ }
+ if (c.stats && c.stats.sent > 0) {
+ const openRate = c.stats.opened / c.stats.sent;
+ const clickRate = c.stats.clicked / c.stats.sent;
+ openRateSum += openRate;
+ openRateCount++;
+ clickRateSum += clickRate;
+ clickRateCount++;
+ }
+ }
+
+ return {
+ totalByStatus,
+ sentThisYear,
+ avgOpenRate: openRateCount > 0 ? openRateSum / openRateCount : null,
+ avgClickRate: clickRateCount > 0 ? clickRateSum / clickRateCount : null,
+ totalSubscribers: 0, // TODO M7: derive from unique recipients across all campaigns
+ };
+}
+
+// ─── Formatting ──────────────────────────────────────────
+
+/** Format a rate (0..1) as a percentage with one decimal, e.g. 0.234 → "23.4%". */
+export function formatRate(rate: number | null): string {
+ if (rate === null) return '—';
+ return `${(rate * 100).toFixed(1)}%`;
+}
+
+// ─── Settings singleton helpers ──────────────────────────
+
+export { BROADCAST_SETTINGS_ID };
+// Re-exported so UI consumers can `await settingsTable.get(BROADCAST_SETTINGS_ID)`
+// without importing from two places.
+export { settingsTable, campaignTable, templateTable };
diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/stores/campaigns.svelte.ts b/apps/mana/apps/web/src/lib/modules/broadcast/stores/campaigns.svelte.ts
new file mode 100644
index 000000000..96f12adf7
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/broadcast/stores/campaigns.svelte.ts
@@ -0,0 +1,238 @@
+/**
+ * Campaigns store — mutation-only service.
+ *
+ * Status machine enforced here:
+ * draft → scheduled (schedule)
+ * draft → sending (send) [set by server orchestrator, not this store]
+ * sending → sent (server-driven)
+ * draft | scheduled → cancelled (cancel)
+ *
+ * Only drafts are user-editable. Once a campaign starts sending, content
+ * and audience freeze so the recipient graph can't shift mid-flight.
+ */
+
+import { encryptRecord } from '$lib/data/crypto';
+import { emitDomainEvent } from '$lib/data/events';
+import { campaignTable } from '../collections';
+import { broadcastSettingsStore } from './settings.svelte';
+import type { LocalCampaign, CampaignContent, AudienceDefinition, CampaignStatus } from '../types';
+
+export interface CreateCampaignInput {
+ name?: string;
+ subject?: string;
+ preheader?: string | null;
+ fromName?: string;
+ fromEmail?: string;
+ replyTo?: string | null;
+ content?: CampaignContent;
+ audience?: AudienceDefinition;
+ templateId?: string | null;
+}
+
+const EMPTY_TIPTAP = {
+ type: 'doc',
+ content: [{ type: 'paragraph' }],
+};
+
+const EMPTY_AUDIENCE: AudienceDefinition = {
+ filters: [],
+ estimatedCount: 0,
+};
+
+export const broadcastCampaignsStore = {
+ /**
+ * Create a new campaign in status `draft`. Sender fields + footer default
+ * to the user's broadcast settings so first-time use feels like "start
+ * typing and go" rather than "fill out ten fields before you can type".
+ */
+ async createCampaign(input: CreateCampaignInput = {}): Promise {
+ const defaults = await broadcastSettingsStore.getDefaults();
+ const now = new Date().toISOString();
+
+ const newLocal: LocalCampaign = {
+ id: crypto.randomUUID(),
+ name: input.name ?? 'Neue Kampagne',
+ subject: input.subject ?? '',
+ preheader: input.preheader ?? null,
+ fromName: input.fromName ?? defaults.fromName,
+ fromEmail: input.fromEmail ?? defaults.fromEmail,
+ replyTo: input.replyTo ?? defaults.replyTo,
+ content: input.content ?? { tiptap: EMPTY_TIPTAP },
+ templateId: input.templateId ?? null,
+ audience: input.audience ?? EMPTY_AUDIENCE,
+ scheduledAt: null,
+ sentAt: null,
+ status: 'draft',
+ serverJobId: null,
+ stats: null,
+ createdAt: now,
+ updatedAt: now,
+ };
+
+ await encryptRecord('broadcastCampaigns', newLocal);
+ await campaignTable.add(newLocal);
+ emitDomainEvent('BroadcastCampaignCreated', 'broadcast', 'broadcastCampaigns', newLocal.id, {
+ campaignId: newLocal.id,
+ name: newLocal.name,
+ });
+ return newLocal.id;
+ },
+
+ /**
+ * Generic metadata patch — only valid in `draft`. Sending and onward
+ * freeze the row to preserve the "what you saw is what went out"
+ * invariant for the recipient.
+ */
+ async updateCampaign(
+ id: string,
+ patch: Partial<
+ Pick<
+ LocalCampaign,
+ 'name' | 'subject' | 'preheader' | 'fromName' | 'fromEmail' | 'replyTo' | 'templateId'
+ >
+ >
+ ) {
+ const existing = await campaignTable.get(id);
+ if (!existing) return;
+ if (existing.status !== 'draft') {
+ throw new Error('[broadcast] only drafts can be edited; duplicate to revise a sent campaign');
+ }
+ const wrapped = { ...patch } as Record;
+ await encryptRecord('broadcastCampaigns', wrapped);
+ await campaignTable.update(id, {
+ ...wrapped,
+ updatedAt: new Date().toISOString(),
+ });
+ },
+
+ /**
+ * Replace content (Tiptap JSON + derived HTML/plaintext). Derived
+ * outputs are passed in by the caller because rendering happens
+ * client-side in the editor component; the store stays dumb about
+ * Tiptap's schema.
+ */
+ async updateContent(id: string, content: CampaignContent) {
+ const existing = await campaignTable.get(id);
+ if (!existing) return;
+ if (existing.status !== 'draft') {
+ throw new Error('[broadcast] only drafts can be edited');
+ }
+ const patch = { content } as Record;
+ await encryptRecord('broadcastCampaigns', patch);
+ await campaignTable.update(id, {
+ ...patch,
+ updatedAt: new Date().toISOString(),
+ });
+ },
+
+ async updateAudience(id: string, audience: AudienceDefinition) {
+ const existing = await campaignTable.get(id);
+ if (!existing) return;
+ if (existing.status !== 'draft') {
+ throw new Error('[broadcast] only drafts can be edited');
+ }
+ const patch = { audience } as Record;
+ await encryptRecord('broadcastCampaigns', patch);
+ await campaignTable.update(id, {
+ ...patch,
+ updatedAt: new Date().toISOString(),
+ });
+ },
+
+ /**
+ * Flip draft → scheduled with a future timestamp. Actual send happens
+ * server-side when mana-mail's cron sees the row; this store just
+ * arms the trigger.
+ */
+ async schedule(id: string, scheduledAt: string) {
+ const existing = await campaignTable.get(id);
+ if (!existing) return;
+ if (existing.status !== 'draft') return;
+ await campaignTable.update(id, {
+ status: 'scheduled' as CampaignStatus,
+ scheduledAt,
+ updatedAt: new Date().toISOString(),
+ });
+ emitDomainEvent('BroadcastCampaignScheduled', 'broadcast', 'broadcastCampaigns', id, {
+ campaignId: id,
+ scheduledAt,
+ });
+ },
+
+ /** Revoke a scheduled send before it fires. Can be reactivated as draft. */
+ async cancel(id: string) {
+ const existing = await campaignTable.get(id);
+ if (!existing) return;
+ if (existing.status !== 'draft' && existing.status !== 'scheduled') {
+ throw new Error('[broadcast] only drafts or scheduled campaigns can be cancelled');
+ }
+ await campaignTable.update(id, {
+ status: 'cancelled' as CampaignStatus,
+ scheduledAt: null,
+ updatedAt: new Date().toISOString(),
+ });
+ emitDomainEvent('BroadcastCampaignCancelled', 'broadcast', 'broadcastCampaigns', id, {
+ campaignId: id,
+ });
+ },
+
+ /**
+ * Duplicate an existing campaign (typically a sent one being reused as
+ * template-of-the-moment). Produces a fresh draft with the same
+ * content + audience but new number / status.
+ */
+ async duplicate(id: string): Promise {
+ const existing = await campaignTable.get(id);
+ if (!existing) throw new Error('[broadcast] duplicate: source not found');
+ const { decryptRecords } = await import('$lib/data/crypto');
+ const [decrypted] = (await decryptRecords('broadcastCampaigns', [existing])) as LocalCampaign[];
+ return this.createCampaign({
+ name: `Kopie von ${decrypted.name}`,
+ subject: decrypted.subject,
+ preheader: decrypted.preheader,
+ fromName: decrypted.fromName,
+ fromEmail: decrypted.fromEmail,
+ replyTo: decrypted.replyTo,
+ content: decrypted.content,
+ audience: decrypted.audience,
+ templateId: decrypted.templateId,
+ });
+ },
+
+ async deleteCampaign(id: string) {
+ const existing = await campaignTable.get(id);
+ if (!existing) return;
+ if (existing.status === 'sending' || existing.status === 'sent') {
+ throw new Error(
+ '[broadcast] versendete oder laufende Kampagnen können nicht gelöscht werden (Bookkeeping)'
+ );
+ }
+ await campaignTable.update(id, {
+ deletedAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ });
+ emitDomainEvent('BroadcastCampaignDeleted', 'broadcast', 'broadcastCampaigns', id, {
+ campaignId: id,
+ });
+ },
+
+ /**
+ * Server-side hook surface: once M4's orchestrator accepts a send, it
+ * writes back here to reflect progress. Exposed as a store method so
+ * callers share the encryption/event plumbing.
+ */
+ async applyServerStatus(
+ id: string,
+ patch: {
+ status: CampaignStatus;
+ serverJobId?: string | null;
+ sentAt?: string | null;
+ stats?: LocalCampaign['stats'];
+ }
+ ) {
+ await campaignTable.update(id, {
+ ...patch,
+ updatedAt: new Date().toISOString(),
+ });
+ },
+};
diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/stores/settings.svelte.ts b/apps/mana/apps/web/src/lib/modules/broadcast/stores/settings.svelte.ts
new file mode 100644
index 000000000..3303792cf
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/broadcast/stores/settings.svelte.ts
@@ -0,0 +1,83 @@
+/**
+ * Broadcast settings store — singleton row per user/space.
+ *
+ * Same pattern as invoices settings: stable sentinel id, lazy create on
+ * first read, plaintext structural fields stay out of the Dexie-tx crypto
+ * boundary so ensure/get paths don't need crypto ops inside a transaction.
+ */
+
+import { encryptRecord, decryptRecords } from '$lib/data/crypto';
+import { emitDomainEvent } from '$lib/data/events';
+import { settingsTable } from '../collections';
+import { BROADCAST_SETTINGS_ID } from '../constants';
+import type { LocalBroadcastSettings, BroadcastSettings } from '../types';
+import { toSettings } from '../queries';
+
+async function ensureSettings(): Promise {
+ const existing = await settingsTable.get(BROADCAST_SETTINGS_ID);
+ if (existing) return existing;
+
+ const defaults: LocalBroadcastSettings = {
+ id: BROADCAST_SETTINGS_ID,
+ defaultFromName: '',
+ defaultFromEmail: '',
+ defaultReplyTo: null,
+ defaultFooter: null,
+ dnsCheck: null,
+ legalAddress: '',
+ unsubscribeLandingCopy: null,
+ };
+ const wrapped = { ...defaults };
+ await encryptRecord('broadcastSettings', wrapped);
+ await settingsTable.add(wrapped);
+ return wrapped;
+}
+
+export const broadcastSettingsStore = {
+ async get(): Promise {
+ await ensureSettings();
+ const row = await settingsTable.get(BROADCAST_SETTINGS_ID);
+ if (!row) throw new Error('[broadcast] settings row vanished after ensure');
+ const [decrypted] = (await decryptRecords('broadcastSettings', [
+ row,
+ ])) as LocalBroadcastSettings[];
+ return toSettings(decrypted);
+ },
+
+ async update(patch: Partial>) {
+ await ensureSettings();
+ const wrapped = { ...patch } as Record;
+ await encryptRecord('broadcastSettings', wrapped);
+ await settingsTable.update(BROADCAST_SETTINGS_ID, {
+ ...wrapped,
+ updatedAt: new Date().toISOString(),
+ });
+ emitDomainEvent(
+ 'BroadcastSettingsUpdated',
+ 'broadcast',
+ 'broadcastSettings',
+ BROADCAST_SETTINGS_ID,
+ { fields: Object.keys(patch) }
+ );
+ },
+
+ /** Quick-read defaults for a fresh campaign. */
+ async getDefaults(): Promise<{
+ fromName: string;
+ fromEmail: string;
+ replyTo: string | null;
+ footer: string | null;
+ legalAddress: string;
+ }> {
+ const s = await this.get();
+ return {
+ fromName: s.defaultFromName,
+ fromEmail: s.defaultFromEmail,
+ replyTo: s.defaultReplyTo,
+ footer: s.defaultFooter,
+ legalAddress: s.legalAddress,
+ };
+ },
+};
+
+export { ensureSettings };
diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/views/ComposeView.svelte b/apps/mana/apps/web/src/lib/modules/broadcast/views/ComposeView.svelte
new file mode 100644
index 000000000..6ab898a53
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/broadcast/views/ComposeView.svelte
@@ -0,0 +1,406 @@
+
+
+
+