feat(newsletters): warn about deliverability before a large send

The composer already paces sends — the rate is clamped to what the queue
can actually drain and `scheduled_at` is staggered — and the help text
said so. But pacing answers the wrong risk. Spam filtering reacts to a
domain's volume and reputation, not to the interval between messages, so
a throttled send of several hundred near-identical mails from a domain
that normally emits only gallery notifications is exactly the shape that
gets junked or blocked. Nothing told the operator that.

Adds a warning above the queue button once the resolved recipient count
reaches 50, covering what actually goes wrong: the reputation hit, that
it damages delivery of transactional email too, the SPF/DKIM/DMARC
prerequisite, and the suggestion to split a first campaign.

It also states the cost of the send in the operator's terms — the real
duration at the chosen rate, and that gallery invitations and password
resets queue behind it, because the email queue is global rather than
per-campaign.

50 is deliberately low: the operators who most need this are the ones
sending their first campaign.

The existing rate hint now mentions looking like spam, not only being
rate-limited.
This commit is contained in:
Paul Nothaft
2026-09-05 08:52:37 +02:00
parent c71ffae912
commit 49197be329
4 changed files with 96 additions and 6 deletions
+8 -2
View File
@@ -6942,7 +6942,7 @@
"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.",
"rateHelp": "Sendungen werden zeitlich verteilt, damit Ihr Mailanbieter Sie nicht drosselt und ein plötzlicher Schwall nicht wie Spam aussieht. 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.",
@@ -6973,6 +6973,12 @@
"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."
"previewEmpty": "Aktualisieren Sie die Vorschau, um die E-Mail so zu sehen, wie ein Kunde sie erhält.",
"largeSend": {
"title": "Großer Versand — prüfen Sie zuerst Ihre Versandreputation",
"body": "{{count}} Personen auf einmal von einer Domain anzuschreiben, die sonst nur Transaktionsmails versendet, lässt Spamfilter aufmerksam werden. Anbieter können den gesamten Versand drosseln, als Spam einstufen oder blockieren — und ein schlechter Lauf beeinträchtigt auch die Zustellung Ihrer Galerie-E-Mails.",
"advice": "Stellen Sie sicher, dass SPF, DKIM und DMARC für Ihre Versanddomain eingerichtet sind, senden Sie sich zuerst einen Test, und teilen Sie eine erste Kampagne nach Möglichkeit in mehrere kleinere Sendungen auf.",
"duration": "Bei {{rate}}/Minute dauert dies etwa {{minutes}} Minuten; andere E-Mails — Galerie-Einladungen, Passwort-Zurücksetzungen — warten so lange in der Warteschlange."
}
}
}
+8 -2
View File
@@ -6941,7 +6941,7 @@
"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.",
"rateHelp": "Sends are spread out so your mail provider does not rate-limit you, and so a sudden burst does not look like spam. Check your provider's hourly cap before raising this.",
"recipientCount": "{{count}} recipients",
"skippedOptOut": "{{count}} skipped (opted out)",
"saveToRefresh": "Save to refresh this count.",
@@ -6972,6 +6972,12 @@
"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."
"previewEmpty": "Refresh the preview to see the email as a customer will.",
"largeSend": {
"title": "Large send — check your sending reputation first",
"body": "Mailing {{count}} people at once from a domain that usually sends only transactional email is what makes spam filters take notice. Providers may throttle, junk or block the whole batch, and a bad run damages delivery of your gallery emails too.",
"advice": "Confirm SPF, DKIM and DMARC are set up for your sending domain, send yourself a test first, and consider splitting a first campaign across several smaller sends.",
"duration": "At {{rate}}/minute this takes about {{minutes}} minutes, and other email — gallery invitations, password resets — queues behind it."
}
}
}
@@ -16,7 +16,7 @@ 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 { Save, Send, TestTube2, Users, Eye, ArrowLeft, AlertTriangle } from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card, Input, Loading, useConfirm } from '../../../components/common';
@@ -27,6 +27,17 @@ import {
import { customerAdminService } from '../../../services/customerAdmin.service';
import { usePermissions } from '../../../contexts/PermissionsContext';
/**
* Recipient count above which the composer warns about deliverability.
*
* Not a provider limit — the queue's own pacing handles rate. This is about
* reputation: what trips spam filtering is a domain that normally sends a
* trickle of transactional mail suddenly emitting hundreds of near-identical
* messages. 50 is deliberately conservative, because the operators who most
* need the warning are the ones sending their first campaign.
*/
const LARGE_SEND_THRESHOLD = 50;
/** Variables the server substitutes per recipient. */
const VARIABLES = [
'customer_name', 'first_name', 'last_name', 'salutation',
@@ -378,7 +389,9 @@ export const NewsletterComposerPage: React.FC = () => {
/>
<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.')}
'Sends are spread out so your mail provider does not rate-limit you, and so a '
+ 'sudden burst does not look like spam. Check your provider\'s hourly cap '
+ 'before raising this.')}
</p>
</div>
@@ -404,6 +417,46 @@ export const NewsletterComposerPage: React.FC = () => {
</Button>
</div>
{(resolution?.recipientCount ?? 0) >= LARGE_SEND_THRESHOLD && (
<div
data-testid="large-send-warning"
className="rounded-md border border-amber-300 dark:border-amber-700/60 bg-amber-50 dark:bg-amber-900/20 p-3"
>
<div className="flex gap-2">
<AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
<div className="text-xs text-amber-900 dark:text-amber-200 space-y-1">
<p className="font-medium">
{t('newsletters.largeSend.title',
'Large send — check your sending reputation first')}
</p>
<p>
{t('newsletters.largeSend.body',
'Mailing {{count}} people at once from a domain that usually sends '
+ 'only transactional email is what makes spam filters take notice. '
+ 'Providers may throttle, junk or block the whole batch, and a bad '
+ 'run damages delivery of your gallery emails too.',
{ count: resolution?.recipientCount ?? 0 })}
</p>
<p>
{t('newsletters.largeSend.advice',
'Confirm SPF, DKIM and DMARC are set up for your sending domain, '
+ 'send yourself a test first, and consider splitting a first '
+ 'campaign across several smaller sends.')}
</p>
<p>
{t('newsletters.largeSend.duration',
'At {{rate}}/minute this takes about {{minutes}} minutes, and other '
+ 'email — gallery invitations, password resets — queues behind it.',
{
rate: draft.sendRatePerMinute,
minutes: resolution?.estimatedMinutes ?? 1,
})}
</p>
</div>
</div>
</div>
)}
<Button
onClick={queueCampaign}
disabled={!canQueue}
@@ -151,6 +151,31 @@ describe('newsletter composer', () => {
expect(summary).toHaveTextContent('3 skipped (opted out)');
});
it('warns about deliverability once the send is large', async () => {
// Spam filtering reacts to a domain's volume, not to the queue's pacing,
// so the throttle alone is not something to reassure the operator with.
resolution = { ...resolution, recipientCount: 120, estimatedMinutes: 12 };
renderComposer();
await screen.findByTestId('recipient-summary');
const warning = await screen.findByTestId('large-send-warning');
expect(warning).toHaveTextContent(/sending reputation/i);
expect(warning).toHaveTextContent(/spam filters/i);
expect(warning).toHaveTextContent(/SPF, DKIM and DMARC/i);
// The operator is told what it costs: duration, and what waits behind it.
expect(warning).toHaveTextContent(/12 minutes/);
expect(warning).toHaveTextContent(/queues behind it/i);
});
it('does not warn on a send small enough not to matter', async () => {
resolution = { ...resolution, recipientCount: 12 };
renderComposer();
await screen.findByTestId('recipient-summary');
await waitFor(() => expect(resolveSpy).toHaveBeenCalled());
expect(screen.queryByTestId('large-send-warning')).not.toBeInTheDocument();
});
it('renders the preview in a sandboxed iframe with no allow-scripts', async () => {
renderComposer();
await screen.findByTestId('recipient-summary');