Will Browning 4 роки тому
батько
коміт
193b325baa
49 змінених файлів з 2042 додано та 509 видалено
  1. 4 1
      .env.example
  2. 0 1
      .husky/.gitignore
  3. 30 0
      .husky/_/husky.sh
  4. 15 10
      README.md
  5. 12 25
      SELF-HOSTING.md
  6. 59 0
      app/Console/Commands/CheckDomainsMxValidation.php
  7. 59 0
      app/Console/Commands/CheckDomainsSendingVerification.php
  8. 43 0
      app/Console/Commands/ClearFailedDeliveries.php
  9. 43 0
      app/Console/Commands/ClearPostfixQueueIds.php
  10. 141 0
      app/Console/Commands/ReceiveEmail.php
  11. 4 0
      app/Console/Kernel.php
  12. 32 0
      app/CustomMailDriver/CustomMailManager.php
  13. 182 0
      app/CustomMailDriver/CustomSmtpTransport.php
  14. 2 2
      app/Http/Controllers/Auth/ForgotUsernameController.php
  15. 8 0
      app/Http/Controllers/DomainVerificationController.php
  16. 18 0
      app/Http/Controllers/ShowFailedDeliveryController.php
  17. 1 0
      app/Http/Resources/DomainResource.php
  18. 1 0
      app/Jobs/DeleteAccount.php
  19. 2 2
      app/Mail/ReplyToEmail.php
  20. 2 2
      app/Mail/SendFromEmail.php
  21. 8 0
      app/Models/Alias.php
  22. 46 0
      app/Models/Domain.php
  23. 81 0
      app/Models/FailedDelivery.php
  24. 29 0
      app/Models/PostfixQueueId.php
  25. 8 0
      app/Models/Recipient.php
  26. 8 0
      app/Models/User.php
  27. 65 0
      app/Notifications/DomainMxRecordsInvalid.php
  28. 68 0
      app/Notifications/DomainUnverifiedForSending.php
  29. 179 165
      composer.lock
  30. 3 3
      config/version.yml
  31. 28 0
      database/factories/FailedDeliveryFactory.php
  32. 34 0
      database/migrations/2021_07_05_141300_create_postfix_queue_ids_table.php
  33. 32 0
      database/migrations/2021_07_14_140246_add_domain_mx_validated_at_to_domains_table.php
  34. 42 0
      database/migrations/2021_07_15_083825_create_failed_deliveries_table.php
  35. 295 291
      package-lock.json
  36. 2 1
      package.json
  37. 2 0
      resources/css/app.css
  38. 1 0
      resources/js/app.js
  39. 2 1
      resources/js/pages/Aliases.vue
  40. 53 2
      resources/js/pages/Domains.vue
  41. 276 0
      resources/js/pages/FailedDeliveries.vue
  42. 11 2
      resources/js/pages/Rules.vue
  43. 9 0
      resources/views/failed_deliveries/index.blade.php
  44. 14 0
      resources/views/mail/domain_mx_records_invalid.blade.php
  45. 18 0
      resources/views/mail/domain_unverified_for_sending.blade.php
  46. 3 0
      resources/views/nav/nav.blade.php
  47. 2 0
      routes/web.php
  48. 1 1
      tests/Feature/ShowDomainsTest.php
  49. 64 0
      tests/Feature/ShowFailedDeliveriesTest.php

+ 4 - 1
.env.example

@@ -27,9 +27,12 @@ REDIS_HOST=127.0.0.1
 REDIS_PASSWORD=null
 REDIS_PORT=6379
 
-MAIL_DRIVER=sendmail
 MAIL_FROM_NAME=Example
 MAIL_FROM_ADDRESS=mailer@example.com
+MAIL_DRIVER=smtp
+MAIL_HOST=mail.example.com
+MAIL_PORT=25
+MAIL_ENCRYPTION=tls
 
 ANONADDY_RETURN_PATH=mailer@example.com
 ANONADDY_ADMIN_USERNAME=johndoe

+ 0 - 1
.husky/.gitignore

@@ -1 +0,0 @@
-_

+ 30 - 0
.husky/_/husky.sh

@@ -0,0 +1,30 @@
+#!/bin/sh
+if [ -z "$husky_skip_init" ]; then
+  debug () {
+    [ "$HUSKY_DEBUG" = "1" ] && echo "husky (debug) - $1"
+  }
+
+  readonly hook_name="$(basename "$0")"
+  debug "starting $hook_name..."
+
+  if [ "$HUSKY" = "0" ]; then
+    debug "HUSKY env variable is set to 0, skipping hook"
+    exit 0
+  fi
+
+  if [ -f ~/.huskyrc ]; then
+    debug "sourcing ~/.huskyrc"
+    . ~/.huskyrc
+  fi
+
+  export readonly husky_skip_init=1
+  sh -e "$0" "$@"
+  exitCode="$?"
+
+  if [ $exitCode != 0 ]; then
+    echo "husky - $hook_name hook exited with code $exitCode (error)"
+    exit $exitCode
+  fi
+
+  exit 0
+fi

+ 15 - 10
README.md

