Merge pull request #1302 from PicPeak/feat/newsletter-large-send-warning

feat(newsletters): warn about deliverability before a large send
This commit is contained in:
Paul Nothaft
2026-09-05 23:37:00 +02:00
committed by GitHub
4 changed files with 138 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. Die Versand-Warteschlange wird geteilt: Während der Kampagne können andere E-Mails — Galerie-Einladungen, Passwort-Zurücksetzungen — dahinter verzögert werden."
}
}
}
+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. The send queue is shared, so while it runs other email — gallery invitations, password resets — can be delayed 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,25 @@ 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;
/**
* Queue throughput ceiling, mirroring newsletterService.clampRate. The server
* clamps the stored rate to this, so the estimate has to clamp identically or
* it would promise a speed the queue cannot deliver.
*/
const MIN_RATE_PER_MINUTE = 1;
const MAX_RATE_PER_MINUTE = 10;
/** Variables the server substitutes per recipient. */
const VARIABLES = [
'customer_name', 'first_name', 'last_name', 'salutation',
@@ -170,6 +189,21 @@ export const NewsletterComposerPage: React.FC = () => {
// 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.
// The rate the send will actually use: queueing persists the draft first,
// so an edited rate is the one that takes effect. `estimatedMinutes` from
// the resolution is computed from the SAVED rate, so pairing the two showed
// a contradiction after any unsaved edit — 120 recipients switched from
// 10/min to 1/min still claimed 12 minutes instead of 120. Recomputed here
// with the server's own formula (adminNewsletters.js: ceil(count / rate)).
const effectiveRate = Math.min(
MAX_RATE_PER_MINUTE,
Math.max(MIN_RATE_PER_MINUTE, Number(draft?.sendRatePerMinute) || MAX_RATE_PER_MINUTE)
);
const estimatedMinutes = Math.max(
1,
Math.ceil((resolution?.recipientCount ?? 0) / effectiveRate)
);
const canQueue = useMemo(() => Boolean(
draft
&& draft.status === 'draft'
@@ -378,7 +412,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 +440,44 @@ 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. The send '
+ 'queue is shared, so while it runs other email — gallery '
+ 'invitations, password resets — can be delayed behind it.',
{ rate: effectiveRate, minutes: estimatedMinutes })}
</p>
</div>
</div>
</div>
)}
<Button
onClick={queueCampaign}
disabled={!canQueue}
@@ -151,6 +151,52 @@ 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 may wait behind
// it. 120 recipients at the fixture's 20/min clamps to the queue's real
// ceiling of 10/min, so the honest estimate is 12 minutes.
expect(warning).toHaveTextContent(/12 minutes/);
expect(warning).toHaveTextContent(/can be delayed behind it/i);
});
it('recomputes the duration when the rate is edited, before saving', async () => {
// The estimate the server returns is computed from the SAVED rate, while
// the input shows the edited one. Pairing them meant the warning could
// claim 12 minutes for a send that would actually take 120.
resolution = { ...resolution, recipientCount: 120, estimatedMinutes: 12 };
renderComposer();
await screen.findByTestId('recipient-summary');
await screen.findByTestId('large-send-warning');
const rate = screen.getByLabelText(/Send rate/i);
await userEvent.clear(rate);
await userEvent.type(rate, '1');
await waitFor(() => {
expect(screen.getByTestId('large-send-warning')).toHaveTextContent(/At 1\/minute/);
expect(screen.getByTestId('large-send-warning')).toHaveTextContent(/120 minutes/);
});
});
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');