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.
Package info
github.com/glitchr-studio/omnibase-newsletter
Type:symfony-bundle
pkg:composer/omnibase/newsletter
Requires
- php: ^8.2
- doctrine/orm: ^2.15|^3.0
- glitchr/omnibase: 3.0.*-dev
- omnibase/admin: dev-main
- symfony/config: ^6.0|^7.0|^8.0
- symfony/console: ^6.0|^7.0|^8.0
- symfony/dependency-injection: ^6.0|^7.0|^8.0
- symfony/form: ^6.0|^7.0|^8.0
- symfony/http-kernel: ^6.0|^7.0|^8.0
- symfony/mailer: ^6.0|^7.0|^8.0
- symfony/messenger: ^6.0|^7.0|^8.0
- symfony/rate-limiter: ^6.0|^7.0|^8.0
- symfony/security-bundle: ^6.0|^7.0|^8.0
- symfony/twig-bridge: ^6.0|^7.0|^8.0
Requires (Dev)
- phpunit/phpunit: ^10.5|^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-03 16:12:24 UTC
README
A newsletter for 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. First made for two musicians' sites (a harpist, a violinist), where it is the only thing one 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
(source: site, import, a page's key) and its history: createdAt,
confirmedAt, unsubscribedAt. Leaving keeps the row - nothing more is sent
to it, the history is known - and coming back is a new confirmation, under a
new token.
A Campaign is a letter: a subject, a text (plain, a blank line between two
paragraphs), an optional button (link, linkLabel), who it goes to (one
locale, or everyone), its state (draft, sending, sent) and how many
it went to.
Install
composer require omnibase/newsletter:dev-main
// config/bundles.php Base\Newsletter\NewsletterBundle::class => ['all' => true],
# config/routes.yaml newsletter_controller: resource: "@NewsletterBundle/src/Controller/Client" type: attribute prefix: /
# config/packages/newsletter.yaml (every key optional) newsletter: double_opt_in: true # nothing is sent before the address confirms min_delay: 3 # seconds a human needs to fill the form flood_interval: 60 # seconds between two sign-ups from one address from_name: null # the sender's name; null: the site's (base.settings.mail.name) digest: enabled: true title: null # "{month}" replaced; null: "Your dates in {month}", translated
# config/packages/messenger.yaml: a letter goes out in the background framework: messenger: routing: Base\Newsletter\Message\SendCampaignMessage: async
Then bin/console doctrine:migrations:diff && bin/console doctrine:migrations:migrate
and bin/console assets:install (the stylesheet lives in public/css/newsletter.css).
A worker sending letters builds absolute links without a request: set
framework.router.default_uri (e.g. '%env(DEFAULT_URI)%'), or the links
of the mails point at localhost.
Routes
| Route | Path | |
|---|---|---|
newsletter_index |
GET /newsletter |
a page with the form and what to expect, in the sitemap |
newsletter_subscribe |
POST /newsletter |
the form; back to the page it was filled on, #newsletter, with a flash |
newsletter_confirm |
GET /newsletter/confirm/{token} |
the confirmation mail's link |
newsletter_unsubscribe |
GET|POST /newsletter/unsubscribe/{token} |
GET: a page with one button; POST: unsubscribed |
Double opt-in
- The visitor fills the form. A trap field (
website) catches the robots that fill everything, a signed timestamp (opened_at, an HMAC with the kernel's secret) the ones that send faster thanmin_delay, the flood interval the second sign-up from one address, and the rate limiternewsletter_subscribe(5 an hour per visitor, declared by the bundle whensymfony/rate-limiteris there; a host's ownframework.rate_limiter.newsletter_subscribeoverrides it). - The answer is the same whatever the address: "check your inbox". It never says whether the address was already on the list. Only an address that has not confirmed receives a mail.
- The mail's link (
newsletter_confirm) puts it on the list. An old link of an address that left since does not bring it back. - Every letter carries a link to
newsletter_unsubscribeand the headersList-Unsubscribe: <…>andList-Unsubscribe-Post: List-Unsubscribe=One-Click(RFC 8058): the mail client's own "unsubscribe" button POSTs to it. The link itself opens a page with one button - mail scanners follow links, a GET never unsubscribes anyone.
With double_opt_in: false the address is on the list at once.
The mails
The confirmation goes through omnibase's notifier (send('email')), from the
site's address. A letter does not: omnibase's Notification builds its
e-mail itself and offers no way to add a header, so letters are
TemplatedEmails sent with Symfony's MailerInterface, from the same
address (NotifierInterface::getTechnicalRecipient()), named after
from_name when it is set. With the mailer routed to Messenger, each mail is
queued too.
Message\SendCampaignMessage is handled by MessageHandler\SendCampaignHandler:
one mail per confirmed subscriber (of the letter's language when it has one),
each remembered on the subscriber as it goes so a retried send does not write
twice to the same address; then the letter is sent.
The monthly digest
A service implementing Base\Newsletter\Digest\DigestSourceInterface is
found by itself (tag newsletter.digest_source):
final class ConcertDigest implements DigestSourceInterface { public function getName(): string { return 'Concerts'; } public function digest(\DateTimeImmutable $from, \DateTimeImmutable $to, string $locale): array { return array_map(fn (Concert $c) => [ 'title' => $c->getTitle(), 'text' => $c->getVenue(), 'url' => $c->getUrl(), 'date' => $c->getDate(), ], $this->concerts->findBetween($from, $to)); } }
bin/console newsletter:draft-digest [--from=Y-m-d] [--to=Y-m-d] [--locale=de]
asks every source for the coming month (by default) and writes a DRAFT
campaign - "Your dates in November 2026" - with a part per source, its
entries in date order. Nothing is sent: it waits in the back office to be
read and sent. A cron on the 20th of each month is the usual way.
What the host provides
The templates extend layout1.html.twig and fill title, description,
content and stylesheets. The form goes in a footer with
{% include '@Newsletter/client/_form.html.twig' %}
{% include '@Newsletter/client/_form.html.twig' with {source: 'home', heading: false} %}
and two Twig functions help: newsletter_form(source = 'site') (a FormView)
and newsletter_count(locale = null) (the confirmed subscribers).
Every template is overridden the Symfony way, from
templates/bundles/NewsletterBundle/ - the mails too:
templates/bundles/NewsletterBundle/email/campaign.html.twig gets
subject, campaign, link, site_title (base.settings.title),
locale, unsubscribe, home, test.
The back office gets a Campaign CRUD (send me a test, send - with a
confirmation), a Subscriber CRUD (filters confirmed / left, language,
source; CSV export of the confirmed addresses) and a dashboard widget,
newsletter_subscribers.
Tests
vendor/bin/phpunit
The unit tests (tests/Entity, tests/Service) need no kernel.