feat(crm): newsletter campaigns behind a newsletters flag (#1264)

Part B of #1264. Flag off by default, so an install that never enables it
gains no route, no nav entry and no way to mass-mail.

A campaign is a body plus a recipient rule. Queueing one writes ordinary
email_queue rows (email_type 'newsletter', origin 'campaign', new
campaign_id), so retry, rendered_html, sent_at and error_message all come
from the existing processor rather than a parallel sender. Throttling
staggers scheduled_at; the processor loop is untouched.

Two rules the service enforces: no raw HTML is ever stored (sanitized on
write and again on render, idempotently), and opt-out is checked at queue
time AND again at send time.

Migration 199 adds email_campaigns, email_campaign_recipients,
email_queue.campaign_id, customer_accounts.marketing_opt_out(_at), and
the newsletters.view / newsletters.send permissions.

Three rounds of external review are folded in, including several that
would otherwise have shipped broken:

- Campaign rows never came due on SQLite. queueEmail writes a Date, which
  the sqlite3 binding stores as epoch ms; ISO text in the same column
  compares as TEXT against an INTEGER, and SQLite orders every INTEGER
  below every TEXT. The feature silently sent nothing there.
- The flag had no Settings card and no sidebar entry, so it could not be
  enabled through the UI at all.
- Consent is per ADDRESS, not per row: two accounts sharing an inbox meant
  unsubscribing stopped one and not the other, at both queue and send time.
- The unsubscribe GET mutated consent, so a mail-security scanner walking
  a campaign could have unsubscribed much of the list. GET now confirms,
  POST acts.
- The rate ceiling is clamped to the queue's real throughput (10/min), so
  the composer's estimate stops being wrong by up to 12x.

Closes #1264
This commit is contained in:
Paul Nothaft
2026-09-04 14:32:31 +02:00
committed by GitHub
parent a7d0972b13
commit fc595409b4
32 changed files with 5044 additions and 28 deletions
+26 -7
View File
@@ -36,6 +36,11 @@ import {
BillDetailPage,
} from './pages/admin';
import { CrmDevelopmentPage } from './pages/admin/clients/CrmDevelopmentPage';
// Newsletter campaigns (#1264). Gated by the `newsletters` flag inside the
// Clients block; the API refuses these routes independently when it is off.
import { NewsletterListPage } from './pages/admin/newsletters/NewsletterListPage';
import { NewsletterComposerPage } from './pages/admin/newsletters/NewsletterComposerPage';
import { NewsletterDetailPage } from './pages/admin/newsletters/NewsletterDetailPage';
import { TaxReportPage } from './pages/admin/clients/TaxReportPage';
import { HoursLoggingPage } from './pages/admin/clients/HoursLoggingPage';
// E.6 — Calendar page lazy-loaded so the ~200 KB FullCalendar bundle
@@ -272,7 +277,12 @@ function App() {
independently. */}
<Route element={<RequireFeature flag="clients" />}>
<Route path="clients" element={<ClientsLayout />}>
<Route element={<RequireFeature flag="customerPortal" />}>
{/* Newsletters also lives here: the customer detail
page hosts the newsletter consent control, so a
newsletter-only install (portal off) must still
be able to open a customer to record a phone
opt-out (#1264). */}
<Route element={<RequireFeature anyOf={['customerPortal', 'newsletters']} />}>
<Route path="accounts" element={<CustomerManagementPage />} />
<Route path="accounts/:id" element={<CustomerDetailPage />} />
</Route>
@@ -334,16 +344,25 @@ function App() {
section. Keep this path as a redirect so old
bookmarks / links don't 404. */}
<Route path="tax-report" element={<Navigate to="/admin/accounting/tax-report" replace />} />
{/* Newsletter campaigns (#1264) — gated by
`newsletters`. Mass marketing mail to customer
accounts, with per-customer opt-out. */}
<Route element={<RequireFeature flag="newsletters" />}>
<Route path="newsletters" element={<NewsletterListPage />} />
<Route path="newsletters/:id" element={<NewsletterDetailPage />} />
<Route path="newsletters/:id/edit" element={<NewsletterComposerPage />} />
</Route>
{/* Developer tools — gated by `crmDevelopment`. */}
<Route element={<RequireFeature flag="crmDevelopment" />}>
<Route path="development" element={<CrmDevelopmentPage />} />
</Route>
{/* Default: send /admin/clients (no sub-path) to
the first enabled sub-feature. accounts comes
first because it predates the others. The empty
state inside ClientsLayout handles "parent on,
all children off". */}
<Route index element={<Navigate to="/admin/clients/accounts" replace />} />
{/* No index redirect here: ClientsLayout picks the
first sub-feature this user can actually reach,
which a fixed /accounts target could not — a
newsletters-only role has no customers.view and
would bounce straight back out (#1264). It also
owns the "parent on, all children off" empty
state. */}
</Route>
</Route>
+15 -1
View File
@@ -52,6 +52,13 @@ interface NavItem {
* constraint.
*/
featureFlagsAny?: FeatureKey[];
/**
* Alternative permissions, any ONE of which reveals the entry. For a
* section whose sub-features are gated independently server-side — Clients
* hosts both customer accounts and newsletters, and the backend supports a
* role holding `newsletters.view` without `customers.view` (#1264).
*/
permissionAny?: string[];
}
// Sidebar shape after the Settings reorg (#feature-flags-settings-reorg).
@@ -93,7 +100,9 @@ export const adminNavigation: NavItem[] = [
// their own permission keys and the gate here grows into an OR.
{
nameKey: 'navigation.clients', href: '/admin/clients', icon: Briefcase,
permission: 'customers.view',
// Any of these opens the section; each sub-page is gated on its own
// permission once inside.
permissionAny: ['customers.view', 'newsletters.view'],
featureFlag: 'clients',
// Hide the entry when the parent is on but no sub-feature is —
// there's nothing inside ClientsLayout to link to. Mirror the same
@@ -108,6 +117,9 @@ export const adminNavigation: NavItem[] = [
featureFlagsAny: [
'customerPortal', 'crmDevelopment', 'quotes', 'bills',
'hoursLogging', 'contracts', 'calendar', 'projects',
// #1264 — newsletters is a Clients child and must light up the entry,
// or a newsletter-only install has no way into the section.
'newsletters',
],
},
// Accounting section (migration 122) — inbound supplier invoices,
@@ -161,6 +173,8 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, col
const filteredNavigation = adminNavigation.filter((item) => {
if (item.permission && !hasPermission(item.permission as string)) return false;
if (item.permissionAny?.length
&& !item.permissionAny.some((p) => hasPermission(p))) return false;
if (item.featureFlag && !flags[item.featureFlag]) return false;
// featureFlagsAny: entry is hidden when none of the listed
// sub-flags are on, even if the parent flag IS on. Used by
@@ -12,11 +12,12 @@
* active item with white icon + label.
*/
import React from 'react';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { NavLink, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Briefcase, UserCog, FileText, Receipt, Wrench, Clock, ScrollText, Calendar, FolderKanban } from 'lucide-react';
import { Briefcase, UserCog, FileText, Receipt, Wrench, Clock, ScrollText, Calendar, FolderKanban, Megaphone } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
import { usePermissions } from '../../contexts/PermissionsContext';
interface NavItem {
key: string;
@@ -30,6 +31,12 @@ interface NavItem {
* to declare their own sub-flag here.
*/
featureFlag: FeatureKey;
/**
* Permission required to reach the page behind this entry. Without it the
* item still rendered for anyone who could enter Clients at all, and the
* click landed on a backend 403 (#1264 review).
*/
permission?: string;
}
export const ClientsLayout: React.FC = () => {
@@ -52,6 +59,7 @@ export const ClientsLayout: React.FC = () => {
label: t('clients.subnav.accounts', 'Accounts'),
icon: UserCog,
featureFlag: 'customerPortal',
permission: 'customers.view',
},
{
key: 'calendar',
@@ -92,6 +100,14 @@ export const ClientsLayout: React.FC = () => {
// longer a CRM sub-feature). See AccountingLayout.
// Future sub-features:
// { key: 'messaging', ... featureFlag: 'messaging' }
{
key: 'newsletters',
to: '/admin/clients/newsletters',
label: t('clients.subnav.newsletters', 'Newsletters'),
icon: Megaphone,
featureFlag: 'newsletters',
permission: 'newsletters.view',
},
{
key: 'development',
to: '/admin/clients/development',
@@ -101,7 +117,17 @@ export const ClientsLayout: React.FC = () => {
},
];
const enabledItems = navItems.filter((item) => flags[item.featureFlag]);
const { hasPermission } = usePermissions();
const enabledItems = navItems.filter((item) =>
flags[item.featureFlag] && (!item.permission || hasPermission(item.permission)));
// /admin/clients has no page of its own. Rather than a hard-coded redirect
// to Accounts — which a newsletters-only role cannot open — land on the
// first entry this user can actually reach.
const isSectionRoot = location.pathname.replace(/\/+$/, '') === '/admin/clients';
if (isSectionRoot && enabledItems.length > 0) {
return <Navigate to={enabledItems[0].to} replace />;
}
// When the parent `clients` flag is on but no sub-feature is enabled,
// there's nothing to render. Settings → Features is one click away
@@ -3,7 +3,16 @@ import { Navigate, Outlet } from 'react-router-dom';
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
interface RequireFeatureProps {
flag: FeatureKey;
/** Single required flag. Mutually exclusive with `anyOf`. */
flag?: FeatureKey;
/**
* Pass when a surface belongs to more than one feature and should stay
* reachable while ANY of them is on. The customer detail page is the case
* this exists for: it hosts both the portal account fields and the
* newsletter consent control, so a newsletter-only install must still be
* able to open it (#1264).
*/
anyOf?: FeatureKey[];
fallback?: string;
}
@@ -15,11 +24,18 @@ interface RequireFeatureProps {
* Mounted as the `element` of a parent <Route>, with the gated routes as
* children — see App.tsx.
*/
export const RequireFeature: React.FC<RequireFeatureProps> = ({ flag, fallback = '/admin/dashboard' }) => {
export const RequireFeature: React.FC<RequireFeatureProps> = ({
flag,
anyOf,
fallback = '/admin/dashboard',
}) => {
const { flags, isLoading } = useFeatureFlags();
// Wait for the first fetch — otherwise we'd briefly fall back to the
// default-flags object and could redirect on a transient false.
if (isLoading) return null;
if (!flags[flag]) return <Navigate to={fallback} replace />;
const required = anyOf?.length ? anyOf : (flag ? [flag] : []);
if (required.length > 0 && !required.some((key) => flags[key])) {
return <Navigate to={fallback} replace />;
}
return <Outlet />;
};
@@ -72,6 +72,10 @@ export const DEFAULT_FLAGS: FeatureFlags = {
workflows: false,
// #1074 — off by default is the whole "zero behaviour change" guarantee.
faces: false,
// Newsletter campaigns (migration 199, #1264). Off by default — an
// install that never turns this on never gains a nav entry or a way to
// mass-mail its customers.
newsletters: false,
};
export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const;
@@ -131,6 +135,9 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags {
// NOTE: taxReport is intentionally NOT here anymore — the Tax export
// moved permanently into the Accounting section (its own master).
// future siblings: || out.messaging
// #1264 — newsletters is a Clients child; without it here the staged
// sidebar preview disagrees with the server until Save.
|| out.newsletters
);
return out;
}
@@ -25,6 +25,7 @@ import {
MonitorPlay,
Send,
Workflow,
Megaphone,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -383,6 +384,22 @@ export const FeaturesTab: React.FC = () => {
onToggle={(next) => setFlag('bills', next)}
/>
{/* Newsletter campaigns (#1264). Clients child. Mass marketing mail
to customer accounts, so the copy leads with consent. */}
<FeatureCard
icon={Megaphone}
title={t('settings.features.newsletters.title', 'Newsletters')}
description={t(
'settings.features.newsletters.description',
'Send a marketing campaign to your customer accounts. Compose the body with the rich-text editor, preview it, send yourself a test, then queue it — sends are spread over time so your mail provider does not rate-limit you. Every customer can be opted out individually, opted-out customers are skipped automatically, and every campaign carries an unsubscribe link. Emails about galleries, quotes and invoices are never affected.',
)}
status="new"
statusLabel={statusLabel('new')}
sidebarLabel={t('settings.features.newsletters.sidebar', 'Newsletters')}
enabled={staged.newsletters}
onToggle={(next) => setFlag('newsletters', next)}
/>
<FeatureCard
icon={Briefcase}
title={t('settings.features.hoursLogging.title', 'Hours logging')}
+104 -3
View File
@@ -3431,6 +3431,14 @@
"feature_flags_summary_one": "{{count}} Funktion aktualisiert: {{summary}}",
"feature_flags_summary_other": "{{count}} Funktionen aktualisiert: {{summary}}",
"feature_flags_updated": "Funktionseinstellungen aktualisiert",
"newsletter_created": "Newsletter-Kampagne erstellt",
"newsletter_updated": "Newsletter-Kampagne aktualisiert",
"newsletter_test_sent": "Newsletter-Test-E-Mail gesendet",
"newsletter_queued": "Newsletter an {{recipients}} Empfänger eingereiht",
"newsletter_cancelled": "Newsletter-Kampagne abgebrochen",
"newsletter_completed": "Newsletter abgeschlossen — {{sent}} gesendet, {{failed}} fehlgeschlagen",
"newsletter_deleted": "Newsletter-Kampagne gelöscht",
"customer_marketing_opt_out": "Newsletter-Einwilligung des Kunden geändert",
"css_template_remote_urls_removed": "Externe URLs aus {{count}} CSS-Vorlage(n) entfernt — siehe Release Notes"
},
"people": {
@@ -4807,7 +4815,10 @@
"field": {
"featureHoursLogging": "Stundenerfassung",
"hourlyRate": "Standard-Stundensatz",
"hourlyRateHint": "Haupteinheiten (z. B. 150.00 für {{currency}} 150). Leer lassen, um pro Eintrag einen Satz zu verlangen."
"hourlyRateHint": "Haupteinheiten (z. B. 150.00 für {{currency}} 150). Leer lassen, um pro Eintrag einen Satz zu verlangen.",
"marketingOptOut": "Vom Newsletter abgemeldet",
"marketingOptOutHelp": "Wenn aktiv, wird dieser Kunde von jeder Newsletter-Kampagne übersprungen. E-Mails zu Galerien, Offerten und Rechnungen sind nicht betroffen.",
"marketingOptOutSince": "Seit {{date}}"
},
"hours": {
"section": "Stunden",
@@ -5012,7 +5023,8 @@
"noBills": "Noch keine Rechnungen für diesen Kunden.",
"rebillsSection": "Weiterverrechnungen & Durchlaufposten",
"noRebills": "Noch keine weiterverrechneten oder durchlaufenden Lieferantenrechnungen für diesen Kunden.",
"companyName": "Firmenname"
"companyName": "Firmenname",
"marketingSection": "Newsletter-Einwilligung"
},
"billing": {
"section": "Abrechnungsrhythmus",
@@ -5102,7 +5114,8 @@
"calendar": "Kalender",
"bills": "Rechnungen",
"taxReport": "Steuer",
"development": "Entwicklung"
"development": "Entwicklung",
"newsletters": "Newsletter"
}
},
"accounting": {
@@ -6873,5 +6886,93 @@
"admin": "Admin",
"super_admin": "Super-Admin"
}
},
"newsletters": {
"title": "Newsletter",
"subtitle": "Senden Sie eine Kampagne an Ihre Kundenkonten. Abgemeldete Kunden werden automatisch übersprungen, und jede Sendung enthält einen Abmeldelink.",
"new": "Neue Kampagne",
"untitled": "Unbenannte Kampagne",
"untitledSubject": "Newsletter",
"empty": "Noch keine Kampagnen.",
"allStatuses": "Alle Status",
"filterByStatus": "Nach Status filtern",
"backToList": "Alle Kampagnen",
"recipientsTitle": "Empfänger",
"notEditable": "Diese Kampagne wurde bereits eingereiht und kann nicht mehr bearbeitet werden.",
"viewCampaign": "Kampagne ansehen",
"status": {
"draft": "Entwurf",
"queued": "Eingereiht",
"sending": "Wird gesendet",
"sent": "Gesendet",
"cancelled": "Abgebrochen",
"failed": "Fehlgeschlagen"
},
"recipientStatus": {
"queued": "Eingereiht",
"sent": "Gesendet",
"failed": "Fehlgeschlagen",
"cancelled": "Abgebrochen",
"skipped_opt_out": "Übersprungen (abgemeldet)"
},
"col": {
"name": "Name",
"status": "Status",
"recipients": "Empfänger",
"sent": "Gesendet",
"failed": "Fehlgeschlagen",
"created": "Erstellt"
},
"section": {
"content": "Inhalt",
"recipients": "Empfänger & Versand",
"preview": "Vorschau"
},
"field": {
"name": "Kampagnenname (intern)",
"subject": "Betreff",
"body": "Inhalt",
"rate": "Senderate (E-Mails pro Minute)",
"testTo": "Test senden an"
},
"mode": {
"allActive": "Alle aktiven Kunden",
"manual": "Kunden auswählen"
},
"showCss": "Eigenes CSS (optional)",
"hideCss": "Eigenes CSS ausblenden",
"cssHelp": "Viele E-Mail-Programme entfernen einen <style>-Block — halten Sie wichtige Gestaltung in Inline-Attributen. Externe Bilder und @import werden entfernt.",
"rateHelp": "Sendungen werden gestreckt, damit Ihr Mailanbieter nicht drosselt. Prüfen Sie das Stundenlimit Ihres Anbieters, bevor Sie diesen Wert erhöhen.",
"recipientCount": "{{count}} Empfänger",
"skippedOptOut": "{{count}} übersprungen (abgemeldet)",
"saveToRefresh": "Speichern, um diese Zahl zu aktualisieren.",
"refreshPreview": "Vorschau aktualisieren",
"previewTitle": "Newsletter-Vorschau",
"sendTest": "Test",
"queueButton": "Kampagne einreihen",
"queueBlocked": "Betreff, Inhalt und mindestens ein Empfänger werden zum Senden benötigt.",
"queueTitle": "Diese Kampagne senden?",
"queueBody": "Damit werden {{count}} Kunden mit {{rate}} pro Minute angeschrieben (rund {{minutes}} Min.). Sobald der Versand läuft, lässt sich das nicht rückgängig machen.",
"queueConfirm": "An {{count}} Kunden senden",
"cancel": "Kampagne abbrechen",
"cancelTitle": "Diese Kampagne abbrechen?",
"cancelBody": "Noch nicht versendete E-Mails werden verworfen. Bereits versendete E-Mails lassen sich nicht zurückholen.",
"cancelled": "{{count}} ausstehende E-Mails abgebrochen.",
"sendingAt": "Versand mit {{rate}} E-Mails pro Minute.",
"deleteTitle": "Kampagne löschen?",
"deleteBody": "\"{{name}}\" wird gelöscht. Das lässt sich nicht rückgängig machen.",
"deleteAria": "{{name}} löschen",
"saved": "Kampagne gespeichert.",
"queued": "Kampagne eingereiht.",
"deleted": "Kampagne gelöscht.",
"testSent": "Test-E-Mail an {{to}} gesendet.",
"createFailed": "Kampagne konnte nicht erstellt werden.",
"saveFailed": "Kampagne konnte nicht gespeichert werden.",
"deleteFailed": "Kampagne konnte nicht gelöscht werden.",
"previewFailed": "Vorschau konnte nicht erzeugt werden.",
"testFailed": "Test-E-Mail konnte nicht gesendet werden.",
"queueFailed": "Kampagne konnte nicht eingereiht werden.",
"cancelFailed": "Kampagne konnte nicht abgebrochen werden.",
"previewEmpty": "Aktualisieren Sie die Vorschau, um die E-Mail so zu sehen, wie ein Kunde sie erhält."
}
}
+104 -3
View File
@@ -2963,6 +2963,14 @@
"feature_flags_summary_one": "{{count}} features updated: {{summary}}",
"feature_flags_summary_other": "{{count}} features updated: {{summary}}",
"feature_flags_updated": "Feature settings updated",
"newsletter_created": "Newsletter campaign created",
"newsletter_updated": "Newsletter campaign updated",
"newsletter_test_sent": "Newsletter test email sent",
"newsletter_queued": "Newsletter queued to {{recipients}} recipients",
"newsletter_cancelled": "Newsletter campaign cancelled",
"newsletter_completed": "Newsletter finished — {{sent}} sent, {{failed}} failed",
"newsletter_deleted": "Newsletter campaign deleted",
"customer_marketing_opt_out": "Customer newsletter consent changed",
"css_template_remote_urls_removed": "Remote URLs removed from {{count}} CSS template(s) — see the release notes"
},
"people": {
@@ -4807,7 +4815,10 @@
"field": {
"featureHoursLogging": "Hours logging",
"hourlyRate": "Default hourly rate",
"hourlyRateHint": "Major units (e.g. 150.00 for {{currency}} 150). Leave blank to require a per-entry override on every block."
"hourlyRateHint": "Major units (e.g. 150.00 for {{currency}} 150). Leave blank to require a per-entry override on every block.",
"marketingOptOut": "Unsubscribed from newsletters",
"marketingOptOutHelp": "When on, this customer is skipped by every newsletter campaign. Emails about their galleries, quotes and invoices are not affected.",
"marketingOptOutSince": "Since {{date}}"
},
"hours": {
"section": "Hours",
@@ -5012,7 +5023,8 @@
"manageEvents": "Manage galleries",
"rebillsSection": "Re-bills & passthrough",
"noRebills": "No re-billed or passed-through supplier invoices for this customer yet.",
"companyName": "Company name"
"companyName": "Company name",
"marketingSection": "Newsletter consent"
},
"billing": {
"section": "Billing cadence",
@@ -5102,7 +5114,8 @@
"calendar": "Calendar",
"bills": "Invoices",
"taxReport": "Tax",
"development": "Development"
"development": "Development",
"newsletters": "Newsletters"
}
},
"accounting": {
@@ -6872,5 +6885,93 @@
"admin": "Admin",
"super_admin": "Super Admin"
}
},
"newsletters": {
"title": "Newsletters",
"subtitle": "Send a campaign to your customer accounts. Everyone who has opted out is skipped automatically, and every send carries an unsubscribe link.",
"new": "New campaign",
"untitled": "Untitled campaign",
"untitledSubject": "Newsletter",
"empty": "No campaigns yet.",
"allStatuses": "All statuses",
"filterByStatus": "Filter by status",
"backToList": "All campaigns",
"recipientsTitle": "Recipients",
"notEditable": "This campaign has already been queued and can no longer be edited.",
"viewCampaign": "View campaign",
"status": {
"draft": "Draft",
"queued": "Queued",
"sending": "Sending",
"sent": "Sent",
"cancelled": "Cancelled",
"failed": "Failed"
},
"recipientStatus": {
"queued": "Queued",
"sent": "Sent",
"failed": "Failed",
"cancelled": "Cancelled",
"skipped_opt_out": "Skipped (opted out)"
},
"col": {
"name": "Name",
"status": "Status",
"recipients": "Recipients",
"sent": "Sent",
"failed": "Failed",
"created": "Created"
},
"section": {
"content": "Content",
"recipients": "Recipients & send",
"preview": "Preview"
},
"field": {
"name": "Campaign name (internal)",
"subject": "Subject",
"body": "Body",
"rate": "Send rate (emails per minute)",
"testTo": "Send a test to"
},
"mode": {
"allActive": "All active customers",
"manual": "Pick customers"
},
"showCss": "Custom CSS (optional)",
"hideCss": "Hide custom CSS",
"cssHelp": "Many email clients drop a <style> block — keep the important styling on inline attributes. Remote images and @import are stripped.",
"rateHelp": "Sends are spread out so your mail provider does not rate-limit you. Check your provider's hourly cap before raising this.",
"recipientCount": "{{count}} recipients",
"skippedOptOut": "{{count}} skipped (opted out)",
"saveToRefresh": "Save to refresh this count.",
"refreshPreview": "Refresh preview",
"previewTitle": "Newsletter preview",
"sendTest": "Test",
"queueButton": "Queue campaign",
"queueBlocked": "A subject, a body and at least one recipient are needed before sending.",
"queueTitle": "Send this campaign?",
"queueBody": "This will email {{count}} customers at {{rate}} per minute (roughly {{minutes}} min). It cannot be undone once messages start going out.",
"queueConfirm": "Send to {{count}} customers",
"cancel": "Cancel campaign",
"cancelTitle": "Cancel this campaign?",
"cancelBody": "Emails that have not gone out yet will be dropped. Emails already sent cannot be recalled.",
"cancelled": "{{count}} pending emails cancelled.",
"sendingAt": "Sending at {{rate}} emails per minute.",
"deleteTitle": "Delete campaign?",
"deleteBody": "\"{{name}}\" will be deleted. This cannot be undone.",
"deleteAria": "Delete {{name}}",
"saved": "Campaign saved.",
"queued": "Campaign queued.",
"deleted": "Campaign deleted.",
"testSent": "Test email sent to {{to}}.",
"createFailed": "Could not create the campaign.",
"saveFailed": "Could not save the campaign.",
"deleteFailed": "Could not delete the campaign.",
"previewFailed": "Could not render the preview.",
"testFailed": "Could not send the test email.",
"queueFailed": "Could not queue the campaign.",
"cancelFailed": "Could not cancel the campaign.",
"previewEmpty": "Refresh the preview to see the email as a customer will."
}
}
@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import {
ArrowLeft, Mail, MapPin, Phone, Building2, Save, Trash2, AlertTriangle,
CheckCircle2, X, FileText, Calendar, KeyRound, ToggleLeft, Settings as SettingsIcon,
CheckCircle2, X, FileText, Calendar, KeyRound, ToggleLeft, Settings as SettingsIcon, Megaphone,
} from 'lucide-react';
import { Button, Card, CountrySelect, Input, Loading } from '../../components/common';
@@ -40,7 +40,8 @@ type EditableFields =
| 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'state'
| 'countryCode' | 'countryName' | 'preferredLanguage' | 'notes'
| 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging' | 'featureContracts'
| 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay' | 'skontoDisabled' | 'rebillAttachProof';
| 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay' | 'skontoDisabled' | 'rebillAttachProof'
| 'marketingOptOut';
// `fmtDate` (from useLocalizedDate, below) is the single canonical date
// formatter. It honors the admin's `general_date_format` setting AND
@@ -147,6 +148,8 @@ export const CustomerDetailPage: React.FC = () => {
// Tri-state (null = inherit global). Kept as-is so the select can show
// "Inherit" distinctly from an explicit on/off (#866).
rebillAttachProof: customer.rebillAttachProof ?? null,
// Newsletter consent (#1264). Opt-OUT, so the default is false.
marketingOptOut: customer.marketingOptOut ?? false,
} as any);
}
}, [customer, form]);
@@ -569,6 +572,46 @@ export const CustomerDetailPage: React.FC = () => {
toggle inside it is OFF — an empty "Customer features" card
with just a title + hint reads as broken. The Card reappears
the moment any master flag is re-enabled. */}
{/* Newsletter consent (migration 199, #1264). Its OWN card, gated
only by `newsletters`: a consent record is not a per-customer
feature override, and the features card above hides itself when
the other CRM flags are off — which would make this unreachable on
an install that runs newsletters alone. Admin-settable so a
customer who unsubscribes by phone can be honoured immediately. */}
{flags.newsletters && (
<Card padding="lg">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Megaphone className="w-5 h-5" />
{t('customers.detail.marketingSection', 'Newsletter consent')}
</h2>
<label className="flex items-start justify-between gap-3 cursor-pointer">
<span className="text-sm">
<span className="font-medium text-neutral-900 dark:text-neutral-100">
{t('customers.field.marketingOptOut', 'Unsubscribed from newsletters')}
</span>
<span className="block text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
{t('customers.field.marketingOptOutHelp',
'When on, this customer is skipped by every newsletter campaign. Emails about their galleries, quotes and invoices are not affected.')}
</span>
{form.marketingOptOut && customer?.marketingOptOutAt && (
<span className="block text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{t('customers.field.marketingOptOutSince', 'Since {{date}}',
{ date: fmtDate(customer.marketingOptOutAt) })}
</span>
)}
</span>
<input
type="checkbox"
className="mt-1 h-4 w-4 shrink-0"
checked={!!form.marketingOptOut}
onChange={(e) => setForm((prev) => ({
...prev, marketingOptOut: e.target.checked,
} as any))}
/>
</label>
</Card>
)}
{(flags.calendar || flags.quotes || flags.bills || flags.hoursLogging || flags.contracts) && (
<Card padding="lg">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
@@ -677,6 +720,7 @@ export const CustomerDetailPage: React.FC = () => {
</p>
</div>
)}
</Card>
)}
@@ -87,6 +87,61 @@ describe('dashboard activity feed interpolation', () => {
});
});
describe('newsletter activity strings (#1264)', () => {
beforeAll(async () => {
await i18n.changeLanguage('en');
});
// The exact metadata newsletterService writes for each type. Anything that
// drifts between the log call and the i18n string surfaces as a raw
// `{{token}}` in the dashboard feed and the bell.
const CASES: Array<[string, Record<string, unknown>]> = [
['newsletter_created', { campaignId: 1, name: 'Spring' }],
['newsletter_updated', { campaignId: 1, fields: ['subject'] }],
['newsletter_test_sent', { campaignId: 1, to: '[email protected]' }],
['newsletter_queued', { campaignId: 1, name: 'Spring', recipients: 42, skippedOptOut: 3, sendRatePerMinute: 10 }],
['newsletter_cancelled', { campaignId: 1, name: 'Spring', cancelledRows: 12 }],
['newsletter_completed', { campaignId: 1, name: 'Spring', sent: 40, failed: 2 }],
['newsletter_deleted', { campaignId: 1, name: 'Spring' }],
['customer_marketing_opt_out', { customerId: 7, optOut: true, source: 'link' }],
];
it.each(CASES)('renders %s without a raw placeholder', (type, metadata) => {
const msg = render(activity(type, metadata));
expect(msg).not.toContain('{{');
// A missing key would render the key path itself.
expect(msg).not.toContain('admin.activities.');
});
it('interpolates the recipient count into newsletter_queued', () => {
const msg = render(activity('newsletter_queued', { name: 'Spring', recipients: 42 }));
expect(msg).toContain('42');
});
it('interpolates both counts into newsletter_completed', () => {
const msg = render(activity('newsletter_completed', { name: 'Spring', sent: 40, failed: 2 }));
expect(msg).toContain('40');
expect(msg).toContain('2');
});
it('renders every newsletter string in German too', async () => {
await i18n.changeLanguage('de');
for (const [type, metadata] of CASES) {
const msg = render(activity(type, metadata));
expect(msg).not.toContain('{{');
expect(msg).not.toContain('admin.activities.');
}
await i18n.changeLanguage('en');
});
it('renders in the notification bell as well as the dashboard feed', () => {
for (const [type, metadata] of CASES) {
const msg = notificationsService.formatNotificationMessage(notification(type, metadata));
expect(msg).not.toContain('{{');
}
});
});
describe('notification bell interpolation', () => {
beforeAll(async () => {
await i18n.changeLanguage('en');
@@ -0,0 +1,460 @@
/**
* Clients → Newsletters → composer (#1264).
*
* Three columns: content, recipients, preview & send.
*
* The recipient count is a live server-side dry run rather than a
* client-side estimate — the number in the confirm dialog has to be the
* number the server will actually mail, including its opt-out filtering, or
* the confirmation is theatre.
*
* The preview renders in a `sandbox`-ed iframe with no `allow-scripts`. The
* body is already sanitized server-side; this is defence in depth, and it is
* the only place campaign HTML is ever put in a DOM.
*/
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Save, Send, TestTube2, Users, Eye, ArrowLeft } from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card, Input, Loading, useConfirm } from '../../../components/common';
import { EmailTemplateEditor } from '../../../components/admin/EmailTemplateEditor';
import {
newslettersService, type Campaign, type RecipientMode,
} from '../../../services/newsletters.service';
import { customerAdminService } from '../../../services/customerAdmin.service';
import { usePermissions } from '../../../contexts/PermissionsContext';
/** Variables the server substitutes per recipient. */
const VARIABLES = [
'customer_name', 'first_name', 'last_name', 'salutation',
'company_name', 'support_email', 'unsubscribe_url',
];
export const NewsletterComposerPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const campaignId = Number(id);
const navigate = useNavigate();
const confirm = useConfirm();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['newsletter', campaignId],
queryFn: () => newslettersService.get(campaignId),
enabled: Number.isFinite(campaignId),
});
const [draft, setDraft] = useState<Campaign | null>(null);
useEffect(() => { if (data?.campaign) setDraft(data.campaign); }, [data]);
// The manual picker reads /admin/customers, which is gated on
// `customers.view` — a role holding only the newsletter permissions would
// get an empty list with no explanation (#1264 review).
const { hasPermission } = usePermissions();
const canPickCustomers = hasPermission('customers.view');
const [testEmail, setTestEmail] = useState('');
const [previewHtml, setPreviewHtml] = useState('');
const [showCss, setShowCss] = useState(false);
const patch = (changes: Partial<Campaign>) =>
setDraft((prev) => (prev ? { ...prev, ...changes } : prev));
// ---- recipients dry run -------------------------------------------------
// Re-runs whenever the recipient rule changes, so the count on screen and
// the count in the confirm dialog are always the server's own answer.
const { data: resolution, refetch: refetchRecipients } = useQuery({
queryKey: ['newsletter-recipients', campaignId],
queryFn: () => newslettersService.resolveRecipients(campaignId),
enabled: Number.isFinite(campaignId) && !!draft,
});
const { data: customers } = useQuery({
queryKey: ['customers-for-newsletter'],
queryFn: () => customerAdminService.list(),
enabled: draft?.recipientMode === 'manual' && canPickCustomers,
});
const save = useMutation({
mutationFn: async () => {
if (!draft) throw new Error('no draft');
return newslettersService.update(campaignId, {
name: draft.name,
subject: draft.subject,
bodyHtml: draft.bodyHtml,
bodyCss: draft.bodyCss,
language: draft.language,
recipientMode: draft.recipientMode,
customerIds: draft.customerIds,
sendRatePerMinute: draft.sendRatePerMinute,
});
},
onSuccess: (campaign) => {
setDraft(campaign);
queryClient.invalidateQueries({ queryKey: ['newsletter', campaignId] });
queryClient.invalidateQueries({ queryKey: ['newsletters'] });
refetchRecipients();
},
});
// Every server-side action below renders or sends the STORED campaign, but
// the editor's state lives in `draft` until Save runs. Previewing, testing
// or queueing straight after an edit therefore acted on the previous
// version — the operator would proof one body and mail another. Persist
// first, always, so what is checked is what goes out.
const persistDraft = () => save.mutateAsync();
const loadPreview = async () => {
try {
await persistDraft();
const res = await newslettersService.preview(campaignId, {});
setPreviewHtml(res.html);
} catch {
toast.error(t('newsletters.previewFailed', 'Could not render the preview.'));
}
};
const sendTest = async () => {
try {
await persistDraft();
await newslettersService.sendTest(campaignId, testEmail);
toast.success(t('newsletters.testSent', 'Test email sent to {{to}}.', { to: testEmail }));
} catch {
toast.error(t('newsletters.testFailed', 'Could not send the test email.'));
}
};
const queueCampaign = async () => {
// Save BEFORE resolving the count and confirming: the dialog must quote
// the recipient rule that is about to be used, not the one from before
// the operator's last edit.
let fresh;
try {
await persistDraft();
// Use what the refetch RETURNS. `resolution` is captured from the
// render that produced this callback, so reading it here quotes the
// count from before the operator's last recipient change — the dialog
// would promise "all active customers" while the backend queues the
// manual selection just saved.
fresh = (await refetchRecipients()).data;
} catch {
toast.error(t('newsletters.saveFailed', 'Could not save the campaign.'));
return;
}
const count = fresh?.recipientCount ?? 0;
const ok = await confirm({
title: t('newsletters.queueTitle', 'Send this campaign?') as string,
message: t('newsletters.queueBody',
'This will email {{count}} customers at {{rate}} per minute (roughly {{minutes}} min). It cannot be undone once messages start going out.',
{
count,
rate: fresh?.sendRatePerMinute ?? draft?.sendRatePerMinute ?? 10,
minutes: fresh?.estimatedMinutes ?? 1,
}) as string,
confirmLabel: t('newsletters.queueConfirm', 'Send to {{count}} customers', { count }) as string,
variant: 'danger',
});
if (!ok) return;
try {
await newslettersService.queue(campaignId);
queryClient.invalidateQueries({ queryKey: ['newsletters'] });
toast.success(t('newsletters.queued', 'Campaign queued.'));
navigate(`/admin/clients/newsletters/${campaignId}`);
} catch {
toast.error(t('newsletters.queueFailed', 'Could not queue the campaign.'));
}
};
// A campaign with no subject, no body or nobody to send to must not be
// sendable — the button is the last place to catch that before 2 000
// people get a blank email.
const canQueue = useMemo(() => Boolean(
draft
&& draft.status === 'draft'
&& draft.subject.trim()
&& draft.bodyHtml.trim()
&& (resolution?.recipientCount ?? 0) > 0
), [draft, resolution]);
if (isLoading || !draft) return <Loading />;
if (draft.status !== 'draft') {
return (
<Card>
<p className="text-neutral-700 dark:text-neutral-300">
{t('newsletters.notEditable',
'This campaign has already been queued and can no longer be edited.')}
</p>
<Button
variant="outline"
className="mt-4"
onClick={() => navigate(`/admin/clients/newsletters/${campaignId}`)}
>
{t('newsletters.viewCampaign', 'View campaign')}
</Button>
</Card>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6 gap-4">
<button
type="button"
onClick={() => navigate('/admin/clients/newsletters')}
className="flex items-center gap-1 text-sm text-neutral-600 dark:text-neutral-400 hover:underline"
>
<ArrowLeft className="w-4 h-4" />
{t('newsletters.backToList', 'All campaigns')}
</button>
<Button
onClick={async () => {
try {
await persistDraft();
toast.success(t('newsletters.saved', 'Campaign saved.'));
} catch {
toast.error(t('newsletters.saveFailed', 'Could not save the campaign.'));
}
}}
isLoading={save.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
{t('common.save', 'Save')}
</Button>
</div>
{/* Two columns, not three. An email body is 600px wide and the editor
toolbar has ~14 controls; giving each of the three panels an equal
third left the toolbar wrapping onto seven rows and the body being
composed in a box narrower than a phone, while the Recipients panel —
two radios, a count and one number field — sat mostly empty. Compose
gets the width, the send settings get the rail, and the preview moves
full-width below where it can render at true email size. */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* ---- 1. Content ---- */}
<Card className="xl:col-span-2">
<h3 className="font-semibold mb-4 text-neutral-900 dark:text-neutral-100">
{t('newsletters.section.content', 'Content')}
</h3>
<div className="space-y-4">
<Input
label={t('newsletters.field.name', 'Campaign name (internal)') as string}
value={draft.name}
onChange={(e) => patch({ name: e.target.value })}
/>
<Input
label={t('newsletters.field.subject', 'Subject') as string}
value={draft.subject}
maxLength={255}
onChange={(e) => patch({ subject: e.target.value })}
/>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('newsletters.field.body', 'Body')}
</label>
<EmailTemplateEditor
content={draft.bodyHtml}
onChange={(html) => patch({ bodyHtml: html })}
variables={VARIABLES}
/>
</div>
<div>
<button
type="button"
onClick={() => setShowCss((v) => !v)}
className="text-sm hover:underline"
style={{ color: 'var(--color-accent)' }}
>
{showCss
? t('newsletters.hideCss', 'Hide custom CSS')
: t('newsletters.showCss', 'Custom CSS (optional)')}
</button>
{showCss && (
<>
<textarea
rows={6}
value={draft.bodyCss}
onChange={(e) => patch({ bodyCss: e.target.value })}
placeholder=".cta { background: #5C8762; color: #fff; }"
className="mt-2 w-full font-mono text-xs rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2"
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('newsletters.cssHelp',
'Many email clients drop a <style> block — keep the important styling on inline attributes. Remote images and @import are stripped.')}
</p>
</>
)}
</div>
</div>
</Card>
{/* ---- 2. Recipients ---- */}
<Card>
<div className="flex items-center gap-2 mb-4">
<Users className="w-5 h-5 text-neutral-500" />
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100">
{t('newsletters.section.recipients', 'Recipients & send')}
</h3>
</div>
<div className="space-y-2 mb-4">
{(['all_active', 'manual'] as RecipientMode[])
.filter((mode) => mode === 'all_active' || canPickCustomers)
.map((mode) => (
<label key={mode} className="flex items-start gap-2 cursor-pointer">
<input
type="radio"
name="recipientMode"
className="mt-1"
checked={draft.recipientMode === mode}
onChange={() => patch({ recipientMode: mode })}
/>
<span className="text-sm">
<span className="font-medium text-neutral-900 dark:text-neutral-100">
{mode === 'all_active'
? t('newsletters.mode.allActive', 'All active customers')
: t('newsletters.mode.manual', 'Pick customers')}
</span>
</span>
</label>
))}
</div>
{draft.recipientMode === 'manual' && (
<div className="mb-4 max-h-64 overflow-y-auto border border-neutral-200 dark:border-neutral-700 rounded-md p-2">
{(customers ?? []).map((c) => (
<label key={c.id} className="flex items-center gap-2 py-1 cursor-pointer text-sm">
<input
type="checkbox"
checked={draft.customerIds.includes(c.id)}
onChange={(e) => patch({
customerIds: e.target.checked
? [...draft.customerIds, c.id]
: draft.customerIds.filter((x) => x !== c.id),
})}
/>
<span className="text-neutral-800 dark:text-neutral-200">
{c.displayName || c.email}
</span>
</label>
))}
</div>
)}
{/* The server's own count, not a local estimate. */}
<div
data-testid="recipient-summary"
className="rounded-md bg-neutral-50 dark:bg-neutral-800/60 p-3 text-sm"
>
<p className="font-medium text-neutral-900 dark:text-neutral-100">
{t('newsletters.recipientCount', '{{count}} recipients',
{ count: resolution?.recipientCount ?? 0 })}
</p>
{(resolution?.skippedOptOut ?? 0) > 0 && (
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
{t('newsletters.skippedOptOut', '{{count}} skipped (opted out)',
{ count: resolution?.skippedOptOut ?? 0 })}
</p>
)}
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2">
{t('newsletters.saveToRefresh', 'Save to refresh this count.')}
</p>
</div>
<div className="mt-4">
<Input
type="number"
min={1}
// 10 is what the queue can actually deliver: the processor takes
// 10 rows once a minute, globally. Anything higher was rejected
// server-side after passing this control.
max={10}
label={t('newsletters.field.rate', 'Send rate (emails per minute)') as string}
value={String(draft.sendRatePerMinute)}
onChange={(e) => patch({ sendRatePerMinute: Number(e.target.value) })}
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('newsletters.rateHelp',
'Sends are spread out so your mail provider does not rate-limit you. Check your provider\'s hourly cap before raising this.')}
</p>
</div>
{/* Test + queue live with the recipient rule they act on. */}
<div className="mt-6 pt-4 border-t border-neutral-200 dark:border-neutral-700 space-y-3">
<div className="flex gap-2 items-end">
<div className="flex-1">
<Input
type="email"
label={t('newsletters.field.testTo', 'Send a test to') as string}
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
placeholder="[email protected]"
/>
</div>
<Button
variant="outline"
onClick={sendTest}
disabled={!testEmail}
leftIcon={<TestTube2 className="w-4 h-4" />}
>
{t('newsletters.sendTest', 'Test')}
</Button>
</div>
<Button
onClick={queueCampaign}
disabled={!canQueue}
className="w-full"
leftIcon={<Send className="w-4 h-4" />}
>
{t('newsletters.queueButton', 'Queue campaign')}
</Button>
{!canQueue && (
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('newsletters.queueBlocked',
'A subject, a body and at least one recipient are needed before sending.')}
</p>
)}
</div>
</Card>
</div>
{/* ---- Preview, full width ---- */}
<Card className="mt-6">
<div className="flex items-center justify-between gap-4 mb-4">
<div className="flex items-center gap-2">
<Eye className="w-5 h-5 text-neutral-500" />
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100">
{t('newsletters.section.preview', 'Preview')}
</h3>
</div>
<Button variant="outline" onClick={loadPreview}>
{t('newsletters.refreshPreview', 'Refresh preview')}
</Button>
</div>
{previewHtml ? (
<iframe
data-testid="newsletter-preview"
title={t('newsletters.previewTitle', 'Newsletter preview') as string}
// No allow-scripts. The body is sanitized server-side; this is
// the second line of defence, and it is the only DOM campaign
// HTML ever reaches.
sandbox=""
srcDoc={previewHtml}
// 680px: the 600px email plus its wrapper padding, so it renders
// at the width a recipient sees instead of side-scrolling.
className="w-full max-w-[680px] mx-auto h-[640px] border border-neutral-200 dark:border-neutral-700 rounded-md bg-white"
/>
) : (
<div className="max-w-[680px] mx-auto h-[240px] rounded-md border border-dashed border-neutral-300 dark:border-neutral-600 flex items-center justify-center text-sm text-neutral-500 dark:text-neutral-400">
{t('newsletters.previewEmpty', 'Refresh the preview to see the email as a customer will.')}
</div>
)}
</Card>
</div>
);
};
@@ -0,0 +1,218 @@
/**
* Clients → Newsletters → campaign detail (#1264).
*
* The delivery record. While a campaign is sending this polls so the
* operator can watch it drain — and, more to the point, so they can hit
* Cancel while there are still pending rows to cancel.
*
* Per-recipient errors are shown verbatim: a bounced address is the one
* thing the operator can actually act on afterwards.
*/
import React, { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Ban } from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card, Loading, useConfirm } from '../../../components/common';
import { usePermissions } from '../../../contexts/PermissionsContext';
import {
newslettersService, type RecipientStatus,
} from '../../../services/newsletters.service';
import { StatusChip } from './NewsletterListPage';
const RECIPIENT_STATUS_STYLES: Record<RecipientStatus, string> = {
queued: 'text-neutral-600 dark:text-neutral-400',
sent: 'text-green-700 dark:text-green-400',
failed: 'text-red-700 dark:text-red-400',
cancelled: 'text-neutral-400 dark:text-neutral-500',
skipped_opt_out: 'text-amber-700 dark:text-amber-400',
};
export const NewsletterDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const campaignId = Number(id);
const navigate = useNavigate();
const confirm = useConfirm();
const queryClient = useQueryClient();
// Cancel stops a live send, so it is a `send` action. Progress and the
// recipient table stay visible to a view-only role (#1264 review).
const { hasPermission } = usePermissions();
const canSend = hasPermission('newsletters.send');
const [statusFilter, setStatusFilter] = useState<RecipientStatus | ''>('');
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['newsletter', campaignId],
queryFn: () => newslettersService.get(campaignId),
enabled: Number.isFinite(campaignId),
// Poll only while it is actually moving. A finished campaign is a
// static record and does not need to be re-fetched every few seconds.
refetchInterval: (query) => {
const status = query.state.data?.campaign.status;
return status === 'queued' || status === 'sending' ? 5000 : false;
},
});
const { data: recipients } = useQuery({
queryKey: ['newsletter-recipients-list', campaignId, statusFilter, page],
queryFn: () => newslettersService.recipients(campaignId, {
page, limit: 25, status: statusFilter || undefined,
}),
enabled: Number.isFinite(campaignId),
// Poll alongside the summary while the campaign is draining — otherwise
// every row sat at "queued" until the operator reloaded, while the
// counters above them climbed.
refetchInterval: data?.campaign.status === 'queued' || data?.campaign.status === 'sending'
? 5000
: false,
});
const cancelCampaign = async () => {
const ok = await confirm({
title: t('newsletters.cancelTitle', 'Cancel this campaign?') as string,
message: t('newsletters.cancelBody',
'Emails that have not gone out yet will be dropped. Emails already sent cannot be recalled.') as string,
variant: 'danger',
});
if (!ok) return;
try {
const res = await newslettersService.cancel(campaignId);
queryClient.invalidateQueries({ queryKey: ['newsletter', campaignId] });
queryClient.invalidateQueries({ queryKey: ['newsletter-recipients-list'] });
toast.success(t('newsletters.cancelled', '{{count}} pending emails cancelled.',
{ count: res.cancelled }));
} catch {
toast.error(t('newsletters.cancelFailed', 'Could not cancel the campaign.'));
}
};
if (isLoading || !data) return <Loading />;
const { campaign } = data;
const inFlight = campaign.status === 'queued' || campaign.status === 'sending';
const progress = campaign.recipientCount > 0
? Math.round(((campaign.sentCount + campaign.failedCount) / campaign.recipientCount) * 100)
: 0;
return (
<div>
<div className="flex items-center justify-between mb-6 gap-4">
<button
type="button"
onClick={() => navigate('/admin/clients/newsletters')}
className="flex items-center gap-1 text-sm text-neutral-600 dark:text-neutral-400 hover:underline"
>
<ArrowLeft className="w-4 h-4" />
{t('newsletters.backToList', 'All campaigns')}
</button>
{inFlight && canSend && (
<Button variant="outline" onClick={cancelCampaign} leftIcon={<Ban className="w-4 h-4" />}>
{t('newsletters.cancel', 'Cancel campaign')}
</Button>
)}
</div>
<Card className="mb-6">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{campaign.name}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400">{campaign.subject}</p>
</div>
<StatusChip status={campaign.status} />
</div>
<div className="grid grid-cols-3 gap-4 text-center">
{([
['recipients', campaign.recipientCount, ''],
['sent', campaign.sentCount, 'text-green-700 dark:text-green-400'],
['failed', campaign.failedCount, campaign.failedCount > 0 ? 'text-red-700 dark:text-red-400' : ''],
] as const).map(([key, value, cls]) => (
<div key={key} className="rounded-md bg-neutral-50 dark:bg-neutral-800/60 p-3">
<div className={`text-2xl font-semibold tabular-nums ${cls}`}>{value}</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 uppercase tracking-wide mt-1">
{t(`newsletters.col.${key}`, key)}
</div>
</div>
))}
</div>
{inFlight && (
<div className="mt-4">
<div className="h-2 rounded-full bg-neutral-200 dark:bg-neutral-700 overflow-hidden">
<div
data-testid="newsletter-progress"
className="h-full rounded-full transition-all"
style={{ width: `${progress}%`, backgroundColor: 'var(--color-accent)' }}
/>
</div>
<p className="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
{t('newsletters.sendingAt', 'Sending at {{rate}} emails per minute.',
{ rate: campaign.sendRatePerMinute })}
</p>
</div>
)}
</Card>
<Card padding="none">
<div className="p-4 flex items-center justify-between gap-4 border-b border-neutral-200 dark:border-neutral-700">
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100">
{t('newsletters.recipientsTitle', 'Recipients')}
</h3>
<select
aria-label={t('newsletters.filterByStatus', 'Filter by status') as string}
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value as RecipientStatus | ''); setPage(1); }}
className="rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-1.5 text-sm"
>
<option value="">{t('newsletters.allStatuses', 'All statuses')}</option>
{(['queued', 'sent', 'failed', 'cancelled', 'skipped_opt_out'] as RecipientStatus[])
.map((s) => (
<option key={s} value={s}>{t(`newsletters.recipientStatus.${s}`, s)}</option>
))}
</select>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<tbody>
{(recipients?.data ?? []).map((r) => (
<tr key={r.id} className="border-b border-neutral-100 dark:border-neutral-800 last:border-0">
<td className="px-4 py-2 text-neutral-800 dark:text-neutral-200">{r.email}</td>
<td className={`px-4 py-2 ${RECIPIENT_STATUS_STYLES[r.status]}`}>
{t(`newsletters.recipientStatus.${r.status}`, r.status)}
</td>
<td className="px-4 py-2 text-xs text-neutral-500 dark:text-neutral-400">
{r.errorMessage || (r.sentAt ? new Date(r.sentAt).toLocaleString() : '')}
</td>
</tr>
))}
</tbody>
</table>
</div>
{recipients && recipients.pagination.totalPages > 1 && (
<div className="p-4 flex items-center justify-between border-t border-neutral-200 dark:border-neutral-700">
<Button variant="outline" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t('common.previous', 'Previous')}
</Button>
<span className="text-sm text-neutral-500 dark:text-neutral-400">
{t('common.pageOf', 'Page {{page}} of {{total}}',
{ page, total: recipients.pagination.totalPages })}
</span>
<Button
variant="outline"
disabled={!recipients.pagination.hasMore}
onClick={() => setPage((p) => p + 1)}
>
{t('common.next', 'Next')}
</Button>
</div>
)}
</Card>
</div>
);
};
@@ -0,0 +1,196 @@
/**
* Clients → Newsletters — campaign list (#1264).
*
* The list is the safety surface as much as the index: status, how many
* people a campaign reached, and how many failed, all visible without
* opening anything. A campaign that half-delivered should be obvious here.
*/
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Megaphone, Trash2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card, Loading, useConfirm } from '../../../components/common';
import { usePermissions } from '../../../contexts/PermissionsContext';
import {
newslettersService, type Campaign, type CampaignStatus,
} from '../../../services/newsletters.service';
const STATUS_STYLES: Record<CampaignStatus, string> = {
draft: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200',
queued: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200',
sending: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200',
sent: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200',
cancelled: 'bg-neutral-100 text-neutral-500 dark:bg-neutral-800 dark:text-neutral-400',
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-200',
};
export const StatusChip: React.FC<{ status: CampaignStatus }> = ({ status }) => {
const { t } = useTranslation();
return (
<span
data-testid={`status-${status}`}
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_STYLES[status]}`}
>
{t(`newsletters.status.${status}`, status)}
</span>
);
};
export const NewsletterListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const confirm = useConfirm();
const queryClient = useQueryClient();
// The backend deliberately splits view from send, so a role can read
// campaigns without being able to mail anyone. Showing New/Delete to such a
// role only produces a 403 after the click (#1264 review).
const { hasPermission } = usePermissions();
const canSend = hasPermission('newsletters.send');
const [statusFilter, setStatusFilter] = useState<CampaignStatus | ''>('');
const { data: campaigns, isLoading } = useQuery({
queryKey: ['newsletters', statusFilter],
queryFn: () => newslettersService.list(statusFilter || undefined),
});
const createDraft = async () => {
try {
const campaign = await newslettersService.create({
name: t('newsletters.untitled', 'Untitled campaign'),
subject: t('newsletters.untitledSubject', 'Newsletter'),
});
navigate(`/admin/clients/newsletters/${campaign.id}/edit`);
} catch {
toast.error(t('newsletters.createFailed', 'Could not create the campaign.'));
}
};
const remove = async (campaign: Campaign) => {
const ok = await confirm({
title: t('newsletters.deleteTitle', 'Delete campaign?') as string,
message: t('newsletters.deleteBody',
'"{{name}}" will be deleted. This cannot be undone.', { name: campaign.name }) as string,
variant: 'danger',
});
if (!ok) return;
try {
await newslettersService.remove(campaign.id);
queryClient.invalidateQueries({ queryKey: ['newsletters'] });
toast.success(t('newsletters.deleted', 'Campaign deleted.'));
} catch {
toast.error(t('newsletters.deleteFailed', 'Could not delete the campaign.'));
}
};
if (isLoading) return <Loading />;
return (
<div>
<div className="flex items-start justify-between mb-6 gap-4">
<div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{t('newsletters.title', 'Newsletters')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
{t('newsletters.subtitle',
'Send a campaign to your customer accounts. Everyone who has opted out is skipped automatically, and every send carries an unsubscribe link.')}
</p>
</div>
{canSend && (
<Button onClick={createDraft} leftIcon={<Plus className="w-4 h-4" />}>
{t('newsletters.new', 'New campaign')}
</Button>
)}
</div>
<div className="mb-4">
<select
aria-label={t('newsletters.filterByStatus', 'Filter by status') as string}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as CampaignStatus | '')}
className="rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
>
<option value="">{t('newsletters.allStatuses', 'All statuses')}</option>
{(['draft', 'queued', 'sending', 'sent', 'cancelled', 'failed'] as CampaignStatus[])
.map((s) => <option key={s} value={s}>{t(`newsletters.status.${s}`, s)}</option>)}
</select>
</div>
{!campaigns || campaigns.length === 0 ? (
<Card>
<div className="py-12 text-center">
<Megaphone className="w-10 h-10 mx-auto text-neutral-300 dark:text-neutral-600 mb-3" />
<p className="text-neutral-600 dark:text-neutral-400">
{t('newsletters.empty', 'No campaigns yet.')}
</p>
</div>
</Card>
) : (
<Card padding="none">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-neutral-200 dark:border-neutral-700">
<tr className="text-left text-neutral-600 dark:text-neutral-400">
<th className="px-4 py-3 font-medium">{t('newsletters.col.name', 'Name')}</th>
<th className="px-4 py-3 font-medium">{t('newsletters.col.status', 'Status')}</th>
<th className="px-4 py-3 font-medium text-right">{t('newsletters.col.recipients', 'Recipients')}</th>
<th className="px-4 py-3 font-medium text-right">{t('newsletters.col.sent', 'Sent')}</th>
<th className="px-4 py-3 font-medium text-right">{t('newsletters.col.failed', 'Failed')}</th>
<th className="px-4 py-3 font-medium">{t('newsletters.col.created', 'Created')}</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{campaigns.map((c) => (
<tr key={c.id} className="border-b border-neutral-100 dark:border-neutral-800 last:border-0">
<td className="px-4 py-3">
<Link
// A draft opens straight in the composer: the detail
// page has no edit action, so linking a draft there
// left the operator with no way to resume it.
to={c.status === 'draft' && canSend
? `/admin/clients/newsletters/${c.id}/edit`
: `/admin/clients/newsletters/${c.id}`}
className="font-medium hover:underline"
style={{ color: 'var(--color-accent)' }}
>
{c.name}
</Link>
<div className="text-xs text-neutral-500 dark:text-neutral-400">{c.subject}</div>
</td>
<td className="px-4 py-3"><StatusChip status={c.status} /></td>
<td className="px-4 py-3 text-right tabular-nums">{c.recipientCount}</td>
<td className="px-4 py-3 text-right tabular-nums">{c.sentCount}</td>
<td className={`px-4 py-3 text-right tabular-nums ${c.failedCount > 0 ? 'text-red-600 dark:text-red-400 font-medium' : ''}`}>
{c.failedCount}
</td>
<td className="px-4 py-3 text-neutral-500 dark:text-neutral-400">
{new Date(c.createdAt).toLocaleDateString()}
</td>
<td className="px-4 py-3 text-right">
{/* Only a draft or a cancelled campaign can be deleted —
a sent one is a delivery record. */}
{canSend && (c.status === 'draft' || c.status === 'cancelled') && (
<button
type="button"
onClick={() => remove(c)}
aria-label={t('newsletters.deleteAria', 'Delete {{name}}', { name: c.name }) as string}
className="text-neutral-400 hover:text-red-600 dark:hover:text-red-400"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
);
};
@@ -0,0 +1,253 @@
/**
* Newsletter composer (#1264).
*
* Two things are worth pinning here, and they are both about not mailing
* 2 000 people by accident:
*
* 1. The queue button is inert until there is a subject, a body and at
* least one recipient, and the confirm dialog repeats the SERVER's
* recipient count — not a locally-guessed one.
* 2. The preview iframe is sandboxed with no allow-scripts. The body is
* sanitized server-side; this is the second line of defence and the only
* DOM campaign HTML ever reaches.
*/
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import type { Campaign } from '../../../../services/newsletters.service';
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({
t: (k: string, fb?: unknown, opts?: Record<string, unknown>) => {
const base = typeof fb === 'string' ? fb : k;
if (!opts) return base;
return base.replace(/\{\{(\w+)\}\}/g, (_m, key) => String(opts[key] ?? ''));
},
i18n: { language: 'en' },
}),
};
});
vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
// TipTap pulls a large editor bundle and contenteditable behaviour we don't
// need here — a plain textarea is enough to drive the body field.
vi.mock('../../../../components/admin/EmailTemplateEditor', () => ({
EmailTemplateEditor: ({ content, onChange }: { content: string; onChange: (v: string) => void }) => (
<textarea aria-label="Body" value={content} onChange={(e) => onChange(e.target.value)} />
),
}));
// The composer gates manual recipient mode on `customers.view` (#1264
// review), so it now consults PermissionsContext.
let grantedPermissions = ['newsletters.view', 'newsletters.send', 'customers.view'];
vi.mock('../../../../contexts/PermissionsContext', () => ({
usePermissions: () => ({
hasPermission: (p: string) => grantedPermissions.includes(p),
isLoading: false,
}),
}));
const confirmSpy = vi.fn(async () => true);
vi.mock('../../../../components/common', async () => {
const actual = await vi.importActual<any>('../../../../components/common');
return { ...actual, useConfirm: () => confirmSpy };
});
const baseCampaign: Campaign = {
id: 7,
name: 'Spring news',
subject: 'Our spring offers',
bodyHtml: '<p>Hi {{first_name}}</p>',
bodyCss: '',
language: 'en',
status: 'draft',
recipientMode: 'all_active',
customerIds: [],
recipientCount: 0,
sentCount: 0,
failedCount: 0,
sendRatePerMinute: 20,
createdByAdminId: 1,
testSentAt: null,
queuedAt: null,
completedAt: null,
createdAt: '2026-09-01T00:00:00Z',
updatedAt: '2026-09-01T00:00:00Z',
};
let campaignFixture: Campaign = baseCampaign;
let resolution = {
recipientCount: 42, skippedOptOut: 3, skippedNoEmail: 0,
sendRatePerMinute: 20, estimatedMinutes: 3,
};
const queueSpy = vi.fn(async () => ({ queued: 42, skippedOptOut: 3, sendRatePerMinute: 20 }));
const resolveSpy = vi.fn(async () => resolution);
vi.mock('../../../../services/newsletters.service', () => ({
newslettersService: {
get: vi.fn(async () => ({ campaign: campaignFixture, recipientSummary: {} })),
update: vi.fn(async () => campaignFixture),
preview: vi.fn(async () => ({
subject: 'Our spring offers',
html: '<html><body><p>Hi Alex</p></body></html>',
language: 'en',
isSample: true,
})),
resolveRecipients: (...a: unknown[]) => resolveSpy(...(a as [])),
queue: (...a: unknown[]) => queueSpy(...(a as [])),
sendTest: vi.fn(async () => undefined),
},
}));
vi.mock('../../../../services/customerAdmin.service', () => ({
customerAdminService: {
list: vi.fn(async () => [
{ id: 1, email: '[email protected]', displayName: 'Ada', isActive: true, createdAt: '', lastLogin: null,
firstName: null, lastName: null, salutation: null, companyName: null },
]),
},
}));
import { NewsletterComposerPage } from '../NewsletterComposerPage';
function renderComposer() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={['/admin/clients/newsletters/7/edit']}>
<Routes>
<Route path="/admin/clients/newsletters/:id/edit" element={<NewsletterComposerPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
);
}
describe('newsletter composer', () => {
beforeEach(() => {
grantedPermissions = ['newsletters.view', 'newsletters.send', 'customers.view'];
campaignFixture = { ...baseCampaign };
resolution = {
recipientCount: 42, skippedOptOut: 3, skippedNoEmail: 0,
sendRatePerMinute: 20, estimatedMinutes: 3,
};
confirmSpy.mockClear();
queueSpy.mockClear();
resolveSpy.mockClear();
});
it("shows the server's recipient count and opt-out skips", async () => {
renderComposer();
const summary = await screen.findByTestId('recipient-summary');
// The count starts at 0 and is replaced by the server's dry run.
await waitFor(() => expect(summary).toHaveTextContent('42 recipients'));
expect(summary).toHaveTextContent('3 skipped (opted out)');
});
it('renders the preview in a sandboxed iframe with no allow-scripts', async () => {
renderComposer();
await screen.findByTestId('recipient-summary');
await userEvent.click(screen.getByRole('button', { name: /Refresh preview/i }));
const iframe = await screen.findByTestId('newsletter-preview');
// Empty sandbox = every restriction on, scripts included.
expect(iframe.getAttribute('sandbox')).toBe('');
expect(iframe.getAttribute('sandbox')).not.toContain('allow-scripts');
// srcdoc, not src — the HTML never becomes a navigable same-origin doc.
expect(iframe).toHaveAttribute('srcdoc');
});
it('disables the queue button when there are no recipients', async () => {
resolution = { ...resolution, recipientCount: 0 };
renderComposer();
await screen.findByTestId('recipient-summary');
await waitFor(() => expect(resolveSpy).toHaveBeenCalled());
expect(screen.getByRole('button', { name: /Queue campaign/i })).toBeDisabled();
});
it('disables the queue button when the body is empty', async () => {
campaignFixture = { ...baseCampaign, bodyHtml: '' };
renderComposer();
const summary = await screen.findByTestId('recipient-summary');
// 42 recipients resolved — so the button can only be disabled by the
// missing bodyHtml, not by an empty recipient list.
await waitFor(() => expect(summary).toHaveTextContent('42 recipients'));
expect(screen.getByRole('button', { name: /Queue campaign/i })).toBeDisabled();
});
it('disables the queue button when the subject is empty', async () => {
campaignFixture = { ...baseCampaign, subject: '' };
renderComposer();
const summary = await screen.findByTestId('recipient-summary');
// 42 recipients resolved — so the button can only be disabled by the
// missing subject, not by an empty recipient list.
await waitFor(() => expect(summary).toHaveTextContent('42 recipients'));
expect(screen.getByRole('button', { name: /Queue campaign/i })).toBeDisabled();
});
it('confirms with the recipient count and rate before queueing', async () => {
renderComposer();
await screen.findByTestId('recipient-summary');
await userEvent.click(screen.getByRole('button', { name: /Queue campaign/i }));
await waitFor(() => expect(confirmSpy).toHaveBeenCalled());
const opts = confirmSpy.mock.calls[0][0] as { message: string; confirmLabel: string };
expect(opts.message).toContain('42 customers');
expect(opts.message).toContain('20 per minute');
expect(opts.message).toContain('roughly 3 min');
expect(opts.confirmLabel).toContain('42');
expect(queueSpy).toHaveBeenCalledWith(7);
});
it('does not queue when the confirm is declined', async () => {
confirmSpy.mockResolvedValueOnce(false);
renderComposer();
await screen.findByTestId('recipient-summary');
await userEvent.click(screen.getByRole('button', { name: /Queue campaign/i }));
await waitFor(() => expect(confirmSpy).toHaveBeenCalled());
expect(queueSpy).not.toHaveBeenCalled();
});
it('switching to manual mode reveals the customer picker', async () => {
renderComposer();
await screen.findByTestId('recipient-summary');
await userEvent.click(screen.getByRole('radio', { name: /Pick customers/i }));
expect(await screen.findByText('Ada')).toBeInTheDocument();
});
it('refuses to edit a campaign that is already queued', async () => {
campaignFixture = { ...baseCampaign, status: 'queued' };
renderComposer();
expect(await screen.findByText(/can no longer be edited/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Queue campaign/i })).not.toBeInTheDocument();
});
it('hides manual mode from a role that cannot read customers', async () => {
// The picker reads /admin/customers, which needs `customers.view`. Showing
// the radio to a newsletters-only role produced an empty list with no
// explanation (#1264 review).
grantedPermissions = ['newsletters.view', 'newsletters.send'];
renderComposer();
await screen.findByTestId('recipient-summary');
expect(screen.queryByRole('radio', { name: /Pick customers/i })).not.toBeInTheDocument();
expect(screen.getByRole('radio', { name: /All active customers/i })).toBeInTheDocument();
});
});
@@ -0,0 +1,204 @@
/**
* Newsletter list (#1264).
*
* The list is a safety surface: status and failure counts have to be legible
* at a glance, and delete must not be offered for a campaign that has
* already reached people — a sent campaign is a delivery record.
*/
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import type { Campaign, CampaignStatus } from '../../../../services/newsletters.service';
// Resolve against the REAL en.json rather than returning fallbacks. That
// makes these assertions double as a check that the `newsletters.*` keys
// actually exist — a missing key shows up as a failing label, not a silent
// fallback that looks right.
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
const en = (await import('../../../../i18n/locales/en.json')).default as Record<string, unknown>;
const lookup = (key: string): string | undefined =>
key.split('.').reduce<unknown>(
(node, part) => (node && typeof node === 'object'
? (node as Record<string, unknown>)[part] : undefined),
en
) as string | undefined;
return {
...actual,
useTranslation: () => ({
t: (k: string, fb?: unknown, opts?: Record<string, unknown>) => {
const base = lookup(k) ?? (typeof fb === 'string' ? fb : k);
if (!opts) return base;
return base.replace(/\{\{(\w+)\}\}/g, (_m, key) => String(opts[key] ?? ''));
},
i18n: { language: 'en' },
}),
};
});
vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const confirmSpy = vi.fn(async () => true);
vi.mock('../../../../components/common', async () => {
const actual = await vi.importActual<any>('../../../../components/common');
return { ...actual, useConfirm: () => confirmSpy };
});
// The list gates New/Delete on `newsletters.send` (#1264 review), so the page
// now consults PermissionsContext. Default to a full-permission admin; the
// view-only case gets its own describe below.
let grantedPermissions = ['newsletters.view', 'newsletters.send'];
vi.mock('../../../../contexts/PermissionsContext', () => ({
usePermissions: () => ({
hasPermission: (p: string) => grantedPermissions.includes(p),
isLoading: false,
}),
}));
const makeCampaign = (over: Partial<Campaign>): Campaign => ({
id: 1, name: 'Spring', subject: 'Spring offers', bodyHtml: '<p>x</p>', bodyCss: '',
language: 'en', status: 'draft', recipientMode: 'all_active', customerIds: [],
recipientCount: 0, sentCount: 0, failedCount: 0, sendRatePerMinute: 20,
createdByAdminId: 1, testSentAt: null, queuedAt: null, completedAt: null,
createdAt: '2026-09-01T00:00:00Z', updatedAt: '2026-09-01T00:00:00Z',
...over,
});
let listFixture: Campaign[] = [];
const listSpy = vi.fn(async () => listFixture);
const removeSpy = vi.fn(async () => undefined);
vi.mock('../../../../services/newsletters.service', () => ({
newslettersService: {
list: (...a: unknown[]) => listSpy(...(a as [])),
remove: (...a: unknown[]) => removeSpy(...(a as [])),
create: vi.fn(async () => makeCampaign({ id: 99 })),
},
}));
import { NewsletterListPage } from '../NewsletterListPage';
function renderList() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter><NewsletterListPage /></MemoryRouter>
</QueryClientProvider>
);
}
describe('newsletter list', () => {
beforeEach(() => {
listFixture = [];
grantedPermissions = ['newsletters.view', 'newsletters.send'];
confirmSpy.mockClear();
listSpy.mockClear();
removeSpy.mockClear();
});
it('shows an empty state when there are no campaigns', async () => {
renderList();
expect(await screen.findByText('No campaigns yet.')).toBeInTheDocument();
});
it.each<[CampaignStatus, string]>([
['draft', 'Draft'],
['queued', 'Queued'],
['sending', 'Sending'],
['sent', 'Sent'],
['cancelled', 'Cancelled'],
['failed', 'Failed'],
])('renders a %s chip', async (status, label) => {
listFixture = [makeCampaign({ status })];
renderList();
expect(await screen.findByTestId(`status-${status}`)).toHaveTextContent(label);
});
it('shows recipient, sent and failed counts', async () => {
listFixture = [makeCampaign({ recipientCount: 120, sentCount: 118, failedCount: 2 })];
renderList();
const row = (await screen.findByText('Spring')).closest('tr')!;
expect(within(row).getByText('120')).toBeInTheDocument();
expect(within(row).getByText('118')).toBeInTheDocument();
expect(within(row).getByText('2')).toBeInTheDocument();
});
it.each<CampaignStatus>(['draft', 'cancelled'])(
'offers delete for a %s campaign', async (status) => {
listFixture = [makeCampaign({ status })];
renderList();
expect(await screen.findByRole('button', { name: /Delete Spring/i })).toBeInTheDocument();
}
);
it.each<CampaignStatus>(['queued', 'sending', 'sent', 'failed'])(
'does not offer delete for a %s campaign', async (status) => {
listFixture = [makeCampaign({ status })];
renderList();
await screen.findByText('Spring');
expect(screen.queryByRole('button', { name: /Delete Spring/i })).not.toBeInTheDocument();
}
);
it('confirms before deleting', async () => {
listFixture = [makeCampaign({ status: 'draft' })];
renderList();
await userEvent.click(await screen.findByRole('button', { name: /Delete Spring/i }));
expect(confirmSpy).toHaveBeenCalled();
expect(removeSpy).toHaveBeenCalledWith(1);
});
it('does not delete when the confirm is declined', async () => {
confirmSpy.mockResolvedValueOnce(false);
listFixture = [makeCampaign({ status: 'draft' })];
renderList();
await userEvent.click(await screen.findByRole('button', { name: /Delete Spring/i }));
expect(removeSpy).not.toHaveBeenCalled();
});
it('passes the status filter through to the API', async () => {
listFixture = [makeCampaign({})];
renderList();
await screen.findByText('Spring');
await userEvent.selectOptions(
screen.getByLabelText('Filter by status'), 'sent'
);
expect(listSpy).toHaveBeenLastCalledWith('sent');
});
describe('a role with newsletters.view but not newsletters.send', () => {
// The backend supports this split deliberately. Showing write controls to
// such a role only produces a 403 after the click (#1264 review).
beforeEach(() => { grantedPermissions = ['newsletters.view']; });
it('hides the New campaign button', async () => {
listFixture = [makeCampaign({})];
renderList();
await screen.findByText('Spring');
expect(screen.queryByRole('button', { name: /New campaign/i })).not.toBeInTheDocument();
});
it('hides the delete control on a draft', async () => {
listFixture = [makeCampaign({ status: 'draft' })];
renderList();
await screen.findByText('Spring');
expect(screen.queryByRole('button', { name: /Delete Spring/i })).not.toBeInTheDocument();
});
it('still shows the campaigns themselves', async () => {
listFixture = [makeCampaign({ recipientCount: 5, sentCount: 5 })];
renderList();
expect(await screen.findByText('Spring')).toBeInTheDocument();
});
});
});
@@ -37,6 +37,12 @@ export interface CustomerAccountSummary {
* null when admin hasn't set one — each entry then requires a
* per-block override. */
hourlyRateMinor?: number | null;
/** Newsletter consent (migration 199, #1264). Opt-OUT: false means the
* customer still receives campaigns. Transactional mail — galleries,
* quotes, invoices — ignores this entirely. */
marketingOptOut?: boolean;
/** When the customer opted out. null while they are still subscribed. */
marketingOptOutAt?: string | null;
}
export interface CustomerAccountDetail extends CustomerAccountSummary {
@@ -174,6 +180,9 @@ export const customerAdminService = {
skontoDisabled: 'skonto_disabled',
// Per-customer re-bill proof-attachment override (#866). null clears it.
rebillAttachProof: 'rebill_attach_proof',
// Newsletter consent (migration 199, #1264). Admin-settable so a
// customer who unsubscribes by phone can be honoured immediately.
marketingOptOut: 'marketing_opt_out',
};
for (const [k, v] of Object.entries(payload)) {
if (k in map) snake[map[k]] = v;
@@ -85,7 +85,11 @@ export type FeatureKey =
// sidecar. Face embeddings are biometric data (GDPR Art. 9); turning this
// on is only the first of two deliberate actions, since detection is still
// enabled per event.
| 'faces';
| 'faces'
// Newsletter campaigns (migration 199, #1264). Child of `clients` —
// mass marketing mail to customer accounts, with per-customer opt-out
// and an unsubscribe link on every send. Strictly opt-in.
| 'newsletters';
export type FeatureFlags = Record<FeatureKey, boolean>;
@@ -0,0 +1,142 @@
/**
* Admin → Newsletter campaigns API client (#1264).
*
* Every route behind this client is gated server-side by the `newsletters`
* feature flag AND a `newsletters.view` / `newsletters.send` permission, so a
* 403 here is a legitimate answer rather than a bug — the UI hides the
* surface, and the API refuses it independently.
*/
import { api } from '../config/api';
export type CampaignStatus =
| 'draft' | 'queued' | 'sending' | 'sent' | 'cancelled' | 'failed';
export type RecipientMode = 'all_active' | 'manual';
export type RecipientStatus =
| 'queued' | 'sent' | 'failed' | 'cancelled' | 'skipped_opt_out';
export interface Campaign {
id: number;
name: string;
subject: string;
/** Stored already-sanitized by the server. Never render outside the
* sandboxed preview iframe. */
bodyHtml: string;
bodyCss: string;
language: string;
status: CampaignStatus;
recipientMode: RecipientMode;
/** Only meaningful when recipientMode is 'manual'. */
customerIds: number[];
recipientCount: number;
sentCount: number;
failedCount: number;
/** Recipients per minute. Server clamps to 1..120. */
sendRatePerMinute: number;
createdByAdminId: number | null;
testSentAt: string | null;
queuedAt: string | null;
completedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface CampaignRecipient {
id: number;
customerAccountId: number | null;
email: string;
status: RecipientStatus;
errorMessage: string | null;
sentAt: string | null;
createdAt: string;
}
/** Counts only — the composer never pulls 2 000 addresses to show a number. */
export interface RecipientResolution {
recipientCount: number;
skippedOptOut: number;
skippedNoEmail: number;
sendRatePerMinute: number;
estimatedMinutes: number;
}
export interface CampaignPayload {
name?: string;
subject?: string;
bodyHtml?: string;
bodyCss?: string;
language?: string;
recipientMode?: RecipientMode;
customerIds?: number[];
sendRatePerMinute?: number;
}
export interface Pagination {
page: number;
limit: number;
total: number;
totalPages: number;
hasMore: boolean;
}
const BASE = '/admin/newsletters';
export const newslettersService = {
async list(status?: CampaignStatus): Promise<Campaign[]> {
const { data } = await api.get(BASE, { params: status ? { status } : undefined });
return data.campaigns || [];
},
async get(id: number): Promise<{ campaign: Campaign; recipientSummary: Record<string, number> }> {
const { data } = await api.get(`${BASE}/${id}`);
return data;
},
async create(payload: CampaignPayload): Promise<Campaign> {
const { data } = await api.post(BASE, payload);
return data.campaign;
},
async update(id: number, payload: CampaignPayload): Promise<Campaign> {
const { data } = await api.put(`${BASE}/${id}`, payload);
return data.campaign;
},
async remove(id: number): Promise<void> {
await api.delete(`${BASE}/${id}`);
},
/** Rendered HTML for the sandboxed preview iframe. */
async preview(id: number, opts: { customerId?: number; language?: string } = {}):
Promise<{ subject: string; html: string; language: string; isSample: boolean }> {
const { data } = await api.post(`${BASE}/${id}/preview`, opts);
return data;
},
/** Dry run: how many people this would reach, and how many said no. */
async resolveRecipients(id: number): Promise<RecipientResolution> {
const { data } = await api.post(`${BASE}/${id}/recipients/resolve`);
return data;
},
async sendTest(id: number, to: string): Promise<void> {
await api.post(`${BASE}/${id}/test`, { to });
},
async queue(id: number): Promise<{ queued: number; skippedOptOut: number; sendRatePerMinute: number }> {
const { data } = await api.post(`${BASE}/${id}/queue`);
return data;
},
async cancel(id: number): Promise<{ cancelled: number }> {
const { data } = await api.post(`${BASE}/${id}/cancel`);
return data;
},
async recipients(id: number, opts: { page?: number; limit?: number; status?: RecipientStatus } = {}):
Promise<{ data: CampaignRecipient[]; pagination: Pagination }> {
const { data } = await api.get(`${BASE}/${id}/recipients`, { params: opts });
return data;
},
};