352 lines
11 KiB
PHP
352 lines
11 KiB
PHP
<?php
|
|
|
|
require __DIR__.'/vendor/autoload.php';
|
|
|
|
use Illuminate\Database\Capsule\Manager as Database;
|
|
use ParagonIE\ConstantTime\Base32;
|
|
|
|
try {
|
|
$repository = Dotenv\Repository\RepositoryBuilder::createWithDefaultAdapters()
|
|
->allowList([
|
|
'DB_HOST',
|
|
'DB_PORT',
|
|
'DB_DATABASE',
|
|
'DB_USERNAME',
|
|
'DB_PASSWORD',
|
|
'DB_SOCKET',
|
|
'MYSQL_ATTR_SSL_CA',
|
|
'ACTION_DOES_NOT_EXIST',
|
|
'ACTION_ALIAS_DISCARD',
|
|
'ACTION_USERNAME_DISCARD',
|
|
'ACTION_DOMAIN_DISCARD',
|
|
'ACTION_REJECT',
|
|
'ACTION_DEFER',
|
|
'ACTION_DEFER_NEW',
|
|
'ANONADDY_ALL_DOMAINS',
|
|
'ANONADDY_SECRET',
|
|
'ANONADDY_ADMIN_USERNAME',
|
|
])
|
|
->make();
|
|
|
|
$dotenv = Dotenv\Dotenv::create($repository, dirname(__DIR__));
|
|
$dotenv->load();
|
|
|
|
$database = new Database();
|
|
|
|
$database->addConnection([
|
|
'driver' => 'mysql',
|
|
'read' => [
|
|
'host' => [
|
|
$_ENV['DB_HOST'] ?? '127.0.0.1',
|
|
],
|
|
],
|
|
'write' => [
|
|
'host' => [
|
|
$_ENV['DB_HOST'] ?? '127.0.0.1',
|
|
],
|
|
],
|
|
'port' => $_ENV['DB_PORT'] ?? '3306',
|
|
'database' => $_ENV['DB_DATABASE'] ?? 'forge',
|
|
'username' => $_ENV['DB_USERNAME'] ?? 'forge',
|
|
'password' => $_ENV['DB_PASSWORD'] ?? '',
|
|
'unix_socket' => $_ENV['DB_SOCKET'] ?? '',
|
|
'charset' => 'utf8mb4',
|
|
'collation' => 'utf8mb4_unicode_ci',
|
|
'prefix' => '',
|
|
'prefix_indexes' => true,
|
|
'strict' => true,
|
|
'engine' => null,
|
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
|
PDO::MYSQL_ATTR_SSL_CA => $_ENV['MYSQL_ATTR_SSL_CA'] ?? null,
|
|
]) : [],
|
|
'sticky' => true,
|
|
]);
|
|
|
|
// Make this instance available globally via static methods
|
|
$database->setAsGlobal();
|
|
|
|
// Define actions, these can be overridden by adding the variables to your .env file
|
|
// e.g. ACTION_DOES_NOT_EXIST='550 5.1.1 User not found'
|
|
define('ACTION_DOES_NOT_EXIST', $_ENV['ACTION_DOES_NOT_EXIST'] ?? '550 5.1.1 Address does not exist');
|
|
define('ACTION_ALIAS_DISCARD', $_ENV['ACTION_ALIAS_DISCARD'] ?? 'DISCARD is inactive alias');
|
|
define('ACTION_USERNAME_DISCARD', $_ENV['ACTION_USERNAME_DISCARD'] ?? 'DISCARD has inactive username');
|
|
define('ACTION_DOMAIN_DISCARD', $_ENV['ACTION_DOMAIN_DISCARD'] ?? 'DISCARD has inactive domain');
|
|
define('ACTION_REJECT', $_ENV['ACTION_REJECT'] ?? '552 5.2.2 User over quota');
|
|
define('ACTION_DEFER', $_ENV['ACTION_DEFER'] ?? '452 4.2.2 User over quota');
|
|
define('ACTION_DEFER_NEW', $_ENV['ACTION_DEFER_NEW'] ?? '450 4.2.1 User over quota');
|
|
|
|
$args = getArgs();
|
|
|
|
$allDomains = explode(',', $_ENV['ANONADDY_ALL_DOMAINS'] ?? '');
|
|
|
|
$adminUsername = $_ENV['ANONADDY_ADMIN_USERNAME'] ?? null;
|
|
|
|
$aliasEmail = strtolower($args['recipient']);
|
|
|
|
// If no alias email is provided then exit
|
|
if (empty($aliasEmail) || empty($allDomains)) {
|
|
logData('No alias email or $allDomains not set.');
|
|
exit(0);
|
|
}
|
|
|
|
//$senderEmail = strtolower($args['sender']);
|
|
$aliasDomain = explode('@', $aliasEmail)[1];
|
|
$aliasHasSharedDomain = in_array($aliasDomain, $allDomains);
|
|
|
|
// Check if it is a bounce with a valid VERP...
|
|
if (substr($aliasEmail, 0, 2) === 'b_') {
|
|
if ($outboundMessageId = getIdFromVerp($aliasEmail)) {
|
|
// Is a valid bounce
|
|
$outboundMessage = Database::table('outbound_messages')->find($outboundMessageId);
|
|
|
|
// If there is no outbound message found or no alias_id then reject since we cannot forward this to the user
|
|
if (is_null($outboundMessage) || (is_null($outboundMessage?->alias_id) && $outboundMessage->bounced)) {
|
|
// Must have been more than 7 days or a notification that has already bounced
|
|
sendAction(ACTION_DOES_NOT_EXIST);
|
|
} else {
|
|
// Allow through, may be an auto-reply etc.
|
|
sendAction('DUNNO');
|
|
}
|
|
|
|
// exit to prevent running the rest of the script
|
|
exit(0);
|
|
}
|
|
}
|
|
|
|
// Check if the alias has a username subdomain
|
|
$aliasHasUsernameDomain = ! empty(array_filter($allDomains, fn ($domain) => endsWith($aliasDomain, ".{$domain}")));
|
|
|
|
// If the alias has a plus extension then remove it
|
|
if (str_contains($aliasEmail, '+')) {
|
|
$aliasEmail = before($aliasEmail, '+').'@'.afterLast($aliasEmail, '@');
|
|
}
|
|
|
|
// Check if the alias already exists or not
|
|
$noAliasExists = Database::table('aliases')->select('id')->where('email', $aliasEmail)->doesntExist();
|
|
|
|
if ($noAliasExists && $aliasHasSharedDomain) {
|
|
// If admin username is set then allow through with catch-all
|
|
if ($adminUsername) {
|
|
sendAction('DUNNO');
|
|
} else {
|
|
sendAction(ACTION_DOES_NOT_EXIST);
|
|
}
|
|
} else {
|
|
$aliasAction = null;
|
|
|
|
if (! $noAliasExists) {
|
|
$aliasActionQuery = Database::table('aliases')
|
|
->leftJoin('users', 'aliases.user_id', '=', 'users.id')
|
|
->where('aliases.email', $aliasEmail)
|
|
->selectRaw('CASE
|
|
WHEN aliases.deleted_at IS NOT NULL THEN ?
|
|
WHEN aliases.active = 0 THEN ?
|
|
WHEN users.reject_until > NOW() THEN ?
|
|
WHEN users.defer_until > NOW() THEN ?
|
|
ELSE "DUNNO"
|
|
END', [
|
|
ACTION_DOES_NOT_EXIST,
|
|
ACTION_ALIAS_DISCARD,
|
|
ACTION_REJECT,
|
|
ACTION_DEFER,
|
|
])
|
|
->first();
|
|
|
|
$aliasAction = getAction($aliasActionQuery);
|
|
}
|
|
|
|
if (in_array($aliasAction, [ACTION_ALIAS_DISCARD, ACTION_DOES_NOT_EXIST])) {
|
|
// If the alias is inactive or deleted then increment the blocked count
|
|
Database::table('aliases')
|
|
->where('email', $aliasEmail)
|
|
->increment('emails_blocked');
|
|
|
|
sendAction($aliasAction);
|
|
} elseif ($aliasHasSharedDomain || in_array($aliasAction, [ACTION_REJECT, ACTION_DEFER])) {
|
|
// If the alias has a shared domain then we don't need to check the usernames or domains
|
|
|
|
sendAction($aliasAction);
|
|
} elseif ($aliasHasUsernameDomain) {
|
|
$concatDomainsStatement = array_reduce(array_keys($allDomains), function ($carry, $key) {
|
|
$comma = $key === 0 ? '' : ',';
|
|
|
|
return "{$carry}{$comma}CONCAT(usernames.username, ?)";
|
|
}, '');
|
|
|
|
$dotDomains = array_map(fn ($domain) => ".{$domain}", $allDomains);
|
|
|
|
$usernameActionQuery = Database::table('usernames')
|
|
->leftJoin('users', 'usernames.user_id', '=', 'users.id')
|
|
->whereRaw('? IN ('.$concatDomainsStatement.')', [$aliasDomain, ...$dotDomains])
|
|
->selectRaw('CASE
|
|
WHEN ? AND usernames.catch_all = 0 THEN ?
|
|
WHEN usernames.active = 0 THEN ?
|
|
WHEN users.reject_until > NOW() THEN ?
|
|
WHEN users.defer_until > NOW() THEN ?
|
|
WHEN ? AND users.defer_new_aliases_until > NOW() THEN ?
|
|
ELSE "DUNNO"
|
|
END', [
|
|
$noAliasExists,
|
|
ACTION_DOES_NOT_EXIST,
|
|
ACTION_USERNAME_DISCARD,
|
|
ACTION_REJECT,
|
|
ACTION_DEFER,
|
|
$noAliasExists,
|
|
ACTION_DEFER_NEW,
|
|
])
|
|
->first();
|
|
|
|
sendAction(getAction($usernameActionQuery));
|
|
} else {
|
|
$domainActionQuery = Database::table('domains')
|
|
->leftJoin('users', 'domains.user_id', '=', 'users.id')
|
|
->where('domains.domain', $aliasDomain)
|
|
->selectRaw('CASE
|
|
WHEN ? AND domains.catch_all = 0 THEN ?
|
|
WHEN domains.active = 0 THEN ?
|
|
WHEN users.reject_until > NOW() THEN ?
|
|
WHEN users.defer_until > NOW() THEN ?
|
|
WHEN ? AND users.defer_new_aliases_until > NOW() THEN ?
|
|
ELSE "DUNNO"
|
|
END', [
|
|
$noAliasExists,
|
|
ACTION_DOES_NOT_EXIST,
|
|
ACTION_DOMAIN_DISCARD,
|
|
ACTION_REJECT,
|
|
ACTION_DEFER,
|
|
$noAliasExists,
|
|
ACTION_DEFER_NEW,
|
|
])
|
|
->first();
|
|
|
|
sendAction(getAction($domainActionQuery));
|
|
}
|
|
}
|
|
} catch (\Exception $e) {
|
|
logData($e->getMessage());
|
|
|
|
exit(0);
|
|
}
|
|
|
|
// Get the arguments sent by Postfix
|
|
function getArgs()
|
|
{
|
|
$args = [];
|
|
while ($line = trim(fgets(STDIN))) {
|
|
[$key, $value] = explode('=', $line, 2);
|
|
$args[$key] = $value;
|
|
}
|
|
|
|
return $args;
|
|
}
|
|
|
|
// Get the action from the action query result
|
|
function getAction($actionQuery)
|
|
{
|
|
return is_object($actionQuery) ? array_values(get_object_vars($actionQuery))[0] : null;
|
|
}
|
|
|
|
// Send the action back to Postfix
|
|
function sendAction($action)
|
|
{
|
|
echo 'action='.$action."\n\n";
|
|
}
|
|
|
|
// Get the outbound message ID from the VERP address
|
|
function getIdFromVerp($verp)
|
|
{
|
|
$localPart = beforeLast($verp, '@');
|
|
|
|
$parts = explode('_', $localPart);
|
|
|
|
if (count($parts) !== 3) {
|
|
//logData('VERP invalid email: '.$verp);
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$id = Base32::decodeNoPadding($parts[1]);
|
|
|
|
$signature = Base32::decodeNoPadding($parts[2]);
|
|
} catch (\Exception $e) {
|
|
logData('VERP base32 decode failure: '.$verp.' '.$e->getMessage());
|
|
|
|
return;
|
|
}
|
|
|
|
$expectedSignature = substr(hash_hmac('sha3-224', $id, $_ENV['ANONADDY_SECRET'] ?? ''), 0, 8);
|
|
|
|
if ($signature !== $expectedSignature) {
|
|
logData('VERP invalid signature: '.$verp);
|
|
|
|
return;
|
|
}
|
|
|
|
return $id;
|
|
}
|
|
|
|
// Get the portion of a string before the first occurrence of a given value
|
|
function before($subject, $search)
|
|
{
|
|
if ($search === '') {
|
|
return $subject;
|
|
}
|
|
|
|
$result = strstr($subject, (string) $search, true);
|
|
|
|
return $result === false ? $subject : $result;
|
|
}
|
|
|
|
// Get the portion of a string before the last occurrence of a given value.
|
|
function beforeLast($subject, $search)
|
|
{
|
|
if ($search === '') {
|
|
return $subject;
|
|
}
|
|
|
|
$pos = mb_strrpos($subject, $search);
|
|
|
|
if ($pos === false) {
|
|
return $subject;
|
|
}
|
|
|
|
return mb_substr($subject, 0, $pos, 'UTF-8');
|
|
}
|
|
|
|
// Return the remainder of a string after the last occurrence of a given value
|
|
function afterLast($subject, $search)
|
|
{
|
|
if ($search === '') {
|
|
return $subject;
|
|
}
|
|
|
|
$position = strrpos($subject, (string) $search);
|
|
|
|
if ($position === false) {
|
|
return $subject;
|
|
}
|
|
|
|
return substr($subject, $position + strlen($search));
|
|
}
|
|
|
|
// Determine if a given string ends with a given substring
|
|
function endsWith($haystack, $needles)
|
|
{
|
|
if (! is_iterable($needles)) {
|
|
$needles = (array) $needles;
|
|
}
|
|
|
|
foreach ($needles as $needle) {
|
|
if ((string) $needle !== '' && str_ends_with($haystack, $needle)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function logData($data)
|
|
{
|
|
file_put_contents(__DIR__.'/../storage/logs/postfix-access-policy.log', '['.(new DateTime())->format('Y-m-d H:i:s').'] '.$data.PHP_EOL, FILE_APPEND);
|
|
}
|