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
+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 />;
};