mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 02:59:40 +02:00
feat(broadcast): settings + detail view + compliance polish
Closes the "could actually dogfood" gap: legal address can be set,
sent campaigns have a proper view with live stats, and the send path
respects DSGVO.
Webapp
- components/SettingsForm.svelte: sender defaults + Impressum (required,
highlighted amber until filled) + footer. Matches the invoices
SenderProfileForm pattern — immediate save, dedicated section per
concern.
- /broadcasts/settings/+page.svelte: mounts the form. ComposeView step
3's "Einstellungen öffnen" CTA now lands somewhere.
- views/DetailView.svelte: read-only view for sent/scheduled/cancelled
campaigns. 5-card stats grid (sent, open, click, bounce, unsub) with
rate percentages. Polls mana-mail every 30s for up to 30 min after
mount, persists back to Dexie via applyServerStatus so the list view
+ widget catch up. Includes a preview of the actual rendered campaign
so "what went out" is visible after the fact.
- /broadcasts/[id]/+page.svelte: DetailView for non-drafts; drafts
bounce to /edit via $effect-triggered goto.
- ListView row-click now routes by status (draft → edit, else → detail).
mana-mail compliance
- Orchestrator loadUnsubscribedEmails(): queries broadcast.sends WHERE
status='unsubscribed' scoped to the user, filters the recipient list
BEFORE any send rows get written. Campaign's totalRecipients reflects
the post-skip count so open rates aren't inflated by "virtual sends".
Skipped count surfaces in result.errors for the UI to show.
- jmap-client.submitEmail: new extraHeaders param. Sets custom headers
via JMAP's `header:<Name>:asText` property convention.
- Orchestrator sets RFC 8058 headers per recipient:
List-Unsubscribe: <https://.../track/unsubscribe/{token}>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
This is what makes Gmail / Apple Mail show their native "Abmelden"
button in the message header (not just a body link).
All checks clean: 0 TS errors, 37/37 webapp tests, 9/9 tracking-token
tests, mana-mail bun build = 2.50 MB.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c9141e3b35
commit
d887fc125d
7 changed files with 746 additions and 10 deletions
|
|
@ -12,12 +12,10 @@
|
|||
const campaigns = $derived(campaigns$.value ?? []);
|
||||
|
||||
function openCampaign(id: string, status: string) {
|
||||
// Drafts go straight to edit; sent/scheduled to a detail view
|
||||
// (detail lands in M7; until then, edit is the entry for drafts
|
||||
// and we bounce sent ones back to the list — see canEdit guard
|
||||
// in the edit route).
|
||||
// Drafts open in the compose flow for editing; everything else
|
||||
// opens the read-only DetailView with stats.
|
||||
if (status === 'draft') goto(`/broadcasts/${id}/edit`);
|
||||
else goto(`/broadcasts/${id}/edit`);
|
||||
else goto(`/broadcasts/${id}`);
|
||||
}
|
||||
|
||||
function onNewCampaign() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,239 @@
|
|||
<!--
|
||||
SettingsForm — sender defaults, legal address, default footer.
|
||||
|
||||
Mirrors the invoices SenderProfileForm pattern (singleton-settings,
|
||||
immediate save). Legal address is Pflicht (DSGVO / Impressumspflicht)
|
||||
— the form flags it in red until filled in and the send step in
|
||||
ComposeView blocks without it.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { broadcastSettingsStore } from '../stores/settings.svelte';
|
||||
import type { BroadcastSettings } from '../types';
|
||||
|
||||
let settings = $state<BroadcastSettings | null>(null);
|
||||
let saving = $state(false);
|
||||
let savedAt = $state<string | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
broadcastSettingsStore.get().then((s) => {
|
||||
settings = s;
|
||||
});
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!settings) return;
|
||||
saving = true;
|
||||
error = null;
|
||||
try {
|
||||
await broadcastSettingsStore.update({
|
||||
defaultFromName: settings.defaultFromName,
|
||||
defaultFromEmail: settings.defaultFromEmail,
|
||||
defaultReplyTo: settings.defaultReplyTo,
|
||||
defaultFooter: settings.defaultFooter,
|
||||
legalAddress: settings.legalAddress,
|
||||
unsubscribeLandingCopy: settings.unsubscribeLandingCopy,
|
||||
});
|
||||
savedAt = new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Speichern fehlgeschlagen';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
const legalMissing = $derived(!settings?.legalAddress?.trim());
|
||||
</script>
|
||||
|
||||
{#if !settings}
|
||||
<p class="loading">Lade Einstellungen …</p>
|
||||
{:else}
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}}
|
||||
class="form"
|
||||
>
|
||||
<section class="section">
|
||||
<h3>Absender-Standard</h3>
|
||||
<p class="hint">
|
||||
Wird als Vorbelegung für jede neue Kampagne gesetzt. Du kannst's pro Kampagne überschreiben.
|
||||
</p>
|
||||
|
||||
<div class="grid-2">
|
||||
<label class="field">
|
||||
<span>Name *</span>
|
||||
<input type="text" bind:value={settings.defaultFromName} required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>E-Mail *</span>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="newsletter@deine-domain.ch"
|
||||
bind:value={settings.defaultFromEmail}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>Reply-To</span>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Optional — sonst geht die Antwort an die Absender-Adresse"
|
||||
value={settings.defaultReplyTo ?? ''}
|
||||
oninput={(e) => settings && (settings.defaultReplyTo = e.currentTarget.value || null)}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="section" class:section-warn={legalMissing}>
|
||||
<h3>Impressum *</h3>
|
||||
<p class="hint">
|
||||
<strong>Pflicht</strong> in jedem Newsletter (DSGVO / §5 TMG / Art. 13 DSG). Erscheint im Footer
|
||||
jeder Kampagne.
|
||||
</p>
|
||||
|
||||
<label class="field">
|
||||
<span>Legal-Adresse *</span>
|
||||
<textarea
|
||||
rows="4"
|
||||
placeholder="Till Beispiel AG Bahnhofstrasse 1 8000 Zürich CHE-123.456.789 MWST"
|
||||
bind:value={settings.legalAddress}
|
||||
required
|
||||
></textarea>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h3>Standard-Footer</h3>
|
||||
<p class="hint">
|
||||
Optional — ergänzt den Abmelde-Link + das Impressum am Ende jeder Kampagne.
|
||||
</p>
|
||||
|
||||
<label class="field">
|
||||
<span>Footer-Text</span>
|
||||
<textarea
|
||||
rows="3"
|
||||
placeholder="Danke, dass du dabei bist — bis zum nächsten Newsletter."
|
||||
value={settings.defaultFooter ?? ''}
|
||||
oninput={(e) => settings && (settings.defaultFooter = e.currentTarget.value || null)}
|
||||
></textarea>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-primary" disabled={saving}>
|
||||
{saving ? 'Speichert …' : 'Speichern'}
|
||||
</button>
|
||||
{#if savedAt}
|
||||
<span class="saved">Gespeichert um {savedAt}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.loading {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-warn {
|
||||
padding: 1rem;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.field > span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea {
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.4rem;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
padding: 0.55rem 1.25rem;
|
||||
border: 0;
|
||||
border-radius: 0.4rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.saved {
|
||||
font-size: 0.85rem;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
<!--
|
||||
DetailView — read-only view for sent / scheduled / cancelled campaigns.
|
||||
Drafts still route to /broadcasts/[id]/edit (editable).
|
||||
|
||||
Polls mana-mail for live tracking stats every 30s when a campaign is
|
||||
in 'sent' state. Stops polling after 30 minutes (diminishing returns)
|
||||
or when the tab loses focus.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { STATUS_LABELS, STATUS_COLORS } from '../constants';
|
||||
import { formatRate } from '../queries';
|
||||
import { broadcastCampaignsStore } from '../stores/campaigns.svelte';
|
||||
import { fetchCampaignStats } from '../api';
|
||||
import EmailPreview from '../preview/EmailPreview.svelte';
|
||||
import { broadcastSettingsStore } from '../stores/settings.svelte';
|
||||
import { renderEmailHtml } from '../render/email-html';
|
||||
import type { Campaign, BroadcastSettings, CampaignStats } from '../types';
|
||||
|
||||
interface Props {
|
||||
campaign: Campaign;
|
||||
}
|
||||
|
||||
let { campaign }: Props = $props();
|
||||
|
||||
// Seed from the current campaign row; polling takes over after mount.
|
||||
// Route-level key ensures we remount when the id changes.
|
||||
let liveStats = $state<CampaignStats | null>(untrack(() => campaign.stats));
|
||||
let pollError = $state<string | null>(null);
|
||||
let settings = $state<BroadcastSettings | null>(null);
|
||||
let polling = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
broadcastSettingsStore.get().then((s) => {
|
||||
settings = s;
|
||||
});
|
||||
});
|
||||
|
||||
// Poll stats for sent campaigns. 30s interval, max 30 minutes.
|
||||
// Stops earlier if the sum of opens/clicks stabilises (not
|
||||
// implemented yet — would need a 2-tick compare).
|
||||
$effect(() => {
|
||||
if (campaign.status !== 'sent') return;
|
||||
let cancelled = false;
|
||||
const pollStartedAt = Date.now();
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
const POLL_TIMEOUT_MS = 30 * 60_000;
|
||||
|
||||
async function poll() {
|
||||
if (cancelled) return;
|
||||
if (Date.now() - pollStartedAt > POLL_TIMEOUT_MS) return;
|
||||
polling = true;
|
||||
try {
|
||||
const stats = await fetchCampaignStats(campaign.id);
|
||||
if (stats && !cancelled) {
|
||||
liveStats = stats;
|
||||
// Persist back to Dexie so the list view + widget see
|
||||
// the updated numbers without hitting the server again.
|
||||
await broadcastCampaignsStore.applyServerStatus(campaign.id, {
|
||||
status: campaign.status,
|
||||
stats,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) pollError = e instanceof Error ? e.message : 'Stats-Fetch fehlgeschlagen';
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
if (!cancelled) setTimeout(poll, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
const openRate = $derived(
|
||||
liveStats && liveStats.sent > 0 ? liveStats.opened / liveStats.sent : null
|
||||
);
|
||||
const clickRate = $derived(
|
||||
liveStats && liveStats.sent > 0 ? liveStats.clicked / liveStats.sent : null
|
||||
);
|
||||
const bounceRate = $derived(
|
||||
liveStats && liveStats.sent > 0 ? liveStats.bounced / liveStats.sent : null
|
||||
);
|
||||
const unsubRate = $derived(
|
||||
liveStats && liveStats.sent > 0 ? liveStats.unsubscribed / liveStats.sent : null
|
||||
);
|
||||
|
||||
const previewHtml = $derived(
|
||||
settings
|
||||
? renderEmailHtml({
|
||||
tiptapHtml: campaign.content.html ?? '',
|
||||
campaign: {
|
||||
subject: campaign.subject,
|
||||
preheader: campaign.preheader,
|
||||
fromName: campaign.fromName,
|
||||
fromEmail: campaign.fromEmail,
|
||||
},
|
||||
settings,
|
||||
})
|
||||
: ''
|
||||
);
|
||||
|
||||
async function onDuplicate() {
|
||||
const newId = await broadcastCampaignsStore.duplicate(campaign.id);
|
||||
goto(`/broadcasts/${newId}/edit`);
|
||||
}
|
||||
|
||||
async function onCancel() {
|
||||
if (!confirm('Geplante Kampagne abbrechen?')) return;
|
||||
await broadcastCampaignsStore.cancel(campaign.id);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
// $effect cleanup already handles cancellation — this is a safety net.
|
||||
});
|
||||
</script>
|
||||
|
||||
<article class="detail">
|
||||
<header class="head">
|
||||
<div class="head-left">
|
||||
<h1>{campaign.name}</h1>
|
||||
<span class="status" style="--dot: {STATUS_COLORS[campaign.status]}">
|
||||
<span class="dot"></span>
|
||||
{STATUS_LABELS[campaign.status].de}
|
||||
</span>
|
||||
<p class="subject">{campaign.subject}</p>
|
||||
</div>
|
||||
<div class="head-right">
|
||||
{#if campaign.sentAt}
|
||||
<div class="sent-at">Versendet {new Date(campaign.sentAt).toLocaleString()}</div>
|
||||
{/if}
|
||||
{#if campaign.scheduledAt}
|
||||
<div class="sent-at">Geplant für {new Date(campaign.scheduledAt).toLocaleString()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn" onclick={onDuplicate}>Duplizieren</button>
|
||||
{#if campaign.status === 'scheduled'}
|
||||
<button class="btn btn-danger" onclick={onCancel}>Abbrechen</button>
|
||||
{/if}
|
||||
<a class="btn" href="/broadcasts">Zur Übersicht</a>
|
||||
</div>
|
||||
|
||||
{#if liveStats}
|
||||
<section class="stats-grid">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Versendet</div>
|
||||
<div class="stat-value">{liveStats.sent}</div>
|
||||
<div class="stat-sub">von {liveStats.totalRecipients}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Geöffnet</div>
|
||||
<div class="stat-value">{formatRate(openRate)}</div>
|
||||
<div class="stat-sub">{liveStats.opened} Öffnungen</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Geklickt</div>
|
||||
<div class="stat-value">{formatRate(clickRate)}</div>
|
||||
<div class="stat-sub">{liveStats.clicked} Klicks</div>
|
||||
</div>
|
||||
<div class="stat" class:stat-warn={liveStats.bounced > 0}>
|
||||
<div class="stat-label">Bounced</div>
|
||||
<div class="stat-value">{formatRate(bounceRate)}</div>
|
||||
<div class="stat-sub">{liveStats.bounced}</div>
|
||||
</div>
|
||||
<div class="stat" class:stat-warn={liveStats.unsubscribed > 0}>
|
||||
<div class="stat-label">Abgemeldet</div>
|
||||
<div class="stat-value">{formatRate(unsubRate)}</div>
|
||||
<div class="stat-sub">{liveStats.unsubscribed}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="poll-hint">
|
||||
{#if polling}
|
||||
Live-Update …
|
||||
{:else}
|
||||
Letzte Aktualisierung: {new Date(liveStats.lastSyncedAt).toLocaleTimeString()}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if pollError}
|
||||
<div class="poll-error">Stats-Fetch fehlgeschlagen: {pollError}</div>
|
||||
{/if}
|
||||
|
||||
{#if settings && previewHtml}
|
||||
<section class="preview-section">
|
||||
<h3>Wie die Kampagne aussah</h3>
|
||||
<EmailPreview html={previewHtml} viewport="desktop" />
|
||||
</section>
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.detail {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.head-left,
|
||||
.head-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.head-right {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.head h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subject {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
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);
|
||||
}
|
||||
|
||||
.sent-at {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text, #0f172a);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: #b91c1c;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
|
||||
.stat-warn {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat-warn .stat-value {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
margin-top: 0.15rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.poll-hint {
|
||||
margin: -0.5rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.poll-error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.preview-section h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { useAllCampaigns } from '$lib/modules/broadcast/queries';
|
||||
import DetailView from '$lib/modules/broadcast/views/DetailView.svelte';
|
||||
|
||||
const campaigns$ = useAllCampaigns();
|
||||
const campaigns = $derived(campaigns$.value ?? []);
|
||||
const id = $derived($page.params.id);
|
||||
const campaign = $derived(campaigns.find((c) => c.id === id));
|
||||
|
||||
// Drafts bounce straight to the edit route — Compose is the
|
||||
// canonical view for drafts; DetailView is read-only for sent /
|
||||
// scheduled / cancelled.
|
||||
$effect(() => {
|
||||
if (campaign?.status === 'draft') {
|
||||
goto(`/broadcasts/${campaign.id}/edit`, { replaceState: true });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{campaign?.name ?? 'Kampagne'} - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if !campaign && campaigns$.value !== undefined}
|
||||
<div class="not-found">
|
||||
<p>Kampagne nicht gefunden.</p>
|
||||
<a href="/broadcasts">Zurück zur Übersicht</a>
|
||||
</div>
|
||||
{:else if campaign && campaign.status !== 'draft'}
|
||||
<DetailView {campaign} />
|
||||
{:else}
|
||||
<div class="loading">Lädt …</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.not-found,
|
||||
.loading {
|
||||
padding: 3rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<script lang="ts">
|
||||
import SettingsForm from '$lib/modules/broadcast/components/SettingsForm.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Broadcast-Einstellungen - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<header class="head">
|
||||
<h1>Broadcast-Einstellungen</h1>
|
||||
<p class="subtitle">Sender-Defaults, Impressum und Footer für alle neuen Kampagnen.</p>
|
||||
</header>
|
||||
|
||||
<SettingsForm />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.head {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
* live in Dexie (local-first) and the server never sees them decrypted.
|
||||
*/
|
||||
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import juice from 'juice';
|
||||
import type { Database } from '../db/connection';
|
||||
import { campaigns, sends, type NewBroadcastSend } from '../db/schema';
|
||||
|
|
@ -130,6 +131,22 @@ export class BroadcastOrchestrator {
|
|||
return { html, text: '', unsubscribeUrl, webViewUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the set of email addresses this user's audience has
|
||||
* unsubscribed from previous campaigns. Lowercased so a later case-
|
||||
* insensitive compare works. Scope is per-user, not per-campaign —
|
||||
* once someone unsubscribes from you, they're out of every future
|
||||
* campaign you send, not just the one they unsubscribed via.
|
||||
*/
|
||||
private async loadUnsubscribedEmails(userId: string): Promise<Set<string>> {
|
||||
const rows = await this.db
|
||||
.selectDistinct({ email: sends.recipientEmail })
|
||||
.from(sends)
|
||||
.innerJoin(campaigns, eq(sends.campaignId, campaigns.id))
|
||||
.where(and(eq(campaigns.userId, userId), eq(sends.status, 'unsubscribed')));
|
||||
return new Set(rows.map((r) => r.email.toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the bulk send. Returns aggregate stats. Blocks for the duration
|
||||
* of the send (MVP — see module header).
|
||||
|
|
@ -149,10 +166,24 @@ export class BroadcastOrchestrator {
|
|||
throw new Error('No mail account configured for this user');
|
||||
}
|
||||
|
||||
// Compliance gate: drop anyone who's ever unsubscribed from this
|
||||
// user. Done BEFORE any send rows are written so the dashboard
|
||||
// counts reflect "tatsächlich versandt" rather than "geplant".
|
||||
const unsubscribed = await this.loadUnsubscribedEmails(input.userId);
|
||||
const originalCount = input.recipients.length;
|
||||
const recipients = input.recipients.filter((r) => !unsubscribed.has(r.email.toLowerCase()));
|
||||
const skipped = originalCount - recipients.length;
|
||||
|
||||
if (recipients.length === 0) {
|
||||
throw new Error('Alle Empfänger haben sich abgemeldet — nichts zu senden.');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// 1. Persist campaign row (mirror of the webapp's campaign for
|
||||
// server-side tracking joins).
|
||||
// server-side tracking joins). totalRecipients reflects the
|
||||
// post-skip count so open/click rates aren't artificially
|
||||
// lowered by "virtual sends" that never happened.
|
||||
await this.db
|
||||
.insert(campaigns)
|
||||
.values({
|
||||
|
|
@ -162,23 +193,31 @@ export class BroadcastOrchestrator {
|
|||
fromEmail: input.fromEmail,
|
||||
fromName: input.fromName,
|
||||
sentAt: now,
|
||||
totalRecipients: input.recipients.length,
|
||||
totalRecipients: recipients.length,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
const inlinedHtml = this.inlineOnce(input.htmlBody);
|
||||
const result: BulkSendResult = {
|
||||
campaignId: input.campaignId,
|
||||
accepted: input.recipients.length,
|
||||
accepted: recipients.length,
|
||||
delivered: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
errors:
|
||||
skipped > 0
|
||||
? [
|
||||
{
|
||||
email: '(skipped)',
|
||||
reason: `${skipped} Empfänger übersprungen — haben sich vorher abgemeldet`,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
|
||||
// 2. Loop. One send row per recipient, written first (status=queued)
|
||||
// so a crash mid-loop leaves the DB truthful about who got a
|
||||
// mail attempt.
|
||||
for (const recipient of input.recipients) {
|
||||
for (const recipient of recipients) {
|
||||
const sendId = crypto.randomUUID();
|
||||
const nonce = generateNonce();
|
||||
const sendRow: NewBroadcastSend = {
|
||||
|
|
@ -205,12 +244,21 @@ export class BroadcastOrchestrator {
|
|||
.replaceAll('[Abmelde-Link wird beim Versand eingefügt]', textUnsubUrl);
|
||||
|
||||
try {
|
||||
// RFC 8058: `List-Unsubscribe: <https://…>` + the POST
|
||||
// header together tell Gmail / Apple Mail to show the
|
||||
// native "Abmelden"-button in the header. Without this
|
||||
// pair, they fall back to just the body link.
|
||||
const listUnsubUrl = `${this.baseUrl}/api/v1/track/unsubscribe/${textToken}`;
|
||||
await this.jmap.submitEmail(account.stalwartAccountId, {
|
||||
from: { name: input.fromName, email: input.fromEmail },
|
||||
to: [{ name: recipient.name ?? null, email: recipient.email }],
|
||||
subject: input.subject,
|
||||
textBody: text,
|
||||
htmlBody: html,
|
||||
extraHeaders: {
|
||||
'List-Unsubscribe': `<${listUnsubUrl}>`,
|
||||
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
||||
},
|
||||
});
|
||||
await this.db
|
||||
.update(sends)
|
||||
|
|
|
|||
|
|
@ -252,6 +252,13 @@ export class JmapClient {
|
|||
htmlBody?: string;
|
||||
inReplyTo?: string;
|
||||
references?: string[];
|
||||
/**
|
||||
* Extra headers to add to the outgoing mail. Keys should be
|
||||
* the header name (e.g. "List-Unsubscribe"); values are the
|
||||
* string header value. Used for RFC 8058 one-click unsubscribe.
|
||||
* JMAP's property convention is `header:<Name>:asText`.
|
||||
*/
|
||||
extraHeaders?: Record<string, string>;
|
||||
}
|
||||
): Promise<string> {
|
||||
const emailId = `draft-${Date.now()}`;
|
||||
|
|
@ -286,6 +293,13 @@ export class JmapClient {
|
|||
if (email.bcc) emailCreate.bcc = email.bcc;
|
||||
if (email.inReplyTo) emailCreate.inReplyTo = email.inReplyTo;
|
||||
if (email.references) emailCreate.references = email.references;
|
||||
// Custom headers via JMAP's `header:<Name>:asText` convention.
|
||||
// Used for RFC 8058 (List-Unsubscribe + List-Unsubscribe-Post).
|
||||
if (email.extraHeaders) {
|
||||
for (const [name, value] of Object.entries(email.extraHeaders)) {
|
||||
emailCreate[`header:${name}:asText`] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const responses = await this.call(
|
||||
[
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue