mirror of
https://github.com/anonaddy/anonaddy
synced 2026-04-25 17:15:29 +02:00
Added inbound rejections to failed deliveries
This commit is contained in:
@@ -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
|
||||
|
||||
146
SELF-HOSTING.md
146
SELF-HOSTING.md
@@ -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.
|
||||
|
||||
193
app/Console/Commands/ParsePostfixMailLog.php
Normal file
193
app/Console/Commands/ParsePostfixMailLog.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
42
app/Http/Requests/IndexFailedDeliveryRequest.php
Normal file
42
app/Http/Requests/IndexFailedDeliveryRequest.php
Normal 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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
67
app/Notifications/AliasDeletedByUnsubscribeNotification.php
Normal file
67
app/Notifications/AliasDeletedByUnsubscribeNotification.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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');
|
||||
|
||||
@@ -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
36
composer.lock
generated
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
214
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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,20 +157,6 @@ try {
|
||||
$aliasEmail = before($aliasEmail, '+').'@'.$aliasDomain;
|
||||
}
|
||||
|
||||
// Check if the alias already exists or not
|
||||
$noAliasExists = Database::table('aliases')->select('id')->where('email', $aliasEmail)->doesntExist();
|
||||
|
||||
if ($noAliasExists && $aliasHasSharedDomain) {
|
||||
// If admin username is set then allow through with catch-all
|
||||
if ($adminUsername) {
|
||||
sendAction('DUNNO');
|
||||
} else {
|
||||
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)
|
||||
@@ -176,8 +174,13 @@ try {
|
||||
])
|
||||
->first();
|
||||
|
||||
$aliasAction = getAction($aliasActionQuery);
|
||||
}
|
||||
// Check if the alias already exists or not
|
||||
$noAliasExists = is_null($aliasActionQuery);
|
||||
|
||||
if ($noAliasExists && $aliasHasSharedDomain) {
|
||||
sendAction(ACTION_DOES_NOT_EXIST);
|
||||
} else {
|
||||
$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)
|
||||
{
|
||||
|
||||
@@ -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]))"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
28
resources/views/mail/alias_deleted_by_unsubscribe.blade.php
Normal file
28
resources/views/mail/alias_deleted_by_unsubscribe.blade.php
Normal 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
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
119
tests/Feature/DeactivateAliasOneClickTest.php
Normal file
119
tests/Feature/DeactivateAliasOneClickTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
160
tests/Feature/ParsePostfixMailLogTest.php
Normal file
160
tests/Feature/ParsePostfixMailLogTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user