Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -269,3 +269,33 @@ parameters:
identifier: staticMethod.notFound
count: 1
path: src/database/migrations/2025_04_19_184307_create_notifications_table.php

-
message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Mail\:\:to\(\)\.$#'
identifier: staticMethod.notFound
count: 1
path: src/Channels/MailChannel.php

-
message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Log\:\:error\(\)\.$#'
identifier: staticMethod.notFound
count: 1
path: src/NotificationEvents.php

-
message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Log\:\:warning\(\)\.$#'
identifier: staticMethod.notFound
count: 1
path: src/Support/DeliveryLog.php

-
message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Schema\:\:create\(\)\.$#'
identifier: staticMethod.notFound
count: 1
path: src/database/migrations/2026_10_07_000000_create_notification_deliveries_table.php

-
message: '#^Call to an undefined static method Phaseolies\\Support\\Facades\\Schema\:\:dropIfExists\(\)\.$#'
identifier: staticMethod.notFound
count: 1
path: src/database/migrations/2026_10_07_000000_create_notification_deliveries_table.php
16 changes: 11 additions & 5 deletions src/Channels/Contracts/ChannelDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ abstract class ChannelDriver

/**
* Create a new channel driver instance
*
*
* @param mixed $app
*/
public function __construct($app = null)
{
$this->app = $app;
if ($app instanceof Application) {
$this->app = $app;
}
}

/**
Expand All @@ -43,10 +45,14 @@ abstract public function send($notifiable, Notification $notification): void;
*/
protected function config(string $key, $default = null)
{
if ($this->app && function_exists('config')) {
return config($key, $default);
if (function_exists('config')) {
try {
return config($key, $default);
} catch (\Throwable) {
return $default;
}
}

return $default;
}
}
}
18 changes: 14 additions & 4 deletions src/Channels/DatabaseChannel.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,31 @@

namespace Doppar\Notifier\Channels;

use Doppar\Notifier\Models\DatabaseNotification;
use Doppar\Notifier\Contracts\Notification;
use Doppar\Notifier\Channels\Contracts\ChannelDriver;
use Doppar\Notifier\Support\Notifiables;

class DatabaseChannel extends ChannelDriver
{
/**
* Send the notification through the database channel.
* Store the notification so the notifiable can read it in the application
*
* @param mixed $notifiable
* @param Notification $notification
* @return void
* @throws \RuntimeException
*/
public function send($notifiable, Notification $notification): void
{
// Database storage is handled by NotificationDispatcher
// This channel just marks that database storage is needed
DatabaseNotification::create([
'notifiable_type' => Notifiables::type($notifiable),
'notifiable_id' => Notifiables::requireKey($notifiable),
'type' => get_class($notification),
'data' => json_encode($notification->contentFor('database', $notifiable), JSON_THROW_ON_ERROR),
'metadata' => json_encode($notification->metadata(), JSON_THROW_ON_ERROR),
'read_at' => null,
'created_at' => date('Y-m-d H:i:s'),
]);
}
}
}
31 changes: 14 additions & 17 deletions src/Channels/DiscordChannel.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
namespace Doppar\Notifier\Channels;

use Doppar\Notifier\Contracts\Notification;
use Doppar\Notifier\Channels\Support\Http;
use Doppar\Notifier\Channels\Contracts\ChannelDriver;

class DiscordChannel extends ChannelDriver
{
/**
* Send notification to Discord
*
*
* @param mixed $notifiable
* @param Notification $notification
* @return void
Expand All @@ -23,16 +24,16 @@ public function send($notifiable, Notification $notification): void
throw new \RuntimeException('No Discord webhook URL defined for notifiable entity.');
}

$content = $notification->content($notifiable);
$content = $notification->contentFor('discord', $notifiable);

$payload = [
'content' => $content['content'] ?? '',
'username' => $content['username'] ?? config('notification.discord.username', 'Doppar Bot'),
'username' => $content['username'] ?? $this->config('notification.discord.username', 'Doppar Bot'),
'avatar_url' => $content['avatar'] ?? null,
'embeds' => $content['embeds'] ?? [],
];

$this->postToDiscord($webhookUrl, $payload);
$this->postToDiscord((string) $webhookUrl, $payload);
}

/**
Expand All @@ -45,18 +46,14 @@ public function send($notifiable, Notification $notification): void
*/
protected function postToDiscord(string $webhookUrl, array $payload): void
{
$ch = curl_init($webhookUrl);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);

$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($httpCode < 200 || $httpCode >= 300) {
throw new \RuntimeException("Discord notification failed with HTTP code: {$httpCode}");
$response = Http::post(
$webhookUrl,
json_encode($payload, JSON_THROW_ON_ERROR),
['Content-Type: application/json']
);

if ($response['status'] < 200 || $response['status'] >= 300) {
throw new \RuntimeException("Discord notification failed with HTTP code: {$response['status']}");
}
}
}
}
110 changes: 110 additions & 0 deletions src/Channels/MailChannel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

