Compare commits

...

37 commits

Author SHA1 Message Date
Will Browning
fca7d88894 Updated packages 2024-12-09 16:45:54 +00:00
Will Browning
67c824ce1c Updated packages 2024-11-13 07:49:16 +00:00
Will Browning
abab703ecf Updated packages 2024-10-22 20:49:52 +01:00
Will Browning
e7045cc7f7 Updated API auth routes 2024-09-27 15:38:35 +01:00
Will Browning
c832b1b556 Updated packages 2024-09-04 15:51:29 +01:00
Will Browning
aa0addeacd Added Edge browser extension link 2024-08-28 09:44:50 +01:00
Will Browning
adddee0ba1 Rollback mews/catpcha to v3.3.3 2024-08-27 11:57:41 +01:00
Will Browning
5d0270d880 Fixed failing test 2024-08-23 14:21:50 +01:00
Will Browning
16933763d0 Added "last used at" sort option to aliases 2024-08-23 14:12:18 +01:00
Will Browning
7f2ea49651 Increased custom domain max length 2024-08-02 08:52:16 +01:00
Will Browning
3f789f53ef Updated packages 2024-08-01 17:12:13 +01:00
Will Browning
7916d0c004 Fixed #654 2024-08-01 09:43:28 +01:00
Will Browning
28d685de61 Updated packages 2024-07-30 10:58:10 +01:00
Will Browning
28cf7b475e Added regex options to rule conditions 2024-07-24 12:35:24 +01:00
Will Browning
18e1223b90 Added auto create regex to usernames and domains 2024-07-23 11:41:54 +01:00
Will Browning
0e75d42e3d Added info tooltips 2024-07-03 11:04:03 +01:00
Will Browning
bc626e80bf Updated Rules 2024-06-26 11:43:30 +01:00
Will Browning
5e186b4f08 Updated packages 2024-05-28 13:39:01 +01:00
Will Browning
50f7ef4556 Updated self-hosting 2024-05-17 13:37:59 +01:00
Will Browning
5cf59c4f7d Upgraded to Laravel 11 2024-05-15 12:19:56 +01:00
Will Browning
e7a723df1c Fixed rules table on mobile 2024-05-04 09:42:34 +01:00
Will Browning
731d33ac2c Added applied columns to rules 2024-05-03 16:52:28 +01:00
Will Browning
9677d53db5 Fixed selectedRows bug in aliases table 2024-04-15 12:39:41 +01:00
Will Browning
617bef732a Updated README 2024-03-26 17:22:52 +00:00
Will Browning
3661285b95 Updated README 2024-03-26 17:19:27 +00:00
Will Browning
0753ffcdd0 Added 'last used at' to aliases 2024-03-25 15:39:49 +00:00
Will Browning
59ba258d17 Closes #143 2024-03-13 17:35:12 +00:00
Will Browning
576dd351b6 Updated catch to Throwable 2024-03-06 08:37:08 +00:00
Will Browning
7becf1ac87 Closes #605 2024-03-01 12:52:43 +00:00
Will Browning
7e7edaffb2 Fixed #604 2024-02-28 09:32:02 +00:00
Will Browning
13053e506e Fixed #602 2024-02-27 13:54:50 +00:00
Will Browning
f80dfa4a69 Added overrides to package.json 2024-02-18 16:01:54 +00:00
Will Browning
31d206ac41 Closes #589 2024-02-12 16:08:29 +00:00
Will Browning
fef0c1b02c Fixed return type in Account Details endpoint 2024-02-12 13:10:16 +00:00
Will Browning
f939a0ba87 Added params to aliases endpoint 2024-02-06 16:16:02 +00:00
Will Browning
e72336d020 Updated packages 2024-01-26 13:06:28 +00:00
Will Browning
e1c47248b8 Fixed PersonalAccessTokenResource 2024-01-24 09:53:24 +00:00
196 changed files with 7551 additions and 4343 deletions

5
.gitignore vendored
View file

@ -1,3 +1,4 @@
/.phpunit.cache
/node_modules
/public/hot
/public/storage
@ -9,14 +10,18 @@
/storage/debugbar
/vendor
/postfix/vendor
/.fleet
/.idea
/.vscode
/.vagrant
Homestead.json
Homestead.yaml
auth.json
npm-debug.log
yarn-error.log
.env
.env.backup
.env.production
.php-cs-fixer.cache
.phpunit.result.cache
ray.php

View file

