diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 4b6da63ba..9cc816621 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -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 = { // ─── Chat ──────────────────────────────────────────────── @@ -619,6 +624,42 @@ export const ENCRYPTION_REGISTRY: Record = { '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([ + 'name', + 'subject', + 'preheader', + 'fromName', + 'fromEmail', + 'replyTo', + 'content', + 'audience', + ]), + broadcastTemplates: entry(['name', 'description', 'subject', 'content']), + broadcastSettings: entry([ + '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 diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index 016c0c063..8a1afb5b8 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -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, ]; diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/ListView.svelte b/apps/mana/apps/web/src/lib/modules/broadcast/ListView.svelte new file mode 100644 index 000000000..db225ddff --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/broadcast/ListView.svelte @@ -0,0 +1,173 @@ + + + +
+
+
+

Broadcasts

+

Newsletter und Kampagnen an deine Kontakte

+
+ +
+ + {#if campaigns.length === 0} +
+
📣
+

Noch keine Kampagnen

+

+ Verschicke deinen ersten Newsletter — mit Rich-Text-Editor, Tracking und DSGVO-konformem + Abmelden. +

+

M1 Skelett — Compose-Flow folgt in M2.

+
+ {:else} +
    + {#each campaigns as campaign (campaign.id)} +
  • + {campaign.subject} + + {campaign.audience?.estimatedCount ?? 0} Empfänger + + + + {STATUS_LABELS[campaign.status].de} + +
  • + {/each} +
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/collections.ts b/apps/mana/apps/web/src/lib/modules/broadcast/collections.ts new file mode 100644 index 000000000..596123f3e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/broadcast/collections.ts @@ -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('broadcastCampaigns'); +export const templateTable = db.table('broadcastTemplates'); +export const settingsTable = db.table('broadcastSettings'); + +/** Explicitly empty so module-registry loaders still get a valid export. */ +export const BROADCAST_GUEST_SEED = {}; diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/constants.ts b/apps/mana/apps/web/src/lib/modules/broadcast/constants.ts new file mode 100644 index 000000000..f8077e4dc --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/broadcast/constants.ts @@ -0,0 +1,27 @@ +import type { CampaignStatus } from './types'; + +export const STATUS_LABELS: Record = { + 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 = { + 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; diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/index.ts b/apps/mana/apps/web/src/lib/modules/broadcast/index.ts new file mode 100644 index 000000000..2a1551014 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/broadcast/index.ts @@ -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'; diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/module.config.ts b/apps/mana/apps/web/src/lib/modules/broadcast/module.config.ts new file mode 100644 index 000000000..7ab01bedc --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/broadcast/module.config.ts @@ -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' }, + ], +}; diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/types.ts b/apps/mana/apps/web/src/lib/modules/broadcast/types.ts new file mode 100644 index 000000000..9643cd743 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/broadcast/types.ts @@ -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; +} diff --git a/apps/mana/apps/web/src/routes/(app)/broadcasts/+page.svelte b/apps/mana/apps/web/src/routes/(app)/broadcasts/+page.svelte new file mode 100644 index 000000000..78750eef2 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/broadcasts/+page.svelte @@ -0,0 +1,9 @@ + + + + Broadcasts - Mana + + + diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index 56a557edc..e86a92dd4 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -236,6 +236,12 @@ export const APP_ICONS = { // Emerald→teal sits next to finance green in the Arbeit & Finanzen row. `` ), + 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. + `` + ), } as const; export type AppIconId = keyof typeof APP_ICONS; diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index bcff304ab..50148dd0f 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -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',