@@ -143,15 +143,13 @@ Yes there is an [open-source](https://github.com/anonaddy/browser-extension) bro
 
 ## Is there an Android app?
 
-There is not an official Android app that I have made myself as I am not familiar with mobile development.
+Yes, there is an excellent [open-source](https://gitlab.com/Stjin/anonaddy-android) Android app created by [Stjin](https://twitter.com/Stjinchan) that is available to download from the [Play Store](https://play.google.com/store/apps/details?id=host.stjin.anonaddy) (paid) and [F-Droid](https://f-droid.org/packages/host.stjin.anonaddy) (free). The developer of this app has put in a lot of time and effort so if you would like to support him please purchase the Play Store version.
 
-There is however an excellent [open-source](https://gitlab.com/Stjin/anonaddy-android) Android app created by [Stjin](https://twitter.com/Stjinchan) that is available to download from the [Play Store](https://play.google.com/store/apps/details?id=host.stjin.anonaddy) (paid) and [F-Droid](https://f-droid.org/packages/host.stjin.anonaddy) (free). The developer of this app has put in a lot of time and effort so if you would like to support him please purchase the Play Store version.
-
-There is also another unofficial [open-source](https://github.com/KhalidWar/anonaddy) Android app created by [KhalidWar](https://twitter.com/RealKhalidWar) available on the [Play Store](https://play.google.com/store/apps/details?id=com.khalidwar.anonaddy).
+There is also another [open-source](https://github.com/KhalidWar/anonaddy) Android app created by [KhalidWar](https://twitter.com/RealKhalidWar) available on the [Play Store](https://play.google.com/store/apps/details?id=com.khalidwar.anonaddy).
 
 ## Is there an iOS app?
 
-Yes, [KhalidWar's](https://twitter.com/RealKhalidWar) unofficial [open-source](https://github.com/KhalidWar/anonaddy) app from above is also available on the [App Store](https://apps.apple.com/us/app/addymanager/id1547461270).
+Yes, [KhalidWar's](https://twitter.com/RealKhalidWar) [open-source](https://github.com/KhalidWar/anonaddy) app from above is also available on the [App Store](https://apps.apple.com/us/app/addymanager/id1547461270).
 
 ## How do I add my own GPG/OpenPGP key for encryption?
 
@@ -178,7 +176,7 @@ If you're concerned that your aliases are all linked by your username e.g. @john
 
 ## Where is the server located?
 
-The server is located in Amsterdam, Netherlands with [Greenhost.net](https://greenhost.net/). Greenhost focuses greatly on privacy and security and their servers run entirely on Dutch wind energy.
+The server is located in Amsterdam, Netherlands with [Greenhost.net](https://greenhost.net/). Greenhost focuses greatly on privacy and security and their servers run entirely on Dutch wind energy. The backup mail server is located in Warsaw, Poland with [UpCloud](https://upcloud.com).
 
 ## What if I don't trust you?
 
@@ -364,7 +362,7 @@ You can add 1 additional username as a Lite user and up to 3 additional username
 
 ## I'm not receiving any emails, what's wrong?
 
-Please make sure to add mailer@anonaddy.me, mailer@anonaddy.com and any other aliases you use to your address book and also to check your spam folder. Make sure to mark emails from us as safe if they turn up in spam.
+Please make sure to add mailer@anonaddy.me, mailer@anonaddy.com and any other aliases you use to your address book and also to check your spam folder. Make sure to mark emails from AnonAddy as safe if they turn up in spam.
 
 If an alias has been previously deleted and you try to send email to it, the emails will be rejected with an error message - "554 5.7.1 Recipient address rejected: Access denied".
 
@@ -374,13 +372,20 @@ The sender of the email may be failing SPF, DMARC or DNS blacklist checks result
 
 If you are forwarding emails to an icloud.com email address some users are having issues with a small number of emails being rejected (often those from Facebook).
 
-For some reason Apple seems to think these emails are spam and returns this error message:
+For some reason Apple seems to think these emails are spam/phishing and returns this error message:
 
 > Diagnostic-Code: smtp; 550 5.7.1 [CS01] Message rejected due to local policy.
 
 I have contacted Apple multiple times about this but they have not yet responded.
 
-If you are having issues with emails being rejected as "possibly spammy" by Google, iCloud or Microsoft then try adding a GPP key and **enabling encryption**. This will prevent the email's content being scanned and reduce the change of it being rejected.
+If you are having issues with emails being rejected as "possibly spammy" by Google, iCloud or Microsoft then please try the following steps if you can:
+
+1. **Replace the email subject** by going to your settings in AnonAddy
+2. Try adding a GPP key and **enabling encryption**. This will prevent the email's content being scanned and reduce the change of it being rejected.
+
+I will also soon be adding an option to change the format of the display from part of the "From:" header.
+
+If neither of the above options work then please try changing to another recipient so that you can continue to receive emails.
 
 If you still aren't receiving emails please contact me.
 
@@ -417,7 +422,7 @@ For any other questions just send an email to - [contact@anonaddy.com](mailto:co
 ## Software Requirements
 
 * Postfix (3.0.0+) (plus postfix-mysql for database queries and postfix-pcre)
-* PHP (7.4+) and the [php-mailparse](https://pecl.php.net/package/mailparse) extension, the [php-gnupg](https://pecl.php.net/package/gnupg) extension if you plan to encrypt forwarded emails, the [php-imagick](https://pecl.php.net/package/imagick) extension for generating 2FA QR codes
+* PHP (8.0+) and the [php-mailparse](https://pecl.php.net/package/mailparse) extension, the [php-gnupg](https://pecl.php.net/package/gnupg) extension if you plan to encrypt forwarded emails, the [php-imagick](https://pecl.php.net/package/imagick) extension for generating 2FA QR codes
 * Port 25 unblocked and open
 * Redis (4.x+) for throttling and queues
 * FQDN as hostname e.g. mail.anonaddy.me

+ 12 - 25
SELF-HOSTING.md

@@ -247,16 +247,7 @@ sudo postconf myhostname
 
 You'll see warnings that the mysql-... files do not exist. You should see mail.example.com, if you don't edit `/etc/postfix/main.cf` and update the myhostname value.
 
-Open up `/etc/postfix/master.cf` and update this line at the top of the file:
-
-```
-smtp       inet  n       -       -       -       -       smtpd
-    -o content_filter=anonaddy:dummy
-```
-
-This should be the only line for smtp.
-
-Then add these lines to the bottom of the file:
+Open up `/etc/postfix/master.cf` and add these lines to the bottom of the file:
 
 ```
 anonaddy unix - n n - - pipe
@@ -269,15 +260,7 @@ This command will pipe the email through to our applicaton so that we can determ
 
 ## Installing Nginx
 
-On Ubuntu 20.04 Nginx is included in the default repositories so we can simply run:
-
-```bash
-sudo apt update
-sudo apt install nginx
-sudo nginx -v
-```
-
-If you're on Ubuntu 18.04 you will need to add the following signing key and repo.
+To install Nginx add the following signing key and repo.
 
 Import the nginx signing key and the repository.
 
@@ -294,7 +277,7 @@ sudo apt install nginx
 sudo nginx -v
 ```
 
-At the time of writing this I have `nginx version: nginx/1.19.10`.
+At the time of writing this I have `nginx version: nginx/1.21.1`.
 
 Create the directory for where the application will be stored.
 
@@ -479,15 +462,17 @@ To install the certificate run:
 
 Make sure to change example.com to your domain.
 
+You might see the following error message "Run reload cmd: service nginx force-reload nginx.service is not active, cannot reload.", this can be ignored.
+
 You can now type `exit` to go back to the `johndoe` user instead of `root`.
 
 ## Installing MariaDB
 
-At the time of writing this the latest stable release is v10.5. Make sure to check for any newer releases.
+At the time of writing this the latest stable release is v10.6. Make sure to check for any newer releases.
 
 Follow the instructions on this link to install MariaDB (make sure to change to 18.04 if you are using it):
 
-[https://downloads.mariadb.org/mariadb/repositories/#distro=Ubuntu&distro_release=focal--ubuntu_focal&mirror=digital-pacific&version=10.5](https://downloads.mariadb.org/mariadb/repositories/#distro=Ubuntu&distro_release=focal--ubuntu_focal&mirror=digital-pacific&version=10.5)
+[https://downloads.mariadb.org/mariadb/repositories/#distro=Ubuntu&distro_release=focal--ubuntu_focal&mirror=nus&version=10.6](https://downloads.mariadb.org/mariadb/repositories/#distro=Ubuntu&distro_release=focal--ubuntu_focal&mirror=nus&version=10.6)
 
 Make sure it is running correctly and check the version
 
@@ -496,7 +481,7 @@ sudo systemctl status mariadb
 sudo mysql -V
 ```
 
-At the time of writing this I am using "Ver 15.1 Distrib 10.5.8-MariaDB"
+At the time of writing this I am using "Ver 15.1 Distrib 10.6.3-MariaDB"
 
 When running securing mariadb Answer `no` for "Switch to unix_socket authentication" and `yes` for "Change the root password?" (Set a secure MySQL root password and make a note of it somewhere e.g. password manager.). Answer `yes` (default) to the other questions.
 
@@ -966,7 +951,7 @@ group "headers" {
     "FROM_NEQ_DISPLAY_NAME" {
       weight = 0.0;
     }
-    
+
     "FORGED_RECIPIENTS" {
       weight = 0.0;
     }
@@ -1009,7 +994,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-20-04#option-3-%E2%80%94-installing-node-using-the-node-version-manager](https://www.digitalocean.com/community/tutorials/how-to-install-node-js-on-ubuntu-20-04#option-3-%E2%80%94-installing-node-using-the-node-version-manager)
 
-At the time of writing this I'm using the latest LTS - v14.15.1
+At the time of writing this I'm using the latest LTS - v14.17.3
 
 ```bash
 cd /var/www/anonaddy
@@ -1153,6 +1138,8 @@ sudo supervisorctl update
 sudo supervisorctl start anonaddy:*
 ```
 
+Run `sudo service nginx start` to make sure Nginx is running.
+
 ## Creating your account
 
 You should now be able to visit `app.example.com` if you've set the correct DNS records.

+ 59 - 0
app/Console/Commands/CheckDomainsMxValidation.php

@@ -0,0 +1,59 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\Domain;
+use App\Notifications\DomainMxRecordsInvalid;
+use Illuminate\Console\Command;
+
+class CheckDomainsMxValidation extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'anonaddy:check-domains-mx-validation';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Checks all existing domains to see if they still have valid MX records';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        Domain::all()
+        ->each(function ($domain) {
+            try {
+                if (! $domain->checkMxRecords()) {
+                    // Notify user via email only if domain's MX previously were valid
+                    if (!is_null($domain->domain_mx_validated_at)) {
+                        $domain->user->notify(new DomainMxRecordsInvalid($domain->domain));
+                    }
+
+                    $domain->domain_mx_validated_at = null;
+                    $domain->save();
+                }
+            } catch (\Exception $e) {
+                //
+            }
+        });
+    }
+}

+ 59 - 0
app/Console/Commands/CheckDomainsSendingVerification.php

@@ -0,0 +1,59 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\Domain;
+use App\Notifications\DomainUnverifiedForSending;
+use Illuminate\Console\Command;
+
+class CheckDomainsSendingVerification extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'anonaddy:check-domains-sending-verification';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Checks all existing domains to see if they are still verified for sending';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return mixed
+     */
+    public function handle()
+    {
+        Domain::whereNotNull('domain_sending_verified_at')->get()
+            ->each(function ($domain) {
+                try {
+                    $result = $domain->checkVerificationForSending();
+
+                    if ($result->getData()->success === false) {
+                        // Notify user via email, give reason
+                        $domain->user->notify(new DomainUnverifiedForSending($domain->domain, $result->getData()->message));
+
+                        $domain->domain_sending_verified_at = null;
+                        $domain->save();
+                    }
+                } catch (\Exception $e) {
+                    //
+                }
+            });
+    }
+}

+ 43 - 0
app/Console/Commands/ClearFailedDeliveries.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\FailedDelivery;
+use Illuminate\Console\Command;
+
+class ClearFailedDeliveries extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'anonaddy:clear-failed-deliveries';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Clears failed deliveries that are older than 3 days';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        FailedDelivery::where('created_at', '<=', now()->subDays(3))->delete();
+    }
+}

+ 43 - 0
app/Console/Commands/ClearPostfixQueueIds.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\PostfixQueueId;
+use Illuminate\Console\Command;
+
+class ClearPostfixQueueIds extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'anonaddy:clear-postfix-queue-ids';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Clears postfix queue ids that are older than 7 days';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        PostfixQueueId::where('created_at', '<=', now()->subDays(7))->delete();
+    }
+}

+ 141 - 0
app/Console/Commands/ReceiveEmail.php

@@ -9,10 +9,13 @@ use App\Models\AdditionalUsername;
 use App\Models\Alias;
 use App\Models\Domain;
 use App\Models\EmailData;
+use App\Models\PostfixQueueId;
+use App\Models\Recipient;
 use App\Models\User;
 use App\Notifications\NearBandwidthLimit;
 use Illuminate\Console\Command;
 use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Mail;
 use Illuminate\Support\Str;
 use PhpMimeMailParser\Parser;
@@ -75,6 +78,11 @@ class ReceiveEmail extends Command
 
             foreach ($recipients as $recipient) {
 
+                // Handle bounces
+                if ($this->option('sender') === 'MAILER-DAEMON') {
+                    $this->handleBounce($recipient['email']);
+                }
+
                 // First determine if the alias already exists in the database
                 if ($alias = Alias::firstWhere('email', $recipient['local_part'] . '@' . $recipient['domain'])) {
                     $user = $alias->user;
@@ -254,6 +262,100 @@ class ReceiveEmail extends Command
         });
     }
 
+    protected function handleBounce($returnPath)
+    {
+        // Collect the attachments
+        $attachments = collect($this->parser->getAttachments());
+
+        // Find the delivery report
+        $deliveryReport = $attachments->filter(function ($attachment) {
+            return $attachment->getContentType() === 'message/delivery-status';
+        })->first();
+
+        if ($deliveryReport) {
+            $dsn = $this->parseDeliveryStatus($deliveryReport->getMimePartStr());
+
+            // Verify queue ID
+            if (isset($dsn['X-postfix-queue-id'])) {
+
+                // First check in DB
+                $postfixQueueId = PostfixQueueId::firstWhere('queue_id', strtoupper($dsn['X-postfix-queue-id']));
+
+                if (!$postfixQueueId) {
+                    exit(0);
+                }
+
+                // If found then delete from DB
+                $postfixQueueId->delete();
+            } else {
+                exit(0);
+            }
+
+            // Get the bounced email address
+            $bouncedEmailAddress = isset($dsn['Final-recipient']) ? trim(Str::after($dsn['Final-recipient'], ';')) : '';
+
+            $remoteMta = isset($dsn['Remote-mta']) ? trim(Str::after($dsn['Remote-mta'], ';')) : '';
+
+            if (isset($dsn['Diagnostic-code']) && isset($dsn['Status'])) {
+                // Try to determine the bounce type, HARD, SPAM, SOFT
+                $bounceType = $this->getBounceType($dsn['Diagnostic-code'], $dsn['Status']);
+
+                $diagnosticCode = Str::limit($dsn['Diagnostic-code'], 497);
+            } else {
+                $bounceType = null;
+                $diagnosticCode = null;
+            }
+
+            // The return path is the alias except when it is from an unverified custom domain
+            if ($returnPath !== config('anonaddy.return_path')) {
+                $alias = Alias::withTrashed()->firstWhere('email', $returnPath);
+
+                if (isset($alias)) {
+                    $user = $alias->user;
+                }
+            }
+
+            // Try to find a user from the bounced email address
+            if ($recipient = Recipient::select(['id', 'email', 'email_verified_at'])->whereNotNull('email_verified_at')->get()->firstWhere('email', $bouncedEmailAddress)) {
+                if (!isset($user)) {
+                    $user = $recipient->user;
+                }
+            }
+
+            // Get the undelivered message
+            $undeliveredMessage = $attachments->filter(function ($attachment) {
+                return in_array($attachment->getContentType(), ['text/rfc822-headers', 'message/rfc822']);
+            })->first();
+
+            $undeliveredMessageHeaders = [];
+
+            if ($undeliveredMessage) {
+                $undeliveredMessageHeaders = $this->parseDeliveryStatus($undeliveredMessage->getMimePartStr());
+            }
+
+            if (isset($user)) {
+                $user->failedDeliveries()->create([
+                    'recipient_id' => $recipient->id ?? null,
+                    'alias_id' => $alias->id ?? null,
+                    'bounce_type' => $bounceType,
+                    'remote_mta' => $remoteMta ?? null,
+                    'sender' => $undeliveredMessageHeaders['X-anonaddy-original-sender'] ?? null,
+                    'email_type' => $parts[0] ?? null,
+                    'status' => $dsn['Status'] ?? null,
+                    'code' => $diagnosticCode,
+                ]);
+            } else {
+                Log::info([
+                    'info' => 'user not found from bounce report',
+                    'deliveryReport' => $deliveryReport,
+                    'undeliveredMessage' => $undeliveredMessage,
+                ]);
+            }
+        }
+
+        exit(0);
+    }
+
     protected function checkBandwidthLimit($user)
     {
         if ($user->hasReachedBandwidthLimit()) {
@@ -334,6 +436,45 @@ class ReceiveEmail extends Command
         return $parser;
     }
 
+    protected function parseDeliveryStatus($deliveryStatus)
+    {
+        $lines = explode(PHP_EOL, $deliveryStatus);
+
+        $result = [];
+
+        foreach ($lines as $line) {
+            if (preg_match('#^([^\s.]*):\s*(.*)\s*#', $line, $matches)) {
+                $key = ucfirst(strtolower($matches[1]));
+
+                if (empty($result[$key])) {
+                    $result[$key] = trim($matches[2]);
+                }
+            } elseif (preg_match('/^\s+(.+)\s*/', $line) && isset($key)) {
+                $result[$key] .= ' ' . $line;
+            }
+        }
+
+        return $result;
+    }
+
+    protected function getBounceType($code, $status)
+    {
+        if (preg_match("/(:?mailbox|address|user|account|recipient|@).*(:?rejected|unknown|disabled|unavailable|invalid|inactive|not exist|does(n't| not) exist)|(:?rejected|unknown|unavailable|no|illegal|invalid|no such).*(:?mailbox|address|user|account|recipient|alias)|(:?address|user|recipient) does(n't| not) have .*(:?mailbox|account)|returned to sender|(:?auth).*(:?required)/i", $code)) {
+            return 'hard';
+        }
+
+        if (preg_match("/(:?spam|unsolicited|blacklisting|blacklisted|blacklist|554|mail content denied|reject for policy reason|mail rejected by destination domain|security issue)/i", $code)) {
+            return 'spam';
+        }
+
+        // No match for code but status starts with 5 e.g. 5.2.2
+        if (Str::startsWith($status, '5')) {
+            return 'hard';
+        }
+
+        return 'soft';
+    }
+
     protected function exitIfFromSelf()
     {
         // To prevent recipient alias infinite nested looping.

+ 4 - 0
app/Console/Kernel.php

@@ -26,6 +26,10 @@ class Kernel extends ConsoleKernel
     {
         $schedule->command('anonaddy:reset-bandwidth')->monthlyOn(1, '00:00');
         $schedule->command('anonaddy:email-users-with-token-expiring-soon')->dailyAt('12:00');
+        $schedule->command('anonaddy:check-domains-sending-verification')->daily();
+        $schedule->command('anonaddy:check-domains-mx-validation')->daily();
+        $schedule->command('anonaddy:clear-failed-deliveries')->daily();
+        $schedule->command('anonaddy:clear-postfix-queue-ids')->hourly();
         $schedule->command('auth:clear-resets')->daily();
     }
 

+ 32 - 0
app/CustomMailDriver/CustomMailManager.php

@@ -18,4 +18,36 @@ class CustomMailManager extends MailManager
             $config['path'] ?? $this->app['config']->get('mail.sendmail')
         );
     }
+
+    /**
+     * Create an instance of the SMTP Swift Transport driver.
+     *
+     * @param  array  $config
+     * @return \Swift_SmtpTransport
+     */
+    protected function createSmtpTransport(array $config)
+    {
+        // The Swift SMTP transport instance will allow us to use any SMTP backend
+        // for delivering mail such as Sendgrid, Amazon SES, or a custom server
+        // a developer has available. We will just pass this configured host.
+        $transport = new CustomSmtpTransport(
+            $config['host'],
+            $config['port']
+        );
+
+        if (! empty($config['encryption'])) {
+            $transport->setEncryption($config['encryption']);
+        }
+
+        // Once we have the transport we will check for the presence of a username
+        // and password. If we have it we will set the credentials on the Swift
+        // transporter instance so that we'll properly authenticate delivery.
+        if (isset($config['username'])) {
+            $transport->setUsername($config['username']);
+
+            $transport->setPassword($config['password']);
+        }
+
+        return $this->configureSmtpTransport($transport, $config);
+    }
 }

+ 182 - 0
app/CustomMailDriver/CustomSmtpTransport.php

@@ -0,0 +1,182 @@
+<?php
+
+namespace App\CustomMailDriver;
+
+use App\Models\PostfixQueueId;
+use Illuminate\Database\QueryException;
+use Illuminate\Support\Facades\Mail;
+use Illuminate\Support\Str;
+use Swift_AddressEncoderException;
+use Swift_DependencyContainer;
+use Swift_Events_SendEvent;
+use Swift_Mime_SimpleMessage;
+use Swift_Plugins_LoggerPlugin;
+use Swift_Plugins_Loggers_ArrayLogger;
+use Swift_Transport_EsmtpTransport;
+use Swift_TransportException;
+
+class CustomSmtpTransport extends Swift_Transport_EsmtpTransport
+{
+    /**
+     * @param string $host
+     * @param int    $port
+     * @param string|null $encryption SMTP encryption mode:
+     *        - null for plain SMTP (no encryption),
+     *        - 'tls' for SMTP with STARTTLS (best effort encryption),
+     *        - 'ssl' for SMTPS = SMTP over TLS (always encrypted).
+     */
+    public function __construct($host = 'localhost', $port = 25, $encryption = null)
+    {
+        \call_user_func_array(
+            [$this, 'Swift_Transport_EsmtpTransport::__construct'],
+            Swift_DependencyContainer::getInstance()
+                ->createDependenciesFor('transport.smtp')
+        );
+
+        $this->setHost($host);
+        $this->setPort($port);
+        $this->setEncryption($encryption);
+    }
+
+    /**
+     * Send the given Message.
+     *
+     * Recipient/sender data will be retrieved from the Message API.
+     * The return value is the number of recipients who were accepted for delivery.
+     *
+     * @param string[] $failedRecipients An array of failures by-reference
+     *
+     * @return int
+     */
+    public function send(Swift_Mime_SimpleMessage $message, &$failedRecipients = null)
+    {
+        if (!$this->isStarted()) {
+            $this->start();
+        }
+
+        $logger = new Swift_Plugins_Loggers_ArrayLogger();
+        Mail::getSwiftMailer()->registerPlugin(new Swift_Plugins_LoggerPlugin($logger));
+
+        $sent = 0;
+        $failedRecipients = (array) $failedRecipients;
+
+        if ($evt = $this->eventDispatcher->createSendEvent($this, $message)) {
+            $this->eventDispatcher->dispatchEvent($evt, 'beforeSendPerformed');
+            if ($evt->bubbleCancelled()) {
+                return 0;
+            }
+        }
+
+        if (!$reversePath = $this->getReversePath($message)) {
+            $this->throwException(new Swift_TransportException('Cannot send message without a sender address'));
+        }
+
+        $to = (array) $message->getTo();
+        $cc = (array) $message->getCc();
+        $tos = array_merge($to, $cc);
+        $bcc = (array) $message->getBcc();
+
+        $message->setBcc([]);
+
+        // This allows us to have the To: header set as the alias whilst still delivering to the correct RCPT TO.
+        if ($aliasTo = $message->getHeaders()->get('Alias-To')) {
+            $message->setTo($aliasTo->getFieldBodyModel());
+            $message->getHeaders()->remove('Alias-To');
+        }
+
+        try {
+            $sent += $this->sendTo($message, $reversePath, $tos, $failedRecipients);
+            $sent += $this->sendBcc($message, $reversePath, $bcc, $failedRecipients);
+        } finally {
+            $message->setBcc($bcc);
+        }
+
+        if ($evt) {
+            if ($sent == \count($to) + \count($cc) + \count($bcc)) {
+                $evt->setResult(Swift_Events_SendEvent::RESULT_SUCCESS);
+            } elseif ($sent > 0) {
+                $evt->setResult(Swift_Events_SendEvent::RESULT_TENTATIVE);
+            } else {
+                $evt->setResult(Swift_Events_SendEvent::RESULT_FAILED);
+            }
+            $evt->setFailedRecipients($failedRecipients);
+            $this->eventDispatcher->dispatchEvent($evt, 'sendPerformed');
+        }
+
+        $message->generateId(); //Make sure a new Message ID is used
+
+        try {
+            // Get Postfix Queue ID and store in Redis for 6 hours?
+            $id = str_replace("\r\n", "", Str::after($logger->dump(), 'Ok: queued as '));
+
+            PostfixQueueId::create([
+                'queue_id' => $id
+            ]);
+        } catch (QueryException $e) {
+            // duplicate entry
+        }
+
+        return $sent;
+    }
+
+    /** Send a message to the given To: recipients */
+    private function sendTo(Swift_Mime_SimpleMessage $message, $reversePath, array $to, array &$failedRecipients)
+    {
+        if (empty($to)) {
+            return 0;
+        }
+
+        return $this->doMailTransaction(
+            $message,
+            $reversePath,
+            array_keys($to),
+            $failedRecipients
+        );
+    }
+
+    /** Send a message to all Bcc: recipients */
+    private function sendBcc(Swift_Mime_SimpleMessage $message, $reversePath, array $bcc, array &$failedRecipients)
+    {
+        $sent = 0;
+        foreach ($bcc as $forwardPath => $name) {
+            $message->setBcc([$forwardPath => $name]);
+            $sent += $this->doMailTransaction(
+                $message,
+                $reversePath,
+                [$forwardPath],
+                $failedRecipients
+            );
+        }
+
+        return $sent;
+    }
+
+    /** Send an email to the given recipients from the given reverse path */
+    private function doMailTransaction($message, $reversePath, array $recipients, array &$failedRecipients)
+    {
+        $sent = 0;
+        $this->doMailFromCommand($reversePath);
+        foreach ($recipients as $forwardPath) {
+            try {
+                $this->doRcptToCommand($forwardPath);
+                ++$sent;
+            } catch (Swift_TransportException $e) {
+                $failedRecipients[] = $forwardPath;
+            } catch (Swift_AddressEncoderException $e) {
+                $failedRecipients[] = $forwardPath;
+            }
+        }
+
+        if (0 != $sent) {
+            $sent += \count($failedRecipients);
+            $this->doDataCommand($failedRecipients);
+            $sent -= \count($failedRecipients);
+
+            $this->streamMessage($message);
+        } else {
+            $this->reset();
+        }
+
+        return $sent;
+    }
+}

+ 2 - 2
app/Http/Controllers/Auth/ForgotUsernameController.php

@@ -39,7 +39,7 @@ class ForgotUsernameController extends Controller
     {
         $this->validateEmail($request);
 
-        $recipient = Recipient::all()->where('email', $request->email)->first();
+        $recipient = Recipient::select(['id', 'email', 'email_verified_at'])->whereNotNull('email_verified_at')->get()->firstWhere('email', $request->email);
 
         if (isset($recipient)) {
             $recipient->sendUsernameReminderNotification();
@@ -56,6 +56,6 @@ class ForgotUsernameController extends Controller
      */
     protected function validateEmail(Request $request)
     {
-        $request->validate(['email' => 'required|email:rfc,dns']);
+        $request->validate(['email' => 'required|email:rfc']);
     }
 }

+ 8 - 0
app/Http/Controllers/DomainVerificationController.php

@@ -13,6 +13,14 @@ class DomainVerificationController extends Controller
     {
         $domain = user()->domains()->findOrFail($id);
 
+        // Check MX records separately
+        if (! $domain->checkMxRecords()) {
+            return response()->json([
+                'success' => false,
+                'message' => 'MX record not found or does not have correct priority. This could be due to DNS caching, please try again later.'
+            ]);
+        }
+
         return $domain->checkVerificationForSending();
     }
 }

+ 18 - 0
app/Http/Controllers/ShowFailedDeliveryController.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace App\Http\Controllers;
+
+class ShowFailedDeliveryController extends Controller
+{
+    public function index()
+    {
+        return view('failed_deliveries.index', [
+            'failedDeliveries' => user()
+                ->failedDeliveries()
+                ->with(['recipient:id,email','alias:id,email'])
+                ->select(['alias_id','bounce_type','code','created_at','id','recipient_id','remote_mta','sender'])
+                ->latest()
+                ->get()
+        ]);
+    }
+}

+ 1 - 0
app/Http/Resources/DomainResource.php

@@ -18,6 +18,7 @@ class DomainResource extends JsonResource
             'active' => $this->active,
             'catch_all' => $this->catch_all,
             'domain_verified_at' => $this->domain_verified_at ? $this->domain_verified_at->toDateTimeString() : null,
+            'domain_mx_validated_at' => $this->domain_mx_validated_at ? $this->domain_mx_validated_at->toDateTimeString() : null,
             'domain_sending_verified_at' => $this->domain_sending_verified_at ? $this->domain_sending_verified_at->toDateTimeString() : null,
             'created_at' => $this->created_at->toDateTimeString(),
             'updated_at' => $this->updated_at->toDateTimeString(),

+ 1 - 0
app/Jobs/DeleteAccount.php

@@ -59,6 +59,7 @@ class DeleteAccount implements ShouldQueue, ShouldBeEncrypted
         $this->user->tokens()->delete();
         $this->user->rules()->delete();
         $this->user->webauthnKeys()->delete();
+        $this->user->failedDeliveries()->delete();
         $this->user->delete();
     }
 }

+ 2 - 2
app/Mail/ReplyToEmail.php

@@ -84,7 +84,7 @@ class ReplyToEmail extends Mailable implements ShouldQueue, ShouldBeEncrypted
             ->from($this->fromEmail, $this->displayFrom)
             ->subject(base64_decode($this->emailSubject))
             ->text('emails.reply.text')->with([
-                'text' => base64_decode($this->emailText)
+                'text' => str_ireplace($this->sender, '', base64_decode($this->emailText))
             ])
             ->withSwiftMessage(function ($message) use ($returnPath) {
                 $message->setReturnPath($returnPath);
@@ -115,7 +115,7 @@ class ReplyToEmail extends Mailable implements ShouldQueue, ShouldBeEncrypted
 
         if ($this->emailHtml) {
             $this->email->view('emails.reply.html')->with([
-                'html' => base64_decode($this->emailHtml)
+                'html' => str_ireplace($this->sender, '', base64_decode($this->emailHtml))
             ]);
         }
 

+ 2 - 2
app/Mail/SendFromEmail.php

@@ -80,7 +80,7 @@ class SendFromEmail extends Mailable implements ShouldQueue, ShouldBeEncrypted
             ->from($this->fromEmail, $this->displayFrom)
             ->subject(base64_decode($this->emailSubject))
             ->text('emails.reply.text')->with([
-                'text' => base64_decode($this->emailText)
+                'text' => str_ireplace($this->sender, '', base64_decode($this->emailText))
             ])
             ->withSwiftMessage(function ($message) use ($returnPath) {
                 $message->setReturnPath($returnPath);
@@ -101,7 +101,7 @@ class SendFromEmail extends Mailable implements ShouldQueue, ShouldBeEncrypted
 
         if ($this->emailHtml) {
             $this->email->view('emails.reply.html')->with([
-                'html' => base64_decode($this->emailHtml)
+                'html' => str_ireplace($this->sender, '', base64_decode($this->emailHtml))
             ]);
         }
 

+ 8 - 0
app/Models/Alias.php

@@ -108,6 +108,14 @@ class Alias extends Model
         return $this->belongsToMany(Recipient::class, 'alias_recipients')->withPivot('id')->using(AliasRecipient::class);
     }
 
+    /**
+     * Get all of the aliases' failed deliveries.
+     */
+    public function failedDeliveries()
+    {
+        return $this->hasMany(FailedDelivery::class);
+    }
+
     /**
      * Get all of the verified recipients for the email alias.
      */

+ 46 - 0
app/Models/Domain.php

@@ -32,6 +32,7 @@ class Domain extends Model
         'created_at',
         'updated_at',
         'domain_verified_at',
+        'domain_mx_validated_at',
         'domain_sending_verified_at'
     ];
 
@@ -169,6 +170,18 @@ class Domain extends Model
         ])->save();
     }
 
+    /**
+     * Mark this domain as having valid MX records.
+     *
+     * @return bool
+     */
+    public function markDomainAsValidMx()
+    {
+        return $this->forceFill([
+            'domain_mx_validated_at' => $this->freshTimestamp(),
+        ])->save();
+    }
+
     /**
      * Checks if the domain has the correct records.
      */
@@ -184,11 +197,44 @@ class Domain extends Model
             });
     }
 
+    /**
+     * Checks if the domain has the correct MX records.
+     */
+    public function checkMxRecords()
+    {
+        if (App::environment('testing')) {
+            return true;
+        }
+
+        $mx = collect(dns_get_record($this->domain . '.', DNS_MX))
+            ->sortBy('pri')
+            ->first();
+
+        if (! isset($mx['target'])) {
+            return false;
+        }
+
+        if ($mx['target'] !== config('anonaddy.hostname')) {
+            return false;
+        }
+
+        $this->markDomainAsValidMx();
+
+        return true;
+    }
+
     /**
      * Checks if the domain has the correct records for sending.
      */
     public function checkVerificationForSending()
     {
+        if (App::environment('testing')) {
+            return response()->json([
+                'success' => true,
+                'message' => 'Records verified for sending.',
+            ]);
+        }
+
         $spf = collect(dns_get_record($this->domain . '.', DNS_TXT))
             ->contains(function ($r) {
                 return preg_match("/^(v=spf1).*(include:spf\." . config('anonaddy.domain') . ").*(-|~)all$/", $r['txt']);

+ 81 - 0
app/Models/FailedDelivery.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace App\Models;
+
+use App\Traits\HasEncryptedAttributes;
+use App\Traits\HasUuid;
+use DateTimeInterface;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class FailedDelivery extends Model
+{
+    use HasUuid, HasEncryptedAttributes, HasFactory;
+
+    public $incrementing = false;
+
+    protected $keyType = 'string';
+
+    protected $encrypted = [
+        'sender'
+    ];
+
+    protected $fillable = [
+        'user_id',
+        'recipient_id',
+        'alias_id',
+        'bounce_type',
+        'remote_mta',
+        'sender',
+        'email_type',
+        'status',
+        'code'
+    ];
+
+    protected $dates = [
+        'created_at',
+        'updated_at'
+    ];
+
+    protected $casts = [
+        'id' => 'string',
+        'user_id' => 'string',
+        'recipient_id' => 'string',
+        'alias_id' => 'string'
+    ];
+
+    /**
+     * Prepare a date for array / JSON serialization.
+     *
+     * @param  \DateTimeInterface  $date
+     * @return string
+     */
+    protected function serializeDate(DateTimeInterface $date)
+    {
+        return $date->format('Y-m-d H:i:s');
+    }
+
+    /**
+     * Get the user for the failed delivery.
+     */
+    public function user()
+    {
+        return $this->belongsTo(User::class);
+    }
+
+    /**
+     * Get the recipient for the failed delivery.
+     */
+    public function recipient()
+    {
+        return $this->belongsTo(Recipient::class);
+    }
+
+    /**
+     * Get the alias for the failed delivery.
+     */
+    public function alias()
+    {
+        return $this->belongsTo(Alias::class);
+    }
+}

+ 29 - 0
app/Models/PostfixQueueId.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace App\Models;
+
+use App\Traits\HasUuid;
+use Illuminate\Database\Eloquent\Model;
+
+class PostfixQueueId extends Model
+{
+    use HasUuid;
+
+    public $incrementing = false;
+
+    protected $keyType = 'string';
+
+    protected $fillable = [
+        'queue_id',
+    ];
+
+    protected $dates = [
+        'created_at',
+        'updated_at'
+    ];
+
+    protected $casts = [
+        'id' => 'string',
+        'queue_id' => 'string',
+    ];
+}

+ 8 - 0
app/Models/Recipient.php

@@ -85,6 +85,14 @@ class Recipient extends Model
         return $this->belongsToMany(Alias::class, 'alias_recipients')->using(AliasRecipient::class);
     }
 
+    /**
+     * Get all of the recipient's failed deliveries.
+     */
+    public function failedDeliveries()
+    {
+        return $this->hasMany(FailedDelivery::class);
+    }
+
     /**
      * Get all of the user's custom domains.
      */

+ 8 - 0
app/Models/User.php

@@ -170,6 +170,14 @@ class User extends Authenticatable implements MustVerifyEmail
         return $this->hasMany(Rule::class);
     }
 
+    /**
+     * Get all of the user's failed deliveries.
+     */
+    public function failedDeliveries()
+    {
+        return $this->hasMany(FailedDelivery::class);
+    }
+
     /**
      * Get all of the user's active rules.
      */

+ 65 - 0
app/Notifications/DomainMxRecordsInvalid.php

@@ -0,0 +1,65 @@
+<?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;
+
+class DomainMxRecordsInvalid extends Notification implements ShouldQueue, ShouldBeEncrypted
+{
+    use Queueable;
+
+    protected $domain;
+
+    /**
+     * Create a new notification instance.
+     *
+     * @return void
+     */
+    public function __construct($domain)
+    {
+        $this->domain = $domain;
+    }
+
+    /**
+     * Get the notification's delivery channels.
+     *
+     * @param  mixed  $notifiable
+     * @return array
+     */
+    public function via($notifiable)
+    {
+        return ['mail'];
+    }
+
+    /**
+     * Get the mail representation of the notification.
+     *
+     * @param  mixed  $notifiable
+     * @return \Illuminate\Notifications\Messages\MailMessage
+     */
+    public function toMail($notifiable)
+    {
+        return (new MailMessage)
+            ->subject("Your domain's MX records no longer point to AnonAddy")
+            ->markdown('mail.domain_mx_records_invalid', [
+                'domain' => $this->domain
+            ]);
+    }
+
+    /**
+     * Get the array representation of the notification.
+     *
+     * @param  mixed  $notifiable
+     * @return array
+     */
+    public function toArray($notifiable)
+    {
+        return [
+            //
+        ];
+    }
+}

+ 68 - 0
app/Notifications/DomainUnverifiedForSending.php

@@ -0,0 +1,68 @@
+<?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;
+
+class DomainUnverifiedForSending extends Notification implements ShouldQueue, ShouldBeEncrypted
+{
+    use Queueable;
+
+    protected $domain;
+    protected $reason;
+
+    /**
+     * Create a new notification instance.
+     *
+     * @return void
+     */
+    public function __construct($domain, $reason)
+    {
+        $this->domain = $domain;
+        $this->reason = $reason;
+    }
+
+    /**
+     * Get the notification's delivery channels.
+     *
+     * @param  mixed  $notifiable
+     * @return array
+     */
+    public function via($notifiable)
+    {
+        return ['mail'];
+    }
+
+    /**
+     * Get the mail representation of the notification.
+     *
+     * @param  mixed  $notifiable
+     * @return \Illuminate\Notifications\Messages\MailMessage
+     */
+    public function toMail($notifiable)
+    {
+        return (new MailMessage)
+            ->subject("Your domain has been unverified for sending on AnonAddy")
+            ->markdown('mail.domain_unverified_for_sending', [
+                'domain' => $this->domain,
+                'reason' => $this->reason
+            ]);
+    }
+
+    /**
+     * Get the array representation of the notification.
+     *
+     * @param  mixed  $notifiable
+     * @return array
+     */
+    public function toArray($notifiable)
+    {
+        return [
+            //
+        ];
+    }
+}

+ 179 - 165
composer.lock

@@ -8,16 +8,16 @@
     "packages": [
         {
             "name": "asbiin/laravel-webauthn",
-            "version": "1.0.0",
+            "version": "1.1.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/asbiin/laravel-webauthn.git",
-                "reference": "8c1e9f29da47afe1042ca7ea8c515e9e84e5358e"
+                "reference": "b957ef8d8dd9a0b9a119f1bb97e855bf9f61ac22"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/asbiin/laravel-webauthn/zipball/8c1e9f29da47afe1042ca7ea8c515e9e84e5358e",
-                "reference": "8c1e9f29da47afe1042ca7ea8c515e9e84e5358e",
+                "url": "https://api.github.com/repos/asbiin/laravel-webauthn/zipball/b957ef8d8dd9a0b9a119f1bb97e855bf9f61ac22",
+                "reference": "b957ef8d8dd9a0b9a119f1bb97e855bf9f61ac22",
                 "shasum": ""
             },
             "require": {
@@ -35,7 +35,7 @@
             "require-dev": {
                 "ext-sqlite3": "*",
                 "laravel/legacy-factories": "^1.0",
-                "nunomaduro/larastan": "^0.4 || ^0.5 || ^0.6",
+                "nunomaduro/larastan": "^0.4 || ^0.5 || ^0.6 || ^0.7",
                 "ocramius/package-versions": "^1.5 || ^2.0",
                 "orchestra/testbench": "^3.5 || ^5.0 || ^6.0",
                 "phpstan/phpstan-deprecation-rules": "^0.12",
@@ -93,7 +93,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2020-12-30T19:20:58+00:00"
+            "time": "2021-07-03T12:01:46+00:00"
         },
         {
             "name": "asm89/stack-cors",
@@ -581,24 +581,23 @@
         },
         {
             "name": "doctrine/cache",
-            "version": "2.0.3",
+            "version": "2.1.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/cache.git",
-                "reference": "c9622c6820d3ede1e2315a6a377ea1076e421d88"
+                "reference": "331b4d5dbaeab3827976273e9356b3b453c300ce"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/cache/zipball/c9622c6820d3ede1e2315a6a377ea1076e421d88",
-                "reference": "c9622c6820d3ede1e2315a6a377ea1076e421d88",
+                "url": "https://api.github.com/repos/doctrine/cache/zipball/331b4d5dbaeab3827976273e9356b3b453c300ce",
+                "reference": "331b4d5dbaeab3827976273e9356b3b453c300ce",
                 "shasum": ""
             },
             "require": {
                 "php": "~7.1 || ^8.0"
             },
             "conflict": {
-                "doctrine/common": ">2.2,<2.4",
-                "psr/cache": ">=3"
+                "doctrine/common": ">2.2,<2.4"
             },
             "require-dev": {
                 "alcaeus/mongo-php-adapter": "^1.1",
@@ -607,8 +606,9 @@
                 "mongodb/mongodb": "^1.1",
                 "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
                 "predis/predis": "~1.0",
-                "psr/cache": "^1.0 || ^2.0",
-                "symfony/cache": "^4.4 || ^5.2"
+                "psr/cache": "^1.0 || ^2.0 || ^3.0",
+                "symfony/cache": "^4.4 || ^5.2 || ^6.0@dev",
+                "symfony/var-exporter": "^4.4 || ^5.2 || ^6.0@dev"
             },
             "suggest": {
                 "alcaeus/mongo-php-adapter": "Required to use legacy MongoDB driver"
@@ -660,7 +660,7 @@
             ],
             "support": {
                 "issues": "https://github.com/doctrine/cache/issues",
-                "source": "https://github.com/doctrine/cache/tree/2.0.3"
+                "source": "https://github.com/doctrine/cache/tree/2.1.1"
             },
             "funding": [
                 {
@@ -676,7 +676,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-05-25T09:43:04+00:00"
+            "time": "2021-07-17T14:49:29+00:00"
         },
         {
             "name": "doctrine/dbal",
@@ -1850,26 +1850,26 @@
         },
         {
             "name": "intervention/image",
-            "version": "2.5.1",
+            "version": "2.6.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Intervention/image.git",
-                "reference": "abbf18d5ab8367f96b3205ca3c89fb2fa598c69e"
+                "reference": "a2d7238069bb01322f9c2a661449955434fec9c6"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Intervention/image/zipball/abbf18d5ab8367f96b3205ca3c89fb2fa598c69e",
-                "reference": "abbf18d5ab8367f96b3205ca3c89fb2fa598c69e",
+                "url": "https://api.github.com/repos/Intervention/image/zipball/a2d7238069bb01322f9c2a661449955434fec9c6",
+                "reference": "a2d7238069bb01322f9c2a661449955434fec9c6",
                 "shasum": ""
             },
             "require": {
                 "ext-fileinfo": "*",
-                "guzzlehttp/psr7": "~1.1",
+                "guzzlehttp/psr7": "~1.1 || ^2.0",
                 "php": ">=5.4.0"
             },
             "require-dev": {
                 "mockery/mockery": "~0.9.2",
-                "phpunit/phpunit": "^4.8 || ^5.7"
+                "phpunit/phpunit": "^4.8 || ^5.7 || ^7.5.15"
             },
             "suggest": {
                 "ext-gd": "to use GD library based image processing.",
@@ -1918,22 +1918,32 @@
             ],
             "support": {
                 "issues": "https://github.com/Intervention/image/issues",
-                "source": "https://github.com/Intervention/image/tree/master"
+                "source": "https://github.com/Intervention/image/tree/2.6.0"
             },
-            "time": "2019-11-02T09:15:47+00:00"
+            "funding": [
+                {
+                    "url": "https://www.paypal.me/interventionphp",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/Intervention",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-07-06T13:35:54+00:00"
         },
         {
             "name": "laravel/framework",
-            "version": "v8.48.1",
+            "version": "v8.50.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laravel/framework.git",
-                "reference": "edc138060a13c9e5f15c005fad5f6a39b4ccf5fa"
+                "reference": "d892dbacbe3859cf9303ccda98ac8d782141d5ae"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laravel/framework/zipball/edc138060a13c9e5f15c005fad5f6a39b4ccf5fa",
-                "reference": "edc138060a13c9e5f15c005fad5f6a39b4ccf5fa",
+                "url": "https://api.github.com/repos/laravel/framework/zipball/d892dbacbe3859cf9303ccda98ac8d782141d5ae",
+                "reference": "d892dbacbe3859cf9303ccda98ac8d782141d5ae",
                 "shasum": ""
             },
             "require": {
@@ -1943,7 +1953,7 @@
                 "ext-json": "*",
                 "ext-mbstring": "*",
                 "ext-openssl": "*",
-                "league/commonmark": "^1.3",
+                "league/commonmark": "^1.3|^2.0",
                 "league/flysystem": "^1.1",
                 "monolog/monolog": "^2.0",
                 "nesbot/carbon": "^2.31",
@@ -2088,7 +2098,7 @@
                 "issues": "https://github.com/laravel/framework/issues",
                 "source": "https://github.com/laravel/framework"
             },
-            "time": "2021-06-23T13:41:59+00:00"
+            "time": "2021-07-13T12:41:53+00:00"
         },
         {
             "name": "laravel/passport",
@@ -2430,16 +2440,16 @@
         },
         {
             "name": "league/commonmark",
-            "version": "1.6.4",
+            "version": "1.6.6",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/commonmark.git",
-                "reference": "c3c8b7217c52572fb42aaf84211abccf75a151b2"
+                "reference": "c4228d11e30d7493c6836d20872f9582d8ba6dcf"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/c3c8b7217c52572fb42aaf84211abccf75a151b2",
-                "reference": "c3c8b7217c52572fb42aaf84211abccf75a151b2",
+                "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/c4228d11e30d7493c6836d20872f9582d8ba6dcf",
+                "reference": "c4228d11e30d7493c6836d20872f9582d8ba6dcf",
                 "shasum": ""
             },
             "require": {
@@ -2527,7 +2537,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-06-19T20:08:14+00:00"
+            "time": "2021-07-17T17:13:23+00:00"
         },
         {
             "name": "league/event",
@@ -2918,27 +2928,32 @@
         },
         {
             "name": "league/uri-interfaces",
-            "version": "2.2.0",
+            "version": "2.3.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/uri-interfaces.git",
-                "reference": "667f150e589d65d79c89ffe662e426704f84224f"
+                "reference": "00e7e2943f76d8cb50c7dfdc2f6dee356e15e383"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/667f150e589d65d79c89ffe662e426704f84224f",
-                "reference": "667f150e589d65d79c89ffe662e426704f84224f",
+                "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/00e7e2943f76d8cb50c7dfdc2f6dee356e15e383",
+                "reference": "00e7e2943f76d8cb50c7dfdc2f6dee356e15e383",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
-                "php": "^7.1 || ^8.0"
+                "php": "^7.2 || ^8.0"
             },
             "require-dev": {
-                "friendsofphp/php-cs-fixer": "^2.0",
-                "phpstan/phpstan": "^0.12",
-                "phpstan/phpstan-phpunit": "^0.12",
-                "phpstan/phpstan-strict-rules": "^0.12"
+                "friendsofphp/php-cs-fixer": "^2.19",
+                "phpstan/phpstan": "^0.12.90",
+                "phpstan/phpstan-phpunit": "^0.12.19",
+                "phpstan/phpstan-strict-rules": "^0.12.9",
+                "phpunit/phpunit": "^8.5.15 || ^9.5"
+            },
+            "suggest": {
+                "ext-intl": "to use the IDNA feature",
+                "symfony/intl": "to use the IDNA feature via Symfony Polyfill"
             },
             "type": "library",
             "extra": {
@@ -2972,7 +2987,7 @@
             ],
             "support": {
                 "issues": "https://github.com/thephpleague/uri-interfaces/issues",
-                "source": "https://github.com/thephpleague/uri-interfaces/tree/2.2.0"
+                "source": "https://github.com/thephpleague/uri-interfaces/tree/2.3.0"
             },
             "funding": [
                 {
@@ -2980,20 +2995,20 @@
                     "type": "github"
                 }
             ],
-            "time": "2020-10-31T13:45:51+00:00"
+            "time": "2021-06-28T04:27:21+00:00"
         },
         {
             "name": "maatwebsite/excel",
-            "version": "3.1.31",
+            "version": "3.1.32",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Maatwebsite/Laravel-Excel.git",
-                "reference": "cbe6370af70f93bd017f77ef92d32bd492a47fcb"
+                "reference": "9dc29b63a77fb7f2f514ef754af3a1b57e83cadf"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Maatwebsite/Laravel-Excel/zipball/cbe6370af70f93bd017f77ef92d32bd492a47fcb",
-                "reference": "cbe6370af70f93bd017f77ef92d32bd492a47fcb",
+                "url": "https://api.github.com/repos/Maatwebsite/Laravel-Excel/zipball/9dc29b63a77fb7f2f514ef754af3a1b57e83cadf",
+                "reference": "9dc29b63a77fb7f2f514ef754af3a1b57e83cadf",
                 "shasum": ""
             },
             "require": {
@@ -3046,7 +3061,7 @@
             ],
             "support": {
                 "issues": "https://github.com/Maatwebsite/Laravel-Excel/issues",
-                "source": "https://github.com/Maatwebsite/Laravel-Excel/tree/3.1.31"
+                "source": "https://github.com/Maatwebsite/Laravel-Excel/tree/3.1.32"
             },
             "funding": [
                 {
@@ -3058,7 +3073,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2021-06-02T17:31:29+00:00"
+            "time": "2021-07-08T10:11:21+00:00"
         },
         {
             "name": "maennchen/zipstream-php",
@@ -3375,16 +3390,16 @@
         },
         {
             "name": "monolog/monolog",
-            "version": "2.2.0",
+            "version": "2.3.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Seldaek/monolog.git",
-                "reference": "1cb1cde8e8dd0f70cc0fe51354a59acad9302084"
+                "reference": "9738e495f288eec0b187e310b7cdbbb285777dbe"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Seldaek/monolog/zipball/1cb1cde8e8dd0f70cc0fe51354a59acad9302084",
-                "reference": "1cb1cde8e8dd0f70cc0fe51354a59acad9302084",
+                "url": "https://api.github.com/repos/Seldaek/monolog/zipball/9738e495f288eec0b187e310b7cdbbb285777dbe",
+                "reference": "9738e495f288eec0b187e310b7cdbbb285777dbe",
                 "shasum": ""
             },
             "require": {
@@ -3403,7 +3418,7 @@
                 "php-amqplib/php-amqplib": "~2.4",
                 "php-console/php-console": "^3.1.3",
                 "phpspec/prophecy": "^1.6.1",
-                "phpstan/phpstan": "^0.12.59",
+                "phpstan/phpstan": "^0.12.91",
                 "phpunit/phpunit": "^8.5",
                 "predis/predis": "^1.1",
                 "rollbar/rollbar": "^1.3",
@@ -3455,7 +3470,7 @@
             ],
             "support": {
                 "issues": "https://github.com/Seldaek/monolog/issues",
-                "source": "https://github.com/Seldaek/monolog/tree/2.2.0"
+                "source": "https://github.com/Seldaek/monolog/tree/2.3.1"
             },
             "funding": [
                 {
@@ -3467,20 +3482,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-12-14T13:15:25+00:00"
+            "time": "2021-07-14T11:56:39+00:00"
         },
         {
             "name": "myclabs/php-enum",
-            "version": "1.8.0",
+            "version": "1.8.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/myclabs/php-enum.git",
-                "reference": "46cf3d8498b095bd33727b13fd5707263af99421"
+                "reference": "b942d263c641ddb5190929ff840c68f78713e937"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/myclabs/php-enum/zipball/46cf3d8498b095bd33727b13fd5707263af99421",
-                "reference": "46cf3d8498b095bd33727b13fd5707263af99421",
+                "url": "https://api.github.com/repos/myclabs/php-enum/zipball/b942d263c641ddb5190929ff840c68f78713e937",
+                "reference": "b942d263c641ddb5190929ff840c68f78713e937",
                 "shasum": ""
             },
             "require": {
@@ -3490,7 +3505,7 @@
             "require-dev": {
                 "phpunit/phpunit": "^9.5",
                 "squizlabs/php_codesniffer": "1.*",
-                "vimeo/psalm": "^4.5.1"
+                "vimeo/psalm": "^4.6.2"
             },
             "type": "library",
             "autoload": {
@@ -3515,7 +3530,7 @@
             ],
             "support": {
                 "issues": "https://github.com/myclabs/php-enum/issues",
-                "source": "https://github.com/myclabs/php-enum/tree/1.8.0"
+                "source": "https://github.com/myclabs/php-enum/tree/1.8.3"
             },
             "funding": [
                 {
@@ -3527,20 +3542,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-02-15T16:11:48+00:00"
+            "time": "2021-07-05T08:18:36+00:00"
         },
         {
             "name": "nesbot/carbon",
-            "version": "2.49.0",
+            "version": "2.50.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/briannesbitt/Carbon.git",
-                "reference": "93d9db91c0235c486875d22f1e08b50bdf3e6eee"
+                "reference": "f47f17d17602b2243414a44ad53d9f8b9ada5fdb"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/93d9db91c0235c486875d22f1e08b50bdf3e6eee",
-                "reference": "93d9db91c0235c486875d22f1e08b50bdf3e6eee",
+                "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/f47f17d17602b2243414a44ad53d9f8b9ada5fdb",
+                "reference": "f47f17d17602b2243414a44ad53d9f8b9ada5fdb",
                 "shasum": ""
             },
             "require": {
@@ -3592,15 +3607,15 @@
                 {
                     "name": "Brian Nesbitt",
                     "email": "brian@nesbot.com",
-                    "homepage": "http://nesbot.com"
+                    "homepage": "https://markido.com"
                 },
                 {
                     "name": "kylekatarnls",
-                    "homepage": "http://github.com/kylekatarnls"
+                    "homepage": "https://github.com/kylekatarnls"
                 }
             ],
             "description": "An API extension for DateTime that supports 281 different languages.",
-            "homepage": "http://carbon.nesbot.com",
+            "homepage": "https://carbon.nesbot.com",
             "keywords": [
                 "date",
                 "datetime",
@@ -3620,20 +3635,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-06-02T07:31:40+00:00"
+            "time": "2021-06-28T22:38:45+00:00"
         },
         {
             "name": "nikic/php-parser",
-            "version": "v4.10.5",
+            "version": "v4.11.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/nikic/PHP-Parser.git",
-                "reference": "4432ba399e47c66624bc73c8c0f811e5c109576f"
+                "reference": "fe14cf3672a149364fb66dfe11bf6549af899f94"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/4432ba399e47c66624bc73c8c0f811e5c109576f",
-                "reference": "4432ba399e47c66624bc73c8c0f811e5c109576f",
+                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/fe14cf3672a149364fb66dfe11bf6549af899f94",
+                "reference": "fe14cf3672a149364fb66dfe11bf6549af899f94",
                 "shasum": ""
             },
             "require": {
@@ -3674,22 +3689,22 @@
             ],
             "support": {
                 "issues": "https://github.com/nikic/PHP-Parser/issues",
-                "source": "https://github.com/nikic/PHP-Parser/tree/v4.10.5"
+                "source": "https://github.com/nikic/PHP-Parser/tree/v4.11.0"
             },
-            "time": "2021-05-03T19:11:20+00:00"
+            "time": "2021-07-03T13:36:55+00:00"
         },
         {
             "name": "nyholm/psr7",
-            "version": "1.4.0",
+            "version": "1.4.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Nyholm/psr7.git",
-                "reference": "23ae1f00fbc6a886cbe3062ca682391b9cc7c37b"
+                "reference": "2212385b47153ea71b1c1b1374f8cb5e4f7892ec"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Nyholm/psr7/zipball/23ae1f00fbc6a886cbe3062ca682391b9cc7c37b",
-                "reference": "23ae1f00fbc6a886cbe3062ca682391b9cc7c37b",
+                "url": "https://api.github.com/repos/Nyholm/psr7/zipball/2212385b47153ea71b1c1b1374f8cb5e4f7892ec",
+                "reference": "2212385b47153ea71b1c1b1374f8cb5e4f7892ec",
                 "shasum": ""
             },
             "require": {
@@ -3703,7 +3718,7 @@
                 "psr/http-message-implementation": "1.0"
             },
             "require-dev": {
-                "http-interop/http-factory-tests": "^0.8",
+                "http-interop/http-factory-tests": "^0.9",
                 "php-http/psr7-integration-tests": "^1.0",
                 "phpunit/phpunit": "^7.5 || 8.5 || 9.4",
                 "symfony/error-handler": "^4.4"
@@ -3741,7 +3756,7 @@
             ],
             "support": {
                 "issues": "https://github.com/Nyholm/psr7/issues",
-                "source": "https://github.com/Nyholm/psr7/tree/1.4.0"
+                "source": "https://github.com/Nyholm/psr7/tree/1.4.1"
             },
             "funding": [
                 {
@@ -3753,7 +3768,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2021-02-18T15:41:32+00:00"
+            "time": "2021-07-02T08:32:20+00:00"
         },
         {
             "name": "opis/closure",
@@ -6034,16 +6049,16 @@
         },
         {
             "name": "symfony/error-handler",
-            "version": "v5.3.0",
+            "version": "v5.3.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/error-handler.git",
-                "reference": "0e6768b8c0dcef26df087df2bbbaa143867a59b2"
+                "reference": "43323e79c80719e8a4674e33484bca98270d223f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/error-handler/zipball/0e6768b8c0dcef26df087df2bbbaa143867a59b2",
-                "reference": "0e6768b8c0dcef26df087df2bbbaa143867a59b2",
+                "url": "https://api.github.com/repos/symfony/error-handler/zipball/43323e79c80719e8a4674e33484bca98270d223f",
+                "reference": "43323e79c80719e8a4674e33484bca98270d223f",
                 "shasum": ""
             },
             "require": {
@@ -6083,7 +6098,7 @@
             "description": "Provides tools to manage errors and ease debugging PHP code",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/error-handler/tree/v5.3.0"
+                "source": "https://github.com/symfony/error-handler/tree/v5.3.3"
             },
             "funding": [
                 {
@@ -6099,7 +6114,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-05-26T17:43:10+00:00"
+            "time": "2021-06-24T08:13:00+00:00"
         },
         {
             "name": "symfony/event-dispatcher",
@@ -6406,16 +6421,16 @@
         },
         {
             "name": "symfony/http-foundation",
-            "version": "v5.3.2",
+            "version": "v5.3.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/http-foundation.git",
-                "reference": "7b6dd714d95106b831aaa7f3c9c612ab886516bd"
+                "reference": "0e45ab1574caa0460d9190871a8ce47539e40ccf"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/http-foundation/zipball/7b6dd714d95106b831aaa7f3c9c612ab886516bd",
-                "reference": "7b6dd714d95106b831aaa7f3c9c612ab886516bd",
+                "url": "https://api.github.com/repos/symfony/http-foundation/zipball/0e45ab1574caa0460d9190871a8ce47539e40ccf",
+                "reference": "0e45ab1574caa0460d9190871a8ce47539e40ccf",
                 "shasum": ""
             },
             "require": {
@@ -6459,7 +6474,7 @@
             "description": "Defines an object-oriented layer for the HTTP specification",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/http-foundation/tree/v5.3.2"
+                "source": "https://github.com/symfony/http-foundation/tree/v5.3.3"
             },
             "funding": [
                 {
@@ -6475,20 +6490,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-06-12T10:15:17+00:00"
+            "time": "2021-06-27T09:19:40+00:00"
         },
         {
             "name": "symfony/http-kernel",
-            "version": "v5.3.2",
+            "version": "v5.3.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/http-kernel.git",
-                "reference": "e7021165d9dbfb4051296b8de827e92c8a7b5c87"
+                "reference": "90ad9f4b21ddcb8ebe9faadfcca54929ad23f9f8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/e7021165d9dbfb4051296b8de827e92c8a7b5c87",
-                "reference": "e7021165d9dbfb4051296b8de827e92c8a7b5c87",
+                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/90ad9f4b21ddcb8ebe9faadfcca54929ad23f9f8",
+                "reference": "90ad9f4b21ddcb8ebe9faadfcca54929ad23f9f8",
                 "shasum": ""
             },
             "require": {
@@ -6571,7 +6586,7 @@
             "description": "Provides a structured process for converting a Request into a Response",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/http-kernel/tree/v5.3.2"
+                "source": "https://github.com/symfony/http-kernel/tree/v5.3.3"
             },
             "funding": [
                 {
@@ -6587,7 +6602,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-06-17T14:18:27+00:00"
+            "time": "2021-06-30T08:27:49+00:00"
         },
         {
             "name": "symfony/mime",
@@ -7722,16 +7737,16 @@
         },
         {
             "name": "symfony/string",
-            "version": "v5.3.2",
+            "version": "v5.3.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/string.git",
-                "reference": "0732e97e41c0a590f77e231afc16a327375d50b0"
+                "reference": "bd53358e3eccec6a670b5f33ab680d8dbe1d4ae1"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/string/zipball/0732e97e41c0a590f77e231afc16a327375d50b0",
-                "reference": "0732e97e41c0a590f77e231afc16a327375d50b0",
+                "url": "https://api.github.com/repos/symfony/string/zipball/bd53358e3eccec6a670b5f33ab680d8dbe1d4ae1",
+                "reference": "bd53358e3eccec6a670b5f33ab680d8dbe1d4ae1",
                 "shasum": ""
             },
             "require": {
@@ -7785,7 +7800,7 @@
                 "utf8"
             ],
             "support": {
-                "source": "https://github.com/symfony/string/tree/v5.3.2"
+                "source": "https://github.com/symfony/string/tree/v5.3.3"
             },
             "funding": [
                 {
@@ -7801,20 +7816,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-06-06T09:51:56+00:00"
+            "time": "2021-06-27T11:44:38+00:00"
         },
         {
             "name": "symfony/translation",
-            "version": "v5.3.2",
+            "version": "v5.3.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/translation.git",
-                "reference": "7e2603bcc598e14804c4d2359d8dc4ee3c40391b"
+                "reference": "380b8c9e944d0e364b25f28e8e555241eb49c01c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/translation/zipball/7e2603bcc598e14804c4d2359d8dc4ee3c40391b",
-                "reference": "7e2603bcc598e14804c4d2359d8dc4ee3c40391b",
+                "url": "https://api.github.com/repos/symfony/translation/zipball/380b8c9e944d0e364b25f28e8e555241eb49c01c",
+                "reference": "380b8c9e944d0e364b25f28e8e555241eb49c01c",
                 "shasum": ""
             },
             "require": {
@@ -7880,7 +7895,7 @@
             "description": "Provides tools to internationalize your application",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/translation/tree/v5.3.2"
+                "source": "https://github.com/symfony/translation/tree/v5.3.3"
             },
             "funding": [
                 {
@@ -7896,7 +7911,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-06-06T09:51:56+00:00"
+            "time": "2021-06-27T12:22:47+00:00"
         },
         {
             "name": "symfony/translation-contracts",
@@ -7978,16 +7993,16 @@
         },
         {
             "name": "symfony/var-dumper",
-            "version": "v5.3.2",
+            "version": "v5.3.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/var-dumper.git",
-                "reference": "905a22c68b292ffb6f20d7636c36b220d1fba5ae"
+                "reference": "46aa709affb9ad3355bd7a810f9662d71025c384"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/var-dumper/zipball/905a22c68b292ffb6f20d7636c36b220d1fba5ae",
-                "reference": "905a22c68b292ffb6f20d7636c36b220d1fba5ae",
+                "url": "https://api.github.com/repos/symfony/var-dumper/zipball/46aa709affb9ad3355bd7a810f9662d71025c384",
+                "reference": "46aa709affb9ad3355bd7a810f9662d71025c384",
                 "shasum": ""
             },
             "require": {
@@ -8046,7 +8061,7 @@
                 "dump"
             ],
             "support": {
-                "source": "https://github.com/symfony/var-dumper/tree/v5.3.2"
+                "source": "https://github.com/symfony/var-dumper/tree/v5.3.3"
             },
             "funding": [
                 {
@@ -8062,20 +8077,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-06-06T09:51:56+00:00"
+            "time": "2021-06-24T08:13:00+00:00"
         },
         {
             "name": "symfony/yaml",
-            "version": "v5.3.2",
+            "version": "v5.3.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/yaml.git",
-                "reference": "71719ab2409401711d619765aa255f9d352a59b2"
+                "reference": "485c83a2fb5893e2ff21bf4bfc7fdf48b4967229"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/yaml/zipball/71719ab2409401711d619765aa255f9d352a59b2",
-                "reference": "71719ab2409401711d619765aa255f9d352a59b2",
+                "url": "https://api.github.com/repos/symfony/yaml/zipball/485c83a2fb5893e2ff21bf4bfc7fdf48b4967229",
+                "reference": "485c83a2fb5893e2ff21bf4bfc7fdf48b4967229",
                 "shasum": ""
             },
             "require": {
@@ -8121,7 +8136,7 @@
             "description": "Loads and dumps YAML files",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/yaml/tree/v5.3.2"
+                "source": "https://github.com/symfony/yaml/tree/v5.3.3"
             },
             "funding": [
                 {
@@ -8137,7 +8152,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-06-06T09:51:56+00:00"
+            "time": "2021-06-24T08:13:00+00:00"
         },
         {
             "name": "thecodingmachine/safe",
@@ -9329,16 +9344,16 @@
         },
         {
             "name": "facade/ignition",
-            "version": "2.10.2",
+            "version": "2.11.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/facade/ignition.git",
-                "reference": "43688227bbf27c43bc1ad83af224f135b6ef0ff4"
+                "reference": "dc6818335f50ccf0b90284784718ea9a82604286"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/facade/ignition/zipball/43688227bbf27c43bc1ad83af224f135b6ef0ff4",
-                "reference": "43688227bbf27c43bc1ad83af224f135b6ef0ff4",
+                "url": "https://api.github.com/repos/facade/ignition/zipball/dc6818335f50ccf0b90284784718ea9a82604286",
+                "reference": "dc6818335f50ccf0b90284784718ea9a82604286",
                 "shasum": ""
             },
             "require": {
@@ -9346,7 +9361,6 @@
                 "ext-mbstring": "*",
                 "facade/flare-client-php": "^1.6",
                 "facade/ignition-contracts": "^1.0.2",
-                "filp/whoops": "^2.4",
                 "illuminate/support": "^7.0|^8.0",
                 "monolog/monolog": "^2.0",
                 "php": "^7.2.5|^8.0",
@@ -9402,7 +9416,7 @@
                 "issues": "https://github.com/facade/ignition/issues",
                 "source": "https://github.com/facade/ignition"
             },
-            "time": "2021-06-11T06:57:25+00:00"
+            "time": "2021-07-12T15:55:51+00:00"
         },
         {
             "name": "facade/ignition-contracts",
@@ -9459,16 +9473,16 @@
         },
         {
             "name": "fakerphp/faker",
-            "version": "v1.14.1",
+            "version": "v1.15.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/FakerPHP/Faker.git",
-                "reference": "ed22aee8d17c7b396f74a58b1e7fefa4f90d5ef1"
+                "reference": "89c6201c74db25fa759ff16e78a4d8f32547770e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/ed22aee8d17c7b396f74a58b1e7fefa4f90d5ef1",
-                "reference": "ed22aee8d17c7b396f74a58b1e7fefa4f90d5ef1",
+                "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/89c6201c74db25fa759ff16e78a4d8f32547770e",
+                "reference": "89c6201c74db25fa759ff16e78a4d8f32547770e",
                 "shasum": ""
             },
             "require": {
@@ -9518,22 +9532,22 @@
             ],
             "support": {
                 "issues": "https://github.com/FakerPHP/Faker/issues",
-                "source": "https://github.com/FakerPHP/Faker/tree/v.1.14.1"
+                "source": "https://github.com/FakerPHP/Faker/tree/v1.15.0"
             },
-            "time": "2021-03-30T06:27:33+00:00"
+            "time": "2021-07-06T20:39:40+00:00"
         },
         {
             "name": "filp/whoops",
-            "version": "2.13.0",
+            "version": "2.14.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/filp/whoops.git",
-                "reference": "2edbc73a4687d9085c8f20f398eebade844e8424"
+                "reference": "fdf92f03e150ed84d5967a833ae93abffac0315b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/filp/whoops/zipball/2edbc73a4687d9085c8f20f398eebade844e8424",
-                "reference": "2edbc73a4687d9085c8f20f398eebade844e8424",
+                "url": "https://api.github.com/repos/filp/whoops/zipball/fdf92f03e150ed84d5967a833ae93abffac0315b",
+                "reference": "fdf92f03e150ed84d5967a833ae93abffac0315b",
                 "shasum": ""
             },
             "require": {
@@ -9583,7 +9597,7 @@
             ],
             "support": {
                 "issues": "https://github.com/filp/whoops/issues",
-                "source": "https://github.com/filp/whoops/tree/2.13.0"
+                "source": "https://github.com/filp/whoops/tree/2.14.0"
             },
             "funding": [
                 {
@@ -9591,7 +9605,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2021-06-04T12:00:00+00:00"
+            "time": "2021-07-13T12:00:00+00:00"
         },
         {
             "name": "friendsofphp/php-cs-fixer",
@@ -10658,16 +10672,16 @@
         },
         {
             "name": "phpunit/phpunit",
-            "version": "9.5.6",
+            "version": "9.5.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "fb9b8333f14e3dce976a60ef6a7e05c7c7ed8bfb"
+                "reference": "d0dc8b6999c937616df4fb046792004b33fd31c5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fb9b8333f14e3dce976a60ef6a7e05c7c7ed8bfb",
-                "reference": "fb9b8333f14e3dce976a60ef6a7e05c7c7ed8bfb",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d0dc8b6999c937616df4fb046792004b33fd31c5",
+                "reference": "d0dc8b6999c937616df4fb046792004b33fd31c5",
                 "shasum": ""
             },
             "require": {
@@ -10745,7 +10759,7 @@
             ],
             "support": {
                 "issues": "https://github.com/sebastianbergmann/phpunit/issues",
-                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.6"
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.7"
             },
             "funding": [
                 {
@@ -10757,20 +10771,20 @@
                     "type": "github"
                 }
             ],
-            "time": "2021-06-23T05:14:38+00:00"
+            "time": "2021-07-19T06:14:47+00:00"
         },
         {
             "name": "psr/cache",
-            "version": "2.0.0",
+            "version": "3.0.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/php-fig/cache.git",
-                "reference": "213f9dbc5b9bfbc4f8db86d2838dc968752ce13b"
+                "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/cache/zipball/213f9dbc5b9bfbc4f8db86d2838dc968752ce13b",
-                "reference": "213f9dbc5b9bfbc4f8db86d2838dc968752ce13b",
+                "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
+                "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
                 "shasum": ""
             },
             "require": {
@@ -10804,9 +10818,9 @@
                 "psr-6"
             ],
             "support": {
-                "source": "https://github.com/php-fig/cache/tree/2.0.0"
+                "source": "https://github.com/php-fig/cache/tree/3.0.0"
             },
-            "time": "2021-02-03T23:23:37+00:00"
+            "time": "2021-02-03T23:26:27+00:00"
         },
         {
             "name": "sebastian/cli-parser",
@@ -11774,16 +11788,16 @@
         },
         {
             "name": "symfony/filesystem",
-            "version": "v5.3.0",
+            "version": "v5.3.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/filesystem.git",
-                "reference": "348116319d7fb7d1faa781d26a48922428013eb2"
+                "reference": "19b71c8f313b411172dd5f470fd61f24466d79a9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/filesystem/zipball/348116319d7fb7d1faa781d26a48922428013eb2",
-                "reference": "348116319d7fb7d1faa781d26a48922428013eb2",
+                "url": "https://api.github.com/repos/symfony/filesystem/zipball/19b71c8f313b411172dd5f470fd61f24466d79a9",
+                "reference": "19b71c8f313b411172dd5f470fd61f24466d79a9",
                 "shasum": ""
             },
             "require": {
@@ -11816,7 +11830,7 @@
             "description": "Provides basic utilities for the filesystem",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/filesystem/tree/v5.3.0"
+                "source": "https://github.com/symfony/filesystem/tree/v5.3.3"
             },
             "funding": [
                 {
@@ -11832,7 +11846,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-05-26T17:43:10+00:00"
+            "time": "2021-06-30T07:27:52+00:00"
         },
         {
             "name": "symfony/options-resolver",

+ 3 - 3
config/version.yml

@@ -4,10 +4,10 @@ current:
   label: v
   major: 0
   minor: 7
-  patch: 4
-  prerelease: 5-ge730c97
+  patch: 5
+  prerelease: 1-g7b9a95c
   buildmetadata: ''
-  commit: e730c9
+  commit: 7b9a95
   timestamp:
     year: 2020
     month: 10

+ 28 - 0
database/factories/FailedDeliveryFactory.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace Database\Factories;
+
+use App\Models\FailedDelivery;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+class FailedDeliveryFactory extends Factory
+{
+    /**
+     * The name of the factory's corresponding model.
+     *
+     * @var string
+     */
+    protected $model = FailedDelivery::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array
+     */
+    public function definition()
+    {
+        return [
+            //
+        ];
+    }
+}

+ 34 - 0
database/migrations/2021_07_05_141300_create_postfix_queue_ids_table.php

@@ -0,0 +1,34 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreatePostfixQueueIdsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('postfix_queue_ids', function (Blueprint $table) {
+            $table->uuid('id');
+            $table->string('queue_id', 20)->unique();
+
+            $table->timestamps();
+            $table->primary('id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('postfix_queue_ids');
+    }
+}

+ 32 - 0
database/migrations/2021_07_14_140246_add_domain_mx_validated_at_to_domains_table.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddDomainMxValidatedAtToDomainsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('domains', function (Blueprint $table) {
+            $table->timestamp('domain_mx_validated_at')->nullable()->after('domain_verified_at');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('domains', function (Blueprint $table) {
+            $table->dropColumn('domain_mx_validated_at');
+        });
+    }
+}

+ 42 - 0
database/migrations/2021_07_15_083825_create_failed_deliveries_table.php

@@ -0,0 +1,42 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateFailedDeliveriesTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('failed_deliveries', function (Blueprint $table) {
+            $table->uuid('id');
+            $table->uuid('user_id');
+            $table->uuid('recipient_id')->nullable();
+            $table->uuid('alias_id')->nullable();
+            $table->string('bounce_type', 4)->nullable();
+            $table->string('remote_mta')->nullable();
+            $table->text('sender')->nullable();
+            $table->string('email_type', 3)->nullable();
+            $table->string('status')->nullable();
+            $table->string('code')->nullable();
+            $table->timestamps();
+
+            $table->primary('id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('failed_deliveries');
+    }
+}

Різницю між файлами не показано, бо вона завелика
+ 295 - 291
package-lock.json


+ 2 - 1
package.json

@@ -35,7 +35,8 @@
         "vuedraggable": "^2.24.2"
     },
     "devDependencies": {
-        "husky": "^6.0.0",
+        "css-loader": "^5.2.7",
+        "husky": "^7.0.0",
         "lint-staged": "^11.0.0",
         "prettier": "^2.2.1"
     },

+ 2 - 0
resources/css/app.css

@@ -74,6 +74,8 @@ html {
 }
 .vgt-responsive {
   @apply overflow-x-auto w-full relative;
+}
+.aliases .vgt-responsive {
   min-height: 220px;
 }
 .vgt-text-disabled {

+ 1 - 0
resources/js/app.js

@@ -30,6 +30,7 @@ Vue.component('recipients', require('./pages/Recipients.vue').default)
 Vue.component('domains', require('./pages/Domains.vue').default)
 Vue.component('usernames', require('./pages/Usernames.vue').default)
 Vue.component('rules', require('./pages/Rules.vue').default)
+Vue.component('failed-deliveries', require('./pages/FailedDeliveries.vue').default)
 
 Vue.component(
   'passport-personal-access-tokens',

+ 2 - 1
resources/js/pages/Aliases.vue

@@ -1,5 +1,5 @@
 <template>
-  <div>
+  <div class="aliases">
     <div class="flex flex-wrap flex-row items-center justify-between mb-8 md:px-2 lg:px-6">
       <div
         class="
@@ -293,6 +293,7 @@
       v-if="initialAliases.length"
       @on-search="debounceToolips"
       @on-page-change="debounceToolips"
+      @on-per-page-change="debounceToolips"
       :columns="columns"
       :rows="rows"
       :search-options="{

+ 53 - 2
resources/js/pages/Domains.vue

@@ -207,8 +207,9 @@
           />
         </span>
         <span v-else-if="props.column.field === 'domain_sending_verified_at'">
-          <div v-if="props.row.domain_sending_verified_at">
+          <div v-if="props.row.domain_sending_verified_at || props.row.domain_mx_validated_at">
             <svg
+              v-if="props.row.domain_sending_verified_at && props.row.domain_mx_validated_at"
               class="h-5 w-5 inline-block"
               xmlns="http://www.w3.org/2000/svg"
               viewBox="0 0 20 20"
@@ -224,6 +225,56 @@
                 ></polyline>
               </g>
             </svg>
+            <svg
+              v-else-if="!props.row.domain_mx_validated_at"
+              xmlns="http://www.w3.org/2000/svg"
+              viewBox="0 0 20 20"
+              class="h-5 w-5 inline-block tooltip"
+              data-tippy-content="MX records invalid"
+            >
+              <g fill="none" fill-rule="evenodd">
+                <circle cx="10" cy="10" r="10" fill="#FF9B9B"></circle>
+                <polyline
+                  stroke="#AB091E"
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  stroke-width="2"
+                  points="14 6 6 14"
+                ></polyline>
+                <polyline
+                  stroke="#AB091E"
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  stroke-width="2"
+                  points="6 6 14 14"
+                ></polyline>
+              </g>
+            </svg>
+            <svg
+              v-else
+              xmlns="http://www.w3.org/2000/svg"
+              viewBox="0 0 20 20"
+              class="h-5 w-5 inline-block tooltip"
+              data-tippy-content="DNS records for sending invalid"
+            >
+              <g fill="none" fill-rule="evenodd">
+                <circle cx="10" cy="10" r="10" fill="#FF9B9B"></circle>
+                <polyline
+                  stroke="#AB091E"
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  stroke-width="2"
+                  points="14 6 6 14"
+                ></polyline>
+                <polyline
+                  stroke="#AB091E"
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  stroke-width="2"
+                  points="6 6 14 14"
+                ></polyline>
+              </g>
+            </svg>
             <button
               @click="openCheckRecordsModal(rows[props.row.originalIndex])"
               class="focus:outline-none text-sm ml-2"
@@ -650,7 +701,7 @@ export default {
           globalSearchDisabled: true,
         },
         {
-          label: 'Verified for Sending',
+          label: 'Verified Records',
           field: 'domain_sending_verified_at',
           globalSearchDisabled: true,
         },

+ 276 - 0
resources/js/pages/FailedDeliveries.vue

@@ -0,0 +1,276 @@
+<template>
+  <div>
+    <div class="mb-6 flex flex-col md:flex-row justify-between md:items-center">
+      <div class="relative">
+        <input
+          v-model="search"
+          @keyup.esc="search = ''"
+          tabindex="0"
+          type="text"
+          class="
+            w-full
+            md:w-64
+            appearance-none
+            shadow
+            bg-white
+            text-grey-700
+            focus:outline-none
+            rounded
+            py-3
+            pl-3
+            pr-8
+          "
+          placeholder="Search Failed Deliveries"
+        />
+        <icon
+          v-if="search"
+          @click.native="search = ''"
+          name="close-circle"
+          class="
+            absolute
+            right-0
+            inset-y-0
+            w-5
+            h-full
+            text-grey-300
+            fill-current
+            mr-2
+            flex
+            items-center
+            cursor-pointer
+          "
+        />
+        <icon
+          v-else
+          name="search"
+          class="
+            absolute
+            right-0
+            inset-y-0
+            w-5
+            h-full
+            text-grey-300
+            fill-current
+            pointer-events-none
+            mr-2
+            flex
+            items-center
+          "
+        />
+      </div>
+    </div>
+
+    <vue-good-table
+      v-if="initialFailedDeliveries.length"
+      @on-search="debounceToolips"
+      :columns="columns"
+      :rows="rows"
+      :search-options="{
+        enabled: true,
+        skipDiacritics: true,
+        externalQuery: search,
+      }"
+      :sort-options="{
+        enabled: true,
+        initialSortBy: { field: 'created_at', type: 'desc' },
+      }"
+      styleClass="vgt-table"
+    >
+      <div slot="emptystate" class="flex items-center justify-center h-24 text-lg text-grey-700">
+        No failed deliveries found for that search!
+      </div>
+      <template slot="table-row" slot-scope="props">
+        <span
+          v-if="props.column.field == 'created_at'"
+          class="tooltip outline-none text-sm"
+          :data-tippy-content="rows[props.row.originalIndex].created_at | formatDate"
+          >{{ props.row.created_at | timeAgo }}
+        </span>
+        <span v-else-if="props.column.field == 'recipient'">
+          <span
+            class="tooltip cursor-pointer outline-none text-sm"
+            data-tippy-content="Click to copy"
+            v-clipboard="
+              () =>
+                rows[props.row.originalIndex].recipient
+                  ? rows[props.row.originalIndex].recipient.email
+                  : ''
+            "
+            v-clipboard:success="clipboardSuccess"
+            v-clipboard:error="clipboardError"
+            >{{ props.row.recipient ? props.row.recipient.email : '' }}</span
+          >
+        </span>
+        <span v-else-if="props.column.field == 'alias'">
+          <span
+            class="tooltip cursor-pointer outline-none text-sm"
+            data-tippy-content="Click to copy"
+            v-clipboard="
+              () =>
+                rows[props.row.originalIndex].alias ? rows[props.row.originalIndex].alias.email : ''
+            "
+            v-clipboard:success="clipboardSuccess"
+            v-clipboard:error="clipboardError"
+            >{{ props.row.alias ? props.row.alias.email : '' }}</span
+          >
+        </span>
+        <span v-else-if="props.column.field == 'bounce_type'" class="text-sm">
+          {{ props.row.bounce_type }}
+        </span>
+        <span v-else-if="props.column.field == 'remote_mta'" class="text-sm">
+          {{ props.row.remote_mta }}
+        </span>
+        <span v-else-if="props.column.field == 'sender'" class="text-sm">
+          <span
+            class="tooltip cursor-pointer outline-none"
+            data-tippy-content="Click to copy"
+            v-clipboard="() => rows[props.row.originalIndex].sender"
+            v-clipboard:success="clipboardSuccess"
+            v-clipboard:error="clipboardError"
+            >{{ props.row.sender }}</span
+          >
+        </span>
+        <!-- <span v-else-if="props.column.field == 'email_type'" class="text-sm">
+          {{ props.row.email_type }}
+        </span>
+        <span v-else-if="props.column.field == 'status'" class="text-sm">
+          {{ props.row.status }}
+        </span> -->
+        <span v-else-if="props.column.field == 'code'" class="text-sm">
+          {{ props.row.code }}
+        </span>
+      </template>
+    </vue-good-table>
+
+    <div v-else class="bg-white rounded shadow overflow-x-auto">
+      <div class="p-8 text-center text-lg text-grey-700">
+        <h1 class="mb-6 text-xl text-indigo-800 font-semibold">
+          This is where you can see failed email delivery attempts
+        </h1>
+        <div class="mx-auto mb-6 w-24 border-b-2 border-grey-200"></div>
+        <p class="mb-4">
+          Sometimes when AnonAddy attempts to send an email, the delivery is not successful. This is
+          often referred to as a "bounced email".
+        </p>
+        <p>
+          This page allows you to see any failed deliveries relating to your account and the reason
+          why they failed.
+        </p>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { roundArrow } from 'tippy.js'
+import 'tippy.js/dist/svg-arrow.css'
+import 'tippy.js/dist/tippy.css'
+import tippy from 'tippy.js'
+
+export default {
+  props: {
+    initialFailedDeliveries: {
+      type: Array,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      search: '',
+      errors: {},
+      columns: [
+        {
+          label: 'Created',
+          field: 'created_at',
+          globalSearchDisabled: true,
+        },
+        {
+          label: 'Recipient',
+          field: 'recipient',
+          globalSearchDisabled: true,
+        },
+        {
+          label: 'Alias',
+          field: 'alias',
+          globalSearchDisabled: true,
+        },
+        {
+          label: 'Type',
+          field: 'bounce_type',
+        },
+        {
+          label: 'Remote MTA',
+          field: 'remote_mta',
+        },
+        {
+          label: 'Sender',
+          field: 'sender',
+        },
+        /* {
+          label: 'Email Type',
+          field: 'email_type',
+        }, */
+        /* {
+          label: 'Status',
+          field: 'status',
+        }, */
+        {
+          label: 'Code',
+          field: 'code',
+          sortable: false,
+        },
+        {
+          label: '',
+          field: 'actions',
+          sortable: false,
+          globalSearchDisabled: true,
+        },
+      ],
+      rows: this.initialFailedDeliveries,
+      tippyInstance: null,
+    }
+  },
+  methods: {
+    addTooltips() {
+      if (this.tippyInstance) {
+        _.each(this.tippyInstance, instance => instance.destroy())
+      }
+
+      this.tippyInstance = tippy('.tooltip', {
+        arrow: roundArrow,
+        allowHTML: true,
+      })
+    },
+    debounceToolips: _.debounce(function () {
+      this.addTooltips()
+    }, 50),
+    clipboardSuccess() {
+      this.success('Copied to clipboard')
+    },
+    clipboardError() {
+      this.error('Could not copy to clipboard')
+    },
+    warn(text = '') {
+      this.$notify({
+        title: 'Information',
+        text: text,
+        type: 'warn',
+      })
+    },
+    success(text = '') {
+      this.$notify({
+        title: 'Success',
+        text: text,
+        type: 'success',
+      })
+    },
+    error(text = 'An error has occurred, please try again later') {
+      this.$notify({
+        title: 'Error',
+        text: text,
+        type: 'error',
+      })
+    },
+  },
+}
+</script>

+ 11 - 2
resources/js/pages/Rules.vue

@@ -73,10 +73,19 @@
 
     <div v-else class="bg-white rounded shadow overflow-x-auto">
       <div class="p-8 text-center text-lg text-grey-700">
-        <h1 class="mb-6 text-2xl text-indigo-800 font-semibold">
-          It doesn't look like you have any rules yet!
+        <h1 class="mb-6 text-xl text-indigo-800 font-semibold">
+          This is where you can add and view your rules
         </h1>
         <div class="mx-auto mb-6 w-24 border-b-2 border-grey-200"></div>
+        <p class="mb-4">
+          Rules can be used to set certain conditions that when matched cause actions to be
+          performed. Rules are evaluated in the order they appear on this page, you can reorder them
+          by dragging and dropping them.
+        </p>
+        <p class="mb-4">
+          For example you could create a rule that checks if the subject of an email contains the
+          word "offer" and if so to block the email.
+        </p>
         <p class="mb-4">Click the button above to create a new rule.</p>
       </div>
     </div>

+ 9 - 0
resources/views/failed_deliveries/index.blade.php

@@ -0,0 +1,9 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="container py-8">
+        @include('shared.status')
+
+        <failed-deliveries :initial-failed-deliveries="{{json_encode($failedDeliveries)}}"/>
+    </div>
+@endsection

+ 14 - 0
resources/views/mail/domain_mx_records_invalid.blade.php

@@ -0,0 +1,14 @@
+@component('mail::message')
+
+# Domain MX records invalid
+
+A recent DNS record check on your custom domain **{{ $domain }}** on AnonAddy showed that your MX records are no longer pointing to the AnonAddy server. This means that AnonAddy will not be able to handle your emails for you.
+
+If this MX record change was intentional then you can ignore this email.
+
+Otherwise please visit the domains page on the site by clicking the button below and then rechecking your domain's records to resolve the issue.
+
+@component('mail::button', ['url' => config('app.url').'/domains'])
+Check Domain
+@endcomponent
+@endcomponent

+ 18 - 0
resources/views/mail/domain_unverified_for_sending.blade.php

@@ -0,0 +1,18 @@
+@component('mail::message')
+
+# Domain Unverified For Sending
+
+A recent DNS record check on your custom domain **{{ $domain }}** failed on AnonAddy. This means that your domain had been unverified for sending until the DNS records are added correctly.
+
+The check failed for the following reason:
+
+**{{ $reason }}**
+
+Please visit the domains page on the site by clicking the button below to resolve the issue.
+
+Emails for your custom domain will be sent from an AnonAddy domain in the mean time.
+
+@component('mail::button', ['url' => config('app.url').'/domains'])
+Check Domain
+@endcomponent
+@endcomponent

+ 3 - 0
resources/views/nav/nav.blade.php

@@ -25,6 +25,9 @@
                 <a href="{{ route('usernames.index') }}" class="block mt-4 md:inline-block md:mt-0 hover:text-white mr-4 {{ Route::currentRouteNamed('usernames.index') ? 'text-white' : 'text-indigo-100' }}">
                     Usernames
                 </a>
+                <a href="{{ route('failed_deliveries.index') }}" class="block mt-3 md:inline-block md:mt-0 hover:text-white mr-4 {{ Route::currentRouteNamed('failed_deliveries.index') ? 'text-white' : 'text-indigo-100' }}">
+                    Failed Deliveries
+                </a>
                 <a href="{{ route('rules.index') }}" class="block mt-4 md:inline-block md:mt-0 hover:text-white mr-4 {{ Route::currentRouteNamed('rules.index') ? 'text-white' : 'text-indigo-100' }}">
                     Rules
                 </a>

+ 2 - 0
routes/web.php

@@ -47,6 +47,8 @@ Route::middleware(['auth', 'verified', '2fa', 'webauthn'])->group(function () {
     Route::get('/deactivate/{alias}', 'DeactivateAliasController@deactivate')->name('deactivate');
 
     Route::get('/rules', 'ShowRuleController@index')->name('rules.index');
+
+    Route::get('/failed-deliveries', 'ShowFailedDeliveryController@index')->name('failed_deliveries.index');
 });
 
 

+ 1 - 1
tests/Feature/ShowDomainsTest.php

@@ -73,6 +73,6 @@ class ShowDomainsTest extends TestCase
 
         $response->assertStatus(200);
 
-        $this->assertEquals('SPF record not found. This could be due to DNS caching, please try again later.', $response->json('message'));
+        $this->assertEquals('Records verified for sending.', $response->json('message'));
     }
 }

+ 64 - 0
tests/Feature/ShowFailedDeliveriesTest.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\FailedDelivery;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Carbon;
+use Tests\TestCase;
+
+class ShowFailedDeliveriesTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $user;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->user = User::factory()->create(['username' => 'johndoe']);
+        $this->actingAs($this->user);
+    }
+
+    /** @test */
+    public function user_can_view_failed_deliveries_from_the_failed_deliveries_page()
+    {
+        $this->withoutExceptionHandling();
+        $failedDeliveries = FailedDelivery::factory()->count(3)->create([
+            'user_id' => $this->user->id
+        ]);
+
+        $response = $this->get('/failed-deliveries');
+
+        $response->assertSuccessful();
+        $this->assertCount(3, $response->data('failedDeliveries'));
+        $failedDeliveries->assertEquals($response->data('failedDeliveries'));
+    }
+
+    /** @test */
+    public function latest_failed_deliveries_are_listed_first()
+    {
+        $a = FailedDelivery::factory()->create([
+            'user_id' => $this->user->id,
+            'created_at' => Carbon::now()->subDays(15)
+        ]);
+        $b = FailedDelivery::factory()->create([
+            'user_id' => $this->user->id,
+            'created_at' => Carbon::now()->subDays(5)
+        ]);
+        $c = FailedDelivery::factory()->create([
+            'user_id' => $this->user->id,
+            'created_at' => Carbon::now()->subDays(10)
+        ]);
+
+        $response = $this->get('/failed-deliveries');
+
+        $response->assertSuccessful();
+        $this->assertCount(3, $response->data('failedDeliveries'));
+        $this->assertTrue($response->data('failedDeliveries')[0]->is($b));
+        $this->assertTrue($response->data('failedDeliveries')[1]->is($c));
+        $this->assertTrue($response->data('failedDeliveries')[2]->is($a));
+    }
+}

Деякі файли не було показано, через те що забагато файлів було змінено