@ -21,6 +21,11 @@ This is the source code for self-hosting addy.io.
- [How do I add my own GPG/OpenPGP key for encryption?](#how-do-i-add-my-own-gpgopenpgp-key-for-encryption)
- [Are attachments encrypted too?](#are-attachments-encrypted-too)
- [Are forwarded emails signed when encryption is enabled?](#are-forwarded-emails-signed-when-encryption-is-enabled)
- [Can I reply/send from aliases using encryption?](#can-i-replysend-from-aliases-using-encryption)
- [Is my public GPG/OpenPGP key removed when I reply/send from an alias?](#is-my-public-gpgopenpgp-key-removed-when-i-replysend-from-an-alias)
- [Can I mark emails forwarded to me by addy.io as spam?](#can-i-mark-emails-forwarded-to-me-by-addyio-as-spam)
- [Can I use aliases to create multiple accounts on other websites and services?](#can-i-use-aliases-to-create-multiple-accounts-on-other-websites-and-services)
- [Can I have multiple Free accounts?](#can-i-have-multiple-free-accounts)
- [What if I don't want anyone to link ownership of my aliases together?](#what-if-i-dont-want-anyone-to-link-ownership-of-my-aliases-together)
- [Where is the server located?](#where-is-the-server-located)
- [What if I don't trust you?](#what-if-i-dont-trust-you)
@ -30,6 +35,7 @@ This is the source code for self-hosting addy.io.
- [How do I reply to a forwarded email?](#how-do-i-reply-to-a-forwarded-email)
- [I'm trying to reply/send from an alias but the email keeps coming back to me, what's wrong?](#im-trying-to-replysend-from-an-alias-but-the-email-keeps-coming-back-to-me-whats-wrong)
- [I'm trying to reply/send from an alias but it is rejected, what's wrong?](#im-trying-to-replysend-from-an-alias-but-it-is-rejected-whats-wrong)
- [I've been forwarded an email with a red warning banner saying it may have been spoofed, what does it mean?](#ive-been-forwarded-an-email-with-a-red-warning-banner-saying-it-may-have-been-spoofed-what-does-it-mean)
- [Does addy.io strip out the banner information when I reply to an email?](#does-addyio-strip-out-the-banner-information-when-i-reply-to-an-email)
- [How do I send email from an alias?](#how-do-i-send-email-from-an-alias)
- [Will people see my real email if I reply to a forwarded one?](#will-people-see-my-real-email-if-i-reply-to-a-forwarded-one)
@ -37,6 +43,7 @@ This is the source code for self-hosting addy.io.
- [What is the max email size limit?](#what-is-the-max-email-size-limit)
- [What happens if I have a subscription but then cancel it?](#what-happens-if-i-have-a-subscription-but-then-cancel-it)
- [If I subscribe will Stripe see my real email address?](#if-i-subscribe-will-stripe-see-my-real-email-address)
- [Do you offer student discount?](#do-you-offer-student-discount)
- [How do you prevent spammers?](#how-do-you-prevent-spammers)
- [What do you use to do DNS lookups on domain names?](#what-do-you-use-to-do-dns-lookups-on-domain-names)
- [Is there a limit to how many emails I can forward?](#is-there-a-limit-to-how-many-emails-i-can-forward)
@ -88,7 +95,7 @@ There are a number of reasons you should consider using this service:
## Do you store emails?
No I definitely do not store/save any emails that pass through the server.
Emails are only ever stored in the event of a failed delivery, and only if you have this option enabled in your account settings.
## What is a shared domain alias?
@ -173,6 +180,32 @@ You can add this key to your own keyring so that you can verify emails have come
The fingerprint of the no-reply@addy.io key is "26A987650243B28802524E2F809FD0D502E2F695" you can find the key on [https://keys.openpgp.org](https://keys.openpgp.org/search?q=26A987650243B28802524E2F809FD0D502E2F695).
## Can I reply/send from aliases using encryption?
1. If the person you are sending your message to **already uses GPG/OpenPGP encryption** then you can simply encrypt your reply/send from your alias using their public key.
2. If the person you are sending your message to **does not use GPG/OpenPGP encryption** then you can instead encrypt your reply/send with the `no-reply@addy.io` [public key](https://keys.openpgp.org/search?q=26A987650243B28802524E2F809FD0D502E2F695) (<span class="break-words">"26A987650243B28802524E2F809FD0D502E2F695"</span>). Your reply/send will then be **automatically decrypted** on the addy.io server before being sent on to the correct destination in clear text. This is useful if you wish to hide your replies/sends from your email provider such as Gmail.
## Is my public GPG/OpenPGP key removed when I reply/send from an alias?
Yes, any attached GPG/OpenPGP public keys or GPG/OpenPGP signatures are automatically removed when replying or sending from an alias. This is to prevent you accidentally revealing your real email address which is usually shown as an identity in your public key.
## Can I mark emails forwarded to me by addy.io as spam?
No, you must not mark messages forwarded to you by addy.io as spam as this can damage the reputation of the mail servers and is against the [terms and conditions](https://addy.io/terms/).
If an alias is receiving spam messages then please deactivate it or delete it.
addy.io is signed up to multiple feedback loops (FBLs) that trigger a notification when any messages are marked as spam. Repeatedly marking messages as spam will result in your account being disabled.
## Can I use aliases to create multiple accounts on other websites and services?
No, you must not use addy.io to create large numbers of accounts on other websites/services as this is against the [terms and conditions](https://addy.io/terms/).
## Can I have multiple Free accounts?
Having multiple Free accounts is not considered an acceptable use of our service. Any users found to be abusing this rule may have their accounts disabled. This does not apply to those with a paid subscription.
## What if I don't want anyone to link ownership of my aliases together?
If you're concerned that your aliases are all linked by your username e.g. @johndoe.anonaddy.com, then you have a couple of options:
@ -255,10 +288,20 @@ To learn more about DMARC please see this site - [https://dmarc.org/](https://dm
If your addy.io recipient is with a popular mail service provider for example: Gmail, Outlook, Tutanota, Mailbox.org, Protonmail etc. then they will already have a DMARC policy in place so you do not need to take any action.
## I've been forwarded an email with a red warning banner saying it may have been spoofed, what does it mean?
If an incoming email looks like spam (for example, because it has failed its [DMARC](https://dmarc.org/overview/) check) then a red warning banner is added by addy.io before forwarding the message on to you. This warning banner is added in order to help protect you from any potential phishing attempts, for example someone pretending to be your bank.
Most of the time this is nothing to worry about and is just because the sender has not correctly configured their DNS records.
To see why this banner was added you can view the headers of the received email and look for the header called 'X-AnonAddy-Authentication-Results'. This header shows the original email's authentication results and will show you why the email failed its DMARC checks.
## Does addy.io strip out the banner information when I reply to an email?
Yes, the email banner "This email was sent to..." will be automatically removed when you reply to any messages. You can test this by replying to yourself from one of your aliases.
Make sure not to alter or edit the email banner as this may cause issues when trying to match and remove it. You can still remove it manually from the quoted message of your reply if you wish.
## How do I send email from an alias?
This works in the same way as replying to an email.
@ -321,6 +364,10 @@ You will not be able to activate any of the above again until you resubscribe.
When you subscribe you can choose which email to provide to Stripe, feel free to use an alias. This email will be used for notifications from Stripe such as; if your card payment fails or if your card has expired.
## Do you offer student discount?
Currently, addy.io does not offer any student discounts.
## How do you prevent spammers?
The following is in place to help prevent spam:
@ -362,7 +409,7 @@ If you get close to your limit (over 80%) you'll be sent an email letting you kn
## Can I login using an additional username?
Yes, you can login with any of your usernames. You can add 1 additional username as a Lite user and up to 10 additional usernames as a Pro user for totals of 2 and 11 respectively (including the one you signed up with).
Yes, you can login with any of your usernames. You can add 5 additional username as a Lite user and up to 20 additional usernames as a Pro user for totals of 6 and 21 respectively (including the one you signed up with).
## I'm not receiving any emails, what's wrong?

View file

@ -19,6 +19,7 @@
- [Enabling DANE by implementing DNSSEC and adding a TLSA record](#enabling-dane-by-implementing-dnssec-and-adding-a-tlsa-record)
- [Adding Certification Authority Authorization](#adding-certification-authority-authorization)
- [Updating](#updating)
- [Troubleshooting](#troubleshooting)
## Assumptions
@ -259,7 +260,6 @@ smtpd_recipient_restrictions =
reject_rhsbl_reverse_client dbl.spamhaus.org,
reject_rhsbl_sender dbl.spamhaus.org,
reject_rbl_client zen.spamhaus.org
reject_rbl_client dul.dnsbl.sorbs.net
# Block clients that speak too early.
smtpd_data_restrictions = reject_unauth_pipelining
@ -1356,18 +1356,29 @@ npm install
npm run production
# Run any database migrations
php artisan migrate
php artisan migrate --force
# Clear cache
php artisan config:cache
php artisan view:cache
php artisan route:cache
php artisan event:cache
# Cache config, events, routes and views
php artisan optimize
# Restart queue workers to reflect changes
php artisan queue:restart
```
## Troubleshooting
If you run into any problems then please check the following logs which should provide more information:
- `/var/www/anonaddy/storage/logs/laravel*.log` - Web application error logs (any errors relating to issues with the web application)
- `/var/log/mail.log` - Postfix mail logs (details of received and sent emails)
- `/var/log/mail.err` - Postfix errors (errors relating to Postfix configuration)
- `/var/log/php8.2-fpm.log` - PHP logs (logs relating to PHP FastCGI Process Manager)
- `/var/log/nginx/access.log` - Nginx access logs (log of client requests)
- `/var/log/nginx/error.log` - Nginx error logs (log of any server or request errors)
- `/var/log/supervisor/*.log` - Supervisor logs (log of any web application queue issues)
If a queued job (e.g. forwarding an email) fails, it is stored in the `failed_jobs` table in the database and can be [retried](https://laravel.com/docs/11.x/queues#retrying-failed-jobs).
## Credits
A big thank you to Xiao Guoan over at [linuxbabe.com](https://www.linuxbabe.com/) for all of his amazing articles. I highly recommend you subscribe to his newsletter.

View file

@ -44,7 +44,7 @@ class ClearFailedDeliveries extends Command
// Delete any stored failed deliveries older than 7 days
collect(Storage::disk('local')->listContents(''))
->each(function ($file) {
if ($file['type'] == 'file' && $file['lastModified'] < now()->subDays(7)->getTimestamp()) {
if ($file['type'] === 'file' && $file['lastModified'] < now()->subDays(7)->getTimestamp()) {
Storage::disk('local')->delete($file['path']);
}
});

View file

@ -55,14 +55,14 @@ class CreateUser extends Command
'regex:/^[a-zA-Z0-9]*$/',
'max:20',
'unique:usernames,username',
new NotDeletedUsername(),
new NotDeletedUsername,
],
'email' => [
'required',
'email:rfc,dns',
'max:254',
new RegisterUniqueRecipient(),
new NotLocalRecipient(),
new RegisterUniqueRecipient,
new NotLocalRecipient,
],
]);

View file

@ -41,7 +41,7 @@ class EmailUsersWithTokenExpiringSoon extends Command
*/
public function handle()
{
User::with(['defaultUsername', 'defaultRecipient'])
User::with(['defaultUsername', 'defaultRecipient', 'tokens'])
->whereHas('tokens', function ($query) {
$query->whereDate('expires_at', now()->addWeek());
})

View file

@ -75,13 +75,13 @@ class ReceiveEmail extends Command
try {
$this->exitIfFromSelf();
$recipients = $this->getRecipients();
$file = $this->argument('file');
$this->parser = $this->getParser($file);
$this->senderFrom = $this->getSenderFrom();
$recipients = $this->getRecipients();
// Divide the size of the email by the number of recipients (excluding any unsubscribe recipients) to prevent it being added multiple times.
$recipientCount = $recipients->where('domain', '!=', 'unsubscribe.'.config('anonaddy.domain'))->count();
@ -200,9 +200,9 @@ class ReceiveEmail extends Command
}
if ($this->parser->getHeader('In-Reply-To') && $alias) {
$this->handleReply($user, $recipient, $alias);
$this->handleReply($user, $alias, $validEmailDestination);
} else {
$this->handleSendFrom($user, $recipient, $alias ?? null, $aliasable ?? null);
$this->handleSendFrom($user, $recipient, $alias ?? null, $aliasable ?? null, $validEmailDestination);
}
} elseif ($verifiedRecipient?->can_reply_send === false) {
// Notify user that they have not allowed this recipient to reply and send from aliases
@ -214,11 +214,11 @@ class ReceiveEmail extends Command
$this->handleForward($user, $recipient, $alias ?? null, $aliasable ?? null, $this->parser->getHeader('X-AnonAddy-Spam') === 'Yes');
}
}
} catch (\Exception $e) {
report($e);
} catch (\Throwable $e) {
$this->error('4.3.0 An error has occurred, please try again later.');
report($e);
exit(1);
}
}
@ -232,18 +232,16 @@ class ReceiveEmail extends Command
}
}
protected function handleReply($user, $recipient, $alias)
protected function handleReply($user, $alias, $destination)
{
$sendTo = Str::replaceLast('=', '@', $recipient['extension']);
$emailData = new EmailData($this->parser, $this->option('sender'), $this->size);
$emailData = new EmailData($this->parser, $this->option('sender'), $this->size, 'R');
$message = new ReplyToEmail($user, $alias, $emailData);
Mail::to($sendTo)->queue($message);
Mail::to($destination)->queue($message);
}
protected function handleSendFrom($user, $recipient, $alias, $aliasable)
protected function handleSendFrom($user, $recipient, $alias, $aliasable, $destination)
{
if (is_null($alias)) {
$alias = $user->aliases()->create([
@ -258,13 +256,11 @@ class ReceiveEmail extends Command
$alias->refresh();
}
$sendTo = Str::replaceLast('=', '@', $recipient['extension']);
$emailData = new EmailData($this->parser, $this->option('sender'), $this->size);
$emailData = new EmailData($this->parser, $this->option('sender'), $this->size, 'S');
$message = new SendFromEmail($user, $alias, $emailData);
Mail::to($sendTo)->queue($message);
Mail::to($destination)->queue($message);
}
protected function handleForward($user, $recipient, $alias, $aliasable, $isSpam)
@ -351,12 +347,24 @@ class ReceiveEmail extends Command
// Try to determine the bounce type, HARD, SPAM, SOFT
$bounceType = $this->getBounceType($dsn['Diagnostic-code'], $dsn['Status']);
$diagnosticCode = Str::limit($dsn['Diagnostic-code'], 497);
$diagnosticCode = trim(Str::limit($dsn['Diagnostic-code'], 497));
} else {
$bounceType = null;
$diagnosticCode = null;
}
// To sort '5.7.1 (delivery not authorized, message refused)' as status
if ($status = $dsn['Status'] ?? null) {
if (Str::length($status) > 5) {
if (is_null($diagnosticCode)) {
$diagnosticCode = trim(Str::substr($status, 5, 497));
}
$status = trim(Str::substr($status, 0, 5));
}
}
// Get the undelivered message
$undeliveredMessage = $attachments->filter(function ($attachment) {
return in_array($attachment->getContentType(), ['text/rfc822-headers', 'message/rfc822']);
@ -394,7 +402,7 @@ class ReceiveEmail extends Command
'sender' => $undeliveredMessageHeaders['X-anonaddy-original-sender'] ?? null,
'destination' => $bouncedEmailAddress,
'email_type' => $emailType,
'status' => $dsn['Status'] ?? null,
'status' => $status ?? null,
'code' => $diagnosticCode,
'attempted_at' => $outboundMessage->created_at,
]);
@ -446,7 +454,7 @@ class ReceiveEmail extends Command
}
if ($user->nearBandwidthLimit() && ! Cache::has("user:{$user->id}:near-bandwidth")) {
$user->notify(new NearBandwidthLimit());
$user->notify(new NearBandwidthLimit);
Cache::put("user:{$user->id}:near-bandwidth", now()->toDateTimeString(), now()->addDay());
}
@ -458,8 +466,7 @@ class ReceiveEmail extends Command
->allow(config('anonaddy.limit'))
->every(3600)
->then(
function () {
},
function () {},
function () use ($user) {
$user->update(['defer_until' => now()->addHour()]);
@ -484,7 +491,7 @@ class ReceiveEmail extends Command
protected function getParser($file)
{
$parser = new Parser();
$parser = new Parser;
// Fix some edge cases in from name e.g. "\" John Doe \"" <johndoe@example.com>
$parser->addMiddleware(function ($mimePart, $next) {
@ -505,7 +512,7 @@ class ReceiveEmail extends Command
return $next($mimePart);
});
if ($file == 'stream') {
if ($file === 'stream') {
$fd = fopen('php://stdin', 'r');
$this->rawEmail = '';
while (! feof($fd)) {
@ -549,6 +556,12 @@ class ReceiveEmail extends Command
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)) {
// If the status starts with 4 then return soft instead of hard
if (Str::startsWith($status, '4')) {
return 'soft';
}
return 'hard';
}
@ -570,7 +583,7 @@ class ReceiveEmail extends Command
// Ensure contains '@', may be malformed header which causes sends/replies to fail
$address = $this->parser->getAddresses('from')[0]['address'];
return Str::contains($address, '@') ? $address : $this->option('sender');
return Str::contains($address, '@') && filter_var($address, FILTER_VALIDATE_EMAIL) ? $address : $this->option('sender');
} catch (\Exception $e) {
return $this->option('sender');
}

View file

@ -1,48 +0,0 @@
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
/**
* The Artisan commands provided by your application.
*
* @var array
*/
protected $commands = [
'App\Console\Commands\ResetBandwidth',
];
/**
* Define the application's command schedule.
*
* @return void
*/
protected function schedule(Schedule $schedule)
{
$schedule->command('anonaddy:reset-bandwidth')->monthlyOn(1, '00: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-outbound-messages')->everySixHours();
$schedule->command('anonaddy:email-users-with-token-expiring-soon')->daily();
$schedule->command('auth:clear-resets')->daily();
$schedule->command('sanctum:prune-expired --hours=168')->daily();
$schedule->command('cache:prune-stale-tags')->hourly();
}
/**
* Register the commands for the application.
*
* @return void
*/
protected function commands()
{
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php');
}
}

View file

@ -4,14 +4,20 @@ namespace App\CustomMailDriver;
use App\CustomMailDriver\Mime\Crypto\AlreadyEncrypted;
use App\CustomMailDriver\Mime\Crypto\OpenPGPEncrypter;
use App\Models\Alias;
use App\Models\OutboundMessage;
use App\Models\Recipient;
use App\Models\User;
use App\Notifications\FailedDeliveryNotification;
use App\Notifications\GpgKeyExpired;
use Exception;
use Illuminate\Contracts\Mail\Mailable as MailableContract;
use Illuminate\Mail\Mailer;
use Illuminate\Mail\SentMessage;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use ParagonIE\ConstantTime\Base32;
use Ramsey\Uuid\Uuid;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mime\Crypto\DkimOptions;
use Symfony\Component\Mime\Crypto\DkimSigner;
@ -19,6 +25,8 @@ use Symfony\Component\Mime\Email;
class CustomMailer extends Mailer
{
private $data;
/**
* Send a new message using a view.
*
@ -28,6 +36,8 @@ class CustomMailer extends Mailer
*/
public function send($view, array $data = [], $callback = null)
{
$this->data = $data;
if ($view instanceof MailableContract) {
return $this->sendMailable($view);
}
@ -74,7 +84,7 @@ class CustomMailer extends Mailer
$recipient->update(['should_encrypt' => false]);
$recipient->notify(new GpgKeyExpired());
$recipient->notify(new GpgKeyExpired);
}
if ($encryptedSymfonyMessage) {
@ -88,11 +98,12 @@ class CustomMailer extends Mailer
}
// DkimSigner only for forwards, replies and sends...
if (isset($data['needsDkimSignature']) && $data['needsDkimSignature']) {
if (isset($data['needsDkimSignature']) && $data['needsDkimSignature'] && ! is_null(config('anonaddy.dkim_signing_key'))) {
$dkimSigner = new DkimSigner(config('anonaddy.dkim_signing_key'), $data['aliasDomain'], config('anonaddy.dkim_selector'));
$options = (new DkimOptions())->headersToIgnore([
$options = (new DkimOptions)->headersToIgnore([
'List-Unsubscribe',
'List-Unsubscribe-Post',
'Return-Path',
'Feedback-ID',
'Content-Type',
@ -128,12 +139,82 @@ class CustomMailer extends Mailer
// If the message is a forward, reply or send then use the verp domain
if (isset($data['emailType']) && in_array($data['emailType'], ['F', 'R', 'S'])) {
$message->returnPath($verpLocalPart.'@'.$data['verpDomain']);
$symfonyMessage->returnPath($verpLocalPart.'@'.$data['verpDomain']);
} else {
$message->returnPath($verpLocalPart.'@'.config('anonaddy.domain'));
$symfonyMessage->returnPath($verpLocalPart.'@'.config('anonaddy.domain'));
}
$symfonySentMessage = $this->sendSymfonyMessage($symfonyMessage);
try {
$symfonySentMessage = $this->sendSymfonyMessage($symfonyMessage);
} catch (Exception $e) {
$symfonySentMessage = false;
$userId = $data['userId'] ?? '';
// Store the undelivered message if enabled by user. Do not store email verification notifications.
if ($user = User::find($userId)) {
$failedDeliveryId = Uuid::uuid4();
// Example $e->getMessage();
// Expected response code "250/251/252" but got code "554", with message "554 5.7.1 Spam message rejected".
// Expected response code "250" but got empty code.
// Connection could not be established with host "mail.example:25": stream_socket_client(): Unable to connect to mail.example.com:25 (Connection refused)
$matches = Str::of($e->getMessage())->matchAll('/"([^"]*)"/');
$status = $matches[1] ?? '4.3.2';
$code = $matches[2] ?? '453 4.3.2 A temporary error has occurred.';
if ($code && $status) {
// If the error is temporary e.g. connection lost then rethrow the error to allow retry or send to failed_jobs table
if (Str::startsWith($status, '4')) {
throw $e;
}
// Try to determine the bounce type, HARD, SPAM, SOFT
$bounceType = $this->getBounceType($code, $status);
$diagnosticCode = Str::limit($code, 497);
} else {
$bounceType = null;
$diagnosticCode = null;
}
$emailType = $data['emailType'] ?? null;
if ($user->store_failed_deliveries && ! in_array($emailType, ['VR', 'VU'])) {
$isStored = Storage::disk('local')->put("{$failedDeliveryId}.eml", $symfonyMessage->toString());
}
$failedDelivery = $user->failedDeliveries()->create([
'id' => $failedDeliveryId,
'recipient_id' => $data['recipientId'] ?? null,
'alias_id' => $data['aliasId'] ?? null,
'is_stored' => $isStored ?? false,
'bounce_type' => $bounceType,
'remote_mta' => config('mail.mailers.smtp.host'),
'sender' => $symfonyMessage->getHeaders()->get('X-AnonAddy-Original-Sender')?->getValue(),
'destination' => $symfonyMessage->getTo()[0]?->getAddress(),
'email_type' => $emailType,
'status' => $status,
'code' => $diagnosticCode,
'attempted_at' => now(),
]);
// Calling $failedDelivery->email_type will return 'Failed Delivery' and not 'FDN'
// Check if the bounce is a Failed delivery notification or Alias deactivated notification and if so do not notify the user again
if (! in_array($emailType, ['FDN', 'ADN']) && ! is_null($emailType)) {
$recipient = Recipient::find($failedDelivery->recipient_id);
$alias = Alias::find($failedDelivery->alias_id);
$notifiable = $recipient?->email_verified_at ? $recipient : $user?->defaultRecipient;
// Notify user of failed delivery
if ($notifiable?->email_verified_at) {
$notifiable->notify(new FailedDeliveryNotification($alias->email ?? null, $failedDelivery->sender, $symfonyMessage->getSubject(), $failedDelivery?->is_stored, $user?->store_failed_deliveries, $recipient?->email));
}
}
}
}
if ($symfonySentMessage) {
$sentMessage = new SentMessage($symfonySentMessage);
@ -170,10 +251,24 @@ class CustomMailer extends Mailer
{
try {
$envelopeMessage = clone $message;
// 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->to($aliasTo->getValue());
$message->getHeaders()->remove('Alias-To');
// Add in original Tos that have been updated
if ($tos = $this->data['tos'] ?? null) {
foreach ($tos as $key => $to) {
if ($key === 0) {
// This allows us to have the To: header set as the alias whilst still delivering to the correct RCPT TO for forwards.
$message->to($to); // In order to override recipient email for forwards
} else {
$message->addTo($to);
}
}
}
// Add in original CCs that have been updated
if ($ccs = $this->data['ccs'] ?? null) {
foreach ($ccs as $cc) {
$message->addCc($cc);
}
}
// Add the original sender header here to prevent it altering the envelope from address
@ -197,4 +292,28 @@ class CustomMailer extends Mailer
return "b_{$encodedPayload}_{$encodedSignature}";
}
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)) {
// If the status starts with 4 then return soft instead of hard
if (Str::startsWith($status, '4')) {
return 'soft';
}
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';
}
}

View file

@ -220,7 +220,7 @@ class OpenPGPEncrypter
}
if (! $this->gnupg) {
$this->gnupg = new \gnupg();
$this->gnupg = new \gnupg;
}
$this->gnupg->seterrormode(\gnupg::ERROR_EXCEPTION);
@ -290,6 +290,6 @@ class OpenPGPEncrypter
protected function isValidKey($key, $purpose)
{
return ! ($key['disabled'] || $key['expired'] || $key['revoked'] || ($purpose == 'sign' && ! $key['can_sign']) || ($purpose == 'encrypt' && ! $key['can_encrypt']));
return ! ($key['disabled'] || $key['expired'] || $key['revoked'] || ($purpose === 'sign' && ! $key['can_sign']) || ($purpose === 'encrypt' && ! $key['can_encrypt']));
}
}

View file

@ -86,7 +86,7 @@ class EncryptedPart extends AbstractPart
public function getPreparedHeaders(): Headers
{
return clone new Headers();
return clone new Headers;
}
public function asDebugString(): string
@ -104,7 +104,7 @@ class EncryptedPart extends AbstractPart
private function getEncoder(): ContentEncoderInterface
{
return new RawContentEncoder();
return new RawContentEncoder;
}
public function __sleep(): array

View file

@ -11,4 +11,5 @@ enum DisplayFromFormat: int
case ADDRESS = 4;
case NONE = 5;
case DOMAINONLY = 6;
case LEGACY = 7;
}

View file

@ -1,27 +0,0 @@
<?php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
class Handler extends ExceptionHandler
{
/**
* The list of the inputs that are never flashed to the session on validation exceptions.
*
* @var array<int, string>
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
/**
* Register the exception handling callbacks for the application.
*/
public function register(): void
{
//
}
}

View file

@ -3,6 +3,7 @@
namespace App\Helpers;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Symfony\Component\Process\Exception\RuntimeException;
use Symfony\Component\Process\Process;
@ -18,6 +19,20 @@ class GitVersionHelper
return self::cacheFreshVersion();
}
public static function updateAvailable()
{
$currentVersion = self::version()->value();
// Cache latestVersion for 1 day
$latestVersion = Cache::remember('app-latest-version', now()->addDay(), function () {
$response = Http::get('https://api.github.com/repos/anonaddy/anonaddy/releases/latest');
return Str::of($response->json('tag_name', 'v0.0.0'))->after('v')->trim();
});
return version_compare($latestVersion, $currentVersion, '>');
}
public static function cacheFreshVersion()
{
$version = self::freshVersion();

View file

@ -1,6 +1,7 @@
<?php
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
function user()
{
@ -25,3 +26,17 @@ function randomString(int $length): string
return $str;
}
function stripEmailExtension(string $email): string
{
if (! Str::contains($email, '@')) {
return $email;
}
// Strip the email of extensions
[$localPart, $domain] = explode('@', strtolower($email));
// Remove plus extension from local part if present
$localPart = Str::contains($localPart, '+') ? Str::before($localPart, '+') : $localPart;
return $localPart.'@'.$domain;
}

View file

@ -13,6 +13,6 @@ class AliasExportController extends Controller
return back()->withErrors(['aliases_export' => 'You don\'t have any aliases to export.']);
}
return Excel::download(new AliasesExport(), 'aliases-'.now()->toDateString().'.csv');
return Excel::download(new AliasesExport, 'aliases-'.now()->toDateString().'.csv');
}
}

View file

@ -16,7 +16,7 @@ class ActiveDomainController extends Controller
$domain->activate();
return new DomainResource($domain->load(['aliases', 'defaultRecipient']));
return new DomainResource($domain->load('defaultRecipient')->loadCount('aliases'));
}
public function destroy($id)

View file

@ -16,7 +16,7 @@ class ActiveUsernameController extends Controller
$username->activate();
return new UsernameResource($username->load(['aliases', 'defaultRecipient']));
return new UsernameResource($username->load('defaultRecipient')->loadCount('aliases'));
}
public function destroy($id)

View file

@ -3,9 +3,10 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\GeneralAliasBulkRequest;
use App\Http\Requests\RecipientsAliasBulkRequest;
use App\Http\Resources\AliasResource;
use App\Rules\VerifiedRecipientId;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Ramsey\Uuid\Uuid;
@ -16,13 +17,8 @@ class AliasBulkController extends Controller
$this->middleware('throttle:12,1');
}
public function get(Request $request)
public function get(GeneralAliasBulkRequest $request)
{
$request->validate([
'ids' => 'required|array|max:25|min:1',
'ids.*' => 'required|uuid|distinct',
]);
$aliases = user()->aliases()->withTrashed()
->whereIn('id', $request->ids)
->get();
@ -35,13 +31,8 @@ class AliasBulkController extends Controller
return AliasResource::collection($aliases);
}
public function activate(Request $request)
public function activate(GeneralAliasBulkRequest $request)
{
$request->validate([
'ids' => 'required|array|max:25|min:1',
'ids.*' => 'required|uuid|distinct',
]);
$aliasesWithTrashed = user()->aliases()->withTrashed()
->select(['id', 'user_id', 'active', 'deleted_at'])
->where('active', false)
@ -75,13 +66,8 @@ class AliasBulkController extends Controller
], 200);
}
public function deactivate(Request $request)
public function deactivate(GeneralAliasBulkRequest $request)
{
$request->validate([
'ids' => 'required|array|max:25|min:1',
'ids.*' => 'required|uuid|distinct',
]);
$aliasIds = user()->aliases()
->where('active', true)
->whereIn('id', $request->ids)
@ -100,13 +86,8 @@ class AliasBulkController extends Controller
], 200);
}
public function delete(Request $request)
public function delete(GeneralAliasBulkRequest $request)
{
$request->validate([
'ids' => 'required|array|max:25|min:1',
'ids.*' => 'required|uuid|distinct',
]);
$aliasIds = user()->aliases()
->whereIn('id', $request->ids)
->pluck('id');
@ -129,13 +110,8 @@ class AliasBulkController extends Controller
], 200);
}
public function forget(Request $request)
public function forget(GeneralAliasBulkRequest $request)
{
$request->validate([
'ids' => 'required|array|max:25|min:1',
'ids.*' => 'required|uuid|distinct',
]);
$aliasIds = user()->aliases()->withTrashed()
->whereIn('id', $request->ids)
->pluck('id');
@ -160,6 +136,10 @@ class AliasBulkController extends Controller
'emails_blocked' => 0,
'emails_replied' => 0,
'emails_sent' => 0,
'last_forwarded' => null,
'last_blocked' => null,
'last_replied' => null,
'last_sent' => null,
'active' => false,
'deleted_at' => now(),
]);
@ -179,13 +159,8 @@ class AliasBulkController extends Controller
], 200);
}
public function restore(Request $request)
public function restore(GeneralAliasBulkRequest $request)
{
$request->validate([
'ids' => 'required|array|max:25|min:1',
'ids.*' => 'required|uuid|distinct',
]);
$aliasIds = user()->aliases()->onlyTrashed()
->whereIn('id', $request->ids)
->pluck('id');
@ -205,7 +180,7 @@ class AliasBulkController extends Controller
], 200);
}
public function recipients(Request $request)
public function recipients(RecipientsAliasBulkRequest $request)
{
$request->validate([
'ids' => 'required|array|max:25|min:1',
@ -213,7 +188,7 @@ class AliasBulkController extends Controller
'recipient_ids' => [
'array',
'max:10',
new VerifiedRecipientId(),
new VerifiedRecipientId,
],
'recipient_ids.*' => 'required|uuid|distinct',
]);

View file

@ -17,10 +17,42 @@ class AliasController extends Controller
public function index(IndexAliasRequest $request)
{
$aliases = user()->aliases()->with('recipients')
->when($request->input('recipient'), function ($query, $id) {
return $query->usesRecipientWithId($id, $id === user()->default_recipient_id);
})
->when($request->input('domain'), function ($query, $id) {
return $query->belongsToAliasable('App\Models\Domain', $id);
})
->when($request->input('username'), function ($query, $id) {
return $query->belongsToAliasable('App\Models\Username', $id);
})
->when($request->input('sort'), function ($query, $sort) {
$direction = strpos($sort, '-') === 0 ? 'desc' : 'asc';
$sort = ltrim($sort, '-');
$compareOperator = $direction === 'desc' ? '>' : '<';
// If sort is last_used then order by all and return
if ($sort === 'last_used') {
return $query
->orderByRaw(
"CASE
WHEN (last_forwarded {$compareOperator} last_replied
OR (last_forwarded IS NOT NULL
AND last_replied IS NULL))
AND (last_forwarded {$compareOperator} last_sent
OR (last_forwarded IS NOT NULL
AND last_sent IS NULL))
THEN last_forwarded
WHEN last_replied {$compareOperator} last_sent
OR (last_replied IS NOT NULL
AND last_sent IS NULL)
THEN last_replied
ELSE last_sent
END {$direction}"
)->orderBy('created_at', 'desc');
}
// If sort is created at then simply return as no need for secondary sorting below
if ($sort === 'created_at') {
return $query->orderBy($sort, $direction);
}
@ -202,10 +234,13 @@ class AliasController extends Controller
'emails_blocked' => 0,
'emails_replied' => 0,
'emails_sent' => 0,
'last_forwarded' => null,
'last_blocked' => null,
'last_replied' => null,
'last_sent' => null,
'active' => false,
'deleted_at' => now(), // Soft delete to prevent from being regenerated
]);
// Soft delete to prevent from being regenerated
$alias->delete();
} else {
$alias->forceDelete();
}

View file

@ -16,7 +16,7 @@ class AllowedRecipientController extends Controller
$recipient->update(['can_reply_send' => true]);
return new RecipientResource($recipient->load('aliases'));
return new RecipientResource($recipient->loadCount('aliases'));
}
public function destroy($id)

View file

@ -16,7 +16,7 @@ class CatchAllDomainController extends Controller
$domain->enableCatchAll();
return new DomainResource($domain->load(['aliases', 'defaultRecipient']));
return new DomainResource($domain->load('defaultRecipient')->loadCount('aliases'));
}
public function destroy($id)

View file

@ -16,7 +16,7 @@ class CatchAllUsernameController extends Controller
$username->enableCatchAll();
return new UsernameResource($username->load(['aliases', 'defaultRecipient']));
return new UsernameResource($username->load('defaultRecipient')->loadCount('aliases'));
}
public function destroy($id)

View file

@ -17,19 +17,19 @@ class DomainController extends Controller
public function index()
{
return DomainResource::collection(user()->domains()->with(['aliases', 'defaultRecipient'])->latest()->get());
return DomainResource::collection(user()->domains()->with('defaultRecipient')->withCount('aliases')->latest()->get());
}
public function show($id)
{
$domain = user()->domains()->findOrFail($id);
return new DomainResource($domain->load(['aliases', 'defaultRecipient']));
return new DomainResource($domain->load('defaultRecipient')->loadCount('aliases'));
}
public function store(StoreDomainRequest $request)
{
$domain = new Domain();
$domain = new Domain;
$domain->domain = $request->domain;
if (! $domain->checkVerification()) {
@ -40,7 +40,7 @@ class DomainController extends Controller
$domain->markDomainAsVerified();
return new DomainResource($domain->refresh()->load(['aliases', 'defaultRecipient']));
return new DomainResource($domain->refresh()->load('defaultRecipient')->loadCount('aliases'));
}
public function update(UpdateDomainRequest $request, $id)
@ -55,9 +55,13 @@ class DomainController extends Controller
$domain->from_name = $request->from_name;
}
if ($request->has('auto_create_regex')) {
$domain->auto_create_regex = $request->auto_create_regex;
}
$domain->save();
return new DomainResource($domain->refresh()->load(['aliases', 'defaultRecipient']));
return new DomainResource($domain->refresh()->load('defaultRecipient')->loadCount('aliases'));
}
public function destroy($id)

View file

@ -20,6 +20,6 @@ class DomainDefaultRecipientController extends Controller
$domain->save();
return new DomainResource($domain->load(['aliases', 'defaultRecipient']));
return new DomainResource($domain->load('defaultRecipient')->loadCount('aliases'));
}
}

View file

@ -20,7 +20,7 @@ class EncryptedRecipientController extends Controller
$recipient->update(['should_encrypt' => true]);
return new RecipientResource($recipient->load('aliases'));
return new RecipientResource($recipient->loadCount('aliases'));
}
public function destroy($id)

View file

@ -24,7 +24,7 @@ class InlineEncryptedRecipientController extends Controller
$recipient->update(['inline_encryption' => true]);
return new RecipientResource($recipient->load('aliases'));
return new RecipientResource($recipient->loadCount('aliases'));
}
public function destroy($id)

View file

@ -16,7 +16,7 @@ class LoginableUsernameController extends Controller
$username->allowLogin();
return new UsernameResource($username->load(['aliases', 'defaultRecipient']));
return new UsernameResource($username->load('defaultRecipient')->loadCount('aliases'));
}
public function destroy($id)

View file

@ -24,7 +24,7 @@ class ProtectedHeadersRecipientController extends Controller
$recipient->update(['protected_headers' => true]);
return new RecipientResource($recipient->load('aliases'));
return new RecipientResource($recipient->loadCount('aliases'));
}
public function destroy($id)

View file

@ -11,7 +11,7 @@ class RecipientController extends Controller
{
public function index(IndexRecipientRequest $request)
{
$recipients = user()->recipients()->with('aliases')->latest();
$recipients = user()->recipients()->withCount('aliases')->latest();
if ($request->input('filter.verified') === 'true') {
$recipients->verified();
@ -28,7 +28,7 @@ class RecipientController extends Controller
{
$recipient = user()->recipients()->findOrFail($id);
return new RecipientResource($recipient->load('aliases'));
return new RecipientResource($recipient->loadCount('aliases'));
}
public function store(StoreRecipientRequest $request)
@ -45,7 +45,7 @@ class RecipientController extends Controller
$recipient->sendEmailVerificationNotification();
}
return new RecipientResource($recipient->refresh()->load('aliases'));
return new RecipientResource($recipient->refresh()->loadCount('aliases'));
}
public function destroy($id)

View file

@ -12,7 +12,7 @@ class RecipientKeyController extends Controller
public function __construct()
{
$this->gnupg = new \gnupg();
$this->gnupg = new \gnupg;
}
public function update(UpdateRecipientKeyRequest $request, $id)
@ -30,7 +30,7 @@ class RecipientKeyController extends Controller
'fingerprint' => $info['fingerprint'],
]);
return new RecipientResource($recipient->fresh()->load('aliases'));
return new RecipientResource($recipient->fresh()->loadCount('aliases'));
}
public function destroy($id)

View file

@ -11,14 +11,14 @@ class UsernameController extends Controller
{
public function index()
{
return UsernameResource::collection(user()->usernames()->with(['aliases', 'defaultRecipient'])->latest()->get());
return UsernameResource::collection(user()->usernames()->with('defaultRecipient')->withCount('aliases')->latest()->get());
}
public function show($id)
{
$username = user()->usernames()->findOrFail($id);
return new UsernameResource($username->load(['aliases', 'defaultRecipient']));
return new UsernameResource($username->load('defaultRecipient')->loadCount('aliases'));
}
public function store(StoreUsernameRequest $request)
@ -31,7 +31,7 @@ class UsernameController extends Controller
user()->increment('username_count');
return new UsernameResource($username->refresh()->load(['aliases', 'defaultRecipient']));
return new UsernameResource($username->refresh()->load('defaultRecipient')->loadCount('aliases'));
}
public function update(UpdateUsernameRequest $request, $id)
@ -46,9 +46,13 @@ class UsernameController extends Controller
$username->from_name = $request->from_name;
}
if ($request->has('auto_create_regex')) {
$username->auto_create_regex = $request->auto_create_regex;
}
$username->save();
return new UsernameResource($username->refresh()->load(['aliases', 'defaultRecipient']));
return new UsernameResource($username->refresh()->load('defaultRecipient')->loadCount('aliases'));
}
public function destroy($id)

View file

@ -20,6 +20,6 @@ class UsernameDefaultRecipientController extends Controller
$username->save();
return new UsernameResource($username->load(['aliases', 'defaultRecipient']));
return new UsernameResource($username->load('defaultRecipient')->loadCount('aliases'));
}
}

View file

@ -6,9 +6,12 @@ use App\Facades\Webauthn;
use App\Http\Controllers\Controller;
use App\Http\Requests\ApiAuthenticationLoginRequest;
use App\Http\Requests\ApiAuthenticationMfaRequest;
use App\Http\Requests\DestroyAccountRequest;
use App\Jobs\DeleteAccount;
use App\Models\User;
use App\Models\Username;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
@ -20,35 +23,58 @@ class ApiAuthenticationController extends Controller
public function __construct()
{
$this->middleware('throttle:3,1');
$this->middleware(['auth:sanctum', 'verified'])->only(['logout', 'destroy']);
}
public function login(ApiAuthenticationLoginRequest $request)
{
$user = Username::firstWhere('username', $request->username)?->user;
$user = Username::select(['user_id', 'username'])->firstWhere('username', $request->username)?->user;
if (! $user || ! Hash::check($request->password, $user->password)) {
return response()->json([
'error' => 'The provided credentials are incorrect',
'message' => 'The provided credentials are incorrect.',
], 401);
}
if (! $user->hasVerifiedDefaultRecipient()) {
return response()->json([
'message' => 'Your email address is not verified.',
], 401);
}
// Check if user has 2FA enabled, if needs OTP then return mfa_key
if ($user->two_factor_enabled) {
return response()->json([
'message' => "OTP required, please make a request to /api/auth/mfa with the 'mfa_key', 'otp' and 'device_name' including a 'X-CSRF-TOKEN' header",
'message' => "OTP required, please make a request to /api/auth/mfa with the 'mfa_key', 'otp' and 'device_name' including a 'X-CSRF-TOKEN' header.",
'mfa_key' => Crypt::encryptString($user->id.'|'.config('anonaddy.secret').'|'.Carbon::now()->addMinutes(5)->getTimestamp()),
'csrf_token' => csrf_token(),
], 422);
} elseif (Webauthn::enabled($user)) {
// If WebAuthn is enabled then return currently unsupported message
return response()->json([
'error' => 'Security key authentication is not currently supported from the extension or mobile apps, please use an API key to login instead',
'message' => 'Security key authentication is not currently supported from the extension or mobile apps, please use an API key to login instead.',
], 403);
}
// day, week, month, year or null
if ($request->expiration) {
$method = 'add'.ucfirst($request->expiration);
$expiration = now()->{$method}();
} else {
$expiration = null;
}
// Token expires after 3 months, user must re-login
$newToken = $user->createToken($request->device_name, ['*'], $expiration);
$token = $newToken->accessToken;
// If the user doesn't use 2FA then return the new API key
return response()->json([
'api_key' => explode('|', $user->createToken($request->device_name)->plainTextToken, 2)[1],
'api_key' => explode('|', $newToken->plainTextToken, 2)[1],
'name' => $token->name,
'created_at' => $token->created_at?->toDateTimeString(),
'expires_at' => $token->expires_at?->toDateTimeString(),
]);
}
@ -58,7 +84,7 @@ class ApiAuthenticationController extends Controller
$mfaKey = Crypt::decryptString($request->mfa_key);
} catch (DecryptException $e) {
return response()->json([
'error' => 'Invalid mfa_key',
'message' => 'Invalid mfa_key.',
], 401);
}
$parts = explode('|', $mfaKey, 3);
@ -66,26 +92,29 @@ class ApiAuthenticationController extends Controller
$user = User::find($parts[0]);
if (! $user || $parts[1] !== config('anonaddy.secret')) {
return response()->json([
'error' => 'Invalid mfa_key',
'message' => 'Invalid mfa_key.',
], 401);
}
// Check if the mfa_key has expired
if (Carbon::now()->getTimestamp() > $parts[2]) {
return response()->json([
'error' => 'mfa_key expired, please request a new one at /api/auth/login',
'message' => 'mfa_key expired, please request a new one at /api/auth/login.',
], 401);
}
$google2fa = new Google2FA();
$lastTimeStamp = Cache::get('2fa_ts:'.$user->id);
$google2fa = new Google2FA;
$lastTimeStamp = Cache::get('2fa_ts:'.$user->id, 0);
$timestamp = $google2fa->verifyKeyNewer($user->two_factor_secret, $request->otp, $lastTimeStamp);
$timestamp = $google2fa->verifyKeyNewer($user->two_factor_secret, $request->otp, $lastTimeStamp, config('google2fa.window'));
if (! $timestamp) {
return response()->json([
'error' => 'The \'One Time Password\' typed was wrong',
'message' => 'The \'One Time Password\' typed was wrong.',
], 401);
}
@ -93,8 +122,44 @@ class ApiAuthenticationController extends Controller
Cache::put('2fa_ts:'.$user->id, $timestamp, now()->addMinutes(5));
}
// day, week, month, year or null
if ($request->expiration) {
$method = 'add'.ucfirst($request->expiration);
$expiration = now()->{$method}();
} else {
$expiration = null;
}
$newToken = $user->createToken($request->device_name, ['*'], $expiration);
$token = $newToken->accessToken;
return response()->json([
'api_key' => explode('|', $user->createToken($request->device_name)->plainTextToken, 2)[1],
'api_key' => explode('|', $newToken->plainTextToken, 2)[1],
'name' => $token->name,
'created_at' => $token->created_at?->toDateTimeString(),
'expires_at' => $token->expires_at?->toDateTimeString(),
]);
}
public function logout(Request $request)
{
$token = $request->user()?->currentAccessToken();
if (! $token) {
return response()->json([
'message', 'API key not found.',
], 404);
}
$token->delete();
return response()->json([], 204);
}
public function destroy(DestroyAccountRequest $request)
{
DeleteAccount::dispatch($request->user());
return response()->json([], 204);
}
}

