Add plugin-extensible notification API (#7775)

This commit is contained in:
Thomas Vincent authored and GitHub committed 2026-08-21 14:29:33 -07:00
1 parent a8e1afba73
commit 4d021a93e3
16 files changed
+1153 -3

No files matched your search

@@ -0,0 +1,72 @@
name: Notification API Coverage
on:
push:
branches: [develop]
paths:
- '.github/workflows/notification-api-coverage.yml'
- '.github/actions/setup-php-composer/**'
- 'composer.json'
- 'composer.lock'
- 'lib/CactiEmailChannel.php'
- 'lib/CactiNotification.php'
- 'lib/CactiRecipient.php'
- 'lib/api_notification.php'
- 'phpunit-notification-coverage.xml'
- 'tests/Unit/Core/CactiNotificationApiTest.php'
pull_request:
branches: [develop]
paths:
- '.github/workflows/notification-api-coverage.yml'
- '.github/actions/setup-php-composer/**'
- 'composer.json'
- 'composer.lock'
- 'lib/CactiEmailChannel.php'
- 'lib/CactiNotification.php'
- 'lib/CactiRecipient.php'
- 'lib/api_notification.php'
- 'phpunit-notification-coverage.xml'
- 'tests/Unit/Core/CactiNotificationApiTest.php'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: notification-api-coverage-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
COMPOSER_ALLOW_SUPERUSER: 1
jobs:
coverage:
name: Notification API 100% coverage
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- name: Setup locked PHP 8.1 dependencies
uses: ./.github/actions/setup-php-composer
with:
php-version: '8.1'
coverage: xdebug
extensions: intl, mysql, gd, ldap, gmp, xml, curl, json, mbstring
dependency-mode: locked-notification-api-coverage
install-args: --prefer-dist --no-progress --no-interaction
- name: Enforce line coverage
env:
XDEBUG_MODE: coverage
run: |
set -euo pipefail
include/vendor/bin/pest \
--configuration=phpunit-notification-coverage.xml \
--coverage \
--coverage-text \
--min=100
+1
View File
@@ -59,6 +59,7 @@
"symfony/filesystem": "^6.4",
"symfony/http-foundation": "^6.4",
"symfony/mailer": "^6.4",
"symfony/notifier": "^6.4",
"symfony/validator": "^6.4"
},
"require-dev": {
Generated
+85 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "af5d264d94fc6720a43169e60d4d650e",
"content-hash": "6795c9d58ef8594663378567eaf66aa1",
"packages": [
{
"name": "doctrine/lexer",
@@ -2534,6 +2534,90 @@
],
"time": "2026-07-29T07:59:11+00:00"
},
{
"name": "symfony/notifier",
"version": "v6.4.34",
"source": {
"type": "git",
"url": "https://github.com/symfony/notifier.git",
"reference": "16a6179e8e6d6e3c71e592e59cdd649f86f1a6f9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/notifier/zipball/16a6179e8e6d6e3c71e592e59cdd649f86f1a6f9",
"reference": "16a6179e8e6d6e3c71e592e59cdd649f86f1a6f9",
"shasum": ""
},
"require": {
"php": ">=8.1",
"psr/log": "^1|^2|^3"
},
"conflict": {
"symfony/event-dispatcher": "<5.4",
"symfony/event-dispatcher-contracts": "<2.5",
"symfony/http-client-contracts": "<2.5",
"symfony/http-kernel": "<5.4"
},
"require-dev": {
"symfony/event-dispatcher": "^6.4|^7.0",
"symfony/event-dispatcher-contracts": "^2.5|^3",
"symfony/http-client": "^6.4|^7.0",
"symfony/http-client-contracts": "^2.5|^3",
"symfony/http-foundation": "^5.4|^6.0|^7.0",
"symfony/messenger": "^5.4|^6.0|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Notifier\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Sends notifications via one or more channels (email, SMS, ...)",
"homepage": "https://symfony.com",
"keywords": [
"notification",
"notifier"
],
"support": {
"source": "https://github.com/symfony/notifier/tree/v6.4.34"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-02-18T07:27:25+00:00"
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.37.0",
+83
View File
@@ -0,0 +1,83 @@
# Notification API
Cacti provides a plugin-extensible notification API backed by Symfony
Notifier. The API uses Cacti's existing mail settings for the built-in email
channel, including native mail, sendmail, SMTP, and OAuth2 delivery.
This is an internal PHP API for Cacti core and plugins. It is not an HTTP
endpoint.
## Send a notification
Load the API and pass a subject, content, and one or more recipients:
```php
require_once(CACTI_PATH_LIBRARY . '/api_notification.php');
$sent = api_notification_send(
'Device unavailable',
'The core router is not responding.',
'Operations <ops@example.com>'
);
```
The recipient argument accepts the same email forms as Cacti's mailer, a
Symfony `RecipientInterface`, or a list of Symfony recipients. The function
returns `true` when every selected channel accepts every recipient. Delivery
errors are logged to the `NOTIFIER` facility and return `false`.
Email-specific options preserve the capabilities of Cacti's mailer:
```php
use Symfony\Component\Notifier\Notification\Notification;
api_notification_send(
'Nightly report',
'<h1>Report complete</h1>',
'reports@example.com',
options: [
'importance' => Notification::IMPORTANCE_LOW,
'email' => [
'html' => true,
'text' => 'Report complete',
'cc' => 'audit@example.com',
'reply_to' => 'noreply@example.com',
'attachments' => $attachments,
'headers' => ['X-Cacti-Source' => 'reports'],
],
]
);
```
Supported email options are `from`, `cc`, `bcc`, `reply_to`, `html`, `text`,
`attachments`, `headers`, and `expand_ids`.
## Add a plugin channel
Plugins can register the `notification_channels` hook. The hook receives and
must return an array keyed by channel name. Each value must implement Symfony's
`ChannelInterface`:
```php
function plugin_name_notification_channels(array $channels) : array {
$channels['chat'] = new PluginChatChannel();
return $channels;
}
```
Call the API with the registered channel name and an appropriate Symfony
recipient:
```php
api_notification_send(
'Device unavailable',
'The core router is not responding.',
$recipient,
['email', 'chat']
);
```
Channel implementations control their own credentials and configuration.
Secrets must remain in Cacti settings or another protected runtime store, not
in notification content or source files.
+78
View File
@@ -799,6 +799,84 @@ return array(
'Symfony\\Component\\Mime\\Test\\Constraint\\EmailHtmlBodyContains' => $vendorDir . '/symfony/mime/Test/Constraint/EmailHtmlBodyContains.php',
'Symfony\\Component\\Mime\\Test\\Constraint\\EmailSubjectContains' => $vendorDir . '/symfony/mime/Test/Constraint/EmailSubjectContains.php',
'Symfony\\Component\\Mime\\Test\\Constraint\\EmailTextBodyContains' => $vendorDir . '/symfony/mime/Test/Constraint/EmailTextBodyContains.php',
'Symfony\\Component\\Notifier\\Channel\\AbstractChannel' => $vendorDir . '/symfony/notifier/Channel/AbstractChannel.php',
'Symfony\\Component\\Notifier\\Channel\\BrowserChannel' => $vendorDir . '/symfony/notifier/Channel/BrowserChannel.php',
'Symfony\\Component\\Notifier\\Channel\\ChannelInterface' => $vendorDir . '/symfony/notifier/Channel/ChannelInterface.php',
'Symfony\\Component\\Notifier\\Channel\\ChannelPolicy' => $vendorDir . '/symfony/notifier/Channel/ChannelPolicy.php',
'Symfony\\Component\\Notifier\\Channel\\ChannelPolicyInterface' => $vendorDir . '/symfony/notifier/Channel/ChannelPolicyInterface.php',
'Symfony\\Component\\Notifier\\Channel\\ChatChannel' => $vendorDir . '/symfony/notifier/Channel/ChatChannel.php',
'Symfony\\Component\\Notifier\\Channel\\EmailChannel' => $vendorDir . '/symfony/notifier/Channel/EmailChannel.php',
'Symfony\\Component\\Notifier\\Channel\\PushChannel' => $vendorDir . '/symfony/notifier/Channel/PushChannel.php',
'Symfony\\Component\\Notifier\\Channel\\SmsChannel' => $vendorDir . '/symfony/notifier/Channel/SmsChannel.php',
'Symfony\\Component\\Notifier\\Chatter' => $vendorDir . '/symfony/notifier/Chatter.php',
'Symfony\\Component\\Notifier\\ChatterInterface' => $vendorDir . '/symfony/notifier/ChatterInterface.php',
'Symfony\\Component\\Notifier\\DataCollector\\NotificationDataCollector' => $vendorDir . '/symfony/notifier/DataCollector/NotificationDataCollector.php',
'Symfony\\Component\\Notifier\\EventListener\\NotificationLoggerListener' => $vendorDir . '/symfony/notifier/EventListener/NotificationLoggerListener.php',
'Symfony\\Component\\Notifier\\EventListener\\SendFailedMessageToNotifierListener' => $vendorDir . '/symfony/notifier/EventListener/SendFailedMessageToNotifierListener.php',
'Symfony\\Component\\Notifier\\Event\\FailedMessageEvent' => $vendorDir . '/symfony/notifier/Event/FailedMessageEvent.php',
'Symfony\\Component\\Notifier\\Event\\MessageEvent' => $vendorDir . '/symfony/notifier/Event/MessageEvent.php',
'Symfony\\Component\\Notifier\\Event\\NotificationEvents' => $vendorDir . '/symfony/notifier/Event/NotificationEvents.php',
'Symfony\\Component\\Notifier\\Event\\SentMessageEvent' => $vendorDir . '/symfony/notifier/Event/SentMessageEvent.php',
'Symfony\\Component\\Notifier\\Exception\\ExceptionInterface' => $vendorDir . '/symfony/notifier/Exception/ExceptionInterface.php',
'Symfony\\Component\\Notifier\\Exception\\FlashMessageImportanceMapperException' => $vendorDir . '/symfony/notifier/Exception/FlashMessageImportanceMapperException.php',
'Symfony\\Component\\Notifier\\Exception\\IncompleteDsnException' => $vendorDir . '/symfony/notifier/Exception/IncompleteDsnException.php',
'Symfony\\Component\\Notifier\\Exception\\InvalidArgumentException' => $vendorDir . '/symfony/notifier/Exception/InvalidArgumentException.php',
'Symfony\\Component\\Notifier\\Exception\\LengthException' => $vendorDir . '/symfony/notifier/Exception/LengthException.php',
'Symfony\\Component\\Notifier\\Exception\\LogicException' => $vendorDir . '/symfony/notifier/Exception/LogicException.php',
'Symfony\\Component\\Notifier\\Exception\\MissingRequiredOptionException' => $vendorDir . '/symfony/notifier/Exception/MissingRequiredOptionException.php',
'Symfony\\Component\\Notifier\\Exception\\MultipleExclusiveOptionsUsedException' => $vendorDir . '/symfony/notifier/Exception/MultipleExclusiveOptionsUsedException.php',
'Symfony\\Component\\Notifier\\Exception\\RuntimeException' => $vendorDir . '/symfony/notifier/Exception/RuntimeException.php',
'Symfony\\Component\\Notifier\\Exception\\TransportException' => $vendorDir . '/symfony/notifier/Exception/TransportException.php',
'Symfony\\Component\\Notifier\\Exception\\TransportExceptionInterface' => $vendorDir . '/symfony/notifier/Exception/TransportExceptionInterface.php',
'Symfony\\Component\\Notifier\\Exception\\UnsupportedMessageTypeException' => $vendorDir . '/symfony/notifier/Exception/UnsupportedMessageTypeException.php',
'Symfony\\Component\\Notifier\\Exception\\UnsupportedSchemeException' => $vendorDir . '/symfony/notifier/Exception/UnsupportedSchemeException.php',
'Symfony\\Component\\Notifier\\FlashMessage\\AbstractFlashMessageImportanceMapper' => $vendorDir . '/symfony/notifier/FlashMessage/AbstractFlashMessageImportanceMapper.php',
'Symfony\\Component\\Notifier\\FlashMessage\\BootstrapFlashMessageImportanceMapper' => $vendorDir . '/symfony/notifier/FlashMessage/BootstrapFlashMessageImportanceMapper.php',
'Symfony\\Component\\Notifier\\FlashMessage\\DefaultFlashMessageImportanceMapper' => $vendorDir . '/symfony/notifier/FlashMessage/DefaultFlashMessageImportanceMapper.php',
'Symfony\\Component\\Notifier\\FlashMessage\\FlashMessageImportanceMapperInterface' => $vendorDir . '/symfony/notifier/FlashMessage/FlashMessageImportanceMapperInterface.php',
'Symfony\\Component\\Notifier\\Message\\ChatMessage' => $vendorDir . '/symfony/notifier/Message/ChatMessage.php',
'Symfony\\Component\\Notifier\\Message\\EmailMessage' => $vendorDir . '/symfony/notifier/Message/EmailMessage.php',
'Symfony\\Component\\Notifier\\Message\\FromNotificationInterface' => $vendorDir . '/symfony/notifier/Message/FromNotificationInterface.php',
'Symfony\\Component\\Notifier\\Message\\MessageInterface' => $vendorDir . '/symfony/notifier/Message/MessageInterface.php',
'Symfony\\Component\\Notifier\\Message\\MessageOptionsInterface' => $vendorDir . '/symfony/notifier/Message/MessageOptionsInterface.php',
'Symfony\\Component\\Notifier\\Message\\NullMessage' => $vendorDir . '/symfony/notifier/Message/NullMessage.php',
'Symfony\\Component\\Notifier\\Message\\PushMessage' => $vendorDir . '/symfony/notifier/Message/PushMessage.php',
'Symfony\\Component\\Notifier\\Message\\SentMessage' => $vendorDir . '/symfony/notifier/Message/SentMessage.php',
'Symfony\\Component\\Notifier\\Message\\SmsMessage' => $vendorDir . '/symfony/notifier/Message/SmsMessage.php',
'Symfony\\Component\\Notifier\\Messenger\\MessageHandler' => $vendorDir . '/symfony/notifier/Messenger/MessageHandler.php',
'Symfony\\Component\\Notifier\\Notification\\ChatNotificationInterface' => $vendorDir . '/symfony/notifier/Notification/ChatNotificationInterface.php',
'Symfony\\Component\\Notifier\\Notification\\EmailNotificationInterface' => $vendorDir . '/symfony/notifier/Notification/EmailNotificationInterface.php',
'Symfony\\Component\\Notifier\\Notification\\Notification' => $vendorDir . '/symfony/notifier/Notification/Notification.php',
'Symfony\\Component\\Notifier\\Notification\\PushNotificationInterface' => $vendorDir . '/symfony/notifier/Notification/PushNotificationInterface.php',
'Symfony\\Component\\Notifier\\Notification\\SmsNotificationInterface' => $vendorDir . '/symfony/notifier/Notification/SmsNotificationInterface.php',
'Symfony\\Component\\Notifier\\Notifier' => $vendorDir . '/symfony/notifier/Notifier.php',
'Symfony\\Component\\Notifier\\NotifierInterface' => $vendorDir . '/symfony/notifier/NotifierInterface.php',
'Symfony\\Component\\Notifier\\Recipient\\EmailRecipientInterface' => $vendorDir . '/symfony/notifier/Recipient/EmailRecipientInterface.php',
'Symfony\\Component\\Notifier\\Recipient\\EmailRecipientTrait' => $vendorDir . '/symfony/notifier/Recipient/EmailRecipientTrait.php',
'Symfony\\Component\\Notifier\\Recipient\\NoRecipient' => $vendorDir . '/symfony/notifier/Recipient/NoRecipient.php',
'Symfony\\Component\\Notifier\\Recipient\\Recipient' => $vendorDir . '/symfony/notifier/Recipient/Recipient.php',
'Symfony\\Component\\Notifier\\Recipient\\RecipientInterface' => $vendorDir . '/symfony/notifier/Recipient/RecipientInterface.php',
'Symfony\\Component\\Notifier\\Recipient\\SmsRecipientInterface' => $vendorDir . '/symfony/notifier/Recipient/SmsRecipientInterface.php',
'Symfony\\Component\\Notifier\\Recipient\\SmsRecipientTrait' => $vendorDir . '/symfony/notifier/Recipient/SmsRecipientTrait.php',
'Symfony\\Component\\Notifier\\Test\\Constraint\\NotificationCount' => $vendorDir . '/symfony/notifier/Test/Constraint/NotificationCount.php',
'Symfony\\Component\\Notifier\\Test\\Constraint\\NotificationIsQueued' => $vendorDir . '/symfony/notifier/Test/Constraint/NotificationIsQueued.php',
'Symfony\\Component\\Notifier\\Test\\Constraint\\NotificationSubjectContains' => $vendorDir . '/symfony/notifier/Test/Constraint/NotificationSubjectContains.php',
'Symfony\\Component\\Notifier\\Test\\Constraint\\NotificationTransportIsEqual' => $vendorDir . '/symfony/notifier/Test/Constraint/NotificationTransportIsEqual.php',
'Symfony\\Component\\Notifier\\Test\\TransportFactoryTestCase' => $vendorDir . '/symfony/notifier/Test/TransportFactoryTestCase.php',
'Symfony\\Component\\Notifier\\Test\\TransportTestCase' => $vendorDir . '/symfony/notifier/Test/TransportTestCase.php',
'Symfony\\Component\\Notifier\\Texter' => $vendorDir . '/symfony/notifier/Texter.php',
'Symfony\\Component\\Notifier\\TexterInterface' => $vendorDir . '/symfony/notifier/TexterInterface.php',
'Symfony\\Component\\Notifier\\Transport' => $vendorDir . '/symfony/notifier/Transport.php',
'Symfony\\Component\\Notifier\\Transport\\AbstractTransport' => $vendorDir . '/symfony/notifier/Transport/AbstractTransport.php',
'Symfony\\Component\\Notifier\\Transport\\AbstractTransportFactory' => $vendorDir . '/symfony/notifier/Transport/AbstractTransportFactory.php',
'Symfony\\Component\\Notifier\\Transport\\Dsn' => $vendorDir . '/symfony/notifier/Transport/Dsn.php',
'Symfony\\Component\\Notifier\\Transport\\FailoverTransport' => $vendorDir . '/symfony/notifier/Transport/FailoverTransport.php',
'Symfony\\Component\\Notifier\\Transport\\NullTransport' => $vendorDir . '/symfony/notifier/Transport/NullTransport.php',
'Symfony\\Component\\Notifier\\Transport\\NullTransportFactory' => $vendorDir . '/symfony/notifier/Transport/NullTransportFactory.php',
'Symfony\\Component\\Notifier\\Transport\\RoundRobinTransport' => $vendorDir . '/symfony/notifier/Transport/RoundRobinTransport.php',
'Symfony\\Component\\Notifier\\Transport\\TransportFactoryInterface' => $vendorDir . '/symfony/notifier/Transport/TransportFactoryInterface.php',
'Symfony\\Component\\Notifier\\Transport\\TransportInterface' => $vendorDir . '/symfony/notifier/Transport/TransportInterface.php',
'Symfony\\Component\\Notifier\\Transport\\Transports' => $vendorDir . '/symfony/notifier/Transport/Transports.php',
'Symfony\\Component\\Validator\\Attribute\\HasNamedArguments' => $vendorDir . '/symfony/validator/Attribute/HasNamedArguments.php',
'Symfony\\Component\\Validator\\Command\\DebugCommand' => $vendorDir . '/symfony/validator/Command/DebugCommand.php',
'Symfony\\Component\\Validator\\Constraint' => $vendorDir . '/symfony/validator/Constraint.php',
+1
View File
@@ -17,6 +17,7 @@ return array(
'Symfony\\Contracts\\Service\\' => array($vendorDir . '/symfony/service-contracts'),
'Symfony\\Contracts\\EventDispatcher\\' => array($vendorDir . '/symfony/event-dispatcher-contracts'),
'Symfony\\Component\\Validator\\' => array($vendorDir . '/symfony/validator'),
'Symfony\\Component\\Notifier\\' => array($vendorDir . '/symfony/notifier'),
'Symfony\\Component\\Mime\\' => array($vendorDir . '/symfony/mime'),
'Symfony\\Component\\Mailer\\' => array($vendorDir . '/symfony/mailer'),
'Symfony\\Component\\HttpFoundation\\' => array($vendorDir . '/symfony/http-foundation'),
+83
View File
@@ -37,6 +37,7 @@ class ComposerStaticInit4457df976b6743d4cf060b8bfc645118
'Symfony\\Contracts\\Service\\' => 26,
'Symfony\\Contracts\\EventDispatcher\\' => 34,
'Symfony\\Component\\Validator\\' => 28,
'Symfony\\Component\\Notifier\\' => 27,
'Symfony\\Component\\Mime\\' => 23,
'Symfony\\Component\\Mailer\\' => 25,
'Symfony\\Component\\HttpFoundation\\' => 33,
@@ -139,6 +140,10 @@ class ComposerStaticInit4457df976b6743d4cf060b8bfc645118
array (
0 => __DIR__ . '/..' . '/symfony/validator',
),
'Symfony\\Component\\Notifier\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/notifier',
),
'Symfony\\Component\\Mime\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/mime',
@@ -1047,6 +1052,84 @@ class ComposerStaticInit4457df976b6743d4cf060b8bfc645118
'Symfony\\Component\\Mime\\Test\\Constraint\\EmailHtmlBodyContains' => __DIR__ . '/..' . '/symfony/mime/Test/Constraint/EmailHtmlBodyContains.php',
'Symfony\\Component\\Mime\\Test\\Constraint\\EmailSubjectContains' => __DIR__ . '/..' . '/symfony/mime/Test/Constraint/EmailSubjectContains.php',
'Symfony\\Component\\Mime\\Test\\Constraint\\EmailTextBodyContains' => __DIR__ . '/..' . '/symfony/mime/Test/Constraint/EmailTextBodyContains.php',
'Symfony\\Component\\Notifier\\Channel\\AbstractChannel' => __DIR__ . '/..' . '/symfony/notifier/Channel/AbstractChannel.php',
'Symfony\\Component\\Notifier\\Channel\\BrowserChannel' => __DIR__ . '/..' . '/symfony/notifier/Channel/BrowserChannel.php',
'Symfony\\Component\\Notifier\\Channel\\ChannelInterface' => __DIR__ . '/..' . '/symfony/notifier/Channel/ChannelInterface.php',
'Symfony\\Component\\Notifier\\Channel\\ChannelPolicy' => __DIR__ . '/..' . '/symfony/notifier/Channel/ChannelPolicy.php',
'Symfony\\Component\\Notifier\\Channel\\ChannelPolicyInterface' => __DIR__ . '/..' . '/symfony/notifier/Channel/ChannelPolicyInterface.php',
'Symfony\\Component\\Notifier\\Channel\\ChatChannel' => __DIR__ . '/..' . '/symfony/notifier/Channel/ChatChannel.php',
'Symfony\\Component\\Notifier\\Channel\\EmailChannel' => __DIR__ . '/..' . '/symfony/notifier/Channel/EmailChannel.php',
'Symfony\\Component\\Notifier\\Channel\\PushChannel' => __DIR__ . '/..' . '/symfony/notifier/Channel/PushChannel.php',
'Symfony\\Component\\Notifier\\Channel\\SmsChannel' => __DIR__ . '/..' . '/symfony/notifier/Channel/SmsChannel.php',
'Symfony\\Component\\Notifier\\Chatter' => __DIR__ . '/..' . '/symfony/notifier/Chatter.php',
'Symfony\\Component\\Notifier\\ChatterInterface' => __DIR__ . '/..' . '/symfony/notifier/ChatterInterface.php',
'Symfony\\Component\\Notifier\\DataCollector\\NotificationDataCollector' => __DIR__ . '/..' . '/symfony/notifier/DataCollector/NotificationDataCollector.php',
'Symfony\\Component\\Notifier\\EventListener\\NotificationLoggerListener' => __DIR__ . '/..' . '/symfony/notifier/EventListener/NotificationLoggerListener.php',
'Symfony\\Component\\Notifier\\EventListener\\SendFailedMessageToNotifierListener' => __DIR__ . '/..' . '/symfony/notifier/EventListener/SendFailedMessageToNotifierListener.php',
'Symfony\\Component\\Notifier\\Event\\FailedMessageEvent' => __DIR__ . '/..' . '/symfony/notifier/Event/FailedMessageEvent.php',
'Symfony\\Component\\Notifier\\Event\\MessageEvent' => __DIR__ . '/..' . '/symfony/notifier/Event/MessageEvent.php',
'Symfony\\Component\\Notifier\\Event\\NotificationEvents' => __DIR__ . '/..' . '/symfony/notifier/Event/NotificationEvents.php',
'Symfony\\Component\\Notifier\\Event\\SentMessageEvent' => __DIR__ . '/..' . '/symfony/notifier/Event/SentMessageEvent.php',
'Symfony\\Component\\Notifier\\Exception\\ExceptionInterface' => __DIR__ . '/..' . '/symfony/notifier/Exception/ExceptionInterface.php',
'Symfony\\Component\\Notifier\\Exception\\FlashMessageImportanceMapperException' => __DIR__ . '/..' . '/symfony/notifier/Exception/FlashMessageImportanceMapperException.php',
'Symfony\\Component\\Notifier\\Exception\\IncompleteDsnException' => __DIR__ . '/..' . '/symfony/notifier/Exception/IncompleteDsnException.php',
'Symfony\\Component\\Notifier\\Exception\\InvalidArgumentException' => __DIR__ . '/..' . '/symfony/notifier/Exception/InvalidArgumentException.php',
'Symfony\\Component\\Notifier\\Exception\\LengthException' => __DIR__ . '/..' . '/symfony/notifier/Exception/LengthException.php',
'Symfony\\Component\\Notifier\\Exception\\LogicException' => __DIR__ . '/..' . '/symfony/notifier/Exception/LogicException.php',
'Symfony\\Component\\Notifier\\Exception\\MissingRequiredOptionException' => __DIR__ . '/..' . '/symfony/notifier/Exception/MissingRequiredOptionException.php',
'Symfony\\Component\\Notifier\\Exception\\MultipleExclusiveOptionsUsedException' => __DIR__ . '/..' . '/symfony/notifier/Exception/MultipleExclusiveOptionsUsedException.php',
'Symfony\\Component\\Notifier\\Exception\\RuntimeException' => __DIR__ . '/..' . '/symfony/notifier/Exception/RuntimeException.php',
'Symfony\\Component\\Notifier\\Exception\\TransportException' => __DIR__ . '/..' . '/symfony/notifier/Exception/TransportException.php',
'Symfony\\Component\\Notifier\\Exception\\TransportExceptionInterface' => __DIR__ . '/..' . '/symfony/notifier/Exception/TransportExceptionInterface.php',
'Symfony\\Component\\Notifier\\Exception\\UnsupportedMessageTypeException' => __DIR__ . '/..' . '/symfony/notifier/Exception/UnsupportedMessageTypeException.php',
'Symfony\\Component\\Notifier\\Exception\\UnsupportedSchemeException' => __DIR__ . '/..' . '/symfony/notifier/Exception/UnsupportedSchemeException.php',
'Symfony\\Component\\Notifier\\FlashMessage\\AbstractFlashMessageImportanceMapper' => __DIR__ . '/..' . '/symfony/notifier/FlashMessage/AbstractFlashMessageImportanceMapper.php',
'Symfony\\Component\\Notifier\\FlashMessage\\BootstrapFlashMessageImportanceMapper' => __DIR__ . '/..' . '/symfony/notifier/FlashMessage/BootstrapFlashMessageImportanceMapper.php',
'Symfony\\Component\\Notifier\\FlashMessage\\DefaultFlashMessageImportanceMapper' => __DIR__ . '/..' . '/symfony/notifier/FlashMessage/DefaultFlashMessageImportanceMapper.php',
'Symfony\\Component\\Notifier\\FlashMessage\\FlashMessageImportanceMapperInterface' => __DIR__ . '/..' . '/symfony/notifier/FlashMessage/FlashMessageImportanceMapperInterface.php',
'Symfony\\Component\\Notifier\\Message\\ChatMessage' => __DIR__ . '/..' . '/symfony/notifier/Message/ChatMessage.php',
'Symfony\\Component\\Notifier\\Message\\EmailMessage' => __DIR__ . '/..' . '/symfony/notifier/Message/EmailMessage.php',
'Symfony\\Component\\Notifier\\Message\\FromNotificationInterface' => __DIR__ . '/..' . '/symfony/notifier/Message/FromNotificationInterface.php',
'Symfony\\Component\\Notifier\\Message\\MessageInterface' => __DIR__ . '/..' . '/symfony/notifier/Message/MessageInterface.php',
'Symfony\\Component\\Notifier\\Message\\MessageOptionsInterface' => __DIR__ . '/..' . '/symfony/notifier/Message/MessageOptionsInterface.php',
'Symfony\\Component\\Notifier\\Message\\NullMessage' => __DIR__ . '/..' . '/symfony/notifier/Message/NullMessage.php',
'Symfony\\Component\\Notifier\\Message\\PushMessage' => __DIR__ . '/..' . '/symfony/notifier/Message/PushMessage.php',
'Symfony\\Component\\Notifier\\Message\\SentMessage' => __DIR__ . '/..' . '/symfony/notifier/Message/SentMessage.php',
'Symfony\\Component\\Notifier\\Message\\SmsMessage' => __DIR__ . '/..' . '/symfony/notifier/Message/SmsMessage.php',
'Symfony\\Component\\Notifier\\Messenger\\MessageHandler' => __DIR__ . '/..' . '/symfony/notifier/Messenger/MessageHandler.php',
'Symfony\\Component\\Notifier\\Notification\\ChatNotificationInterface' => __DIR__ . '/..' . '/symfony/notifier/Notification/ChatNotificationInterface.php',
'Symfony\\Component\\Notifier\\Notification\\EmailNotificationInterface' => __DIR__ . '/..' . '/symfony/notifier/Notification/EmailNotificationInterface.php',
'Symfony\\Component\\Notifier\\Notification\\Notification' => __DIR__ . '/..' . '/symfony/notifier/Notification/Notification.php',
'Symfony\\Component\\Notifier\\Notification\\PushNotificationInterface' => __DIR__ . '/..' . '/symfony/notifier/Notification/PushNotificationInterface.php',
'Symfony\\Component\\Notifier\\Notification\\SmsNotificationInterface' => __DIR__ . '/..' . '/symfony/notifier/Notification/SmsNotificationInterface.php',
'Symfony\\Component\\Notifier\\Notifier' => __DIR__ . '/..' . '/symfony/notifier/Notifier.php',
'Symfony\\Component\\Notifier\\NotifierInterface' => __DIR__ . '/..' . '/symfony/notifier/NotifierInterface.php',
'Symfony\\Component\\Notifier\\Recipient\\EmailRecipientInterface' => __DIR__ . '/..' . '/symfony/notifier/Recipient/EmailRecipientInterface.php',
'Symfony\\Component\\Notifier\\Recipient\\EmailRecipientTrait' => __DIR__ . '/..' . '/symfony/notifier/Recipient/EmailRecipientTrait.php',
'Symfony\\Component\\Notifier\\Recipient\\NoRecipient' => __DIR__ . '/..' . '/symfony/notifier/Recipient/NoRecipient.php',
'Symfony\\Component\\Notifier\\Recipient\\Recipient' => __DIR__ . '/..' . '/symfony/notifier/Recipient/Recipient.php',
'Symfony\\Component\\Notifier\\Recipient\\RecipientInterface' => __DIR__ . '/..' . '/symfony/notifier/Recipient/RecipientInterface.php',
'Symfony\\Component\\Notifier\\Recipient\\SmsRecipientInterface' => __DIR__ . '/..' . '/symfony/notifier/Recipient/SmsRecipientInterface.php',
'Symfony\\Component\\Notifier\\Recipient\\SmsRecipientTrait' => __DIR__ . '/..' . '/symfony/notifier/Recipient/SmsRecipientTrait.php',
'Symfony\\Component\\Notifier\\Test\\Constraint\\NotificationCount' => __DIR__ . '/..' . '/symfony/notifier/Test/Constraint/NotificationCount.php',
'Symfony\\Component\\Notifier\\Test\\Constraint\\NotificationIsQueued' => __DIR__ . '/..' . '/symfony/notifier/Test/Constraint/NotificationIsQueued.php',
'Symfony\\Component\\Notifier\\Test\\Constraint\\NotificationSubjectContains' => __DIR__ . '/..' . '/symfony/notifier/Test/Constraint/NotificationSubjectContains.php',
'Symfony\\Component\\Notifier\\Test\\Constraint\\NotificationTransportIsEqual' => __DIR__ . '/..' . '/symfony/notifier/Test/Constraint/NotificationTransportIsEqual.php',
'Symfony\\Component\\Notifier\\Test\\TransportFactoryTestCase' => __DIR__ . '/..' . '/symfony/notifier/Test/TransportFactoryTestCase.php',
'Symfony\\Component\\Notifier\\Test\\TransportTestCase' => __DIR__ . '/..' . '/symfony/notifier/Test/TransportTestCase.php',
'Symfony\\Component\\Notifier\\Texter' => __DIR__ . '/..' . '/symfony/notifier/Texter.php',
'Symfony\\Component\\Notifier\\TexterInterface' => __DIR__ . '/..' . '/symfony/notifier/TexterInterface.php',
'Symfony\\Component\\Notifier\\Transport' => __DIR__ . '/..' . '/symfony/notifier/Transport.php',
'Symfony\\Component\\Notifier\\Transport\\AbstractTransport' => __DIR__ . '/..' . '/symfony/notifier/Transport/AbstractTransport.php',
'Symfony\\Component\\Notifier\\Transport\\AbstractTransportFactory' => __DIR__ . '/..' . '/symfony/notifier/Transport/AbstractTransportFactory.php',
'Symfony\\Component\\Notifier\\Transport\\Dsn' => __DIR__ . '/..' . '/symfony/notifier/Transport/Dsn.php',
'Symfony\\Component\\Notifier\\Transport\\FailoverTransport' => __DIR__ . '/..' . '/symfony/notifier/Transport/FailoverTransport.php',
'Symfony\\Component\\Notifier\\Transport\\NullTransport' => __DIR__ . '/..' . '/symfony/notifier/Transport/NullTransport.php',
'Symfony\\Component\\Notifier\\Transport\\NullTransportFactory' => __DIR__ . '/..' . '/symfony/notifier/Transport/NullTransportFactory.php',
'Symfony\\Component\\Notifier\\Transport\\RoundRobinTransport' => __DIR__ . '/..' . '/symfony/notifier/Transport/RoundRobinTransport.php',
'Symfony\\Component\\Notifier\\Transport\\TransportFactoryInterface' => __DIR__ . '/..' . '/symfony/notifier/Transport/TransportFactoryInterface.php',
'Symfony\\Component\\Notifier\\Transport\\TransportInterface' => __DIR__ . '/..' . '/symfony/notifier/Transport/TransportInterface.php',
'Symfony\\Component\\Notifier\\Transport\\Transports' => __DIR__ . '/..' . '/symfony/notifier/Transport/Transports.php',
'Symfony\\Component\\Validator\\Attribute\\HasNamedArguments' => __DIR__ . '/..' . '/symfony/validator/Attribute/HasNamedArguments.php',
'Symfony\\Component\\Validator\\Command\\DebugCommand' => __DIR__ . '/..' . '/symfony/validator/Command/DebugCommand.php',
'Symfony\\Component\\Validator\\Constraint' => __DIR__ . '/..' . '/symfony/validator/Constraint.php',
+87
View File
@@ -2639,6 +2639,93 @@
],
"install-path": "../symfony/mime"
},
{
"name": "symfony/notifier",
"version": "v6.4.34",
"version_normalized": "6.4.34.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/notifier.git",
"reference": "16a6179e8e6d6e3c71e592e59cdd649f86f1a6f9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/notifier/zipball/16a6179e8e6d6e3c71e592e59cdd649f86f1a6f9",
"reference": "16a6179e8e6d6e3c71e592e59cdd649f86f1a6f9",
"shasum": ""
},
"require": {
"php": ">=8.1",
"psr/log": "^1|^2|^3"
},
"conflict": {
"symfony/event-dispatcher": "<5.4",
"symfony/event-dispatcher-contracts": "<2.5",
"symfony/http-client-contracts": "<2.5",
"symfony/http-kernel": "<5.4"
},
"require-dev": {
"symfony/event-dispatcher": "^6.4|^7.0",
"symfony/event-dispatcher-contracts": "^2.5|^3",
"symfony/http-client": "^6.4|^7.0",
"symfony/http-client-contracts": "^2.5|^3",
"symfony/http-foundation": "^5.4|^6.0|^7.0",
"symfony/messenger": "^5.4|^6.0|^7.0"
},
"time": "2026-02-18T07:27:25+00:00",
"type": "library",
"installation-source": "dist",
"autoload": {
"psr-4": {
"Symfony\\Component\\Notifier\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Sends notifications via one or more channels (email, SMS, ...)",
"homepage": "https://symfony.com",
"keywords": [
"notification",
"notifier"
],
"support": {
"source": "https://github.com/symfony/notifier/tree/v6.4.34"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"install-path": "../symfony/notifier"
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.37.0",
+9
View File
@@ -388,6 +388,15 @@
'aliases' => array(),
'dev_requirement' => false,
),
'symfony/notifier' => array(
'pretty_version' => 'v6.4.34',
'version' => '6.4.34.0',
'reference' => '16a6179e8e6d6e3c71e592e59cdd649f86f1a6f9',
'type' => 'library',
'install_path' => __DIR__ . '/../symfony/notifier',
'aliases' => array(),
'dev_requirement' => false,
),
'symfony/polyfill-ctype' => array(
'pretty_version' => 'v1.37.0',
'version' => '1.37.0.0',
+93
View File
@@ -0,0 +1,93 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU General Public License for more details. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
use Symfony\Component\Notifier\Channel\ChannelInterface;
use Symfony\Component\Notifier\Exception\LogicException;
use Symfony\Component\Notifier\Exception\RuntimeException;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\Recipient\EmailRecipientInterface;
use Symfony\Component\Notifier\Recipient\RecipientInterface;
/**
* Delivers Symfony notifications through Cacti's configured mailer.
*/
final class CactiEmailChannel implements ChannelInterface {
private Closure $mailer;
public function __construct(?callable $mailer = null) {
if ($mailer === null) {
$mailer = static fn (...$arguments) : string => (string) call_user_func_array('mailer', $arguments);
}
$this->mailer = Closure::fromCallable($mailer);
}
public function notify(Notification $notification, RecipientInterface $recipient, ?string $transportName = null) : void {
if (!$recipient instanceof EmailRecipientInterface || trim($recipient->getEmail()) === '') {
throw new LogicException('The Cacti email channel needs an email recipient.');
}
if ($transportName !== null) {
throw new LogicException('The Cacti email channel uses the transport selected in Cacti settings.');
}
$options = $notification instanceof CactiNotification ? $notification->getOptions('email') : [];
$name = $recipient instanceof CactiRecipient ? $recipient->getName() : '';
$to = [['email' => $recipient->getEmail(), 'name' => $name]];
$html = (bool) ($options['html'] ?? false);
$text = (string) ($options['text'] ?? ($html ? '' : $notification->getContent()));
$attachments = $options['attachments'] ?? [];
$headers = $options['headers'] ?? [];
if (!is_array($attachments)) {
throw new LogicException('The Cacti email attachments option must be an array.');
}
if (!is_array($headers)) {
throw new LogicException('The Cacti email headers option must be an array.');
}
$error = ($this->mailer)(
$options['from'] ?? '',
$to,
$options['cc'] ?? null,
$options['bcc'] ?? null,
$options['reply_to'] ?? null,
$notification->getSubject(),
$notification->getContent(),
$text,
$attachments,
$headers,
$html,
(bool) ($options['expand_ids'] ?? false)
);
if ($error !== '') {
throw new RuntimeException(trim(strip_tags((string) $error)));
}
}
public function supports(Notification $notification, RecipientInterface $recipient) : bool {
if (!$recipient instanceof EmailRecipientInterface) {
return false;
}
return trim($recipient->getEmail()) !== '';
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU General Public License for more details. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
use Symfony\Component\Notifier\Notification\Notification;
/**
* A notification carrying optional channel-specific Cacti delivery data.
*/
final class CactiNotification extends Notification {
/** @var array<string, mixed> */
private array $options;
private bool $sealed = false;
/**
* @param list<string> $channels
* @param array<string, mixed> $options
*/
public function __construct(string $subject, string $content, array $channels = ['email'], array $options = []) {
parent::__construct($subject, $channels);
parent::content($content);
if (isset($options['importance'])) {
parent::importance((string) $options['importance']);
}
$this->options = $options;
$this->sealed = true;
}
public function subject(string $subject) : static {
$this->assertMutable();
return parent::subject($subject);
}
public function content(string $content) : static {
$this->assertMutable();
return parent::content($content);
}
public function importance(string $importance) : static {
$this->assertMutable();
return parent::importance($importance);
}
public function importanceFromLogLevelName(string $level) : static {
$this->assertMutable();
return parent::importanceFromLogLevelName($level);
}
public function emoji(string $emoji) : static {
$this->assertMutable();
return parent::emoji($emoji);
}
public function exception(Throwable $exception) : static {
$this->assertMutable();
return parent::exception($exception);
}
/**
* @param list<string> $channels
*/
public function channels(array $channels) : static {
$this->assertMutable();
return parent::channels($channels);
}
/**
* @return array<string, mixed>
*/
public function getOptions(string $channel) : array {
$options = $this->options[$channel] ?? [];
return is_array($options) ? $options : [];
}
private function assertMutable() : void {
if ($this->sealed) {
throw new LogicException('Cacti notifications are immutable after construction.');
}
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU General Public License for more details. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
use Symfony\Component\Notifier\Recipient\Recipient;
/**
* Symfony recipient with the display name used by Cacti mail delivery.
*/
final class CactiRecipient extends Recipient {
public function __construct(string $email = '', string $phone = '', private string $name = '') {
parent::__construct($email, $phone);
}
public function getName() : string {
return $this->name;
}
}
+155
View File
@@ -0,0 +1,155 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU General Public License for more details. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
use Symfony\Component\Notifier\Channel\ChannelInterface;
use Symfony\Component\Notifier\Notifier;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\Recipient\EmailRecipientInterface;
use Symfony\Component\Notifier\Recipient\RecipientInterface;
function api_notification_dependencies_available() : bool {
return interface_exists(ChannelInterface::class)
&& class_exists(Notifier::class)
&& interface_exists(NotifierInterface::class)
&& class_exists(Notification::class)
&& interface_exists(EmailRecipientInterface::class)
&& interface_exists(RecipientInterface::class);
}
if (api_notification_dependencies_available()) {
require_once __DIR__ . '/CactiNotification.php';
require_once __DIR__ . '/CactiRecipient.php';
require_once __DIR__ . '/CactiEmailChannel.php';
}
function api_notification_assert_dependencies() : void {
if (!api_notification_dependencies_available()) {
throw new RuntimeException('Symfony Notifier dependencies are unavailable. Run Composer install to restore the vendor tree.');
}
}
/**
* Returns Cacti's notification channels, extended by installed plugins.
*
* Plugins may register the `notification_channels` hook and return the channel
* map with additional Symfony ChannelInterface instances.
*
* @return array<string, ChannelInterface>
*/
function api_notification_channels() : array {
api_notification_assert_dependencies();
$channels = ['email' => new CactiEmailChannel()];
if (function_exists('api_plugin_hook_function')) {
$channels = api_plugin_hook_function('notification_channels', $channels);
}
return api_notification_validate_channels($channels);
}
/**
* @return array<string, ChannelInterface>
*/
function api_notification_validate_channels(mixed $channels) : array {
api_notification_assert_dependencies();
if (!is_array($channels)) {
throw new InvalidArgumentException('The notification_channels hook must return an array.');
}
foreach ($channels as $name => $channel) {
if (!is_string($name) || $name === '' || !$channel instanceof ChannelInterface) {
throw new InvalidArgumentException('Notification channels must be named Symfony ChannelInterface instances.');
}
}
return $channels;
}
/**
* Normalizes Cacti email values and Symfony recipients for notification use.
*
* @return list<RecipientInterface>
*/
function api_notification_recipients(array|string|RecipientInterface $recipients) : array {
api_notification_assert_dependencies();
if ($recipients instanceof RecipientInterface) {
return [$recipients];
}
if (is_array($recipients) && array_filter($recipients, fn (mixed $recipient) : bool => $recipient instanceof RecipientInterface) !== []) {
foreach ($recipients as $recipient) {
if (!$recipient instanceof RecipientInterface) {
throw new InvalidArgumentException('Symfony recipients cannot be mixed with email address values.');
}
}
return array_values($recipients);
}
$normalized = [];
$emails = parse_email_details($recipients);
foreach ($emails as $email) {
if (($email['email'] ?? '') !== '') {
$normalized[] = new CactiRecipient(trim($email['email']), '', trim($email['name'] ?? ''));
}
}
return $normalized;
}
/**
* Sends a notification through Symfony Notifier and Cacti delivery channels.
*
* The options array accepts `importance` plus channel-specific arrays. Email
* options are: from, cc, bcc, reply_to, html, text, attachments, headers and
* expand_ids.
*
* @param list<string> $channels
* @param array<string, mixed> $options
*/
function api_notification_send(string $subject, string $content, array|string|RecipientInterface $recipients,
array $channels = ['email'], array $options = [], ?NotifierInterface $notifier = null) : bool {
try {
api_notification_assert_dependencies();
$normalized = api_notification_recipients($recipients);
if ($normalized === []) {
throw new InvalidArgumentException('At least one notification recipient is required.');
}
$notification = new CactiNotification($subject, $content, $channels, $options);
$notifier ??= new Notifier(api_notification_channels());
$notifier->send($notification, ...$normalized);
return true;
} catch (Throwable $e) {
$logSubject = str_replace(["\r", "\n"], ' ', $subject);
$logError = str_replace(["\r", "\n"], ' ', $e->getMessage());
cacti_log(sprintf("ERROR: Notification '%s' failed: %s", $logSubject, $logError), false, 'NOTIFIER');
return false;
}
}
+9 -2
View File
@@ -5562,8 +5562,15 @@ function admin_email(string $subject, string $message) : bool {
$to = $admin_details['email_address'];
}
// If we get any message back then we have failed
$result = empty(send_mail($to, $from, $subject, $fin_message, html: true, expandIds: true));
require_once(CACTI_PATH_LIBRARY . '/api_notification.php');
$result = api_notification_send($subject, $fin_message, $to, options: [
'email' => [
'from' => $from,
'html' => true,
'expand_ids' => true,
],
]);
}
}
}
+19
View File
@@ -0,0 +1,19 @@
<phpunit bootstrap="tests/bootstrap-unit.php" beStrictAboutOutputDuringTests="false">
<php>
<ini name="error_reporting" value="-1"/>
<env name="CACTI_TEST_BOOTSTRAP" value="1"/>
</php>
<source>
<include>
<file>lib/CactiEmailChannel.php</file>
<file>lib/CactiNotification.php</file>
<file>lib/CactiRecipient.php</file>
<file>lib/api_notification.php</file>
</include>
</source>
<testsuites>
<testsuite name="Notification API coverage">
<file>tests/Unit/Core/CactiNotificationApiTest.php</file>
</testsuite>
</testsuites>
</phpunit>
@@ -0,0 +1,239 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU General Public License for more details. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
use Symfony\Component\Notifier\Exception\LogicException;
use Symfony\Component\Notifier\Exception\RuntimeException;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Recipient\NoRecipient;
use Symfony\Component\Notifier\Recipient\Recipient;
use Symfony\Component\Notifier\Recipient\RecipientInterface;
require_once dirname(__DIR__, 3) . '/lib/api_notification.php';
test('the notification API gates local classes behind Symfony dependencies', function () {
$source = file_get_contents(CACTI_PATH_LIBRARY . '/api_notification.php');
expect($source)->toContain('if (api_notification_dependencies_available()) {')
->and(strpos($source, 'if (api_notification_dependencies_available()) {'))
->toBeLessThan(strpos($source, "require_once __DIR__ . '/CactiNotification.php';"))
->and($source)->toContain('api_notification_assert_dependencies();');
});
final class RecordingNotifier implements NotifierInterface {
public ?Notification $notification = null;
/** @var list<RecipientInterface> */
public array $recipients = [];
public function send(Notification $notification, RecipientInterface ...$recipients) : void {
$this->notification = $notification;
$this->recipients = $recipients;
}
}
test('Cacti notifications retain content and channel options', function () {
$notification = new CactiNotification('Disk warning', 'Disk is full', ['email', 'chat'], [
'email' => ['html' => true],
'chat' => 'invalid options',
]);
expect($notification->getSubject())->toBe('Disk warning')
->and($notification->getContent())->toBe('Disk is full')
->and($notification->getChannels(new Recipient('ops@example.com')))->toBe(['email', 'chat'])
->and($notification->getOptions('email'))->toBe(['html' => true])
->and($notification->getOptions('chat'))->toBe([])
->and($notification->getOptions('missing'))->toBe([]);
});
test('Cacti notifications cannot be mutated after construction', function () {
$notification = new CactiNotification('Original', 'Body', ['email'], [
'importance' => Notification::IMPORTANCE_URGENT,
]);
expect($notification->getImportance())->toBe(Notification::IMPORTANCE_URGENT)
->and(fn () => $notification->subject('Changed'))->toThrow(\LogicException::class, 'immutable')
->and(fn () => $notification->content('Changed'))->toThrow(\LogicException::class, 'immutable')
->and(fn () => $notification->importance(Notification::IMPORTANCE_LOW))->toThrow(\LogicException::class, 'immutable')
->and(fn () => $notification->channels(['chat']))->toThrow(\LogicException::class, 'immutable');
});
test('Cacti recipients retain their display name', function () {
$recipient = new CactiRecipient('ops@example.com', '+15551234567', 'Operations');
expect($recipient->getEmail())->toBe('ops@example.com')
->and($recipient->getPhone())->toBe('+15551234567')
->and($recipient->getName())->toBe('Operations');
});
test('the email channel delegates all Cacti mail options', function () {
$args = [];
$channel = new CactiEmailChannel(function (...$received) use (&$args) : string {
$args = $received;
return '';
});
$recipient = new CactiRecipient('ops@example.com', '', 'Operations');
$notification = new CactiNotification('Alert', '<b>Down</b>', ['email'], [
'email' => [
'from' => 'cacti@example.com',
'cc' => 'cc@example.com',
'bcc' => 'bcc@example.com',
'reply_to' => 'reply@example.com',
'html' => true,
'text' => 'Down',
'attachments' => ['report.txt'],
'headers' => ['X-Cacti' => 'yes'],
'expand_ids' => true,
],
]);
$channel->notify($notification, $recipient);
expect($channel->supports($notification, $recipient))->toBeTrue()
->and($channel->supports($notification, new NoRecipient()))->toBeFalse()
->and($channel->supports($notification, new Recipient('', '+15551234567')))->toBeFalse()
->and($args)->toBe([
'cacti@example.com',
[['email' => 'ops@example.com', 'name' => 'Operations']],
'cc@example.com',
'bcc@example.com',
'reply@example.com',
'Alert',
'<b>Down</b>',
'Down',
['report.txt'],
['X-Cacti' => 'yes'],
true,
true,
]);
});
test('the email channel supplies safe defaults for standard notifications', function () {
$args = [];
$channel = new CactiEmailChannel(function (...$received) use (&$args) : string {
$args = $received;
return '';
});
$notification = (new Notification('Alert', ['email']))->content('Plain text');
$channel->notify($notification, new Recipient('ops@example.com'));
expect($args[0])->toBe('')
->and($args[6])->toBe('Plain text')
->and($args[7])->toBe('Plain text')
->and($args[10])->toBeFalse()
->and($args[11])->toBeFalse();
});
test('the email channel rejects unsupported recipients and transport overrides', function () {
$channel = new CactiEmailChannel(fn () : string => '');
$notification = new Notification('Alert', ['email']);
expect(fn () => $channel->notify($notification, new NoRecipient()))
->toThrow(LogicException::class, 'needs an email recipient')
->and(fn () => $channel->notify($notification, new Recipient('ops@example.com'), 'backup'))
->toThrow(LogicException::class, 'transport selected in Cacti settings');
});
test('the email channel rejects malformed mailer collection options', function () {
$channel = new CactiEmailChannel(fn () : string => '');
$recipient = new Recipient('ops@example.com');
$badAttachments = new CactiNotification('Alert', 'Body', ['email'], [
'email' => ['attachments' => 'report.txt'],
]);
$badHeaders = new CactiNotification('Alert', 'Body', ['email'], [
'email' => ['headers' => 'X-Cacti: yes'],
]);
expect(fn () => $channel->notify($badAttachments, $recipient))
->toThrow(LogicException::class, 'attachments option must be an array')
->and(fn () => $channel->notify($badHeaders, $recipient))
->toThrow(LogicException::class, 'headers option must be an array');
});
test('the email channel surfaces sanitized mailer failures', function () {
$channel = new CactiEmailChannel(fn () : string => '<b>SMTP unavailable</b>');
expect(fn () => $channel->notify(new Notification('Alert'), new Recipient('ops@example.com')))
->toThrow(RuntimeException::class, 'SMTP unavailable');
});
test('notification recipients accept Cacti email forms and Symfony recipients', function () {
$direct = new Recipient('direct@example.com');
$list = [new Recipient('one@example.com'), new Recipient('two@example.com')];
expect(api_notification_recipients($direct))->toBe([$direct])
->and(api_notification_recipients($list))->toBe($list);
$emails = api_notification_recipients('Operations <ops@example.com>,admin@example.com');
expect($emails)->toHaveCount(2)
->and($emails[0])->toBeInstanceOf(CactiRecipient::class)
->and($emails[0]->getEmail())->toBe('ops@example.com')
->and($emails[0]->getName())->toBe('Operations')
->and($emails[1]->getEmail())->toBe('admin@example.com');
expect(fn () => api_notification_recipients([$direct, 'mixed@example.com']))
->toThrow(InvalidArgumentException::class, 'cannot be mixed');
});
test('the public API dispatches through an injected Symfony notifier', function () {
$notifier = new RecordingNotifier();
expect(api_notification_send(
'Disk warning',
'Disk is full',
'Operations <ops@example.com>',
['email'],
['importance' => Notification::IMPORTANCE_URGENT],
$notifier
))->toBeTrue()
->and($notifier->notification)->toBeInstanceOf(CactiNotification::class)
->and($notifier->notification?->getImportance())->toBe(Notification::IMPORTANCE_URGENT)
->and($notifier->recipients)->toHaveCount(1)
->and($notifier->recipients[0])->toBeInstanceOf(CactiRecipient::class);
});
test('the default channel map exposes Cacti email delivery', function () {
$channels = api_notification_channels();
expect($channels)->toHaveKey('email')
->and($channels['email'])->toBeInstanceOf(CactiEmailChannel::class);
});
test('plugin channel maps must contain named Symfony channels', function () {
$channel = new CactiEmailChannel(fn () : string => '');
expect(api_notification_validate_channels(['email' => $channel]))->toBe(['email' => $channel])
->and(fn () => api_notification_validate_channels(null))
->toThrow(InvalidArgumentException::class, 'must return an array')
->and(fn () => api_notification_validate_channels(['invalid']))
->toThrow(InvalidArgumentException::class, 'must be named');
});
test('the public API fails closed for an unknown default channel', function () {
expect(api_notification_send('Alert', 'Unknown channel', 'ops@example.com', ['missing']))->toBeFalse();
});
test('the public API fails closed when no recipient is usable', function () {
expect(api_notification_send('Alert', 'No recipient', [], notifier: new RecordingNotifier()))->toBeFalse();
});