A newsletter for the sites built on omnisense/omnibase,
with no account behind it: the visitor leaves an address, confirms it from the mail it
receives, and gets the letters the site writes in its back office - each carrying its way out.
It is a Symfony bundle (Base\Newsletter\NewsletterBundle) on the core 3 or 4; the letters go
out through Symfony Messenger and Mailer. It was first made for two musicians' sites, where it
is the only thing a visitor can subscribe to.
- A
Subscriberis an address (unique, lowercased), the key of its links (a 32-hex token), the language of the page it signed up from, where it came from, and its history: signed up, confirmed, left. Leaving keeps the row; coming back is a new confirmation under a new token. - A
Campaignis a letter: a subject, a plain text, an optional button, one language or everyone, its state (draft,sending,sent) and how many it went to. - A monthly digest is drafted from the site's own sources (an agenda's dates, a blog's posts)
by
newsletter:draft-digest, as a draft letter to read and send.
Sign-ups are double opt-in by default, guarded against robots and against the use of the form to flood a stranger's inbox, and answered with the same words whatever the address - the form never tells who reads the letters. Every letter carries the one-click unsubscribe headers of RFC 8058.
Use it for a site that writes to its readers a few times a year to a few thousand addresses. It keeps no opening or click statistics and has no segments beyond the language.
How this manual is organised
- Installation: requirements, the bundle, the routes, Messenger, the router's default URI, the schema.
- Subscribers and letters: the entities, their states and methods, the queries.
- Signing up: the form and its guards, double opt-in, confirming, leaving.
- Sending a letter: the mails, the headers, the message and its handler.
- The monthly digest: digest sources, the command, the draft it writes.
- In a host page: the form partial, the Twig functions, the templates, the stylesheet, the translations.
- The back office: the two screens, test and send, the export, the tile.
- Configuration reference:
newsletter:, the rate limiter, the services. - Testing: the suite and what it checks.
Propose a change docs/index.md
Installation
Requirements
What composer.json requires: PHP 8.2 or later, doctrine/orm 2.15 or 3,
omnisense/omnibase 3.0 or 4.0, omnibridge/symfony 1.x, omnibase/admin, and
symfony/config, dependency-injection, form, http-kernel, security-bundle,
messenger, rate-limiter, mailer, twig-bridge and console 6, 7 or 8.
The confirmation mail goes through the core's notifier; the letters through Symfony's
MailerInterface, from the notifier's technical recipient (the site's address,
base.settings.mail): a site without one cannot send a letter (LogicException).
The bundle
composer require omnibase/newsletter:dev-main
// config/bundles.php
return [
// ...
Base\BaseBundle::class => ['all' => true],
Base\Admin\AdminBundle::class => ['all' => true],
Base\Newsletter\NewsletterBundle::class => ['all' => true],
];
NewsletterBundle extends the core's AbstractBaseBundle: its root is the package's
(templates/ is @Newsletter, public/ is bundles/newsletter/), and build() aliases
Base\Newsletter\Entity\* to App\Entity\Newsletter\* and the repositories to
App\Repository\Newsletter\*, unless the application declares its own class there.
The routes
# config/routes.yaml
newsletter_controller:
resource: "@NewsletterBundle/src/Controller/Client"
type: attribute
prefix: /
| Route | Path | Methods | |
|---|---|---|---|
newsletter_index |
/newsletter |
GET | a page with the form; in the sitemap (0.4, monthly) |
newsletter_subscribe |
/newsletter |
POST | the form's target |
newsletter_confirm |
/newsletter/confirm/{token} |
GET | the confirmation mail's link |
newsletter_unsubscribe |
/newsletter/unsubscribe/{token} |
GET, POST | GET: a page with one button; POST: unsubscribed |
{token} is 32 hexadecimal characters.
Messenger
The back office's "send" dispatches Base\Newsletter\Message\SendCampaignMessage. Route it to
an asynchronous transport, so the button answers at once whatever the list's length:
# config/packages/messenger.yaml
framework:
messenger:
routing:
Base\Newsletter\Message\SendCampaignMessage: async
Without routing the message is handled synchronously, in the request.
Links built in a worker
A worker builds absolute links without a request: set the router's default URI, or the links
of the mails point at localhost.
# config/packages/routing.yaml
framework:
router:
default_uri: '%env(DEFAULT_URI)%'
The schema and the public files
bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate
bin/console assets:install
Tables newsletter_subscriber and newsletter_campaign; the stylesheet
bundles/newsletter/css/newsletter.css.
What the layout provides
The pages extend layout1.html.twig and fill title, description, content and
stylesheets. The form goes where the site wants it (In a host page).
Propose a change docs/installation.md
Subscribers and letters
Subscriber
Base\Newsletter\Entity\Subscriber (newsletter_subscriber, unique on email and on
token, indexed on ip and createdAt) - an address, never an account.
| Property | Column | Meaning |
|---|---|---|
email |
string(180) |
trimmed and lowercased (Subscriber::normalize()) |
token |
string(32) |
16 random bytes in hex: the key of the confirm and unsubscribe links |
locale |
string(5) |
the language of the page it signed up from (lowercased; en when empty): its letters are in it |
source |
string(40) |
where it came from: site, import, a page's key; site when empty |
ip |
string(45), nullable |
the address it signed up from, for the flood interval |
createdAt |
datetime_immutable |
|
confirmedAt |
nullable | when its link was followed |
unsubscribedAt |
nullable | when it left |
lastCampaign |
integer, nullable |
the id of the last letter it was sent: a retried send skips it |
new Subscriber(string $email, string $locale = 'en', string $source = 'site', ?string $ip = null).
States:
| Method | True when |
|---|---|
isConfirmed() |
it confirmed, whether or not it left since |
isUnsubscribed() |
it left |
isActive() |
confirmed and still there: what the letters go to |
isPending() |
signed up, not confirmed, not gone |
Transitions:
confirm()- setsconfirmedAtonce (the first date is kept); an address that left is not brought back by an old link.unsubscribe()- setsunsubscribedAtonce; the row stays.resubscribe(bool $doubleOptIn = true)- for an address that left:unsubscribedAtcleared, a new token, andconfirmedAtcleared (double opt-in) or set to now. An address still on the list is left as it is.sent(Campaign),wasSent(Campaign)- the last letter it got.
Campaign
Base\Newsletter\Entity\Campaign (newsletter_campaign):
| Property | Column | Constraints / meaning |
|---|---|---|
subject |
string(150) |
not blank, 150 at most |
body |
text |
not blank; plain text, a blank line between two paragraphs (line ends normalised to \n) |
link |
string(255), nullable |
the button's page: a path of the site (/concerts) or a full http(s):// address |
linkLabel |
string(80), nullable |
the button's words |
locale |
string(5), nullable |
who it goes to: the subscribers of this language; null: everyone |
state |
CampaignState |
DRAFT at first |
createdAt, sentAt |
||
recipients |
integer |
how many it went to |
Empty strings are stored as null. getParagraphs() splits the body on blank lines.
markSending() refuses (LogicException) anything but a draft; markSent(int $recipients)
sets SENT, the count and sentAt. isDraft(), isSent().
CampaignState: DRAFT (draft), SENDING (sending: the message dispatched, the mails
going out), SENT (sent).
In memory
Run in omnisense/omnibase's harness image, with this checkout's src/ on the autoloader:
use Base\Newsletter\Entity\Campaign;
use Base\Newsletter\Entity\Subscriber;
use Base\Newsletter\Service\DigestDrafter;
use Base\Newsletter\Service\SignedTimestamp;
$subscriber = new Subscriber(' Anna@Example.ORG ', 'DE', '');
echo $subscriber->getEmail(), ' ', $subscriber->getLocale(), ' ', $subscriber->getSource(), ' ', strlen($subscriber->getToken()), "\n";
$subscriber->confirm()->unsubscribe();
$old = $subscriber->getToken();
$subscriber->resubscribe(true);
var_dump($subscriber->isPending(), $old !== $subscriber->getToken());
$campaign = new Campaign('November', "First paragraph.\r\n\r\nSecond\nparagraph.");
print_r($campaign->getParagraphs());
$signed = SignedTimestamp::sign(1767225600, 'secret');
var_dump(SignedTimestamp::verify($signed, 'secret'), SignedTimestamp::verify($signed, 'another'));
[$from, $to] = DigestDrafter::nextMonth(new DateTimeImmutable('2026-10-20'));
echo $from->format('Y-m-d'), ' ', $to->format('Y-m-d'), "\n";
anna@example.org de site 32
bool(true)
bool(true)
Array
(
[0] => First paragraph.
[1] => Second
paragraph.
)
int(1767225600)
NULL
2026-11-01 2026-12-01
Repositories
SubscriberRepository: findOneByEmail() (normalised), findOneByToken(),
findConfirmed(?string $locale = null) (confirmed and not gone, of one language when given,
by id), countConfirmed(?string $locale = null), countPending(), findLatest(int $limit = 5)
(the last signed up, whatever became of them), findLastFromIp(string $ip).
CampaignRepository has the default queries.
Propose a change docs/concepts.md
Signing up
The form
Base\Newsletter\Form\SubscribeType (block prefix newsletter, translation domain
newsletter, CSRF id newsletter_subscribe): email (not blank, an e-mail, 180 at most) and
source (hidden, 40 at most: which page it was filled on). It posts to newsletter_subscribe.
With a core that has the forms' guard (Base\Service\FormGuard), its guard option is
{action: newsletter, min_delay: newsletter.min_delay, captcha_after: 1}: the trap, the time
the form takes, the core's address lists, and the captcha (omnisense/omnishield) after one
refused try. With a core from before the guard, the form adds its own trap (website, hidden
by newsletter.css) and the time it was opened (opened_at, signed - see below), which
SubscribeGuard reads.
What POST /newsletter does
- An invalid form (the guard refusing included) - back with the flash "too fast" when the
guard refused for its haste (
too_fast,stale), "invalid" otherwise. Base\Newsletter\Service\SubscribeGuard::check()- in order:- the old trap filled:
trapped- the robot is thanked as anyone ("check your inbox", or "subscribed" without double opt-in) and nothing is kept; - the old signed time missing, forged or younger than
min_delay:too_fast; - another sign-up from the same IP within
flood_intervalseconds:flood; - the rate limiter
newsletter_subscriberefusing (5 an hour per IP by default):too_many. Each reason becomes the flashflash.<reason>.
- the old trap filled:
- The address is found, or a
Subscriberis created with the request's locale, the form's source (lowercased,[a-z0-9_-], elsesite) and the client IP. resubscribe(double_opt_in); without double opt-in,confirm(). Flushed.- Only an address that has not confirmed is sent the confirmation mail.
- Back to the page the form was filled on (the referer, when it is this host), at
#newsletter, else/newsletter#newsletter, with the flash "check your inbox" (or "subscribed").
The answer is the same for a new address, a pending one and a confirmed one: the form cannot be used to find out who reads the letters.
The signed time
Base\Newsletter\Service\SignedTimestamp::sign(int $time, string $secret) gives
<seconds>.<HMAC-SHA256 of "newsletter-opened-at:<seconds>"> under the kernel secret;
verify() returns the time, or null for another secret, a changed time or nothing.
Confirming
GET /newsletter/confirm/{token} confirms the subscriber of that token and renders
@Newsletter/client/confirmed.html.twig; an unknown token is a 404. Confirming twice keeps the
first date; the link of an address that left does nothing.
Leaving
GET /newsletter/unsubscribe/{token} renders a page with one button (mail scanners follow
links: a GET never unsubscribes anyone). POST to the same address unsubscribes - from that
button, or from a mail client's one-click unsubscribe (RFC 8058), which sends no CSRF token:
the token is the secret. An unknown token is answered "done" all the same. The row stays,
marked; coming back is a new sign-up, a new confirmation and a new token.
Without double opt-in
newsletter.double_opt_in: false: the address is on the list at once and no confirmation mail
is sent.
Propose a change docs/usage-subscribing.md
Sending a letter
The two mails
Base\Newsletter\Service\NewsletterMailer:
confirm(Subscriber $subscriber)- "Confirm your address", through the core's notifier: aBase\Entity\User\Notificationwith@Newsletter/email/confirm.html.twig(variablessubject,site_title,locale,confirm- the link -,home), sent on theemailchannel to the address, in its language.campaign(Subscriber $subscriber, Campaign $campaign, bool $test = false)- a letter: aTemplatedEmailsent with Symfony's mailer, because the notifier's e-mail cannot carry a header of its own. From the notifier's technical recipient (the site's address), namednewsletter.from_namewhen set; subject prefixed[Test]for a test;@Newsletter/email/campaign.html.twigwithsubject,campaign,link(a path made absolute on the site's home),site_title(base.settings.title),locale,unsubscribe,home,test; the e-mail's locale set to the subscriber's (Symfony 6.4+). Headers:
List-Unsubscribe: <https://example.com/newsletter/unsubscribe/<token>>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
With the mailer itself routed to Messenger, each mail is queued too.
The send
The back office's "send" calls Campaign::markSending() (a draft only), flushes, and dispatches
SendCampaignMessage($campaignId). Base\Newsletter\MessageHandler\SendCampaignHandler:
- A campaign that is gone or already
SENTis left. - Each confirmed subscriber still on the list - of the letter's language when it has one - is
mailed, then marked with the letter (
Subscriber::sent()); a subscriber already marked (a retried send) is counted and skipped. - An address the transport refuses (
TransportExceptionInterface) is logged as a warning and skipped. - The marks are flushed every 50 subscribers; at the end the campaign is
SENTwith the number of mails.
A send that fails half-way and is retried does not write twice to the first half.
The templates
Both mails are overridden the Symfony way: templates/bundles/NewsletterBundle/email/confirm.html.twig
and .../email/campaign.html.twig, with the variables above.
Propose a change docs/usage-letters.md
The monthly digest
Digest sources
A service implementing Base\Newsletter\Digest\DigestSourceInterface is found by itself
(#[AutoconfigureTag('newsletter.digest_source')]):
use Base\Newsletter\Digest\DigestSourceInterface;
final class ConcertDigest implements DigestSourceInterface
{
public function __construct(private readonly ConcertRepository $concerts)
{
}
public function getName(): string
{
return 'Concerts';
}
/** @return list<array{title: string, text: string, url: ?string, date: ?\DateTimeImmutable}> */
public function digest(\DateTimeImmutable $from, \DateTimeImmutable $to, string $locale): array
{
return array_map(fn (Concert $concert) => [
'title' => $concert->getTitle(),
'text' => $concert->getVenue(),
'url' => $concert->getUrl(),
'date' => $concert->getDate(),
], $this->concerts->findBetween($from, $to));
}
}
omnibase/agenda ships one (Base\Agenda\Digest\AgendaDigestSource).
The command
bin/console newsletter:draft-digest
bin/console newsletter:draft-digest --from=2026-11-01 --to=2026-12-01 --locale=de
| Option | Default | |
|---|---|---|
--from |
the first day of next month | the first day (Y-m-d) |
--to |
a month after --from |
the day after the last |
--locale |
everyone, in kernel.default_locale |
the letter's language and audience |
A date it cannot read, or --to not after --from, is exit 2. With
newsletter.digest.enabled: false it says the digest is off and does nothing. With nothing to
announce it writes no draft. A cron on the 20th of each month is the usual way.
The draft
Base\Newsletter\Service\DigestDrafter::draft($from, $to, ?string $locale = null, string $fallbackLocale = 'en'): ?Campaign
asks every source for the period in the letter's language, drops entries without a title, and
writes, per source with something to say, its name in capitals and its entries in date order
(undated last): the title and its date (IntlDateFormatter::FULL, the time when it is not
midnight), the text, the link. The subject is newsletter.digest.title with {month}
replaced, else the translation digest.subject ("Your dates in {month}"); the text opens with
digest.intro ("What is coming in {month}."). The month is written LLLL yyyy in the language,
capitalised. The command persists the draft; nothing is sent - it waits in the back office.
DigestDrafter::nextMonth(?\DateTimeImmutable $today = null) gives the coming month: its first
day and the first of the next.
Propose a change docs/usage-digest.md
In a host page
The form partial
{% include '@Newsletter/client/_form.html.twig' %}
{% include '@Newsletter/client/_form.html.twig' with {source: 'home', heading: false} %}
source is kept with the address (which page it was filled on); heading: false leaves the
heading out. The partial uses the page's newsletter_form when one is given and not rendered
yet (the /newsletter page's), else builds one with newsletter_form(source). Its flash
messages are in the newsletter flash bag.
Twig functions
Base\Newsletter\Twig\NewsletterExtension:
| Function | Returns |
|---|---|
newsletter_form(source = 'site') |
the sign-up form's FormView |
newsletter_count(locale = null) |
the confirmed subscribers still on the list |
Templates
@Newsletter/client/: index (the /newsletter page), _form, confirmed, unsubscribe;
@Newsletter/email/: confirm, campaign; @Newsletter/admin/widget/subscribers. An
application overrides them in templates/bundles/NewsletterBundle/....
The stylesheet
bundles/newsletter/css/newsletter.css reads --newsletter-accent, --newsletter-soft,
--newsletter-line, --newsletter-surface and --newsletter-measure when the host sets
them; it hides the old trap field (.newsletter-trap).
Translations
The domain newsletter (ICU), in English, French and German
(translations/newsletter+intl-icu.{en,fr,de}.yaml): the form, the flash messages
(flash.check_inbox, flash.subscribed, flash.invalid, flash.too_fast, flash.flood,
flash.too_many), the mails, the digest (digest.subject, digest.intro) and the back office.
Propose a change docs/usage-twig.md
The back office
Loaded when omnibase/admin is installed; omnibase/admin's default permissions.
Letters
CampaignCrudController (slug campaigns): subject, language (the choices are
kernel.enabled_locales, else en, fr, de; empty for everyone), state (read), text,
button's page and words, recipients and dates (read). Filters: state, language.
On a draft, in the list and on the record:
- Send me a test (
/admin/campaigns/{entityId}/test) - the letter as subscribers get it,[Test]in its subject, to the signed-in user's address (an address that is no subscriber: its unsubscribe link leads nowhere). Without an address on the user, an error flash. - Send (
/admin/campaigns/{entityId}/send), asked to confirm -SENDING, the message dispatched, the flash saying how many confirmed addresses it goes to. Anything but a draft is refused.
Subscribers
SubscriberCrudController (slug subscribers): address, language, source, the three dates,
the IP on the detail page. Filters: confirmed (yes/no on confirmedAt), unsubscribed, language,
source. Export (/admin/subscribers/export, GET) downloads newsletter-<date>.csv: the
confirmed addresses still on the list, columns email, locale, confirmed_at, source.
The dashboard tile
SubscribersWidgetType, named newsletter_subscribers
(@Newsletter/admin/widget/subscribers.html.twig): the confirmed subscribers, those not
confirmed yet, the five last to sign up.
// App\Controller\Admin\DashboardController::configureWidgetItems()
yield MenuItem::block('newsletter_subscribers', 'Newsletter', 'fa-solid fa-envelope-open-text');
Propose a change docs/back-office.md
Configuration reference
newsletter:
Base\Newsletter\DependencyInjection\NewsletterConfiguration, dumped with Symfony's
YamlReferenceDumper in omnisense/omnibase's harness image:
newsletter:
# An address receives nothing before it confirms from the mail it was sent. Off, it is signed up at once.
double_opt_in: true
# Seconds between the form being opened and sent: faster is a robot.
min_delay: 3
# Seconds between two sign-ups from the same address.
flood_interval: 60
# The sender's name on the letters; null: the site's (base.settings.mail.name).
from_name: null
digest:
# newsletter:draft-digest drafts at all.
enabled: true
# The digest's subject, {month} replaced; null: "Your dates in {month}", translated.
title: null
| Option | Type | Default | Read by | Effect |
|---|---|---|---|---|
double_opt_in |
bool | true |
NewsletterController |
false: on the list at once, no confirmation mail |
min_delay |
int, 0 or more | 3 |
SubscribeType (the guard's min_delay), SubscribeGuard |
the seconds a person needs; 0 turns the check off |
flood_interval |
int, 0 or more | 60 |
SubscribeGuard |
seconds between two sign-ups from one IP; 0 off |
from_name |
string or null | null |
NewsletterMailer |
the letters' sender name; null keeps the site address's own |
digest.enabled |
bool | true |
DraftDigestCommand |
false: the command does nothing |
digest.title |
string or null | null |
DigestDrafter |
the digest's subject, {month} replaced |
Parameters: newsletter.double_opt_in, newsletter.min_delay, newsletter.flood_interval,
newsletter.from_name, newsletter.digest, newsletter.digest.enabled,
newsletter.digest.title.
The rate limiter
NewsletterExtension prepends to FrameworkBundle, when symfony/rate-limiter is installed:
framework:
rate_limiter:
newsletter_subscribe:
policy: sliding_window
limit: 5
interval: '1 hour'
A host's own framework.rate_limiter.newsletter_subscribe, read after, wins. SubscribeGuard
gets limiter.newsletter_subscribe (null when it does not exist: no limit).
Services
config/services.php autowires and autoconfigures every class of src/ but
DependencyInjection/, Entity/, Enum/, Message/, Controller/Admin/, Admin/ and the
bundle class; Controller/Client/ is tagged controller.service_arguments; with omnibase/admin,
Controller/Admin/ and Admin/. SendCampaignHandler is #[AsMessageHandler],
DraftDigestCommand #[AsCommand], DigestSourceInterface tags newsletter.digest_source
(collected by DigestDrafter with #[AutowireIterator]).
Propose a change docs/configuration.md
Testing
vendor/bin/phpunit # checked out on its own
php vendor/bin/phpunit -c vendor/omnibase/newsletter/phpunit.xml.dist # from an application
In omnisense/omnibase's harness, this checkout mounted over the package:
docker run --rm --network none -v "$PWD":/srv/demo/vendor/omnibase/newsletter:ro omnisense/omnibase-harness test newsletter
...................SSSSSS....... 32 / 32 (100%)
OK, but incomplete, skipped, or risky tests!
Tests: 32, Assertions: 90, Skipped: 6.
The six skipped are tests/Form/SubscribeGuardTest's: they boot the host application's kernel
and skip, saying so, when it does not load this bundle (the harness's does not register it), or
has no forms' guard.
| File | What |
|---|---|
Form/SubscribeGuardTest |
the form guarded in place of its own trap; a sign-up made as a person goes through; a filled trap, a sign-up too fast, one without the captcha's token are refused; without a captcha, the trap and the time alone |
Entity/SubscriberTest |
a new address lowercased and waiting; two addresses never share a token; an empty source is the site; confirming once, the first date kept; leaving keeps the row; an old link does not bring back an address that left; coming back with and without double opt-in; resubscribing an address still there changes nothing; the last letter remembered |
Entity/CampaignTest |
a new letter is a draft for everyone; it goes, then is sent with its count; only a draft goes |
Service/SignedTimestampTest |
what was signed is read back; another secret, a changed time or nothing are refused |
Service/DigestDrafterTest |
the coming month; each source asked for the period in the letter's language; nothing to announce, no draft; a part per source, entries in date order; the configured title replaces the default subject |
Controller/CrudRequiredFieldsTest |
the CRUDs' required fields |