View file

@ -14,7 +14,7 @@ class BackupCodeController extends Controller
public function __construct()
{
$this->middleware('auth');
$this->middleware('throttle:3,1')->only('login');
$this->middleware('throttle:3,1')->only(['login', 'update']);
}
public function index(Request $request)

View file

@ -7,7 +7,6 @@ use App\Http\Controllers\Controller;
use App\Models\Username;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
@ -137,20 +136,6 @@ class LoginController extends Controller
]);
}
/**
* The user has been authenticated.
*
* @param mixed $user
* @return mixed
*/
protected function authenticated(Request $request, $user)
{
// Check if the user's password needs rehashing
if (Hash::needsRehash($user->password)) {
$user->update(['password' => Hash::make($request->password)]);
}
}
/**
* The user has logged out of the application.
*

View file

@ -36,7 +36,7 @@ class PersonalAccessTokenController extends Controller
return [
'token' => new PersonalAccessTokenResource($token->accessToken),
'accessToken' => $accessToken,
'qrCode' => (new QRCode())->render(config('app.url').'|'.$accessToken),
'qrCode' => (new QRCode)->render(config('app.url').'|'.$accessToken),
];
}

View file

@ -79,8 +79,8 @@ class RegisterController extends Controller
'regex:/^[a-zA-Z0-9]*$/',
'max:20',
'unique:usernames,username',
new NotBlacklisted(),
new NotDeletedUsername(),
new NotBlacklisted,
new NotDeletedUsername,
],
'email' => [
'bail',
@ -88,8 +88,8 @@ class RegisterController extends Controller
'email:rfc,dns',
'max:254',
'confirmed',
new RegisterUniqueRecipient(),
new NotLocalRecipient(),
new RegisterUniqueRecipient,
new NotLocalRecipient,
],
'password' => ['required', Password::defaults()],
], [

View file

@ -16,6 +16,7 @@ class TwoFactorAuthController extends Controller
public function __construct(Request $request)
{
$this->middleware('throttle:3,1')->only(['store', 'update', 'destroy']);
$this->twoFactor = app('pragmarx.google2fa');
$this->authenticator = app(Authenticator::class)->boot($request);
}
@ -27,7 +28,7 @@ class TwoFactorAuthController extends Controller
public function store(EnableTwoFactorAuthRequest $request)
{
if (! $this->twoFactor->verifyKey(user()->two_factor_secret, $request->two_factor_token)) {
if (! $this->twoFactor->verifyKey(user()->two_factor_secret, $request->two_factor_token, config('google2fa.window'))) {
return redirect(url()->previous().'#two-factor')->withErrors(['two_factor_token' => 'The token you entered was incorrect']);
}

View file

@ -12,6 +12,7 @@ use Illuminate\Foundation\Auth\VerifiesEmails;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Inertia\Inertia;
class VerificationController extends Controller
@ -43,7 +44,7 @@ class VerificationController extends Controller
*/
public function __construct()
{
$this->middleware('auth')->except('verify');
$this->middleware('auth');
$this->middleware('signed')->only('verify');
$this->middleware('throttle:1,1')->only('resend');
$this->middleware('throttle:6,1')->only('verify');
@ -106,7 +107,8 @@ class VerificationController extends Controller
$user = $verifiable->user;
$defaultRecipient = $user->defaultRecipient;
// Notify the current default recipient of the change
$defaultRecipient->notify(new DefaultRecipientUpdated($verifiable->email));
// Have to use sendNow method here to ensure this notification is sent before the current defaultRecipient's email is updated below
Notification::sendNow($defaultRecipient, new DefaultRecipientUpdated($verifiable->email));
// Set verifiable email as new default recipient
$defaultRecipient->update([

View file

@ -14,6 +14,11 @@ use LaravelWebauthn\Http\Requests\WebauthnRegisterRequest;
class WebauthnController extends ControllersWebauthnController
{
public function __construct()
{
$this->middleware('throttle:3,1')->only('destroy');
}
public function index()
{
return user()->webauthnKeys()->latest()->select(['id', 'name', 'enabled', 'created_at'])->get()->values();

View file

@ -7,6 +7,11 @@ use Illuminate\Http\Request;
class WebauthnEnabledKeyController extends Controller
{
public function __construct()
{
$this->middleware('throttle:3,1')->only('destroy');
}
public function store(Request $request)
{
$webauthnKey = user()->webauthnKeys()->findOrFail($request->id);

View file

@ -7,6 +7,11 @@ use Illuminate\Support\Facades\Auth;
class BrowserSessionController extends Controller
{
public function __construct()
{
$this->middleware('throttle:3,1')->only('destroy');
}
public function destroy(Request $request)
{
$request->validate([

View file

@ -8,6 +8,11 @@ use Illuminate\Support\Facades\Hash;
class PasswordController extends Controller
{
public function __construct()
{
$this->middleware('throttle:3,1')->only('update');
}
public function update(UpdatePasswordRequest $request)
{
// Log out of other sessions

View file

@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\UpdateSaveAliasLastUsedRequest;
class SaveAliasLastUsedController extends Controller
{
public function update(UpdateSaveAliasLastUsedRequest $request)
{
if ($request->save_alias_last_used) {
user()->update(['save_alias_last_used' => true]);
} else {
user()->update(['save_alias_last_used' => false]);
}
return back()->with(['flash' => $request->save_alias_last_used ? 'Save Alias Last Used At Enabled Successfully' : 'Save Alias Last Used At Disabled Successfully']);
}
}

View file

@ -11,6 +11,11 @@ use LaravelWebauthn\Facades\Webauthn;
class SettingController extends Controller
{
public function __construct()
{
$this->middleware('throttle:3,1')->only('destroy');
}
public function show()
{
return Inertia::render('Settings/General', [
@ -20,6 +25,7 @@ class SettingController extends Controller
'displayFromFormat' => user()->display_from_format->value,
'useReplyTo' => user()->use_reply_to,
'storeFailedDeliveries' => user()->store_failed_deliveries,
'saveAliasLastUsed' => user()->save_alias_last_used,
'fromName' => user()->from_name ?? '',
'emailSubject' => user()->email_subject ?? '',
'bannerLocation' => user()->banner_location,

View file

@ -54,6 +54,11 @@ class ShowAliasController extends Controller
'emails_blocked',
'emails_replied',
'emails_sent',
'last_forwarded',
'last_blocked',
'last_replied',
'last_sent',
'last_used',
'active',
'created_at',
'updated_at',
@ -65,6 +70,11 @@ class ShowAliasController extends Controller
'-emails_blocked',
'-emails_replied',
'-emails_sent',
'-last_forwarded',
'-last_blocked',
'-last_replied',
'-last_sent',
'-last_used',
'-active',
'-created_at',
'-updated_at',
@ -87,17 +97,19 @@ class ShowAliasController extends Controller
$sort = $request->session()->get('aliasesSort', 'created_at');
$direction = $request->session()->get('aliasesSortDirection', 'desc');
$compareOperator = $request->session()->get('aliasesSortCompareOperator', '>');
if ($request->has('sort')) {
$direction = strpos($request->input('sort'), '-') === 0 ? 'desc' : 'asc';
$sort = ltrim($request->input('sort'), '-');
$compareOperator = $direction === 'desc' ? '>' : '<';
$request->session()->put('aliasesSort', $sort);
$request->session()->put('aliasesSortDirection', $direction);
}
$aliases = user()->aliases()
->select(['id', 'user_id', 'aliasable_id', 'aliasable_type', 'local_part', 'extension', 'email', 'domain', 'description', 'active', 'emails_forwarded', 'emails_blocked', 'emails_replied', 'emails_sent', 'created_at', 'deleted_at'])
->select(['id', 'user_id', 'aliasable_id', 'aliasable_type', 'local_part', 'extension', 'email', 'domain', 'description', 'active', 'emails_forwarded', 'emails_blocked', 'emails_replied', 'emails_sent', 'last_forwarded', 'last_blocked', 'last_replied', 'last_sent', 'created_at', 'deleted_at'])
->when($request->input('recipient'), function ($query, $id) {
return $query->usesRecipientWithId($id, $id === user()->default_recipient_id);
})
@ -107,11 +119,32 @@ class ShowAliasController extends Controller
->when($request->input('username'), function ($query, $id) {
return $query->belongsToAliasable('App\Models\Username', $id);
})
->when($sort !== 'created_at' || $direction !== 'desc', function ($query) use ($sort, $direction) {
->when($sort !== 'created_at' || $direction !== 'desc', function ($query) use ($sort, $direction, $compareOperator) {
if ($sort === 'created_at') {
return $query->orderBy($sort, $direction);
}
// If sort is last_used then order by all and return
if ($sort === 'last_used') {
return $query
->orderByRaw(
"CASE
WHEN (last_forwarded {$compareOperator} last_replied
OR (last_forwarded IS NOT NULL
AND last_replied IS NULL))
AND (last_forwarded {$compareOperator} last_sent
OR (last_forwarded IS NOT NULL
AND last_sent IS NULL))
THEN last_forwarded
WHEN last_replied {$compareOperator} last_sent
OR (last_replied IS NOT NULL
AND last_sent IS NULL)
THEN last_replied
ELSE last_sent
END {$direction}"
)->orderBy('created_at', 'desc');
}
// Secondary order by latest first
return $query
->orderBy($sort, $direction)

View file

@ -47,7 +47,7 @@ class ShowDomainController extends Controller
$domain = user()->domains()->findOrFail($id);
return Inertia::render('Domains/Edit', [
'initialDomain' => $domain->only(['id', 'user_id', 'domain', 'description', 'from_name', 'domain_sending_verified_at', 'domain_mx_validated_at', 'updated_at']),
'initialDomain' => $domain->only(['id', 'user_id', 'domain', 'description', 'from_name', 'domain_sending_verified_at', 'domain_mx_validated_at', 'auto_create_regex', 'updated_at']),
]);
}
}

View file

@ -22,6 +22,7 @@ class ShowRuleController extends Controller
})
->orderBy('order')
->get(),
'recipientOptions' => user()->verifiedRecipients()->select(['id', 'email'])->get(),
'search' => $validated['search'] ?? null,
]);
}

View file

@ -44,7 +44,7 @@ class ShowUsernameController extends Controller
$username = user()->usernames()->findOrFail($id);
return Inertia::render('Usernames/Edit', [
'initialUsername' => $username->only(['id', 'user_id', 'username', 'description', 'from_name', 'can_login', 'updated_at']),
'initialUsername' => $username->only(['id', 'user_id', 'username', 'description', 'from_name', 'can_login', 'auto_create_regex', 'updated_at']),
]);
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\TestAutoCreateRegexRequest;
class TestAutoCreateRegexController extends Controller
{
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('throttle:60,1');
}
public function index(TestAutoCreateRegexRequest $request)
{
$query = $request->resource === 'username' ? user()->usernames() : user()->domains();
return response()->json([
'success' => $query
->where('id', $request->id)
->whereNotNull('auto_create_regex')
->whereRaw('? REGEXP auto_create_regex', [$request->local_part])
->exists(),
]);
}
}

View file

@ -1,71 +0,0 @@
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
];
/**
* The application's route middleware groups.
*
* @var array
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\HandleInertiaRequests::class, // Must be the last item!
],
'api' => [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
/**
* The application's middleware aliases.
*
* Aliases may be used instead of class names to conveniently assign middleware to routes and groups.
*
* @var array
*/
protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequestsWithRedis::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'2fa' => \App\Http\Middleware\VerifyTwoFactorAuth::class,
'webauthn' => \App\Http\Middleware\VerifyWebauthn::class,
];
}

View file

@ -1,17 +0,0 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
use Illuminate\Http\Request;
class Authenticate extends Middleware
{
/**
* Get the path the user should be redirected to when they are not authenticated.
*/
protected function redirectTo(Request $request): ?string
{
return $request->expectsJson() ? null : route('login');
}
}

View file

@ -1,17 +0,0 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
class EncryptCookies extends Middleware
{
/**
* The names of the cookies that should not be encrypted.
*
* @var array
*/
protected $except = [
//
];
}

View file

@ -57,6 +57,7 @@ class HandleInertiaRequests extends Middleware
})->all();
},
'version' => GitVersionHelper::version(),
'updateAvailable' => GitVersionHelper::updateAvailable(),
]);
}
}

View file

@ -1,17 +0,0 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance as Middleware;
class PreventRequestsDuringMaintenance extends Middleware
{
/**
* The URIs that should be reachable while maintenance mode is enabled.
*
* @var array
*/
protected $except = [
//
];
}

View file

@ -1,30 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, string ...$guards): Response
{
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
return redirect(RouteServiceProvider::HOME);
}
}
return $next($request);
}
}

