Skip to content

omnibase/newsletter

A newsletter for omnibase: subscribers with double opt-in, campaigns written in the back office and sent through Messenger, a monthly digest drafted from the site’s sources.

Floor
Prepared structure · Omnibase
Version
main
Verified for real
Its README does not say
Licence
MIT
composer require omnibase/newsletter:dev-main

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 Subscriber is 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 Campaign is 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

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.

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() - sets confirmedAt once (the first date is kept); an address that left is not brought back by an old link.
  • unsubscribe() - sets unsubscribedAt once; the row stays.
  • resubscribe(bool $doubleOptIn = true) - for an address that left: unsubscribedAt cleared, a new token, and confirmedAt cleared (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

  1. 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.
  2. 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_interval seconds: flood;
    • the rate limiter newsletter_subscribe refusing (5 an hour per IP by default): too_many. Each reason becomes the flash flash.<reason>.
  3. The address is found, or a Subscriber is created with the request's locale, the form's source (lowercased, [a-z0-9_-], else site) and the client IP.
  4. resubscribe(double_opt_in); without double opt-in, confirm(). Flushed.
  5. Only an address that has not confirmed is sent the confirmation mail.
  6. 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: a Base\Entity\User\Notification with @Newsletter/email/confirm.html.twig (variables subject, site_title, locale, confirm - the link -, home), sent on the email channel to the address, in its language.
  • campaign(Subscriber $subscriber, Campaign $campaign, bool $test = false) - a letter: a TemplatedEmail sent 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), named newsletter.from_name when set; subject prefixed [Test] for a test; @Newsletter/email/campaign.html.twig with subject, 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:

  1. A campaign that is gone or already SENT is left.
  2. 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.
  3. An address the transport refuses (TransportExceptionInterface) is logged as a warning and skipped.
  4. The marks are flushed every 50 subscribers; at the end the campaign is SENT with 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

Propose a change docs/testing.md