Skip to content

Releases: goldnead/statamic-notifications

1.12.0

Choose a tag to compare

@goldnead goldnead released this 17 Oct 18:25

Changed

  • Settings: the addon now declares settingsNavParent() ([section, name] of its own Control Panel navigation entry). With statamic-brand-context 1.16 or newer its settings show as the sub-item "Einstellungen" under that entry; addons without a navigation of their own share one "Addon-Suite" page under Settings. Older brand-context versions ignore the method and keep the previous layout.

Upgrading

  • No migration, no new config key.

1.11.0

Choose a tag to compare

@goldnead goldnead released this 14 Oct 22:04

Added: deferred delivery queue

  • Notifications::notifyDeferred() writes the notification and one notification_deliveries row per channel in a single transaction; php artisan notifications:dispatch-pending sends them later, with retry and exponential backoff (notifications.deferred.max_attempts, backoff_seconds). The addon schedules nothing; the host runs the command.
  • Only channels implementing DeferredChannel (in-app and mail) can carry a deferred row; recipients are resolved at send time through DeferredRecipientResolver. Outcomes are accepted, skipped, retry, cancelled or failed.
  • dispatchPending counts a row as claimed once the dispatcher took it under its lock.
  • New migration notification_deliveries.

1.10.1

Choose a tag to compare

@goldnead goldnead released this 25 Sep 17:48

Fixed

  • [fix] The preference page no longer offers "Lead zugewiesen" and "Follow-up fällig" to subscribers. Both types now apply only to super users and users with the view leadhub permission. On a site where every subscriber has an account (ChoirLive) the old line, "has an account", let them through. Sending is unchanged.

1.10.0

Choose a tag to compare

@goldnead goldnead released this 22 Sep 20:49

Fixed: the weekly digest arrived every week with nothing in it

The guard against an empty digest existed and sat in the right place, and it still let every
single weekly mail through. It asked whether there were any items and whether any source had
contributed something — and the bundled LeadHub source contributed the same thing forever. It
counted follow-ups that were overdue and not yet completed, which is a state, not an event. One
forgotten follow-up was therefore news again on Monday, and the Monday after that. The digest was
never empty, so it was never skipped, and what arrived was a mail whose body said "Nothing new
this time" and then printed {"leadhub":{"overdue_followups":2}} in a <pre> block underneath.

Both halves are fixed, because either one alone still leaves a broken mail. A source is now handed
the end of the last digest this recipient actually received, and counts only what became overdue
since then — read from the existing notification_digest_runs rows, so there is no new column and
no migration. And what reaches the mail is a finished sentence under a new line key, so the
template has something to print rather than something to encode.

Anything without a line now contributes nothing — not a row in the body, and not a reason to
send the digest at all. That is the general form of the fix and the part that matters most: a
source this package has never heard of can no longer force an empty mail out of the door by
reporting a permanent state. It is also what keeps the change safe. DigestSource::collect() still
returns array and still takes the same three arguments, so a source written against the older
shape keeps loading and keeps running. It simply stops contributing until it says something in
words. Narrowing the return type instead would have been a compile-time fatal in every
implementation in the field, unreachable by any try/catch here, and it would have taken the
whole digest run down with it.

Second argument names what it always should have: $since, the end of the last digest, not the
start of the window. A source that ignores it and reports everything currently open brings the
original bug back with it.

The placeholder line also stops arguing with the rest of the mail: "Nothing new this time" now
appears only when the sources have nothing either.

Fixed: and no second mail that says exactly what the first one said

Empty is not the only way to have nothing to report. A source that answers with a state — three
open tasks, four upcoming events — has content every week and news only sometimes, and the window
check cannot tell those apart, because every window is new. The result is a mail that arrives every
Monday listing the same three tasks. It has content. It reports nothing.

So a digest is now also compared against what this recipient was last actually sent. Word for word
the same, and it does not go. The run reports it: Skipped: 0 empty, 1 unchanged since the last one, ….