View file

@ -1,18 +0,0 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\TrimStrings as Middleware;
class TrimStrings extends Middleware
{
/**
* The names of the attributes that should not be trimmed.
*
* @var array
*/
protected $except = [
'password',
'password_confirmation',
];
}

View file

@ -1,28 +0,0 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Illuminate\Http\Request;
class TrustProxies extends Middleware
{
/**
* The trusted proxies for this application.
*
* @var array
*/
protected $proxies;
/**
* The headers that should be used to detect proxies.
*
* @var int
*/
protected $headers =
Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB;
}

View file

@ -1,24 +0,0 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* Indicates whether the XSRF-TOKEN cookie should be set on the response.
*
* @var bool
*/
protected $addHttpCookie = true;
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array
*/
protected $except = [
'api/auth/login',
];
}

View file

@ -24,9 +24,27 @@ class ApiAuthenticationLoginRequest extends FormRequest
public function rules()
{
return [
'username' => 'required|string',
'password' => 'required|string',
'device_name' => 'required|string|max:50',
'username' => [
'required',
'regex:/^[a-zA-Z0-9]*$/',
'min:1',
'max:20',
],
'password' => [
'required',
'string',
],
'device_name' => [
'required',
'string',
'max:50',
],
'expiration' => [
'nullable',
'string',
'max:5',
'in:day,week,month,year',
],
];
}
}