namespace Doppar\Notifier\Channels;

use Phaseolies\Support\Facades\Mail;
use Phaseolies\Support\Mail\Mailable;
use Doppar\Notifier\Contracts\Notification;
use Doppar\Notifier\Channels\Contracts\ChannelDriver;
use Doppar\Notifier\Channels\Support\MailTemplate;
use Doppar\Notifier\Support\Notifiables;

class MailChannel extends ChannelDriver
{
/**
* The function that hands the mail over, replaced by tests
*
* @var (callable(string, string|null, Mailable, array<string, mixed>): void)|null
*/
protected static $sender = null;

/**
* Replace how mail is handed over, or restore the application mailer with null
*
* @param callable|null $sender
* @return void
*/
public static function using(?callable $sender): void
{
static::$sender = $sender;
}

/**
* Send the notification as an email
*
* @param mixed $notifiable
* @param Notification $notification
* @return void
* @throws \RuntimeException
*/
public function send($notifiable, Notification $notification): void
{
$address = $notifiable->routeNotificationFor('mail');

if (!is_string($address) || !filter_var($address, FILTER_VALIDATE_EMAIL)) {
throw new \RuntimeException('Mail address is missing or invalid.');
}

$content = $notification->contentFor('mail', $notifiable);
$mailable = $this->buildMailable($content);
$name = Notifiables::attribute($notifiable, 'name');
$name = is_string($name) && $name !== '' ? $name : null;

if (static::$sender !== null) {
(static::$sender)($address, $name, $mailable, $content);

return;
}

$mail = Mail::to($address, $name);

foreach (['cc', 'bcc'] as $field) {
if (!empty($content[$field])) {
$mail->{$field}($content[$field]);
}
}

$mail->send($mailable);
}

/**
* Build the Mailable for the mail content
*
* @param array<string, mixed> $content
* @return Mailable
*/
protected function buildMailable(array $content): Mailable
{
$mailable = new Mailable();
$mailable->subject = (string) ($content['subject'] ?? 'Notification');

$rendered = isset($content['html']) ? null : MailTemplate::render($content);
$mailable->html((string) ($content['html'] ?? $rendered['html'] ?? ''));

$text = $content['text'] ?? $rendered['text'] ?? null;

if (is_string($text) && $text !== '') {
$mailable->text($text);
}

if (!empty($content['replyTo'])) {
$mailable->replyTo($content['replyTo']);
}

foreach ((array) ($content['tags'] ?? []) as $tag) {
$mailable->tag((string) $tag);
}

if (isset($content['priority'])) {
$mailable->priority((int) $content['priority']);
}

foreach ((array) ($content['attachments'] ?? []) as $attachment) {
$attachment = is_array($attachment) ? $attachment : ['path' => $attachment];

$mailable->attach((string) $attachment['path'], $attachment['name'] ?? null, $attachment['mime'] ?? null);
}

return $mailable;
}
}
34 changes: 13 additions & 21 deletions src/Channels/SlackChannel.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

namespace Doppar\Notifier\Channels;

use Phaseolies\Support\Facades\Log;
use Doppar\Notifier\Contracts\Notification;
use Doppar\Notifier\Channels\Support\Http;
use Doppar\Notifier\Channels\Contracts\ChannelDriver;

class SlackChannel extends ChannelDriver
{
/**
* Send notification to Slack
*
*
* @param mixed $notifiable
* @param Notification $notification
* @return void
Expand All @@ -24,7 +24,7 @@ public function send($notifiable, Notification $notification): void
throw new \RuntimeException('Slack webhook URL is missing.');
}

$content = $notification->content($notifiable);
$content = $notification->contentFor('slack', $notifiable);

$payload = [
'text' => $content['text'] ?? '',
Expand All @@ -44,7 +44,7 @@ public function send($notifiable, Notification $notification): void
$payload['channel'] = $content['channel'];
}

$this->postToSlack($webhookUrl, $payload);
$this->postToSlack((string) $webhookUrl, $payload);
}

/**
Expand All @@ -57,24 +57,16 @@ public function send($notifiable, Notification $notification): void
*/
protected function postToSlack(string $webhookUrl, array $payload): void
{
$ch = curl_init($webhookUrl);

curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 10,
]);

$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);

curl_close($ch);
$response = Http::post(
$webhookUrl,
json_encode($payload, JSON_THROW_ON_ERROR),
['Content-Type: application/json']
);

if ($httpCode !== 200 || trim($response) !== 'ok') {
Log::error("Slack notification failed (HTTP {$httpCode}): " . ($response ?: $error));
if ($response['status'] !== 200 || trim($response['body']) !== 'ok') {
throw new \RuntimeException(
"Slack notification failed (HTTP {$response['status']}): " . ($response['body'] ?: $response['error'])
);
}
}
}
Loading
Loading