Compare commits
86 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
fca7d88894 | ||
![]() |
67c824ce1c | ||
![]() |
abab703ecf | ||
![]() |
e7045cc7f7 | ||
![]() |
c832b1b556 | ||
![]() |
aa0addeacd | ||
![]() |
adddee0ba1 | ||
![]() |
5d0270d880 | ||
![]() |
16933763d0 | ||
![]() |
7f2ea49651 | ||
![]() |
3f789f53ef | ||
![]() |
7916d0c004 | ||
![]() |
28d685de61 | ||
![]() |
28cf7b475e | ||
![]() |
18e1223b90 | ||
![]() |
0e75d42e3d | ||
![]() |
bc626e80bf | ||
![]() |
5e186b4f08 | ||
![]() |
50f7ef4556 | ||
![]() |
5cf59c4f7d | ||
![]() |
e7a723df1c | ||
![]() |
731d33ac2c | ||
![]() |
9677d53db5 | ||
![]() |
617bef732a | ||
![]() |
3661285b95 | ||
![]() |
0753ffcdd0 | ||
![]() |
59ba258d17 | ||
![]() |
576dd351b6 | ||
![]() |
7becf1ac87 | ||
![]() |
7e7edaffb2 | ||
![]() |
13053e506e | ||
![]() |
f80dfa4a69 | ||
![]() |
31d206ac41 | ||
![]() |
fef0c1b02c | ||
![]() |
f939a0ba87 | ||
![]() |
e72336d020 | ||
![]() |
e1c47248b8 | ||
![]() |
5c04739d23 | ||
![]() |
c8296a1a68 | ||
![]() |
83a1020b8b | ||
![]() |
dc88b9bcd2 | ||
![]() |
6917f60842 | ||
![]() |
d2030a2dcf | ||
![]() |
ad84ddea3f | ||
![]() |
d05b185543 | ||
![]() |
e5a1d64158 | ||
![]() |
6470253fb9 | ||
![]() |
dab4e34919 | ||
![]() |
3142e8685d | ||
![]() |
e8261730e3 | ||
![]() |
6c90184058 | ||
![]() |
cf707f3e40 | ||
![]() |
87ab6933ca | ||
![]() |
cc38f24e06 | ||
![]() |
c98e636f54 | ||
![]() |
8d6ddb4434 | ||
![]() |
045e82bae8 | ||
![]() |
4f47bc4f4c | ||
![]() |
9125741f4f | ||
![]() |
2822e87f36 | ||
![]() |
ad77a6ccf9 | ||
![]() |
7cbe139e6d | ||
![]() |
83580cd178 | ||
![]() |
678c814bb3 | ||
![]() |
62d9c79167 | ||
![]() |
999e2f7abc | ||
![]() |
c49535b800 | ||
![]() |
4b4e8255dc | ||
![]() |
937b7ed02e | ||
![]() |
008029600d | ||
![]() |
842f9ce6f1 | ||
![]() |
87078b1672 | ||
![]() |
8cfe24ceeb | ||
![]() |
6c7ac62d06 | ||
![]() |
0891256d83 | ||
![]() |
3e75abe757 | ||
![]() |
fd92e09e5e | ||
![]() |
8618da1f3c | ||
![]() |
5f17114ca9 | ||
![]() |
26b4704ae5 | ||
![]() |
24b8286675 | ||
![]() |
fa5a7a87d7 | ||
![]() |
2fa852383c | ||
![]() |
c8bcdc7878 | ||
![]() |
f34101dc37 | ||
![]() |
009959e4c4 |
404 changed files with 30602 additions and 28267 deletions
14
.env.example
14
.env.example
|
@ -1,9 +1,9 @@
|
|||
APP_NAME=AnonAddy
|
||||
APP_NAME=addy.io
|
||||
APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_LOG_LEVEL=debug
|
||||
# The URL of the AnonAddy instance, can be anything you like e.g. aa.example.com, or just example.com
|
||||
# The URL of the addy.io instance, can be anything you like e.g. https://aa.example.com, or just https://example.com, if using a non-standard port you must include it e.g. https://example.test:8000. Do not include a trailing slash '/'
|
||||
APP_URL=https://app.example.com
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
|
@ -11,8 +11,8 @@ LOG_CHANNEL=stack
|
|||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=anonaddy_database
|
||||
DB_USERNAME=anonaddy
|
||||
DB_DATABASE=addy_database
|
||||
DB_USERNAME=addy
|
||||
DB_PASSWORD=secret
|
||||
|
||||
BROADCAST_DRIVER=log
|
||||
|
@ -28,9 +28,9 @@ REDIS_HOST=127.0.0.1
|
|||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
# The from name to be used for outgoing email notifications from AnonAddy
|
||||
# The from name to be used for outgoing email notifications from addy.io
|
||||
MAIL_FROM_NAME=Example
|
||||
# The from address to be used for outgoing email notifications from AnonAddy
|
||||
# The from address to be used for outgoing email notifications from addy.io
|
||||
MAIL_FROM_ADDRESS=mailer@example.com
|
||||
MAIL_DRIVER=smtp
|
||||
MAIL_HOST=mail.example.com
|
||||
|
@ -48,7 +48,7 @@ ANONADDY_DOMAIN=example.com
|
|||
ANONADDY_HOSTNAME=mail.example.com
|
||||
ANONADDY_DNS_RESOLVER=127.0.0.1
|
||||
ANONADDY_ALL_DOMAINS=example.com,example2.com
|
||||
# Used for verifying custom domains, can be anything e.g. 64U64QcpgWHAZPyr4nN58kDGvwj9TkKMGyuXcjMFA7CdhTDy2f
|
||||
# Used for verifying custom domains and variable envelope return paths, can be anything e.g. 64U64QcpgWHAZPyr4nN58kDGvwj9TkKMGyuXcjMFA7CdhTDy2f
|
||||
ANONADDY_SECRET=long-random-string
|
||||
# Number of emails that can be forwarded through the service per hour by any one user
|
||||
ANONADDY_LIMIT=200
|
||||
|
|
7
.gitignore
vendored
7
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
/.phpunit.cache
|
||||
/node_modules
|
||||
/public/hot
|
||||
/public/storage
|
||||
|
@ -8,14 +9,20 @@
|
|||
/storage/*.key
|
||||
/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
|
||||
.husky/pre-commit
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
#!/bin/sh
|
||||
#!/usr/bin/env sh
|
||||
if [ -z "$husky_skip_init" ]; then
|
||||
debug () {
|
||||
if [ "$HUSKY_DEBUG" = "1" ]; then
|
||||
|
@ -6,7 +6,7 @@ if [ -z "$husky_skip_init" ]; then
|
|||
fi
|
||||
}
|
||||
|
||||
readonly hook_name="$(basename "$0")"
|
||||
readonly hook_name="$(basename -- "$0")"
|
||||
debug "starting $hook_name..."
|
||||
|
||||
if [ "$HUSKY" = "0" ]; then
|
||||
|
@ -19,7 +19,8 @@ if [ -z "$husky_skip_init" ]; then
|
|||
. ~/.huskyrc
|
||||
fi
|
||||
|
||||
export readonly husky_skip_init=1
|
||||
readonly husky_skip_init=1
|
||||
export husky_skip_init
|
||||
sh -e "$0" "$@"
|
||||
exitCode="$?"
|
||||
|
||||
|
@ -27,5 +28,9 @@ if [ -z "$husky_skip_init" ]; then
|
|||
echo "husky - $hook_name hook exited with code $exitCode (error)"
|
||||
fi
|
||||
|
||||
if [ $exitCode = 127 ]; then
|
||||
echo "husky - command not found in PATH=$PATH"
|
||||
fi
|
||||
|
||||
exit $exitCode
|
||||
fi
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npm run pre-commit
|
|
@ -1,21 +0,0 @@
|
|||
<?php
|
||||
|
||||
$finder = Symfony\Component\Finder\Finder::create()
|
||||
->notPath('vendor')
|
||||
->notPath('bootstrap')
|
||||
->notPath('storage')
|
||||
->in(__DIR__)
|
||||
->name('*.php')
|
||||
->notName('*.blade.php')
|
||||
->ignoreDotFiles(true)
|
||||
->ignoreVCS(true);
|
||||
|
||||
$config = new PhpCsFixer\Config();
|
||||
|
||||
return $config->setRules([
|
||||
'@PSR12' => true,
|
||||
'array_syntax' => ['syntax' => 'short'],
|
||||
'ordered_imports' => ['sort_algorithm' => 'alpha'],
|
||||
'no_unused_imports' => true,
|
||||
])
|
||||
->setFinder($finder);
|
189
README.md
189
README.md
|
@ -1,12 +1,12 @@
|
|||
# Anonymous Email Forwarding
|
||||
|
||||
This is the source code for self-hosting AnonAddy.
|
||||
This is the source code for self-hosting addy.io.
|
||||
|
||||
## FAQ
|
||||
|
||||
- [Why is it called AnonAddy?](#why-is-it-called-anonaddy)
|
||||
- [Why is it called addy.io?](#why-is-it-called-addyio)
|
||||
- [Why did you make this site?](#why-did-you-make-this-site)
|
||||
- [Why should I use AnonAddy?](#why-should-i-use-anonaddy)
|
||||
- [Why should I use addy.io?](#why-should-i-use-addyio)
|
||||
- [Do you store emails?](#do-you-store-emails)
|
||||
- [What is a shared domain alias?](#what-is-a-shared-domain-alias)
|
||||
- [What is a standard alias?](#what-is-a-standard-alias)
|
||||
|
@ -17,9 +17,15 @@ This is the source code for self-hosting AnonAddy.
|
|||
- [Is there a browser extension?](#is-there-a-browser-extension)
|
||||
- [Is there an Android app?](#is-there-an-android-app)
|
||||
- [Is there an iOS app?](#is-there-an-ios-app)
|
||||
- [Is there a Raycast extension?](#is-there-a-raycast-extension)
|
||||
- [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)
|
||||
|
@ -29,13 +35,15 @@ This is the source code for self-hosting AnonAddy.
|
|||
- [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)
|
||||
- [Does AnonAddy strip out the banner information when I reply to an email?](#does-anonaddy-strip-out-the-banner-information-when-i-reply-to-an-email)
|
||||
- [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)
|
||||
- [Can emails have attachments?](#can-emails-have-attachments)
|
||||
- [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)
|
||||
|
@ -47,15 +55,15 @@ This is the source code for self-hosting AnonAddy.
|
|||
- [I'm not receiving any emails, what's wrong?](#im-not-receiving-any-emails-whats-wrong)
|
||||
- [I'm having trouble logging in, what's wrong?](#im-having-trouble-logging-in-whats-wrong)
|
||||
- [How do I know this site won't disappear next month?](#how-do-i-know-this-site-wont-disappear-next-month)
|
||||
- [What happens to AnonAddy if you die?](#what-happens-to-anonaddy-if-you-die)
|
||||
- [What happens to addy.io if you die?](#what-happens-to-addyio-if-you-die)
|
||||
- [Is the application tested?](#is-the-appliction-tested)
|
||||
- [How do I host this myself?](#how-do-i-host-this-myself)
|
||||
- [Who's behind AnonAddy?](#whos-behind-anonaddy)
|
||||
- [Who's behind addy.io?](#whos-behind-addyio)
|
||||
- [I couldn't find an answer to my question, how can I contact you?](#i-couldnt-find-an-answer-to-my-question-how-can-i-contact-you)
|
||||
|
||||
## Why is it called AnonAddy?
|
||||
## Why is it called addy.io?
|
||||
|
||||
AnonAddy is short for "Anonymous Email Address". The word "Addy" is internet slang for email address, e.g.
|
||||
Addy is short for "Address". The word "Addy" is internet slang for an email address, e.g.
|
||||
|
||||
> "My addy is being spammed. I should've kept it private."
|
||||
|
||||
|
@ -74,7 +82,7 @@ I made the code open-source to show everyone what was going on behind the scenes
|
|||
|
||||
I use this service myself for the vast majority of sites I'm signed up to.
|
||||
|
||||
## Why should I use AnonAddy?
|
||||
## Why should I use addy.io?
|
||||
|
||||
There are a number of reasons you should consider using this service:
|
||||
|
||||
|
@ -87,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?
|
||||
|
||||
|
@ -95,7 +103,7 @@ A shared domain alias is any alias that has a domain name that is also shared wi
|
|||
|
||||
## What is a standard alias?
|
||||
|
||||
A standard alias is any alias that can be created on-the-fly. Automatic on-the-fly alias creation is only available for domains that are unique to you. For example, your unique username subdomain, any additional usernames or any custom domains. So if you signed up with the username "johndoe", any alias you create using @johndoe.anonaddy.com would be a standard alias (even if you've generated a UUID/Random Word one).
|
||||
A standard alias is any alias that can be created on-the-fly. Automatic on-the-fly alias creation is only available for domains that are unique to you. For example, your unique username subdomain, any additional usernames or any custom domains. So if you signed up with the username "johndoe", any alias you create using @johndoe.anonaddy.com would be a standard alias (even if you've generated a Random Character/Random Word one).
|
||||
|
||||
## Can I use my own domain?
|
||||
|
||||
|
@ -103,24 +111,24 @@ Yes you can use your own domain name so you can also have *@example.com as your
|
|||
|
||||
## Can I add a domain and also use it as a recipient?
|
||||
|
||||
No, you cannot use the same domain as a custom domain and also for a recipient on AnonAddy.
|
||||
No, you cannot use the same domain as a custom domain and also for a recipient on addy.io.
|
||||
|
||||
e.g if you add "example.com" as a custom domain, you cannot then add "xyz@example.com" as a recipient. This is because a domain cannot direct email to multiple locations simultaneously using MX records. So your email would arrive for "example.com" and then attempt to be forwarded to "xyz@example.com" which would create a loop.
|
||||
|
||||
You can instead use a subdomain for your custom domain, e.g. "mail.example.com" instead of "example.com", this would allow you to create *@mail.example.com for your aliases. More details can be found [here](https://anonaddy.com/help/adding-a-custom-domain/).
|
||||
You can instead use a subdomain for your custom domain, e.g. "mail.example.com" instead of "example.com", this would allow you to create *@mail.example.com for your aliases. More details can be found [here](https://addy.io/help/adding-a-custom-domain/).
|
||||
|
||||
## Can I add a domain if I'm already using it for email somewhere else?
|
||||
|
||||
If you have a custom domain say **example.com** and you are already using it for email somewhere else e.g. ProtonMail or Namecheap then you cannot also use it simultaneously with AnonAddy.
|
||||
If you have a custom domain say **example.com** and you are already using it for email somewhere else e.g. ProtonMail or Namecheap then you cannot also use it simultaneously with addy.io.
|
||||
|
||||
This is because emails cannot be handled by multiple different mail servers at the same time, even if they have the same priority MX records. It can only be delivered to one mail server at a time which will typically be the MX record with the smallest number since this has the highest priority.
|
||||
|
||||
You can either:
|
||||
|
||||
- Migrate your domain to AnonAddy by removing the current provider's MX records and adding AnonAddy's.
|
||||
- Or, if you would like to keep using your domain with your current email provider then I would recommend instead adding a subdomain of it to AnonAddy such as **mail.example.com**.
|
||||
- Migrate your domain to addy.io by removing the current provider's MX records and adding addy.io's.
|
||||
- Or, if you would like to keep using your domain with your current email provider then I would recommend instead adding a subdomain of it to addy.io such as **mail.example.com**.
|
||||
|
||||
Using a subdomain will not interfere with your current email setup and you'll be able to create aliases ***@mail.example.com** through AnonAddy.
|
||||
Using a subdomain will not interfere with your current email setup and you'll be able to create aliases ***@mail.example.com** through addy.io.
|
||||
|
||||
## Why should I use this instead of a similar service?
|
||||
|
||||
|
@ -133,14 +141,14 @@ Here are a few reasons I can think of:
|
|||
* Open-source application code
|
||||
* No limitation on the number of aliases that can be created
|
||||
* Generous monthly bandwidth
|
||||
* Multiple domains to choose for aliases (currently anonaddy.com, anonaddy.me and another 3 for paid plan users)
|
||||
* Ability to generate UUID and random word aliases at shared domains
|
||||
* Multiple domains to choose for aliases (currently anonaddy.com, anonaddy.me and more for paid plan users)
|
||||
* Ability to generate random character and random word aliases at shared domains
|
||||
* Ability to add additional usernames to compartmentalise aliases
|
||||
* New features added regularly
|
||||
|
||||
## Is there a browser extension?
|
||||
|
||||
Yes there is an [open-source](https://github.com/anonaddy/browser-extension) browser extension available to download for [Firefox](https://addons.mozilla.org/en-GB/firefox/addon/anonaddy/) and [Chrome](https://chrome.google.com/webstore/detail/anonaddy/iadbdpnoknmbdeolbapdackdcogdmjpe) (also available on other chromium based browsers such as Brave and Vivaldi). You can use the extension to generate new aliases remotely.
|
||||
Yes there is an [open-source](https://github.com/anonaddy/browser-extension) browser extension available to download for [Firefox](https://addons.mozilla.org/en-GB/firefox/addon/addy_io/) and [Chrome](https://chrome.google.com/webstore/detail/addyio-anonymous-email-fo/iadbdpnoknmbdeolbapdackdcogdmjpe) (also available on other chromium based browsers such as Brave and Vivaldi). You can use the extension to generate new aliases remotely.
|
||||
|
||||
## Is there an Android app?
|
||||
|
||||
|
@ -152,9 +160,13 @@ There is also another [open-source](https://github.com/KhalidWar/anonaddy) Andro
|
|||
|
||||
Yes, [KhalidWar's](https://github.com/KhalidWar) [open-source](https://github.com/KhalidWar/anonaddy) app from above is also available on the [App Store](https://apps.apple.com/us/app/addymanager/id1547461270).
|
||||
|
||||
## Is there a Raycast extension?
|
||||
|
||||
Yes, [http.james'](https://httpjames.space/) [open-source](https://github.com/raycast/extensions/tree/cceffa51046266f25819f800316561b783c52663/extensions/anonaddy/) extension is available on the [Raycast Store](https://www.raycast.com/http.james/anonaddy).
|
||||
|
||||
## How do I add my own GPG/OpenPGP key for encryption?
|
||||
|
||||
On the recipients page you simply need to click "Add public key" and paste in your **public** key data. Now all emails forwarded to you will be encrypted with your key. You should also replace the subject line of forwarded messages in your account settings as this cannot be encrypted.
|
||||
On the recipients page you simply need to click "Add public key" and paste in your **public** key data. Now all emails forwarded to you will be encrypted with your key. You can even hide and encrypt the subject as addy.io supports protected headers.
|
||||
|
||||
## Are attachments encrypted too?
|
||||
|
||||
|
@ -162,18 +174,44 @@ Yes attachments are part of the email body and are also encrypted if you have it
|
|||
|
||||
## Are forwarded emails signed when encryption is enabled?
|
||||
|
||||
Yes when you have encryption enabled all forwarded emails are signed using our mailer@anonaddy.me private key.
|
||||
Yes when you have encryption enabled all forwarded emails are signed using our no-reply@addy.io private key.
|
||||
|
||||
You can add this key to your own keyring so that you can verify emails have come from us.
|
||||
|
||||
The fingerprint of the mailer@anonaddy.me key is "26A987650243B28802524E2F809FD0D502E2F695" you can find the key on [https://keys.openpgp.org](https://keys.openpgp.org/search?q=26A987650243B28802524E2F809FD0D502E2F695).
|
||||
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:
|
||||
|
||||
1. You can generate UUID or random word aliases instead, these are all under a shared domain and cannot be linked to a user.
|
||||
2. You can add additional usernames and separate your aliases under your these. e.g. you could have one username for personal stuff, another for work, another for hobbies etc.
|
||||
1. You can generate random character or random word aliases instead, these are all under a shared domain and cannot be linked to a user.
|
||||
2. You can add additional usernames and separate your aliases under each of them. e.g. you could have one username for personal stuff, another for work, another for hobbies etc.
|
||||
|
||||
## Where is the server located?
|
||||
|
||||
|
@ -192,7 +230,7 @@ The limit is currently set to 10 which should suffice in the vast majority of si
|
|||
When you delete your account the following happens:
|
||||
|
||||
* All of your recipients are deleted from the database
|
||||
* All of your aliases that use a shared domain e.g. @anonaddy.me are soft deleted from the database (this is to prevent any chance of another user generating the same alias in the future)
|
||||
* All of your aliases that use a shared domain e.g. @anonaddy.me are soft deleted from the database (this is to prevent any chance of another user generating the same alias in the future) any identifying information e.g the alias description is removed
|
||||
* All of your other aliases are deleted from the database
|
||||
* All of your custom domains are deleted from the database
|
||||
* Your user details are deleted from the database
|
||||
|
@ -215,11 +253,11 @@ All you need to do is click reply in your email client or web interface and it w
|
|||
|
||||
To check if a reply has worked properly check in your dashboard if the reply count has been incremented for that alias.
|
||||
|
||||
For further details please see this help article - [Replying to email using an alias](https://anonaddy.com/help/replying-to-email-using-an-alias/).
|
||||
For further details please see this help article - [Replying to email using an alias](https://addy.io/help/replying-to-email-using-an-alias/).
|
||||
|
||||
## I'm trying to reply/send from an alias but the email keeps coming back to me, what's wrong?
|
||||
|
||||
If you are tying to reply or send from an alias but the email keeps coming back to yourself then it is most likely because you are not sending the message from an email address that **is not listed as a verified recipient** on your AnonAddy account.
|
||||
If you are trying to reply or send from an alias but the email keeps coming back to yourself then it is most likely because you are not sending the message from an email address that **is not listed as a verified recipient** on your addy.io account.
|
||||
|
||||
If you try to reply or send from an alias using an unverified email address then the message will simply be forwarded to you as it would be if it was sent by any other sender.
|
||||
|
||||
|
@ -227,17 +265,15 @@ Please double check that you are indeed sending from a verified recipient email
|
|||
|
||||
## I'm trying to reply/send from an alias but it is rejected, what's wrong?
|
||||
|
||||
If you see the rejection message `5.7.1 Recipient address rejected: Address does not exist` then this means that the alias has either been deleted or does not yet exist (and you do not have catch-all enabled), you must restore (or create) it before you can send/reply from it.
|
||||
If you see the rejection message `550 5.1.1 Recipient address rejected: Address does not exist` then this means that the alias has either been deleted or does not yet exist (and you do not have catch-all enabled), you must restore (or create) it before you can send/reply from it.
|
||||
|
||||
If you see the rejection message `5.7.1 Rejected due to missing/failed DMARC policy...` then it is because your AnonAddy recipient's domain either does not have a DMARC policy or it has failed DMARC checks.
|
||||
If you receive an email notification with the subject "Attempted reply/send from alias has failed" then it is usually because you have a verified recipient that is using your own domain which does not have a DMARC policy.
|
||||
|
||||
This is usually because you have a verified recipient that is using your own domain which does not have a DMARC policy.
|
||||
> Note: This is referring to **your verified recipient address** on your addy.io account **and not** any of your custom domains or the email address that you are replying / sending to
|
||||
|
||||
> Note: This is referring to **your verified recipient address** on your AnonAddy account **and not** any of your custom domains or the email address that you are replying / sending to
|
||||
When replying or sending from an alias, **additional checks** are carried out to ensure it is not a spoofed email. Your addy.io recipient's email domain must pass DMARC checks in order to protect against spoofed emails and to make sure that the reply/send from attempt definitely came from your recipient.
|
||||
|
||||
When replying or sending from an alias, **additional checks** are carried out to ensure it is not a spoofed email. Your AnonAddy recipient's email domain must pass DMARC checks in order to protect against spoofed emails and to make sure that the reply/send from attempt definitely came from your recipient.
|
||||
|
||||
For example if the verified recipient on your AnonAddy account is `hello@example.com` and you get the "missing/failed DMARC policy" rejection message then it is because the domain "example.com" does not have a DMARC policy in place.
|
||||
For example if the verified recipient on your addy.io account is `hello@example.com` and you get this email notification then it is because the domain "example.com" does not have a DMARC policy in place.
|
||||
|
||||
To resolve this you simply need to add a DMARC record, for example:
|
||||
|
||||
|
@ -250,11 +286,21 @@ You should also have SPF and DKIM records in place.
|
|||
|
||||
To learn more about DMARC please see this site - [https://dmarc.org/](https://dmarc.org/).
|
||||
|
||||
If your AnonAddy 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.
|
||||
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.
|
||||
|
||||
## Does AnonAddy strip out the banner information when I reply to an email?
|
||||
## I've been forwarded an email with a red warning banner saying it may have been spoofed, what does it mean?
|
||||
|
||||
At the moment the site does not automatically strip out the "This email was sent to..." text from forwarded emails when you reply to them. You need to either remove this from the quoted text manually or set the banner information to "off" in your account settings.
|
||||
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?
|
||||
|
||||
|
@ -284,7 +330,7 @@ If you need to send an email to an address with an extension e.g. **hello+whatev
|
|||
|
||||
Just enter the extension too!
|
||||
|
||||
For further details please see this help article - [Sending email from an alias](https://anonaddy.com/help/sending-email-from-an-alias/).
|
||||
For further details please see this help article - [Sending email from an alias](https://addy.io/help/sending-email-from-an-alias/).
|
||||
|
||||
## Will people see my real email if I reply to a forwarded one?
|
||||
|
||||
|
@ -296,7 +342,7 @@ Yes you can add attachments to emails forwarded and replies. Attachments count t
|
|||
|
||||
## What is the max email size limit?
|
||||
|
||||
The max email size is currently set to 10MB (including attachments).
|
||||
The max email size is currently set to 25MB (including attachments).
|
||||
|
||||
## What happens if I have a subscription but then cancel it?
|
||||
|
||||
|
@ -306,17 +352,21 @@ A few days before your billing cycle ends you will receive an email letting you
|
|||
|
||||
* Any custom domains will be **deactivated**
|
||||
* Any additional usernames will be **deactivated**
|
||||
* If you have any more than **2 recipients** they will be **deleted**
|
||||
* If you have any more than **1 recipient** they will be **deleted**
|
||||
* Paid account settings will be reverted to default values
|
||||
* Any aliases using paid plan only domains will be **deactivated**
|
||||
* If you have any more than 20 aliases using a shared domain e.g. anonaddy.me they will be **deactivated**
|
||||
* If you have any more than 10 aliases using a shared domain e.g. anonaddy.me they will be **deactivated**
|
||||
* If your account username has catch-all disabled then it will be enabled
|
||||
|
||||
You will not be able to activate any of the above again until you resubscribe.
|
||||
|
||||
## If I subscribe will Stripe see my real email address?
|
||||
|
||||
No, Stripe will instead be given an alias. This alias will only be created if Stripe sends an email to it, for example if your card payment fails or if your card has expired.
|
||||
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?
|
||||
|
||||
|
@ -347,25 +397,25 @@ Each time a new email is received Postfix calculates its size in bytes. A column
|
|||
|
||||
I don't use rolling 30 day total as the only way to do this would be to log the date and size of every single email received.
|
||||
|
||||
Blocked emails do not count towards your bandwidth (e.g. an alias is inactive or deleted).
|
||||
Blocked emails do not count towards your bandwidth (e.g. if an alias is inactive or deleted).
|
||||
|
||||
## How many emails can I receive before I go over my bandwidth limit?
|
||||
|
||||
The average email is about 76800 bytes (75KB), this is roughly equivalent to 7,000 words in plain text. So the 10MB monthly allowance would be around 140 emails and the Lite plan's 50MB would be almost 700 emails.
|
||||
The average email is about 76800 bytes (75KB), this is roughly equivalent to 7,000 words in plain text. So the 10MB monthly allowance would be around 140 emails and the Lite plan's 100MB would be almost 1,400 emails.
|
||||
|
||||
## What happens if I go over my bandwidth limit in a given month?
|
||||
|
||||
If you get close to your limit (over 80%) you'll be sent an email letting you know. If you continue and go over your limit the server will start discarding emails until your bandwidth resets the next month or you upgrade your plan.
|
||||
If you get close to your limit (over 80%) you'll be sent an email letting you know. If you continue and go over your limit the server will respond to any delivery attempts to your aliases with the following: `552 5.2.2 Recipient address rejected: User over quota` until your bandwidth resets the next month or you upgrade your plan.
|
||||
|
||||
## Can I login using an additional username?
|
||||
|
||||
You can add 1 additional username as a Lite user and up to 3 additional usernames as a Pro user for totals of 2 and 4 respectively (including the one you signed up with). You can currently only login with the one that you originally 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?
|
||||
|
||||
Please make sure to add mailer@anonaddy.me, mailer@anonaddy.com and any other aliases you use to your address book and also to check your spam folder. Make sure to mark emails from AnonAddy as safe if they turn up in spam.
|
||||
Please make sure to add no-reply@addy.io and any aliases you use to your address book and also to check your spam folder. Make sure to mark emails from addy.io as safe if they turn up in spam.
|
||||
|
||||
If an alias has been previously deleted and you try to send email to it, the emails will be rejected with an error message - "554 5.7.1 Recipient address rejected: Address does not exist".
|
||||
If an alias has been deleted and you try to send email to it, the emails will be rejected with an error message - "550 5.1.1 Recipient address rejected: Address does not exist".
|
||||
|
||||
Check that you have not deactivated the alias, custom domain or additional username. When any of these are deactivated, emails will be silently discarded, they will not be rejected or return any error message.
|
||||
|
||||
|
@ -377,12 +427,15 @@ For some reason Apple seems to think these emails are spam/phishing and returns
|
|||
|
||||
> Diagnostic-Code: smtp; 550 5.7.1 [CS01] Message rejected due to local policy.
|
||||
|
||||
I have contacted Apple multiple times about this but they have not yet responded.
|
||||
|
||||
If you are having issues with emails being rejected as "possibly spammy" by Google, iCloud or Microsoft then please try the following steps if you can:
|
||||
|
||||
1. **Replace the email subject** by going to your settings in AnonAddy
|
||||
2. Try adding a GPP key and **enabling encryption**. This will prevent the email's content being scanned and reduce the change of it being rejected.
|
||||
1. **Replace the email subject** by going to your settings in addy.io
|
||||
2. Try adding a GPG key and **enabling encryption**. This will prevent the email's content being scanned and reduce the chance of it being rejected.
|
||||
3. Enable the option to hide and encrypt the email subject
|
||||
4. Try disabling the banner information on forwarded emails
|
||||
5. Try adding the alias email (and/or domain) to your contact list (address book) or safe senders list if possible
|
||||
|
||||
For Outlook, Hotmail or MSN you can find instructions on how to add a domain to your safe senders list [here](https://support.microsoft.com/en-gb/office/safe-senders-in-outlook-com-470d4ee6-e3b6-402b-8cd9-a6f00eda7339).
|
||||
|
||||
I will also soon be adding an option to change the format of the display from part of the "From:" header.
|
||||
|
||||
|
@ -400,11 +453,11 @@ Please make sure you are using your account username (e.g. johndoe) and not your
|
|||
|
||||
2. Forgotten password
|
||||
|
||||
If you've forgotten your password you can reset it by entering your username here - https://app.anonaddy.com/password/reset
|
||||
If you've forgotten your password you can reset it by entering your username here - https://app.addy.io/password/reset
|
||||
|
||||
3. Forgotten username
|
||||
|
||||
If you've forgotten your username you can request a reminder by entering your email address here - https://app.anonaddy.com/username/reminder
|
||||
If you've forgotten your username you can request a reminder by entering your email address here - https://app.addy.io/username/reminder
|
||||
|
||||
4. Lost 2FA device
|
||||
|
||||
|
@ -418,11 +471,11 @@ If you have a YubiKey and are using Windows and have an issue with your personal
|
|||
|
||||
I am very passionate about this project. I use it myself every day and will be keeping it running indefinitely. The service also provides me with an income.
|
||||
|
||||
## What happens to AnonAddy if you die?
|
||||
## What happens to addy.io if you die?
|
||||
|
||||
I do have someone in place who can keep the service running in the event of me not being here. They are able to continue paying for the servers that host AnonAddy and the domains that it uses. All AnonAddy domains also always have over 5 years until they expire.
|
||||
I do have someone in place who can keep the service running in the event of me not being here. They are able to continue paying for the servers that host addy.io and the domains that it uses. All addy.io domains also always have over 5 years until they expire.
|
||||
|
||||
They would make a Twitter announcement informing all users that they would be keeping the service running. You would then be able to decide whether you'd like to continue using AnonAddy or start to update your email addresses.
|
||||
They would make a Twitter announcement informing all users that they would be keeping the service running. You would then be able to decide whether you'd like to continue using addy.io or start to update your email addresses.
|
||||
|
||||
## Is the application tested?
|
||||
|
||||
|
@ -434,26 +487,26 @@ You will need to set up your own server with Postfix so that you can pipe the re
|
|||
|
||||
For those who prefer using Docker there is an image you can use here - [github.com/anonaddy/docker](https://github.com/anonaddy/docker).
|
||||
|
||||
## Who's behind AnonAddy?
|
||||
## Who's behind addy.io?
|
||||
|
||||
My name is Will Browning, I'm a web developer from the UK and an advocate for online privacy and open-source software. You can find me on [Twitter](https://twitter.com/willbrowningme) although I don't tweet that much!
|
||||
|
||||
## I couldn't find an answer to my question, how can I contact you?
|
||||
|
||||
For any other questions just send an email to - [contact@anonaddy.com](mailto:contact@anonaddy.com) ([GPG Key](https://anonaddy.com/anonaddy-contact-public-key.asc))
|
||||
For any other questions just send an email to - contact (at) help.addy.io ([GPG Key](https://addy.io/contact-public-key.asc))
|
||||
|
||||
## Self Hosting
|
||||
|
||||
## Software Requirements
|
||||
|
||||
* Postfix (3.0.0+) (plus postfix-mysql for database queries and postfix-pcre)
|
||||
* PHP (8.0+) and the [php-mailparse](https://pecl.php.net/package/mailparse) extension, the [php-gnupg](https://pecl.php.net/package/gnupg) extension if you plan to encrypt forwarded emails, the [php-imagick](https://pecl.php.net/package/imagick) extension for generating 2FA QR codes
|
||||
* PHP (8.2+) and the [php-mailparse](https://pecl.php.net/package/mailparse) extension, the [php-gnupg](https://pecl.php.net/package/gnupg) extension if you plan to encrypt forwarded emails, the [php-imagick](https://pecl.php.net/package/imagick) extension for generating 2FA QR codes
|
||||
* Port 25 unblocked and open
|
||||
* Redis (4.x+) for throttling and queues
|
||||
* Redis (7.x+) for throttling and queues
|
||||
* FQDN as hostname e.g. mail.anonaddy.me
|
||||
* MariaDB / MySQL
|
||||
* Nginx
|
||||
* (SpamAssassin, OpenDKIM, OpenDMARC, postfix-policyd-spf-python) OR Rspamd
|
||||
* Rspamd
|
||||
* DNS records - MX, SPF, DKIM, DMARC
|
||||
* Reverse DNS
|
||||
* SSL/TLS Encryption - you can install a free certificate from Let’s Encrypt.
|
||||
|
@ -462,14 +515,16 @@ For full details please see the [self-hosting instructions file](SELF-HOSTING.md
|
|||
|
||||
## My sponsors
|
||||
|
||||
Thanks to [Vlad Timofeev](https://github.com/vlad-timofeev), [Patrick Dobler](https://github.com/patrickdobler), [Luca Steeb](https://github.com/steebchen) and [Laiteux](https://github.com/Laiteux) for supporting me by sponsoring the project on GitHub!
|
||||
Thanks to [Vlad Timofeev](https://github.com/vlad-timofeev), [Patrick Dobler](https://github.com/patrickdobler), [Luca Steeb](https://github.com/steebchen), [narolinus](https://github.com/narolinus) and [Lukas](https://github.com/lunibo) for supporting me by sponsoring the project on GitHub!
|
||||
|
||||
Also an extra special thanks to [CrazyMax](https://github.com/crazy-max) for sponsoring me and also creating and maintaining the awesome [AnonAddy Docker image](https://github.com/anonaddy/docker)!
|
||||
Also an extra special thanks to [CrazyMax](https://github.com/crazy-max) for sponsoring me and also creating and maintaining the awesome [addy.io Docker image](https://github.com/anonaddy/docker)!
|
||||
|
||||
## Thanks
|
||||
|
||||
Thanks to [https://gitlab.com/mailcare/mailcare](https://gitlab.com/mailcare/mailcare) and [https://github.com/niftylettuce/forward-email](https://github.com/niftylettuce/forward-email) for their awesome open-source projects that helped me along the way.
|
||||
Huge thank you to [Stjin](https://twitter.com/Stjinchan) and [KhalidWar](https://github.com/KhalidWar) for their amazing mobile apps.
|
||||
|
||||
Also to [https://gitlab.com/mailcare/mailcare](https://gitlab.com/mailcare/mailcare) and [https://github.com/niftylettuce/forward-email](https://github.com/niftylettuce/forward-email) for their awesome open-source projects that helped me along the way.
|
||||
|
||||
## License
|
||||
|
||||
The MIT License (MIT). Please see [License File](LICENSE.md) for more information.
|
||||
GNU Affero General Public License v3.0. Please see [License File](LICENSE.md) for more information.
|
||||
|
|
20
SECURITY.md
20
SECURITY.md
|
@ -1,4 +1,4 @@
|
|||
If you believe you've found a security issue in the AnonAddy product or service, I encourage you to
|
||||
If you believe you've found a security issue in the addy.io product or service, I encourage you to
|
||||
notify me. I welcome working with you to resolve the issue promptly. Thanks in advance!
|
||||
|
||||
# Disclosure Policy
|
||||
|
@ -11,27 +11,27 @@ notify me. I welcome working with you to resolve the issue promptly. Thanks in a
|
|||
degradation of the service. Only interact with accounts you own or with explicit permission of the
|
||||
account holder.
|
||||
- If you would like to encrypt your report, please use the PGP key with fingerprint
|
||||
`5FCAFD8A67D2A783CFF4D0E31AC6D923E6FB4EF7` (available on the openpgp.org keyserver).
|
||||
`E652C2DB43859328F35575DEBF7B93C6497510D0` (available on the openpgp.org keyserver).
|
||||
|
||||
# Reporting a Vulnerability
|
||||
|
||||
To report a vulnerability please send an email to contact@anonaddy.com, you can use the PGP key above if you wish to encrypt it.
|
||||
To report a vulnerability please send an email to contact (at) help.addy.io, you can use the PGP key above if you wish to encrypt it.
|
||||
|
||||
# In-scope
|
||||
|
||||
- Security issues in any current release of AnonAddy. This includes the web application, browser extension,
|
||||
- Security issues in any current release of addy.io. This includes the web application, browser extension,
|
||||
and landing page. Source code is available at https://github.com/anonaddy.
|
||||
|
||||
# Exclusions
|
||||
|
||||
The following bug classes are out-of scope:
|
||||
|
||||
- Bugs that are already reported on any of AnonAddy's issue trackers (https://github.com/anonaddy),
|
||||
- Bugs that are already reported on any of addy.io's issue trackers (https://github.com/anonaddy),
|
||||
or that I already know of.
|
||||
- Attacks requiring physical access to a user's device.
|
||||
- Self-XSS
|
||||
- Issues related to software or protocols not under AnonAddy's control
|
||||
- Vulnerabilities in outdated versions of AnonAddy
|
||||
- Issues related to software or protocols not under addy.io's control
|
||||
- Vulnerabilities in outdated versions of addy.io
|
||||
- Missing security best practices that do not directly lead to a vulnerability
|
||||
- Issues that do not have any impact on the general public
|
||||
|
||||
|
@ -39,7 +39,7 @@ While researching, I'd like to ask you to refrain from:
|
|||
|
||||
- Denial of service
|
||||
- Spamming
|
||||
- Social engineering (including phishing) of AnonAddy emails
|
||||
- Any physical attempts against AnonAddy property or data centers
|
||||
- Social engineering (including phishing) of addy.io emails
|
||||
- Any physical attempts against addy.io property or data centers
|
||||
|
||||
Thank you for helping keep AnonAddy and its users safe!
|
||||
Thank you for helping keep addy.io and its users safe!
|
589
SELF-HOSTING.md
589
SELF-HOSTING.md
|
@ -1,18 +1,37 @@
|
|||
# AnonAddy Self-Hosting Instructions
|
||||
# How to self-host addy.io (AnonAddy)
|
||||
|
||||
- [Assumptions](#assumptions)
|
||||
- [Setting up the server](#setting-up-the-server)
|
||||
- [DNS records](#dns-records)
|
||||
- [Installing Postfix](#installing-postfix)
|
||||
- [Installing Nginx](#installing-nginx)
|
||||
- [Installing PHP](#installing-php)
|
||||
- [Let's Encrypt](#lets-encrypt)
|
||||
- [Installing MariaDB](#installing-mariadb)
|
||||
- [Installing Redis](#installing-redis)
|
||||
- [Installing Rspamd](#installing-rspamd)
|
||||
- [The web application](#the-web-application)
|
||||
- [Installing Supervisor](#installing-supervisor)
|
||||
- [Creating your account](#creating-your-account)
|
||||
- [Adding your private key to sign emails](#adding-your-private-key-to-sign-emails)
|
||||
- [Setting up a local caching DNS resolver](#setting-up-a-local-caching-dns-resolver)
|
||||
- [Adding MTA Strict Transport Security and SMTP TLS Reporting](#adding-mta-strict-transport-security-and-smtp-tls-reporting)
|
||||
- [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
|
||||
|
||||
This guide assumes that you are competent using the command line to manage an Ubuntu server and that you have already taken appropriate steps to harden and secure the server, for example: no root login, key auth only, 2FA, automatic security updates etc. I will not go over these here as there are already many great resources availble covering this:
|
||||
This guide assumes that you are competent using the command line to manage an Ubuntu server and that you have already taken appropriate steps to harden and secure the server, for example: no root login, key auth only, 2FA, automatic security updates etc. I will not go over these here as there are already many great resources available covering this:
|
||||
|
||||
- [https://github.com/imthenachoman/How-To-Secure-A-Linux-Server](https://github.com/imthenachoman/How-To-Secure-A-Linux-Server)
|
||||
- [https://jacyhong.wordpress.com/2016/06/27/my-first-10-minutes-on-a-server-primer-for-securing-ubuntu/](https://jacyhong.wordpress.com/2016/06/27/my-first-10-minutes-on-a-server-primer-for-securing-ubuntu/)
|
||||
- [https://plusbryan.com/my-first-5-minutes-on-a-server-or-essential-security-for-linux-servers](https://plusbryan.com/my-first-5-minutes-on-a-server-or-essential-security-for-linux-servers)
|
||||
|
||||
You should have a fresh 20.04 Ubuntu server (or 18.04) with Fail2ban, a Firewall (e.g UFW), and make sure that ports **25**, **22** (or whatever your SSH port is if you've changed it) **443** and **80** are open.
|
||||
|
||||
You should have a fresh 22.04 Ubuntu server with Fail2ban, a Firewall (e.g UFW), and make sure that ports **25**, **22** (or whatever your SSH port is if you've changed it) **443** and **80** are open.
|
||||
## Setting up the server
|
||||
|
||||
Choosing a provider (that you trust), [UpCloud](https://upcloud.com/signup/?promo=D5H33W) (referral link), Vultr, Greenhost, OVH, Hetzner, Linode, Cockbox (make sure the host allows port 25 to be used, some providers block it).
|
||||
Choosing a provider (that you trust), [UpCloud](https://upcloud.com/signup/?promo=D5H33W) (referral link), [Vultr](https://www.vultr.com/?ref=6987509) (referral link), Greenhost, OVH, [Hetzner](https://hetzner.cloud/?ref=MYpsZhIjB7eE) (referral link), Linode, Cockbox (make sure the host allows port 25 to be used, some providers block it).
|
||||
|
||||
With Vultr and UpCloud you may need to open a support ticket and request for them to unblock port 25 as it is typically disabled by default.
|
||||
|
||||
|
@ -22,7 +41,7 @@ If it is, then check if the blacklists are just preventitive e.g. because the IP
|
|||
|
||||
If the IP is on many blacklists specifically for sending out spam then it migt be best to destroy it and deploy a new one. You might notice that some providers such as Vultr have entire ranges of IPs listed.
|
||||
|
||||
I will be running all commands as a sudo user called `johndoe`. The domain used will be `example.com` and the hostname `mail.example.com`. I'll be using Vultr for this example (Note: if you also use Vultr for managing DNS records they do not currently support TSLA records required for DANE).
|
||||
Throughout these instructions I will be running all commands as a sudo user called `johndoe`. The domain used will be `example.com` and the hostname `mail.example.com`. I'll be using Vultr for this example (Note: if you also use Vultr for managing DNS records they do not currently support TSLA records required for DANE).
|
||||
|
||||
To check your server's hostname run:
|
||||
|
||||
|
@ -83,6 +102,10 @@ AAAA app.example.com <Your-IPv6-address>
|
|||
|
||||
Make sure to replace the placeholders above with the actual IP address of your server.
|
||||
|
||||
<div class="flex justify-center mb-4">
|
||||
<img class="shadow" src="https://addy.io/assets/img/dns-records.png" alt="DNS records" title="DNS records">
|
||||
</div>
|
||||
|
||||
Now we need to set up the correct PTR record for reverse DNS lookups. This needs to be set as your FQDN (fully qualified domain name) which in our case is mail.example.com.
|
||||
|
||||
On your server run `host <Your-IPv4-address>` to check what it is.
|
||||
|
@ -93,8 +116,16 @@ In Vultr you can update your reverse DNS by clicking on your server, then going
|
|||
|
||||
Change it to `mail.example.com`. Don't forget to update this for IPv6 if you are using it too.
|
||||
|
||||
<div class="flex justify-center mb-4">
|
||||
<img class="shadow" src="https://addy.io/assets/img/reverse-dns-ipv4.png" alt="Reverse DNS IPv4" title="Reverse DNS IPv4">
|
||||
</div>
|
||||
|
||||
You can check that it is set correctly by entering your IPv4 and IPv6 addresses here [https://mxtoolbox.com/ReverseLookup.aspx](https://mxtoolbox.com/ReverseLookup.aspx).
|
||||
|
||||
<div class="flex justify-center">
|
||||
<img class="shadow" src="https://addy.io/assets/img/reverse-dns-ipv6.png" alt="Reverse DNS IPv6" title="Reverse DNS IPv6">
|
||||
</div>
|
||||
|
||||
## Installing Postfix
|
||||
|
||||
Now we're going to install our MTA (mail transfer agent) Postfix.
|
||||
|
@ -105,8 +136,16 @@ sudo apt install postfix
|
|||
```
|
||||
For configuration type select "Internet Site".
|
||||
|
||||
<div class="flex justify-center mb-4">
|
||||
<img class="shadow" src="https://addy.io/assets/img/postfix-install.png" alt="Postfix install" title="Postfix install">
|
||||
</div>
|
||||
|
||||
For System mail name: enter "example.com" note the missing mail subdomain.
|
||||
|
||||
<div class="flex justify-center mb-4">
|
||||
<img class="shadow" src="https://addy.io/assets/img/postfix-install-2.png" alt="Postfix install system name" title="Postfix install system name">
|
||||
</div>
|
||||
|
||||
Postfix should now begin installing.
|
||||
|
||||
If you would like to check the version of Postfix that you are running you can do:
|
||||
|
@ -115,7 +154,7 @@ If you would like to check the version of Postfix that you are running you can d
|
|||
sudo postconf mail_version
|
||||
```
|
||||
|
||||
At the time of writing this I am running `mail_version = 3.4.13`.
|
||||
At the time of writing this I am running `mail_version = 3.6.4`.
|
||||
|
||||
We'll install an extension we will need later so that Postfix can query our database.
|
||||
|
||||
|
@ -142,9 +181,7 @@ append_dot_mydomain = no
|
|||
|
||||
readme_directory = no
|
||||
|
||||
# See http://www.postfix.org/COMPATIBILITY_README.html -- default to 2 on
|
||||
# fresh installs.
|
||||
compatibility_level = 2
|
||||
compatibility_level = 3.6
|
||||
|
||||
# SMTPD
|
||||
smtpd_tls_cert_file=/etc/nginx/conf.d/example.com.d/server.crt
|
||||
|
@ -218,12 +255,11 @@ smtpd_sender_restrictions =
|
|||
smtpd_recipient_restrictions =
|
||||
permit_mynetworks,
|
||||
reject_unauth_destination,
|
||||
check_recipient_access mysql:/etc/postfix/mysql-recipient-access.cf,
|
||||
check_policy_service unix:private/policy,
|
||||
reject_rhsbl_helo dbl.spamhaus.org,
|
||||
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
|
||||
|
@ -239,6 +275,10 @@ disable_vrfy_command = yes
|
|||
strict_rfc821_envelopes = yes
|
||||
```
|
||||
|
||||
<div class="flex justify-center mb-4">
|
||||
<img class="shadow" src="https://addy.io/assets/img/postfix-main.png" alt="Postfix main" title="Postfix main">
|
||||
</div>
|
||||
|
||||
Make sure your hostname is correct in the Postfix config file.
|
||||
|
||||
```bash
|
||||
|
@ -250,26 +290,44 @@ You'll see warnings that the mysql-... files do not exist. You should see mail.e
|
|||
Open up `/etc/postfix/master.cf` and add these lines to the bottom of the file:
|
||||
|
||||
```
|
||||
# Pipe to addy.io application
|
||||
anonaddy unix - n n - - pipe
|
||||
flags=F user=johndoe argv=php /var/www/anonaddy/artisan anonaddy:receive-email --sender=${sender} --recipient=${recipient} --local_part=${user} --extension=${extension} --domain=${domain} --size=${size}
|
||||
|
||||
# addy.io access policy
|
||||
policy unix - n n - 0 spawn
|
||||
user=johndoe argv=php /var/www/anonaddy/postfix/AccessPolicy.php
|
||||
```
|
||||
|
||||
Making sure to replace `johndoe` with the username of the user who will run the artisan command and also to update the /path to wherever you plan to place the web app installation. For this tutorial I'm going to use the location `/var/www/anonaddy`.
|
||||
|
||||
This command will pipe the email through to our applicaton so that we can determine who the alias belongs to and who to forward the email to.
|
||||
<div class="flex justify-center mb-4">
|
||||
<img class="shadow" src="https://addy.io/assets/img/postfix-master-2.png" alt="Postfix master pipe" title="Postfix master pipe">
|
||||
</div>
|
||||
|
||||
This command will pipe the email through to our application so that we can determine who the alias belongs to and who to forward the email to.
|
||||
|
||||
## Installing Nginx
|
||||
|
||||
To install Nginx add the following signing key and repo.
|
||||
To install Nginx first add the prerequisites add then add the following signing key and repo (instructions taken from [nginx.org](https://nginx.org/en/linux_packages.html#Ubuntu)).
|
||||
|
||||
Import the nginx signing key and the repository.
|
||||
|
||||
```bash
|
||||
sudo apt-key adv --fetch-keys 'https://nginx.org/keys/nginx_signing.key'
|
||||
sudo sh -c "echo 'deb https://nginx.org/packages/mainline/ubuntu/ '$(lsb_release -cs)' nginx' > /etc/apt/sources.list.d/Nginx.list"
|
||||
sudo apt install curl gnupg2 ca-certificates lsb-release ubuntu-keyring
|
||||
|
||||
curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \
|
||||
| sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null
|
||||
|
||||
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \
|
||||
http://nginx.org/packages/mainline/ubuntu `lsb_release -cs` nginx" \
|
||||
| sudo tee /etc/apt/sources.list.d/nginx.list
|
||||
|
||||
echo -e "Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n" \
|
||||
| sudo tee /etc/apt/preferences.d/99nginx
|
||||
```
|
||||
|
||||
Then you can install and check the version.
|
||||
Then you can Install and check the version.
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
|
@ -277,7 +335,7 @@ sudo apt install nginx
|
|||
sudo nginx -v
|
||||
```
|
||||
|
||||
At the time of writing this I have `nginx version: nginx/1.21.1`.
|
||||
At the time of writing this I have `nginx version: nginx/1.25.2`.
|
||||
|
||||
Create the directory for where the application will be stored.
|
||||
|
||||
|
@ -307,66 +365,67 @@ Add the following inside
|
|||
|
||||
```
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
|
||||
server_name app.example.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
server_name app.example.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name app.example.com;
|
||||
root /var/www/anonaddy/public;
|
||||
server_tokens off;
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name app.example.com;
|
||||
root /var/www/anonaddy/public;
|
||||
server_tokens off;
|
||||
http2 on;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'";
|
||||
add_header Referrer-Policy "origin-when-cross-origin";
|
||||
add_header Expect-CT "enforce, max-age=604800";
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'";
|
||||
add_header Referrer-Policy "origin-when-cross-origin";
|
||||
add_header Expect-CT "enforce, max-age=604800";
|
||||
|
||||
index index.html index.htm index.php;
|
||||
index index.html index.htm index.php;
|
||||
|
||||
charset utf-8;
|
||||
charset utf-8;
|
||||
|
||||
ssl_certificate /etc/nginx/conf.d/example.com.d/server.crt;
|
||||
ssl_certificate_key /etc/nginx/conf.d/example.com.d/server.key;
|
||||
ssl_trusted_certificate /root/.acme.sh/example.com/fullchain.cer;
|
||||
ssl_certificate /etc/nginx/conf.d/example.com.d/server.crt;
|
||||
ssl_certificate_key /etc/nginx/conf.d/example.com.d/server.key;
|
||||
ssl_trusted_certificate /root/.acme.sh/example.com/fullchain.cer;
|
||||
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_timeout 5m;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384";
|
||||
ssl_ecdh_curve secp384r1;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
ssl_dhparam /etc/nginx/ssl/dhparam.pem;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_timeout 5m;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384";
|
||||
ssl_ecdh_curve secp384r1;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
ssl_dhparam /etc/nginx/ssl/dhparam.pem;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location = /favicon.ico { access_log off; log_not_found off; }
|
||||
location = /robots.txt { access_log off; log_not_found off; }
|
||||
location = /favicon.ico { access_log off; log_not_found off; }
|
||||
location = /robots.txt { access_log off; log_not_found off; }
|
||||
|
||||
error_page 404 /index.php;
|
||||
error_page 404 /index.php;
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
location ~ /\.(?!well-known).* {
|
||||
deny all;
|
||||
}
|
||||
location ~ /\.(?!well-known).* {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -379,9 +438,9 @@ We won't restart nginx yet because it won't be able to find the SSL certificates
|
|||
|
||||
## Installing PHP
|
||||
|
||||
We're going to install the latest version of PHP at the time of writing this - version 8.1
|
||||
We're going to install the latest version of PHP at the time of writing this - version 8.2
|
||||
|
||||
First we need to add the following repository so we can install php8.1.
|
||||
First we need to add the following repository so we can install PHP8.2.
|
||||
|
||||
```bash
|
||||
sudo apt install software-properties-common
|
||||
|
@ -389,21 +448,14 @@ sudo add-apt-repository ppa:ondrej/php
|
|||
sudo apt update
|
||||
```
|
||||
|
||||
Install php8.1 and check the version.
|
||||
Install PHP8.2 and the required extensions.
|
||||
|
||||
```bash
|
||||
sudo apt install php8.1-fpm
|
||||
php-fpm8.1 -v
|
||||
```
|
||||
|
||||
Install some required extensions:
|
||||
|
||||
```bash
|
||||
sudo apt install php8.1-common php8.1-mysql php8.1-dev php8.1-gmp php8.1-mbstring php8.1-dom php8.1-gd php8.1-imagick php8.1-opcache php8.1-soap php8.1-zip php8.1-cli php8.1-curl php8.1-mailparse php8.1-gnupg php8.1-redis -y
|
||||
sudo apt install php8.2-fpm php8.2-common php8.2-mysql php8.2-dev php8.2-gmp php8.2-mbstring php8.2-dom php8.2-gd php8.2-imagick php8.2-opcache php8.2-soap php8.2-zip php8.2-cli php8.2-curl php8.2-mailparse php8.2-gnupg php8.2-redis -y
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo nano /etc/php/8.1/fpm/pool.d/www.conf
|
||||
sudo nano /etc/php/8.2/fpm/pool.d/www.conf
|
||||
```
|
||||
|
||||
```
|
||||
|
@ -413,10 +465,14 @@ listen.owner = johndoe
|
|||
listen.group = johndoe
|
||||
```
|
||||
|
||||
Restart php8.1-fpm to reflect the changes.
|
||||
<div class="flex justify-center mb-4">
|
||||
<img class="shadow" src="https://addy.io/assets/img/php-www-conf.png" alt="PHP www.conf" title="PHP www.conf">
|
||||
</div>
|
||||
|
||||
Restart php8.2-fpm to reflect the changes.
|
||||
|
||||
```bash
|
||||
sudo service php8.1-fpm restart
|
||||
sudo service php8.2-fpm restart
|
||||
```
|
||||
|
||||
## Let's Encrypt
|
||||
|
@ -434,9 +490,12 @@ Download the install script from GitHub and run it. (you can install git by runn
|
|||
```bash
|
||||
cd ~
|
||||
git clone https://github.com/acmesh-official/acme.sh.git
|
||||
cd acme.sh
|
||||
cd ./acme.sh
|
||||
./acme.sh --install
|
||||
```
|
||||
<div class="flex justify-center mb-4">
|
||||
<img class="shadow" src="https://addy.io/assets/img/acme-install.png" alt="acme.sh install" title="acme.sh install">
|
||||
</div>
|
||||
|
||||
You should set up automatic DNS API integration for wildcard certs if you are using them, this will allow automatic renewal of your certificates.
|
||||
|
||||
|
@ -468,31 +527,31 @@ You can now type `exit` to go back to the `johndoe` user instead of `root`.
|
|||
|
||||
## Installing MariaDB
|
||||
|
||||
At the time of writing this the latest stable release is v10.6. Make sure to check for any newer releases.
|
||||
At the time of writing this the latest stable release is v11.1. Make sure to check for any newer releases.
|
||||
|
||||
Follow the instructions on this link to install MariaDB (make sure to change to 18.04 if you are using it):
|
||||
Follow the instructions on this link to install MariaDB:
|
||||
|
||||
[https://downloads.mariadb.org/mariadb/repositories/#distro=Ubuntu&distro_release=focal--ubuntu_focal&mirror=nus&version=10.6](https://downloads.mariadb.org/mariadb/repositories/#distro=Ubuntu&distro_release=focal--ubuntu_focal&mirror=nus&version=10.6)
|
||||
[https://mariadb.org/download/?t=repo-config&d=22.04+%22jammy%22&v=11.1&r_m=starburst](https://mariadb.org/download/?t=repo-config&d=22.04+%22jammy%22&v=11.1&r_m=starburst)
|
||||
|
||||
Make sure it is running correctly and check the version
|
||||
|
||||
```bash
|
||||
sudo systemctl status mariadb
|
||||
sudo mysql -V
|
||||
sudo mariadb -V
|
||||
```
|
||||
|
||||
At the time of writing this I am using "Ver 15.1 Distrib 10.6.3-MariaDB"
|
||||
At the time of writing this I am using "mariadb from 11.1.2-MariaDB, client 15.2"
|
||||
|
||||
When running securing mariadb Answer `no` for "Switch to unix_socket authentication" and `yes` for "Change the root password?" (Set a secure MySQL root password and make a note of it somewhere e.g. password manager.). Answer `yes` (default) to the other questions.
|
||||
|
||||
```bash
|
||||
sudo mysql_secure_installation
|
||||
sudo mariadb-secure-installation
|
||||
```
|
||||
|
||||
Next we're going to create the database and also a user with correct permissions.
|
||||
|
||||
```bash
|
||||
sudo mysql -u root -p
|
||||
sudo mariadb -u root -p
|
||||
```
|
||||
Once in the MariaDB shell create a new database called anonaddy_database (or whatever you like)
|
||||
|
||||
|
@ -539,165 +598,14 @@ This file is responsible for determining whether the server should accept email
|
|||
|
||||
The reason these SQL queries are not all nicely formatted is because they have to be on one line.
|
||||
|
||||
Next create another new file `/etc/postfix/mysql-recipient-access.cf` and enter the following inside:
|
||||
|
||||
```sql
|
||||
user = anonaddy
|
||||
password = your-database-password
|
||||
hosts = 127.0.0.1
|
||||
dbname = anonaddy_database
|
||||
query = CALL check_access('%s')
|
||||
```
|
||||
|
||||
This file is responsible for checking first whether an alias exists, and if so has it been deactivated or deleted. If it has been deactivated or deleted then return 'DISCARD' or 'REJECT'.
|
||||
|
||||
If the alias has not been deactivated or deleted or it does not exist then it also checks whether the alias is for a user, additional username or custom domain and if so, is that additional username or custom domain set as active. If it is not set as active then the email is discarded. It also checks if the user, additional usename or custom domain has catch-all enabled, and if not and the alias does not already exist then the email is rejected.
|
||||
|
||||
The reason we're using a stored procedure here is so that we can run multiple queries and use IF statements.
|
||||
|
||||
Either from the command line (`sudo mysql -u root -p`) or from an SQL client, run the following code to create the stored procedure.
|
||||
|
||||
If you have any issues creating the stored procedure, make sure you have set appropriate permissions for your database user.
|
||||
|
||||
```sql
|
||||
DELIMITER $$
|
||||
|
||||
USE `anonaddy_database`$$
|
||||
|
||||
DROP PROCEDURE IF EXISTS `check_access`$$
|
||||
|
||||
CREATE PROCEDURE `check_access`(alias_email VARCHAR(254) charset utf8)
|
||||
BEGIN
|
||||
DECLARE no_alias_exists int(1);
|
||||
DECLARE alias_action varchar(30) charset utf8;
|
||||
DECLARE username_action varchar(30) charset utf8;
|
||||
DECLARE domain_action varchar(30) charset utf8;
|
||||
DECLARE alias_domain varchar(254) charset utf8;
|
||||
|
||||
SET alias_domain = SUBSTRING_INDEX(alias_email, '@', -1);
|
||||
|
||||
# We only want to carry out the checks if it is a full RCPT TO address without any + extension
|
||||
IF LOCATE('+',alias_email) = 0 THEN
|
||||
|
||||
SET no_alias_exists = CASE WHEN NOT EXISTS(SELECT NULL FROM aliases WHERE email = alias_email) THEN 1 ELSE 0 END;
|
||||
|
||||
# If there is an alias, check if it is deactivated or deleted
|
||||
IF NOT no_alias_exists THEN
|
||||
SET alias_action = (SELECT
|
||||
IF(deleted_at IS NULL,
|
||||
'DISCARD',
|
||||
'REJECT Address does not exist')
|
||||
FROM
|
||||
aliases
|
||||
WHERE
|
||||
email = alias_email
|
||||
AND (active = 0
|
||||
OR deleted_at IS NOT NULL));
|
||||
END IF;
|
||||
|
||||
# If the alias is deactivated or deleted then increment its blocked count and return the alias_action
|
||||
IF alias_action IN('DISCARD','REJECT Address does not exist') THEN
|
||||
UPDATE
|
||||
aliases
|
||||
SET
|
||||
emails_blocked = emails_blocked + 1
|
||||
WHERE
|
||||
email = alias_email;
|
||||
|
||||
SELECT alias_action;
|
||||
ELSE
|
||||
SELECT
|
||||
(
|
||||
SELECT
|
||||
CASE
|
||||
WHEN no_alias_exists
|
||||
AND catch_all = 0 THEN "REJECT Address does not exist"
|
||||
WHEN active = 0 THEN "DISCARD"
|
||||
ELSE NULL
|
||||
END
|
||||
FROM
|
||||
usernames
|
||||
WHERE
|
||||
alias_domain IN ( CONCAT(username, '.example.com')) ),
|
||||
(
|
||||
SELECT
|
||||
CASE
|
||||
WHEN no_alias_exists
|
||||
AND catch_all = 0 THEN "REJECT Address does not exist"
|
||||
WHEN active = 0 THEN "DISCARD"
|
||||
ELSE NULL
|
||||
END
|
||||
FROM
|
||||
domains
|
||||
WHERE
|
||||
domain = alias_domain) INTO username_action, domain_action;
|
||||
|
||||
# If all actions are NULL then we can return 'DUNNO' which will prevent Postfix from trying substrings of the alias
|
||||
IF username_action IS NULL AND domain_action IS NULL THEN
|
||||
SELECT 'DUNNO';
|
||||
ELSEIF username_action IN('DISCARD','REJECT Address does not exist') THEN
|
||||
SELECT username_action;
|
||||
ELSE
|
||||
SELECT domain_action;
|
||||
END IF;
|
||||
END IF;
|
||||
ELSE
|
||||
# This means the alias must have a + extension so we will ignore it
|
||||
SELECT NULL;
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
```
|
||||
|
||||
If you need to add multiple domains then just update both of the IN sections to:
|
||||
|
||||
```sql
|
||||
IN (CONCAT(username, '.example.com'),CONCAT(username, '.example2.com'))
|
||||
```
|
||||
|
||||
You may be wondering why we have this line near the top of the procedure:
|
||||
|
||||
```sql
|
||||
IF LOCATE('+',alias_email) = 0 THEN
|
||||
```
|
||||
|
||||
This is present because Postfix will pass multiple arguments (substrings of the alias) to this stored procedure for each incoming email.
|
||||
|
||||
From the Postfix docs for [check_recipient_access](http://www.postfix.org/postconf.5.html#check_recipient_access):
|
||||
|
||||
> "Search the specified access(5) database for the resolved RCPT TO address, domain, parent domains, or localpart@, and execute the corresponding action."
|
||||
|
||||
What this means is that if an email comes in for the alias - hello+extension@username.example.com then Postfix will run the stored procedure with the following arguments and order:
|
||||
|
||||
```sql
|
||||
CALL check_access('hello+extension@username.example.com');
|
||||
CALL check_access('hello@username.example.com'); # We want it to stop the checks here which is why we return 'DUNNO'
|
||||
CALL check_access('username.example.com');
|
||||
CALL check_access('example.com');
|
||||
CALL check_access('com');
|
||||
CALL check_access('hello@');
|
||||
```
|
||||
|
||||
We only want the queries to be run for the RCPT TO address (hello@username.example.com) without any + extension, which is what the check above does. It also prevents needless database queries being run by returning 'DUNNO' when it finds a match.
|
||||
|
||||
Update the permissions and the group of these files:
|
||||
Update the permissions and the group of this file:
|
||||
|
||||
```bash
|
||||
sudo chmod o= /etc/postfix/mysql-virtual-alias-domains-and-subdomains.cf /etc/postfix/mysql-recipient-access.cf
|
||||
sudo chmod o= /etc/postfix/mysql-virtual-alias-domains-and-subdomains.cf
|
||||
|
||||
sudo chgrp postfix /etc/postfix/mysql-virtual-alias-domains-and-subdomains.cf /etc/postfix/mysql-recipient-access.cf
|
||||
sudo chgrp postfix /etc/postfix/mysql-virtual-alias-domains-and-subdomains.cf
|
||||
```
|
||||
|
||||
Make a test call for the stored procedure as your database user to ensure everything is working as expected.
|
||||
|
||||
```sql
|
||||
USE anonaddy_database;
|
||||
CALL check_access('email@example.com');
|
||||
```
|
||||
|
||||
You will get an error stating "Table 'anonaddy_database.aliases' doesn't exist" as we have not yet migrated the database.
|
||||
|
||||
Let's also restart Postfix now that we have created the files for it:
|
||||
|
||||
```bash
|
||||
|
@ -706,9 +614,16 @@ sudo service postfix restart
|
|||
|
||||
## Installing Redis
|
||||
|
||||
Redis is an advanced key-value store that we will use for caching, sessions, queues and more. To install Redis, run the following commands:
|
||||
Redis is an advanced key-value store that we will use for caching, sessions, queues and more. To install Redis, run the following commands (instructions from [https://redis.io/docs/getting-started/installation/install-redis-on-linux/](https://redis.io/docs/getting-started/installation/install-redis-on-linux/)):
|
||||
|
||||
```bash
|
||||
# Install prerequisites
|
||||
sudo apt install lsb-release curl gpg
|
||||
|
||||
curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg
|
||||
|
||||
echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list
|
||||
|
||||
sudo apt update
|
||||
|
||||
sudo apt install redis-server
|
||||
|
@ -720,7 +635,7 @@ Next edit the Redis config file.
|
|||
sudo nano /etc/redis/redis.conf
|
||||
```
|
||||
|
||||
Find the line with `supervised no` and update it to `supervised systemd`. Also make sure the line `bind 127.0.0.1 ::1` is present and uncommented which binds Redis to localhost.
|
||||
Find the line with `supervised auto` and update it to `supervised systemd`. Also make sure the line `bind 127.0.0.1 -::1` is present and uncommented which binds Redis to localhost.
|
||||
|
||||
Next we will add a strong password for Redis in the same redis.conf file.
|
||||
|
||||
|
@ -729,7 +644,7 @@ Find the line `# requirepass foobared`, uncomment this line and change "foobared
|
|||
Save the file and restart Redis to reflect the changes.
|
||||
|
||||
```bash
|
||||
sudo systemctl restart redis.service
|
||||
sudo service redis-server restart
|
||||
```
|
||||
|
||||
Now run:
|
||||
|
@ -742,22 +657,24 @@ Then type `ping`. You'll be promted for the password we just added. You can ente
|
|||
|
||||
Type `exit` to quit the redis-cli.
|
||||
|
||||
## Rspamd
|
||||
## Installing Rspamd
|
||||
|
||||
Rspamd is a fast, free and open-source spam filtering system. It can also handle DKIM/ARC signing, SPF checks, DMARC checks, DKIM checks, RBLs and much more.
|
||||
|
||||
To install Rspamd run the following commands:
|
||||
To install Rspamd run the following commands (instructions from [https://www.rspamd.com/downloads.html](https://www.rspamd.com/downloads.html)):
|
||||
|
||||
```bash
|
||||
sudo apt install -y lsb-release wget
|
||||
sudo apt install -y lsb-release wget gpg
|
||||
|
||||
CODENAME=`lsb_release -c -s`
|
||||
|
||||
wget -O- https://rspamd.com/apt-stable/gpg.key | sudo apt-key add -
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
|
||||
echo "deb [arch=amd64] http://rspamd.com/apt-stable/ $CODENAME main" | sudo tee -a /etc/apt/sources.list.d/rspamd.list
|
||||
wget -O- https://rspamd.com/apt-stable/gpg.key | gpg --dearmor | sudo tee /etc/apt/keyrings/rspamd.gpg > /dev/null
|
||||
|
||||
echo "deb-src [arch=amd64] http://rspamd.com/apt-stable/ $CODENAME main" | sudo tee -a /etc/apt/sources.list.d/rspamd.list
|
||||
echo "deb [signed-by=/etc/apt/keyrings/rspamd.gpg] http://rspamd.com/apt-stable/ $CODENAME main" | sudo tee /etc/apt/sources.list.d/rspamd.list
|
||||
|
||||
echo "deb-src [signed-by=/etc/apt/keyrings/rspamd.gpg] http://rspamd.com/apt-stable/ $CODENAME main" | sudo tee -a /etc/apt/sources.list.d/rspamd.list
|
||||
|
||||
sudo apt update
|
||||
sudo apt --no-install-recommends install rspamd
|
||||
|
@ -771,6 +688,8 @@ First make a new directory:
|
|||
sudo mkdir /var/lib/rspamd/dkim
|
||||
```
|
||||
|
||||
Change the below command from `example.com` to your domain name:
|
||||
|
||||
```bash
|
||||
sudo rspamadm dkim_keygen -s 'default' -b 2048 -d example.com -k /var/lib/rspamd/dkim/example.com.default.key | sudo tee -a /var/lib/rspamd/dkim/example.com.default.pub
|
||||
```
|
||||
|
@ -818,6 +737,10 @@ TXT * "v=spf1 mx ~all"
|
|||
TXT _dmarc "v=DMARC1; p=none; sp=none; adkim=r; aspf=r; pct=100;"
|
||||
```
|
||||
|
||||
<div class="flex justify-center">
|
||||
<img class="shadow" src="https://addy.io/assets/img/dns-records-2.png" alt="DNS records DMARC" title="DNS records DMARC">
|
||||
</div>
|
||||
|
||||
Now we need to create a signing table to tell Rspamd which domains we want it to sign with DKIM and also which key to use.
|
||||
|
||||
Create a new file `/etc/rspamd/local.d/dkim_signing.conf` and enter the following inside:
|
||||
|
@ -867,7 +790,7 @@ level = "error";
|
|||
debug_modules = [];
|
||||
```
|
||||
|
||||
Create a new file `/etc/rspamd/local.d/greylist.conf` and enter the following inside:
|
||||
If you want to enable greylisting (more details [here](https://www.rspamd.com/doc/modules/greylisting.html)) then create a new file `/etc/rspamd/local.d/greylist.conf` and enter the following inside:
|
||||
|
||||
```
|
||||
servers = "127.0.0.1:6379";
|
||||
|
@ -937,7 +860,7 @@ EOD;
|
|||
|
||||
The authentication results header will give information on whether the message passed SPF, DKIM and DMARC checks and the spam header will be added if it fails any of these.
|
||||
|
||||
The custom routine we've created `add_dmarc_allow_header` will simply add a header to messages that have the `DMARC_POLICY_ALLOW` symbol present in Rspamd. We will use this to only allow replies / sends from aliases that are explicity permitted by their DMARC policy, in order to prevent anyone spoofing any of your recipient's email addresses.
|
||||
The custom routine we've created `add_dmarc_allow_header` will simply add a header to messages that have the `DMARC_POLICY_ALLOW` symbol present Rspamd. We will use this to only allow replies / sends from aliases that are explicity permitted by their DMARC policy, in order to prevent anyone spoofing any of your recipient's email addresses.
|
||||
|
||||
To see the currently enabled modules in Rspamd we can run:
|
||||
|
||||
|
@ -972,7 +895,7 @@ Restart Rspamd to reflect the changes.
|
|||
sudo service rspamd restart
|
||||
```
|
||||
|
||||
You can use view the Rspamd web interface by creating an SSH tunnel by running the following command on you local pc:
|
||||
You can view the Rspamd web interface by creating an SSH tunnel by running the following command on your local pc:
|
||||
|
||||
```bash
|
||||
ssh -L 11334:localhost:11334 johndoe@example.com
|
||||
|
@ -1008,7 +931,6 @@ Restart Rspamd to reflect the changes.
|
|||
sudo service rspamd restart
|
||||
```
|
||||
|
||||
|
||||
## The web application
|
||||
|
||||
Next let's get the actual AnonAddy application from GitHub.
|
||||
|
@ -1029,27 +951,26 @@ sudo php composer-setup.php --install-dir=/usr/local/bin --filename=composer
|
|||
|
||||
Before running the NVM install script below make sure that you have a `~/.bashrc` file. If not create one by running `touch ~/.bashrc` so that the NVM installer can be added to your $PATH. Also create a `~/.bash_profile` and add:
|
||||
|
||||
```
|
||||
```bash
|
||||
if [ -f ~/.bashrc ]; then
|
||||
. ~/.bashrc
|
||||
. ~/.bashrc
|
||||
fi
|
||||
```
|
||||
|
||||
Make sure node is installed (`node -v`) if not then install it using NVM - [https://www.digitalocean.com/community/tutorials/how-to-install-node-js-on-ubuntu-20-04#option-3-%E2%80%94-installing-node-using-the-node-version-manager](https://www.digitalocean.com/community/tutorials/how-to-install-node-js-on-ubuntu-20-04#option-3-%E2%80%94-installing-node-using-the-node-version-manager)
|
||||
Make sure node is installed (`node -v`) if not then install it using NVM - [https://www.digitalocean.com/community/tutorials/how-to-install-node-js-on-ubuntu-22-04#option-3-installing-node-using-the-node-version-manager](https://www.digitalocean.com/community/tutorials/how-to-install-node-js-on-ubuntu-22-04#option-3-installing-node-using-the-node-version-manager)
|
||||
|
||||
At the time of writing this I'm using the latest LTS - v14.17.3
|
||||
At the time of writing this I'm using the latest LTS - v18.18.2
|
||||
|
||||
Next copy the .env.example file and update it with correct values (database password, app url, redis password etc.) then install the dependencies.
|
||||
|
||||
```bash
|
||||
cd /var/www/anonaddy
|
||||
composer install --prefer-dist --no-dev -o && npm install
|
||||
npm run production
|
||||
```
|
||||
|
||||
Next copy the .env.example file and update it with correct values (database password, app url, redis password etc.)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
|
||||
composer install --prefer-dist --no-dev -o && npm install
|
||||
npm run production
|
||||
```
|
||||
|
||||
Make sure to update the database settings, redis password and the AnonAddy variables. You can use Redis for queue, sessions and cache.
|
||||
|
@ -1058,7 +979,7 @@ We'll set `ANONADDY_SIGNING_KEY_FINGERPRINT` shortly.
|
|||
|
||||
`APP_KEY` will be generated in the next step, this is used by Laravel for securely encrypting values.
|
||||
|
||||
For more information on Laravel configuration please visit - [https://laravel.com/docs/8.x/installation#configuration](https://laravel.com/docs/8.x/installation#configuration)
|
||||
For more information on Laravel configuration please visit - [https://laravel.com/docs/10.x/configuration](https://laravel.com/docs/10.x/configuration)
|
||||
|
||||
For the `ANONADDY_DKIM_SIGNING_KEY` you only need to fill in this variable if you plan to add any custom domains through the web application.
|
||||
|
||||
|
@ -1139,6 +1060,10 @@ redirect_stderr=true
|
|||
stopwaitsecs=3600
|
||||
```
|
||||
|
||||
<div class="flex justify-center mb-4">
|
||||
<img class="shadow" src="https://addy.io/assets/img/supervisor.png" alt="Supervisor config" title="Supervisor config">
|
||||
</div>
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
|
@ -1208,7 +1133,6 @@ Then update the value of `ANONADDY_SIGNING_KEY_FINGERPRINT=` in your .env file t
|
|||
|
||||
Then run `php artisan config:cache` to update.
|
||||
|
||||
|
||||
## Setting up a local caching DNS resolver
|
||||
|
||||
This is to speed up queries and to prevent you getting rate limited when querying DNSBLs (DNS black lists) etc.
|
||||
|
@ -1217,10 +1141,6 @@ Follow the below blog post on how to install bind9.
|
|||
|
||||
[https://www.linuxbabe.com/ubuntu/set-up-local-dns-resolver-ubuntu-20-04-bind9](https://www.linuxbabe.com/ubuntu/set-up-local-dns-resolver-ubuntu-20-04-bind9)
|
||||
|
||||
Or if you're using Ubuntu 18.04 then:
|
||||
|
||||
[https://www.linuxbabe.com/ubuntu/set-up-local-dns-resolver-ubuntu-18-04-16-04-bind9](https://www.linuxbabe.com/ubuntu/set-up-local-dns-resolver-ubuntu-18-04-16-04-bind9)
|
||||
|
||||
Now open up `/etc/nginx/conf.d/example.com.conf` and add these two lines below the ssl parameters.
|
||||
|
||||
```
|
||||
|
@ -1228,6 +1148,10 @@ resolver 127.0.0.1 valid=86400s;
|
|||
resolver_timeout 5s;
|
||||
```
|
||||
|
||||
<div class="flex justify-center mb-4">
|
||||
<img class="shadow" src="https://addy.io/assets/img/nginx-resolver.png" alt="Nginx resolver" title="Nginx resolver">
|
||||
</div>
|
||||
|
||||
Restart nginx:
|
||||
|
||||
```bash
|
||||
|
@ -1272,67 +1196,68 @@ Let's add a new Nginx block `/etc/nginx/conf.d/wildcard.example.com.conf`
|
|||
|
||||
```
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
|
||||
server_name *.example.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
server_name *.example.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name *.example.com;
|
||||
server_tokens off;
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'";
|
||||
add_header Referrer-Policy "origin-when-cross-origin";
|
||||
add_header Expect-CT "enforce, max-age=604800";
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name *.example.com;
|
||||
server_tokens off;
|
||||
http2 on;
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'";
|
||||
add_header Referrer-Policy "origin-when-cross-origin";
|
||||
add_header Expect-CT "enforce, max-age=604800";
|
||||
|
||||
index index.html;
|
||||
index index.html;
|
||||
|
||||
charset utf-8;
|
||||
charset utf-8;
|
||||
|
||||
ssl_certificate /etc/nginx/conf.d/example.com.d/server.crt;
|
||||
ssl_certificate_key /etc/nginx/conf.d/example.com.d/server.key;
|
||||
ssl_trusted_certificate /root/.acme.sh/example.com/fullchain.cer;
|
||||
ssl_certificate /etc/nginx/conf.d/example.com.d/server.crt;
|
||||
ssl_certificate_key /etc/nginx/conf.d/example.com.d/server.key;
|
||||
ssl_trusted_certificate /root/.acme.sh/example.com/fullchain.cer;
|
||||
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_timeout 5m;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384";
|
||||
ssl_ecdh_curve secp384r1;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
ssl_dhparam /etc/nginx/ssl/dhparam.pem;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_timeout 5m;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384";
|
||||
ssl_ecdh_curve secp384r1;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
ssl_dhparam /etc/nginx/ssl/dhparam.pem;
|
||||
|
||||
location / {
|
||||
add_header Content-Type text/plain;
|
||||
return 200 'Hello world';
|
||||
}
|
||||
location / {
|
||||
add_header Content-Type text/plain;
|
||||
return 200 'Hello world';
|
||||
}
|
||||
|
||||
location = /favicon.ico { return 204; access_log off; log_not_found off; }
|
||||
location = /robots.txt { access_log off; log_not_found off; }
|
||||
location = /favicon.ico { return 204; access_log off; log_not_found off; }
|
||||
location = /robots.txt { access_log off; log_not_found off; }
|
||||
|
||||
location ~ /\.(?!well-known).* {
|
||||
deny all;
|
||||
}
|
||||
location ~ /\.(?!well-known).* {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location ^~ /.well-known/mta-sts.txt {
|
||||
try_files $uri @mta-sts;
|
||||
}
|
||||
location @mta-sts {
|
||||
add_header Content-Type text/plain;
|
||||
return 200 "version: STSv1
|
||||
mode: enforce
|
||||
max_age: 10368000
|
||||
mx: mail.example.com\n";
|
||||
}
|
||||
location ^~ /.well-known/mta-sts.txt {
|
||||
try_files $uri @mta-sts;
|
||||
}
|
||||
location @mta-sts {
|
||||
add_header Content-Type text/plain;
|
||||
return 200 "version: STSv1
|
||||
mode: enforce
|
||||
max_age: 10368000
|
||||
mx: mail.example.com\n";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -1404,7 +1329,6 @@ CAA @ 0 issuewild "letsencrypt.org"
|
|||
CAA @ 0 iodef "mailto:caapolicy@example.com"
|
||||
```
|
||||
|
||||
|
||||
## Updating
|
||||
|
||||
Before updating, **please check the release notes** on [GitHub](https://github.com/anonaddy/anonaddy/releases) for any **breaking changes**.
|
||||
|
@ -1432,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.
|
||||
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.
|
||||
|
|
|
@ -15,12 +15,6 @@ class RegisterKeyStore extends ActionsRegisterKeyStore
|
|||
{
|
||||
/**
|
||||
* Register a new key.
|
||||
*
|
||||
* @param Authenticatable $user
|
||||
* @param PublicKeyCredentialCreationOptions $publicKey
|
||||
* @param string $data
|
||||
* @param string $keyName
|
||||
* @return WebauthnKey|null
|
||||
*/
|
||||
public function __invoke(Authenticatable $user, PublicKeyCredentialCreationOptions $publicKey, string $data, string $keyName): ?WebauthnKey
|
||||
{
|
||||
|
|
|
@ -39,21 +39,22 @@ class CheckDomainsMxValidation extends Command
|
|||
*/
|
||||
public function handle()
|
||||
{
|
||||
Domain::all()
|
||||
->each(function ($domain) {
|
||||
try {
|
||||
if (! $domain->checkMxRecords()) {
|
||||
// Notify user via email only if domain's MX previously were valid
|
||||
if (!is_null($domain->domain_mx_validated_at)) {
|
||||
$domain->user->notify(new DomainMxRecordsInvalid($domain->domain));
|
||||
}
|
||||
Domain::with('user.defaultUsername')
|
||||
->get()
|
||||
->each(function ($domain) {
|
||||
try {
|
||||
if (! $domain->checkMxRecords()) {
|
||||
// Notify user via email only if domain's MX previously were valid
|
||||
if (! is_null($domain->domain_mx_validated_at)) {
|
||||
$domain->user->notify(new DomainMxRecordsInvalid($domain->domain));
|
||||
}
|
||||
|
||||
$domain->domain_mx_validated_at = null;
|
||||
$domain->save();
|
||||
$domain->domain_mx_validated_at = null;
|
||||
$domain->save();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
//
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
//
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,7 +39,8 @@ class CheckDomainsSendingVerification extends Command
|
|||
*/
|
||||
public function handle()
|
||||
{
|
||||
Domain::whereNotNull('domain_sending_verified_at')->get()
|
||||
Domain::with('user.defaultUsername')
|
||||
->whereNotNull('domain_sending_verified_at')->get()
|
||||
->each(function ($domain) {
|
||||
try {
|
||||
$result = $domain->checkVerificationForSending();
|
||||
|
|
|
@ -4,6 +4,7 @@ namespace App\Console\Commands;
|
|||
|
||||
use App\Models\FailedDelivery;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ClearFailedDeliveries extends Command
|
||||
{
|
||||
|
@ -38,6 +39,14 @@ class ClearFailedDeliveries extends Command
|
|||
*/
|
||||
public function handle()
|
||||
{
|
||||
FailedDelivery::where('created_at', '<=', now()->subDays(3))->delete();
|
||||
FailedDelivery::where('created_at', '<=', now()->subDays(7))->delete();
|
||||
|
||||
// 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()) {
|
||||
Storage::disk('local')->delete($file['path']);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
35
app/Console/Commands/ClearOutboundMessages.php
Normal file
35
app/Console/Commands/ClearOutboundMessages.php
Normal file
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\OutboundMessage;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ClearOutboundMessages extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'anonaddy:clear-outbound-messages';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Clears outbound messages that are older than 7 days';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
OutboundMessage::where('created_at', '<', now()->subDays(7))->delete();
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\PostfixQueueId;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ClearPostfixQueueIds extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'anonaddy:clear-postfix-queue-ids';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Clears postfix queue ids that are older than 7 days';
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
PostfixQueueId::where('created_at', '<=', now()->subDays(7))->delete();
|
||||
}
|
||||
}
|
|
@ -49,40 +49,41 @@ class CreateUser extends Command
|
|||
{
|
||||
$validator = Validator::make([
|
||||
'username' => $this->argument('username'),
|
||||
'email' => $this->argument('email')], [
|
||||
'username' => [
|
||||
'required',
|
||||
'regex:/^[a-zA-Z0-9]*$/',
|
||||
'max:20',
|
||||
'unique:usernames,username',
|
||||
new NotDeletedUsername()
|
||||
],
|
||||
'email' => [
|
||||
'required',
|
||||
'email:rfc,dns',
|
||||
'max:254',
|
||||
new RegisterUniqueRecipient(),
|
||||
new NotLocalRecipient()
|
||||
],
|
||||
]);
|
||||
'email' => $this->argument('email'), ], [
|
||||
'username' => [
|
||||
'required',
|
||||
'regex:/^[a-zA-Z0-9]*$/',
|
||||
'max:20',
|
||||
'unique:usernames,username',
|
||||
new NotDeletedUsername,
|
||||
],
|
||||
'email' => [
|
||||
'required',
|
||||
'email:rfc,dns',
|
||||
'max:254',
|
||||
new RegisterUniqueRecipient,
|
||||
new NotLocalRecipient,
|
||||
],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($errors->all() as $message) {
|
||||
$this->error($message);
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
$userId = Uuid::uuid4();
|
||||
|
||||
$recipient = Recipient::create([
|
||||
'email' => $this->argument('email'),
|
||||
'user_id' => $userId
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
$username = Username::create([
|
||||
'username' => $this->argument('username'),
|
||||
'user_id' => $userId
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
$twoFactor = app('pragmarx.google2fa');
|
||||
|
@ -92,7 +93,7 @@ class CreateUser extends Command
|
|||
'default_username_id' => $username->id,
|
||||
'default_recipient_id' => $recipient->id,
|
||||
'password' => Hash::make($userId),
|
||||
'two_factor_secret' => $twoFactor->generateSecretKey()
|
||||
'two_factor_secret' => $twoFactor->generateSecretKey(),
|
||||
]);
|
||||
|
||||
event(new Registered($user));
|
||||
|
|
63
app/Console/Commands/EmailUsersWithTokenExpiringSoon.php
Normal file
63
app/Console/Commands/EmailUsersWithTokenExpiringSoon.php
Normal file
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\TokenExpiringSoon;
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class EmailUsersWithTokenExpiringSoon extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'anonaddy:email-users-with-token-expiring-soon';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Send an email to users who have an API token that is expiring soon';
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
User::with(['defaultUsername', 'defaultRecipient', 'tokens'])
|
||||
->whereHas('tokens', function ($query) {
|
||||
$query->whereDate('expires_at', now()->addWeek());
|
||||
})
|
||||
->get()
|
||||
->each(function (User $user) {
|
||||
$this->sendTokenExpiringSoonMail($user);
|
||||
});
|
||||
}
|
||||
|
||||
protected function sendTokenExpiringSoonMail(User $user)
|
||||
{
|
||||
try {
|
||||
Mail::to($user->email)->send(new TokenExpiringSoon($user));
|
||||
} catch (Exception $exception) {
|
||||
$this->error("exception when sending mail to user: {$user->username}", $exception);
|
||||
report($exception);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -81,7 +81,7 @@ class ListUsers extends Command
|
|||
'username' => $user->defaultUsername->username,
|
||||
'bandwidth' => $user->bandwidth,
|
||||
'created_at' => $user->created_at,
|
||||
'updated_at' => $user->updated_at
|
||||
'updated_at' => $user->updated_at,
|
||||
];
|
||||
});
|
||||
|
||||
|
@ -95,7 +95,6 @@ class ListUsers extends Command
|
|||
/**
|
||||
* Display the user information on the console.
|
||||
*
|
||||
* @param array $users
|
||||
* @return void
|
||||
*/
|
||||
protected function displayUsers(array $users)
|
||||
|
@ -135,7 +134,6 @@ class ListUsers extends Command
|
|||
/**
|
||||
* Parse the column list.
|
||||
*
|
||||
* @param array $columns
|
||||
* @return array
|
||||
*/
|
||||
protected function parseColumns(array $columns)
|
||||
|
|
|
@ -8,8 +8,7 @@ use App\Mail\SendFromEmail;
|
|||
use App\Models\Alias;
|
||||
use App\Models\Domain;
|
||||
use App\Models\EmailData;
|
||||
use App\Models\PostfixQueueId;
|
||||
use App\Models\Recipient;
|
||||
use App\Models\OutboundMessage;
|
||||
use App\Models\Username;
|
||||
use App\Notifications\DisallowedReplySendAttempt;
|
||||
use App\Notifications\FailedDeliveryNotification;
|
||||
|
@ -19,8 +18,11 @@ use Illuminate\Console\Command;
|
|||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use ParagonIE\ConstantTime\Base32;
|
||||
use PhpMimeMailParser\Parser;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
class ReceiveEmail extends Command
|
||||
{
|
||||
|
@ -44,10 +46,15 @@ class ReceiveEmail extends Command
|
|||
* @var string
|
||||
*/
|
||||
protected $description = 'Receive email from postfix pipe';
|
||||
|
||||
protected $parser;
|
||||
|
||||
protected $senderFrom;
|
||||
|
||||
protected $size;
|
||||
|
||||
protected $rawEmail;
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
|
@ -68,26 +75,61 @@ 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();
|
||||
|
||||
$this->size = $this->option('size') / ($recipientCount ? $recipientCount : 1);
|
||||
|
||||
foreach ($recipients as $recipient) {
|
||||
// Handle bounces
|
||||
if ($this->option('sender') === 'MAILER-DAEMON') {
|
||||
$this->handleBounce($recipient['email']);
|
||||
// Check if VERP bounce
|
||||
if (substr($recipient['email'], 0, 2) === 'b_') {
|
||||
if ($outboundMessageId = $this->getIdFromVerp($recipient['email'])) {
|
||||
// Is a valid bounce
|
||||
$outboundMessage = OutboundMessage::with(['user', 'alias', 'recipient'])->find($outboundMessageId);
|
||||
|
||||
if (is_null($outboundMessage)) {
|
||||
// Must have been more than 7 days
|
||||
Log::info('VERP outboundMessage not found');
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$bouncedAlias = $outboundMessage->alias;
|
||||
|
||||
// If already bounced then forward to the user instead
|
||||
if (! $outboundMessage->bounced) {
|
||||
$this->handleBounce($outboundMessage);
|
||||
}
|
||||
|
||||
if (in_array(strtolower($this->parser->getHeader('Auto-Submitted')), ['auto-replied', 'auto-generated']) && ! in_array($outboundMessage->email_type, ['R', 'S'])) {
|
||||
Log::info('VERP auto-response to forward/notification, username: '.$outboundMessage->user?->username.' outboundMessageID: '.$outboundMessageId);
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// If it is a notification then there is no alias so exit and log, may be an auto-reply to a notification.
|
||||
if (is_null($bouncedAlias)) {
|
||||
Log::info('VERP previously bounced/auto-response to notification, username: '.$outboundMessage->user?->username.' outboundMessageID: '.$outboundMessageId);
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// If it is not a bounce (could be auto-reply) then redirect to alias
|
||||
$recipient['email'] = $bouncedAlias->email;
|
||||
$recipient['local_part'] = $bouncedAlias->local_part;
|
||||
$recipient['domain'] = $bouncedAlias->domain;
|
||||
}
|
||||
}
|
||||
|
||||
// First determine if the alias already exists in the database
|
||||
if ($alias = Alias::firstWhere('email', $recipient['local_part'] . '@' . $recipient['domain'])) {
|
||||
if ($alias = Alias::firstWhere('email', $recipient['local_part'].'@'.$recipient['domain'])) {
|
||||
$user = $alias->user;
|
||||
|
||||
if ($alias->aliasable_id) {
|
||||
|
@ -96,22 +138,23 @@ class ReceiveEmail extends Command
|
|||
} else {
|
||||
// Does not exist, must be a standard, username or custom domain alias
|
||||
$parentDomain = collect(config('anonaddy.all_domains'))
|
||||
->filter(function ($name) use ($recipient) {
|
||||
return Str::endsWith($recipient['domain'], $name);
|
||||
})
|
||||
->first();
|
||||
->filter(function ($name) use ($recipient) {
|
||||
return Str::endsWith($recipient['domain'], $name);
|
||||
})
|
||||
->first();
|
||||
|
||||
if (!empty($parentDomain)) {
|
||||
if (! empty($parentDomain)) {
|
||||
// It is standard or username alias
|
||||
$subdomain = substr($recipient['domain'], 0, strrpos($recipient['domain'], '.' . $parentDomain)); // e.g. johndoe
|
||||
$subdomain = substr($recipient['domain'], 0, strrpos($recipient['domain'], '.'.$parentDomain)); // e.g. johndoe
|
||||
|
||||
if ($subdomain === 'unsubscribe') {
|
||||
$this->handleUnsubscribe($recipient);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is an username or standard alias
|
||||
if (!empty($subdomain)) {
|
||||
if (! empty($subdomain)) {
|
||||
$username = Username::where('username', $subdomain)->first();
|
||||
$user = $username->user;
|
||||
$aliasable = $username;
|
||||
|
@ -124,13 +167,13 @@ class ReceiveEmail extends Command
|
|||
}
|
||||
}
|
||||
|
||||
if (!isset($user) && !empty(config('anonaddy.admin_username'))) {
|
||||
if (! isset($user) && ! empty(config('anonaddy.admin_username'))) {
|
||||
$user = Username::where('username', config('anonaddy.admin_username'))->first()?->user;
|
||||
}
|
||||
}
|
||||
|
||||
// If there is still no user or the user has no verified default recipient then continue.
|
||||
if (!isset($user) || !$user->hasVerifiedDefaultRecipient()) {
|
||||
if (! isset($user) || ! $user->hasVerifiedDefaultRecipient()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
@ -141,36 +184,41 @@ class ReceiveEmail extends Command
|
|||
// Check whether this email is a reply/send from or a new email to be forwarded.
|
||||
$destination = Str::replaceLast('=', '@', $recipient['extension']);
|
||||
$validEmailDestination = filter_var($destination, FILTER_VALIDATE_EMAIL);
|
||||
$verifiedRecipient = $user->getVerifiedRecipientByEmail($this->senderFrom);
|
||||
if ($validEmailDestination) {
|
||||
$verifiedRecipient = $user->getVerifiedRecipientByEmail($this->senderFrom);
|
||||
} else {
|
||||
$verifiedRecipient = null;
|
||||
}
|
||||
|
||||
if ($validEmailDestination && $verifiedRecipient?->can_reply_send) {
|
||||
if ($verifiedRecipient?->can_reply_send) {
|
||||
// Check if the Dmarc allow or spam headers are present from Rspamd
|
||||
if (! $this->parser->getHeader('X-AnonAddy-Dmarc-Allow') || $this->parser->getHeader('X-AnonAddy-Spam')) {
|
||||
if (! $this->parser->getHeader('X-AnonAddy-Dmarc-Allow')) {
|
||||
// Notify user and exit
|
||||
$verifiedRecipient->notify(new SpamReplySendAttempt($recipient, $this->senderFrom, $this->parser->getHeader('X-AnonAddy-Authentication-Results')));
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if ($this->parser->getHeader('In-Reply-To')) {
|
||||
$this->handleReply($user, $recipient);
|
||||
if ($this->parser->getHeader('In-Reply-To') && $alias) {
|
||||
$this->handleReply($user, $alias, $validEmailDestination);
|
||||
} else {
|
||||
$this->handleSendFrom($user, $recipient, $aliasable ?? null);
|
||||
$this->handleSendFrom($user, $recipient, $alias ?? null, $aliasable ?? null, $validEmailDestination);
|
||||
}
|
||||
} elseif ($validEmailDestination && $verifiedRecipient?->can_reply_send === false) {
|
||||
} elseif ($verifiedRecipient?->can_reply_send === false) {
|
||||
// Notify user that they have not allowed this recipient to reply and send from aliases
|
||||
$verifiedRecipient->notify(new DisallowedReplySendAttempt($recipient, $this->senderFrom, $this->parser->getHeader('X-AnonAddy-Authentication-Results')));
|
||||
|
||||
exit(0);
|
||||
} else {
|
||||
$this->handleForward($user, $recipient, $aliasable ?? null);
|
||||
// Check if the spam header is present from Rspamd
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
@ -184,60 +232,49 @@ class ReceiveEmail extends Command
|
|||
}
|
||||
}
|
||||
|
||||
protected function handleReply($user, $recipient)
|
||||
protected function handleReply($user, $alias, $destination)
|
||||
{
|
||||
$alias = $user->aliases()->where('email', $recipient['local_part'] . '@' . $recipient['domain'])->first();
|
||||
$emailData = new EmailData($this->parser, $this->option('sender'), $this->size, 'R');
|
||||
|
||||
if ($alias) {
|
||||
$sendTo = Str::replaceLast('=', '@', $recipient['extension']);
|
||||
$message = new ReplyToEmail($user, $alias, $emailData);
|
||||
|
||||
$emailData = new EmailData($this->parser, $this->size);
|
||||
|
||||
$message = new ReplyToEmail($user, $alias, $emailData);
|
||||
|
||||
Mail::to($sendTo)->queue($message);
|
||||
}
|
||||
Mail::to($destination)->queue($message);
|
||||
}
|
||||
|
||||
protected function handleSendFrom($user, $recipient, $aliasable)
|
||||
protected function handleSendFrom($user, $recipient, $alias, $aliasable, $destination)
|
||||
{
|
||||
$alias = $user->aliases()->withTrashed()->firstOrNew([
|
||||
'email' => $recipient['local_part'] . '@' . $recipient['domain'],
|
||||
'local_part' => $recipient['local_part'],
|
||||
'domain' => $recipient['domain'],
|
||||
'aliasable_id' => $aliasable->id ?? null,
|
||||
'aliasable_type' => $aliasable ? 'App\\Models\\' . class_basename($aliasable) : null
|
||||
]);
|
||||
if (is_null($alias)) {
|
||||
$alias = $user->aliases()->create([
|
||||
'email' => $recipient['local_part'].'@'.$recipient['domain'],
|
||||
'local_part' => $recipient['local_part'],
|
||||
'domain' => $recipient['domain'],
|
||||
'aliasable_id' => $aliasable?->id,
|
||||
'aliasable_type' => $aliasable ? 'App\\Models\\'.class_basename($aliasable) : null,
|
||||
]);
|
||||
|
||||
// This is a new alias but at a shared domain or the sender is not a verified recipient.
|
||||
if (!isset($alias->id) && in_array($recipient['domain'], config('anonaddy.all_domains'))) {
|
||||
exit(0);
|
||||
// Hydrate all alias fields
|
||||
$alias->refresh();
|
||||
}
|
||||
|
||||
$alias->save();
|
||||
$alias->refresh();
|
||||
|
||||
$sendTo = Str::replaceLast('=', '@', $recipient['extension']);
|
||||
|
||||
$emailData = new EmailData($this->parser, $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, $aliasable)
|
||||
protected function handleForward($user, $recipient, $alias, $aliasable, $isSpam)
|
||||
{
|
||||
$alias = $user->aliases()->withTrashed()->firstOrNew([
|
||||
'email' => $recipient['local_part'] . '@' . $recipient['domain'],
|
||||
'local_part' => $recipient['local_part'],
|
||||
'domain' => $recipient['domain'],
|
||||
'aliasable_id' => $aliasable->id ?? null,
|
||||
'aliasable_type' => $aliasable ? 'App\\Models\\' . class_basename($aliasable) : null
|
||||
]);
|
||||
if (is_null($alias)) {
|
||||
// This is a new alias
|
||||
$alias = new Alias([
|
||||
'email' => $recipient['local_part'].'@'.$recipient['domain'],
|
||||
'local_part' => $recipient['local_part'],
|
||||
'domain' => $recipient['domain'],
|
||||
'aliasable_id' => $aliasable?->id,
|
||||
'aliasable_type' => $aliasable ? 'App\\Models\\'.class_basename($aliasable) : null,
|
||||
]);
|
||||
|
||||
if (!isset($alias->id)) {
|
||||
// This is a new alias.
|
||||
if ($user->hasExceededNewAliasLimit()) {
|
||||
$this->error('4.2.1 New aliases per hour limit exceeded for user.');
|
||||
|
||||
|
@ -250,35 +287,38 @@ class ReceiveEmail extends Command
|
|||
$keys = explode('.', $recipient['extension']);
|
||||
|
||||
$recipientIds = $user
|
||||
->recipients()
|
||||
->oldest()
|
||||
->get()
|
||||
->filter(function ($item, $key) use ($keys) {
|
||||
return in_array($key+1, $keys) && !is_null($item['email_verified_at']);
|
||||
})
|
||||
->pluck('id')
|
||||
->take(10)
|
||||
->toArray();
|
||||
->recipients()
|
||||
->select(['id', 'email_verified_at'])
|
||||
->oldest()
|
||||
->get()
|
||||
->filter(function ($item, $key) use ($keys) {
|
||||
return in_array($key + 1, $keys) && ! is_null($item['email_verified_at']);
|
||||
})
|
||||
->pluck('id')
|
||||
->take(10)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
$user->aliases()->save($alias);
|
||||
|
||||
// Hydrate all alias fields
|
||||
$alias->refresh();
|
||||
|
||||
if (isset($recipientIds)) {
|
||||
$alias->recipients()->sync($recipientIds);
|
||||
}
|
||||
}
|
||||
|
||||
$alias->save();
|
||||
$alias->refresh();
|
||||
$emailData = new EmailData($this->parser, $this->option('sender'), $this->size);
|
||||
|
||||
if (isset($recipientIds)) {
|
||||
$alias->recipients()->sync($recipientIds);
|
||||
}
|
||||
|
||||
$emailData = new EmailData($this->parser, $this->size);
|
||||
|
||||
$alias->verifiedRecipientsOrDefault()->each(function ($recipient) use ($alias, $emailData) {
|
||||
$message = new ForwardEmail($alias, $emailData, $recipient);
|
||||
$alias->verifiedRecipientsOrDefault()->each(function ($recipient) use ($alias, $emailData, $isSpam) {
|
||||
$message = (new ForwardEmail($alias, $emailData, $recipient, $isSpam));
|
||||
|
||||
Mail::to($recipient->email)->queue($message);
|
||||
});
|
||||
}
|
||||
|
||||
protected function handleBounce($returnPath)
|
||||
protected function handleBounce($outboundMessage)
|
||||
{
|
||||
// Collect the attachments
|
||||
$attachments = collect($this->parser->getAttachments());
|
||||
|
@ -288,126 +328,115 @@ class ReceiveEmail extends Command
|
|||
return $attachment->getContentType() === 'message/delivery-status';
|
||||
})->first();
|
||||
|
||||
if ($deliveryReport) {
|
||||
$dsn = $this->parseDeliveryStatus($deliveryReport->getMimePartStr());
|
||||
// Is not a bounce, may be an auto-reply so return
|
||||
if (! $deliveryReport) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify queue ID
|
||||
if (isset($dsn['X-postfix-queue-id'])) {
|
||||
// First check in DB
|
||||
$postfixQueueId = PostfixQueueId::firstWhere('queue_id', strtoupper($dsn['X-postfix-queue-id']));
|
||||
// Mark the outboundMessage as bounced
|
||||
$outboundMessage->markAsBounced();
|
||||
|
||||
if (!$postfixQueueId) {
|
||||
exit(0);
|
||||
$dsn = $this->parseDeliveryStatus($deliveryReport->getMimePartStr());
|
||||
|
||||
// Get the bounced email address
|
||||
$bouncedEmailAddress = isset($dsn['Final-recipient']) ? trim(Str::after($dsn['Final-recipient'], ';')) : null;
|
||||
|
||||
$remoteMta = isset($dsn['Remote-mta']) ? trim(Str::after($dsn['Remote-mta'], ';')) : '';
|
||||
|
||||
if (isset($dsn['Diagnostic-code']) && isset($dsn['Status'])) {
|
||||
// Try to determine the bounce type, HARD, SPAM, SOFT
|
||||
$bounceType = $this->getBounceType($dsn['Diagnostic-code'], $dsn['Status']);
|
||||
|
||||
$diagnosticCode = 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));
|
||||
}
|
||||
|
||||
// If found then delete from DB
|
||||
$postfixQueueId->delete();
|
||||
} else {
|
||||
exit(0);
|
||||
$status = trim(Str::substr($status, 0, 5));
|
||||
}
|
||||
}
|
||||
|
||||
// Get the bounced email address
|
||||
$bouncedEmailAddress = isset($dsn['Final-recipient']) ? trim(Str::after($dsn['Final-recipient'], ';')) : '';
|
||||
// Get the undelivered message
|
||||
$undeliveredMessage = $attachments->filter(function ($attachment) {
|
||||
return in_array($attachment->getContentType(), ['text/rfc822-headers', 'message/rfc822']);
|
||||
})->first();
|
||||
|
||||
$remoteMta = isset($dsn['Remote-mta']) ? trim(Str::after($dsn['Remote-mta'], ';')) : '';
|
||||
$undeliveredMessageHeaders = [];
|
||||
|
||||
if (isset($dsn['Diagnostic-code']) && isset($dsn['Status'])) {
|
||||
// Try to determine the bounce type, HARD, SPAM, SOFT
|
||||
$bounceType = $this->getBounceType($dsn['Diagnostic-code'], $dsn['Status']);
|
||||
if ($undeliveredMessage) {
|
||||
$undeliveredMessageHeaders = $this->parseDeliveryStatus($undeliveredMessage->getMimePartStr());
|
||||
}
|
||||
|
||||
$diagnosticCode = Str::limit($dsn['Diagnostic-code'], 497);
|
||||
} else {
|
||||
$bounceType = null;
|
||||
$diagnosticCode = null;
|
||||
}
|
||||
// Get bounce user information
|
||||
$user = $outboundMessage->user;
|
||||
$alias = $outboundMessage->alias;
|
||||
$recipient = $outboundMessage->recipient;
|
||||
$emailType = $outboundMessage->getRawOriginal('email_type');
|
||||
|
||||
// The return path is the alias except when it is from an unverified custom domain
|
||||
if ($returnPath !== config('anonaddy.return_path')) {
|
||||
$alias = Alias::withTrashed()->firstWhere('email', $returnPath);
|
||||
|
||||
if (isset($alias)) {
|
||||
$user = $alias->user;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find a user from the bounced email address
|
||||
if ($recipient = Recipient::select(['id', 'user_id', 'email', 'email_verified_at'])->get()->firstWhere('email', $bouncedEmailAddress)) {
|
||||
if (!isset($user)) {
|
||||
$user = $recipient->user;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the undelivered message
|
||||
$undeliveredMessage = $attachments->filter(function ($attachment) {
|
||||
return in_array($attachment->getContentType(), ['text/rfc822-headers', 'message/rfc822']);
|
||||
})->first();
|
||||
|
||||
$undeliveredMessageHeaders = [];
|
||||
if ($user) {
|
||||
$failedDeliveryId = Uuid::uuid4();
|
||||
|
||||
if ($undeliveredMessage) {
|
||||
$undeliveredMessageHeaders = $this->parseDeliveryStatus($undeliveredMessage->getMimePartStr());
|
||||
|
||||
if (isset($undeliveredMessageHeaders['Feedback-id'])) {
|
||||
$parts = explode(':', $undeliveredMessageHeaders['Feedback-id']);
|
||||
|
||||
if (in_array($parts[0], ['F', 'R', 'S']) && !isset($alias)) {
|
||||
$alias = Alias::find($parts[1]);
|
||||
|
||||
// Find the user from the alias if we don't have it from the recipient
|
||||
if (!isset($user) && isset($alias)) {
|
||||
$user = $alias->user;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if failed delivery notification or Alias deactivated notification and if so do not notify the user again
|
||||
if (! in_array($parts[0], ['FDN'])) {
|
||||
if (isset($recipient)) {
|
||||
// Notify recipient of failed delivery, check that $recipient address is verified
|
||||
if ($recipient->email_verified_at) {
|
||||
$recipient->notify(new FailedDeliveryNotification($alias->email ?? null, $undeliveredMessageHeaders['X-anonaddy-original-sender'] ?? null, $undeliveredMessageHeaders['Subject'] ?? null));
|
||||
}
|
||||
} elseif (in_array($parts[0], ['R', 'S']) && isset($user)) {
|
||||
if ($user->email_verified_at) {
|
||||
$user->defaultRecipient->notify(new FailedDeliveryNotification($alias->email ?? null, $undeliveredMessageHeaders['X-anonaddy-original-sender'] ?? null, $undeliveredMessageHeaders['Subject'] ?? null));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Store the undelivered message if enabled by user. Do not store email verification notifications.
|
||||
if ($user->store_failed_deliveries && ! in_array($emailType, ['VR', 'VU'])) {
|
||||
$isStored = Storage::disk('local')->put("{$failedDeliveryId}.eml", $this->trimUndeliveredMessage($undeliveredMessage->getMimePartStr()));
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($user)) {
|
||||
$failedDelivery = $user->failedDeliveries()->create([
|
||||
'recipient_id' => $recipient->id ?? null,
|
||||
'alias_id' => $alias->id ?? null,
|
||||
'bounce_type' => $bounceType,
|
||||
'remote_mta' => $remoteMta ?? null,
|
||||
'sender' => $undeliveredMessageHeaders['X-anonaddy-original-sender'] ?? null,
|
||||
'email_type' => $parts[0] ?? null,
|
||||
'status' => $dsn['Status'] ?? null,
|
||||
'code' => $diagnosticCode,
|
||||
'attempted_at' => $postfixQueueId->created_at
|
||||
]);
|
||||
$failedDelivery = $user->failedDeliveries()->create([
|
||||
'id' => $failedDeliveryId,
|
||||
'recipient_id' => $recipient->id ?? null,
|
||||
'alias_id' => $alias->id ?? null,
|
||||
'is_stored' => $isStored ?? false,
|
||||
'bounce_type' => $bounceType,
|
||||
'remote_mta' => $remoteMta ?? null,
|
||||
'sender' => $undeliveredMessageHeaders['X-anonaddy-original-sender'] ?? null,
|
||||
'destination' => $bouncedEmailAddress,
|
||||
'email_type' => $emailType,
|
||||
'status' => $status ?? null,
|
||||
'code' => $diagnosticCode,
|
||||
'attempted_at' => $outboundMessage->created_at,
|
||||
]);
|
||||
|
||||
if (isset($alias)) {
|
||||
// Decrement the alias forward count due to failed delivery
|
||||
if ($failedDelivery->email_type === 'F' && $alias->emails_forwarded > 0) {
|
||||
$alias->decrement('emails_forwarded');
|
||||
}
|
||||
|
||||
if ($failedDelivery->email_type === 'R' && $alias->emails_replied > 0) {
|
||||
$alias->decrement('emails_replied');
|
||||
}
|
||||
|
||||
if ($failedDelivery->email_type === 'S' && $alias->emails_sent > 0) {
|
||||
$alias->decrement('emails_sent');
|
||||
}
|
||||
// Check the aliases failed deliveries
|
||||
if ($alias) {
|
||||
// Decrement the alias forward count due to failed delivery
|
||||
if ($failedDelivery->getRawOriginal('email_type') === 'F' && $alias->emails_forwarded > 0) {
|
||||
$alias->decrement('emails_forwarded');
|
||||
}
|
||||
} else {
|
||||
Log::info([
|
||||
'info' => 'user not found from bounce report',
|
||||
'deliveryReport' => $deliveryReport,
|
||||
'undeliveredMessage' => $undeliveredMessage,
|
||||
]);
|
||||
|
||||
if ($failedDelivery->getRawOriginal('email_type') === 'R' && $alias->emails_replied > 0) {
|
||||
$alias->decrement('emails_replied');
|
||||
}
|
||||
|
||||
if ($failedDelivery->getRawOriginal('email_type') === 'S' && $alias->emails_sent > 0) {
|
||||
$alias->decrement('emails_sent');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log::info('User not found from outbound message, may have been deleted.');
|
||||
}
|
||||
|
||||
// Check if the bounce is a Failed delivery notification and if so do not notify the user again
|
||||
if (! in_array($emailType, ['FDN'])) {
|
||||
|
||||
$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, $undeliveredMessageHeaders['X-anonaddy-original-sender'] ?? null, $undeliveredMessageHeaders['Subject'] ?? null, $failedDelivery?->is_stored, $user?->store_failed_deliveries, $recipient?->email));
|
||||
|
||||
Log::info('FDN '.$emailType.': '.$notifiable->email);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -417,13 +446,15 @@ class ReceiveEmail extends Command
|
|||
protected function checkBandwidthLimit($user)
|
||||
{
|
||||
if ($user->hasReachedBandwidthLimit()) {
|
||||
$user->update(['reject_until' => now()->endOfMonth()]);
|
||||
|
||||
$this->error('4.2.1 Bandwidth limit exceeded for user. Please try again later.');
|
||||
|
||||
exit(1);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
@ -435,9 +466,10 @@ class ReceiveEmail extends Command
|
|||
->allow(config('anonaddy.limit'))
|
||||
->every(3600)
|
||||
->then(
|
||||
function () {
|
||||
},
|
||||
function () {
|
||||
function () {},
|
||||
function () use ($user) {
|
||||
$user->update(['defer_until' => now()->addHour()]);
|
||||
|
||||
$this->error('4.2.1 Rate limit exceeded for user. Please try again later.');
|
||||
|
||||
exit(1);
|
||||
|
@ -452,14 +484,14 @@ class ReceiveEmail extends Command
|
|||
'email' => $item,
|
||||
'local_part' => strtolower($this->option('local_part')[$key]),
|
||||
'extension' => $this->option('extension')[$key],
|
||||
'domain' => strtolower($this->option('domain')[$key])
|
||||
'domain' => strtolower($this->option('domain')[$key]),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
|
@ -472,7 +504,7 @@ class ReceiveEmail extends Command
|
|||
try {
|
||||
mailparse_rfc822_parse_addresses($value);
|
||||
} catch (\Exception $e) {
|
||||
$part['headers']['from'] = str_replace("\\", "", $part['headers']['from']);
|
||||
$part['headers']['from'] = str_replace('\\', '', $part['headers']['from']);
|
||||
$mimePart->setPart($part);
|
||||
}
|
||||
}
|
||||
|
@ -480,10 +512,10 @@ class ReceiveEmail extends Command
|
|||
return $next($mimePart);
|
||||
});
|
||||
|
||||
if ($file == 'stream') {
|
||||
if ($file === 'stream') {
|
||||
$fd = fopen('php://stdin', 'r');
|
||||
$this->rawEmail = '';
|
||||
while (!feof($fd)) {
|
||||
while (! feof($fd)) {
|
||||
$this->rawEmail .= fread($fd, 1024);
|
||||
}
|
||||
fclose($fd);
|
||||
|
@ -491,6 +523,7 @@ class ReceiveEmail extends Command
|
|||
} else {
|
||||
$parser->setPath($file);
|
||||
}
|
||||
|
||||
return $parser;
|
||||
}
|
||||
|
||||
|
@ -508,20 +541,31 @@ class ReceiveEmail extends Command
|
|||
$result[$key] = trim($matches[2]);
|
||||
}
|
||||
} elseif (preg_match('/^\s+(.+)\s*/', $line) && isset($key)) {
|
||||
$result[$key] .= ' ' . $line;
|
||||
$result[$key] .= ' '.$line;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected function trimUndeliveredMessage($message)
|
||||
{
|
||||
return Str::after($message, 'Content-Type: message/rfc822'.PHP_EOL.PHP_EOL);
|
||||
}
|
||||
|
||||
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)) {
|
||||
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';
|
||||
}
|
||||
|
||||
|
@ -536,12 +580,48 @@ class ReceiveEmail extends Command
|
|||
protected function getSenderFrom()
|
||||
{
|
||||
try {
|
||||
return $this->parser->getAddresses('from')[0]['address'];
|
||||
// Ensure contains '@', may be malformed header which causes sends/replies to fail
|
||||
$address = $this->parser->getAddresses('from')[0]['address'];
|
||||
|
||||
return Str::contains($address, '@') && filter_var($address, FILTER_VALIDATE_EMAIL) ? $address : $this->option('sender');
|
||||
} catch (\Exception $e) {
|
||||
return $this->option('sender');
|
||||
}
|
||||
}
|
||||
|
||||
protected function getIdFromVerp($verp)
|
||||
{
|
||||
$localPart = Str::beforeLast($verp, '@');
|
||||
|
||||
$parts = explode('_', $localPart);
|
||||
|
||||
if (count($parts) !== 3) {
|
||||
Log::channel('single')->info('VERP invalid email: '.$verp);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$id = Base32::decodeNoPadding($parts[1]);
|
||||
|
||||
$signature = Base32::decodeNoPadding($parts[2]);
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('single')->info('VERP base32 decode failure: '.$verp.' '.$e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$expectedSignature = substr(hash_hmac('sha3-224', $id, config('anonaddy.secret')), 0, 8);
|
||||
|
||||
if ($signature !== $expectedSignature) {
|
||||
Log::channel('single')->info('VERP invalid signature: '.$verp);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
protected function exitIfFromSelf()
|
||||
{
|
||||
// To prevent recipient alias infinite nested looping.
|
||||
|
|
|
@ -38,6 +38,6 @@ class ResetBandwidth extends Command
|
|||
*/
|
||||
public function handle()
|
||||
{
|
||||
User::where('bandwidth', '>', 0)->update(['bandwidth' => 0]);
|
||||
User::where('bandwidth', '>', 0)->update(['bandwidth' => 0, 'reject_until' => null, 'defer_until' => null]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ class UpdateAppVersion extends Command
|
|||
public function handle()
|
||||
{
|
||||
$version = GitVersionHelper::cacheFreshVersion();
|
||||
$this->info("AnonAddy version: {$version}");
|
||||
$this->info("addy.io version: {$version}");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
|
|
@ -1,46 +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.
|
||||
*
|
||||
* @param \Illuminate\Console\Scheduling\Schedule $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-postfix-queue-ids')->hourly();
|
||||
$schedule->command('auth:clear-resets')->daily();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the commands for the application.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function commands()
|
||||
{
|
||||
$this->load(__DIR__.'/Commands');
|
||||
|
||||
require base_path('routes/console.php');
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@ class CustomMailManager extends MailManager
|
|||
/**
|
||||
* Resolve the given mailer.
|
||||
*
|
||||
* @param string $name
|
||||
* @param string $name
|
||||
* @return Mailer
|
||||
*/
|
||||
protected function resolve($name): CustomMailer
|
||||
|
|
|
@ -4,32 +4,40 @@ namespace App\CustomMailDriver;
|
|||
|
||||
use App\CustomMailDriver\Mime\Crypto\AlreadyEncrypted;
|
||||
use App\CustomMailDriver\Mime\Crypto\OpenPGPEncrypter;
|
||||
use App\Models\PostfixQueueId;
|
||||
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\Database\QueryException;
|
||||
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\Mailer\Exception\RuntimeException;
|
||||
use Symfony\Component\Mime\Crypto\DkimOptions;
|
||||
use Symfony\Component\Mime\Crypto\DkimSigner;
|
||||
use Symfony\Component\Mime\Email;
|
||||
|
||||
class CustomMailer extends Mailer
|
||||
{
|
||||
private $data;
|
||||
|
||||
/**
|
||||
* Send a new message using a view.
|
||||
*
|
||||
* @param MailableContract|string|array $view
|
||||
* @param array $data
|
||||
* @param MailableContract|string|array $view
|
||||
* @param \Closure|string|null $callback
|
||||
* @return SentMessage|null
|
||||
*/
|
||||
public function send($view, array $data = [], $callback = null)
|
||||
{
|
||||
$this->data = $data;
|
||||
|
||||
if ($view instanceof MailableContract) {
|
||||
return $this->sendMailable($view);
|
||||
}
|
||||
|
@ -67,18 +75,20 @@ class CustomMailer extends Mailer
|
|||
$recipient = Recipient::find($data['recipientId']);
|
||||
|
||||
try {
|
||||
$encrypter = new OpenPGPEncrypter(config('anonaddy.signing_key_fingerprint'), $data['fingerprint'], "~/.gnupg", $recipient->protected_headers);
|
||||
} catch (RuntimeException $e) {
|
||||
$encrypter = new OpenPGPEncrypter(config('anonaddy.signing_key_fingerprint'), $data['fingerprint'], '~/.gnupg', $recipient->protected_headers);
|
||||
|
||||
$encryptedSymfonyMessage = $recipient->inline_encryption ? $encrypter->encryptInline($symfonyMessage) : $encrypter->encrypt($symfonyMessage);
|
||||
} catch (Exception $e) {
|
||||
info($e->getMessage());
|
||||
$encrypter = null;
|
||||
$encryptedSymfonyMessage = null;
|
||||
|
||||
$recipient->update(['should_encrypt' => false]);
|
||||
|
||||
$recipient->notify(new GpgKeyExpired());
|
||||
$recipient->notify(new GpgKeyExpired);
|
||||
}
|
||||
|
||||
if ($encrypter) {
|
||||
$symfonyMessage = $recipient->inline_encryption ? $encrypter->encryptInline($symfonyMessage) : $encrypter->encrypt($symfonyMessage);
|
||||
if ($encryptedSymfonyMessage) {
|
||||
$symfonyMessage = $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',
|
||||
|
@ -114,30 +125,116 @@ class CustomMailer extends Mailer
|
|||
'Subject',
|
||||
'Date',
|
||||
'Original-Sender',
|
||||
'Sender'
|
||||
'Sender',
|
||||
'Received',
|
||||
])->toArray();
|
||||
$signedEmail = $dkimSigner->sign($symfonyMessage, $options);
|
||||
$symfonyMessage->setHeaders($signedEmail->getHeaders());
|
||||
}
|
||||
|
||||
if ($this->shouldSendMessage($symfonyMessage, $data)) {
|
||||
$symfonySentMessage = $this->sendSymfonyMessage($symfonyMessage);
|
||||
// Set VERP address
|
||||
$id = randomString(12);
|
||||
$verpLocalPart = $this->getVerpLocalPart($id);
|
||||
|
||||
// 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'])) {
|
||||
$symfonyMessage->returnPath($verpLocalPart.'@'.$data['verpDomain']);
|
||||
} else {
|
||||
$symfonyMessage->returnPath($verpLocalPart.'@'.config('anonaddy.domain'));
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
$this->dispatchSentEvent($sentMessage, $data);
|
||||
|
||||
try {
|
||||
// Get Postfix Queue ID and save in DB
|
||||
$id = str_replace("\r\n", "", Str::after($sentMessage->getDebug(), 'Ok: queued as '));
|
||||
// Create a new Outbound Message for verifying any bounces
|
||||
if (isset($data['userId']) && ! is_null($data['userId']) && isset($data['emailType']) && ! is_null($data['emailType'])) {
|
||||
|
||||
PostfixQueueId::create([
|
||||
'queue_id' => $id
|
||||
]);
|
||||
} catch (QueryException $e) {
|
||||
// duplicate entry
|
||||
//Log::info('Failed to save Postfix Queue ID: ' . $id);
|
||||
try {
|
||||
OutboundMessage::create([
|
||||
'id' => $id,
|
||||
'user_id' => $data['userId'],
|
||||
'alias_id' => $data['aliasId'] ?? null,
|
||||
'recipient_id' => $data['recipientId'] ?? null,
|
||||
'email_type' => $data['emailType'],
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
return $sentMessage;
|
||||
|
@ -148,17 +245,30 @@ class CustomMailer extends Mailer
|
|||
/**
|
||||
* Send a Symfony Email instance.
|
||||
*
|
||||
* @param \Symfony\Component\Mime\Email $message
|
||||
* @return \Symfony\Component\Mailer\SentMessage|null
|
||||
*/
|
||||
protected function sendSymfonyMessage(Email $message)
|
||||
{
|
||||
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
|
||||
|
@ -172,4 +282,38 @@ class CustomMailer extends Mailer
|
|||
//
|
||||
}
|
||||
}
|
||||
|
||||
protected function getVerpLocalPart($id)
|
||||
{
|
||||
$hmac = hash_hmac('sha3-224', $id, config('anonaddy.secret'));
|
||||
$hmacPayload = substr($hmac, 0, 8);
|
||||
$encodedPayload = Base32::encodeUnpadded($id);
|
||||
$encodedSignature = Base32::encodeUnpadded($hmacPayload);
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ use Symfony\Component\Mime\Email;
|
|||
class OpenPGPEncrypter
|
||||
{
|
||||
protected $gnupg = null;
|
||||
|
||||
protected $usesProtectedHeaders;
|
||||
|
||||
/**
|
||||
|
@ -53,18 +54,17 @@ class OpenPGPEncrypter
|
|||
*/
|
||||
protected $gnupgHome = null;
|
||||
|
||||
|
||||
public function __construct($signingKey = null, $recipientKey = null, $gnupgHome = null, $usesProtectedHeaders = false)
|
||||
{
|
||||
$this->initGNUPG();
|
||||
$this->signingKey = $signingKey;
|
||||
$this->signingKey = $signingKey;
|
||||
$this->recipientKey = $recipientKey;
|
||||
$this->gnupgHome = $gnupgHome;
|
||||
$this->gnupgHome = $gnupgHome;
|
||||
$this->usesProtectedHeaders = $usesProtectedHeaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $micalg
|
||||
* @param string $micalg
|
||||
*/
|
||||
public function setMicalg($micalg)
|
||||
{
|
||||
|
@ -72,15 +72,14 @@ class OpenPGPEncrypter
|
|||
}
|
||||
|
||||
/**
|
||||
* @param $identifier
|
||||
* @param null $passPhrase
|
||||
* @param null $passPhrase
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function addSignature($identifier, $keyFingerprint = null, $passPhrase = null)
|
||||
{
|
||||
if (!$keyFingerprint) {
|
||||
$keyFingerprint = $this->getKey($identifier, 'sign');
|
||||
if (! $keyFingerprint) {
|
||||
$keyFingerprint = $this->getKey($identifier, 'sign');
|
||||
}
|
||||
$this->signingKey = $keyFingerprint;
|
||||
|
||||
|
@ -90,49 +89,36 @@ class OpenPGPEncrypter
|
|||
}
|
||||
|
||||
/**
|
||||
* @param $identifier
|
||||
* @param $passPhrase
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function addKeyPassphrase($identifier, $passPhrase)
|
||||
{
|
||||
$keyFingerprint = $this->getKey($identifier, 'sign');
|
||||
$keyFingerprint = $this->getKey($identifier, 'sign');
|
||||
$this->keyPassphrases[$keyFingerprint] = $passPhrase;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Email $email
|
||||
*
|
||||
* @param Email $email
|
||||
* @return $this
|
||||
*
|
||||
* @throws RuntimeException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function encrypt(Email $message): Email
|
||||
public function encrypt(Email $symfonyMessage): Email
|
||||
{
|
||||
$originalMessage = clone $message;
|
||||
$originalMessage = clone $symfonyMessage;
|
||||
// Clone to ensure headers are not altered if encryption fails
|
||||
$message = clone $symfonyMessage;
|
||||
|
||||
$headers = $message->getPreparedHeaders();
|
||||
|
||||
$boundary = strtr(base64_encode(random_bytes(6)), '+/', '-_');
|
||||
|
||||
$headers->setHeaderBody('Parameterized', 'Content-Type', 'multipart/signed');
|
||||
$headers->setHeaderParameter('Content-Type', 'micalg', sprintf("pgp-%s", strtolower($this->micalg)));
|
||||
$headers->setHeaderParameter('Content-Type', 'protocol', 'application/pgp-signature');
|
||||
$headers->setHeaderBody('Parameterized', 'Content-Type', 'multipart/encrypted');
|
||||
$headers->setHeaderParameter('Content-Type', 'protocol', 'application/pgp-encrypted');
|
||||
$headers->setHeaderParameter('Content-Type', 'boundary', $boundary);
|
||||
|
||||
$message->setHeaders($headers);
|
||||
|
||||
if (!$this->signingKey) {
|
||||
foreach ($message->getFrom() as $key => $value) {
|
||||
$this->addSignature($this->getKey($key, 'sign'));
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->signingKey) {
|
||||
throw new RuntimeException('Signing has been enabled, but no signature has been added. Use autoAddSignature() or addSignature()');
|
||||
}
|
||||
|
||||
// If the email does not have any text part then we need to add a text/plain legacy display part
|
||||
if ($this->usesProtectedHeaders && is_null($originalMessage->getTextBody())) {
|
||||
$originalMessage->text($headers->get('Subject')->toString());
|
||||
|
@ -143,7 +129,7 @@ class OpenPGPEncrypter
|
|||
// Check if using protected headers or not
|
||||
if ($this->usesProtectedHeaders) {
|
||||
$protectedHeadersSet = false;
|
||||
for ($i=0; $i<count($lines); $i++) {
|
||||
for ($i = 0; $i < count($lines); $i++) {
|
||||
if (Str::startsWith(strtolower($lines[$i]), 'content-type: text/plain') || Str::startsWith(strtolower($lines[$i]), 'content-type: multipart/')) {
|
||||
$lines[$i] = rtrim($lines[$i])."; protected-headers=\"v1\"\r\n";
|
||||
if (! $protectedHeadersSet) {
|
||||
|
@ -155,40 +141,16 @@ class OpenPGPEncrypter
|
|||
}
|
||||
}
|
||||
} else {
|
||||
for ($i=0; $i<count($lines); $i++) {
|
||||
for ($i = 0; $i < count($lines); $i++) {
|
||||
$lines[$i] = rtrim($lines[$i])."\r\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Remove excess trailing newlines (RFC3156 section 5.4)
|
||||
$signedBody = rtrim(implode('', $lines))."\r\n";
|
||||
$originalBody = rtrim(implode('', $lines))."\r\n";
|
||||
|
||||
$signature = $this->pgpSignString($signedBody, $this->signingKey);
|
||||
|
||||
// Fixes DKIM signature incorrect body hash for custom domains
|
||||
$body = "This is an OpenPGP/MIME signed message (RFC 4880 and 3156)\r\n\r\n";
|
||||
$body .= "--{$boundary}\r\n";
|
||||
$body .= $signedBody."\r\n";
|
||||
$body .= "--{$boundary}\r\n";
|
||||
$body .= "Content-Type: application/pgp-signature; name=\"signature.asc\"\r\n";
|
||||
$body .= "Content-Description: OpenPGP digital signature\r\n";
|
||||
$body .= "Content-Disposition: attachment; filename=\"signature.asc\"\r\n\r\n";
|
||||
$body .= $signature."\r\n\r\n";
|
||||
$body .= "--{$boundary}--";
|
||||
|
||||
$signed = sprintf("%s\r\n%s", $message->getHeaders()->get('content-type')->toString(), $body);
|
||||
|
||||
if (!$this->recipientKey) {
|
||||
throw new RuntimeException('Encryption has been enabled, but no recipients have been added. Use autoAddRecipients() or addRecipient()');
|
||||
}
|
||||
|
||||
//Create body from signed message
|
||||
$encryptedBody = $this->pgpEncryptString($signed, $this->recipientKey);
|
||||
|
||||
$headers->setHeaderBody('Parameterized', 'Content-Type', 'multipart/encrypted');
|
||||
$headers->setHeaderParameter('Content-Type', 'micalg', null);
|
||||
$headers->setHeaderParameter('Content-Type', 'protocol', 'application/pgp-encrypted');
|
||||
$headers->setHeaderParameter('Content-Type', 'boundary', $boundary);
|
||||
// Create encrypted body from original message
|
||||
$encryptedBody = $this->pgpEncryptAndSignString($originalBody, $this->recipientKey, $this->signingKey);
|
||||
|
||||
// Fixes DKIM signature incorrect body hash for custom domains
|
||||
$body = "This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)\r\n\r\n";
|
||||
|
@ -207,38 +169,37 @@ class OpenPGPEncrypter
|
|||
}
|
||||
|
||||
/**
|
||||
* @param Email $email
|
||||
*
|
||||
* @param Email $email
|
||||
* @return $this
|
||||
*
|
||||
* @throws RuntimeException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function encryptInline(Email $message): Email
|
||||
public function encryptInline(Email $symfonyMessage): Email
|
||||
{
|
||||
if (!$this->signingKey) {
|
||||
foreach ($message->getFrom() as $key => $value) {
|
||||
if (! $this->signingKey) {
|
||||
foreach ($symfonyMessage->getFrom() as $key => $value) {
|
||||
$this->addSignature($this->getKey($key, 'sign'));
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->signingKey) {
|
||||
if (! $this->signingKey) {
|
||||
throw new RuntimeException('Signing has been enabled, but no signature has been added. Use autoAddSignature() or addSignature()');
|
||||
}
|
||||
|
||||
if (!$this->recipientKey) {
|
||||
if (! $this->recipientKey) {
|
||||
throw new RuntimeException('Encryption has been enabled, but no recipients have been added. Use autoAddRecipients() or addRecipient()');
|
||||
}
|
||||
|
||||
$body = $message->getTextBody() ?? '';
|
||||
$body = $symfonyMessage->getTextBody() ?? '';
|
||||
|
||||
$text = $this->pgpEncryptAndSignString($body, $this->recipientKey, $this->signingKey);
|
||||
|
||||
$headers = $message->getPreparedHeaders();
|
||||
$headers = $symfonyMessage->getPreparedHeaders();
|
||||
$headers->setHeaderBody('Parameterized', 'Content-Type', 'text/plain');
|
||||
$headers->setHeaderParameter('Content-Type', 'charset', 'utf-8');
|
||||
$message->setHeaders($headers);
|
||||
$symfonyMessage->setHeaders($headers);
|
||||
|
||||
return $message->setBody(new EncryptedPart($text));
|
||||
return $symfonyMessage->setBody(new EncryptedPart($text));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -246,91 +207,35 @@ class OpenPGPEncrypter
|
|||
*/
|
||||
protected function initGNUPG()
|
||||
{
|
||||
if (!class_exists('gnupg')) {
|
||||
if (! class_exists('gnupg')) {
|
||||
throw new RuntimeException('PHPMailerPGP requires the GnuPG class');
|
||||
}
|
||||
|
||||
if (!$this->gnupgHome && isset($_SERVER['HOME'])) {
|
||||
$this->gnupgHome = $_SERVER['HOME'] . '/.gnupg';
|
||||
if (! $this->gnupgHome && isset($_SERVER['HOME'])) {
|
||||
$this->gnupgHome = $_SERVER['HOME'].'/.gnupg';
|
||||
}
|
||||
|
||||
if (!$this->gnupgHome && getenv('HOME')) {
|
||||
$this->gnupgHome = getenv('HOME') . '/.gnupg';
|
||||
if (! $this->gnupgHome && getenv('HOME')) {
|
||||
$this->gnupgHome = getenv('HOME').'/.gnupg';
|
||||
}
|
||||
|
||||
if (!$this->gnupg) {
|
||||
$this->gnupg = new \gnupg();
|
||||
if (! $this->gnupg) {
|
||||
$this->gnupg = new \gnupg;
|
||||
}
|
||||
|
||||
$this->gnupg->seterrormode(\gnupg::ERROR_EXCEPTION);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $plaintext
|
||||
* @param $keyFingerprint
|
||||
*
|
||||
* @param $plaintext
|
||||
* @param $keyFingerprints
|
||||
* @return string
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
protected function pgpSignString($plaintext, $keyFingerprint)
|
||||
protected function pgpEncryptAndSignString($text, $keyFingerprint, $signingKeyFingerprint)
|
||||
{
|
||||
if (isset($this->keyPassphrases[$keyFingerprint]) && !$this->keyPassphrases[$keyFingerprint]) {
|
||||
$passPhrase = $this->keyPassphrases[$keyFingerprint];
|
||||
} else {
|
||||
$passPhrase = null;
|
||||
}
|
||||
|
||||
$this->gnupg->clearsignkeys();
|
||||
$this->gnupg->addsignkey($keyFingerprint, $passPhrase);
|
||||
$this->gnupg->setsignmode(\gnupg::SIG_MODE_DETACH);
|
||||
$this->gnupg->setarmor(1);
|
||||
|
||||
$signed = $this->gnupg->sign($plaintext);
|
||||
|
||||
if ($signed) {
|
||||
return $signed;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Unable to sign message (perhaps the secret key is encrypted with a passphrase?)');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $plaintext
|
||||
* @param $keyFingerprints
|
||||
*
|
||||
* @return string
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
protected function pgpEncryptString($plaintext, $keyFingerprint)
|
||||
{
|
||||
$this->gnupg->clearencryptkeys();
|
||||
|
||||
$this->gnupg->addencryptkey($keyFingerprint);
|
||||
|
||||
$this->gnupg->setarmor(1);
|
||||
|
||||
$encrypted = $this->gnupg->encrypt($plaintext);
|
||||
|
||||
if ($encrypted) {
|
||||
return $encrypted;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Unable to encrypt message');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $plaintext
|
||||
* @param $keyFingerprints
|
||||
*
|
||||
* @return string
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
protected function pgpEncryptAndSignString($plaintext, $keyFingerprint, $signingKeyFingerprint)
|
||||
{
|
||||
if (isset($this->keyPassphrases[$signingKeyFingerprint]) && !$this->keyPassphrases[$signingKeyFingerprint]) {
|
||||
if (isset($this->keyPassphrases[$signingKeyFingerprint]) && ! $this->keyPassphrases[$signingKeyFingerprint]) {
|
||||
$passPhrase = $this->keyPassphrases[$signingKeyFingerprint];
|
||||
} else {
|
||||
$passPhrase = null;
|
||||
|
@ -342,7 +247,7 @@ class OpenPGPEncrypter
|
|||
$this->gnupg->addencryptkey($keyFingerprint);
|
||||
$this->gnupg->setarmor(1);
|
||||
|
||||
$encrypted = $this->gnupg->encryptsign($plaintext);
|
||||
$encrypted = $this->gnupg->encryptsign($text);
|
||||
|
||||
if ($encrypted) {
|
||||
return $encrypted;
|
||||
|
@ -352,16 +257,13 @@ class OpenPGPEncrypter
|
|||
}
|
||||
|
||||
/**
|
||||
* @param $identifier
|
||||
* @param $purpose
|
||||
*
|
||||
* @return string
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
protected function getKey($identifier, $purpose)
|
||||
{
|
||||
$keys = $this->gnupg->keyinfo($identifier);
|
||||
$keys = $this->gnupg->keyinfo($identifier);
|
||||
$fingerprints = [];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
|
@ -388,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']));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ final class RawContentEncoder implements ContentEncoderInterface
|
|||
{
|
||||
public function encodeByteStream($stream, int $maxLineLength = 0): iterable
|
||||
{
|
||||
while (!feof($stream)) {
|
||||
while (! feof($stream)) {
|
||||
yield fread($stream, 8192);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,16 +13,20 @@ class EncryptedPart extends AbstractPart
|
|||
protected $_headers;
|
||||
|
||||
private $body;
|
||||
|
||||
private $charset;
|
||||
|
||||
private $subtype;
|
||||
|
||||
/**
|
||||
* @var ?string
|
||||
*/
|
||||
private $disposition;
|
||||
|
||||
private $seekable;
|
||||
|
||||
/**
|
||||
* @param resource|string $body
|
||||
* @param resource|string $body
|
||||
*/
|
||||
public function __construct($body, ?string $charset = 'utf-8', string $subtype = 'plain')
|
||||
{
|
||||
|
@ -30,14 +34,14 @@ class EncryptedPart extends AbstractPart
|
|||
|
||||
parent::__construct();
|
||||
|
||||
if (!\is_string($body) && !\is_resource($body)) {
|
||||
if (! \is_string($body) && ! \is_resource($body)) {
|
||||
throw new \TypeError(sprintf('The body of "%s" must be a string or a resource (got "%s").', self::class, get_debug_type($body)));
|
||||
}
|
||||
|
||||
$this->body = $body;
|
||||
$this->charset = $charset;
|
||||
$this->subtype = $subtype;
|
||||
$this->seekable = \is_resource($body) ? stream_get_meta_data($body)['seekable'] && 0 === fseek($body, 0, \SEEK_CUR) : null;
|
||||
$this->seekable = \is_resource($body) ? stream_get_meta_data($body)['seekable'] && fseek($body, 0, \SEEK_CUR) === 0 : null;
|
||||
}
|
||||
|
||||
public function getMediaType(): string
|
||||
|
@ -52,7 +56,7 @@ class EncryptedPart extends AbstractPart
|
|||
|
||||
public function getBody(): string
|
||||
{
|
||||
if (null === $this->seekable) {
|
||||
if ($this->seekable === null) {
|
||||
return $this->body;
|
||||
}
|
||||
|
||||
|
@ -70,7 +74,7 @@ class EncryptedPart extends AbstractPart
|
|||
|
||||
public function bodyToIterable(): iterable
|
||||
{
|
||||
if (null !== $this->seekable) {
|
||||
if ($this->seekable !== null) {
|
||||
if ($this->seekable) {
|
||||
rewind($this->body);
|
||||
}
|
||||
|
@ -82,16 +86,16 @@ class EncryptedPart extends AbstractPart
|
|||
|
||||
public function getPreparedHeaders(): Headers
|
||||
{
|
||||
return clone new Headers();
|
||||
return clone new Headers;
|
||||
}
|
||||
|
||||
public function asDebugString(): string
|
||||
{
|
||||
$str = parent::asDebugString();
|
||||
if (null !== $this->charset) {
|
||||
if ($this->charset !== null) {
|
||||
$str .= ' charset: '.$this->charset;
|
||||
}
|
||||
if (null !== $this->disposition) {
|
||||
if ($this->disposition !== null) {
|
||||
$str .= ' disposition: '.$this->disposition;
|
||||
}
|
||||
|
||||
|
@ -100,13 +104,13 @@ class EncryptedPart extends AbstractPart
|
|||
|
||||
private function getEncoder(): ContentEncoderInterface
|
||||
{
|
||||
return new RawContentEncoder();
|
||||
return new RawContentEncoder;
|
||||
}
|
||||
|
||||
public function __sleep(): array
|
||||
{
|
||||
// convert resources to strings for serialization
|
||||
if (null !== $this->seekable) {
|
||||
if ($this->seekable !== null) {
|
||||
$this->body = $this->getBody();
|
||||
}
|
||||
|
||||
|
|
|
@ -19,6 +19,21 @@ class InlineImagePart extends DataPart
|
|||
return $this;
|
||||
}
|
||||
|
||||
public function getContentId(): string
|
||||
{
|
||||
return $this->cid ?: $this->cid = $this->generateContentId();
|
||||
}
|
||||
|
||||
public function hasContentId(): bool
|
||||
{
|
||||
return $this->cid !== null;
|
||||
}
|
||||
|
||||
private function generateContentId(): string
|
||||
{
|
||||
return bin2hex(random_bytes(16)).'@symfony';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the name of the file.
|
||||
*
|
||||
|
@ -35,11 +50,11 @@ class InlineImagePart extends DataPart
|
|||
{
|
||||
$headers = parent::getPreparedHeaders();
|
||||
|
||||
if (null !== $this->cid) {
|
||||
if ($this->cid !== null) {
|
||||
$headers->setHeaderBody('Id', 'Content-ID', $this->cid);
|
||||
}
|
||||
|
||||
if (null !== $this->filename) {
|
||||
if ($this->filename !== null) {
|
||||
$headers->setHeaderParameter('Content-Disposition', 'filename', $this->filename);
|
||||
}
|
||||
|
||||
|
|
|
@ -33,6 +33,7 @@ class MessageIDValidation implements EmailValidation
|
|||
// }
|
||||
} catch (\Exception $invalid) {
|
||||
$this->error = new InvalidEmail(new ExceptionFound($invalid), '');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
15
app/Enums/DisplayFromFormat.php
Normal file
15
app/Enums/DisplayFromFormat.php
Normal file
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum DisplayFromFormat: int
|
||||
{
|
||||
case DEFAULT = 0;
|
||||
case BRACKETS = 1;
|
||||
case DOMAIN = 2;
|
||||
case NAME = 3;
|
||||
case ADDRESS = 4;
|
||||
case NONE = 5;
|
||||
case DOMAINONLY = 6;
|
||||
case LEGACY = 7;
|
||||
}
|
12
app/Enums/LoginRedirect.php
Normal file
12
app/Enums/LoginRedirect.php
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum LoginRedirect: int
|
||||
{
|
||||
case DEFAULT = 0;
|
||||
case ALIASES = 1;
|
||||
case RECIPIENTS = 2;
|
||||
case USERNAMES = 3;
|
||||
case DOMAINS = 4;
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||
|
||||
class Handler extends ExceptionHandler
|
||||
{
|
||||
/**
|
||||
* A list of the exception types that are not reported.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $dontReport = [
|
||||
//
|
||||
];
|
||||
|
||||
/**
|
||||
* A list of the inputs that are never flashed for validation exceptions.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $dontFlash = [
|
||||
'password',
|
||||
'password_confirmation',
|
||||
];
|
||||
|
||||
/**
|
||||
* Register the exception handling callbacks for the application.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
|
@ -8,8 +8,8 @@ use Maatwebsite\Excel\Concerns\WithHeadings;
|
|||
class AliasesExport implements FromCollection, WithHeadings
|
||||
{
|
||||
/**
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function collection()
|
||||
{
|
||||
return user()->aliases()->withTrashed()->get();
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
function user()
|
||||
{
|
||||
|
@ -11,3 +12,31 @@ function carbon(...$args)
|
|||
{
|
||||
return new Carbon(...$args);
|
||||
}
|
||||
|
||||
function randomString(int $length): string
|
||||
{
|
||||
$alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
$str = '';
|
||||
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$index = random_int(0, 35);
|
||||
$str .= $alphabet[$index];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -9,6 +9,10 @@ class AliasExportController extends Controller
|
|||
{
|
||||
public function export()
|
||||
{
|
||||
return Excel::download(new AliasesExport(), 'aliases-'.now()->toDateString().'.csv');
|
||||
if (! user()->allAliases()->count()) {
|
||||
return back()->withErrors(['aliases_export' => 'You don\'t have any aliases to export.']);
|
||||
}
|
||||
|
||||
return Excel::download(new AliasesExport, 'aliases-'.now()->toDateString().'.csv');
|
||||
}
|
||||
}
|
||||
|
|
41
app/Http/Controllers/AliasImportController.php
Normal file
41
app/Http/Controllers/AliasImportController.php
Normal file
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ImportAliasesRequest;
|
||||
use App\Imports\AliasesImport;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Maatwebsite\Excel\HeadingRowImport;
|
||||
|
||||
class AliasImportController extends Controller
|
||||
{
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('throttle:1,1'); // Limit to 1 upload per minute
|
||||
}
|
||||
|
||||
public function import(ImportAliasesRequest $request)
|
||||
{
|
||||
try {
|
||||
$import = new AliasesImport(user());
|
||||
|
||||
$headings = (new HeadingRowImport)->toCollection($request->file('aliases_import'))->flatten();
|
||||
|
||||
// Validate the heading row
|
||||
if (($headings->diff(['alias', 'description', 'recipients'])->count() || $headings->count() !== 3) && ! App::environment('testing')) {
|
||||
return back()->withErrors(['aliases_import' => 'The aliases import file has invalid headers, please use the template provided above.']);
|
||||
}
|
||||
|
||||
$import->queue($request->file('aliases_import'));
|
||||
} catch (\Exception $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
return back()->with(['flash' => 'File uploaded successfully, your aliases are being imported']);
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@ class AccountDetailController extends Controller
|
|||
public function index()
|
||||
{
|
||||
return response()->json([
|
||||
'data' => new UserResource(user())
|
||||
'data' => new UserResource(user()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
227
app/Http/Controllers/Api/AliasBulkController.php
Normal file
227
app/Http/Controllers/Api/AliasBulkController.php
Normal file
|
@ -0,0 +1,227 @@
|
|||
<?php
|
||||
|
||||
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\Support\Facades\DB;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
class AliasBulkController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('throttle:12,1');
|
||||
}
|
||||
|
||||
public function get(GeneralAliasBulkRequest $request)
|
||||
{
|
||||
$aliases = user()->aliases()->withTrashed()
|
||||
->whereIn('id', $request->ids)
|
||||
->get();
|
||||
|
||||
// If there are no aliases found return 404 response
|
||||
if (! $aliases->count()) {
|
||||
return response()->json(['message' => 'No aliases found'], 404);
|
||||
}
|
||||
|
||||
return AliasResource::collection($aliases);
|
||||
}
|
||||
|
||||
public function activate(GeneralAliasBulkRequest $request)
|
||||
{
|
||||
$aliasesWithTrashed = user()->aliases()->withTrashed()
|
||||
->select(['id', 'user_id', 'active', 'deleted_at'])
|
||||
->where('active', false)
|
||||
->whereIn('id', $request->ids)
|
||||
->get();
|
||||
|
||||
// If there are no aliases found return 404 response
|
||||
if (! $aliasesWithTrashedCount = $aliasesWithTrashed->count()) {
|
||||
return response()->json(['message' => 'No aliases found'], 404);
|
||||
}
|
||||
|
||||
// Check if all aliases are deleted, if so return message
|
||||
$aliases = $aliasesWithTrashed->filter(function ($alias) {
|
||||
return ! $alias->trashed();
|
||||
});
|
||||
|
||||
if ($aliases->count() === 0) {
|
||||
return response()->json([
|
||||
'message' => $aliasesWithTrashedCount === 1 ? 'You need to restore this alias before you can activate it' : 'You need to restore these aliases before you can activate them',
|
||||
'ids' => $aliasesWithTrashed->pluck('id'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$aliasIds = $aliases->pluck('id')->all();
|
||||
$aliasIdsCount = count($aliasIds);
|
||||
user()->aliases()->whereIn('id', $aliasIds)->update(['active' => true]);
|
||||
|
||||
return response()->json([
|
||||
'message' => $aliasIdsCount === 1 ? '1 alias activated successfully' : "{$aliasIdsCount} aliases activated successfully",
|
||||
'ids' => $aliasIds,
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function deactivate(GeneralAliasBulkRequest $request)
|
||||
{
|
||||
$aliasIds = user()->aliases()
|
||||
->where('active', true)
|
||||
->whereIn('id', $request->ids)
|
||||
->pluck('id');
|
||||
|
||||
// If there are no aliases found return 404 response
|
||||
if (! $aliasIdsCount = $aliasIds->count()) {
|
||||
return response()->json(['message' => 'No aliases found'], 404);
|
||||
}
|
||||
|
||||
user()->aliases()->whereIn('id', $aliasIds)->update(['active' => false]);
|
||||
|
||||
return response()->json([
|
||||
'message' => $aliasIdsCount === 1 ? '1 alias deactivated successfully' : "{$aliasIdsCount} aliases deactivated successfully",
|
||||
'ids' => $aliasIds,
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function delete(GeneralAliasBulkRequest $request)
|
||||
{
|
||||
$aliasIds = user()->aliases()
|
||||
->whereIn('id', $request->ids)
|
||||
->pluck('id');
|
||||
|
||||
// If there are no aliases found return 404 response
|
||||
if (! $aliasIdsCount = $aliasIds->count()) {
|
||||
return response()->json(['message' => 'No aliases found'], 404);
|
||||
}
|
||||
|
||||
// Detach any recipients
|
||||
DB::table('alias_recipients')->whereIn('alias_id', $aliasIds)->delete();
|
||||
|
||||
// Use update since delete() does not trigger model event
|
||||
user()->aliases()->whereIn('id', $aliasIds)->update(['active' => false, 'deleted_at' => now()]);
|
||||
|
||||
// Don't return 204 as that is only for empty responses
|
||||
return response()->json([
|
||||
'message' => $aliasIdsCount === 1 ? '1 alias deleted successfully' : "{$aliasIdsCount} aliases deleted successfully",
|
||||
'ids' => $aliasIds,
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function forget(GeneralAliasBulkRequest $request)
|
||||
{
|
||||
$aliasIds = user()->aliases()->withTrashed()
|
||||
->whereIn('id', $request->ids)
|
||||
->pluck('id');
|
||||
|
||||
// If there are no aliases found return 404 response
|
||||
if (! $aliasIdsCount = $aliasIds->count()) {
|
||||
return response()->json(['message' => 'No aliases found'], 404);
|
||||
}
|
||||
|
||||
// Detach any recipients
|
||||
DB::table('alias_recipients')->whereIn('alias_id', $aliasIds)->delete();
|
||||
|
||||
// Shared Domain aliases, remove all data and change user_id
|
||||
$forgottenSharedDomainCount = user()->aliases()->withTrashed()
|
||||
->whereIn('id', $aliasIds)
|
||||
->whereIn('domain', config('anonaddy.all_domains'))
|
||||
->update([
|
||||
'user_id' => '00000000-0000-0000-0000-000000000000',
|
||||
'extension' => null,
|
||||
'description' => null,
|
||||
'emails_forwarded' => 0,
|
||||
'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(),
|
||||
]);
|
||||
|
||||
if ($forgottenSharedDomainCount < $aliasIdsCount) {
|
||||
// Standard aliases
|
||||
user()->aliases()->withTrashed()
|
||||
->whereIn('id', $aliasIds)
|
||||
->whereNotIn('domain', config('anonaddy.all_domains'))
|
||||
->forceDelete();
|
||||
}
|
||||
|
||||
// Don't return 204 as that is only for empty responses
|
||||
return response()->json([
|
||||
'message' => $aliasIdsCount === 1 ? '1 alias forgotten successfully' : "{$aliasIdsCount} aliases forgotten successfully",
|
||||
'ids' => $aliasIds,
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function restore(GeneralAliasBulkRequest $request)
|
||||
{
|
||||
$aliasIds = user()->aliases()->onlyTrashed()
|
||||
->whereIn('id', $request->ids)
|
||||
->pluck('id');
|
||||
|
||||
// If there are no aliases found return 404 response
|
||||
if (! $aliasIdsCount = $aliasIds->count()) {
|
||||
return response()->json(['message' => 'No aliases found'], 404);
|
||||
}
|
||||
|
||||
// Use update since delete() does not trigger model event
|
||||
user()->aliases()->onlyTrashed()->whereIn('id', $aliasIds)->update(['active' => true, 'deleted_at' => null]);
|
||||
|
||||
// Don't return 204 as that is only for empty responses
|
||||
return response()->json([
|
||||
'message' => $aliasIdsCount === 1 ? '1 alias restored successfully' : "{$aliasIdsCount} aliases restored successfully",
|
||||
'ids' => $aliasIds,
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function recipients(RecipientsAliasBulkRequest $request)
|
||||
{
|
||||
$request->validate([
|
||||
'ids' => 'required|array|max:25|min:1',
|
||||
'ids.*' => 'required|uuid|distinct',
|
||||
'recipient_ids' => [
|
||||
'array',
|
||||
'max:10',
|
||||
new VerifiedRecipientId,
|
||||
],
|
||||
'recipient_ids.*' => 'required|uuid|distinct',
|
||||
]);
|
||||
|
||||
$aliasIds = user()->aliases()->withTrashed()
|
||||
->whereIn('id', $request->ids)
|
||||
->pluck('id');
|
||||
|
||||
// If there are no aliases found return 404 response
|
||||
if (! $aliasIdsCount = $aliasIds->count()) {
|
||||
return response()->json(['message' => 'No aliases found'], 404);
|
||||
}
|
||||
|
||||
// First delete existing alias recipients
|
||||
DB::table('alias_recipients')->whereIn('alias_id', $aliasIds)->delete();
|
||||
// Then create alias recipients
|
||||
DB::table('alias_recipients')->insert((collect($aliasIds))->flatMap(function ($aliasId) use ($request) {
|
||||
$val = [];
|
||||
foreach ($request->recipient_ids as $recipientId) {
|
||||
$val[] = [
|
||||
'id' => Uuid::uuid4(),
|
||||
'alias_id' => $aliasId,
|
||||
'recipient_id' => $recipientId,
|
||||
];
|
||||
}
|
||||
|
||||
return $val;
|
||||
})->all());
|
||||
|
||||
// Don't return 204 as that is only for empty responses
|
||||
return response()->json([
|
||||
'message' => $aliasIdsCount === 1 ? 'recipients updated for 1 alias successfully' : "recipients updated for {$aliasIdsCount} aliases successfully",
|
||||
'ids' => $aliasIds,
|
||||
], 200);
|
||||
}
|
||||
}
|
|
@ -17,10 +17,50 @@ 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' ? '>' : '<';
|
||||
|
||||
return $query->orderBy(ltrim($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');
|
||||
}
|
||||
|
||||
// If sort is created at then simply return as no need for secondary sorting below
|
||||
if ($sort === 'created_at') {
|
||||
return $query->orderBy($sort, $direction);
|
||||
}
|
||||
|
||||
// Secondary order by latest first
|
||||
return $query
|
||||
->orderBy($sort, $direction)
|
||||
->orderBy('created_at', 'desc');
|
||||
}, function ($query) {
|
||||
return $query->latest();
|
||||
})
|
||||
|
@ -65,8 +105,8 @@ class AliasController extends Controller
|
|||
return response('You have reached your hourly limit for creating new aliases', 429);
|
||||
}
|
||||
|
||||
if (isset($request->validated()['local_part'])) {
|
||||
$localPart = $request->validated()['local_part'];
|
||||
if (isset($request->validated()['local_part_without_extension'])) {
|
||||
$localPart = $request->local_part; // To get the local_part with any potential extension
|
||||
|
||||
// Local part has extension
|
||||
if (Str::contains($localPart, '+')) {
|
||||
|
@ -75,47 +115,45 @@ class AliasController extends Controller
|
|||
}
|
||||
|
||||
$data = [
|
||||
'email' => $localPart . '@' . $request->domain,
|
||||
'email' => $localPart.'@'.$request->domain,
|
||||
'local_part' => $localPart,
|
||||
'extension' => $extension ?? null
|
||||
'extension' => $extension ?? null,
|
||||
];
|
||||
} else {
|
||||
if ($request->input('format', 'random_characters') === 'random_words') {
|
||||
$localPart = user()->generateRandomWordLocalPart();
|
||||
|
||||
$data = [
|
||||
'email' => $localPart . '@' . $request->domain,
|
||||
'local_part' => $localPart,
|
||||
];
|
||||
} elseif ($request->input('format', 'random_characters') === 'random_characters') {
|
||||
$localPart = user()->generateRandomCharacterLocalPart(8);
|
||||
|
||||
$data = [
|
||||
'email' => $localPart . '@' . $request->domain,
|
||||
'local_part' => $localPart,
|
||||
];
|
||||
} else {
|
||||
$uuid = Uuid::uuid4();
|
||||
|
||||
$data = [
|
||||
'id' => $uuid,
|
||||
'email' => $uuid . '@' . $request->domain,
|
||||
'local_part' => $uuid,
|
||||
];
|
||||
$format = $request->input('format');
|
||||
// If the request doesn't have format, use user's default alias format
|
||||
if (! $format) {
|
||||
$format = user()->default_alias_format ?? 'random_characters';
|
||||
}
|
||||
}
|
||||
|
||||
$data = [];
|
||||
|
||||
if ($format === 'random_words') {
|
||||
// Random Words
|
||||
$localPart = user()->generateRandomWordLocalPart();
|
||||
} elseif ($format === 'uuid') {
|
||||
// UUID
|
||||
$localPart = Uuid::uuid4();
|
||||
$data['id'] = $localPart;
|
||||
} else {
|
||||
// Random Characters
|
||||
$localPart = user()->generateRandomCharacterLocalPart(8);
|
||||
}
|
||||
|
||||
$data['email'] = $localPart.'@'.$request->domain;
|
||||
$data['local_part'] = $localPart;
|
||||
}
|
||||
|
||||
// Check if domain is for username or custom domain
|
||||
$parentDomain = collect(config('anonaddy.all_domains'))
|
||||
->filter(function ($name) use ($request) {
|
||||
return Str::endsWith($request->domain, $name);
|
||||
})
|
||||
->first();
|
||||
->filter(function ($name) use ($request) {
|
||||
return Str::endsWith($request->domain, $name);
|
||||
})
|
||||
->first();
|
||||
|
||||
$aliasable = null;
|
||||
|
||||
// This is an AnonAddy domain.
|
||||
// This is an addy.io domain.
|
||||
if ($parentDomain) {
|
||||
$subdomain = substr($request->domain, 0, strrpos($request->domain, '.'.$parentDomain));
|
||||
|
||||
|
@ -147,7 +185,15 @@ class AliasController extends Controller
|
|||
{
|
||||
$alias = user()->aliases()->withTrashed()->findOrFail($id);
|
||||
|
||||
$alias->update(['description' => $request->description]);
|
||||
if ($request->has('description')) {
|
||||
$alias->description = $request->description;
|
||||
}
|
||||
|
||||
if ($request->has('from_name')) {
|
||||
$alias->from_name = $request->from_name;
|
||||
}
|
||||
|
||||
$alias->save();
|
||||
|
||||
return new AliasResource($alias->refresh()->load('recipients'));
|
||||
}
|
||||
|
@ -187,11 +233,14 @@ class AliasController extends Controller
|
|||
'emails_forwarded' => 0,
|
||||
'emails_blocked' => 0,
|
||||
'emails_replied' => 0,
|
||||
'emails_sent' => 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();
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -18,7 +18,7 @@ class ApiTokenDetailController extends Controller
|
|||
return response()->json([
|
||||
'name' => $token->name,
|
||||
'created_at' => $token->created_at?->toDateTimeString(),
|
||||
'expires_at' => $token->expires_at?->toDateTimeString()
|
||||
'expires_at' => $token->expires_at?->toDateTimeString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
62
app/Http/Controllers/Api/ChartDataController.php
Normal file
62
app/Http/Controllers/Api/ChartDataController.php
Normal file
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
class ChartDataController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$outboundMessages = user()->outboundMessages()
|
||||
->select(['user_id', 'email_type', 'created_at'])
|
||||
->where('created_at', '>=', now()->subDays(6)->startOfDay())
|
||||
->get()
|
||||
->groupBy(function ($outboundMessage) {
|
||||
return $outboundMessage->created_at->format('l');
|
||||
})
|
||||
->map(function ($group) {
|
||||
return [
|
||||
'forwards' => $group->where('email_type', 'F')->count(),
|
||||
'replies' => $group->where('email_type', 'R')->count(),
|
||||
'sends' => $group->where('email_type', 'S')->count(),
|
||||
];
|
||||
});
|
||||
|
||||
$days = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
|
||||
$today = date('w'); // 0 Sunday
|
||||
|
||||
// Get the days until today including today
|
||||
$previous = array_slice($days, 0, $today + 1);
|
||||
|
||||
// Get remaining days in week
|
||||
$coming = array_slice($days, $today + 1);
|
||||
|
||||
$data = collect(array_merge($coming, $previous))->mapWithKeys(function ($day) use ($outboundMessages) {
|
||||
return [$day => $outboundMessages->get($day, ['forwards' => 0, 'replies' => 0, 'sends' => 0])];
|
||||
});
|
||||
|
||||
$outboundMessageTotals = [
|
||||
$outboundMessages->sum('forwards'),
|
||||
$outboundMessages->sum('replies'),
|
||||
$outboundMessages->sum('sends'),
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'forwardsData' => $data->pluck('forwards'),
|
||||
'repliesData' => $data->pluck('replies'),
|
||||
'sendsData' => $data->pluck('sends'),
|
||||
'labels' => $data->keys(),
|
||||
'outboundMessageTotals' => $outboundMessageTotals,
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -17,39 +17,51 @@ 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()) {
|
||||
return response('Verification record not found, please add the following TXT record to your domain: aa-verify=' . sha1(config('anonaddy.secret') . user()->id . user()->domains->count()), 404);
|
||||
return response('Verification record not found, please add the following TXT record to your domain: aa-verify='.sha1(config('anonaddy.secret').user()->id.user()->domains->count()), 404);
|
||||
}
|
||||
|
||||
user()->domains()->save($domain);
|
||||
|
||||
$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)
|
||||
{
|
||||
$domain = user()->domains()->findOrFail($id);
|
||||
|
||||
$domain->update(['description' => $request->description]);
|
||||
if ($request->has('description')) {
|
||||
$domain->description = $request->description;
|
||||
}
|
||||
|
||||
return new DomainResource($domain->refresh()->load(['aliases', 'defaultRecipient']));
|
||||
if ($request->has('from_name')) {
|
||||
$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('defaultRecipient')->loadCount('aliases'));
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
|
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,8 +10,9 @@ class DomainOptionController extends Controller
|
|||
{
|
||||
return response()->json([
|
||||
'data' => user()->domainOptions(),
|
||||
'sharedDomains' => user()->sharedDomainOptions(),
|
||||
'defaultAliasDomain' => user()->default_alias_domain,
|
||||
'defaultAliasFormat' => user()->default_alias_format
|
||||
'defaultAliasFormat' => user()->default_alias_format,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -9,7 +9,7 @@ class FailedDeliveryController extends Controller
|
|||
{
|
||||
public function index()
|
||||
{
|
||||
$failedDeliveries = user()->failedDeliveries()->with(['recipient:id,email','alias:id,email'])->latest();
|
||||
$failedDeliveries = user()->failedDeliveries()->with(['recipient:id,email', 'alias:id,email'])->latest();
|
||||
|
||||
return FailedDeliveryResource::collection($failedDeliveries->get());
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ class FailedDeliveryController extends Controller
|
|||
{
|
||||
$failedDelivery = user()->failedDeliveries()->findOrFail($id);
|
||||
|
||||
return new FailedDeliveryResource($failedDelivery->load(['recipient:id,email','alias:id,email']));
|
||||
return new FailedDeliveryResource($failedDelivery->load(['recipient:id,email', 'alias:id,email']));
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
|
|
|
@ -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)
|
||||
|
|
34
app/Http/Controllers/Api/LoginableUsernameController.php
Normal file
34
app/Http/Controllers/Api/LoginableUsernameController.php
Normal file
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\UsernameResource;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LoginableUsernameController extends Controller
|
||||
{
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate(['id' => 'required|string']);
|
||||
|
||||
$username = user()->usernames()->findOrFail($request->id);
|
||||
|
||||
$username->allowLogin();
|
||||
|
||||
return new UsernameResource($username->load('defaultRecipient')->loadCount('aliases'));
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
$username = user()->usernames()->findOrFail($id);
|
||||
|
||||
if ($id === user()->default_username_id) {
|
||||
return response('You cannot disallow login for your default username', 403);
|
||||
}
|
||||
|
||||
$username->disallowLogin();
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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,16 +28,24 @@ 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)
|
||||
{
|
||||
$recipient = user()->recipients()->create(['email' => strtolower($request->email)]);
|
||||
$data = ['email' => strtolower($request->email)];
|
||||
|
||||
$recipient->sendEmailVerificationNotification();
|
||||
if (config('anonaddy.auto_verify_new_recipients')) {
|
||||
$data['email_verified_at'] = now();
|
||||
}
|
||||
|
||||
return new RecipientResource($recipient->refresh()->load('aliases'));
|
||||
$recipient = user()->recipients()->create($data);
|
||||
|
||||
if (! config('anonaddy.auto_verify_new_recipients')) {
|
||||
$recipient->sendEmailVerificationNotification();
|
||||
}
|
||||
|
||||
return new RecipientResource($recipient->refresh()->loadCount('aliases'));
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
|
|
|
@ -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)
|
||||
|
@ -21,16 +21,16 @@ class RecipientKeyController extends Controller
|
|||
|
||||
$info = $this->gnupg->import($request->key_data);
|
||||
|
||||
if (!$info || !$info['fingerprint']) {
|
||||
if (! $info || ! $info['fingerprint']) {
|
||||
return response('Key could not be imported', 404);
|
||||
}
|
||||
|
||||
$recipient->update([
|
||||
'should_encrypt' => true,
|
||||
'fingerprint' => $info['fingerprint']
|
||||
'fingerprint' => $info['fingerprint'],
|
||||
]);
|
||||
|
||||
return new RecipientResource($recipient->fresh()->load('aliases'));
|
||||
return new RecipientResource($recipient->fresh()->loadCount('aliases'));
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
|
@ -45,7 +45,7 @@ class RecipientKeyController extends Controller
|
|||
'protected_headers' => false,
|
||||
'inline_encryption' => false,
|
||||
'protected_headers' => false,
|
||||
'fingerprint' => null
|
||||
'fingerprint' => null,
|
||||
]);
|
||||
|
||||
return response('', 204);
|
||||
|
|
|
@ -14,7 +14,7 @@ class ReorderRuleController extends Controller
|
|||
$rule = Rule::findOrFail($id);
|
||||
|
||||
$rule->update([
|
||||
'order' => $key
|
||||
'order' => $key,
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
@ -37,7 +37,7 @@ class RuleController extends Controller
|
|||
'operator' => $request->operator,
|
||||
'forwards' => $request->forwards ?? false,
|
||||
'replies' => $request->replies ?? false,
|
||||
'sends' => $request->sends ?? false
|
||||
'sends' => $request->sends ?? false,
|
||||
]);
|
||||
|
||||
return new RuleResource($rule->refresh());
|
||||
|
@ -62,7 +62,7 @@ class RuleController extends Controller
|
|||
'operator' => $request->operator,
|
||||
'forwards' => $request->forwards ?? false,
|
||||
'replies' => $request->replies ?? false,
|
||||
'sends' => $request->sends ?? false
|
||||
'sends' => $request->sends ?? false,
|
||||
]);
|
||||
|
||||
return new RuleResource($rule->refresh());
|
||||
|
|
|
@ -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,16 +31,28 @@ 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)
|
||||
{
|
||||
$username = user()->usernames()->findOrFail($id);
|
||||
|
||||
$username->update(['description' => $request->description]);
|
||||
if ($request->has('description')) {
|
||||
$username->description = $request->description;
|
||||
}
|
||||
|
||||
return new UsernameResource($username->refresh()->load(['aliases', 'defaultRecipient']));
|
||||
if ($request->has('from_name')) {
|
||||
$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('defaultRecipient')->loadCount('aliases'));
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
|
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
'csrf_token' => csrf_token(),
|
||||
], 422);
|
||||
} elseif (Webauthn::enabled($user)) {
|
||||
// If WebAuthn is enabled then return currently unsupported message
|
||||
return response()->json([
|
||||
'error' => 'WebAuthn 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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
@ -36,7 +36,7 @@ class BackupCodeController extends Controller
|
|||
|
||||
if (! Hash::check($request->backup_code, user()->two_factor_backup_code)) {
|
||||
return back()->withErrors([
|
||||
'backup_code' => __('The backup code was invalid.')
|
||||
'backup_code' => __('The backup code was invalid.'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -45,7 +45,7 @@ class BackupCodeController extends Controller
|
|||
user()->update([
|
||||
'two_factor_enabled' => false,
|
||||
'two_factor_secret' => $twoFactor->generateSecretKey(),
|
||||
'two_factor_backup_code' => null
|
||||
'two_factor_backup_code' => null,
|
||||
]);
|
||||
|
||||
user()->webauthnKeys()->delete();
|
||||
|
@ -57,12 +57,19 @@ class BackupCodeController extends Controller
|
|||
return redirect()->intended($request->redirectPath);
|
||||
}
|
||||
|
||||
public function update()
|
||||
public function update(Request $request)
|
||||
{
|
||||
user()->update([
|
||||
'two_factor_backup_code' => bcrypt($code = Str::random(40))
|
||||
$request->validate([
|
||||
'current' => 'required|string|current_password',
|
||||
]);
|
||||
|
||||
return back()->with(['backupCode' => $code]);
|
||||
user()->update([
|
||||
'two_factor_backup_code' => bcrypt($code = Str::random(40)),
|
||||
]);
|
||||
|
||||
return back()->with([
|
||||
'flash' => 'New Backup Code Generated Successfully',
|
||||
'regeneratedBackupCode' => $code,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,10 +3,10 @@
|
|||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\Username;
|
||||
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
|
||||
class ForgotPasswordController extends Controller
|
||||
|
@ -38,7 +38,6 @@ class ForgotPasswordController extends Controller
|
|||
/**
|
||||
* Send a reset link to the given user.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function sendResetLinkEmail(Request $request)
|
||||
|
@ -63,25 +62,34 @@ class ForgotPasswordController extends Controller
|
|||
/**
|
||||
* Validate the email for the given request.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return void
|
||||
*/
|
||||
protected function validateUsername(Request $request)
|
||||
{
|
||||
$request->validate(['username' => 'required|regex:/^[a-zA-Z0-9]*$/|max:20']);
|
||||
// Validate captcha separately first to prevent username enumeration
|
||||
if (! App::environment('testing')) {
|
||||
$request->validate([
|
||||
'captcha' => 'required|captcha',
|
||||
], [
|
||||
'captcha.captcha' => 'The text entered was incorrect, please try again.',
|
||||
]);
|
||||
}
|
||||
|
||||
$request->validate(['username' => 'required|regex:/^[a-zA-Z0-9]*$/|max:20'], [
|
||||
'username.regex' => 'Your username can only contain letters and numbers, do not use your email.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the response for a failed password reset link.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param string $response
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
|
||||
*/
|
||||
protected function sendResetLinkFailedResponse(Request $request, $response)
|
||||
{
|
||||
return back()
|
||||
->withInput($request->only('username'))
|
||||
->withErrors(['username' => trans($response)]);
|
||||
->withInput($request->only('username'))
|
||||
->withErrors(['username' => trans($response)]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ namespace App\Http\Controllers\Auth;
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Recipient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
|
||||
class ForgotUsernameController extends Controller
|
||||
{
|
||||
|
@ -32,7 +33,6 @@ class ForgotUsernameController extends Controller
|
|||
/**
|
||||
* Send a reset link to the given user.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function sendReminderEmail(Request $request)
|
||||
|
@ -51,11 +51,18 @@ class ForgotUsernameController extends Controller
|
|||
/**
|
||||
* Validate the email for the given request.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return void
|
||||
*/
|
||||
protected function validateEmail(Request $request)
|
||||
{
|
||||
if (! App::environment('testing')) {
|
||||
$request->validate([
|
||||
'captcha' => 'required|captcha',
|
||||
], [
|
||||
'captcha.captcha' => 'The text entered was incorrect, please try again.',
|
||||
]);
|
||||
}
|
||||
|
||||
$request->validate(['email' => 'required|email:rfc']);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,11 +2,14 @@
|
|||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Enums\LoginRedirect;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Username;
|
||||
use Illuminate\Foundation\Auth\AuthenticatesUsers;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
|
@ -40,35 +43,88 @@ class LoginController extends Controller
|
|||
$this->middleware('guest')->except('logout');
|
||||
}
|
||||
|
||||
public function redirectTo()
|
||||
{
|
||||
// Dynamic redirect setting to allow users to choose to go to /aliases page instead etc.
|
||||
return match (user()->login_redirect) {
|
||||
LoginRedirect::ALIASES => '/aliases',
|
||||
LoginRedirect::RECIPIENTS => '/recipients',
|
||||
LoginRedirect::USERNAMES => '/usernames',
|
||||
LoginRedirect::DOMAINS => '/domains',
|
||||
default => '/',
|
||||
};
|
||||
}
|
||||
|
||||
public function username()
|
||||
{
|
||||
$userId = Username::firstWhere('username', request()->input('username'))?->user_id;
|
||||
return 'id';
|
||||
}
|
||||
|
||||
public function addIdToRequest()
|
||||
{
|
||||
$userId = Username::select(['user_id', 'username', 'can_login'])
|
||||
->where('username', request()->input('username'))
|
||||
->where('can_login', true)
|
||||
->first()?->user_id;
|
||||
|
||||
request()->merge(['id' => $userId]);
|
||||
|
||||
return 'id';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the user login request.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return void
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
protected function validateLogin(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
$this->username() => 'nullable|string',
|
||||
$this->addIdToRequest();
|
||||
|
||||
Validator::make($request->all(), [
|
||||
'username' => 'required|regex:/^[a-zA-Z0-9]*$/|min:1|max:20',
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
$this->username() => 'nullable|string',
|
||||
], [
|
||||
'username.regex' => 'Your username can only contain letters and numbers, do not use your email.',
|
||||
])->validate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the needed authorization credentials from the request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function credentials(Request $request)
|
||||
{
|
||||
return $request->only('id', 'password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the response after the user was authenticated.
|
||||
*
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
|
||||
*/
|
||||
protected function sendLoginResponse(Request $request)
|
||||
{
|
||||
$request->session()->regenerate();
|
||||
|
||||
$this->clearLoginAttempts($request);
|
||||
|
||||
if ($response = $this->authenticated($request, $this->guard()->user())) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// If the intended path is just the dashboard then ignore and use the user's login redirect instead
|
||||
$redirectTo = $this->redirectTo();
|
||||
$intended = session()->pull('url.intended');
|
||||
|
||||
return $intended === url('/') ? redirect()->to($redirectTo) : redirect()->intended($intended ?? $redirectTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the failed login response instance.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
|
@ -79,4 +135,14 @@ class LoginController extends Controller
|
|||
'username' => [trans('auth.failed')],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user has logged out of the application.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
protected function loggedOut(Request $request)
|
||||
{
|
||||
return Inertia::location(route('login'));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,19 +6,25 @@ use App\Http\Controllers\Controller;
|
|||
use App\Http\Requests\StorePersonalAccessTokenRequest;
|
||||
use App\Http\Resources\PersonalAccessTokenResource;
|
||||
use chillerlan\QRCode\QRCode;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PersonalAccessTokenController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return PersonalAccessTokenResource::collection(user()->tokens()->select(['id', 'tokenable_id', 'name', 'created_at', 'last_used_at', 'expires_at'])->get());
|
||||
return PersonalAccessTokenResource::collection(user()->tokens()->select(['id', 'tokenable_id', 'name', 'created_at', 'last_used_at', 'expires_at', 'updated_at', 'created_at'])->get());
|
||||
}
|
||||
|
||||
public function store(StorePersonalAccessTokenRequest $request)
|
||||
{
|
||||
if (! Hash::check($request->password, user()->password)) {
|
||||
throw ValidationException::withMessages(['password' => 'Incorrect password entered']);
|
||||
}
|
||||
|
||||
// day, week, month, year or null
|
||||
if ($request->expiration) {
|
||||
$method = "add".ucfirst($request->expiration);
|
||||
$method = 'add'.ucfirst($request->expiration);
|
||||
$expiration = now()->{$method}();
|
||||
} else {
|
||||
$expiration = null;
|
||||
|
@ -30,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),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ use Illuminate\Foundation\Auth\RegistersUsers;
|
|||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
class RegisterController extends Controller
|
||||
|
@ -51,41 +52,54 @@ class RegisterController extends Controller
|
|||
/**
|
||||
* Get a validator for an incoming registration request.
|
||||
*
|
||||
* @param array $data
|
||||
* @return \Illuminate\Contracts\Validation\Validator
|
||||
*/
|
||||
protected function validator(array $data)
|
||||
{
|
||||
// Validate captcha separately first to prevent username enumeration
|
||||
if (! App::environment('testing')) {
|
||||
$validator = Validator::make($data, [
|
||||
'captcha' => [
|
||||
'required',
|
||||
'captcha',
|
||||
],
|
||||
], [
|
||||
'captcha.captcha' => 'The text entered was incorrect, please try again.',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return $validator;
|
||||
}
|
||||
}
|
||||
|
||||
return Validator::make($data, [
|
||||
'username' => [
|
||||
'bail',
|
||||
'required',
|
||||
'regex:/^[a-zA-Z0-9]*$/',
|
||||
'max:20',
|
||||
'unique:usernames,username',
|
||||
new NotBlacklisted(),
|
||||
new NotDeletedUsername()
|
||||
new NotBlacklisted,
|
||||
new NotDeletedUsername,
|
||||
],
|
||||
'email' => [
|
||||
'bail',
|
||||
'required',
|
||||
'email:rfc,dns',
|
||||
'max:254',
|
||||
'confirmed',
|
||||
new RegisterUniqueRecipient(),
|
||||
new NotLocalRecipient()
|
||||
new RegisterUniqueRecipient,
|
||||
new NotLocalRecipient,
|
||||
],
|
||||
'password' => ['required', 'min:8'],
|
||||
'password' => ['required', Password::defaults()],
|
||||
], [
|
||||
'captcha.captcha' => 'The text entered was incorrect, please try again.',
|
||||
])
|
||||
->sometimes('captcha', 'required|captcha', function () {
|
||||
return ! App::environment('testing');
|
||||
});
|
||||
'username.regex' => 'Your username can only contain letters and numbers.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user instance after a valid registration.
|
||||
*
|
||||
* @param array $data
|
||||
* @return \App\Models\User
|
||||
*/
|
||||
protected function create(array $data)
|
||||
|
@ -94,12 +108,12 @@ class RegisterController extends Controller
|
|||
|
||||
$recipient = Recipient::create([
|
||||
'email' => $data['email'],
|
||||
'user_id' => $userId
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
$username = Username::create([
|
||||
'username' => $data['username'],
|
||||
'user_id' => $userId
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
$twoFactor = app('pragmarx.google2fa');
|
||||
|
@ -109,7 +123,7 @@ class RegisterController extends Controller
|
|||
'default_username_id' => $username->id,
|
||||
'default_recipient_id' => $recipient->id,
|
||||
'password' => Hash::make($data['password']),
|
||||
'two_factor_secret' => $twoFactor->generateSecretKey()
|
||||
'two_factor_secret' => $twoFactor->generateSecretKey(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
|||
use App\Models\Username;
|
||||
use Illuminate\Foundation\Auth\ResetsPasswords;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class ResetPasswordController extends Controller
|
||||
{
|
||||
|
@ -44,7 +45,6 @@ class ResetPasswordController extends Controller
|
|||
*
|
||||
* If no token is present, display the link request form.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
|
||||
*/
|
||||
public function showResetForm(Request $request)
|
||||
|
@ -66,14 +66,17 @@ class ResetPasswordController extends Controller
|
|||
return [
|
||||
'token' => 'required',
|
||||
'username' => 'required|regex:/^[a-zA-Z0-9]*$/|max:20',
|
||||
'password' => 'required|confirmed|min:8',
|
||||
'password' => [
|
||||
'required',
|
||||
'confirmed',
|
||||
Password::defaults(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the password reset credentials from the request.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return array
|
||||
*/
|
||||
protected function credentials(Request $request)
|
||||
|
@ -94,14 +97,13 @@ class ResetPasswordController extends Controller
|
|||
/**
|
||||
* Get the response for a failed password reset.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param string $response
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
|
||||
*/
|
||||
protected function sendResetFailedResponse(Request $request, $response)
|
||||
{
|
||||
return back()
|
||||
->withInput($request->only('username'))
|
||||
->withErrors(['username' => trans($response)]);
|
||||
->withInput($request->only('username'))
|
||||
->withErrors(['username' => trans($response)]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,17 +5,18 @@ namespace App\Http\Controllers\Auth;
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\EnableTwoFactorAuthRequest;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use PragmaRX\Google2FALaravel\Support\Authenticator;
|
||||
|
||||
class TwoFactorAuthController extends Controller
|
||||
{
|
||||
protected $twoFactor;
|
||||
|
||||
protected $authenticator;
|
||||
|
||||
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']);
|
||||
}
|
||||
|
||||
|
@ -35,7 +36,7 @@ class TwoFactorAuthController extends Controller
|
|||
|
||||
user()->update([
|
||||
'two_factor_enabled' => true,
|
||||
'two_factor_backup_code' => bcrypt($code = Str::random(40))
|
||||
'two_factor_backup_code' => bcrypt($code = Str::random(40)),
|
||||
]);
|
||||
|
||||
$this->authenticator->login();
|
||||
|
@ -51,23 +52,23 @@ class TwoFactorAuthController extends Controller
|
|||
|
||||
user()->update(['two_factor_secret' => $this->twoFactor->generateSecretKey()]);
|
||||
|
||||
return back()->with(['status' => '2FA Secret Successfully Regenerated']);
|
||||
return back()->with(['flash' => '2FA Secret Successfully Regenerated']);
|
||||
}
|
||||
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
if (!Hash::check($request->current_password_2fa, user()->password)) {
|
||||
return back()->withErrors(['current_password_2fa' => 'Current password incorrect']);
|
||||
}
|
||||
$request->validate([
|
||||
'current' => 'required|string|current_password',
|
||||
]);
|
||||
|
||||
user()->update([
|
||||
'two_factor_enabled' => false,
|
||||
'two_factor_secret' => $this->twoFactor->generateSecretKey()
|
||||
'two_factor_secret' => $this->twoFactor->generateSecretKey(),
|
||||
]);
|
||||
|
||||
$this->authenticator->logout();
|
||||
|
||||
return back()->with(['status' => '2FA Disabled Successfully']);
|
||||
return back()->with(['flash' => '2FA Disabled Successfully']);
|
||||
}
|
||||
|
||||
public function authenticateTwoFactor(Request $request)
|
||||
|
|
|
@ -5,11 +5,15 @@ namespace App\Http\Controllers\Auth;
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Recipient;
|
||||
use App\Models\User;
|
||||
use App\Notifications\DefaultRecipientUpdated;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
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
|
||||
{
|
||||
|
@ -40,22 +44,34 @@ 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the email verification notice.
|
||||
*
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View
|
||||
*/
|
||||
public function show(Request $request)
|
||||
{
|
||||
return $request->user()->hasVerifiedEmail()
|
||||
? redirect($this->redirectPath())
|
||||
: Inertia::render('Auth/Verify', ['flash' => $request->session()->get('resent', null) ? 'A fresh verification link has been sent to your email address.' : null]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the authenticated user's email address as verified.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\Response
|
||||
*
|
||||
* @throws \Illuminate\Auth\Access\AuthorizationException
|
||||
*/
|
||||
public function verify(Request $request)
|
||||
{
|
||||
$verifiable = User::find($request->route('id')) ?? Recipient::find($request->route('id'));
|
||||
$verifiable = User::find($request->route('id')) ?? Recipient::withPending()->find($request->route('id'));
|
||||
|
||||
if (is_null($verifiable)) {
|
||||
throw new AuthorizationException('Email address not found.');
|
||||
|
@ -83,8 +99,36 @@ class VerificationController extends Controller
|
|||
$redirect = 'login';
|
||||
}
|
||||
|
||||
// Check if the verifiable is a pending new email Recipient
|
||||
if ($verifiable instanceof Recipient && $verifiable->pending) {
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($verifiable) {
|
||||
$user = $verifiable->user;
|
||||
$defaultRecipient = $user->defaultRecipient;
|
||||
// Notify the current default recipient of the change
|
||||
// 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([
|
||||
'email' => strtolower($verifiable->email),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
// Delete pending verifiable
|
||||
$verifiable->delete();
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
report($e);
|
||||
|
||||
return redirect($redirect)
|
||||
->with(['flash' => 'An error has occurred, please try again later.']);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect($redirect)
|
||||
->with('verified', true)
|
||||
->with(['status' => 'Email Address Verified Successfully']);
|
||||
->with(['flash' => 'Email Address Verified Successfully']);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,120 +2,91 @@
|
|||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Facades\Webauthn as WebauthnFacade;
|
||||
use App\Models\WebauthnKey;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\Support\Facades\Response;
|
||||
use Illuminate\Support\Str;
|
||||
use LaravelWebauthn\Actions\PrepareCreationData;
|
||||
use LaravelWebauthn\Actions\ValidateKeyCreation;
|
||||
use LaravelWebauthn\Contracts\DestroyResponse;
|
||||
use LaravelWebauthn\Contracts\RegisterSuccessResponse;
|
||||
use LaravelWebauthn\Contracts\RegisterViewResponse;
|
||||
use LaravelWebauthn\Facades\Webauthn;
|
||||
use LaravelWebauthn\Http\Controllers\WebauthnKeyController as ControllersWebauthnController;
|
||||
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();
|
||||
return user()->webauthnKeys()->latest()->select(['id', 'name', 'enabled', 'created_at'])->get()->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the register data to attempt a Webauthn registration.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return RegisterViewResponse
|
||||
*/
|
||||
public function create(Request $request)
|
||||
/* public function create(Request $request): RegisterViewResponse
|
||||
{
|
||||
$publicKey = app(PrepareCreationData::class)($request->user());
|
||||
|
||||
return app(RegisterViewResponse::class)
|
||||
->setPublicKey($request, $publicKey);
|
||||
}
|
||||
} */
|
||||
|
||||
/**
|
||||
* Validate and create the Webauthn request.
|
||||
*
|
||||
* @param WebauthnRegisterRequest $request
|
||||
* @return \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
|
||||
*/
|
||||
public function store(WebauthnRegisterRequest $request)
|
||||
public function store(WebauthnRegisterRequest $request): RegisterSuccessResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:50'
|
||||
'name' => 'required|string|max:50',
|
||||
'password' => 'required|string|current_password',
|
||||
]);
|
||||
|
||||
try {
|
||||
app(ValidateKeyCreation::class)(
|
||||
$request->user(),
|
||||
$request->only(['id', 'rawId', 'response', 'type']),
|
||||
$request->input('name')
|
||||
);
|
||||
|
||||
user()->update([
|
||||
'two_factor_enabled' => false
|
||||
]);
|
||||
|
||||
return $this->redirectAfterSuccessRegister();
|
||||
} catch (\Exception $e) {
|
||||
return Response::json([
|
||||
'error' => [
|
||||
'message' => $e->getMessage(),
|
||||
],
|
||||
], 403);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the redirect destination after a successfull register.
|
||||
*
|
||||
* @param WebauthnKey $webauthnKey
|
||||
* @return \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
|
||||
*/
|
||||
protected function redirectAfterSuccessRegister()
|
||||
{
|
||||
// If the user already has at least one key do not generate a new backup code.
|
||||
if (user()->webauthnKeys()->count() > 1) {
|
||||
return Redirect::intended('/settings');
|
||||
}
|
||||
$webauthnKey = app(ValidateKeyCreation::class)(
|
||||
$request->user(),
|
||||
$request->only(['id', 'rawId', 'response', 'type']),
|
||||
$request->input('name')
|
||||
);
|
||||
|
||||
user()->update([
|
||||
'two_factor_backup_code' => bcrypt($code = Str::random(40))
|
||||
'two_factor_enabled' => false,
|
||||
]);
|
||||
|
||||
return Redirect::intended('/settings')->with(['backupCode' => $code]);
|
||||
return app(RegisterSuccessResponse::class)
|
||||
->setWebauthnKey($request, $webauthnKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an existing Webauthn key.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function destroy(Request $request, $webauthnKeyId)
|
||||
public function destroy(Request $request, $webauthnKeyId): DestroyResponse
|
||||
{
|
||||
try {
|
||||
user()->webauthnKeys()
|
||||
->findOrFail($webauthnKeyId)
|
||||
->delete();
|
||||
$request->validate([
|
||||
'current' => 'required|string|current_password',
|
||||
]);
|
||||
|
||||
if (! WebauthnFacade::hasKey(user())) {
|
||||
WebauthnFacade::logout();
|
||||
}
|
||||
user()->webauthnKeys()
|
||||
->findOrFail($webauthnKeyId)
|
||||
->delete();
|
||||
|
||||
return Response::json([
|
||||
'deleted' => true,
|
||||
'id' => $webauthnKeyId,
|
||||
]);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
return Response::json([
|
||||
'error' => [
|
||||
'message' => trans('webauthn::errors.object_not_found'),
|
||||
],
|
||||
], 404);
|
||||
// Using vendor Facade to ensure disabled keys are included
|
||||
if (! Webauthn::hasKey(user())) {
|
||||
// Remove session value when last key is deleted
|
||||
Webauthn::logout();
|
||||
}
|
||||
|
||||
return app(DestroyResponse::class);
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
return abort(404);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
@ -16,8 +21,12 @@ class WebauthnEnabledKeyController extends Controller
|
|||
return response('', 201);
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
public function destroy(Request $request, $id)
|
||||
{
|
||||
$request->validate([
|
||||
'current' => 'required|string|current_password',
|
||||
]);
|
||||
|
||||
$webauthnKey = user()->webauthnKeys()->findOrFail($id);
|
||||
|
||||
$webauthnKey->disable();
|
||||
|
|
|
@ -10,6 +10,6 @@ class BannerLocationController extends Controller
|
|||
{
|
||||
user()->update(['banner_location' => $request->banner_location]);
|
||||
|
||||
return back()->with(['status' => 'Location Updated Successfully']);
|
||||
return back()->with(['flash' => 'Location Updated Successfully']);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,14 +7,19 @@ 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([
|
||||
'current_password_sesssions' => 'current_password',
|
||||
'current' => 'required|string|current_password',
|
||||
]);
|
||||
|
||||
Auth::logoutOtherDevices($request->current_password_sesssions);
|
||||
Auth::logoutOtherDevices($request->current);
|
||||
|
||||
return back()->with(['status' => 'Successfully logged out of other browser sessions!']);
|
||||
return back()->with(['flash' => 'Successfully logged out of other browser sessions!']);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,13 +3,11 @@
|
|||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Bus\DispatchesJobs;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
class Controller extends BaseController
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use DispatchesJobs;
|
||||
use ValidatesRequests;
|
||||
}
|
||||
|
|
|
@ -22,6 +22,6 @@ class DeactivateAliasController extends Controller
|
|||
$alias->deactivate();
|
||||
|
||||
return redirect()->route('aliases.index')
|
||||
->with(['status' => 'Alias ' . $alias->email . ' deactivated successfully!']);
|
||||
->with(['flash' => 'Alias '.$alias->email.' deactivated successfully!']);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\UpdateDefaultAliasFormatRequest;
|
||||
|
||||
class DefaultAliasFormatController extends Controller
|
||||
{
|
||||
public function update(UpdateDefaultAliasFormatRequest $request)
|
||||
{
|
||||
user()->default_alias_format = $request->format;
|
||||
user()->save();
|
||||
|
||||
return back()->with(['status' => 'Default Alias Format Updated Successfully']);
|
||||
}
|
||||
}
|
|
@ -11,6 +11,6 @@ class DefaultAliasDomainController extends Controller
|
|||
user()->default_alias_domain = $request->domain;
|
||||
user()->save();
|
||||
|
||||
return back()->with(['status' => 'Default Alias Domain Updated Successfully']);
|
||||
return back()->with(['flash' => 'Default Alias Domain Updated Successfully']);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,6 @@ class DefaultAliasFormatController extends Controller
|
|||
user()->default_alias_format = $request->format;
|
||||
user()->save();
|
||||
|
||||
return back()->with(['status' => 'Default Alias Format Updated Successfully']);
|
||||
return back()->with(['flash' => 'Default Alias Format Updated Successfully']);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,29 +21,46 @@ class DefaultRecipientController extends Controller
|
|||
|
||||
public function update(UpdateDefaultRecipientRequest $request)
|
||||
{
|
||||
$recipient = user()->verifiedRecipients()->findOrFail($request->default_recipient);
|
||||
$recipient = user()->verifiedRecipients()->findOrFail($request->id);
|
||||
|
||||
$currentDefaultRecipient = user()->defaultRecipient;
|
||||
|
||||
user()->default_recipient = $recipient;
|
||||
user()->save();
|
||||
user()->update(['default_recipient_id' => $recipient->id]);
|
||||
|
||||
if ($currentDefaultRecipient->id !== $recipient->id) {
|
||||
$currentDefaultRecipient->notify(new DefaultRecipientUpdated($recipient->email));
|
||||
}
|
||||
|
||||
return back()->with(['status' => 'Default Recipient Updated Successfully']);
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(EditDefaultRecipientRequest $request)
|
||||
{
|
||||
$recipient = user()->defaultRecipient;
|
||||
|
||||
$recipient->email = $request->email;
|
||||
// Updating already verified default recipient, create new pending entry and send verification email.
|
||||
if ($recipient->hasVerifiedEmail()) {
|
||||
// Clear all other pending entries
|
||||
user()->pendingRecipients()->delete();
|
||||
|
||||
$pendingRecipient = user()->recipients()->create([
|
||||
'email' => strtolower($request->email),
|
||||
'pending' => true,
|
||||
]);
|
||||
|
||||
$pendingRecipient->sendEmailVerificationNotification();
|
||||
|
||||
return back()->with(['flash' => 'Email Pending Verification, Please Check Your Inbox For The Verification Email']);
|
||||
}
|
||||
|
||||
// Unverified default recipient so we can simply update and send the verification email.
|
||||
$recipient->email = strtolower($request->email);
|
||||
$recipient->save();
|
||||
|
||||
user()->sendEmailVerificationNotification();
|
||||
|
||||
return back()->with(['status' => 'Email Updated Successfully, Please Check Your Inbox For The Verification Email']);
|
||||
return back()->with(['flash' => 'Email Updated Successfully, Please Check Your Inbox For The Verification Email']);
|
||||
}
|
||||
}
|
||||
|
|
22
app/Http/Controllers/DefaultUsernameController.php
Normal file
22
app/Http/Controllers/DefaultUsernameController.php
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\UpdateDefaultUsernameRequest;
|
||||
|
||||
class DefaultUsernameController extends Controller
|
||||
{
|
||||
public function update(UpdateDefaultUsernameRequest $request)
|
||||
{
|
||||
$username = user()->usernames()->findOrFail($request->id);
|
||||
|
||||
// Ensure username can be used to login
|
||||
$username->allowLogin();
|
||||
|
||||
user()->update(['default_username_id' => $username->id]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
}
|
17
app/Http/Controllers/DisplayFromFormatController.php
Normal file
17
app/Http/Controllers/DisplayFromFormatController.php
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\DisplayFromFormat;
|
||||
use App\Http\Requests\UpdateDisplayFromFormatRequest;
|
||||
|
||||
class DisplayFromFormatController extends Controller
|
||||
{
|
||||
public function update(UpdateDisplayFromFormatRequest $request)
|
||||
{
|
||||
user()->display_from_format = DisplayFromFormat::from($request->format);
|
||||
user()->save();
|
||||
|
||||
return back()->with(['flash' => 'Default Alias Format Updated Successfully']);
|
||||
}
|
||||
}
|
|
@ -17,7 +17,7 @@ class DomainVerificationController extends Controller
|
|||
if (! $domain->checkMxRecords()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'MX record not found or does not have correct priority. This could be due to DNS caching, please try again later.'
|
||||
'message' => 'MX record not found or does not have correct priority. This could be due to DNS caching, please try again later.',
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DownloadableFailedDeliveryController extends Controller
|
||||
{
|
||||
public function index($id)
|
||||
{
|
||||
$failedDelivery = user()->failedDeliveries()->findOrFail($id);
|
||||
|
||||
if (! $failedDelivery->is_stored) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! Storage::disk('local')->exists($failedDelivery->id.'.eml')) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return Storage::disk('local')->download($failedDelivery->id.'.eml');
|
||||
}
|
||||
}
|
|
@ -10,6 +10,6 @@ class EmailSubjectController extends Controller
|
|||
{
|
||||
user()->update(['email_subject' => $request->email_subject]);
|
||||
|
||||
return back()->with(['status' => 'Email Subject Updated Successfully']);
|
||||
return back()->with(['flash' => 'Email Subject Updated Successfully']);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,14 +2,14 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\UpdateFromNameRequest;
|
||||
use App\Http\Requests\UpdateAccountFromNameRequest;
|
||||
|
||||
class FromNameController extends Controller
|
||||
{
|
||||
public function update(UpdateFromNameRequest $request)
|
||||
public function update(UpdateAccountFromNameRequest $request)
|
||||
{
|
||||
user()->update(['from_name' => $request->from_name]);
|
||||
|
||||
return back()->with(['status' => 'From Name Updated Successfully']);
|
||||
return back()->with(['flash' => 'From Name Updated Successfully']);
|
||||
}
|
||||
}
|
||||
|
|
17
app/Http/Controllers/LoginRedirectController.php
Normal file
17
app/Http/Controllers/LoginRedirectController.php
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\LoginRedirect;
|
||||
use App\Http\Requests\UpdateLoginRedirectRequest;
|
||||
|
||||
class LoginRedirectController extends Controller
|
||||
{
|
||||
public function update(UpdateLoginRedirectRequest $request)
|
||||
{
|
||||
user()->login_redirect = LoginRedirect::from($request->redirect);
|
||||
user()->save();
|
||||
|
||||
return back()->with(['flash' => 'Login Redirect Updated Successfully']);
|
||||
}
|
||||
}
|
|
@ -8,18 +8,19 @@ use Illuminate\Support\Facades\Hash;
|
|||
|
||||
class PasswordController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('throttle:3,1')->only('update');
|
||||
}
|
||||
|
||||
public function update(UpdatePasswordRequest $request)
|
||||
{
|
||||
if (!Hash::check($request->current, user()->password)) {
|
||||
return redirect(url()->previous().'#update-password')->withErrors(['current' => 'Current password incorrect']);
|
||||
}
|
||||
|
||||
// Log out of other sessions
|
||||
Auth::logoutOtherDevices($request->current);
|
||||
|
||||
user()->password = Hash::make($request->password);
|
||||
user()->save();
|
||||
|
||||
return back()->with(['status' => 'Password Updated Successfully']);
|
||||
return back()->with(['flash' => 'Password Updated Successfully']);
|
||||
}
|
||||
}
|
||||
|
|
19
app/Http/Controllers/SaveAliasLastUsedController.php
Normal file
19
app/Http/Controllers/SaveAliasLastUsedController.php
Normal 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']);
|
||||
}
|
||||
}
|
|
@ -3,12 +3,37 @@
|
|||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\DestroyAccountRequest;
|
||||
use App\Http\Resources\PersonalAccessTokenResource;
|
||||
use App\Jobs\DeleteAccount;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
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', [
|
||||
'defaultAliasDomain' => user()->default_alias_domain,
|
||||
'defaultAliasFormat' => user()->default_alias_format,
|
||||
'loginRedirect' => user()->login_redirect->value,
|
||||
'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,
|
||||
'domainOptions' => user()->domainOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function security(Request $request)
|
||||
{
|
||||
$twoFactor = app('pragmarx.google2fa');
|
||||
|
||||
|
@ -18,26 +43,47 @@ class SettingController extends Controller
|
|||
user()->two_factor_secret
|
||||
);
|
||||
|
||||
return view('settings.show', [
|
||||
'user' => user(),
|
||||
'recipientOptions' => user()->verifiedRecipients,
|
||||
'authSecret' => user()->two_factor_secret,
|
||||
'qrCode' => $qrCode
|
||||
// User has either webauthn or TOTP 2FA enabled
|
||||
$hasTwoFactor = Webauthn::enabled(user()) || user()->two_factor_enabled;
|
||||
|
||||
return Inertia::render('Settings/Security', [
|
||||
'authSecret' => $hasTwoFactor ? null : user()->two_factor_secret,
|
||||
'qrCode' => $hasTwoFactor ? null : $qrCode,
|
||||
'regeneratedBackupCode' => $request->session()->get('regeneratedBackupCode', null),
|
||||
'backupCode' => $request->session()->get('backupCode', null),
|
||||
'twoFactorEnabled' => user()->two_factor_enabled,
|
||||
'webauthnEnabled' => Webauthn::enabled(user()),
|
||||
'initialKeys' => user()->webauthnKeys()->latest()->select(['id', 'name', 'enabled', 'created_at'])->get()->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function api()
|
||||
{
|
||||
return Inertia::render('Settings/Api', [
|
||||
'initialTokens' => PersonalAccessTokenResource::collection(user()->tokens()->select(['id', 'tokenable_id', 'name', 'created_at', 'last_used_at', 'expires_at', 'updated_at', 'created_at'])->get()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function data()
|
||||
{
|
||||
return Inertia::render('Settings/Data', [
|
||||
'totalAliasesCount' => user()->allAliases()->count(),
|
||||
'domainsCount' => user()->domains()->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function account()
|
||||
{
|
||||
return Inertia::render('Settings/Account');
|
||||
}
|
||||
|
||||
public function destroy(DestroyAccountRequest $request)
|
||||
{
|
||||
if (!Hash::check($request->current_password_delete, user()->password)) {
|
||||
return back()->withErrors(['current_password_delete' => 'Incorrect password entered']);
|
||||
}
|
||||
|
||||
DeleteAccount::dispatch(user());
|
||||
|
||||
auth()->logout();
|
||||
$request->session()->invalidate();
|
||||
|
||||
return redirect()->route('login')
|
||||
->with(['status' => 'Account deleted successfully!']);
|
||||
return Inertia::location(route('login'));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,34 +2,229 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ShowAliasController extends Controller
|
||||
{
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
$totals = user()
|
||||
->aliases()
|
||||
->withTrashed()
|
||||
->toBase()
|
||||
->selectRaw("ifnull(sum(emails_forwarded),0) as forwarded")
|
||||
->selectRaw("ifnull(sum(emails_blocked),0) as blocked")
|
||||
->selectRaw("ifnull(sum(emails_replied),0) as replies")
|
||||
->first();
|
||||
$validated = $request->validate([
|
||||
'page' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
],
|
||||
'page_size' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'in:25,50,100',
|
||||
],
|
||||
'search' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:50',
|
||||
'min:2',
|
||||
],
|
||||
'deleted' => [
|
||||
'nullable',
|
||||
'in:with,without,only',
|
||||
'string',
|
||||
],
|
||||
'active' => [
|
||||
'nullable',
|
||||
'in:true,false',
|
||||
'string',
|
||||
],
|
||||
'shared_domain' => [
|
||||
'nullable',
|
||||
'in:true,false',
|
||||
'string',
|
||||
],
|
||||
'sort' => [
|
||||
'nullable',
|
||||
'max:20',
|
||||
'min:3',
|
||||
Rule::in([
|
||||
'local_part',
|
||||
'domain',
|
||||
'email',
|
||||
'emails_forwarded',
|
||||
'emails_blocked',
|
||||
'emails_replied',
|
||||
'emails_sent',
|
||||
'last_forwarded',
|
||||
'last_blocked',
|
||||
'last_replied',
|
||||
'last_sent',
|
||||
'last_used',
|
||||
'active',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
'-local_part',
|
||||
'-domain',
|
||||
'-email',
|
||||
'-emails_forwarded',
|
||||
'-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',
|
||||
],
|
||||
]);
|
||||
|
||||
return view('aliases.index', [
|
||||
'user' => user(),
|
||||
'defaultRecipientEmail' => user()->email,
|
||||
'aliases' => user()
|
||||
->aliases()
|
||||
->with([
|
||||
'recipients:id,email',
|
||||
'aliasable.defaultRecipient:id,email'
|
||||
])
|
||||
->latest()
|
||||
->get(),
|
||||
'recipients' => user()->verifiedRecipients()->select(['id', 'email'])->get(),
|
||||
'totals' => $totals,
|
||||
'domain' => user()->username.'.'.config('anonaddy.domain'),
|
||||
'domainOptions' => user()->domainOptions(),
|
||||
$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', '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);
|
||||
})
|
||||
->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($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)
|
||||
->orderBy('created_at', 'desc');
|
||||
}, function ($query) {
|
||||
return $query->latest();
|
||||
})
|
||||
->when($request->input('active'), function ($query, $value) {
|
||||
$active = $value === 'true' ? true : false;
|
||||
|
||||
return $query->where('active', $active);
|
||||
})
|
||||
->when($request->input('shared_domain'), function ($query, $value) {
|
||||
if ($value === 'true') {
|
||||
return $query->whereIn('domain', config('anonaddy.all_domains'));
|
||||
}
|
||||
|
||||
return $query->whereNotIn('domain', config('anonaddy.all_domains'));
|
||||
})
|
||||
->with([
|
||||
'recipients:id,email',
|
||||
'aliasable.defaultRecipient:id,email',
|
||||
]);
|
||||
|
||||
// Check if with deleted
|
||||
if ($request->deleted === 'with') {
|
||||
$aliases->withTrashed();
|
||||
}
|
||||
|
||||
if ($request->deleted === 'only') {
|
||||
$aliases->onlyTrashed();
|
||||
}
|
||||
|
||||
if (isset($validated['search'])) {
|
||||
$searchTerm = strtolower($validated['search']);
|
||||
|
||||
// Chunk aliases and build results array by passing &$results, this is for users with tens of thousands of aliases to prevent out of memory issues.
|
||||
$searchResults = collect();
|
||||
$aliases->chunk(10000, function ($chunkedAliases) use (&$searchResults, $searchTerm) {
|
||||
$searchResults = $searchResults->concat($chunkedAliases->filter(function ($alias) use ($searchTerm) {
|
||||
return Str::contains(strtolower($alias->email), $searchTerm) || Str::contains(strtolower($alias->description), $searchTerm);
|
||||
})->values());
|
||||
});
|
||||
|
||||
$aliases = $searchResults;
|
||||
}
|
||||
|
||||
$aliases = $aliases->paginate($validated['page_size'] ?? 25)->withQueryString()->onEachSide(1);
|
||||
|
||||
if ($request->has('active')) {
|
||||
$currentAliasStatus = $request->input('active') === 'true' ? 'active' : 'inactive';
|
||||
} elseif ($request->has('deleted')) {
|
||||
$currentAliasStatus = $request->input('deleted') === 'with' ? 'all' : 'deleted';
|
||||
} else {
|
||||
$currentAliasStatus = 'active_inactive';
|
||||
}
|
||||
|
||||
return Inertia::render('Aliases/Index', [
|
||||
'initialRows' => fn () => $aliases,
|
||||
'recipientOptions' => fn () => user()->verifiedRecipients()->select(['id', 'email'])->get(),
|
||||
'domain' => fn () => config('anonaddy.domain'),
|
||||
'subdomain' => fn () => user()->username.'.'.config('anonaddy.domain'),
|
||||
'domainOptions' => fn () => user()->domainOptions(),
|
||||
'defaultAliasDomain' => fn () => user()->default_alias_domain,
|
||||
'defaultAliasFormat' => fn () => user()->default_alias_format,
|
||||
'search' => $validated['search'] ?? null,
|
||||
'initialPageSize' => isset($validated['page_size']) ? (int) $validated['page_size'] : 25,
|
||||
'sort' => $sort,
|
||||
'sortDirection' => $direction,
|
||||
'currentAliasStatus' => $currentAliasStatus,
|
||||
'sharedDomains' => user()->sharedDomainOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
$alias = user()->aliases()->withTrashed()->findOrFail($id);
|
||||
|
||||
return Inertia::render('Aliases/Edit', [
|
||||
'initialAlias' => $alias->only(['id', 'user_id', 'local_part', 'extension', 'domain', 'email', 'active', 'description', 'from_name', 'deleted_at', 'updated_at']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
37
app/Http/Controllers/ShowDashboardController.php
Normal file
37
app/Http/Controllers/ShowDashboardController.php
Normal file
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ShowDashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$totals = user()
|
||||
->aliases()
|
||||
->withTrashed()
|
||||
->toBase()
|
||||
->selectRaw('ifnull(count(id),0) as total')
|
||||
->selectRaw('ifnull(sum(active=1),0) as active')
|
||||
->selectRaw('ifnull(sum(CASE WHEN active=0 AND deleted_at IS NULL THEN 1 END),0) as inactive')
|
||||
->selectRaw('ifnull(sum(CASE WHEN deleted_at IS NOT NULL THEN 1 END),0) as deleted')
|
||||
->selectRaw('ifnull(sum(emails_forwarded),0) as forwarded')
|
||||
->selectRaw('ifnull(sum(emails_blocked),0) as blocked')
|
||||
->selectRaw('ifnull(sum(emails_replied),0) as replies')
|
||||
->selectRaw('ifnull(sum(emails_sent),0) as sent')
|
||||
->first();
|
||||
|
||||
return Inertia::render('Dashboard/Index', [
|
||||
'totals' => $totals,
|
||||
'bandwidthMb' => user()->bandwidthMb,
|
||||
'bandwidthLimit' => user()->getBandwidthLimitMb(),
|
||||
'month' => now()->format('F'),
|
||||
'aliases' => user()->activeSharedDomainAliases()->count(),
|
||||
'recipients' => user()->recipients()->count(),
|
||||
'usernames' => user()->usernames()->count(),
|
||||
'domains' => user()->domains()->count(),
|
||||
'rules' => user()->rules()->count(),
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -2,17 +2,52 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ShowDomainController extends Controller
|
||||
{
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
return view('domains.index', [
|
||||
'domains' => user()
|
||||
->domains()
|
||||
->with('defaultRecipient:id,email')
|
||||
->withCount('aliases')
|
||||
->latest()
|
||||
->get()
|
||||
// Validate search query
|
||||
$validated = $request->validate([
|
||||
'search' => 'nullable|string|max:50|min:2',
|
||||
]);
|
||||
|
||||
$domains = user()
|
||||
->domains()
|
||||
->select(['id', 'user_id', 'default_recipient_id', 'domain', 'description', 'active', 'catch_all', 'domain_mx_validated_at', 'domain_sending_verified_at', 'created_at'])
|
||||
->with('defaultRecipient:id,email')
|
||||
->withCount('aliases')
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
if (isset($validated['search'])) {
|
||||
$searchTerm = strtolower($validated['search']);
|
||||
|
||||
$domains = $domains->filter(function ($domain) use ($searchTerm) {
|
||||
return Str::contains(strtolower($domain->domain), $searchTerm) || Str::contains(strtolower($domain->description), $searchTerm);
|
||||
})->values();
|
||||
}
|
||||
|
||||
return Inertia::render('Domains/Index', [
|
||||
'initialRows' => $domains,
|
||||
'domainName' => config('anonaddy.domain'),
|
||||
'hostname' => config('anonaddy.hostname'),
|
||||
'dkimSelector' => config('anonaddy.dkim_selector'),
|
||||
'recipientOptions' => user()->verifiedRecipients()->select(['id', 'email'])->get(),
|
||||
'initialAaVerify' => sha1(config('anonaddy.secret').user()->id.user()->domains->count()),
|
||||
'search' => $validated['search'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
$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', 'auto_create_regex', 'updated_at']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,17 +2,37 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ShowFailedDeliveryController extends Controller
|
||||
{
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
return view('failed_deliveries.index', [
|
||||
'failedDeliveries' => user()
|
||||
->failedDeliveries()
|
||||
->with(['recipient:id,email','alias:id,email'])
|
||||
->select(['alias_id','bounce_type','code','attempted_at','created_at','id','recipient_id','remote_mta','sender'])
|
||||
->latest()
|
||||
->get()
|
||||
// Validate search query
|
||||
$validated = $request->validate([
|
||||
'search' => 'nullable|string|max:50|min:2',
|
||||
]);
|
||||
|
||||
$failedDeliveries = user()
|
||||
->failedDeliveries()
|
||||
->with(['recipient:id,email', 'alias:id,email'])
|
||||
->select(['alias_id', 'email_type', 'code', 'attempted_at', 'created_at', 'id', 'user_id', 'recipient_id', 'remote_mta', 'sender', 'destination', 'is_stored'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
if (isset($validated['search'])) {
|
||||
$searchTerm = strtolower($validated['search']);
|
||||
|
||||
$failedDeliveries = $failedDeliveries->filter(function ($failedDelivery) use ($searchTerm) {
|
||||
return Str::contains(strtolower($failedDelivery->code), $searchTerm);
|
||||
})->values();
|
||||
}
|
||||
|
||||
return Inertia::render('FailedDeliveries', [
|
||||
'initialRows' => $failedDeliveries,
|
||||
'search' => $validated['search'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,31 +2,31 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ShowRecipientController extends Controller
|
||||
{
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
$recipients = user()->recipients()->with([
|
||||
'aliases:id,aliasable_id,email',
|
||||
'domainsUsingAsDefault.aliases:id,aliasable_id,email',
|
||||
'usernamesUsingAsDefault.aliases:id,aliasable_id,email'
|
||||
])->latest()->get();
|
||||
// Validate search query
|
||||
$validated = $request->validate([
|
||||
'search' => 'nullable|string|max:50|min:2',
|
||||
]);
|
||||
|
||||
$recipients->each(function ($recipient) {
|
||||
if ($recipient->domainsUsingAsDefault) {
|
||||
$domainAliases = $recipient->domainsUsingAsDefault->flatMap(function ($domain) {
|
||||
return $domain->aliases;
|
||||
});
|
||||
$recipient->setRelation('aliases', $recipient->aliases->concat($domainAliases)->unique('email'));
|
||||
}
|
||||
|
||||
if ($recipient->usernamesUsingAsDefault) {
|
||||
$usernameAliases = $recipient->usernamesUsingAsDefault->flatMap(function ($domain) {
|
||||
return $domain->aliases;
|
||||
});
|
||||
$recipient->setRelation('aliases', $recipient->aliases->concat($usernameAliases)->unique('email'));
|
||||
}
|
||||
});
|
||||
$recipients = user()->recipients()
|
||||
->select([
|
||||
'id',
|
||||
'user_id',
|
||||
'email',
|
||||
'should_encrypt',
|
||||
'fingerprint',
|
||||
'email_verified_at',
|
||||
'created_at',
|
||||
])
|
||||
->latest()->get();
|
||||
|
||||
$count = $recipients->count();
|
||||
|
||||
|
@ -34,11 +34,55 @@ class ShowRecipientController extends Controller
|
|||
$item['key'] = $count - $key;
|
||||
});
|
||||
|
||||
return view('recipients.index', [
|
||||
'recipients' => $recipients,
|
||||
'aliasesUsingDefault' => user()->aliasesUsingDefault()->take(5)->get(),
|
||||
'aliasesUsingDefaultCount' => user()->aliasesUsingDefault()->count(),
|
||||
'user' => user()->load('defaultUsername')
|
||||
if (isset($validated['search'])) {
|
||||
$searchTerm = strtolower($validated['search']);
|
||||
|
||||
$recipients = $recipients->filter(function ($recipient) use ($searchTerm) {
|
||||
return Str::contains(strtolower($recipient->email), $searchTerm);
|
||||
})->values();
|
||||
}
|
||||
|
||||
return Inertia::render('Recipients/Index', [
|
||||
'initialRows' => $recipients,
|
||||
//'aliasesUsingDefaultCount' => user()->aliasesUsingDefaultCount(),
|
||||
'search' => $validated['search'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function aliasCount(Request $request)
|
||||
{
|
||||
// Validate search query
|
||||
$validated = $request->validate([
|
||||
'ids' => 'required|array|max:30|min:1',
|
||||
'ids.*' => 'required|uuid|distinct',
|
||||
]);
|
||||
|
||||
$count = user()->recipients()
|
||||
->whereIn('id', $validated['ids'])
|
||||
->select([
|
||||
'id',
|
||||
'user_id',
|
||||
])->withCount([
|
||||
'aliases',
|
||||
'domainAliasesUsingAsDefault' => function (Builder $query) {
|
||||
$query->doesntHave('recipients');
|
||||
},
|
||||
'usernameAliasesUsingAsDefault' => function (Builder $query) {
|
||||
$query->doesntHave('recipients');
|
||||
},
|
||||
])->latest()->get(); // Must order by the same to ensure keys match
|
||||
|
||||
return response()->json([
|
||||
'count' => $count,
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
$recipient = user()->recipients()->findOrFail($id);
|
||||
|
||||
return Inertia::render('Recipients/Edit', [
|
||||
'initialRecipient' => $recipient->only(['id', 'user_id', 'email', 'can_reply_send', 'fingerprint', 'protected_headers', 'inline_encryption', 'email_verified_at', 'updated_at']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,15 +2,28 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ShowRuleController extends Controller
|
||||
{
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
return view('rules.index', [
|
||||
'rules' => user()
|
||||
// Validate search query
|
||||
$validated = $request->validate([
|
||||
'search' => 'nullable|string|max:50|min:2',
|
||||
]);
|
||||
|
||||
return Inertia::render('Rules', [
|
||||
'initialRows' => user()
|
||||
->rules()
|
||||
->when($request->input('search'), function ($query, $search) {
|
||||
return $query->where('name', 'like', '%'.$search.'%');
|
||||
})
|
||||
->orderBy('order')
|
||||
->get()
|
||||
->get(),
|
||||
'recipientOptions' => user()->verifiedRecipients()->select(['id', 'email'])->get(),
|
||||
'search' => $validated['search'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,17 +2,49 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ShowUsernameController extends Controller
|
||||
{
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
return view('usernames.index', [
|
||||
'usernames' => user()
|
||||
->usernames()
|
||||
->with('defaultRecipient:id,email')
|
||||
->withCount('aliases')
|
||||
->latest()
|
||||
->get()
|
||||
// Validate search query
|
||||
$validated = $request->validate([
|
||||
'search' => 'nullable|string|max:50|min:2',
|
||||
]);
|
||||
|
||||
$usernames = user()
|
||||
->usernames()
|
||||
->select(['id', 'user_id', 'default_recipient_id', 'username', 'description', 'active', 'catch_all', 'created_at'])
|
||||
->with('defaultRecipient:id,email')
|
||||
->withCount('aliases')
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
if (isset($validated['search'])) {
|
||||
$searchTerm = strtolower($validated['search']);
|
||||
|
||||
$usernames = $usernames->filter(function ($username) use ($searchTerm) {
|
||||
return Str::contains(strtolower($username->username), $searchTerm) || Str::contains(strtolower($username->description), $searchTerm);
|
||||
})->values();
|
||||
}
|
||||
|
||||
return Inertia::render('Usernames/Index', [
|
||||
'initialRows' => $usernames,
|
||||
'recipientOptions' => user()->verifiedRecipients()->select(['id', 'email'])->get(),
|
||||
'search' => $validated['search'] ?? null,
|
||||
'usernameCount' => (int) config('anonaddy.additional_username_limit'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
$username = user()->usernames()->findOrFail($id);
|
||||
|
||||
return Inertia::render('Usernames/Edit', [
|
||||
'initialUsername' => $username->only(['id', 'user_id', 'username', 'description', 'from_name', 'can_login', 'auto_create_regex', 'updated_at']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
19
app/Http/Controllers/StoreFailedDeliveryController.php
Normal file
19
app/Http/Controllers/StoreFailedDeliveryController.php
Normal file
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\UpdateStoreFailedDeliveryRequest;
|
||||
|
||||
class StoreFailedDeliveryController extends Controller
|
||||
{
|
||||
public function update(UpdateStoreFailedDeliveryRequest $request)
|
||||
{
|
||||
if ($request->store_failed_deliveries) {
|
||||
user()->update(['store_failed_deliveries' => true]);
|
||||
} else {
|
||||
user()->update(['store_failed_deliveries' => false]);
|
||||
}
|
||||
|
||||
return back()->with(['flash' => $request->store_failed_deliveries ? 'Store Failed Deliveries Enabled Successfully' : 'Store Failed Deliveries Disabled Successfully']);
|
||||
}
|
||||
}
|
31
app/Http/Controllers/TestAutoCreateRegexController.php
Normal file
31
app/Http/Controllers/TestAutoCreateRegexController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -14,6 +14,6 @@ class UseReplyToController extends Controller
|
|||
user()->update(['use_reply_to' => false]);
|
||||
}
|
||||
|
||||
return back()->with(['status' => $request->use_reply_to ? 'Use Reply To Enabled Successfully' : 'Use Reply To Disabled Successfully']);
|
||||
return back()->with(['flash' => $request->use_reply_to ? 'Use Reply To Enabled Successfully' : 'Use Reply To Disabled Successfully']);
|
||||
}
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue