Added inbound rejections to failed deliveries

This commit is contained in:
Will Browning
2026-04-10 16:40:47 +01:00
parent 89ecdcf76f
commit fd4c71bef7
31 changed files with 1618 additions and 233 deletions

View File

@@ -79,6 +79,9 @@ ANONADDY_PROXY_AUTHENTICATION_EMAIL_HEADER=X-Email
BLOCKLIST_API_ALLOWED_IPS=127.0.0.1
BLOCKLIST_API_SECRET=uStu5BoDvDoxGi1AHCfEW3ougDcgty
# If the path to your Postfix mail log file is not /var/log/mail.log then you can set it here
#POSTFIX_LOG_PATH=/path/to/mail.log
# Optional: override word/name lists with a comma-separated list or path to a PHP file that returns an array
# ANONADDY_BLACKLIST=reserved,admin,root
# ANONADDY_MALE_FIRST_NAMES=config/lists/custom_male_first.php

View File

@@ -307,6 +307,35 @@ Making sure to replace `johndoe` with the username of the user who will run the
This command will pipe the email through to our application so that we can determine who the alias belongs to and who to forward the email to.
In order to parse the Postfix log files and display inbound rejections to your aliases you will need to give your `johndoe` user permission to view the mail log:
```bash
sudo setfacl -m u:johndoe:r /var/log/mail.log
```
You may also need to add the following to the `postrotate` hook of logrotate so that this is reapplied when you mail log is rotated:
```bash
sudo vim /etc/logrotate.d/rsyslog
```
```
/var/log/mail.log
{
...
postrotate
/usr/lib/rsyslog/rsyslog-rotate
[ -f /var/log/mail.log ] && setfacl -m u:johndoe:r /var/log/mail.log
endscript
}
```
If your mail log is at a location other than `/var/log/mail.log` you can set this in your `.env` file:
```
POSTFIX_LOG_PATH=/path/to/mail.log
```
## Installing Nginx
To install Nginx first add the prerequisites add then add the following signing key and repo (instructions taken from [nginx.org](https://nginx.org/en/linux_packages.html#Ubuntu)).
@@ -895,6 +924,111 @@ Restart Rspamd to reflect the changes.
sudo service rspamd restart
```
Now add support for the user blocklist feature by creating `/etc/rspamd/lua.local.d/addy_blocklist.lua`:
```bash
sudo nano /etc/rspamd/lua.local.d/addy_blocklist.lua
```
```lua
--[[
Rspamd Lua script: user blocklist check via Laravel HTTP API.
Deploy this on each mail server that runs Rspamd. Point blocklist_api_url
at your Laravel app.
Required: rspamd_http (built-in). Symbol BLOCKLIST_USER is set when the
API returns block=true; map this symbol to an action (reject/discard) in
your Rspamd actions config.
--]]
local blocklist_api_url = 'https://your-addy-instance.com/api/blocklist-check'
local blocklist_secret = '' -- same as BLOCKLIST_API_SECRET in .env, or leave '' if unset
-- Simple percent-encode for query parameter values (rspamd_http has no escape)
local function url_encode(s)
if s == nil or s == '' then return '' end
s = tostring(s)
return (s:gsub('[^%w%-_.~ ]', function(c)
return string.format('%%%02X', string.byte(c))
end):gsub(' ', '%%20'))
end
local logger = require "rspamd_logger"
local rspamd_http = require 'rspamd_http'
rspamd_config:register_symbol({
name = 'BLOCKLIST_USER',
callback = function(task)
local rcpts = task:get_recipients('smtp')
local from_env = task:get_from('smtp')
if not rcpts or #rcpts == 0 then
logger.infox('blocklist: skip - missing recipient')
return false
end
local recipient = (rcpts[1].addr and rcpts[1].addr:lower()) or ''
local sender = ''
if from_env and from_env.addr then
sender = from_env.addr:lower()
end
local from_email = ''
local from_hdr = task:get_header('From')
if from_hdr then
local raw = (type(from_hdr) == 'table') and (from_hdr[1] or from_hdr) or from_hdr
raw = tostring(raw)
from_email = raw:match('<([^>]+)>') or raw:match('%S+@%S+') or ''
from_email = from_email:lower()
end
if from_email == '' then
from_email = sender
end
if recipient == '' or (sender == '' and from_email == '') then
logger.infox('blocklist: skip - missing recipient or from (recipient=%1, sender=%2, from_email=%3)', recipient, sender, from_email)
return false
end
local url = blocklist_api_url
.. '?recipient=' .. url_encode(recipient)
.. '&from_email=' .. url_encode(from_email)
local req_headers = {}
if blocklist_secret ~= '' then
req_headers['X-Blocklist-Secret'] = blocklist_secret
end
rspamd_http.request({
url = url,
headers = req_headers,
timeout = 2.0,
task = task,
callback = function(err_message, code, body, _headers)
if err_message then
logger.warnx('blocklist: HTTP error - %1', err_message)
return
end
if code == 200 and body and body:match('"block"%s*:%s*true') then
task:set_pre_result('reject', '550 5.1.1 Address not found')
task:insert_result(true, 'BLOCKLIST_USER', 1000.0, '550 5.1.1 Address not found')
logger.infox('blocklist: BLOCKLIST_USER set for recipient=%1 from_email=%2', recipient, from_email)
end
end,
})
return false -- do not match symbol here; only HTTP callback may add it via insert_result
end,
score = 1000.0,
})
```
Restart Rspamd again after creating the file:
```bash
sudo service rspamd restart
```
You can view the Rspamd web interface by creating an SSH tunnel by running the following command on your local pc:
```bash
@@ -959,7 +1093,7 @@ fi
Make sure node is installed (`node -v`) if not then install it using NVM - [https://www.digitalocean.com/community/tutorials/how-to-install-node-js-on-ubuntu-22-04#option-3-installing-node-using-the-node-version-manager](https://www.digitalocean.com/community/tutorials/how-to-install-node-js-on-ubuntu-22-04#option-3-installing-node-using-the-node-version-manager)
At the time of writing this I'm using the latest LTS - v18.18.2
You should use Node `20.19+` (required for Vite 8).
Next copy the .env.example file and update it with correct values (database password, app url, redis password etc.) then install the dependencies.
@@ -975,6 +1109,16 @@ npm run production
Make sure to update the database settings, redis password and the AnonAddy variables. You can use Redis for queue, sessions and cache.
Also add the blocklist API variables to your `.env`:
```bash
# Blocklist API (Rspamd): comma-separated IPs allowed to call /api/blocklist-check; optional shared secret
BLOCKLIST_API_ALLOWED_IPS=127.0.0.1
BLOCKLIST_API_SECRET=
```
Make sure `BLOCKLIST_API_ALLOWED_IPS` includes the actual IP address(es) of your mail server(s). If you set a secret here, it must match `blocklist_secret` in `/etc/rspamd/lua.local.d/addy_blocklist.lua`.
We'll set `ANONADDY_SIGNING_KEY_FINGERPRINT` shortly.
`APP_KEY` will be generated in the next step, this is used by Laravel for securely encrypting values.

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Console\Commands;
use App\Models\Alias;
use App\Models\FailedDelivery;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ParsePostfixMailLog extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'anonaddy:parse-postfix-mail-log';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Parse Postfix log for inbound rejections and store them';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$logPath = config('anonaddy.postfix_log_path', '/var/log/mail.log');
if (! file_exists($logPath) || ! is_readable($logPath)) {
$this->error("Cannot read log file: {$logPath}");
return 1;
}
$positionFile = 'postfix_log_position.txt';
$lastPosition = Storage::disk('local')->exists($positionFile)
? (int) Storage::disk('local')->get($positionFile)
: 0;
$fileSize = filesize($logPath);
// If the file is smaller than our last position, it was likely rotated
if ($fileSize < $lastPosition) {
$lastPosition = 0;
}
if ($fileSize === $lastPosition) {
return 0;
}
$handle = fopen($logPath, 'r');
if (! $handle) {
$this->error("Failed to open log file: {$logPath}");
return 1;
}
fseek($handle, $lastPosition);
$allDomains = config('anonaddy.all_domains', []);
$count = 0;
$storeErrors = 0;
// Pattern to match syslog and ISO8601 timestamps
$pattern = '/^(.*?)\s+(?:[^\s]+\s+)?postfix\/(?:smtpd|cleanup)\[\d+\]:\s*(?:[A-Z0-9]+:\s*)?(?:reject|milter-reject|discard):\s*(?:RCPT|END-OF-MESSAGE) from\s*([^:]+):\s*(?:<[^>]+>:\s*)?(?:(\d{3}\s+\d\.\d\.\d|\d\.\d\.\d)\s+)?(.*?);\s*from=<(.*?)>\s*to=<(.*?)>/i';
while (($line = fgets($handle)) !== false) {
if (! preg_match('/(?:reject:|milter-reject:|discard:)/', $line) || ! str_contains($line, 'to=<')) {
continue;
}
if (preg_match($pattern, $line, $matches)) {
$timestampStr = trim($matches[1]);
$remoteMta = trim($matches[2]);
$smtpCodeStr = trim($matches[3] ?? '');
$reason = trim($matches[4]);
$sender = trim($matches[5]);
$recipient = trim($matches[6]);
$smtpCode = '';
if ($smtpCodeStr) {
$parts = explode(' ', $smtpCodeStr);
$smtpCode = $parts[0];
$reason = $smtpCodeStr.' '.$reason;
} elseif (preg_match('/^(\d{3})\s+(.*)$/', $reason, $reasonMatches)) {
$smtpCode = $reasonMatches[1];
}
try {
$attemptedAt = Carbon::parse($timestampStr);
if ($attemptedAt->isFuture()) {
$attemptedAt->subYear();
}
} catch (\Exception $e) {
$attemptedAt = now();
}
$recipientLower = strtolower($recipient);
$aliasLookup = $recipientLower;
if (str_contains($recipientLower, '+')) {
$parts = explode('@', $recipientLower);
if (count($parts) === 2) {
$aliasLookup = explode('+', $parts[0])[0].'@'.$parts[1];
}
}
$alias = Alias::withTrashed()->where('email', $aliasLookup)->first();
$userId = null;
if ($alias) {
$userId = $alias->user_id;
}
if (! $userId) {
continue;
}
$bounceType = 'hard';
if (str_contains(strtolower($reason), 'spam message rejected')) {
$bounceType = 'spam';
}
$irDedupeKey = hash('sha256', $userId.'|'.($alias ? $alias->id : '').'|'.$attemptedAt->format('Y-m-d H:i:s'));
try {
FailedDelivery::create([
'user_id' => $userId,
'alias_id' => $alias ? $alias->id : null,
'email_type' => 'IR',
'ir_dedupe_key' => $irDedupeKey,
'sender' => $sender === '' ? '<>' : Str::limit($sender, 255),
'destination' => $recipientLower,
'remote_mta' => Str::limit($remoteMta, 255),
'code' => Str::limit($reason, 255),
'status' => $smtpCode ? Str::limit($smtpCode, 10) : null,
'attempted_at' => $attemptedAt,
'created_at' => $attemptedAt,
'updated_at' => $attemptedAt,
'bounce_type' => $bounceType,
]);
$count++;
} catch (QueryException $e) {
if ($this->isDuplicateKeyException($e)) {
continue;
}
report($e);
$storeErrors++;
}
}
}
$newPosition = ftell($handle);
Storage::disk('local')->put($positionFile, (string) $newPosition);
fclose($handle);
if ($count > 0) {
$this->info("Stored {$count} inbound rejections.");
Log::info("Stored {$count} inbound rejections.");
}
if ($storeErrors > 0) {
$this->warn("Failed to store {$storeErrors} inbound rejection(s); see application log for details.");
Log::info("Failed to store {$storeErrors} inbound rejection(s); see application log for details.");
}
return 0;
}
protected function isDuplicateKeyException(QueryException $e): bool
{
return match (DB::getDriverName()) {
'mysql' => ($e->errorInfo[1] ?? 0) === 1062,
'sqlite' => str_contains($e->getMessage(), 'UNIQUE constraint failed'),
default => ($e->errorInfo[0] ?? '') === '23000',
};
}
}

View File

@@ -403,10 +403,9 @@ class ReceiveEmail extends Command
$forwardToRecipientIds = UserRuleChecker::getRecipientIdsToForwardToFromRuleIdsAndActions($ruleIdsAndActions);
if (! empty($forwardToRecipientIds)) {
$recipients = $this->user->verifiedRecipients()->whereIn('id', $forwardToRecipientIds)->get();
if ($recipients) {
$recipientsToForwardTo = $this->user->verifiedRecipients()->whereIn('id', $forwardToRecipientIds)->get();
$ruleRecipients = $this->user->verifiedRecipients()->whereIn('id', $forwardToRecipientIds)->get();
if ($ruleRecipients->isNotEmpty()) {
$recipientsToForwardTo = $ruleRecipients;
}
}
}

View File

@@ -3,15 +3,29 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\IndexFailedDeliveryRequest;
use App\Http\Resources\FailedDeliveryResource;
class FailedDeliveryController extends Controller
{
public function index()
public function index(IndexFailedDeliveryRequest $request)
{
$failedDeliveries = user()->failedDeliveries()->with(['recipient:id,email', 'alias:id,email'])->latest();
$failedDeliveries = user()
->failedDeliveries()
->with(['recipient:id,email', 'alias:id,email'])
->when($request->input('filter.email_type'), function ($query, $value) {
if ($value === 'inbound') {
return $query->where('email_type', 'IR');
}
return FailedDeliveryResource::collection($failedDeliveries->get());
if ($value === 'outbound') {
return $query->where('email_type', '!=', 'IR');
}
})
->latest()
->jsonPaginate();
return FailedDeliveryResource::collection($failedDeliveries);
}
public function show($id)

View File

@@ -3,6 +3,9 @@
namespace App\Http\Controllers;
use App\Models\Alias;
use App\Notifications\AliasDeactivatedByUnsubscribeNotification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class DeactivateAliasController extends Controller
@@ -30,14 +33,35 @@ class DeactivateAliasController extends Controller
->with(['flash' => 'Alias '.$alias->email.' deactivated successfully!']);
}
public function deactivatePost($id)
public function deactivatePost(Request $request, $id)
{
$alias = Alias::findOrFail($id);
$wasActive = $alias->active;
$alias->deactivate();
Log::info('One-Click Unsubscribe deactivated alias: '.$alias->email.' ID: '.$id);
if ($wasActive) {
$cacheKey = "unsubscribe-deactivate-notify:{$alias->id}";
if (! Cache::has($cacheKey)) {
Cache::put($cacheKey, true, now()->addHour());
$user = $alias->user;
$user->notify(
(new AliasDeactivatedByUnsubscribeNotification(
$alias->email,
$alias->id,
$request->ip(),
$request->userAgent(),
now()->format('F j, g:i A (T)'),
))
);
}
}
return response('');
}
}

View File

@@ -3,6 +3,9 @@
namespace App\Http\Controllers;
use App\Models\Alias;
use App\Notifications\AliasDeletedByUnsubscribeNotification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class DeleteAliasController extends Controller
@@ -13,14 +16,34 @@ class DeleteAliasController extends Controller
$this->middleware('throttle:6,1');
}
public function deletePost($id)
public function deletePost(Request $request, $id)
{
$alias = Alias::findOrFail($id);
$wasNotDeleted = is_null($alias->deleted_at);
$alias->delete();
Log::info('One-Click Unsubscribe deleted alias: '.$alias->email.' ID: '.$id);
if ($wasNotDeleted) {
$cacheKey = "unsubscribe-delete-notify:{$alias->id}";
if (! Cache::has($cacheKey)) {
Cache::put($cacheKey, true, now()->addHour());
$user = $alias->user;
$user->notify(
(new AliasDeletedByUnsubscribeNotification(
$alias->email,
$request->ip(),
$request->userAgent(),
now()->format('F j, g:i A (T)'),
))
);
}
}
return response('');
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
class ShowFailedDeliveryController extends Controller
@@ -13,27 +12,39 @@ class ShowFailedDeliveryController extends Controller
// Validate search query
$validated = $request->validate([
'search' => 'nullable|string|max:50|min:2',
'filter' => 'nullable|string|in:all,inbound,outbound',
'page_size' => 'nullable|integer|in:25,50,100',
]);
$failedDeliveries = user()
$query = user()
->failedDeliveries()
->with(['recipient:id,email', 'alias:id,email'])
->select(['alias_id', 'email_type', 'code', 'attempted_at', 'created_at', 'id', 'user_id', 'recipient_id', 'remote_mta', 'sender', 'destination', 'is_stored', 'resent'])
->latest()
->get();
->latest();
if (isset($validated['search'])) {
$searchTerm = strtolower($validated['search']);
$filter = $validated['filter'] ?? 'all';
$failedDeliveries = $failedDeliveries->filter(function ($failedDelivery) use ($searchTerm) {
return Str::contains(strtolower($failedDelivery->code), $searchTerm);
})->values();
if ($filter === 'inbound') {
$query->where('email_type', 'IR');
} elseif ($filter === 'outbound') {
$query->where('email_type', '!=', 'IR');
}
if (isset($validated['search'])) {
$query->where('code', 'like', '%'.$validated['search'].'%');
}
$failedDeliveries = $query
->paginate($validated['page_size'] ?? 25)
->withQueryString()
->onEachSide(1);
return Inertia::render('FailedDeliveries', [
'initialRows' => $failedDeliveries,
'initialRows' => fn () => $failedDeliveries,
'recipientOptions' => fn () => user()->verifiedRecipients()->select(['id', 'email'])->get(),
'search' => $validated['search'] ?? null,
'initialFilter' => $filter,
'initialPageSize' => isset($validated['page_size']) ? (int) $validated['page_size'] : 25,
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class IndexFailedDeliveryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'page' => [
'nullable',
'array',
],
'page.number' => [
'nullable',
'integer',
],
'page.size' => [
'nullable',
'integer',
'max:100',
'min:1',
],
'filter' => [
'nullable',
'array',
],
'filter.email_type' => [
'nullable',
'string',
'in:inbound,outbound',
],
];
}
}

View File

@@ -41,9 +41,12 @@ class FailedDelivery extends Model
'sender',
'destination',
'email_type',
'ir_dedupe_key',
'status',
'code',
'attempted_at',
'created_at',
'updated_at',
];
protected $casts = [
@@ -109,6 +112,9 @@ class FailedDelivery extends Model
'RSL' => 'Reached Reply/Send Limit',
'SRSA' => 'Spam Reply/Send Attempt',
'AIF' => 'Aliases Import Finished',
'IR' => 'Inbound Rejection',
'ADLN' => 'Alias Deleted by One-Click Unsubscribe',
'ADUN' => 'Alias Deactivated by One-Click Unsubscribe',
default => 'Forward',
},
);

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Symfony\Component\Mime\Email;
class AliasDeactivatedByUnsubscribeNotification extends Notification implements ShouldBeEncrypted, ShouldQueue
{
use Queueable;
public function __construct(
protected string $aliasEmail,
protected string $aliasId,
protected ?string $ipAddress = null,
protected ?string $userAgent = null,
protected ?string $timestamp = null,
) {}
/**
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return ['mail'];
}
/**
* @param mixed $notifiable
* @return MailMessage
*/
public function toMail($notifiable)
{
$recipient = $notifiable->defaultRecipient;
$fingerprint = $recipient->should_encrypt ? $recipient->fingerprint : null;
return (new MailMessage)
->subject('Alias deactivated via one-click unsubscribe')
->markdown('mail.alias_deactivated_by_unsubscribe', [
'aliasEmail' => $this->aliasEmail,
'aliasId' => $this->aliasId,
'ipAddress' => $this->ipAddress,
'userAgent' => $this->userAgent,
'timestamp' => $this->timestamp,
'userId' => $notifiable->id,
'recipientId' => $notifiable->default_recipient_id,
'emailType' => 'ADUN',
'fingerprint' => $fingerprint,
])
->withSymfonyMessage(function (Email $message) {
$message->getHeaders()
->addTextHeader('Feedback-ID', 'ADUN:anonaddy');
});
}
/**
* @param mixed $notifiable
* @return array
*/
public function toArray($notifiable)
{
return [];
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Symfony\Component\Mime\Email;
class AliasDeletedByUnsubscribeNotification extends Notification implements ShouldBeEncrypted, ShouldQueue
{
use Queueable;
public function __construct(
protected string $aliasEmail,
protected ?string $ipAddress = null,
protected ?string $userAgent = null,
protected ?string $timestamp = null,
) {}
/**
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return ['mail'];
}
/**
* @param mixed $notifiable
* @return MailMessage
*/
public function toMail($notifiable)
{
$recipient = $notifiable->defaultRecipient;
$fingerprint = $recipient->should_encrypt ? $recipient->fingerprint : null;
return (new MailMessage)
->subject('Alias deleted via one-click unsubscribe')
->markdown('mail.alias_deleted_by_unsubscribe', [
'aliasEmail' => $this->aliasEmail,
'ipAddress' => $this->ipAddress,
'userAgent' => $this->userAgent,
'timestamp' => $this->timestamp,
'userId' => $notifiable->id,
'recipientId' => $notifiable->default_recipient_id,
'emailType' => 'ADLN',
'fingerprint' => $fingerprint,
])
->withSymfonyMessage(function (Email $message) {
$message->getHeaders()
->addTextHeader('Feedback-ID', 'ADLN:anonaddy');
});
}
/**
* @param mixed $notifiable
* @return array
*/
public function toArray($notifiable)
{
return [];
}
}

View File

@@ -7,7 +7,7 @@ use Illuminate\Contracts\Validation\ValidationRule;
class VerifiedRecipientId implements ValidationRule
{
protected $verifiedRecipientIds;
protected ?array $verifiedRecipientIds;
/**
* Create a new rule instance.
@@ -16,14 +16,7 @@ class VerifiedRecipientId implements ValidationRule
*/
public function __construct(?array $verifiedRecipientIds = null)
{
if (! is_null($verifiedRecipientIds)) {
$this->verifiedRecipientIds = $verifiedRecipientIds;
} else {
$this->verifiedRecipientIds = user()
->verifiedRecipients()
->pluck('id')
->toArray();
}
$this->verifiedRecipientIds = $verifiedRecipientIds;
}
/**
@@ -31,6 +24,13 @@ class VerifiedRecipientId implements ValidationRule
*/
public function validate(string $attribute, mixed $ids, Closure $fail): void
{
if (is_null($this->verifiedRecipientIds)) {
$this->verifiedRecipientIds = user()
->verifiedRecipients()
->pluck('id')
->toArray();
}
// Multiple calls to $fail simply add more validation errors, they don't stop processing.
if (! is_array($ids)) {
$fail('Invalid Recipient');

View File

@@ -4,6 +4,7 @@ namespace App\Services;
use App\Models\Alias;
use App\Models\EmailData;
use App\Models\Rule;
use App\Models\User;
use Illuminate\Support\Str;
@@ -34,6 +35,7 @@ class UserRuleChecker
protected function getRuleIdsAndActions(string $emailType): array
{
$ruleIdsAndActions = [];
$matchedRuleIds = [];
$method = "activeRulesFor{$emailType}Ordered";
$rules = $this->user->{$method};
@@ -43,11 +45,14 @@ class UserRuleChecker
if ($this->ruleConditionsSatisfied($rule->conditions, $rule->operator)) {
$ruleIdsAndActions[$rule->id] = $rule->actions;
// Increment applied count
$rule->increment('applied', 1, ['last_applied' => now()]);
$matchedRuleIds[] = $rule->id;
}
}
if (! empty($matchedRuleIds)) {
Rule::whereIn('id', $matchedRuleIds)->increment('applied', 1, ['last_applied' => now()]);
}
return $ruleIdsAndActions;
}

36
composer.lock generated
View File

@@ -1689,16 +1689,16 @@
},
{
"name": "inertiajs/inertia-laravel",
"version": "v2.0.23",
"version": "v2.0.24",
"source": {
"type": "git",
"url": "https://github.com/inertiajs/inertia-laravel.git",
"reference": "20438dc5a0f3008965ccaa96e990d03116102d80"
"reference": "ea345adad12f110edbbc4bef03b69c2374a535d4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/20438dc5a0f3008965ccaa96e990d03116102d80",
"reference": "20438dc5a0f3008965ccaa96e990d03116102d80",
"url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/ea345adad12f110edbbc4bef03b69c2374a535d4",
"reference": "ea345adad12f110edbbc4bef03b69c2374a535d4",
"shasum": ""
},
"require": {
@@ -1756,9 +1756,9 @@
],
"support": {
"issues": "https://github.com/inertiajs/inertia-laravel/issues",
"source": "https://github.com/inertiajs/inertia-laravel/tree/v2.0.23"
"source": "https://github.com/inertiajs/inertia-laravel/tree/v2.0.24"
},
"time": "2026-04-07T14:01:31+00:00"
"time": "2026-04-10T14:36:44+00:00"
},
{
"name": "intervention/gif",
@@ -4178,16 +4178,16 @@
},
{
"name": "phpoffice/phpspreadsheet",
"version": "1.30.2",
"version": "1.30.3",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "09cdde5e2f078b9a3358dd217e2c8cb4dac84be2"
"reference": "d28d4827f934469e7ca4de940ab0abd0788d1e65"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/09cdde5e2f078b9a3358dd217e2c8cb4dac84be2",
"reference": "09cdde5e2f078b9a3358dd217e2c8cb4dac84be2",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/d28d4827f934469e7ca4de940ab0abd0788d1e65",
"reference": "d28d4827f934469e7ca4de940ab0abd0788d1e65",
"shasum": ""
},
"require": {
@@ -4280,9 +4280,9 @@
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.2"
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.3"
},
"time": "2026-01-11T05:58:24+00:00"
"time": "2026-04-10T03:47:16+00:00"
},
{
"name": "phpoption/phpoption",
@@ -8861,16 +8861,16 @@
},
{
"name": "webmozart/assert",
"version": "2.1.6",
"version": "2.2.0",
"source": {
"type": "git",
"url": "https://github.com/webmozarts/assert.git",
"reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8"
"reference": "1b99650e7ffcad232624a260bc7fbdec2ffc407c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8",
"reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8",
"url": "https://api.github.com/repos/webmozarts/assert/zipball/1b99650e7ffcad232624a260bc7fbdec2ffc407c",
"reference": "1b99650e7ffcad232624a260bc7fbdec2ffc407c",
"shasum": ""
},
"require": {
@@ -8917,9 +8917,9 @@
],
"support": {
"issues": "https://github.com/webmozarts/assert/issues",
"source": "https://github.com/webmozarts/assert/tree/2.1.6"
"source": "https://github.com/webmozarts/assert/tree/2.2.0"
},
"time": "2026-02-27T10:28:38+00:00"
"time": "2026-04-09T16:54:47+00:00"
}
],
"packages-dev": [

View File

@@ -153,6 +153,17 @@ return [
'secret' => env('BLOCKLIST_API_SECRET', ''),
],
/*
|--------------------------------------------------------------------------
| Postfix Log Path
|--------------------------------------------------------------------------
|
| The path to the postfix log file, used for parsing inbound rejections
|
*/
'postfix_log_path' => env('POSTFIX_LOG_PATH', '/var/log/mail.log'),
/*
|--------------------------------------------------------------------------
| All Domains

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Inbound rejection rows (email_type IR) use ir_dedupe_key (SHA-256 hex) for duplicate prevention.
* Outbound rows leave ir_dedupe_key null; MySQL/SQLite allow many nulls under a unique index.
*/
public function up(): void
{
Schema::table('failed_deliveries', function (Blueprint $table) {
$table->string('ir_dedupe_key', 64)->nullable()->after('destination');
$table->unique('ir_dedupe_key', 'failed_deliveries_ir_dedupe_key_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('failed_deliveries', function (Blueprint $table) {
$table->dropUnique('failed_deliveries_ir_dedupe_key_unique');
$table->dropColumn('ir_dedupe_key');
});
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('alias_recipients', function (Blueprint $table) {
$table->index('recipient_id');
});
Schema::table('recipients', function (Blueprint $table) {
$table->index(['user_id', 'pending', 'created_at'], 'recipients_user_pending_created_at_index');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('alias_recipients', function (Blueprint $table) {
$table->dropIndex('alias_recipients_recipient_id_index');
});
Schema::table('recipients', function (Blueprint $table) {
$table->dropIndex('recipients_user_pending_created_at_index');
});
}
};

214
package-lock.json generated
View File

@@ -108,9 +108,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -291,9 +291,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.123.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz",
"integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==",
"version": "0.124.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
"integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -311,9 +311,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz",
"integrity": "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
"cpu": [
"arm64"
],
@@ -328,9 +328,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz",
"integrity": "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
"cpu": [
"arm64"
],
@@ -345,9 +345,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz",
"integrity": "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
"cpu": [
"x64"
],
@@ -362,9 +362,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz",
"integrity": "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
"cpu": [
"x64"
],
@@ -379,9 +379,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz",
"integrity": "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
"integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
"cpu": [
"arm"
],
@@ -396,9 +396,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz",
"integrity": "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
"cpu": [
"arm64"
],
@@ -413,9 +413,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz",
"integrity": "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
"cpu": [
"arm64"
],
@@ -430,9 +430,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz",
"integrity": "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
"cpu": [
"ppc64"
],
@@ -447,9 +447,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz",
"integrity": "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
"cpu": [
"s390x"
],
@@ -464,9 +464,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz",
"integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
"cpu": [
"x64"
],
@@ -481,9 +481,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz",
"integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
"cpu": [
"x64"
],
@@ -498,9 +498,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz",
"integrity": "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
"cpu": [
"arm64"
],
@@ -515,9 +515,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz",
"integrity": "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
"integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
"cpu": [
"wasm32"
],
@@ -525,18 +525,18 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.9.1",
"@emnapi/runtime": "1.9.1",
"@napi-rs/wasm-runtime": "^1.1.2"
"@emnapi/core": "1.9.2",
"@emnapi/runtime": "1.9.2",
"@napi-rs/wasm-runtime": "^1.1.3"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz",
"integrity": "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
"cpu": [
"arm64"
],
@@ -551,9 +551,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz",
"integrity": "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
"cpu": [
"x64"
],
@@ -671,12 +671,12 @@
}
},
"node_modules/@types/node": {
"version": "25.5.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz",
"integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==",
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
"undici-types": "~7.19.0"
}
},
"node_modules/@vitejs/plugin-vue": {
@@ -1166,9 +1166,9 @@
}
},
"node_modules/axios": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
@@ -1177,9 +1177,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.16",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz",
"integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==",
"version": "2.10.17",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz",
"integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
@@ -3235,9 +3235,9 @@
"license": "MIT"
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz",
"integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==",
"dev": true,
"license": "MIT",
"bin": {
@@ -3260,9 +3260,9 @@
}
},
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -3413,14 +3413,14 @@
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz",
"integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
"integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.123.0",
"@rolldown/pluginutils": "1.0.0-rc.13"
"@oxc-project/types": "=0.124.0",
"@rolldown/pluginutils": "1.0.0-rc.15"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -3429,27 +3429,27 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.13",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.13",
"@rolldown/binding-darwin-x64": "1.0.0-rc.13",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.13",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.13",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.13",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.13",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13"
"@rolldown/binding-android-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-x64": "1.0.0-rc.15",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
"integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
"dev": true,
"license": "MIT"
},
@@ -3549,13 +3549,13 @@
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
@@ -3984,9 +3984,9 @@
"optional": true
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"license": "MIT"
},
"node_modules/update-browserslist-db": {
@@ -4026,9 +4026,9 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz",
"integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==",
"version": "8.0.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -4036,7 +4036,7 @@
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.13",
"rolldown": "1.0.0-rc.15",
"tinyglobby": "^0.2.15"
},
"bin": {
@@ -4228,9 +4228,9 @@
}
},
"node_modules/webpack": {
"version": "5.105.4",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz",
"integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==",
"version": "5.106.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.1.tgz",
"integrity": "sha512-EW8af29ak8Oaf4T8k8YsajjrDBDYgnKZ5er6ljWFJsXABfTNowQfvHLftwcepVgdz+IoLSdEAbBiM9DFXoll9w==",
"license": "MIT",
"peer": true,
"dependencies": {

View File

@@ -117,6 +117,18 @@ try {
$aliasHasSharedDomain = in_array($aliasDomain, $allDomains);
// Check if the alias has a username subdomain
$matchedBaseDomain = null;
foreach ($allDomains as $domain) {
if (str_ends_with($aliasDomain, ".{$domain}")) {
$matchedBaseDomain = $domain;
break;
}
}
$aliasHasUsernameDomain = ! is_null($matchedBaseDomain);
// Check if it is a bounce with a valid VERP...
if (substr($aliasEmail, 0, 2) === 'b_') {
if ($outboundMessageId = getIdFromVerp($aliasLocalPart, $aliasEmail)) {
@@ -145,39 +157,30 @@ try {
$aliasEmail = before($aliasEmail, '+').'@'.$aliasDomain;
}
$aliasActionQuery = Database::table('aliases')
->leftJoin('users', 'aliases.user_id', '=', 'users.id')
->where('aliases.email', $aliasEmail)
->selectRaw('CASE
WHEN aliases.deleted_at IS NOT NULL THEN ?
WHEN aliases.active = 0 THEN ?
WHEN users.reject_until > NOW() THEN ?
WHEN users.defer_until > NOW() THEN ?
ELSE "DUNNO"
END', [
ACTION_DOES_NOT_EXIST,
ACTION_ALIAS_DISCARD,
ACTION_REJECT,
ACTION_DEFER,
])
->first();
// Check if the alias already exists or not
$noAliasExists = Database::table('aliases')->select('id')->where('email', $aliasEmail)->doesntExist();
$noAliasExists = is_null($aliasActionQuery);
if ($noAliasExists && $aliasHasSharedDomain) {
// If admin username is set then allow through with catch-all
if ($adminUsername) {
sendAction('DUNNO');
} else {
sendAction(ACTION_DOES_NOT_EXIST);
}
sendAction(ACTION_DOES_NOT_EXIST);
} else {
$aliasAction = null;
if (! $noAliasExists) {
$aliasActionQuery = Database::table('aliases')
->leftJoin('users', 'aliases.user_id', '=', 'users.id')
->where('aliases.email', $aliasEmail)
->selectRaw('CASE
WHEN aliases.deleted_at IS NOT NULL THEN ?
WHEN aliases.active = 0 THEN ?
WHEN users.reject_until > NOW() THEN ?
WHEN users.defer_until > NOW() THEN ?
ELSE "DUNNO"
END', [
ACTION_DOES_NOT_EXIST,
ACTION_ALIAS_DISCARD,
ACTION_REJECT,
ACTION_DEFER,
])
->first();
$aliasAction = getAction($aliasActionQuery);
}
$aliasAction = $noAliasExists ? null : getAction($aliasActionQuery);
if (in_array($aliasAction, [ACTION_ALIAS_DISCARD, ACTION_DOES_NOT_EXIST])) {
// If the alias is inactive or deleted then increment the blocked count
@@ -191,17 +194,11 @@ try {
sendAction($aliasAction);
} elseif ($aliasHasUsernameDomain) {
$concatDomainsStatement = array_reduce(array_keys($allDomains), function ($carry, $key) {
$comma = $key === 0 ? '' : ',';
return "{$carry}{$comma}CONCAT(usernames.username, ?)";
}, '');
$dotDomains = array_map(fn ($domain) => ".{$domain}", $allDomains);
$aliasUsername = substr($aliasDomain, 0, -(strlen($matchedBaseDomain) + 1));
$usernameActionQuery = Database::table('usernames')
->leftJoin('users', 'usernames.user_id', '=', 'users.id')
->whereRaw('? IN ('.$concatDomainsStatement.')', [$aliasDomain, ...$dotDomains])
->where('usernames.username', $aliasUsername)
->selectRaw('CASE
WHEN ? AND usernames.catch_all = 0 AND (usernames.auto_create_regex IS NULL OR ? NOT REGEXP usernames.auto_create_regex) THEN ?
WHEN usernames.active = 0 THEN ?
@@ -322,22 +319,6 @@ function before($subject, $search)
return $result === false ? $subject : $result;
}
// Get the portion of a string before the last occurrence of a given value.
function beforeLast($subject, $search)
{
if ($search === '') {
return $subject;
}
$pos = mb_strrpos($subject, $search);
if ($pos === false) {
return $subject;
}
return mb_substr($subject, 0, $pos, 'UTF-8');
}
// Determine if a given string ends with a given substring
function endsWith($haystack, $needles)
{

View File

@@ -446,7 +446,7 @@
<icon name="pin" class="inline-block w-4 h-4 fill-current" />
</span>
<button
class="text-grey-400 tooltip outline-none"
class="text-grey-400 tooltip outline-none text-left"
data-tippy-content="Click to copy"
@click="clipboard(getAliasEmail(rows[props.row.originalIndex]))"
>

View File

@@ -17,16 +17,25 @@
</button>
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<select
v-model="filterType"
@change="updateFilter"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-grey-300 focus:outline-none focus:ring-cyan-500 focus:border-cyan-500 sm:text-sm rounded-md dark:border-grey-600 dark:bg-grey-700 dark:text-grey-200"
>
<option value="all">All</option>
<option value="outbound">Outbound Bounces</option>
<option value="inbound">Inbound Rejections</option>
</select>
</div>
</div>
<vue-good-table
v-if="rows.length"
v-on:sort-change="debounceToolips"
:columns="columns"
:rows="rows"
:sort-options="{
enabled: true,
initialSortBy: { field: 'created_at', type: 'desc' },
enabled: false,
}"
styleClass="vgt-table"
>
@@ -138,13 +147,158 @@
</template>
</vue-good-table>
<div v-else-if="search" class="text-center">
<!-- Pagination -->
<div
v-if="$page.props.initialRows.data.length"
class="mt-4 rounded-lg shadow flex items-center justify-between bg-white px-4 py-3 sm:px-6 overflow-x-auto horizontal-scroll dark:bg-grey-900"
>
<div class="flex flex-1 justify-between items-center md:hidden gap-x-3">
<Link
v-if="$page.props.initialRows.prev_page_url"
:href="$page.props.initialRows.prev_page_url"
as="button"
class="relative inline-flex items-center rounded-md border border-grey-300 bg-white px-4 py-2 text-sm font-medium text-grey-700 hover:bg-grey-50 dark:bg-grey-950 dark:hover:bg-grey-900 dark:text-grey-200"
>
Previous
</Link>
<span
v-else
class="relative inline-flex h-min items-center rounded-md border border-grey-300 px-4 py-2 text-sm font-medium text-grey-700 bg-grey-100 dark:bg-grey-800 dark:text-grey-200"
>Previous</span
>
<div class="flex flex-col items-center justify-center gap-y-2">
<p class="text-sm text-grey-700 text-center dark:text-grey-200">
Showing
{{ ' ' }}
<span class="font-medium">{{ $page.props.initialRows.from.toLocaleString() }}</span>
{{ ' ' }}
to
{{ ' ' }}
<span class="font-medium">{{ $page.props.initialRows.to.toLocaleString() }}</span>
{{ ' ' }}
of
{{ ' ' }}
<span class="font-medium">{{ $page.props.initialRows.total.toLocaleString() }}</span>
{{ ' ' }}
{{ $page.props.initialRows.total === 1 ? 'result' : 'results' }}
</p>
<select
v-model.number="pageSize"
@change="updatePageSize"
:disabled="updatePageSizeLoading"
class="relative rounded border-0 bg-transparent py-1 pr-8 text-grey-900 text-sm ring-1 ring-inset focus:z-10 focus:ring-2 focus:ring-inset ring-grey-300 focus:ring-indigo-600 disabled:cursor-not-allowed dark:text-grey-200"
>
<option class="dark:bg-grey-900" v-for="size in pageSizeOptions" :value="size">
{{ size }}
</option>
</select>
</div>
<Link
v-if="$page.props.initialRows.next_page_url"
:href="$page.props.initialRows.next_page_url"
as="button"
class="relative inline-flex h-min items-center rounded-md border border-grey-300 bg-white px-4 py-2 text-sm font-medium text-grey-700 dark:bg-grey-950 dark:hover:bg-grey-900 dark:text-grey-200 hover:bg-grey-50"
>
Next
</Link>
<span
v-else
class="relative inline-flex items-center rounded-md border border-grey-300 px-4 py-2 text-sm font-medium text-grey-700 dark:text-grey-200 bg-grey-100 dark:bg-grey-800"
>Next</span
>
</div>
<div class="hidden md:flex md:flex-1 md:items-center md:justify-between md:gap-x-2">
<div class="flex items-center gap-x-2">
<p class="text-sm text-grey-700 dark:text-grey-200">
Showing
{{ ' ' }}
<span class="font-medium">{{ $page.props.initialRows.from.toLocaleString() }}</span>
{{ ' ' }}
to
{{ ' ' }}
<span class="font-medium">{{ $page.props.initialRows.to.toLocaleString() }}</span>
{{ ' ' }}
of
{{ ' ' }}
<span class="font-medium">{{ $page.props.initialRows.total.toLocaleString() }}</span>
{{ ' ' }}
{{ $page.props.initialRows.total === 1 ? 'result' : 'results' }}
</p>
<select
v-model.number="pageSize"
@change="updatePageSize"
:disabled="updatePageSizeLoading"
class="relative rounded border-0 bg-transparent py-1 pr-8 text-grey-900 text-sm ring-1 ring-inset focus:z-10 focus:ring-2 focus:ring-inset ring-grey-300 focus:ring-indigo-600 disabled:cursor-not-allowed dark:text-grey-200"
>
<option class="dark:bg-grey-900" v-for="size in pageSizeOptions" :value="size">
{{ size }}
</option>
</select>
</div>
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<Link
v-if="$page.props.initialRows.prev_page_url"
:href="$page.props.initialRows.prev_page_url"
class="relative inline-flex items-center rounded-l-md border border-grey-300 bg-white px-2 py-2 text-sm font-medium text-grey-500 hover:bg-grey-50 focus:z-20 dark:bg-grey-900 dark:hover:bg-grey-950 dark:border-grey-500"
>
<span class="sr-only">Previous</span>
<ChevronLeftIcon class="h-5 w-5" aria-hidden="true" />
</Link>
<span
v-else
class="disabled cursor-not-allowed relative inline-flex items-center rounded-l-md border border-grey-300 bg-white px-2 py-2 text-sm font-medium text-grey-500 focus:z-20 dark:bg-grey-800 dark:border-grey-500"
>
<span class="sr-only">Previous</span>
<ChevronLeftIcon class="h-5 w-5" aria-hidden="true" />
</span>
<div v-for="link in links" v-bind:key="link.label">
<Link
v-if="link.url"
:href="link.url"
aria-current="page"
class="relative inline-flex items-center border z-10 px-4 py-2 text-sm font-medium focus:z-20"
:class="
link.active
? 'border-indigo-500 bg-indigo-50 text-indigo-600 dark:bg-grey-950 dark:text-grey-100 dark:border-grey-500'
: 'border-grey-300 bg-white text-grey-500 hover:bg-grey-50 dark:bg-grey-900 dark:hover:bg-grey-950 dark:text-grey-200 dark:border-grey-500'
"
>{{ link.label }}</Link
>
<span
v-else
class="relative inline-flex items-center border border-grey-300 bg-white px-4 py-2 text-sm font-medium text-grey-700 dark:bg-grey-900 dark:text-grey-200 dark:border-grey-500"
>...</span
>
</div>
<Link
v-if="$page.props.initialRows.next_page_url"
:href="$page.props.initialRows.next_page_url"
class="relative inline-flex items-center rounded-r-md border border-grey-300 bg-white px-2 py-2 text-sm font-medium text-grey-500 hover:bg-grey-50 focus:z-20 dark:bg-grey-900 dark:hover:bg-grey-950 dark:text-grey-200 dark:border-grey-500"
>
<span class="sr-only">Next</span>
<ChevronRightIcon class="h-5 w-5" aria-hidden="true" />
</Link>
<span
v-else
class="disabled cursor-not-allowed relative inline-flex items-center rounded-r-md border border-grey-300 bg-white px-2 py-2 text-sm font-medium text-grey-500 focus:z-20 dark:bg-grey-800 dark:text-grey-200 dark:border-grey-500"
>
<span class="sr-only">Next</span>
<ChevronRightIcon class="h-5 w-5" aria-hidden="true" />
</span>
</nav>
</div>
</div>
<div v-else-if="search || filterType !== 'all'" class="text-center">
<ExclamationTriangleIcon class="mx-auto h-16 w-16 text-grey-400 dark:text-grey-200" />
<h3 class="mt-2 text-lg font-medium text-grey-900 dark:text-white">
No Failed Deliveries found for that search
No Failed Deliveries found for that search or filter
</h3>
<p class="mt-1 text-md text-grey-500 dark:text-grey-200">
Try entering a different search term.
Try entering a different search term or changing the filter.
</p>
<div class="mt-6">
<Link
@@ -195,7 +349,7 @@
<div class="mt-6 flex flex-col sm:flex-row space-y-4 sm:space-y-0 sm:space-x-4">
<button
type="button"
@click="resendFailedDelivery(failedDeliveryToResend.id)"
@click="resendFailedDelivery(failedDeliveryToResend)"
class="px-4 py-3 text-cyan-900 font-semibold bg-cyan-400 hover:bg-cyan-300 border border-transparent rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:cursor-not-allowed"
:disabled="resendFailedDeliveryLoading"
>
@@ -252,12 +406,12 @@
</p>
<p class="mt-4 text-grey-700 dark:text-grey-200">
This page allows you to see any failed deliveries relating to your account and the reason
why they failed.
why they failed. It also displays any inbound emails that were rejected by the addy.io
servers before reaching your alias.
</p>
<p class="mt-4 text-grey-700 dark:text-grey-200">
Only failed delivery attempts from the addy.io servers to your recipients (or reply/send
attempts from your aliases) will be shown here. It will not show messages that failed to
reach the addy.io server from some other sender.
attempts from your aliases) and inbound rejections to your aliases will be shown here.
</p>
<div class="mt-6 flex flex-col sm:flex-row">
@@ -316,19 +470,24 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Head, Link } from '@inertiajs/vue3'
import { ref, watch, onMounted } from 'vue'
import { Head, Link, router } from '@inertiajs/vue3'
import Modal from '../Components/Modal.vue'
import { roundArrow } from 'tippy.js'
import tippy from 'tippy.js'
import { notify } from '@kyvg/vue3-notification'
import { VueGoodTable } from 'vue-good-table-next'
import Multiselect from '@vueform/multiselect'
import { InformationCircleIcon, ExclamationTriangleIcon } from '@heroicons/vue/24/outline'
import {
InformationCircleIcon,
ExclamationTriangleIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from '@heroicons/vue/24/outline'
const props = defineProps({
initialRows: {
type: Array,
type: Object,
required: true,
},
recipientOptions: {
@@ -338,13 +497,27 @@ const props = defineProps({
search: {
type: String,
},
initialFilter: {
type: String,
default: 'all',
},
initialPageSize: {
type: Number,
default: 25,
},
})
onMounted(() => {
addTooltips()
})
const rows = ref(props.initialRows)
const rows = ref(props.initialRows.data)
const links = ref(props.initialRows.links.slice(1, -1))
const filterType = ref(props.initialFilter)
const pageSize = ref(props.initialPageSize)
const updatePageSizeLoading = ref(false)
const pageSizeOptions = [25, 50, 100]
const resendFailedDeliveryLoading = ref(false)
const resendFailedDeliveryModalOpen = ref(false)
@@ -362,6 +535,14 @@ const failedDeliveryIdToDelete = ref(null)
const tippyInstance = ref(null)
const errors = ref({})
watch(
() => props.initialRows,
newVal => {
rows.value = newVal.data
links.value = newVal.links.slice(1, -1)
},
)
const columns = [
{
label: 'Created',
@@ -408,12 +589,38 @@ const columns = [
},
]
const resendFailedDelivery = id => {
const visitWithParams = (extraParams = {}, omitKeys = []) => {
let params = Object.assign({}, route().params, extraParams)
if (filterType.value === 'all') {
omitKeys.push('filter')
}
if (pageSize.value === 25) {
omitKeys.push('page_size')
}
router.visit(route('failed_deliveries.index', _.omit(params, omitKeys)), {
only: ['initialRows', 'search', 'initialFilter', 'initialPageSize'],
preserveState: true,
})
}
const updateFilter = () => {
visitWithParams({ filter: filterType.value }, ['page'])
}
const updatePageSize = () => {
updatePageSizeLoading.value = true
visitWithParams({ page_size: pageSize.value }, ['page'])
updatePageSizeLoading.value = false
}
const resendFailedDelivery = failedDelivery => {
resendFailedDeliveryLoading.value = true
axios
.post(
`/api/v1/failed-deliveries/${id}/resend`,
`/api/v1/failed-deliveries/${failedDelivery.id}/resend`,
JSON.stringify({
recipient_ids: failedDeliveryRecipientsToResend.value,
}),
@@ -422,6 +629,8 @@ const resendFailedDelivery = id => {
},
)
.then(response => {
successMessage('Failed Delivery Resent Successfully')
failedDelivery.resent = true
resendFailedDeliveryModalOpen.value = false
resendFailedDeliveryLoading.value = false
})

View File

@@ -0,0 +1,28 @@
@component('mail::message')
# Alias Deactivated via One-Click Unsubscribe
Your alias **{{ $aliasEmail }}** has been deactivated because a one-click unsubscribe request was received.
**Important:** This means all emails to this alias will now be discarded, not just emails from the sender in the email that the unsubscribe link was clicked.
@if($ipAddress || $userAgent || $timestamp)
**Request details:**
@if($timestamp)
- **When:** {{ $timestamp }}
@endif
@if($ipAddress)
- **IP:** {{ $ipAddress }}
@endif
@if($userAgent)
- **User Agent:** {{ $userAgent }}
@endif
@endif
If you did not intend to deactivate this alias, you can click the button below, search for it and reactivate it.
@component('mail::button', ['url' => config('app.url').'/aliases'])
View Your Aliases
@endcomponent
@endcomponent

View File

@@ -0,0 +1,28 @@
@component('mail::message')
# Alias Deleted via One-Click Unsubscribe
Your alias **{{ $aliasEmail }}** has been deleted because a one-click unsubscribe request was received.
**Important:** This means all emails to this alias will now be rejected, not just emails from the sender in the email that the unsubscribe link was clicked.
@if($ipAddress || $userAgent || $timestamp)
**Request details:**
@if($timestamp)
- **When:** {{ $timestamp }}
@endif
@if($ipAddress)
- **IP:** {{ $ipAddress }}
@endif
@if($userAgent)
- **User Agent:** {{ $userAgent }}
@endif
@endif
If you did not intend to delete this alias, you can click the button below, search for it and restore it.
@component('mail::button', ['url' => config('app.url').'/aliases?deleted=only'])
View Your Deleted Aliases
@endcomponent
@endcomponent

View File

@@ -19,6 +19,7 @@ Schedule::command('anonaddy:check-domains-mx-validation')->daily();
Schedule::command('anonaddy:clear-failed-deliveries')->daily();
Schedule::command('anonaddy:clear-outbound-messages')->everySixHours();
Schedule::command('anonaddy:email-users-with-token-expiring-soon')->daily();
Schedule::command('anonaddy:parse-postfix-mail-log')->everyFiveMinutes();
Schedule::command('auth:clear-resets')->daily();
Schedule::command('sanctum:prune-expired --hours=168')->daily();
Schedule::command('cache:prune-stale-tags')->hourly();

View File

@@ -52,6 +52,56 @@ class FailedDeliveriesTest extends TestCase
$this->assertEquals($failedDelivery->code, $response->json()['data']['code']);
}
#[Test]
public function user_can_filter_failed_deliveries_by_inbound_type()
{
FailedDelivery::factory()->count(2)->create([
'user_id' => $this->user->id,
'email_type' => 'IR',
]);
FailedDelivery::factory()->create([
'user_id' => $this->user->id,
'email_type' => 'F',
]);
$response = $this->json('GET', '/api/v1/failed-deliveries?filter[email_type]=inbound');
$response->assertSuccessful();
$this->assertCount(2, $response->json()['data']);
}
#[Test]
public function user_can_filter_failed_deliveries_by_outbound_type()
{
FailedDelivery::factory()->count(2)->create([
'user_id' => $this->user->id,
'email_type' => 'IR',
]);
FailedDelivery::factory()->create([
'user_id' => $this->user->id,
'email_type' => 'F',
]);
$response = $this->json('GET', '/api/v1/failed-deliveries?filter[email_type]=outbound');
$response->assertSuccessful();
$this->assertCount(1, $response->json()['data']);
}
#[Test]
public function user_can_paginate_failed_deliveries()
{
FailedDelivery::factory()->count(3)->create([
'user_id' => $this->user->id,
]);
$response = $this->json('GET', '/api/v1/failed-deliveries?page[size]=2&page[number]=1');
$response->assertSuccessful();
$this->assertCount(2, $response->json()['data']);
$this->assertEquals(3, $response->json()['meta']['total']);
}
#[Test]
public function user_can_delete_failed_delivery()
{

View File

@@ -2,6 +2,7 @@
namespace Tests\Feature\Api;
use App\Models\Alias;
use App\Models\Domain;
use App\Models\Recipient;
use App\Notifications\CustomVerifyEmail;
@@ -66,6 +67,24 @@ class RecipientsTest extends TestCase
$this->assertArrayNotHasKey('aliases_count', $response->json()['data'][0]);
}
#[Test]
public function recipients_index_alias_count_excludes_soft_deleted_aliases(): void
{
$recipient = Recipient::factory()->create(['user_id' => $this->user->id]);
$activeAlias = Alias::factory()->create(['user_id' => $this->user->id]);
$deletedAlias = Alias::factory()->create(['user_id' => $this->user->id]);
$activeAlias->recipients()->attach($recipient->id);
$deletedAlias->recipients()->attach($recipient->id);
$deletedAlias->delete();
$response = $this->json('GET', '/api/v1/recipients');
$response->assertSuccessful();
$row = collect($response->json('data'))->firstWhere('id', $recipient->id);
$this->assertNotNull($row);
$this->assertSame(1, (int) $row['aliases_count']);
}
#[Test]
public function recipient_show_omits_alias_count_when_filter_alias_count_is_false()
{

View File

@@ -0,0 +1,119 @@
<?php
namespace Tests\Feature;
use App\Models\Alias;
use App\Notifications\AliasDeactivatedByUnsubscribeNotification;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Routing\Middleware\ThrottleRequestsWithRedis;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\URL;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class DeactivateAliasOneClickTest extends TestCase
{
use LazilyRefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(ThrottleRequestsWithRedis::class);
}
#[Test]
public function signed_post_deactivates_alias_and_sends_notification()
{
Notification::fake();
$user = $this->createUser('johndoe');
$alias = Alias::factory()->create([
'user_id' => $user->id,
'active' => true,
]);
$url = URL::signedRoute('deactivate_post', ['alias' => $alias->id]);
$response = $this->post($url);
$response->assertStatus(200);
$this->assertFalse($alias->refresh()->active);
Notification::assertSentTo($user, AliasDeactivatedByUnsubscribeNotification::class);
}
#[Test]
public function already_inactive_alias_does_not_send_notification()
{
Notification::fake();
$user = $this->createUser('johndoe');
$alias = Alias::factory()->create([
'user_id' => $user->id,
'active' => false,
]);
$url = URL::signedRoute('deactivate_post', ['alias' => $alias->id]);
$response = $this->post($url);
$response->assertStatus(200);
$this->assertFalse($alias->refresh()->active);
Notification::assertNotSentTo($user, AliasDeactivatedByUnsubscribeNotification::class);
}
#[Test]
public function duplicate_requests_only_send_one_notification()
{
Notification::fake();
$user = $this->createUser('johndoe');
$alias = Alias::factory()->create([
'user_id' => $user->id,
'active' => true,
]);
$url = URL::signedRoute('deactivate_post', ['alias' => $alias->id]);
$this->post($url);
$this->assertFalse($alias->refresh()->active);
$alias->update(['active' => true]);
$this->post($url);
Notification::assertSentToTimes($user, AliasDeactivatedByUnsubscribeNotification::class, 1);
$this->assertTrue(Cache::has("unsubscribe-deactivate-notify:{$alias->id}"));
}
#[Test]
public function invalid_signature_returns_403()
{
$user = $this->createUser('johndoe');
$alias = Alias::factory()->create([
'user_id' => $user->id,
'active' => true,
]);
$response = $this->post(route('deactivate_post', ['alias' => $alias->id]));
$response->assertStatus(403);
$this->assertTrue($alias->refresh()->active);
}
#[Test]
public function non_existent_alias_returns_404()
{
$url = URL::signedRoute('deactivate_post', ['alias' => '00000000-0000-0000-0000-000000000000']);
$response = $this->post($url);
$response->assertStatus(404);
}
}

View File

@@ -3,8 +3,11 @@
namespace Tests\Feature;
use App\Models\Alias;
use App\Notifications\AliasDeletedByUnsubscribeNotification;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Routing\Middleware\ThrottleRequestsWithRedis;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\URL;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@@ -22,8 +25,10 @@ class DeleteAliasOneClickTest extends TestCase
}
#[Test]
public function signed_post_deletes_alias_and_returns_empty_response()
public function signed_post_deletes_alias_and_sends_notification()
{
Notification::fake();
$user = $this->createUser('johndoe');
$alias = Alias::factory()->create([
@@ -42,6 +47,53 @@ class DeleteAliasOneClickTest extends TestCase
$alias->refresh();
$this->assertNotNull($alias->deleted_at);
Notification::assertSentTo($user, AliasDeletedByUnsubscribeNotification::class);
}
#[Test]
public function already_deleted_alias_returns_404_and_does_not_send_notification()
{
Notification::fake();
$user = $this->createUser('johndoe');
$alias = Alias::factory()->create([
'user_id' => $user->id,
'deleted_at' => now(),
]);
$url = URL::signedRoute('delete_post', ['alias' => $alias->id]);
$response = $this->post($url);
$response->assertStatus(404);
Notification::assertNotSentTo($user, AliasDeletedByUnsubscribeNotification::class);
}
#[Test]
public function duplicate_requests_only_send_one_notification()
{
Notification::fake();
$user = $this->createUser('johndoe');
$alias = Alias::factory()->create([
'user_id' => $user->id,
'active' => true,
]);
$url = URL::signedRoute('delete_post', ['alias' => $alias->id]);
$this->post($url);
$this->assertNotNull($alias->refresh()->deleted_at);
$alias->restore();
$this->post($url);
Notification::assertSentToTimes($user, AliasDeletedByUnsubscribeNotification::class, 1);
$this->assertTrue(Cache::has("unsubscribe-delete-notify:{$alias->id}"));
}
#[Test]

View File

@@ -0,0 +1,160 @@
<?php
namespace Tests\Feature;
use App\Models\Alias;
use App\Models\FailedDelivery;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class ParsePostfixMailLogTest extends TestCase
{
use RefreshDatabase;
protected $logPath;
protected function setUp(): void
{
parent::setUp();
$this->user = $this->createUser();
Storage::fake('local');
$this->logPath = storage_path('app/mail.log');
Config::set('anonaddy.postfix_log_path', $this->logPath);
Config::set('anonaddy.all_domains', ['anonaddy.me']);
}
protected function tearDown(): void
{
if (file_exists($this->logPath)) {
unlink($this->logPath);
}
parent::tearDown();
}
public function test_it_parses_rejection_lines_and_creates_failed_delivery_for_users()
{
$alias = Alias::factory()->create(['user_id' => $this->user->id, 'email' => 'test@anonaddy.me']);
$logContent = "Mar 17 10:30:00 server postfix/smtpd[12345]: NOQUEUE: reject: RCPT from unknown[1.2.3.4]: 450 4.7.1 Client host rejected: cannot find your hostname; from=<s@x.com> to=<test@anonaddy.me> proto=ESMTP helo=<1.2.3.4>\n";
file_put_contents($this->logPath, $logContent);
$this->artisan('anonaddy:parse-postfix-mail-log')->assertExitCode(0);
$this->assertDatabaseHas('failed_deliveries', [
'user_id' => $this->user->id,
'alias_id' => $alias->id,
'email_type' => 'IR',
'code' => '450 4.7.1 Client host rejected: cannot find your hostname',
'status' => '450',
'remote_mta' => 'unknown[1.2.3.4]',
]);
$failedDelivery = FailedDelivery::first();
$this->assertEquals('s@x.com', $failedDelivery->sender);
$this->assertEquals('test@anonaddy.me', $failedDelivery->destination);
}
public function test_it_skips_missing_alias()
{
$logContent = "Mar 17 10:30:00 server postfix/smtpd[12345]: NOQUEUE: reject: RCPT from unknown[1.2.3.4]: 450 4.7.1 Client host rejected; from=<s@x.com> to=<nobody@anonaddy.me> proto=ESMTP helo=<1.2.3.4>\n";
file_put_contents($this->logPath, $logContent);
$this->artisan('anonaddy:parse-postfix-mail-log')->assertExitCode(0);
$this->assertDatabaseCount('failed_deliveries', 0);
}
public function test_it_handles_log_rotation_and_maintains_position()
{
$alias = Alias::factory()->create(['user_id' => $this->user->id, 'email' => 'test@anonaddy.me']);
$logContent1 = "Mar 17 10:30:00 server postfix/smtpd[12345]: NOQUEUE: reject: RCPT from unknown[1.2.3.4]: 450 4.7.1 Client host rejected; from=<s@x.com> to=<test@anonaddy.me>\n";
file_put_contents($this->logPath, $logContent1);
$this->artisan('anonaddy:parse-postfix-mail-log')->assertExitCode(0);
$this->assertDatabaseCount('failed_deliveries', 1);
// Add a second line to same file
$logContent2 = "Mar 17 10:31:00 server postfix/smtpd[12345]: NOQUEUE: reject: RCPT from unknown[1.2.3.4]: 450 4.7.1 Client host rejected; from=<b@x.com> to=<test@anonaddy.me>\n";
file_put_contents($this->logPath, $logContent1.$logContent2);
$this->artisan('anonaddy:parse-postfix-mail-log')->assertExitCode(0);
$this->assertDatabaseCount('failed_deliveries', 2);
// Simulate log rotation (file smaller)
$logContent3 = "Mar 17 10:32:00 server postfix/smtpd[12345]: NOQUEUE: reject: RCPT from unknown[1.2.3.4]: 450 4.7.1 Client host rejected; from=<c@x.com> to=<test@anonaddy.me>\n";
file_put_contents($this->logPath, $logContent3);
$this->artisan('anonaddy:parse-postfix-mail-log')->assertExitCode(0);
$this->assertDatabaseCount('failed_deliveries', 3);
}
public function test_it_prevents_duplicate_rejections()
{
$alias = Alias::factory()->create(['user_id' => $this->user->id, 'email' => 'test@anonaddy.me']);
$logContent = "Mar 17 10:30:00 server postfix/smtpd[12345]: NOQUEUE: reject: RCPT from unknown[1.2.3.4]: 450 4.7.1 Client host rejected; from=<s@x.com> to=<test@anonaddy.me>\n";
file_put_contents($this->logPath, $logContent);
$this->artisan('anonaddy:parse-postfix-mail-log')->assertExitCode(0);
$this->assertDatabaseCount('failed_deliveries', 1);
// Reset position to force re-reading the same line
Storage::disk('local')->put('postfix_log_position.txt', '0');
$this->artisan('anonaddy:parse-postfix-mail-log')->assertExitCode(0);
$this->assertDatabaseCount('failed_deliveries', 1); // Should not duplicate
}
public function test_it_parses_milter_reject_lines()
{
$alias = Alias::factory()->create(['user_id' => $this->user->id, 'email' => 'test@anonaddy.com']);
$logContent = "Mar 18 12:53:05 mail2 postfix/cleanup[1661539]: 7EB9BFF16A: milter-reject: END-OF-MESSAGE from mx.abc.eu[86.106.123.126]: 5.7.1 Spam message rejected; from=<noreply@hi.market> to=<test@anonaddy.com> proto=ESMTP helo=<mx.abc.eu>\n";
file_put_contents($this->logPath, $logContent);
$this->artisan('anonaddy:parse-postfix-mail-log')->assertExitCode(0);
$this->assertDatabaseHas('failed_deliveries', [
'user_id' => $this->user->id,
'alias_id' => $alias->id,
'email_type' => 'IR',
'code' => '5.7.1 Spam message rejected',
'status' => '5.7.1',
'remote_mta' => 'mx.abc.eu[86.106.123.126]',
'bounce_type' => 'spam',
]);
$failedDelivery = FailedDelivery::first();
$this->assertEquals('noreply@hi.market', $failedDelivery->sender);
$this->assertEquals('test@anonaddy.com', $failedDelivery->destination);
}
public function test_it_parses_discard_lines()
{
$alias = Alias::factory()->create(['user_id' => $this->user->id, 'email' => 'caloric.test@anonaddy.com']);
$logContent = "Mar 18 06:55:15 mail2 postfix/smtpd[1491842]: NOQUEUE: discard: RCPT from a.b.com[52.48.1.81]: <caloric.test@anonaddy.com>: Recipient address is inactive alias; from=<takedown@b.com> to=<caloric.test@anonaddy.com> proto=SMTP helo=<a.b.com>\n";
file_put_contents($this->logPath, $logContent);
$this->artisan('anonaddy:parse-postfix-mail-log')->assertExitCode(0);
$this->assertDatabaseHas('failed_deliveries', [
'user_id' => $this->user->id,
'alias_id' => $alias->id,
'email_type' => 'IR',
'code' => 'Recipient address is inactive alias',
'status' => '',
'remote_mta' => 'a.b.com[52.48.1.81]',
'bounce_type' => 'hard',
]);
$failedDelivery = FailedDelivery::first();
$this->assertEquals('takedown@b.com', $failedDelivery->sender);
$this->assertEquals('caloric.test@anonaddy.com', $failedDelivery->destination);
}
}

View File

@@ -34,7 +34,7 @@ class ShowFailedDeliveriesTest extends TestCase
$response->assertSuccessful();
$response->assertInertia(fn (Assert $page) => $page
->has('initialRows', 3, fn (Assert $page) => $page
->has('initialRows.data', 3, fn (Assert $page) => $page
->where('user_id', $this->user->id)
->etc()
)
@@ -61,13 +61,71 @@ class ShowFailedDeliveriesTest extends TestCase
$response->assertSuccessful();
$response->assertInertia(fn (Assert $page) => $page
->has('initialRows', 3, fn (Assert $page) => $page
->has('initialRows.data', 3, fn (Assert $page) => $page
->where('user_id', $this->user->id)
->etc()
)
);
$this->assertTrue($response->data('page')['props']['initialRows'][0]['id'] === $b->id);
$this->assertTrue($response->data('page')['props']['initialRows'][1]['id'] === $c->id);
$this->assertTrue($response->data('page')['props']['initialRows'][2]['id'] === $a->id);
$this->assertTrue($response->data('page')['props']['initialRows']['data'][0]['id'] === $b->id);
$this->assertTrue($response->data('page')['props']['initialRows']['data'][1]['id'] === $c->id);
$this->assertTrue($response->data('page')['props']['initialRows']['data'][2]['id'] === $a->id);
}
#[Test]
public function user_can_filter_by_inbound_rejections()
{
FailedDelivery::factory()->count(2)->create([
'user_id' => $this->user->id,
'email_type' => 'IR',
]);
FailedDelivery::factory()->create([
'user_id' => $this->user->id,
'email_type' => 'F',
]);
$response = $this->get('/failed-deliveries?filter=inbound');
$response->assertSuccessful();
$response->assertInertia(fn (Assert $page) => $page
->has('initialRows.data', 2)
->where('initialFilter', 'inbound')
);
}
#[Test]
public function user_can_filter_by_outbound_bounces()
{
FailedDelivery::factory()->count(2)->create([
'user_id' => $this->user->id,
'email_type' => 'IR',
]);
FailedDelivery::factory()->create([
'user_id' => $this->user->id,
'email_type' => 'F',
]);
$response = $this->get('/failed-deliveries?filter=outbound');
$response->assertSuccessful();
$response->assertInertia(fn (Assert $page) => $page
->has('initialRows.data', 1)
->where('initialFilter', 'outbound')
);
}
#[Test]
public function user_can_paginate_failed_deliveries()
{
FailedDelivery::factory()->count(30)->create([
'user_id' => $this->user->id,
]);
$response = $this->get('/failed-deliveries?page_size=50');
$response->assertSuccessful();
$response->assertInertia(fn (Assert $page) => $page
->has('initialRows.data', 30)
->where('initialPageSize', 50)
);
}
}