The comparison is a SHA-256 over what the digest says — the items it carries and the sentence
each source wrote — never over the rendered mail. A hash of the HTML would tie the guarantee to the
template, and changing a colour would post one more empty-handed digest to everybody.

Items go in by id, not by their wording. Two separate mentions read identically ("hat dich
erwähnt.") and are not the same news; collapsing them would silence a real notification, which is
worse than the bug being fixed here. So a digest that carries any item at all is always new, and
what this gate really catches is the case it was built for: sources alone, repeating themselves.

The fingerprint is written only after a mail has really been delivered. A run that was recorded
and then failed to send leaves it untouched, so a broken sender identity cannot swallow the next
real digest on top of everything else it already costs.

New column notification_digest_runs.content_fingerprint, nullable, filled from the moment a
digest is delivered. php artisan migrate adds it; installs that never send a digest never fill
it.

Also fixed on the way past: notifications:send-digests kept its count of undelivered digests
between invocations. One process per run on a cron, so it never showed there — but anything calling
the command twice in one process reported the second, healthy run as failed.

A published view keeps working. $extras is still keyed by source handle and still holds
whatever that source returned, so a copy of
resources/views/vendor/notifications/mail/digest.blade.php that reaches into the payload — the
way adriangoldner.com builds its event list — needs no change at all. Only the shipped fallback
was rewritten, from the json_encode() block to the source's own sentence. If your copy still
holds that block, {{ $extra['line'] }} replaces it.

1.9.0

Choose a tag to compare

@goldnead goldnead released this 15 Sep 20:03

New: the detail page shows the mail that went out

Until now a notification's detail page said who was sent what — not how it arrived at the
recipient. Now the mail itself hangs below it, with this row's values inserted.

Not a single line of recipient text is stored in the process. What goes into the shared snapshot
layer (email_template_snapshots in statamic-email-templates) is the mail layout with
{{ title }}, {{ body }} and {{ link }} as placeholders — the template as it stood at the
time of sending, plus the subject and the sender it was really sent under. Insertion happens only
when it is viewed, and the result is not stored. That is why this row needs no retention period
and no deletion concept.

A new column notification_items.email_template_snapshot_id points at the version this row
received. Ten thousand mails of the same kind share one snapshot row; if the layout is changed, a
new one comes into being and the old one stays.

For rows without a mail — in_app and every short-message channel a host registers — nothing is
recorded, and the page says so. A short message has no layout; its "template" would be nothing but
the text to one particular person. That text already stands in the row and is shown there.

Without statamic-email-templates installed, or with it switched off, the section is absent.

New: settings in the Control Panel

Five values are changeable per brand under Addon Settings: the main switch, the list length, the
digest's default frequency, the address of the settings page and the realtime signal. Whatever has
not been touched still follows config/notifications.php.

Not on the page: cp.enabled and sources.leadhub are read at boot and would take effect only at
the next deploy; channels are class names; realtime.channel_prefix is part of the contract with
the client. The group texts say so.

New permission: manage notifications settings. Nobody holds it at first, and until it is assigned
to a role the section stays invisible, even for users who may do everything else in this addon. The
existing permissions stay unchanged.

Requires goldnead/statamic-brand-context 1.13 or later. Older versions carry the page but do
not apply its values reliably. On an installation with a single brand the settings of the addons
registered last were not laid onto the config at all — when this was measured in the playground on
09-07, notifications was one of them: after a reload the page showed the stored value while what
was read was the package default. On top of that, up to 1.12 a second save of the same section
deleted the first save's override without a message. If you set values between 09-06 and this
update, check after updating whether they are still there.

1.8.0

Choose a tag to compare

@goldnead goldnead released this 04 Sep 13:38

Neu: die Zahlen dieses Addons erscheinen in Insights

statamic-insights ist ab 1.1.0 keine Umsatzauswertung mehr, sondern die Auswertungs-Schicht der
Familie: jedes Addon meldet an, was es zählen kann, und bekommt dafür Zeitraum, Vergleich mit dem
Vorzeitraum, Diagramm, Aufteilungen und zwei fertige Schirme.

Die Kopplung ist in beide Richtungen freiwillig. Ohne Insights fehlt hier nichts; ohne dieses
Addon fehlt dort nur seine Gruppe. suggest, nie require.

Jede Zahl hält sich an die Hausregeln des Vertrags: null ist nicht null (eine Quote ohne Nenner
hat keine Antwort und zeigt keine 0 %), available() entscheidet über die Existenz und nie über die
Daten, Lücken im Verlauf füllt Insights und nicht die Kennzahl, und ein Filter, den eine Zahl nicht
versteht, wird ignoriert statt zum Fehler.

Vier Zahlen: verschickt, gelesen, Leserate, Zusammenfassungen.

Die Leserate ist nicht „Gelesen geteilt durch Verschickt". In der Gelesen-Zahl stecken auch
ältere Meldungen, die erst in diesem Zeitraum geöffnet wurden; die Rate misst dagegen die Kohorte
des Zeitraums. Das steht in der Beschreibung, weil die zwei Kacheln nebeneinander sonst wie ein
Rechenfehler aussehen.

Die Zusammenfassungen rechnen auf einer zweiten Tabelle und erben die Marken-Bedingung mit.

Behoben: eine Zahl zählt nur noch die aktive Marke

Beim Bauen der Anbindung bekam diese Frage in der Familie vier verschiedene Antworten, und auf einem
Schirm nebeneinander ist das schlimmer als gar keine: eine Kachel zeigte den Umsatz dreier fremder
Marken, während die daneben korrekt filterte. Die Regel steht jetzt einmal in
TableMetric::brandScoped(), als Abschrift von BrandScope::apply(); hier wird nur noch die Spalte
genannt, und Zahl, Diagramm und jede Aufteilung verengen gemeinsam.

Ist keine Marke gewählt, liest die Kachel 0 und bleibt stehen. Ein Leser versteht eine Null;
eine verschwundene Kachel bemerkt er nicht.

1.7.0

Choose a tag to compare

@goldnead goldnead released this 04 Sep 13:38

Added — nur zeigen, was für diesen Menschen gelten kann

Eine frisch angemeldete Newsletter-Adresse ohne Community-Konto sah auf der
Selbstbedienungs-Seite vier Community-Zeilen und eine interne CRM-Zeile, jede
mit drei Kanälen: fünfzehn Kästchen, von denen kein einziges je gewirkt
hätte.
Der Grund war einfach — die Matrix listete jede registrierte Art mal
jeden konfigurierten Kanal und fragte nie, ob das für den Betrachter überhaupt
in Frage kommt.

Eine Einstellung anzubieten, die nichts bewirken kann, ist schlimmer als keine:
sie sieht aus wie eine Wahl.

Zwei neue Angaben an einer Art, beide freiwillig und beide ohne Wirkung auf
bestehende Typen:

  • [new] appliesTo(Closure) — für wen die Art überhaupt in Frage kommt. Wer
    nein sagt, sieht sie gar nicht; keine ausgegraute Zeile, keine Erklärung.
    Eine Zeile, die nicht gelten kann, ist kein Hinweis, sondern Rauschen. Wirft
    die Prüfung, gilt die Art als nicht anwendbar — im Zweifel verbergen.
  • [new] supportedChannels(array) — welche Kanäle die Art überhaupt anbieten
    darf. Der Unterschied zu defaultChannels(): dort steht, was voreingestellt
    an ist, hier was zur Wahl steht.

Changed — ein nicht unterstützter Kanal ist auch beim Versand zu

allows() prüft supportedChannels vor der required-Ausnahme. Sonst
ließen genau die Arten, die niemand abschalten darf, den einen Weg offen, der
nicht gemeint war — und es käme Post über einen Kanal, den auf der Seite
niemand wählen konnte.

1.6.0

Choose a tag to compare

@goldnead goldnead released this 04 Sep 13:38

Changed

  • The five sender-identity classes moved to goldnead/statamic-brand-context 1.8.0, which is
    now required at ^1.8. They were four byte-identical copies with four namespaces — this package,
    marketing, preference-center and automations each grew their own on 12.08.2026 — and copies drift:
    by the evening the marketing one had stopped refusing a transport without an address. Which
    address a brand sends under is a property of the brand, so the rule lives with the brand.

    Behaviour is unchanged here, down to the log lines. Goldnead\Notifications\Contracts\SenderIdentityResolver
    and Sending\BrandMailer stay as this package's own extension points, so a host can still answer
    "who does a notification come from" separately from "who does marketing post come from".
    Sending\SenderIdentity and Sending\SaidRecently are gone from this namespace; use the
    Goldnead\BrandContext\Sending\ versions.

1.5.0

Choose a tag to compare

@goldnead goldnead released this 04 Sep 13:38

Fixed — every brand sent as whoever the process had sent as first

MailChannel and notifications:send-digests both ended in Mail::to(...)->send(...). That is
the process-wide default mailer with the process-wide mail.from, identically for every brand.
A host serving several brands out of one queue worker — or one digest run walking its brand list —
therefore delivered the second brand's mail under the first brand's name and address, and a relay
that verifies sending domains per account (Scaleway TEM, Postmark, SES) answers that by refusing
the message or rewriting the From to whichever identity the shared account owns. Either way the
recipient hears from the wrong company, and nothing turns red.

Who a notification goes out as is now resolved per message, from values, and never from config.
Contracts\SenderIdentityResolver answers "which mailer, which From, which locale for brand N";
Sending\BrandMailer is the single door both send paths leave through, and it puts the answer on
the message (Mailable::from(), Mail::mailer($name)).

Not on the config, deliberately: Laravel reads mail.from the first time a mailer name is resolved,
burns it into that mailer instance via alwaysFrom(), and caches the instance in the mail.manager
singleton. A scoped Config::set therefore escapes its own finally — whichever brand sends first
leaves its address standing for the rest of the process. That is the same bug one layer down, and
it is why nothing here writes to mail.*.

Fixed — the digest could burn a recipient's window without sending anything

DigestBuilder::markSent() stamps digested_at on every collected item before the mail leaves.
A brand that cannot produce a usable sender identity would have had each recipient's whole window
marked as delivered with nothing delivered, and the items would never have resurfaced. The check
now runs in front of the recipient loop: the brand is skipped, nothing is collected, nothing is
stamped, and everything stays pending until the settings are fixed.

Changed — a brand that declares a broken mail identity sends nothing

Read from brands.settings.mail:

key meaning
from_address required once mail is present at all
from_name defaults to the brand name
mailer a mailer from config/mail.php
locale the language its mail is written in

A brand that declares mail without from_address, or names a mailer that config/mail.php
does not define, now sends nothing and says so at error level (throttled per brand, so a
fan-out cannot bury it). The alternative was delivering under the host-wide From, which on a
multi-brand host is somebody else's identity — quietly.

A single-brand install is unchanged, and that is covered by tests rather than intent. So is a
multi-brand install whose brands carry no settings.mail: no brand, or no mail settings, resolves
to the config identity and the send is byte for byte the one it always was. A host that keeps
sender identities somewhere else rebinds SenderIdentityResolver in its own provider instead of
patching this package.

v1.4.1

Choose a tag to compare

@goldnead goldnead released this 09 Aug 12:34

Fixed — the sibling constraint excluded the new majors

goldnead/statamic-leadhub was pinned to ^1.4 to the 1.x line. LeadHub 2.0.0 and Marketing 2.0.0 carry no code change over 1.12.2
and 1.13.0 — that major is the licence switch alone. A site running both this package and an
updated sibling could not resolve its dependencies at all. The constraints now accept both
lines.