View file

@ -30,7 +30,7 @@ class EditDefaultRecipientRequest extends FormRequest
'required',
'email:rfc,dns',
'max:254',
new RegisterUniqueRecipient(),
new RegisterUniqueRecipient,
'not_in:'.$this->user()->email,
],
'current' => 'required|string|current_password',

View file

@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
class GeneralAliasBulkRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'ids' => Arr::whereNotNull($this->ids ?? []),
]);
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'ids' => 'required|array|max:25|min:1',
'ids.*' => 'required|uuid|distinct',
];
}
}

View file

@ -71,6 +71,11 @@ class IndexAliasRequest extends FormRequest
'emails_blocked',
'emails_replied',
'emails_sent',
'last_forwarded',
'last_blocked',
'last_replied',
'last_sent',
'last_used',
'active',
'created_at',
'updated_at',
@ -82,12 +87,29 @@ class IndexAliasRequest extends FormRequest
'-emails_blocked',
'-emails_replied',
'-emails_sent',
'-last_forwarded',
'-last_blocked',
'-last_replied',
'-last_sent',
'-last_used',
'-active',
'-created_at',
'-updated_at',
'-deleted_at',
]),
],
'recipient' => [
'nullable',
'uuid',
],
'domain' => [
'nullable',
'uuid',
],
'username' => [
'nullable',
'uuid',
],
];
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace App\Http\Requests;
use App\Rules\VerifiedRecipientId;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
class RecipientsAliasBulkRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'ids' => Arr::whereNotNull($this->ids ?? []),
]);
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'ids' => 'required|array|max:25|min:1',
'ids.*' => 'required|uuid|distinct',
'recipient_ids' => [
'array',
'max:10',
new VerifiedRecipientId,
],
'recipient_ids.*' => 'required|uuid|distinct',
];
}
}

View file

@ -29,7 +29,7 @@ class StoreAliasRecipientRequest extends FormRequest
'bail',
'array',
'max:10',
new VerifiedRecipientId(),
new VerifiedRecipientId,
],
];
}

View file

@ -51,7 +51,7 @@ class StoreAliasRequest extends FormRequest
'nullable',
'array',
'max:10',
new VerifiedRecipientId(),
new VerifiedRecipientId,
],
];
}
@ -65,7 +65,7 @@ class StoreAliasRequest extends FormRequest
Rule::unique('aliases', 'local_part')->where(function ($query) {
return $query->where('domain', $this->validationData()['domain']);
}),
new ValidAliasLocalPart(),
new ValidAliasLocalPart,
], function () {
$format = $this->validationData()['format'] ?? 'random_characters';

View file

@ -31,11 +31,11 @@ class StoreDomainRequest extends FormRequest
'bail',
'required',
'string',
'max:50',
'max:100',
'unique:domains',
new ValidDomain(),
new NotLocalDomain(),
new NotUsedAsRecipientDomain(),
new ValidDomain,
new NotLocalDomain,
new NotUsedAsRecipientDomain,
],
];
}

View file

@ -32,8 +32,8 @@ class StoreRecipientRequest extends FormRequest
'string',
'max:254',
'email:rfc',
new UniqueRecipient(),
new NotLocalRecipient(),
new UniqueRecipient,
new NotLocalRecipient,
],
];
}

View file

@ -28,7 +28,7 @@ class StoreReorderRuleRequest extends FormRequest
'ids' => [
'required',
'array',
new ValidRuleId(),
new ValidRuleId,
],
];
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Rules\ValidRegex;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -41,10 +42,10 @@ class StoreRuleRequest extends FormRequest
'subject',
'sender',
'alias',
'alias_description',
]),
],
'conditions.*.match' => [
'sometimes',
'required',
Rule::in([
'is exactly',
@ -63,9 +64,16 @@ class StoreRuleRequest extends FormRequest
'min:1',
'max:10',
],
'conditions.*.values.*' => [
'distinct',
],
'conditions.*.values.*' => Rule::forEach(function ($value, $attribute, $data) {
if (in_array(array_values($data)[1], ['matches regex', 'does not match regex'])) {
return [
new ValidRegex,
'distinct',
];
}
return ['distinct'];
}),
'actions' => [
'required',
'array',
@ -79,13 +87,21 @@ class StoreRuleRequest extends FormRequest
'encryption',
'banner',
'block',
'webhook',
'removeAttachments',
'forwardTo',
//'webhook',
]),
],
'actions.*.value' => [
'required',
'max:50',
],
'actions.*.value' => Rule::forEach(function ($value, $attribute, $data, $action) {
if ($action['type'] === 'forwardTo') {
return [Rule::in(user()->verifiedRecipients()->pluck('id')->toArray())]; // Must be a valid verified recipient
}
return [
'required',
'max:50',
];
}),
'operator' => [
'required',
'in:AND,OR',

View file

@ -32,8 +32,8 @@ class StoreUsernameRequest extends FormRequest
'regex:/^[a-zA-Z0-9]*$/',
'max:20',
'unique:usernames,username',
new NotBlacklisted(),
new NotDeletedUsername(),
new NotBlacklisted,
new NotDeletedUsername,
],
];
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests;
use App\Rules\ValidAliasLocalPart;
use Illuminate\Foundation\Http\FormRequest;
class TestAutoCreateRegexRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'resource' => [
'required',
'in:username,domain',
],
'id' => [
'required',
'uuid',
],
'local_part' => [
'required',
new ValidAliasLocalPart,
],
];
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Rules\ValidRegex;
use Illuminate\Foundation\Http\FormRequest;
class UpdateDomainRequest extends FormRequest
@ -26,6 +27,12 @@ class UpdateDomainRequest extends FormRequest
return [
'description' => 'nullable|max:200',
'from_name' => 'nullable|string|max:50',
'auto_create_regex' => [
'nullable',
'string',
'max:100',
new ValidRegex,
],
];
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateSaveAliasLastUsedRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'save_alias_last_used' => 'required|boolean',
];
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Rules\ValidRegex;
use Illuminate\Foundation\Http\FormRequest;
class UpdateUsernameRequest extends FormRequest
@ -26,6 +27,12 @@ class UpdateUsernameRequest extends FormRequest
return [
'description' => 'nullable|max:200',
'from_name' => 'nullable|string|max:50',
'auto_create_regex' => [
'nullable',
'string',
'max:100',
new ValidRegex,
],
];
}
}

View file

@ -25,6 +25,10 @@ class AliasResource extends JsonResource
'emails_replied' => $this->emails_replied,
'emails_sent' => $this->emails_sent,
'recipients' => RecipientResource::collection($this->whenLoaded('recipients')),
'last_forwarded' => $this->last_forwarded?->toDateTimeString(),
'last_blocked' => $this->last_blocked?->toDateTimeString(),
'last_replied' => $this->last_replied?->toDateTimeString(),
'last_sent' => $this->last_sent?->toDateTimeString(),
'created_at' => $this->created_at->toDateTimeString(),
'updated_at' => $this->updated_at->toDateTimeString(),
'deleted_at' => $this->deleted_at?->toDateTimeString(),

View file

@ -14,10 +14,12 @@ class DomainResource extends JsonResource
'domain' => $this->domain,
'description' => $this->description,
'from_name' => $this->from_name,
'aliases' => AliasResource::collection($this->whenLoaded('aliases')),
'aliases' => [],
'aliases_count' => $this->whenCounted('aliases_count'),
'default_recipient' => new RecipientResource($this->whenLoaded('defaultRecipient')),
'active' => $this->active,
'catch_all' => $this->catch_all,
'auto_create_regex' => $this->auto_create_regex,
'domain_verified_at' => $this->domain_verified_at?->toDateTimeString(),
'domain_mx_validated_at' => $this->domain_mx_validated_at?->toDateTimeString(),
'domain_sending_verified_at' => $this->domain_sending_verified_at?->toDateTimeString(),

View file

@ -12,7 +12,7 @@ class PersonalAccessTokenResource extends JsonResource
'id' => $this->id,
'user_id' => $this->tokenable_id,
'name' => $this->name,
'abilities' => $this->abilities,
//'abilities' => $this->abilities, // Not selected from controllers
'last_used_at' => $this->last_used_at?->toDateTimeString(),
'expires_at' => $this->expires_at?->toDateTimeString(),
'created_at' => $this->created_at?->toDateTimeString(),

View file

@ -18,7 +18,8 @@ class RecipientResource extends JsonResource
'protected_headers' => $this->protected_headers,
'fingerprint' => $this->fingerprint,
'email_verified_at' => $this->email_verified_at?->toDateTimeString(),
'aliases' => AliasResource::collection($this->whenLoaded('aliases')),
'aliases' => [],
'aliases_count' => $this->whenCounted('aliases_count'),
'created_at' => $this->created_at->toDateTimeString(),
'updated_at' => $this->updated_at->toDateTimeString(),
];

View file

@ -20,6 +20,8 @@ class RuleResource extends JsonResource
'replies' => $this->replies,
'sends' => $this->sends,
'active' => $this->active,
'applied' => $this->applied,
'last_applied' => $this->last_applied?->toDateTimeString(),
'created_at' => $this->created_at->toDateTimeString(),
'updated_at' => $this->updated_at->toDateTimeString(),
];

View file

@ -30,7 +30,7 @@ class UserResource extends JsonResource
'banner_location' => $this->banner_location,
'bandwidth' => $this->bandwidth,
'bandwidth_limit' => $this->getBandwidthLimit(),
'username_count' => $this->username_count,
'username_count' => $this->usernames()->count(),
'username_limit' => config('anonaddy.additional_username_limit'),
'default_username_id' => $this->default_username_id,
'default_recipient_id' => $this->default_recipient_id,

View file

@ -14,10 +14,12 @@ class UsernameResource extends JsonResource
'username' => $this->username,
'description' => $this->description,
'from_name' => $this->from_name,
'aliases' => AliasResource::collection($this->whenLoaded('aliases')),
'aliases' => [],
'aliases_count' => $this->whenCounted('aliases_count'),
'default_recipient' => new RecipientResource($this->whenLoaded('defaultRecipient')),
'active' => $this->active,
'catch_all' => $this->catch_all,
'auto_create_regex' => $this->auto_create_regex,
'can_login' => $this->can_login,
'created_at' => $this->created_at->toDateTimeString(),
'updated_at' => $this->updated_at->toDateTimeString(),

View file

@ -158,7 +158,7 @@ class AliasesImport implements ShouldQueue, SkipsEmptyRows, SkipsOnError, SkipsO
'required',
'max:64',
'string',
new ValidAliasLocalPart(),
new ValidAliasLocalPart,
],
'domain' => [
'bail',

View file

@ -47,13 +47,22 @@ class DeleteAccount implements ShouldBeEncrypted, ShouldQueue
'emails_blocked' => 0,
'emails_replied' => 0,
'emails_sent' => 0,
'last_forwarded' => null,
'last_blocked' => null,
'last_replied' => null,
'last_sent' => null,
'active' => false,
'deleted_at' => now(), // Soft delete any aliases at shared domains
]);
// Soft delete any aliases at shared domains
$sharedDomainAliases->delete();
// Force delete any other aliases
$this->user->aliases()->withTrashed()->whereNotIn('domain', config('anonaddy.all_domains'))->forceDelete();
$this->user->recipients()->get()->each->delete(); // In order to fire deleting model event.
$this->user->recipients()->get()->each(function ($recipient) {
// In order to fire deleting model event. With user to prevent lazy loading.
$recipient->setRelation('user', $this->user);
$recipient->delete();
});
$this->user->domains()->delete();
$this->user->usernames()->get()->each->delete(); // In order to fire deleting model event.
$this->user->tokens()->delete();

View file

@ -27,7 +27,7 @@ class SendIncorrectOtpNotification
// Log in auth.log
Log::channel('auth')->info('Failed OTP Notification sent: '.$user->username);
$user->notify(new IncorrectOtpNotification());
$user->notify(new IncorrectOtpNotification);
}
}
}

View file

@ -17,6 +17,7 @@ use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Symfony\Component\Mime\Email;
use Throwable;
class ForwardEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
{
@ -32,6 +33,10 @@ class ForwardEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
protected $sender;
protected $ccs;
protected $tos;
protected $originalCc;
protected $originalTo;
@ -64,6 +69,8 @@ class ForwardEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
protected $encryptedParts;
protected $isInlineEncrypted;
protected $receivedHeaders;
protected $fromEmail;
@ -74,6 +81,8 @@ class ForwardEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
protected $listUnsubscribe;
protected $listUnsubscribePost;
protected $inReplyTo;
protected $references;
@ -102,8 +111,73 @@ class ForwardEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
$this->user = $alias->user;
$this->alias = $alias;
$this->sender = $emailData->sender;
$this->ccs = $emailData->ccs;
$this->tos = $emailData->tos;
$this->originalCc = $emailData->originalCc ?? null;
$this->originalTo = $emailData->originalTo ?? null;
// Create and swap with alias reply-to addresses to allow easy reply-all
if (count($this->ccs)) {
$this->ccs = collect($this->ccs)
->map(function ($cc) {
// Leave alias email Cc as it is
if (stripEmailExtension($cc['address']) === $this->alias->email) {
return [
'display' => $cc['display'] != $cc['address'] ? $cc['display'] : null,
'address' => $this->alias->email,
];
}
return [
'display' => $cc['display'] != $cc['address'] ? $cc['display'] : null,
'address' => $this->alias->local_part.'+'.Str::replaceLast('@', '=', $cc['address']).'@'.$this->alias->domain,
];
})
->filter(fn ($cc) => filter_var($cc['address'], FILTER_VALIDATE_EMAIL))
->map(function ($cc) {
// Only add in display if it exists
if ($cc['display']) {
return $cc['display'].' <'.$cc['address'].'>';
}
return '<'.$cc['address'].'>';
})
->toArray();
}
// Create and swap with alias reply-to addresses to allow easy reply-all
$this->tos = collect($this->tos)
->when(! count($this->tos), function ($tos) {
return $tos->push([
'display' => null,
'address' => $this->alias->email,
]);
})
->map(function ($to) {
// Leave alias email To as it is
if (stripEmailExtension($to['address']) === $this->alias->email) {
return [
'display' => $to['display'] != $to['address'] ? $to['display'] : null,
'address' => $this->alias->email,
];
}
return [
'display' => $to['display'] != $to['address'] ? $to['display'] : null,
'address' => $this->alias->local_part.'+'.Str::replaceLast('@', '=', $to['address']).'@'.$this->alias->domain,
];
})
->filter(fn ($to) => filter_var($to['address'], FILTER_VALIDATE_EMAIL))
->map(function ($to) {
// Only add in display if it exists
if ($to['display']) {
return $to['display'].' <'.$to['address'].'>';
}
return '<'.$to['address'].'>';
})
->toArray();
$this->displayFrom = $emailData->display_from;
$this->replyToAddress = $emailData->reply_to_address ?? $this->sender;
$this->emailSubject = $emailData->subject;
@ -115,6 +189,7 @@ class ForwardEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
$this->size = $emailData->size;
$this->messageId = $emailData->messageId;
$this->listUnsubscribe = $emailData->listUnsubscribe;
$this->listUnsubscribePost = $emailData->listUnsubscribePost;
$this->inReplyTo = $emailData->inReplyTo;
$this->references = $emailData->references;
$this->originalEnvelopeFrom = $emailData->originalEnvelopeFrom;
@ -123,6 +198,7 @@ class ForwardEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
$this->originalSenderHeader = $emailData->originalSenderHeader;
$this->authenticationResults = $emailData->authenticationResults;
$this->encryptedParts = $emailData->encryptedParts ?? null;
$this->isInlineEncrypted = $emailData->isInlineEncrypted ?? false;
$this->receivedHeaders = $emailData->receivedHeaders;
$this->recipientId = $recipient->id;
@ -159,13 +235,7 @@ class ForwardEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
}
}
$displayFrom = base64_decode($this->displayFrom);
if ($displayFrom === $this->sender) {
$displayFrom = Str::replaceLast('@', ' at ', $this->sender);
} else {
$displayFrom = $this->getUserDisplayFrom($displayFrom);
}
$displayFrom = $this->getUserDisplayFrom(base64_decode($this->displayFrom));
$this->email = $this
->from($this->fromEmail, $displayFrom)
@ -175,10 +245,6 @@ class ForwardEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
$message->getHeaders()
->addTextHeader('Feedback-ID', 'F:'.$this->alias->id.':anonaddy');
// This header is used to set the To: header as the alias just before sending.
$message->getHeaders()
->addTextHeader('Alias-To', $this->alias->email);
$message->getHeaders()->remove('Message-ID');
if ($this->messageId) {
@ -192,6 +258,12 @@ class ForwardEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
if ($this->listUnsubscribe) {
$message->getHeaders()
->addTextHeader('List-Unsubscribe', base64_decode($this->listUnsubscribe));
// Only check if has original List-Unsubscribe
if ($this->listUnsubscribePost) {
$message->getHeaders()
->addTextHeader('List-Unsubscribe-Post', base64_decode($this->listUnsubscribePost));
}
}
if ($this->inReplyTo) {
@ -329,6 +401,8 @@ class ForwardEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
'shouldBlock' => $this->size === 0,
'needsDkimSignature' => $this->needsDkimSignature(),
'verpDomain' => $this->verpDomain ?? $this->alias->domain,
'ccs' => $this->ccs,
'tos' => $this->tos,
]);
if (isset($replyToEmail)) {
@ -336,7 +410,11 @@ class ForwardEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
}
if ($this->size > 0) {
$this->alias->increment('emails_forwarded');
if ($this->user->save_alias_last_used) {
$this->alias->increment('emails_forwarded', 1, ['last_forwarded' => now()]);
} else {
$this->alias->increment('emails_forwarded');
}
$this->user->bandwidth += $this->size;
$this->user->save();
@ -348,10 +426,9 @@ class ForwardEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
/**
* Handle a job failure.
*
* @param \Throwable $exception
* @return void
*/
public function failed()
public function failed(Throwable $exception)
{
// Send user failed delivery notification, add to failed deliveries table
$recipient = Recipient::find($this->recipientId);
@ -377,7 +454,7 @@ class ForwardEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
'sender' => $this->sender,
'email_type' => 'F',
'status' => null,
'code' => 'An error has occurred, please check the logs.',
'code' => $exception->getMessage(),
'attempted_at' => now(),
]);
}
@ -393,13 +470,14 @@ class ForwardEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
DisplayFromFormat::ADDRESS => str_replace('@', ' at ', $this->sender),
DisplayFromFormat::DOMAINONLY => Str::afterLast($this->sender, '@'),
DisplayFromFormat::NONE => null,
DisplayFromFormat::LEGACY => $displayFrom." '".$this->sender."'",
default => str_replace('@', ' at ', $displayFrom." '".$this->sender."'"),
};
}
private function isAlreadyEncrypted()
{
return $this->encryptedParts || preg_match('/^-----BEGIN PGP MESSAGE-----([A-Za-z0-9+=\/\n]+)-----END PGP MESSAGE-----$/', base64_decode($this->emailText));
return $this->encryptedParts || $this->isInlineEncrypted;
}
private function needsDkimSignature()

View file

@ -15,6 +15,7 @@ use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
use Symfony\Component\Mime\Email;
use Throwable;
class ReplyToEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
{
@ -30,6 +31,10 @@ class ReplyToEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
protected $sender;
protected $ccs;
protected $tos;
protected $emailSubject;
protected $emailText;
@ -64,6 +69,52 @@ class ReplyToEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
$this->user = $user;
$this->alias = $alias;
$this->sender = $emailData->sender;
$this->ccs = $emailData->ccs;
$this->tos = $emailData->tos;
// Replace alias reply/send CCs back to proper emails
if (count($this->ccs)) {
$this->ccs = collect($this->ccs)
->map(function ($cc) {
return [
'display' => null,
'address' => Str::replaceLast('=', '@', Str::between($cc['address'], $this->alias->local_part.'+', '@'.$this->alias->domain)),
];
})
->filter(fn ($cc) => filter_var($cc['address'], FILTER_VALIDATE_EMAIL))
->map(function ($cc) {
// Only add in display if it exists
if ($cc['display']) {
return $cc['display'].' <'.$cc['address'].'>';
}
return '<'.$cc['address'].'>';
})
->toArray();
}
// Replace alias reply/send Tos back to proper emails
if (count($this->tos)) {
$this->tos = collect($this->tos)
->map(function ($to) {
return [
'display' => null,
'address' => Str::replaceLast('=', '@', Str::between($to['address'], $this->alias->local_part.'+', '@'.$this->alias->domain)),
];
})
->filter(fn ($to) => filter_var($to['address'], FILTER_VALIDATE_EMAIL))
->map(function ($to) {
// Only add in display if it exists
if ($to['display']) {
return $to['display'].' <'.$to['address'].'>';
}
return '<'.$to['address'].'>';
})
->toArray();
}
$this->emailSubject = $emailData->subject;
$this->emailText = $emailData->text;
$this->emailHtml = $emailData->html;
@ -167,6 +218,8 @@ class ReplyToEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
'needsDkimSignature' => $this->needsDkimSignature(),
'aliasDomain' => $this->alias->domain,
'verpDomain' => $this->verpDomain ?? $this->alias->domain,
'ccs' => $this->ccs,
'tos' => $this->tos,
]);
if ($this->alias->isCustomDomain() && ! $this->needsDkimSignature()) {
@ -174,7 +227,11 @@ class ReplyToEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
}
if ($this->size > 0) {
$this->alias->increment('emails_replied');
if ($this->user->save_alias_last_used) {
$this->alias->increment('emails_replied', 1, ['last_replied' => now()]);
} else {
$this->alias->increment('emails_replied');
}
$this->user->bandwidth += $this->size;
$this->user->save();
@ -186,10 +243,9 @@ class ReplyToEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
/**
* Handle a job failure.
*
* @param \Throwable $exception
* @return void
*/
public function failed()
public function failed(Throwable $exception)
{
// Send user failed delivery notification, add to failed deliveries table
$this->user->defaultRecipient->notify(new FailedDeliveryNotification($this->alias->email, $this->sender, base64_decode($this->emailSubject)));
@ -213,7 +269,7 @@ class ReplyToEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
'sender' => $this->sender,
'email_type' => 'R',
'status' => null,
'code' => 'An error has occurred, please check the logs.',
'code' => $exception->getMessage(),
'attempted_at' => now(),
]);
}
@ -225,14 +281,24 @@ class ReplyToEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
private function removeRealEmailAndTextBanner($text)
{
// Replace <alias+hello=example.com@johndoe.anonaddy.com> with <hello@example.com>
$destination = $this->email->to[0]['address'];
// Reply may be HTML but email client added HTML banner plain text version
return Str::of(str_ireplace($this->sender, '', $text))
->replaceMatches('/(?s)((<|&lt;)!--banner-info--(&gt;|>)).*?((<|&lt;)!--banner-info--(&gt;|>))/mi', '');
->replace($this->alias->local_part.'+'.Str::replaceLast('@', '=', $destination).'@'.$this->alias->domain, $destination)
->replaceMatches('/(?s)((<|&lt;)!--banner-info--(&gt;|>)).*?((<|&lt;)!--banner-info--(&gt;|>))/mi', '')
->replaceMatches('/(This email was sent to).*?(to deactivate this alias)/mis', '');
}
private function removeRealEmailAndHtmlBanner($html)
{
// Replace <alias+hello=example.com@johndoe.anonaddy.com> with <hello@example.com>
$destination = $this->email->to[0]['address'];
// Reply may be HTML but have a plain text banner
return Str::of(str_ireplace($this->sender, '', $html))
->replace($this->alias->local_part.'+'.Str::replaceLast('@', '=', $destination).'@'.$this->alias->domain, $destination)
->replaceMatches('/(?s)((<|&lt;)!--banner-info--(&gt;|>)).*?((<|&lt;)!--banner-info--(&gt;|>))/mi', '')
->replaceMatches('/(?s)(<tr((?!<tr).)*?'.preg_quote(Str::of(config('app.url'))->after('://')->rtrim('/'), '/')."(\/|%2F)deactivate(\/|%2F).*?\/tr>)/mi", '');
}

View file

@ -15,6 +15,7 @@ use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
use Symfony\Component\Mime\Email;
use Throwable;
class SendFromEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
{
@ -30,6 +31,10 @@ class SendFromEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
protected $sender;
protected $ccs;
protected $tos;
protected $emailSubject;
protected $emailText;
@ -60,6 +65,52 @@ class SendFromEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
$this->user = $user;
$this->alias = $alias;
$this->sender = $emailData->sender;
$this->ccs = $emailData->ccs;
$this->tos = $emailData->tos;
// Replace alias reply/send CCs back to proper emails
if (count($this->ccs)) {
$this->ccs = collect($this->ccs)
->map(function ($cc) {
return [
'display' => null,
'address' => Str::replaceLast('=', '@', Str::between($cc['address'], $this->alias->local_part.'+', '@'.$this->alias->domain)),
];
})
->filter(fn ($cc) => filter_var($cc['address'], FILTER_VALIDATE_EMAIL))
->map(function ($cc) {
// Only add in display if it exists
if ($cc['display']) {
return $cc['display'].' <'.$cc['address'].'>';
}
return '<'.$cc['address'].'>';
})
->toArray();
}
// Replace alias reply/send Tos back to proper emails
if (count($this->tos)) {
$this->tos = collect($this->tos)
->map(function ($to) {
return [
'display' => null,
'address' => Str::replaceLast('=', '@', Str::between($to['address'], $this->alias->local_part.'+', '@'.$this->alias->domain)),
];
})
->filter(fn ($to) => filter_var($to['address'], FILTER_VALIDATE_EMAIL))
->map(function ($to) {
// Only add in display if it exists
if ($to['display']) {
return $to['display'].' <'.$to['address'].'>';
}
return '<'.$to['address'].'>';
})
->toArray();
}
$this->emailSubject = $emailData->subject;
$this->emailText = $emailData->text;
$this->emailHtml = $emailData->html;
@ -151,6 +202,8 @@ class SendFromEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
'needsDkimSignature' => $this->needsDkimSignature(),
'aliasDomain' => $this->alias->domain,
'verpDomain' => $this->verpDomain ?? $this->alias->domain,
'ccs' => $this->ccs,
'tos' => $this->tos,
]);
if ($this->alias->isCustomDomain() && ! $this->needsDkimSignature()) {
@ -158,7 +211,11 @@ class SendFromEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
}
if ($this->size > 0) {
$this->alias->increment('emails_sent');
if ($this->user->save_alias_last_used) {
$this->alias->increment('emails_sent', 1, ['last_sent' => now()]);
} else {
$this->alias->increment('emails_sent');
}
$this->user->bandwidth += $this->size;
$this->user->save();
@ -170,10 +227,9 @@ class SendFromEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
/**
* Handle a job failure.
*
* @param \Throwable $exception
* @return void
*/
public function failed()
public function failed(Throwable $exception)
{
// Send user failed delivery notification, add to failed deliveries table
$this->user->defaultRecipient->notify(new FailedDeliveryNotification($this->alias->email, $this->sender, base64_decode($this->emailSubject)));
@ -197,7 +253,7 @@ class SendFromEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
'sender' => $this->sender,
'email_type' => 'S',
'status' => null,
'code' => 'An error has occurred, please check the logs.',
'code' => $exception->getMessage(),
'attempted_at' => now(),
]);
}
@ -210,15 +266,16 @@ class SendFromEmail extends Mailable implements ShouldBeEncrypted, ShouldQueue
private function removeRealEmailAndTextBanner($text)
{
return Str::of(str_ireplace($this->sender, '', $text))
->replaceMatches('/(?s)((<|&lt;)!--banner-info--(&gt;|>)).*?((<|&lt;)!--banner-info--(&gt;|>))/mi', '');
->replaceMatches('/(?s)((<|&lt;)!--banner-info--(&gt;|>)).*?((<|&lt;)!--banner-info--(&gt;|>))/mis', '')
->replaceMatches('/(This email was sent to).*?(to deactivate this alias)/mis', '');
}
private function removeRealEmailAndHtmlBanner($html)
{
// Reply may be HTML but have a plain text banner
return Str::of(str_ireplace($this->sender, '', $html))
->replaceMatches('/(?s)((<|&lt;)!--banner-info--(&gt;|>)).*?((<|&lt;)!--banner-info--(&gt;|>))/mi', '')
->replaceMatches('/(?s)(<tr((?!<tr).)*?'.preg_quote(Str::of(config('app.url'))->after('://')->rtrim('/'), '/')."(\/|%2F)deactivate(\/|%2F).*?\/tr>)/mi", '');
->replaceMatches('/(?s)((<|&lt;)!--banner-info--(&gt;|>)).*?((<|&lt;)!--banner-info--(&gt;|>))/mis', '')
->replaceMatches('/(?s)(<tr((?!<tr).)*?'.preg_quote(Str::of(config('app.url'))->after('://')->rtrim('/'), '/').'(\/|%2F)deactivate(\/|%2F).*?\/tr>)/mis', '');
}
/**

View file

@ -19,6 +19,8 @@ class TokenExpiringSoon extends Mailable implements ShouldBeEncrypted, ShouldQue
protected $recipient;
protected $token;
/**
* Create a new message instance.
*
@ -28,6 +30,7 @@ class TokenExpiringSoon extends Mailable implements ShouldBeEncrypted, ShouldQue
{
$this->user = $user;
$this->recipient = $user->defaultRecipient;
$this->token = $user->tokens()->whereDate('expires_at', now()->addWeek())->first();
}
/**
@ -38,13 +41,14 @@ class TokenExpiringSoon extends Mailable implements ShouldBeEncrypted, ShouldQue
public function build()
{
return $this
->subject('Your addy.io API key expires soon')
->subject('Your '.config('app.name').' API key expires soon')
->markdown('mail.token_expiring_soon', [
'user' => $this->user,
'userId' => $this->user->id,
'recipientId' => $this->user->default_recipient_id,
'emailType' => 'TES',
'fingerprint' => $this->recipient->should_encrypt ? $this->recipient->fingerprint : null,
'tokenName' => $this->token?->name,
])
->withSymfonyMessage(function (Email $message) {
$message->getHeaders()

View file

@ -43,6 +43,11 @@ class Alias extends Model
'emails_blocked',
'emails_replied',
'emails_sent',
'last_forwarded',
'last_blocked',
'last_replied',
'last_sent',
'deleted_at',
];
protected $casts = [
@ -51,6 +56,10 @@ class Alias extends Model
'aliasable_id' => 'string',
'aliasable_type' => 'string',
'active' => 'boolean',
'last_forwarded' => 'datetime',
'last_blocked' => 'datetime',
'last_replied' => 'datetime',
'last_sent' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',

View file

@ -10,7 +10,59 @@ class EmailData
{
private static $mimeTypes;
public function __construct(Parser $parser, $sender, $size)
public $sender;
public $display_from;
public $reply_to_address;
public $ccs;
public $tos;
public $originalCc;
public $originalTo;
public $subject;
public $text;
public $html;
public $attachments;
public $inlineAttachments;
public $size;
public $messageId;
public $listUnsubscribe;
public $listUnsubscribePost;
public $inReplyTo;
public $references;
public $originalEnvelopeFrom;
public $originalFromHeader;
public $originalReplyToHeader;
public $originalSenderHeader;
public $authenticationResults;
public $receivedHeaders;
public $encryptedParts;
public $isInlineEncrypted;
public function __construct(Parser $parser, $sender, $size, $emailType = 'F')
{
if (isset($parser->getAddresses('from')[0]['address'])) {
if (filter_var($parser->getAddresses('from')[0]['address'], FILTER_VALIDATE_EMAIL)) {
@ -18,14 +70,33 @@ class EmailData
}
}
// If we can't get a From header address then use the envelope from
// If we can't get a From header then use the envelope from
if (! isset($this->sender)) {
$this->sender = $sender;
}
$this->display_from = base64_encode($parser->getAddresses('from')[0]['display']);
if (isset($parser->getAddresses('from')[0]['display'])) {
$this->display_from = base64_encode($parser->getAddresses('from')[0]['display']);
} else {
$this->display_from = '';
}
if (isset($parser->getAddresses('reply-to')[0])) {
$this->reply_to_address = $parser->getAddresses('reply-to')[0]['address'];
if (filter_var($parser->getAddresses('reply-to')[0]['address'], FILTER_VALIDATE_EMAIL)) {
$this->reply_to_address = $parser->getAddresses('reply-to')[0]['address'];
}
}
try {
$this->ccs = collect($parser->getAddresses('cc'))->all();
} catch (\Throwable $e) {
$this->ccs = [];
}
try {
$this->tos = collect($parser->getAddresses('to'))->all();
} catch (\Throwable $e) {
$this->tos = [];
}
if ($originalCc = $parser->getHeader('cc')) {
@ -44,6 +115,7 @@ class EmailData
$this->size = $size;
$this->messageId = base64_encode(Str::remove(['<', '>'], $parser->getHeader('Message-ID')));
$this->listUnsubscribe = base64_encode($parser->getHeader('List-Unsubscribe'));
$this->listUnsubscribePost = base64_encode($parser->getHeader('List-Unsubscribe-Post'));
$this->inReplyTo = base64_encode($parser->getHeader('In-Reply-To'));
$this->references = base64_encode($parser->getHeader('References'));
$this->originalEnvelopeFrom = $sender;
@ -53,40 +125,130 @@ class EmailData
$this->authenticationResults = $parser->getHeader('X-AnonAddy-Authentication-Results');
$this->receivedHeaders = $parser->getRawHeader('Received');
$isReplyOrSend = in_array($emailType, ['R', 'S']);
if ($parser->getParts()[1]['content-type'] === 'multipart/encrypted') {
$this->encryptedParts = $parser->getAttachments();
// Only try to decrypt Replies or Sends from aliases
if ($isReplyOrSend && config('anonaddy.signing_key_fingerprint')) {
// Check if encrypted with addy.io public key and needs decrypting
$part = collect($this->encryptedParts)->filter(function ($part) {
return $part->getContentType() === 'application/octet-stream';
})->first();
if ($part) {
$this->attemptToDecrypt($part);
}
}
} else {
foreach ($parser->getAttachments() as $attachment) {
// Fix incorrect Content Types e.g. 'png', 'pdf', '.pdf', 'text'
$contentType = $attachment->getContentType();
// If this is a reply or send from an alias then remove any public keys
$this->addAttachments($parser, $isReplyOrSend, $isReplyOrSend);
}
if ($contentType === 'text') {
$this->text = base64_encode(stream_get_contents($attachment->getStream()));
if (preg_match('/^-----BEGIN PGP MESSAGE-----([A-Za-z0-9+=\/\n]+)-----END PGP MESSAGE-----$/', $parser->getMessageBody('text'))) {
$this->isInlineEncrypted = true;
if ($isReplyOrSend && config('anonaddy.signing_key_fingerprint')) {
$this->attemptToDecryptInline($parser->getMessageBody('text'));
}
}
}
private function addAttachments(Parser $parser, $removePublicKeys = false, $removeSignature = false)
{
foreach ($parser->getAttachments() as $attachment) {
// Fix incorrect Content Types e.g. 'png', 'pdf', '.pdf', 'text'
$contentType = $attachment->getContentType();
if ($removePublicKeys && $contentType === 'application/pgp-keys') {
continue;
}
if ($removeSignature && $contentType === 'application/pgp-signature') {
continue;
}
if ($contentType === 'text') {
$this->text = base64_encode(stream_get_contents($attachment->getStream()));
} else {
if (! str_contains($contentType, '/')) {
if (self::$mimeTypes === null) {
self::$mimeTypes = new MimeTypes;
}
$contentType = self::$mimeTypes->getMimeTypes($contentType)[0] ?? 'application/octet-stream';
}
if ($attachment->getContentDisposition() === 'inline') {
$this->inlineAttachments[] = [
'stream' => base64_encode(stream_get_contents($attachment->getStream())),
'file_name' => base64_encode($attachment->getFileName()),
'mime' => base64_encode($contentType),
'contentDisposition' => base64_encode($attachment->getContentDisposition()),
'contentId' => base64_encode($attachment->getContentID()),
];
} else {
if (! str_contains($contentType, '/')) {
if (self::$mimeTypes === null) {
self::$mimeTypes = new MimeTypes();
}
$contentType = self::$mimeTypes->getMimeTypes($contentType)[0] ?? 'application/octet-stream';
}
if ($attachment->getContentDisposition() === 'inline') {
$this->inlineAttachments[] = [
'stream' => base64_encode(stream_get_contents($attachment->getStream())),
'file_name' => base64_encode($attachment->getFileName()),
'mime' => base64_encode($contentType),
'contentDisposition' => base64_encode($attachment->getContentDisposition()),
'contentId' => base64_encode($attachment->getContentID()),
];
} else {
$this->attachments[] = [
'stream' => base64_encode(stream_get_contents($attachment->getStream())),
'file_name' => base64_encode($attachment->getFileName()),
'mime' => base64_encode($contentType),
];
}
$this->attachments[] = [
'stream' => base64_encode(stream_get_contents($attachment->getStream())),
'file_name' => base64_encode($attachment->getFileName()),
'mime' => base64_encode($contentType),
];
}
}
}
}
private function attemptToDecrypt($part)
{
try {
$gnupg = new \gnupg;
$gnupg->cleardecryptkeys();
$gnupg->adddecryptkey(config('anonaddy.signing_key_fingerprint'), null);
$encrypted = stream_get_contents($part->getStream());
$decrypted = $gnupg->decrypt($encrypted);
if ($decrypted) {
$decryptedParser = new Parser;
$decryptedParser->setText($decrypted);
// Set decrypted data as subject (as may have encrypted subject too), html and text
$this->subject = base64_encode($decryptedParser->getHeader('subject'));
$this->text = base64_encode($decryptedParser->getMessageBody('text'));
$this->html = base64_encode($decryptedParser->getMessageBody('html'));
// Add attachments
$this->addAttachments($decryptedParser, true, true);
// Set encrypted parts to NULL
$this->encryptedParts = null;
}
} catch (\Exception $e) {
report($e);
}
}
private function attemptToDecryptInline($text)
{
try {
$gnupg = new \gnupg;
$gnupg->cleardecryptkeys();
$gnupg->adddecryptkey(config('anonaddy.signing_key_fingerprint'), null);
$decrypted = $gnupg->decrypt($text);
if ($decrypted) {
// Set decrypted text as message text
$this->text = base64_encode($decrypted);
$this->html = null;
}
} catch (\Exception $e) {
report($e);
}
}
}

View file

@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class FailedDelivery extends Model
{
@ -107,6 +108,20 @@ class FailedDelivery extends Model
);
}
protected function status(): Attribute
{
return Attribute::make(
set: fn (?string $value) => Str::ascii($value),
);
}
protected function code(): Attribute
{
return Attribute::make(
set: fn (?string $value) => Str::ascii($value),
);
}
/**
* Get the user for the failed delivery.
*/

View file

@ -205,7 +205,7 @@ class Recipient extends Model
*/
public function sendEmailVerificationNotification()
{
$this->notify(new CustomVerifyEmail());
$this->notify(new CustomVerifyEmail);
}
/**
@ -215,7 +215,7 @@ class Recipient extends Model
*/
public function sendUsernameReminderNotification()
{
$this->notify(new UsernameReminder());
$this->notify(new UsernameReminder);
}
/**

View file

@ -24,6 +24,8 @@ class Rule extends Model
'replies',
'sends',
'active',
'applied',
'last_applied',
'order',
];
@ -36,6 +38,8 @@ class Rule extends Model
'sends' => 'boolean',
'conditions' => 'array',
'actions' => 'array',
'applied' => 'integer',
'last_applied' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];

View file

@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Str;
use Laravel\Sanctum\HasApiTokens;
@ -51,6 +52,7 @@ class User extends Authenticatable implements MustVerifyEmail
'default_alias_format',
'use_reply_to',
'store_failed_deliveries',
'save_alias_last_used',
'default_username_id',
'default_recipient_id',
'password',
@ -90,6 +92,7 @@ class User extends Authenticatable implements MustVerifyEmail
'two_factor_enabled' => 'boolean',
'use_reply_to' => 'boolean',
'store_failed_deliveries' => 'boolean',
'save_alias_last_used' => 'boolean',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'email_verified_at' => 'datetime',
@ -171,8 +174,10 @@ class User extends Authenticatable implements MustVerifyEmail
*/
protected function defaultAliasDomain(): Attribute
{
$defaultDomain = $this->canCreateSharedDomainAliases() ? config('anonaddy.domain') : $this->username.'.'.config('anonaddy.domain');
return Attribute::make(
get: fn (?string $value) => $value ?? config('anonaddy.domain'),
get: fn (?string $value) => $value ?? $defaultDomain,
);
}
@ -409,7 +414,7 @@ class User extends Authenticatable implements MustVerifyEmail
*/
public function sendEmailVerificationNotification()
{
$this->notify(new CustomVerifyEmail());
$this->notify(new CustomVerifyEmail);
}
/**
@ -425,6 +430,10 @@ class User extends Authenticatable implements MustVerifyEmail
public function hasVerifiedDefaultRecipient()
{
if (! isset($this->defaultRecipient->email_verified_at)) {
return false;
}
return ! is_null($this->defaultRecipient->email_verified_at);
}
@ -494,7 +503,7 @@ class User extends Authenticatable implements MustVerifyEmail
public function hasReachedUsernameLimit()
{
return $this->username_count >= config('anonaddy.additional_username_limit');
return $this->usernames()->count() >= config('anonaddy.additional_username_limit');
}
public function isVerifiedRecipient($email)
@ -541,7 +550,7 @@ class User extends Authenticatable implements MustVerifyEmail
public function deleteKeyFromKeyring($fingerprint): void
{
$gnupg = new \gnupg();
$gnupg = new \gnupg;
$recipientsUsingFingerprint = $this
->recipients()
@ -556,14 +565,17 @@ class User extends Authenticatable implements MustVerifyEmail
})
->pluck('email')
->each(function ($email) use ($gnupg, $fingerprint, $recipientsUsingFingerprint) {
if ($this->isVerifiedRecipient($email) && $recipientsUsingFingerprint->count() === 1) {
$gnupg->deletekey($fingerprint);
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
if ($this->isVerifiedRecipient($email) && $recipientsUsingFingerprint->count() === 1) {
$gnupg->deletekey($fingerprint);
$recipientsUsingFingerprint->first()->update([
'should_encrypt' => false,
'fingerprint' => null,
]);
$recipientsUsingFingerprint->first()->update([
'should_encrypt' => false,
'fingerprint' => null,
]);
}
}
});
}
}
@ -603,13 +615,29 @@ class User extends Authenticatable implements MustVerifyEmail
});
})
->concat($customDomains)
->concat($allDomains)
->when($this->canCreateSharedDomainAliases(), function (Collection $collection) use ($allDomains) {
return $collection->concat($allDomains);
})
->reverse()
->values();
}
public function sharedDomainOptions()
{
return config('anonaddy.all_domains');
if ($this->canCreateSharedDomainAliases()) {
return config('anonaddy.all_domains');
}
return [];
}
public function isAdminUser()
{
return $this->username === config('anonaddy.admin_username');
}
public function canCreateSharedDomainAliases()
{
return config('anonaddy.non_admin_shared_domains') || $this->isAdminUser();
}
}

View file

@ -59,7 +59,7 @@ class AliasesImportedNotification extends Notification implements ShouldBeEncryp
$recipient = $notifiable->defaultRecipient;
$fingerprint = $recipient->should_encrypt ? $recipient->fingerprint : null;
return (new MailMessage())
return (new MailMessage)
->subject('Your aliases import has finished')
->markdown('mail.aliases_import_finished', [
'totalRows' => $this->totalRows,

View file

@ -37,7 +37,7 @@ class CustomVerifyEmail extends VerifyEmail implements ShouldBeEncrypted, Should
$recipientId = $notifiable instanceof User ? $notifiable->default_recipient_id : $notifiable->id;
$userId = $notifiable instanceof User ? $notifiable->id : $notifiable->user_id;
return (new MailMessage())
return (new MailMessage)
->subject(Lang::get('Verify Email Address'))
->markdown('mail.verify_email', [
'verificationUrl' => $verificationUrl,

View file

@ -44,7 +44,7 @@ class DefaultRecipientUpdated extends Notification implements ShouldBeEncrypted,
*/
public function toMail($notifiable)
{
return (new MailMessage())
return (new MailMessage)
->subject('Your default recipient has just been updated')
->markdown('mail.default_recipient_updated', [
'defaultRecipient' => $notifiable->email,

View file

@ -56,7 +56,7 @@ class DisallowedReplySendAttempt extends Notification implements ShouldBeEncrypt
{
$fingerprint = $notifiable->should_encrypt ? $notifiable->fingerprint : null;
return (new MailMessage())
return (new MailMessage)
->subject('Disallowed reply/send from alias')
->markdown('mail.disallowed_reply_send_attempt', [
'aliasEmail' => $this->aliasEmail,

Some files were not shown because too many files have changed in this diff Show more