Page MenuHomePhorge

D2623.1775163230.diff
No OneTemporary

Authored By
Unknown
Size
144 KB
Referenced Files
None
Subscribers
None

D2623.1775163230.diff

diff --git a/bin/quickstart.sh b/bin/quickstart.sh
--- a/bin/quickstart.sh
+++ b/bin/quickstart.sh
@@ -89,6 +89,8 @@
rm -rf database/database.sqlite
./artisan db:ping --wait
php -dmemory_limit=512M ./artisan migrate:refresh --seed
+./artisan data:import
+./artisan swoole:http stop >/dev/null 2>&1 || :
./artisan swoole:http start
popd
diff --git a/extras/kolab_policy_greylist.py b/extras/kolab_policy_greylist.py
new file mode 100755
--- /dev/null
+++ b/extras/kolab_policy_greylist.py
@@ -0,0 +1,79 @@
+#!/usr/bin/python3
+"""
+An example implementation of a policy service.
+"""
+
+import json
+import time
+import sys
+
+import requests
+
+
+def read_request_input():
+ """
+ Read a single policy request from sys.stdin, and return a dictionary
+ containing the request.
+ """
+ start_time = time.time()
+
+ policy_request = {}
+ end_of_request = False
+
+ while not end_of_request:
+ if (time.time() - start_time) >= 10:
+ sys.exit(0)
+
+ request_line = sys.stdin.readline()
+
+ if request_line.strip() == '':
+ if 'request' in policy_request:
+ end_of_request = True
+ else:
+ request_line = request_line.strip()
+ request_key = request_line.split('=')[0]
+ request_value = '='.join(request_line.split('=')[1:])
+
+ policy_request[request_key] = request_value
+
+ return policy_request
+
+
+if __name__ == "__main__":
+ URL = 'https://services.kolabnow.com/api/webhooks/policy/greylist'
+
+ # Start the work
+ while True:
+ REQUEST = read_request_input()
+
+ try:
+ RESPONSE = requests.post(
+ URL,
+ data=REQUEST,
+ verify=True
+ )
+ # pylint: disable=broad-except
+ except Exception:
+ print("action=DEFER_IF_PERMIT Temporary error, try again later.")
+ sys.exit(1)
+
+ try:
+ R = json.loads(RESPONSE.text)
+ # pylint: disable=broad-except
+ except Exception:
+ sys.exit(1)
+
+ if 'prepend' in R:
+ for prepend in R['prepend']:
+ print("action=PREPEND {0}".format(prepend))
+
+ if RESPONSE.ok:
+ print("action={0}\n".format(R['response']))
+
+ sys.stdout.flush()
+ else:
+ print("action={0} {1}\n".format(R['response'], R['reason']))
+
+ sys.stdout.flush()
+
+ sys.exit(0)
diff --git a/extras/kolab_policy_ratelimit.py b/extras/kolab_policy_ratelimit.py
new file mode 100755
--- /dev/null
+++ b/extras/kolab_policy_ratelimit.py
@@ -0,0 +1,79 @@
+#!/usr/bin/python3
+"""
+This policy applies rate limitations
+"""
+
+import json
+import time
+import sys
+
+import requests
+
+
+def read_request_input():
+ """
+ Read a single policy request from sys.stdin, and return a dictionary
+ containing the request.
+ """
+ start_time = time.time()
+
+ policy_request = {}
+ end_of_request = False
+
+ while not end_of_request:
+ if (time.time() - start_time) >= 10:
+ sys.exit(0)
+
+ request_line = sys.stdin.readline()
+
+ if request_line.strip() == '':
+ if 'request' in policy_request:
+ end_of_request = True
+ else:
+ request_line = request_line.strip()
+ request_key = request_line.split('=')[0]
+ request_value = '='.join(request_line.split('=')[1:])
+
+ policy_request[request_key] = request_value
+
+ return policy_request
+
+
+if __name__ == "__main__":
+ URL = 'https://services.kolabnow.com/api/webhooks/policy/ratelimit'
+
+ # Start the work
+ while True:
+ REQUEST = read_request_input()
+
+ try:
+ RESPONSE = requests.post(
+ URL,
+ data=REQUEST,
+ verify=True
+ )
+ # pylint: disable=broad-except
+ except Exception:
+ print("action=DEFER_IF_PERMIT Temporary error, try again later.")
+ sys.exit(1)
+
+ try:
+ R = json.loads(RESPONSE.text)
+ # pylint: disable=broad-except
+ except Exception:
+ sys.exit(1)
+
+ if 'prepend' in R:
+ for prepend in R['prepend']:
+ print("action=PREPEND {0}".format(prepend))
+
+ if RESPONSE.ok:
+ print("action={0}\n".format(R['response']))
+
+ sys.stdout.flush()
+ else:
+ print("action={0} {1}\n".format(R['response'], R['reason']))
+
+ sys.stdout.flush()
+
+ sys.exit(0)
diff --git a/extras/kolab_policy_spf.py b/extras/kolab_policy_spf.py
new file mode 100755
--- /dev/null
+++ b/extras/kolab_policy_spf.py
@@ -0,0 +1,80 @@
+#!/usr/bin/python3
+"""
+This is the implementation of a (postfix) MTA policy service to enforce the
+Sender Policy Framework.
+"""
+
+import json
+import time
+import sys
+
+import requests
+
+
+def read_request_input():
+ """
+ Read a single policy request from sys.stdin, and return a dictionary
+ containing the request.
+ """
+ start_time = time.time()
+
+ policy_request = {}
+ end_of_request = False
+
+ while not end_of_request:
+ if (time.time() - start_time) >= 10:
+ sys.exit(0)
+
+ request_line = sys.stdin.readline()
+
+ if request_line.strip() == '':
+ if 'request' in policy_request:
+ end_of_request = True
+ else:
+ request_line = request_line.strip()
+ request_key = request_line.split('=')[0]
+ request_value = '='.join(request_line.split('=')[1:])
+
+ policy_request[request_key] = request_value
+
+ return policy_request
+
+
+if __name__ == "__main__":
+ URL = 'https://services.kolabnow.com/api/webhooks/policy/spf'
+
+ # Start the work
+ while True:
+ REQUEST = read_request_input()
+
+ try:
+ RESPONSE = requests.post(
+ URL,
+ data=REQUEST,
+ verify=True
+ )
+ # pylint: disable=broad-except
+ except Exception:
+ print("action=DEFER_IF_PERMIT Temporary error, try again later.")
+ sys.exit(1)
+
+ try:
+ R = json.loads(RESPONSE.text)
+ # pylint: disable=broad-except
+ except Exception:
+ sys.exit(1)
+
+ if 'prepend' in R:
+ for prepend in R['prepend']:
+ print("action=PREPEND {0}".format(prepend))
+
+ if RESPONSE.ok:
+ print("action={0}\n".format(R['response']))
+
+ sys.stdout.flush()
+ else:
+ print("action={0} {1}\n".format(R['response'], R['reason']))
+
+ sys.stdout.flush()
+
+ sys.exit(0)
diff --git a/src/.gitignore b/src/.gitignore
--- a/src/.gitignore
+++ b/src/.gitignore
@@ -7,6 +7,8 @@
public/js/*.js
public/storage/
storage/*.key
+storage/*.log
+storage/*-????-??-??*
storage/export/
tests/report/
vendor
diff --git a/src/app/Console/Commands/User/GreylistCommand.php b/src/app/Console/Commands/User/GreylistCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/User/GreylistCommand.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace App\Console\Commands\User;
+
+use App\Console\Command;
+
+class GreylistCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'user:greylist {user}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'List currently greylisted delivery attempts for the user.';
+
+ /**
+ * Create a new command instance.
+ *
+ * @return void
+ */
+ public function __construct()
+ {
+ parent::__construct();
+ }
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ // pretend that all users are local;
+ $recipientAddress = $this->argument('user');
+ $recipientHash = hash('sha256', $recipientAddress);
+
+ $lastConnect = \App\Policy\Greylist\Connect::where('recipient_hash', $recipientHash)
+ ->orderBy('updated_at', 'desc')
+ ->first();
+
+ if ($lastConnect) {
+ $timestamp = $lastConnect->updated_at->copy();
+ $this->info("Going from timestamp (last connect) {$timestamp}");
+ } else {
+ $timestamp = \Carbon\Carbon::now();
+ $this->info("Going from timestamp (now) {$timestamp}");
+ }
+
+
+ \App\Policy\Greylist\Connect::where('recipient_hash', $recipientHash)
+ ->where('greylisting', true)
+ ->whereDate('updated_at', '>=', $timestamp->copy()->subDays(7))
+ ->orderBy('created_at')->each(
+ function ($connect) {
+ $this->info(
+ sprintf(
+ "From %s@%s since %s",
+ $connect->sender_local,
+ $connect->sender_domain,
+ $connect->created_at
+ )
+ );
+ }
+ );
+ }
+}
diff --git a/src/app/Domain.php b/src/app/Domain.php
--- a/src/app/Domain.php
+++ b/src/app/Domain.php
@@ -3,6 +3,8 @@
namespace App;
use App\Wallet;
+use App\Traits\DomainConfigTrait;
+use App\Traits\SettingsTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -16,6 +18,8 @@
*/
class Domain extends Model
{
+ use DomainConfigTrait;
+ use SettingsTrait;
use SoftDeletes;
// we've simply never heard of this domain
@@ -366,6 +370,16 @@
}
/**
+ * Any (additional) properties of this domain.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function settings()
+ {
+ return $this->hasMany('App\DomainSetting', 'domain_id');
+ }
+
+ /**
* Suspend this domain.
*
* @return void
diff --git a/src/app/DomainSetting.php b/src/app/DomainSetting.php
new file mode 100644
--- /dev/null
+++ b/src/app/DomainSetting.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * A collection of settings for a Domain.
+ *
+ * @property int $id
+ * @property int $domain_id
+ * @property string $key
+ * @property string $value
+ */
+class DomainSetting extends Model
+{
+ protected $fillable = [
+ 'domain_id', 'key', 'value'
+ ];
+
+ /**
+ * The domain to which this setting belongs.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function domain()
+ {
+ return $this->belongsTo(
+ '\App\Domain',
+ 'domain_id', /* local */
+ 'id' /* remote */
+ );
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
--- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
@@ -185,6 +185,8 @@
$response['skus'][$sku->id]['costs'][] = $ent->cost;
}
+ $response['config'] = $user->getConfig();
+
return response()->json($response);
}
diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php
--- a/src/app/Http/Controllers/API/V4/DomainsController.php
+++ b/src/app/Http/Controllers/API/V4/DomainsController.php
@@ -99,6 +99,39 @@
}
/**
+ * Set the domain configuration.
+ *
+ * @param int $id Domain identifier
+ *
+ * @return \Illuminate\Http\JsonResponse|void
+ */
+ public function setConfig($id)
+ {
+ $domain = Domain::find($id);
+
+ if (empty($domain)) {
+ return $this->errorResponse(404);
+ }
+
+ // Only owner (or admin) has access to the domain
+ if (!$this->guard()->user()->canRead($domain)) {
+ return $this->errorResponse(403);
+ }
+
+ $errors = $domain->setConfig(request()->input());
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.domain-setconfig-success'),
+ ]);
+ }
+
+
+ /**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
@@ -138,7 +171,10 @@
// Add DNS/MX configuration for the domain
$response['dns'] = self::getDNSConfig($domain);
- $response['config'] = self::getMXConfig($domain->namespace);
+ $response['mx'] = self::getMXConfig($domain->namespace);
+
+ // Domain configuration, e.g. spf whitelist
+ $response['config'] = $domain->getConfig();
// Status info
$response['statusInfo'] = self::statusInfo($domain);
diff --git a/src/app/Http/Controllers/API/V4/PolicyController.php b/src/app/Http/Controllers/API/V4/PolicyController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/PolicyController.php
@@ -0,0 +1,230 @@
+<?php
+
+namespace App\Http\Controllers\API\V4;
+
+use App\Http\Controllers\Controller;
+use App\Providers\PaymentProvider;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Validator;
+
+class PolicyController extends Controller
+{
+ /**
+ * Take a greylist policy request
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function greylist()
+ {
+ $data = \request()->input();
+
+ list($local, $domainName) = explode('@', $data['recipient']);
+
+ $request = new \App\Policy\Greylist\Request($data);
+
+ $shouldDefer = $request->shouldDefer();
+
+ if ($shouldDefer) {
+ return response()->json(
+ ['response' => 'DEFER_IF_PERMIT', 'reason' => "Greylisted for 5 minutes. Try again later."],
+ 403
+ );
+ }
+
+ $prependGreylist = $request->headerGreylist();
+
+ $result = [
+ 'response' => 'DUNNO',
+ 'prepend' => [$prependGreylist]
+ ];
+
+ return response()->json($result, 200);
+ }
+
+ /*
+ * Apply a sensible rate limitation to a request.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function ratelimit()
+ {
+ /*
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-pass.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/spf', $data);
+ */
+/*
+ $data = \request()->input();
+
+ // TODO: normalize sender address
+ $sender = strtolower($data['sender']);
+
+ $alias = \App\UserAlias::where('alias', $sender)->first();
+
+ if (!$alias) {
+ $user = \App\User::where('email', $sender)->first();
+
+ if (!$user) {
+ // what's the situation here?
+ }
+ } else {
+ $user = $alias->user;
+ }
+
+ // TODO time-limit
+ $userRates = \App\Policy\Ratelimit::where('user_id', $user->id);
+
+ // TODO message vs. recipient limit
+ if ($userRates->count() > 10) {
+ // TODO
+ }
+
+ // this is the wallet to which the account is billed
+ $wallet = $user->wallet;
+
+ // TODO: consider $wallet->payments;
+
+ $owner = $wallet->user;
+
+ // TODO time-limit
+ $ownerRates = \App\Policy\Ratelimit::where('owner_id', $owner->id);
+
+ // TODO message vs. recipient limit (w/ user counts)
+ if ($ownerRates->count() > 10) {
+ // TODO
+ }
+*/
+ }
+
+ /*
+ * Apply the sender policy framework to a request.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function senderPolicyFramework()
+ {
+ $data = \request()->input();
+
+ list($netID, $netType) = \App\Utils::getNetFromAddress($data['client_address']);
+ list($senderLocal, $senderDomain) = explode('@', $data['sender']);
+
+ // This network can not be recognized.
+ if (!$netID) {
+ return response()->json(
+ [
+ 'response' => 'DEFER_IF_PERMIT',
+ 'reason' => 'Temporary error. Please try again later.'
+ ],
+ 403
+ );
+ }
+
+ // Compose the cache key we want.
+ $cacheKey = "{$netType}_{$netID}_{$senderDomain}";
+
+ $result = \App\Policy\SPF\Cache::get($cacheKey);
+
+ if (!$result) {
+ $environment = new \SPFLib\Check\Environment(
+ $data['client_address'],
+ $data['client_name'],
+ $data['sender']
+ );
+
+ $result = (new \SPFLib\Checker())->check($environment);
+
+ \App\Policy\SPF\Cache::set($cacheKey, serialize($result));
+ } else {
+ $result = unserialize($result);
+ }
+
+ $fail = false;
+ $prependSPF = '';
+
+ switch ($result->getCode()) {
+ case \SPFLib\Check\Result::CODE_ERROR_PERMANENT:
+ $fail = true;
+ $prependSPF = "Received-SPF: Permerror";
+ break;
+
+ case \SPFLib\Check\Result::CODE_ERROR_TEMPORARY:
+ $prependSPF = "Received-SPF: Temperror";
+ break;
+
+ case \SPFLib\Check\Result::CODE_FAIL:
+ $fail = true;
+ $prependSPF = "Received-SPF: Fail";
+ break;
+
+ case \SPFLib\Check\Result::CODE_SOFTFAIL:
+ $prependSPF = "Received-SPF: Softfail";
+ break;
+
+ case \SPFLib\Check\Result::CODE_NEUTRAL:
+ $prependSPF = "Received-SPF: Neutral";
+ break;
+
+ case \SPFLib\Check\Result::CODE_PASS:
+ $prependSPF = "Received-SPF: Pass";
+ break;
+
+ case \SPFLib\Check\Result::CODE_NONE:
+ $prependSPF = "Received-SPF: None";
+ break;
+ }
+
+ $prependSPF .= " identity=mailfrom;";
+ $prependSPF .= " client-ip={$data['client_address']};";
+ $prependSPF .= " helo={$data['client_name']};";
+ $prependSPF .= " envelope-from={$data['sender']};";
+
+ if ($fail) {
+ // TODO: check the recipient's policy, such as using barracuda for anti-spam and anti-virus as a relay for
+ // inbound mail to a local recipient address.
+ $objects = \App\Utils::findObjectsByRecipientAddress($data['recipient']);
+
+ if (!empty($objects)) {
+ // check if any of the recipient objects have whitelisted the helo, first one wins.
+ foreach ($objects as $object) {
+ if (method_exists($object, 'senderPolicyFrameworkWhitelist')) {
+ $result = $object->senderPolicyFrameworkWhitelist($data['client_name']);
+
+ if ($result) {
+ $response = [
+ 'response' => 'DUNNO',
+ 'prepend' => ["Received-SPF: Pass Check skipped at recipient's discretion"],
+ 'reason' => 'HELO name whitelisted'
+ ];
+
+ return response()->json($response, 200);
+ }
+ }
+ }
+ }
+
+ $result = [
+ 'response' => 'REJECT',
+ 'prepend' => [$prependSPF],
+ 'reason' => "Prohibited by Sender Policy Framework"
+ ];
+
+ return response()->json($result, 403);
+ }
+
+ $result = [
+ 'response' => 'DUNNO',
+ 'prepend' => [$prependSPF],
+ 'reason' => "Don't know"
+ ];
+
+ return response()->json($result, 200);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -87,6 +87,37 @@
}
/**
+ * Set user config.
+ *
+ * @param int $id The user
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function setConfig($id)
+ {
+ $user = User::find($id);
+
+ if (empty($user)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canRead($user)) {
+ return $this->errorResponse(403);
+ }
+
+ $errors = $user->setConfig(request()->input());
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => __('app.user-setconfig-success'),
+ ]);
+ }
+
+ /**
* Display information on the user account specified by $id.
*
* @param int $id The account to show information for.
@@ -119,6 +150,8 @@
$response['skus'][$sku->id]['costs'][] = $ent->cost;
}
+ $response['config'] = $user->getConfig();
+
return response()->json($response);
}
diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php
--- a/src/app/Http/Kernel.php
+++ b/src/app/Http/Kernel.php
@@ -44,7 +44,7 @@
],
'api' => [
- 'throttle:120,1',
+ //'throttle:120,1',
'bindings',
],
];
diff --git a/src/app/IP4Net.php b/src/app/IP4Net.php
--- a/src/app/IP4Net.php
+++ b/src/app/IP4Net.php
@@ -10,10 +10,31 @@
protected $table = "ip4nets";
protected $fillable = [
+ 'rir_name',
'net_number',
'net_mask',
'net_broadcast',
'country',
- 'serial'
+ 'serial',
+ 'created_at',
+ 'updated_at'
];
+
+ public static function getNet($ip, $mask = 32)
+ {
+ $query = "
+ SELECT id FROM ip4nets
+ WHERE INET_ATON(net_number) <= INET_ATON(?)
+ AND INET_ATON(net_broadcast) >= INET_ATON(?)
+ ORDER BY INET_ATON(net_number), net_mask DESC LIMIT 1
+ ";
+
+ $results = DB::select($query, [$ip, $ip]);
+
+ if (sizeof($results) == 0) {
+ return null;
+ }
+
+ return \App\IP4Net::find($results[0]->id);
+ }
}
diff --git a/src/app/Observers/SignupCodeObserver.php b/src/app/Observers/SignupCodeObserver.php
--- a/src/app/Observers/SignupCodeObserver.php
+++ b/src/app/Observers/SignupCodeObserver.php
@@ -41,7 +41,8 @@
})
->map(function ($value) {
return is_array($value) && count($value) == 1 ? $value[0] : $value;
- });
+ })
+ ->all();
$code->expires_at = Carbon::now()->addHours($exp_hours);
$code->ip_address = request()->ip();
diff --git a/src/app/Policy/Greylist/Connect.php b/src/app/Policy/Greylist/Connect.php
new file mode 100644
--- /dev/null
+++ b/src/app/Policy/Greylist/Connect.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace App\Policy\Greylist;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * @property \App\Domain $domain
+ * @property \App\Domain|\App\User $recipient
+ * @property \App\User $user
+ */
+class Connect extends Model
+{
+ protected $fillable = [
+ 'sender_local',
+ 'sender_domain',
+ 'net_id',
+ 'net_type',
+ 'recipient_hash',
+ 'recipient_id',
+ 'recipient_type',
+ 'connect_count',
+ 'created_at',
+ 'updated_at'
+ ];
+
+ protected $table = 'greylist_connect';
+
+ public function domain()
+ {
+ if ($this->recipient_type == \App\Domain::class) {
+ return $this->recipient;
+ }
+
+ return null;
+ }
+
+ // determine if the sender is a penpal of the recipient.
+ public function isPenpal()
+ {
+ return false;
+ }
+
+ public function user()
+ {
+ if ($this->recipient_type == \App\User::class) {
+ return $this->recipient;
+ }
+
+ return null;
+ }
+
+ public function net()
+ {
+ return $this->morphTo();
+ }
+
+ public function recipient()
+ {
+ return $this->morphTo();
+ }
+}
diff --git a/src/app/Policy/Greylist/Request.php b/src/app/Policy/Greylist/Request.php
new file mode 100644
--- /dev/null
+++ b/src/app/Policy/Greylist/Request.php
@@ -0,0 +1,299 @@
+<?php
+
+namespace App\Policy\Greylist;
+
+use Illuminate\Support\Facades\DB;
+
+class Request
+{
+ protected $header;
+ protected $netID;
+ protected $netType;
+ protected $recipientHash;
+ protected $recipientID = null;
+ protected $recipientType = null;
+ protected $sender;
+ protected $senderLocal;
+ protected $senderDomain;
+ protected $timestamp;
+ protected $whitelist;
+ protected $request = [];
+
+ public function __construct($request)
+ {
+ $this->request = $request;
+
+ if (array_key_exists('timestamp', $this->request)) {
+ $this->timestamp = \Carbon\Carbon::parse($this->request['timestamp']);
+ } else {
+ $this->timestamp = \Carbon\Carbon::now();
+ }
+ }
+
+ public function headerGreylist()
+ {
+ if ($this->whitelist) {
+ if ($this->whitelist->sender_local) {
+ return sprintf(
+ "Received-Greylist: sender %s whitelisted since %s",
+ $this->sender,
+ $this->whitelist->created_at->toDateString()
+ );
+ }
+
+ return sprintf(
+ "Received-Greylist: domain %s from %s whitelisted since %s (UTC)",
+ $this->senderDomain,
+ $this->request['client_address'],
+ $this->whitelist->created_at->toDateTimeString()
+ );
+ }
+
+ $connect = $this->findConnectsCollection()->orderBy('created_at')->first();
+
+ if ($connect) {
+ return sprintf(
+ "Received-Greylist: greylisted from %s until %s.",
+ $connect->created_at,
+ $this->timestamp
+ );
+ }
+
+ return "Received-Greylist: no opinion here";
+ }
+
+ public function shouldDefer()
+ {
+ $deferIfPermit = true;
+
+ list($this->netID, $this->netType) = \App\Utils::getNetFromAddress($this->request['client_address']);
+
+ if (!$this->netID) {
+ return true;
+ }
+
+ $recipient = $this->recipientFromRequest();
+
+ $this->sender = $this->senderFromRequest();
+
+ list($this->senderLocal, $this->senderDomain) = explode('@', $this->sender);
+
+ $entry = $this->findConnectsCollectionRecent()->orderBy('updated_at')->first();
+
+ if (!$entry) {
+ // purge all entries to avoid a unique constraint violation.
+ $this->findConnectsCollection()->delete();
+
+ $entry = Connect::create(
+ [
+ 'sender_local' => $this->senderLocal,
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType,
+ 'recipient_hash' => $this->recipientHash,
+ 'recipient_id' => $this->recipientID,
+ 'recipient_type' => $this->recipientType,
+ 'connect_count' => 1,
+ 'created_at' => $this->timestamp,
+ 'updated_at' => $this->timestamp
+ ]
+ );
+ }
+
+ // see if all recipients and their domains are opt-outs
+ $enabled = false;
+
+ if ($recipient) {
+ $setting = Setting::where(
+ [
+ 'object_id' => $this->recipientID,
+ 'object_type' => $this->recipientType,
+ 'key' => 'greylist_enabled'
+ ]
+ )->first();
+
+ if (!$setting) {
+ $setting = Setting::where(
+ [
+ 'object_id' => $recipient->domain()->id,
+ 'object_type' => \App\Domain::class,
+ 'key' => 'greylist_enabled'
+ ]
+ )->first();
+
+ if (!$setting) {
+ $enabled = true;
+ } else {
+ if ($setting->{'value'} !== 'false') {
+ $enabled = true;
+ }
+ }
+ } else {
+ if ($setting->{'value'} !== 'false') {
+ $enabled = true;
+ }
+ }
+ } else {
+ $enabled = true;
+ }
+
+ // the following block is to maintain statistics and state ...
+ $entries = Connect::where(
+ [
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType
+ ]
+ )
+ ->whereDate('updated_at', '>=', $this->timestamp->copy()->subDays(7));
+
+ // determine if the sender domain is a whitelist from this network
+ $this->whitelist = Whitelist::where(
+ [
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType
+ ]
+ )->first();
+
+ if ($this->whitelist) {
+ if ($this->whitelist->updated_at < $this->timestamp->copy()->subMonthsWithoutOverflow(1)) {
+ $this->whitelist->delete();
+ } else {
+ $this->whitelist->updated_at = $this->timestamp;
+ $this->whitelist->save(['timestamps' => false]);
+
+ $entries->update(
+ [
+ 'greylisting' => false,
+ 'updated_at' => $this->timestamp
+ ]
+ );
+
+ return false;
+ }
+ } else {
+ if ($entries->count() >= 5) {
+ $this->whitelist = Whitelist::create(
+ [
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType,
+ 'created_at' => $this->timestamp,
+ 'updated_at' => $this->timestamp
+ ]
+ );
+
+ $entries->update(
+ [
+ 'greylisting' => false,
+ 'updated_at' => $this->timestamp
+ ]
+ );
+ }
+ }
+
+ // TODO: determine if the sender (individual) is a whitelist
+
+ // TODO: determine if the sender is a penpal of any of the recipients. First recipient wins.
+
+ if (!$enabled) {
+ return false;
+ }
+
+ // determine if the sender, net and recipient combination has existed before, for each recipient
+ // any one recipient matching should supersede the other recipients not having matched
+ $connect = Connect::where(
+ [
+ 'sender_local' => $this->senderLocal,
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType,
+ 'recipient_id' => $this->recipientID,
+ 'recipient_type' => $this->recipientType,
+ ]
+ )
+ ->whereDate('updated_at', '>=', $this->timestamp->copy()->subMonthsWithoutOverflow(1))
+ ->orderBy('updated_at')
+ ->first();
+
+ if (!$connect) {
+ $connect = Connect::create(
+ [
+ 'sender_local' => $this->senderLocal,
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType,
+ 'recipient_id' => $this->recipientID,
+ 'recipient_type' => $this->recipientType,
+ 'connect_count' => 0,
+ 'created_at' => $this->timestamp,
+ 'updated_at' => $this->timestamp
+ ]
+ );
+ }
+
+ $connect->connect_count += 1;
+
+ // TODO: The period of time for which the greylisting persists is configurable.
+ if ($connect->created_at < $this->timestamp->copy()->subMinutes(5)) {
+ $deferIfPermit = false;
+
+ $connect->greylisting = false;
+ }
+
+ $connect->save();
+
+ return $deferIfPermit;
+ }
+
+ private function findConnectsCollection()
+ {
+ $collection = Connect::where(
+ [
+ 'sender_local' => $this->senderLocal,
+ 'sender_domain' => $this->senderDomain,
+ 'net_id' => $this->netID,
+ 'net_type' => $this->netType,
+ 'recipient_id' => $this->recipientID,
+ 'recipient_type' => $this->recipientType
+ ]
+ );
+
+ return $collection;
+ }
+
+ private function findConnectsCollectionRecent()
+ {
+ return $this->findConnectsCollection()
+ ->where('updated_at', '>=', $this->timestamp->copy()->subDays(7));
+ }
+
+ private function recipientFromRequest()
+ {
+ $recipients = \App\Utils::findObjectsByRecipientAddress($this->request['recipient']);
+
+ if (sizeof($recipients) > 1) {
+ \Log::warning(
+ "Only taking the first recipient from the request in to account for {$this->request['recipient']}"
+ );
+ }
+
+ if (count($recipients) >= 1) {
+ $recipient = $recipients[0];
+ $this->recipientID = $recipient->id;
+ $this->recipientType = get_class($recipient);
+ } else {
+ $recipient = null;
+ }
+
+ $this->recipientHash = hash('sha256', $this->request['recipient']);
+
+ return $recipient;
+ }
+
+ public function senderFromRequest()
+ {
+ return \App\Utils::normalizeAddress($this->request['sender']);
+ }
+}
diff --git a/src/app/Policy/Greylist/Setting.php b/src/app/Policy/Greylist/Setting.php
new file mode 100644
--- /dev/null
+++ b/src/app/Policy/Greylist/Setting.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace App\Policy\Greylist;
+
+use Illuminate\Database\Eloquent\Model;
+
+class Setting extends Model
+{
+ protected $table = 'greylist_settings';
+
+ protected $fillable = [
+ 'object_id',
+ 'object_type',
+ 'key',
+ 'value'
+ ];
+}
diff --git a/src/app/Policy/Greylist/Whitelist.php b/src/app/Policy/Greylist/Whitelist.php
new file mode 100644
--- /dev/null
+++ b/src/app/Policy/Greylist/Whitelist.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Policy\Greylist;
+
+use Illuminate\Database\Eloquent\Model;
+
+class Whitelist extends Model
+{
+ protected $table = 'greylist_whitelist';
+
+ protected $fillable = [
+ 'sender_local',
+ 'sender_domain',
+ 'net_id',
+ 'net_type',
+ 'created_at',
+ 'updated_at'
+ ];
+}
diff --git a/src/app/Policy/SPF/Cache.php b/src/app/Policy/SPF/Cache.php
new file mode 100644
--- /dev/null
+++ b/src/app/Policy/SPF/Cache.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Policy\SPF;
+
+use Illuminate\Support\Facades\Cache as LaravelCache;
+
+/**
+ * A caching layer for SPF check results, as sometimes the chasing of DNS entries can take a while but submissions
+ * inbound are virtually not rate-limited.
+ *
+ * A cache key should have the format of ip(4|6)_id_domain and last for 12 hours.
+ *
+ * A cache value should have a serialized version of the \SPFLib\Checker.
+ */
+class Cache
+{
+ public static function get($key)
+ {
+ if (LaravelCache::has($key)) {
+ return LaravelCache::get($key);
+ }
+
+ return null;
+ }
+
+ public static function has($key)
+ {
+ return LaravelCache::has($key);
+ }
+
+ public static function set($key, $value)
+ {
+ if (LaravelCache::has($key)) {
+ LaravelCache::forget($key);
+ }
+
+ // cache the DNS record result for 12 hours
+ LaravelCache::put($key, $value, 60 * 60 * 12);
+ }
+}
diff --git a/src/app/Traits/DomainConfigTrait.php b/src/app/Traits/DomainConfigTrait.php
new file mode 100644
--- /dev/null
+++ b/src/app/Traits/DomainConfigTrait.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Traits;
+
+trait DomainConfigTrait
+{
+ /**
+ * A helper to get the domain configuration.
+ */
+ public function getConfig(): array
+ {
+ $config = [];
+
+ $spf = $this->getSetting('spf_whitelist');
+
+ $config['spf_whitelist'] = $spf ? json_decode($spf, true) : [];
+
+ return $config;
+ }
+
+ /**
+ * A helper to update domain configuration.
+ *
+ * @param array $config An array of configuration options
+ *
+ * @return array A list of input validation errors
+ */
+ public function setConfig(array $config): array
+ {
+ $errors = [];
+
+ foreach ($config as $key => $value) {
+ // validate and save SPF whitelist entries
+ if ($key === 'spf_whitelist') {
+ if (!is_array($value)) {
+ $value = (array) $value;
+ }
+
+ foreach ($value as $i => $v) {
+ $v = rtrim($v, '.');
+
+ if (empty($v)) {
+ unset($value[$i]);
+ continue;
+ }
+
+ $value[$i] = $v;
+
+ if ($v[0] !== '.' || !filter_var(substr($v, 1), FILTER_VALIDATE_DOMAIN)) {
+ $errors[$key][$i] = \trans('validation.spf-entry-invalid');
+ }
+ }
+
+ if (empty($errors[$key])) {
+ $this->setSetting($key, json_encode($value));
+ }
+ } else {
+ $errors[$key] = \trans('validation.invalid-config-parameter');
+ }
+ }
+
+ return $errors;
+ }
+}
diff --git a/src/app/Traits/UserConfigTrait.php b/src/app/Traits/UserConfigTrait.php
new file mode 100644
--- /dev/null
+++ b/src/app/Traits/UserConfigTrait.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Traits;
+
+trait UserConfigTrait
+{
+ /**
+ * A helper to get the user configuration.
+ */
+ public function getConfig(): array
+ {
+ $config = [];
+
+ // TODO: Should we store the default value somewhere in config?
+
+ $config['greylisting'] = $this->getSetting('greylisting') !== 'false';
+
+ return $config;
+ }
+
+ /**
+ * A helper to update user configuration.
+ *
+ * @param array $config An array of configuration options
+ *
+ * @return array A list of input validation error messages
+ */
+ public function setConfig(array $config): array
+ {
+ $errors = [];
+
+ foreach ($config as $key => $value) {
+ if ($key == 'greylisting') {
+ $this->setSetting('greylisting', $value ? 'true' : 'false');
+ } else {
+ $errors[$key] = \trans('validation.invalid-config-parameter');
+ }
+ }
+
+ return $errors;
+ }
+}
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -5,6 +5,7 @@
use App\Entitlement;
use App\UserAlias;
use App\Sku;
+use App\Traits\UserConfigTrait;
use App\Traits\UserAliasesTrait;
use App\Traits\SettingsTrait;
use App\Wallet;
@@ -25,6 +26,7 @@
class User extends Authenticatable implements JWTSubject
{
use NullableFields;
+ use UserConfigTrait;
use UserAliasesTrait;
use SettingsTrait;
use SoftDeletes;
@@ -588,6 +590,46 @@
return $this;
}
+ public function senderPolicyFrameworkWhitelist($clientName)
+ {
+ $setting = $this->getSetting('spf_whitelist');
+
+ if (!$setting) {
+ return false;
+ }
+
+ $whitelist = json_decode($setting);
+
+ $matchFound = false;
+
+ foreach ($whitelist as $entry) {
+ if (substr($entry, 0, 1) == '/') {
+ $match = preg_match($entry, $clientName);
+
+ if ($match) {
+ $matchFound = true;
+ }
+
+ continue;
+ }
+
+ if (substr($entry, 0, 1) == '.') {
+ if (substr($clientName, (-1 * strlen($entry))) == $entry) {
+ $matchFound = true;
+ }
+
+ continue;
+ }
+
+ if ($entry == $clientName) {
+ $matchFound = true;
+ continue;
+ }
+ }
+
+ return $matchFound;
+ }
+
/**
* Any (additional) properties of this user.
*
diff --git a/src/app/Utils.php b/src/app/Utils.php
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -45,7 +45,7 @@
*/
public static function countryForIP($ip)
{
- if (strpos(':', $ip) === false) {
+ if (strpos($ip, ':') === false) {
$query = "
SELECT country FROM ip4nets
WHERE INET_ATON(net_number) <= INET_ATON(?)
@@ -192,6 +192,84 @@
}
/**
+ * Find an object that is the recipient for the specified address.
+ *
+ * @param string $address
+ *
+ * @return array
+ */
+ public static function findObjectsByRecipientAddress($address)
+ {
+ $address = \App\Utils::normalizeAddress($address);
+
+ list($local, $domainName) = explode('@', $address);
+
+ $domain = \App\Domain::where('namespace', $domainName)->first();
+
+ if (!$domain) {
+ return [];
+ }
+
+ $user = \App\User::where('email', $address)->first();
+
+ if ($user) {
+ return [$user];
+ }
+
+ $userAliases = \App\UserAlias::where('alias', $address)->get();
+
+ if (count($userAliases) > 0) {
+ $users = [];
+
+ foreach ($userAliases as $userAlias) {
+ $users[] = $userAlias->user;
+ }
+
+ return $users;
+ }
+
+ $userAliases = \App\UserAlias::where('alias', "catchall@{$domain->namespace}")->get();
+
+ if (count($userAliases) > 0) {
+ $users = [];
+
+ foreach ($userAliases as $userAlias) {
+ $users[] = $userAlias->user;
+ }
+
+ return $users;
+ }
+
+ return [];
+ }
+
+ /**
+ * Retrieve the network ID and Type from a client address
+ *
+ * @param string $clientAddress The IPv4 or IPv6 address.
+ *
+ * @return array An array of ID and class or null and null.
+ */
+ public static function getNetFromAddress($clientAddress)
+ {
+ if (strpos($clientAddress, ':') === false) {
+ $net = \App\IP4Net::getNet($clientAddress);
+
+ if ($net) {
+ return [$net->id, \App\IP4Net::class];
+ }
+ } else {
+ $net = \App\IP6Net::getNet($clientAddress);
+
+ if ($net) {
+ return [$net->id, \App\IP6Net::class];
+ }
+ }
+
+ return [null, null];
+ }
+
+ /**
* Calculate the broadcast address provided a net number and a prefix.
*
* @param string $net A valid IPv6 network number.
@@ -245,6 +323,32 @@
}
/**
+ * Normalize an email address.
+ *
+ * This means to lowercase and strip components separated with recipient delimiters.
+ *
+ * @param string $address The address to normalize.
+ *
+ * @return string
+ */
+ public static function normalizeAddress($address)
+ {
+ $address = strtolower($address);
+
+ list($local, $domain) = explode('@', $address);
+
+ if (strpos($local, '+') === false) {
+ return "{$local}@{$domain}";
+ }
+
+ $localComponents = explode('+', $local);
+
+ $local = array_pop($localComponents);
+
+ return "{$local}@{$domain}";
+ }
+
+ /**
* Provide all unique combinations of elements in $input, with order and duplicates irrelevant.
*
* @param array $input The input array of elements.
diff --git a/src/composer.json b/src/composer.json
--- a/src/composer.json
+++ b/src/composer.json
@@ -24,6 +24,7 @@
"laravel/framework": "6.*",
"laravel/horizon": "^3",
"laravel/tinker": "^2.4",
+ "mlocati/spf-lib": "^3.0",
"mollie/laravel-mollie": "^2.9",
"morrislaptop/laravel-queue-clear": "^1.2",
"silviolleite/laravelpwa": "^2.0",
diff --git a/src/config/cache.php b/src/config/cache.php
--- a/src/config/cache.php
+++ b/src/config/cache.php
@@ -18,7 +18,7 @@
|
*/
- 'default' => env('CACHE_DRIVER', 'file'),
+ 'default' => env('CACHE_DRIVER', 'redis'),
/*
|--------------------------------------------------------------------------
diff --git a/src/config/database.php b/src/config/database.php
--- a/src/config/database.php
+++ b/src/config/database.php
@@ -128,7 +128,7 @@
'options' => [
'cluster' => env('REDIS_CLUSTER', 'predis'),
- 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
+ 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_database_'),
],
'default' => [
diff --git a/src/database/migrations/2020_10_18_091319_create_greylist_tables.php b/src/database/migrations/2020_10_18_091319_create_greylist_tables.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2020_10_18_091319_create_greylist_tables.php
@@ -0,0 +1,123 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+// phpcs:ignore
+class CreateGreylistTables extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create(
+ 'greylist_connect',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->string('sender_local', 256);
+ $table->string('sender_domain', 256);
+ $table->string('recipient_hash', 64);
+ $table->bigInteger('recipient_id')->unsigned()->nullable();
+ $table->string('recipient_type', 16)->nullable();
+ $table->bigInteger('net_id');
+ $table->string('net_type', 16);
+ $table->boolean('greylisting')->default(true);
+ $table->bigInteger('connect_count')->unsigned()->default(1);
+ $table->timestamps();
+
+ /**
+ * Index for recipient request.
+ */
+ $table->index(
+ [
+ 'sender_local',
+ 'sender_domain',
+ 'recipient_hash',
+ 'net_id',
+ 'net_type'
+ ],
+ 'ssrnn_idx'
+ );
+
+ /**
+ * Index for domain whitelist query.
+ */
+ $table->index(
+ [
+ 'sender_domain',
+ 'net_id',
+ 'net_type'
+ ],
+ 'snn_idx'
+ );
+
+ /**
+ * Index for updated_at
+ */
+ $table->index('updated_at');
+
+ $table->unique(
+ ['sender_local', 'sender_domain', 'recipient_hash', 'net_id', 'net_type'],
+ 'ssrnn_unq'
+ );
+ }
+ );
+
+ Schema::create(
+ 'greylist_penpals',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->bigInteger('local_id');
+ $table->string('local_type', 16);
+ $table->string('remote_local', 128);
+ $table->string('remote_domain', 256);
+ $table->timestamps();
+ }
+ );
+
+ Schema::create(
+ 'greylist_settings',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->bigInteger('object_id');
+ $table->string('object_type', 16);
+ $table->string('key', 64);
+ $table->text('value');
+ $table->timestamps();
+
+ $table->index(['object_id', 'object_type', 'key'], 'ook_idx');
+ }
+ );
+
+ Schema::create(
+ 'greylist_whitelist',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->string('sender_local', 128)->nullable();
+ $table->string('sender_domain', 256);
+ $table->bigInteger('net_id');
+ $table->string('net_type', 16);
+ $table->timestamps();
+
+ $table->index(['sender_local', 'sender_domain', 'net_id', 'net_type'], 'ssnn_idx');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('greylist_connect');
+ Schema::dropIfExists('greylist_penpals');
+ Schema::dropIfExists('greylist_settings');
+ Schema::dropIfExists('greylist_whitelist');
+ }
+}
diff --git a/src/database/migrations/2020_11_20_120000_add_domains_primary_key.php b/src/database/migrations/2020_11_20_120000_add_domains_primary_key.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2020_11_20_120000_add_domains_primary_key.php
@@ -0,0 +1,41 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+// phpcs:ignore
+class AddDomainsPrimaryKey extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table(
+ 'domains',
+ function (Blueprint $table) {
+ $table->primary('id');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ if (Schema::hasTable('domains')) {
+ Schema::table(
+ 'domains',
+ function (Blueprint $table) {
+ $table->dropPrimary('id');
+ }
+ );
+ }
+ }
+}
diff --git a/src/database/migrations/2020_11_20_130000_create_domain_settings_table.php b/src/database/migrations/2020_11_20_130000_create_domain_settings_table.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2020_11_20_130000_create_domain_settings_table.php
@@ -0,0 +1,44 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+// phpcs:ignore
+class CreateDomainSettingsTable extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create(
+ 'domain_settings',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->bigInteger('domain_id');
+ $table->string('key');
+ $table->text('value');
+ $table->timestamp('created_at')->useCurrent();
+ $table->timestamp('updated_at')->useCurrent();
+
+ $table->foreign('domain_id')->references('id')->on('domains')
+ ->onDelete('cascade')->onUpdate('cascade');
+
+ $table->unique(['domain_id', 'key']);
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('domain_settings');
+ }
+}
diff --git a/src/database/migrations/2020_11_20_140000_extend_settings_value_column.php b/src/database/migrations/2020_11_20_140000_extend_settings_value_column.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2020_11_20_140000_extend_settings_value_column.php
@@ -0,0 +1,41 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+// phpcs:ignore
+class ExtendSettingsValueColumn extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table(
+ 'user_settings',
+ function (Blueprint $table) {
+ $table->text('value')->change();
+ }
+ );
+
+ Schema::table(
+ 'wallet_settings',
+ function (Blueprint $table) {
+ $table->text('value')->change();
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ // do nothing
+ }
+}
diff --git a/src/database/seeds/local/DomainSeeder.php b/src/database/seeds/local/DomainSeeder.php
--- a/src/database/seeds/local/DomainSeeder.php
+++ b/src/database/seeds/local/DomainSeeder.php
@@ -25,7 +25,8 @@
"groupoffice.ch",
"journalistmail.ch",
"legalprivilege.ch",
- "libertymail.co"
+ "libertymail.co",
+ "libertymail.net"
];
foreach ($domains as $domain) {
diff --git a/src/phpunit.xml b/src/phpunit.xml
--- a/src/phpunit.xml
+++ b/src/phpunit.xml
@@ -40,5 +40,6 @@
<server name="MAIL_DRIVER" value="array"/>
<server name="QUEUE_CONNECTION" value="sync"/>
<server name="SESSION_DRIVER" value="array"/>
+ <server name="SWOOLE_HTTP_ACCESS_LOG" value="false"/>
</php>
</phpunit>
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -189,6 +189,10 @@
isLoading() {
return isLoading > 0
},
+ tab(e) {
+ e.preventDefault()
+ $(e.target).tab('show')
+ },
errorPage(code, msg, hint) {
// Until https://github.com/vuejs/vue-router/issues/977 is implemented
// we can't really use router to display error page as it has two side
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -45,6 +45,7 @@
'domain-verify-error' => 'Domain ownership verification failed.',
'domain-suspend-success' => 'Domain suspended successfully.',
'domain-unsuspend-success' => 'Domain unsuspended successfully.',
+ 'domain-setconfig-success' => 'Domain settings updated successfully.',
'user-update-success' => 'User data updated successfully.',
'user-create-success' => 'User created successfully.',
@@ -52,6 +53,7 @@
'user-suspend-success' => 'User suspended successfully.',
'user-unsuspend-success' => 'User unsuspended successfully.',
'user-reset-2fa-success' => '2-Factor authentication reset successfully.',
+ 'user-setconfig-success' => 'User settings updated successfully.',
'search-foundxdomains' => ':x domains have been found.',
'search-foundxgroups' => ':x distribution lists have been found.',
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -64,6 +64,10 @@
'dns-verify' => "Domain DNS verification sample:",
'dns-config' => "Domain DNS configuration sample:",
'namespace' => "Namespace",
+ 'spf-whitelist' => "SPF Whitelist",
+ 'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, "
+ . "which systems are allowed to send emails with an envelope sender address within said domain.",
+ 'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: <var>.ess.barracuda.com</var>.",
'verify' => "Domain verification",
'verify-intro' => "In order to confirm that you're the actual holder of the domain, we need to run a verification process before finally activating it for email delivery.",
'verify-dns' => "The domain <b>must have one of the following entries</b> in DNS:",
@@ -95,15 +99,19 @@
'date' => "Date",
'description' => "Description",
'details' => "Details",
+ 'disabled' => "disabled",
'domain' => "Domain",
'email' => "Email Address",
+ 'enabled' => "enabled",
'firstname' => "First Name",
+ 'general' => "General",
'lastname' => "Last Name",
'none' => "none",
'or' => "or",
'password' => "Password",
'password-confirm' => "Confirm Password",
'phone' => "Phone",
+ 'settings' => "Settings",
'status' => "Status",
'surname' => "Surname",
'user' => "User",
@@ -341,6 +349,10 @@
'domains-none' => "There are no domains in this account.",
'ext-email' => "External Email",
'finances' => "Finances",
+ 'greylisting' => "Greylisting",
+ 'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender "
+ . "is temporarily rejected. The originating server should try again after a delay. "
+ . "This time the email will be accepted. Spammers usually do not reattempt mail delivery.",
'list-title' => "User accounts",
'managed-by' => "Managed by",
'new' => "New user account",
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -138,6 +138,8 @@
'notalocaluser' => 'The specified email address does not exist.',
'memberislist' => 'A recipient cannot be the same as the list address.',
'listmembersrequired' => 'At least one recipient is required.',
+ 'spf-entry-invalid' => 'The entry format is invalid. Expected a domain name starting with a dot.',
+ 'invalid-config-parameter' => 'The requested configuration parameter is not supported.',
/*
|--------------------------------------------------------------------------
diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss
--- a/src/resources/themes/app.scss
+++ b/src/resources/themes/app.scss
@@ -354,13 +354,8 @@
padding: 0.5rem 0;
}
- .form-group {
- margin-bottom: 0.5rem;
- }
-
.nav-tabs {
flex-wrap: nowrap;
- overflow-x: auto;
.nav-link {
white-space: nowrap;
@@ -368,27 +363,6 @@
}
}
- .tab-content {
- margin-top: 0.5rem;
- }
-
- .col-form-label {
- color: #666;
- font-size: 95%;
- }
-
- .form-group.plaintext .col-form-label {
- padding-bottom: 0;
- }
-
- form.read-only.short label {
- width: 35%;
-
- & + * {
- width: 65%;
- }
- }
-
#app > div.container {
margin-bottom: 1rem;
margin-top: 1rem;
@@ -469,3 +443,9 @@
}
}
}
+
+@include media-breakpoint-down(sm) {
+ .tab-pane > .card-body {
+ padding: 0.5rem;
+ }
+}
diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss
--- a/src/resources/themes/forms.scss
+++ b/src/resources/themes/forms.scss
@@ -75,3 +75,48 @@
margin-bottom: 0;
}
}
+
+// Various improvements for mobile
+@include media-breakpoint-down(sm) {
+ .form-group {
+ margin-bottom: 0.5rem;
+ }
+
+ .form-group.plaintext .col-form-label {
+ padding-bottom: 0;
+ }
+
+ form.read-only.short label {
+ width: 35%;
+
+ & + * {
+ width: 65%;
+ }
+ }
+}
+
+@include media-breakpoint-down(xs) {
+ .col-form-label {
+ color: #666;
+ font-size: 95%;
+ }
+
+ .form-group.checkbox {
+ position: relative;
+
+ & > div {
+ position: initial;
+ padding-top: 0 !important;
+
+ input {
+ position: absolute;
+ top: 0.5rem;
+ right: 1rem;
+ }
+ }
+
+ label {
+ padding-right: 2.5rem;
+ }
+ }
+}
diff --git a/src/resources/vue/Admin/Domain.vue b/src/resources/vue/Admin/Domain.vue
--- a/src/resources/vue/Admin/Domain.vue
+++ b/src/resources/vue/Admin/Domain.vue
@@ -37,10 +37,15 @@
</div>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
- <a class="nav-link active" id="tab-config" href="#domain-config" role="tab" aria-controls="domain-config" aria-selected="true">
+ <a class="nav-link active" id="tab-config" href="#domain-config" role="tab" aria-controls="domain-config" aria-selected="true" @click="$root.tab">
{{ $t('form.config') }}
</a>
</li>
+ <li class="nav-item">
+ <a class="nav-link" id="tab-settings" href="#domain-settings" role="tab" aria-controls="domain-settings" aria-selected="false" @click="$root.tab">
+ {{ $t('form.settings') }}
+ </a>
+ </li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="domain-config" role="tabpanel" aria-labelledby="tab-config">
@@ -49,7 +54,23 @@
<p>{{ $t('domain.dns-verify') }}</p>
<p><pre id="dns-verify">{{ domain.dns.join("\n") }}</pre></p>
<p>{{ $t('domain.dns-config') }}</p>
- <p><pre id="dns-config">{{ domain.config.join("\n") }}</pre></p>
+ <p><pre id="dns-config">{{ domain.mx.join("\n") }}</pre></p>
+ </div>
+ </div>
+ </div>
+ <div class="tab-pane" id="domain-settings" role="tabpanel" aria-labelledby="tab-settings">
+ <div class="card-body">
+ <div class="card-text">
+ <form class="read-only short">
+ <div class="form-group row plaintext">
+ <label for="spf_whitelist" class="col-sm-4 col-form-label">{{ $t('domain.spf-whitelist') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="spf_whitelist">
+ {{ domain.config && domain.config.spf_whitelist.length ? domain.config.spf_whitelist.join(', ') : 'none' }}
+ </span>
+ </div>
+ </div>
+ </form>
</div>
</div>
</div>
diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue
--- a/src/resources/vue/Admin/User.vue
+++ b/src/resources/vue/Admin/User.vue
@@ -117,6 +117,11 @@
{{ $t('user.distlists') }} ({{ distlists.length }})
</a>
</li>
+ <li class="nav-item">
+ <a class="nav-link" id="tab-settings" href="#user-settings" role="tab" aria-controls="user-settings" aria-selected="false">
+ Settings
+ </a>
+ </li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="user-finances" role="tabpanel" aria-labelledby="tab-finances">
@@ -302,6 +307,23 @@
</div>
</div>
</div>
+ <div class="tab-pane" id="user-settings" role="tabpanel" aria-labelledby="tab-settings">
+ <div class="card-body">
+ <div class="card-text">
+ <form class="read-only short">
+ <div class="form-group row plaintext">
+ <label for="greylisting" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="greylisting">
+ <span v-if="user.config.greylisting" class="text-success">{{ $t('form.enabled') }}</span>
+ <span v-else class="text-danger">{{ $t('form.disabled') }}</span>
+ </span>
+ </div>
+ </div>
+ </form>
+ </div>
+ </div>
+ </div>
</div>
<div id="discount-dialog" class="modal" tabindex="-1" role="dialog">
@@ -448,6 +470,7 @@
users: [],
user: {
aliases: [],
+ config: {},
wallet: {},
skus: {},
}
@@ -543,10 +566,7 @@
.catch(this.$root.errorHandler)
},
mounted() {
- $(this.$el).find('ul.nav-tabs a').on('click', e => {
- e.preventDefault()
- $(e.target).tab('show')
- })
+ $(this.$el).find('ul.nav-tabs a').on('click', this.$root.tab)
},
methods: {
capitalize(str) {
diff --git a/src/resources/vue/Domain/Info.vue b/src/resources/vue/Domain/Info.vue
--- a/src/resources/vue/Domain/Info.vue
+++ b/src/resources/vue/Domain/Info.vue
@@ -2,31 +2,70 @@
<div class="container">
<status-component :status="status" @status-update="statusUpdate"></status-component>
- <div v-if="domain && !domain.isConfirmed" class="card" id="domain-verify">
+ <div v-if="domain" class="card">
<div class="card-body">
- <div class="card-title">{{ $t('domain.verify') }}</div>
+ <div class="card-title">{{ domain.namespace }}</div>
<div class="card-text">
- <p>{{ $t('domain.verify-intro') }}</p>
- <p>
- <span v-html="$t('domain.verify-dns')"></span>
- <ul>
- <li>{{ $t('domain.verify-dns-txt') }} <code>{{ domain.hash_text }}</code></li>
- <li>{{ $t('domain.verify-dns-cname') }} <code>{{ domain.hash_cname }}.{{ domain.namespace }}. IN CNAME {{ domain.hash_code }}.{{ domain.namespace }}.</code></li>
- </ul>
- <span>{{ $t('domain.verify-outro') }}</span>
- </p>
- <p>{{ $t('domain.verify-sample') }} <pre>{{ domain.dns.join("\n") }}</pre></p>
- <button class="btn btn-primary" type="button" @click="confirm"><svg-icon icon="sync-alt"></svg-icon> {{ $t('btn.verify') }}</button>
- </div>
- </div>
- </div>
- <div v-if="domain && domain.isConfirmed" class="card" id="domain-config">
- <div class="card-body">
- <div class="card-title">{{ $t('domain.config') }}</div>
- <div class="card-text">
- <p>{{ $t('domain.config-intro', { app: $root.appName }) }}</p>
- <p>{{ $t('domain.config-sample') }} <pre>{{ domain.config.join("\n") }}</pre></p>
- <p>{{ $t('domain.config-hint') }}</p>
+ <ul class="nav nav-tabs mt-3" role="tablist">
+ <li class="nav-item" v-if="!domain.isConfirmed">
+ <a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
+ {{ $t('domain.verify') }}
+ </a>
+ </li>
+ <li class="nav-item" v-if="domain.isConfirmed">
+ <a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
+ {{ $t('domain.config') }}
+ </a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab">
+ {{ $t('form.settings') }}
+ </a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
+ <div v-if="!domain.isConfirmed" class="card-body" id="domain-verify">
+ <div class="card-text">
+ <p>{{ $t('domain.verify-intro') }}</p>
+ <p>
+ <span v-html="$t('domain.verify-dns')"></span>
+ <ul>
+ <li>{{ $t('domain.verify-dns-txt') }} <code>{{ domain.hash_text }}</code></li>
+ <li>{{ $t('domain.verify-dns-cname') }} <code>{{ domain.hash_cname }}.{{ domain.namespace }}. IN CNAME {{ domain.hash_code }}.{{ domain.namespace }}.</code></li>
+ </ul>
+ <span>{{ $t('domain.verify-outro') }}</span>
+ </p>
+ <p>{{ $t('domain.verify-sample') }} <pre>{{ domain.dns.join("\n") }}</pre></p>
+ <button class="btn btn-primary" type="button" @click="confirm"><svg-icon icon="sync-alt"></svg-icon> {{ $t('btn.verify') }}</button>
+ </div>
+ </div>
+ <div v-if="domain.isConfirmed" class="card-body" id="domain-config">
+ <div class="card-text">
+ <p>{{ $t('domain.config-intro', { app: $root.appName }) }}</p>
+ <p>{{ $t('domain.config-sample') }} <pre>{{ domain.mx.join("\n") }}</pre></p>
+ <p>{{ $t('domain.config-hint') }}</p>
+ </div>
+ </div>
+ </div>
+ <div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
+ <div class="card-body">
+ <form @submit.prevent="submitSettings">
+ <div class="form-group row">
+ <label for="spf_whitelist" class="col-sm-4 col-form-label">{{ $t('domain.spf-whitelist') }}</label>
+ <div class="col-sm-8">
+ <list-input id="spf_whitelist" name="spf_whitelist" :list="spf_whitelist"></list-input>
+ <small id="spf-hint" class="form-text text-muted">
+ {{ $t('domain.spf-whitelist-text') }}
+ <span class="d-block" v-html="$t('domain.spf-whitelist-ex')"></span>
+ </small>
+ </div>
+ </div>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
+ </form>
+ </div>
+ </div>
+ </div>
</div>
</div>
</div>
@@ -34,16 +73,19 @@
</template>
<script>
+ import ListInput from '../Widgets/ListInput'
import StatusComponent from '../Widgets/Status'
export default {
components: {
+ ListInput,
StatusComponent
},
data() {
return {
domain_id: null,
domain: null,
+ spf_whitelist: [],
status: {}
}
},
@@ -55,6 +97,7 @@
.then(response => {
this.$root.stopLoading()
this.domain = response.data
+ this.spf_whitelist = this.domain.config.spf_whitelist || []
if (!this.domain.isConfirmed) {
$('#domain-verify button').focus()
@@ -83,6 +126,16 @@
},
statusUpdate(domain) {
this.domain = Object.assign({}, this.domain, domain)
+ },
+ submitSettings() {
+ this.$root.clearFormValidation($('#settings form'))
+
+ let post = { spf_whitelist: this.spf_whitelist }
+
+ axios.post('/api/v4/domains/' + this.domain_id + '/config', post)
+ .then(response => {
+ this.$toast.success(response.data.message)
+ })
}
}
}
diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue
--- a/src/resources/vue/User/Info.vue
+++ b/src/resources/vue/User/Info.vue
@@ -14,6 +14,21 @@
</div>
<div class="card-title" v-if="user_id === 'new'">{{ $t('user.new') }}</div>
<div class="card-text">
+ <ul class="nav nav-tabs mt-3" role="tablist">
+ <li class="nav-item">
+ <a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
+ {{ $t('form.general') }}
+ </a>
+ </li>
+ <li v-if="user_id !== 'new'" class="nav-item">
+ <a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab">
+ {{ $t('form.settings') }}
+ </a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
+ <div class="card-body">
<form @submit.prevent="submit">
<div v-if="user_id !== 'new'" class="form-group row plaintext">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
@@ -159,6 +174,25 @@
</div>
<button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
</form>
+ </div>
+ </div>
+ <div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
+ <div class="card-body">
+ <form @submit.prevent="submitSettings">
+ <div class="form-group row checkbox">
+ <label for="greylisting" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label>
+ <div class="col-sm-8 pt-2">
+ <input type="checkbox" id="greylisting" name="greylisting" value="1" :checked="user.config.greylisting">
+ <small id="greylisting-hint" class="form-text text-muted">
+ {{ $t('user.greylisting-text') }}
+ </small>
+ </div>
+ </div>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
+ </form>
+ </div>
+ </div>
+ </div>
</div>
</div>
</div>
@@ -200,7 +234,7 @@
discount: 0,
discount_description: '',
user_id: null,
- user: { aliases: [] },
+ user: { aliases: [], config: [] },
packages: [],
package_id: null,
skus: [],
@@ -312,6 +346,15 @@
this.$router.push({ name: 'users' })
})
},
+ submitSettings() {
+ this.$root.clearFormValidation($('#settings form'))
+ let post = { greylisting: $('#greylisting').prop('checked') ? 1 : 0 }
+
+ axios.post('/api/v4/users/' + this.user_id + '/config', post)
+ .then(response => {
+ this.$toast.success(response.data.message)
+ })
+ },
onInputSku(e) {
let input = e.target
let sku = this.findSku(input.value)
diff --git a/src/resources/vue/Widgets/ListInput.vue b/src/resources/vue/Widgets/ListInput.vue
--- a/src/resources/vue/Widgets/ListInput.vue
+++ b/src/resources/vue/Widgets/ListInput.vue
@@ -55,10 +55,13 @@
if (this.list.length == 1) {
this.$el.classList.remove('is-invalid')
}
+
+ this.$emit('change', this.$el)
}
},
deleteItem(index) {
this.$delete(this.list, index)
+ this.$emit('change', this.$el)
if (!this.list.length) {
this.$el.classList.remove('is-invalid')
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -63,6 +63,7 @@
Route::apiResource('domains', API\V4\DomainsController::class);
Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm');
Route::get('domains/{id}/status', 'API\V4\DomainsController@status');
+ Route::post('domains/{id}/config', 'API\V4\DomainsController@setConfig');
Route::apiResource('groups', API\V4\GroupsController::class);
Route::get('groups/{id}/status', 'API\V4\GroupsController@status');
@@ -71,6 +72,7 @@
Route::apiResource('skus', API\V4\SkusController::class);
Route::apiResource('users', API\V4\UsersController::class);
+ Route::post('users/{id}/config', 'API\V4\UsersController@setConfig');
Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus');
Route::get('users/{id}/status', 'API\V4\UsersController@status');
@@ -132,7 +134,7 @@
Route::group(
[
'domain' => \config('app.domain'),
- 'prefix' => $prefix . 'api/webhooks',
+ 'prefix' => $prefix . 'api/webhooks'
],
function () {
Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook');
@@ -142,6 +144,18 @@
Route::group(
[
+ 'domain' => 'services.' . \config('app.domain'),
+ 'prefix' => $prefix . 'api/webhooks/policy'
+ ],
+ function () {
+ Route::post('greylist', 'API\V4\PolicyController@greylist');
+ Route::post('ratelimit', 'API\V4\PolicyController@ratelimit');
+ Route::post('spf', 'API\V4\PolicyController@senderPolicyFramework');
+ }
+);
+
+Route::group(
+ [
'domain' => 'admin.' . \config('app.domain'),
'middleware' => ['auth:api', 'admin'],
'prefix' => $prefix . 'api/v4',
diff --git a/src/tests/Browser/Admin/DomainTest.php b/src/tests/Browser/Admin/DomainTest.php
--- a/src/tests/Browser/Admin/DomainTest.php
+++ b/src/tests/Browser/Admin/DomainTest.php
@@ -28,6 +28,9 @@
*/
public function tearDown(): void
{
+ $domain = $this->getTestDomain('kolab.org');
+ $domain->setSetting('spf_whitelist', null);
+
parent::tearDown();
}
@@ -54,6 +57,8 @@
$john = $this->getTestUser('john@kolab.org');
$user_page = new UserPage($john->id);
+ $domain->setSetting('spf_whitelist', null);
+
// Goto the domain page
$browser->visit(new Home())
->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true)
@@ -76,7 +81,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 1);
+ ->assertElementsCount('@nav a', 2);
// Assert Configuration tab
$browser->assertSeeIn('@nav #tab-config', 'Configuration')
@@ -84,6 +89,25 @@
$browser->assertSeeIn('pre#dns-verify', 'kolab-verify.kolab.org.')
->assertSeeIn('pre#dns-config', 'kolab.org.');
});
+
+ // Assert Settings tab
+ $browser->assertSeeIn('@nav #tab-settings', 'Settings')
+ ->click('@nav #tab-settings')
+ ->with('@domain-settings form', function (Browser $browser) {
+ $browser->assertElementsCount('.row', 1)
+ ->assertSeeIn('.row:first-child label', 'SPF Whitelist')
+ ->assertSeeIn('.row:first-child .form-control-plaintext', 'none');
+ });
+
+ // Assert non-empty SPF whitelist
+ $domain->setSetting('spf_whitelist', json_encode(['.test1.com', '.test2.com']));
+
+ $browser->refresh()
+ ->waitFor('@nav #tab-settings')
+ ->click('@nav #tab-settings')
+ ->with('@domain-settings form', function (Browser $browser) {
+ $browser->assertSeeIn('.row:first-child .form-control-plaintext', '.test1.com, .test2.com');
+ });
});
}
diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php
--- a/src/tests/Browser/Admin/UserTest.php
+++ b/src/tests/Browser/Admin/UserTest.php
@@ -116,7 +116,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 6);
+ ->assertElementsCount('@nav a', 7);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -168,6 +168,15 @@
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
+
+ // Assert Settings tab
+ $browser->assertSeeIn('@nav #tab-settings', 'Settings')
+ ->click('@nav #tab-settings')
+ ->whenAvailable('@user-settings form', function (Browser $browser) {
+ $browser->assertElementsCount('.row', 1)
+ ->assertSeeIn('.row:first-child label', 'Greylisting')
+ ->assertSeeIn('.row:first-child .text-success', 'enabled');
+ });
});
}
@@ -188,6 +197,7 @@
$wallet->save();
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($john->wallets->first());
+ $john->setSetting('greylisting', null);
// Click the managed-by link on Jack's page
$browser->click('@user-info #manager a')
@@ -222,7 +232,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 6);
+ ->assertElementsCount('@nav a', 7);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -312,6 +322,7 @@
]);
$page = new UserPage($ned->id);
+ $ned->setSetting('greylisting', 'false');
$browser->click('@user-users tbody tr:nth-child(4) td:first-child a')
->on($page);
@@ -325,7 +336,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 6);
+ ->assertElementsCount('@nav a', 7);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -383,6 +394,15 @@
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
+
+ // Assert Settings tab
+ $browser->assertSeeIn('@nav #tab-settings', 'Settings')
+ ->click('@nav #tab-settings')
+ ->whenAvailable('@user-settings form', function (Browser $browser) {
+ $browser->assertElementsCount('.row', 1)
+ ->assertSeeIn('.row:first-child label', 'Greylisting')
+ ->assertSeeIn('.row:first-child .text-danger', 'disabled');
+ });
});
}
diff --git a/src/tests/Browser/Components/ListInput.php b/src/tests/Browser/Components/ListInput.php
--- a/src/tests/Browser/Components/ListInput.php
+++ b/src/tests/Browser/Components/ListInput.php
@@ -84,7 +84,7 @@
public function removeListEntry($browser, int $num)
{
$selector = '.input-group:nth-child(' . ($num + 1) . ') a.btn';
- $browser->click($selector)->assertMissing($selector);
+ $browser->click($selector);
}
/**
diff --git a/src/tests/Browser/DomainTest.php b/src/tests/Browser/DomainTest.php
--- a/src/tests/Browser/DomainTest.php
+++ b/src/tests/Browser/DomainTest.php
@@ -5,6 +5,7 @@
use App\Domain;
use App\User;
use Tests\Browser;
+use Tests\Browser\Components\ListInput;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\DomainInfo;
@@ -59,6 +60,8 @@
$domain->save();
}
+ $domain->setSetting('spf_whitelist', \json_encode(['.test.com']));
+
$browser->visit('/domain/' . $domain->id)
->on(new DomainInfo())
->whenAvailable('@verify', function ($browser) use ($domain) {
@@ -83,6 +86,47 @@
}
/**
+ * Test domain settings
+ */
+ public function testDomainSettings(): void
+ {
+ $this->browse(function ($browser) {
+ $domain = Domain::where('namespace', 'kolab.org')->first();
+ $domain->setSetting('spf_whitelist', \json_encode(['.test.com']));
+
+ $browser->visit('/domain/' . $domain->id)
+ ->on(new DomainInfo())
+ ->assertElementsCount('@nav a', 2)
+ ->assertSeeIn('@nav #tab-general', 'Domain configuration')
+ ->assertSeeIn('@nav #tab-settings', 'Settings')
+ ->click('@nav #tab-settings')
+ ->with('#settings form', function (Browser $browser) {
+ // Test whitelist widget
+ $widget = new ListInput('#spf_whitelist');
+
+ $browser->assertSeeIn('div.row:nth-child(1) label', 'SPF Whitelist')
+ ->assertVisible('div.row:nth-child(1) .list-input')
+ ->with($widget, function (Browser $browser) {
+ $browser->assertListInputValue(['.test.com'])
+ ->assertValue('@input', '')
+ ->addListEntry('invalid domain');
+ })
+ ->click('button[type=submit]')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->with($widget, function (Browser $browser) {
+ $err = 'The entry format is invalid. Expected a domain name starting with a dot.';
+ $browser->assertFormError(2, $err, false)
+ ->removeListEntry(2)
+ ->removeListEntry(1)
+ ->addListEntry('.new.domain.tld');
+ })
+ ->click('button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Domain settings updated successfully.');
+ });
+ });
+ }
+
+ /**
* Test domains list page (unauthenticated)
*/
public function testDomainListUnauth(): void
diff --git a/src/tests/Browser/Pages/Admin/Domain.php b/src/tests/Browser/Pages/Admin/Domain.php
--- a/src/tests/Browser/Pages/Admin/Domain.php
+++ b/src/tests/Browser/Pages/Admin/Domain.php
@@ -53,6 +53,7 @@
'@domain-info' => '#domain-info',
'@nav' => 'ul.nav-tabs',
'@domain-config' => '#domain-config',
+ '@domain-settings' => '#domain-settings',
];
}
}
diff --git a/src/tests/Browser/Pages/Admin/User.php b/src/tests/Browser/Pages/Admin/User.php
--- a/src/tests/Browser/Pages/Admin/User.php
+++ b/src/tests/Browser/Pages/Admin/User.php
@@ -59,6 +59,7 @@
'@user-distlists' => '#user-distlists',
'@user-domains' => '#user-domains',
'@user-users' => '#user-users',
+ '@user-settings' => '#user-settings',
];
}
}
diff --git a/src/tests/Browser/Pages/DomainInfo.php b/src/tests/Browser/Pages/DomainInfo.php
--- a/src/tests/Browser/Pages/DomainInfo.php
+++ b/src/tests/Browser/Pages/DomainInfo.php
@@ -38,8 +38,10 @@
return [
'@app' => '#app',
'@config' => '#domain-config',
- '@verify' => '#domain-verify',
+ '@nav' => 'ul.nav-tabs',
+ '@settings' => '#settings',
'@status' => '#status-box',
+ '@verify' => '#domain-verify',
];
}
}
diff --git a/src/tests/Browser/Pages/UserInfo.php b/src/tests/Browser/Pages/UserInfo.php
--- a/src/tests/Browser/Pages/UserInfo.php
+++ b/src/tests/Browser/Pages/UserInfo.php
@@ -39,7 +39,9 @@
return [
'@app' => '#app',
'@form' => '#user-info form',
+ '@nav' => 'ul.nav-tabs',
'@packages' => '#user-packages',
+ '@settings' => '#settings',
'@skus' => '#user-skus',
'@status' => '#status-box',
];
diff --git a/src/tests/Browser/Reseller/DomainTest.php b/src/tests/Browser/Reseller/DomainTest.php
--- a/src/tests/Browser/Reseller/DomainTest.php
+++ b/src/tests/Browser/Reseller/DomainTest.php
@@ -77,7 +77,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 1);
+ ->assertElementsCount('@nav a', 2);
// Assert Configuration tab
$browser->assertSeeIn('@nav #tab-config', 'Configuration')
diff --git a/src/tests/Browser/Reseller/UserTest.php b/src/tests/Browser/Reseller/UserTest.php
--- a/src/tests/Browser/Reseller/UserTest.php
+++ b/src/tests/Browser/Reseller/UserTest.php
@@ -113,7 +113,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 6);
+ ->assertElementsCount('@nav a', 7);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -219,7 +219,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 6);
+ ->assertElementsCount('@nav a', 7);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -302,7 +302,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 6);
+ ->assertElementsCount('@nav a', 7);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -447,7 +447,7 @@
$this->browse(function (Browser $browser) {
$this->deleteTestUser('userstest1@kolabnow.com');
$user = $this->getTestUser('userstest1@kolabnow.com');
- $sku2fa = Sku::firstOrCreate(['title' => '2fa']);
+ $sku2fa = Sku::withEnvTenantContext()->where(['title' => '2fa'])->first();
$user->assignSku($sku2fa);
SecondFactor::seed('userstest1@kolabnow.com');
diff --git a/src/tests/Browser/StatusTest.php b/src/tests/Browser/StatusTest.php
--- a/src/tests/Browser/StatusTest.php
+++ b/src/tests/Browser/StatusTest.php
@@ -213,7 +213,7 @@
$browser->click('@status #status-verify')
->assertToast(Toast::TYPE_SUCCESS, 'Domain verified successfully.')
->waitUntilMissing('@status')
- ->assertMissing('@verify')
+ ->waitUntilMissing('@verify')
->assertVisible('@config');
});
}
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -334,6 +334,33 @@
}
/**
+ * Test user settings tab
+ *
+ * @depends testInfo
+ */
+ public function testUserSettings(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $john->setSetting('greylisting', null);
+
+ $this->browse(function (Browser $browser) {
+ $browser->on(new UserInfo())
+ ->assertElementsCount('@nav a', 2)
+ ->assertSeeIn('@nav #tab-general', 'General')
+ ->assertSeeIn('@nav #tab-settings', 'Settings')
+ ->click('@nav #tab-settings')
+ ->with('#settings form', function (Browser $browser) {
+ $browser->assertSeeIn('div.row:nth-child(1) label', 'Greylisting')
+ ->click('div.row:nth-child(1) input[type=checkbox]:checked')
+ ->click('button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, 'User settings updated successfully.');
+ });
+ });
+
+ $this->assertSame('false', $john->fresh()->getSetting('greylisting'));
+ }
+
+ /**
* Test user adding page
*
* @depends testList
diff --git a/src/tests/Feature/Auth/SecondFactorTest.php b/src/tests/Feature/Auth/SecondFactorTest.php
--- a/src/tests/Feature/Auth/SecondFactorTest.php
+++ b/src/tests/Feature/Auth/SecondFactorTest.php
@@ -32,7 +32,7 @@
public function testEntitlementDelete(): void
{
// Create the user, and assign 2FA to him, and add Roundcube setup
- $sku_2fa = Sku::where('title', '2fa')->first();
+ $sku_2fa = Sku::withEnvTenantContext()->where('title', '2fa')->first();
$user = $this->getTestUser('entitlement-test@kolabnow.com');
$user->assignSku($sku_2fa);
SecondFactor::seed('entitlement-test@kolabnow.com');
diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php
--- a/src/tests/Feature/Controller/Admin/UsersTest.php
+++ b/src/tests/Feature/Controller/Admin/UsersTest.php
@@ -234,7 +234,7 @@
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
- $sku2fa = Sku::firstOrCreate(['title' => '2fa']);
+ $sku2fa = Sku::withEnvTenantContext()->where(['title' => '2fa'])->first();
$user->assignSku($sku2fa);
SecondFactor::seed('userscontrollertest1@userscontroller.com');
diff --git a/src/tests/Feature/Controller/DomainsTest.php b/src/tests/Feature/Controller/DomainsTest.php
--- a/src/tests/Feature/Controller/DomainsTest.php
+++ b/src/tests/Feature/Controller/DomainsTest.php
@@ -28,6 +28,9 @@
$this->deleteTestUser('test1@domainscontroller.com');
$this->deleteTestDomain('domainscontroller.com');
+ $domain = $this->getTestDomain('kolab.org');
+ $domain->settings()->whereIn('key', ['spf_whitelist'])->delete();
+
parent::tearDown();
}
@@ -125,6 +128,81 @@
}
/**
+ * Test domain config update (POST /api/v4/domains/<domain>/config)
+ */
+ public function testSetConfig(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $domain = $this->getTestDomain('kolab.org');
+ $domain->setSetting('spf_whitelist', null);
+
+ // Test unknown domain id
+ $post = ['spf_whitelist' => []];
+ $response = $this->actingAs($john)->post("/api/v4/domains/123/config", $post);
+ $json = $response->json();
+
+ $response->assertStatus(404);
+
+ // Test access by user not being a wallet controller
+ $post = ['spf_whitelist' => []];
+ $response = $this->actingAs($jack)->post("/api/v4/domains/{$domain->id}/config", $post);
+ $json = $response->json();
+
+ $response->assertStatus(403);
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("Access denied", $json['message']);
+ $this->assertCount(2, $json);
+
+ // Test some invalid data
+ $post = ['grey' => 1];
+ $response = $this->actingAs($john)->post("/api/v4/domains/{$domain->id}/config", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame('The requested configuration parameter is not supported.', $json['errors']['grey']);
+
+ $this->assertNull($domain->fresh()->getSetting('spf_whitelist'));
+
+ // Test some valid data
+ $post = ['spf_whitelist' => ['.test.domain.com']];
+ $response = $this->actingAs($john)->post("/api/v4/domains/{$domain->id}/config", $post);
+
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('Domain settings updated successfully.', $json['message']);
+
+ $expected = \json_encode($post['spf_whitelist']);
+ $this->assertSame($expected, $domain->fresh()->getSetting('spf_whitelist'));
+
+ // Test input validation
+ $post = ['spf_whitelist' => ['aaa']];
+ $response = $this->actingAs($john)->post("/api/v4/domains/{$domain->id}/config", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame(
+ 'The entry format is invalid. Expected a domain name starting with a dot.',
+ $json['errors']['spf_whitelist'][0]
+ );
+
+ $this->assertSame($expected, $domain->fresh()->getSetting('spf_whitelist'));
+ }
+
+ /**
* Test fetching domain info
*/
public function testShow(): void
@@ -155,8 +233,9 @@
$this->assertSame($domain->hash(Domain::HASH_TEXT), $json['hash_text']);
$this->assertSame($domain->hash(Domain::HASH_CNAME), $json['hash_cname']);
$this->assertSame($domain->hash(Domain::HASH_CODE), $json['hash_code']);
- $this->assertCount(4, $json['config']);
- $this->assertTrue(strpos(implode("\n", $json['config']), $domain->namespace) !== false);
+ $this->assertSame([], $json['config']['spf_whitelist']);
+ $this->assertCount(4, $json['mx']);
+ $this->assertTrue(strpos(implode("\n", $json['mx']), $domain->namespace) !== false);
$this->assertCount(8, $json['dns']);
$this->assertTrue(strpos(implode("\n", $json['dns']), $domain->namespace) !== false);
$this->assertTrue(strpos(implode("\n", $json['dns']), $domain->hash()) !== false);
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -66,6 +66,7 @@
$wallet->discount()->dissociate();
$wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete();
$wallet->save();
+ $user->settings()->whereIn('key', ['greylisting'])->delete();
$user->status |= User::STATUS_IMAP_READY;
$user->save();
@@ -244,6 +245,7 @@
$this->assertTrue(is_array($json['statusInfo']));
$this->assertTrue(is_array($json['settings']));
$this->assertTrue(is_array($json['aliases']));
+ $this->assertTrue($json['config']['greylisting']);
$this->assertSame([], $json['skus']);
// Values below are tested by Unit tests
$this->assertArrayHasKey('isDeleted', $json);
@@ -466,6 +468,75 @@
}
/**
+ * Test user config update (POST /api/v4/users/<user>/config)
+ */
+ public function testSetConfig(): void
+ {
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+
+ $john->setSetting('greylisting', null);
+
+ // Test unknown user id
+ $post = ['greylisting' => 1];
+ $response = $this->actingAs($john)->post("/api/v4/users/123/config", $post);
+ $json = $response->json();
+
+ $response->assertStatus(404);
+
+ // Test access by user not being a wallet controller
+ $post = ['greylisting' => 1];
+ $response = $this->actingAs($jack)->post("/api/v4/users/{$john->id}/config", $post);
+ $json = $response->json();
+
+ $response->assertStatus(403);
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("Access denied", $json['message']);
+ $this->assertCount(2, $json);
+
+ // Test some invalid data
+ $post = ['grey' => 1];
+ $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame('The requested configuration parameter is not supported.', $json['errors']['grey']);
+
+ $this->assertNull($john->fresh()->getSetting('greylisting'));
+
+ // Test some valid data
+ $post = ['greylisting' => 1];
+ $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('User settings updated successfully.', $json['message']);
+
+ $this->assertSame('true', $john->fresh()->getSetting('greylisting'));
+
+ // Test some valid data
+ $post = ['greylisting' => 0];
+ $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('User settings updated successfully.', $json['message']);
+
+ $this->assertSame('false', $john->fresh()->getSetting('greylisting'));
+ }
+
+ /**
* Test user creation (POST /api/v4/users)
*/
public function testStore(): void
diff --git a/src/tests/Feature/Stories/GreylistTest.php b/src/tests/Feature/Stories/GreylistTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Stories/GreylistTest.php
@@ -0,0 +1,617 @@
+<?php
+
+namespace Tests\Feature\Stories;
+
+use App\Policy\Greylist;
+use Illuminate\Support\Facades\DB;
+use Tests\TestCase;
+
+/**
+ * @group slow
+ * @group data
+ * @group greylist
+ */
+class GreylistTest extends TestCase
+{
+ private $clientAddress;
+ private $instance;
+ private $requests = [];
+ private $net;
+
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->setUpTest();
+ $this->useServicesUrl();
+ $this->instance = $this->generateInstanceId();
+ $this->clientAddress = '212.103.80.148';
+
+ $this->net = \App\IP4Net::getNet($this->clientAddress);
+
+ DB::delete("DELETE FROM greylist_connect WHERE sender_domain = 'sender.domain';");
+ DB::delete("DELETE FROM greylist_settings;");
+ DB::delete("DELETE FROM greylist_whitelist WHERE sender_domain = 'sender.domain';");
+ }
+
+ public function tearDown(): void
+ {
+ DB::delete("DELETE FROM greylist_connect WHERE sender_domain = 'sender.domain';");
+ DB::delete("DELETE FROM greylist_settings;");
+ DB::delete("DELETE FROM greylist_whitelist WHERE sender_domain = 'sender.domain';");
+
+ parent::tearDown();
+ }
+
+ public function testWithTimestamp()
+ {
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress,
+ 'client_name' => 'some.mx',
+ 'timestamp' => \Carbon\Carbon::now()->subDays(7)->toString()
+ ]
+ );
+
+ $timestamp = $this->getObjectProperty($request, 'timestamp');
+
+ $this->assertTrue(
+ \Carbon\Carbon::parse($timestamp, 'UTC') < \Carbon\Carbon::now()
+ );
+ }
+
+ public function testNoNet()
+ {
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => '127.128.129.130',
+ 'client_name' => 'some.mx'
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ public function testIp6Net()
+ {
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => '2a00:1450:400a:803::2005',
+ 'client_name' => 'some.mx'
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ // public function testMultiRecipientThroughAlias() {}
+
+ public function testWhitelistNew()
+ {
+ $whitelist = Greylist\Whitelist::where('sender_domain', 'sender.domain')->first();
+
+ $this->assertNull($whitelist);
+
+ for ($i = 0; $i < 5; $i++) {
+ $request = new Greylist\Request(
+ [
+ 'sender' => "someone{$i}@sender.domain",
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress,
+ 'client_name' => 'some.mx',
+ 'timestamp' => \Carbon\Carbon::now()->subDays(1)
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ $whitelist = Greylist\Whitelist::where('sender_domain', 'sender.domain')->first();
+
+ $this->assertNotNull($whitelist);
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => "someone5@sender.domain",
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress,
+ 'client_name' => 'some.mx',
+ 'timestamp' => \Carbon\Carbon::now()->subDays(1)
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ // public function testWhitelistedHit() {}
+
+ public function testWhitelistStale()
+ {
+ $whitelist = Greylist\Whitelist::where('sender_domain', 'sender.domain')->first();
+
+ $this->assertNull($whitelist);
+
+ for ($i = 0; $i < 5; $i++) {
+ $request = new Greylist\Request(
+ [
+ 'sender' => "someone{$i}@sender.domain",
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress,
+ 'client_name' => 'some.mx',
+ 'timestamp' => \Carbon\Carbon::now()->subDays(1)
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ $whitelist = Greylist\Whitelist::where('sender_domain', 'sender.domain')->first();
+
+ $this->assertNotNull($whitelist);
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => "someone5@sender.domain",
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress,
+ 'client_name' => 'some.mx',
+ 'timestamp' => \Carbon\Carbon::now()->subDays(1)
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+
+ $whitelist->updated_at = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2);
+ $whitelist->save(['timestamps' => false]);
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ // public function testWhitelistUpdate() {}
+
+ public function testNew()
+ {
+ $data = [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress,
+ 'client_name' => 'some.mx'
+ ];
+
+ $response = $this->post('/api/webhooks/policy/greylist', $data);
+
+ $response->assertStatus(403);
+ }
+
+ public function testRetry()
+ {
+ $connect = Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => $this->domainOwner->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $connect->created_at = \Carbon\Carbon::now()->subMinutes(6);
+ $connect->save();
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testDomainDisabled()
+ {
+ $setting = Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainHosted->id,
+ 'object_type' => \App\Domain::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testDomainEnabled()
+ {
+ $connect = Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => $this->domainOwner->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => \App\IP4Net::getNet('212.103.80.148')->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $setting = Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainHosted->id,
+ 'object_type' => \App\Domain::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'true'
+ ]
+ );
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+
+ $connect->created_at = \Carbon\Carbon::now()->subMinutes(6);
+ $connect->save();
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testDomainDisabledUserDisabled()
+ {
+ $connect = Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => $this->domainOwner->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $settingDomain = Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainHosted->id,
+ 'object_type' => \App\Domain::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ $settingUser = Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainOwner->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testDomainDisabledUserEnabled()
+ {
+ $connect = Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => $this->domainOwner->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $settingDomain = Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainHosted->id,
+ 'object_type' => \App\Domain::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ $settingUser = Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainOwner->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'true'
+ ]
+ );
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+
+ $connect->created_at = \Carbon\Carbon::now()->subMinutes(6);
+ $connect->save();
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testInvalidDomain()
+ {
+ $connect = Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => 1234,
+ 'recipient_type' => \App\Domain::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => 'not.someone@that.exists',
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ public function testInvalidUser()
+ {
+ $connect = Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => 1234,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => 'not.someone@that.exists',
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ public function testUserDisabled()
+ {
+ $connect = Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => $this->domainOwner->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $setting = Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainOwner->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testUserEnabled()
+ {
+ $connect = Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $this->domainOwner->email),
+ 'recipient_id' => $this->domainOwner->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ $setting = Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainOwner->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'true'
+ ]
+ );
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+
+ $connect->created_at = \Carbon\Carbon::now()->subMinutes(6);
+ $connect->save();
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testMultipleUsersAllDisabled()
+ {
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ foreach ($this->domainUsers as $user) {
+ Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $user->email),
+ 'recipient_id' => $user->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ Greylist\Setting::create(
+ [
+ 'object_id' => $user->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ if ($user->email == $this->domainOwner->email) {
+ continue;
+ }
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $user->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+ }
+
+ public function testMultipleUsersAnyEnabled()
+ {
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ foreach ($this->domainUsers as $user) {
+ Greylist\Connect::create(
+ [
+ 'sender_local' => 'someone',
+ 'sender_domain' => 'sender.domain',
+ 'recipient_hash' => hash('sha256', $user->email),
+ 'recipient_id' => $user->id,
+ 'recipient_type' => \App\User::class,
+ 'connect_count' => 1,
+ 'net_id' => $this->net->id,
+ 'net_type' => \App\IP4Net::class
+ ]
+ );
+
+ Greylist\Setting::create(
+ [
+ 'object_id' => $user->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => ($user->id == $this->jack->id) ? 'true' : 'false'
+ ]
+ );
+
+ if ($user->email == $this->domainOwner->email) {
+ continue;
+ }
+
+ $request = new Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $user->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ if ($user->id == $this->jack->id) {
+ $this->assertTrue($request->shouldDefer());
+ } else {
+ $this->assertFalse($request->shouldDefer());
+ }
+ }
+ }
+
+ private function generateInstanceId()
+ {
+ $instance = [];
+
+ for ($x = 0; $x < 3; $x++) {
+ for ($y = 0; $y < 3; $y++) {
+ $instance[] = substr('01234567889', rand(0, 9), 1);
+ }
+ }
+
+ return implode('.', $instance);
+ }
+}
diff --git a/src/tests/Feature/Stories/SenderPolicyFrameworkTest.php b/src/tests/Feature/Stories/SenderPolicyFrameworkTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Stories/SenderPolicyFrameworkTest.php
@@ -0,0 +1,313 @@
+<?php
+
+namespace Tests\Feature\Stories;
+
+use Tests\TestCase;
+
+/**
+ * @group slow
+ * @group data
+ * @group spf
+ */
+class SenderPolicyFrameworkTest extends TestCase
+{
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->setUpTest();
+ $this->useServicesUrl();
+ }
+
+ public function testSenderFailv4()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-fail.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+ }
+
+ public function testSenderFailv6()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-fail.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ // actually IN AAAA gmail.com.
+ 'client_address' => '2a00:1450:400a:801::2005',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $this->assertFalse(strpos(':', $data['client_address']));
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+ }
+
+ public function testSenderNone()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-none.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(200);
+ }
+
+ public function testSenderNoNet()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-none.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '256.0.0.1',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+ }
+
+ public function testSenderPass()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-pass.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(200);
+ }
+
+ public function testSenderPassAll()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-passall.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(200);
+ }
+
+ public function testSenderPermerror()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-permerror.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+ }
+
+ public function testSenderSoftfail()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-fail.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(200);
+ }
+
+ public function testSenderTemperror()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@spf-temperror.kolab.org',
+ 'client_name' => 'mx.kolabnow.com',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+ }
+
+ public function testSenderRelayPolicyHeloExactNegative()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@amazon.co.uk',
+ 'client_name' => 'helo.some.relayservice.domain',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['the.only.acceptable.helo']));
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->removeSetting('spf_whitelist');
+ }
+
+ public function testSenderRelayPolicyHeloExactPositive()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@amazon.co.uk',
+ 'client_name' => 'helo.some.relayservice.domain',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['helo.some.relayservice.domain']));
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(200);
+
+ $this->domainOwner->removeSetting('spf_whitelist');
+ }
+
+
+ public function testSenderRelayPolicyRegexpNegative()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@amazon.co.uk',
+ 'client_name' => 'helo.some.relayservice.domain',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['/a\.domain/']));
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->removeSetting('spf_whitelist');
+ }
+
+ public function testSenderRelayPolicyRegexpPositive()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@amazon.co.uk',
+ 'client_name' => 'helo.some.relayservice.domain',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['/relayservice\.domain/']));
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(200);
+
+ $this->domainOwner->removeSetting('spf_whitelist');
+ }
+
+ public function testSenderRelayPolicyWildcardSubdomainNegative()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@amazon.co.uk',
+ 'client_name' => 'helo.some.relayservice.domain',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['.helo.some.relayservice.domain']));
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->removeSetting('spf_whitelist');
+ }
+
+ public function testSenderRelayPolicyWildcardSubdomainPositive()
+ {
+ $data = [
+ 'instance' => 'test.local.instance',
+ 'protocol_state' => 'RCPT',
+ 'sender' => 'sender@amazon.co.uk',
+ 'client_name' => 'helo.some.relayservice.domain',
+ 'client_address' => '212.103.80.148',
+ 'recipient' => $this->domainOwner->email
+ ];
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['.some.relayservice.domain']));
+
+ $response = $this->post('/api/webhooks/policy/spf', $data);
+
+ $response->assertStatus(200);
+
+ $this->domainOwner->removeSetting('spf_whitelist');
+ }
+}
diff --git a/src/tests/Functional/HorizonTest.php b/src/tests/Functional/HorizonTest.php
--- a/src/tests/Functional/HorizonTest.php
+++ b/src/tests/Functional/HorizonTest.php
@@ -22,7 +22,7 @@
$response = $this->get('horizon/dashboard');
- $response->assertStatus(404);
+ $response->assertStatus(200);
}
*/
}
diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php
--- a/src/tests/TestCase.php
+++ b/src/tests/TestCase.php
@@ -21,29 +21,6 @@
$this->withoutMiddleware(ThrottleRequests::class);
}
- protected function backdateEntitlements($entitlements, $targetDate)
- {
- $wallets = [];
- $ids = [];
-
- foreach ($entitlements as $entitlement) {
- $ids[] = $entitlement->id;
- $wallets[] = $entitlement->wallet_id;
- }
-
- \App\Entitlement::whereIn('id', $ids)->update([
- 'created_at' => $targetDate,
- 'updated_at' => $targetDate,
- ]);
-
- if (!empty($wallets)) {
- $wallets = array_unique($wallets);
- $owners = \App\Wallet::whereIn('id', $wallets)->pluck('user_id')->all();
-
- \App\User::whereIn('id', $owners)->update(['created_at' => $targetDate]);
- }
- }
-
/**
* Set baseURL to the regular UI location
*/
@@ -88,4 +65,14 @@
\config(['app.url' => str_replace('//', '//reseller.', \config('app.url'))]);
url()->forceRootUrl(config('app.url'));
}
+
+ /**
+ * Set baseURL to the services location
+ */
+ protected static function useServicesUrl(): void
+ {
+ // This will set base URL for all tests in a file.
+ \config(['app.url' => str_replace('//', '//services.', \config('app.url'))]);
+ url()->forceRootUrl(config('app.url'));
+ }
}
diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php
--- a/src/tests/TestCaseTrait.php
+++ b/src/tests/TestCaseTrait.php
@@ -14,7 +14,86 @@
trait TestCaseTrait
{
/**
- * Assert user entitlements state
+ * A domain that is hosted.
+ *
+ * @var ?\App\Domain
+ */
+ protected $domainHosted;
+
+ /**
+ * The hosted domain owner.
+ *
+ * @var ?\App\User
+ */
+ protected $domainOwner;
+
+ /**
+ * Some profile details for an owner of a domain
+ *
+ * @var array
+ */
+ protected $domainOwnerSettings = [
+ 'first_name' => 'John',
+ 'last_name' => 'Doe',
+ 'organization' => 'Test Domain Owner',
+ ];
+
+ /**
+ * Some users for the hosted domain, ultimately including the owner.
+ *
+ * @var \App\User[]
+ */
+ protected $domainUsers = [];
+
+ /**
+ * A specific user that is a regular user in the hosted domain.
+ *
+ * @var ?\App\User
+ */
+ protected $jack;
+
+ /**
+ * A specific user that is a controller on the wallet to which the hosted domain is charged.
+ *
+ * @var ?\App\User
+ */
+ protected $jane;
+
+ /**
+ * A specific user that has a second factor configured.
+ *
+ * @var ?\App\User
+ */
+ protected $joe;
+
+ /**
+ * One of the domains that is available for public registration.
+ *
+ * @var ?\App\Domain
+ */
+ protected $publicDomain;
+
+ /**
+ * A newly generated user in a public domain.
+ *
+ * @var ?\App\User
+ */
+ protected $publicDomainUser;
+
+ /**
+ * A placeholder for a password that can be generated.
+ *
+ * Should be generated with `\App\Utils::generatePassphrase()`.
+ *
+ * @var ?string
+ */
+ protected $userPassword;
+
+ /**
+ * Assert that the entitlements for the user match the expected list of entitlements.
+ *
+ * @param \App\User $user The user for which the entitlements need to be pulled.
+ * @param array $expected An array of expected \App\SKU titles.
*/
protected function assertUserEntitlements($user, $expected)
{
@@ -30,6 +109,29 @@
Assert::assertSame($expected, $skus);
}
+ protected function backdateEntitlements($entitlements, $targetDate)
+ {
+ $wallets = [];
+ $ids = [];
+
+ foreach ($entitlements as $entitlement) {
+ $ids[] = $entitlement->id;
+ $wallets[] = $entitlement->wallet_id;
+ }
+
+ \App\Entitlement::whereIn('id', $ids)->update([
+ 'created_at' => $targetDate,
+ 'updated_at' => $targetDate,
+ ]);
+
+ if (!empty($wallets)) {
+ $wallets = array_unique($wallets);
+ $owners = \App\Wallet::whereIn('id', $wallets)->pluck('user_id')->all();
+
+ \App\User::whereIn('id', $owners)->update(['created_at' => $targetDate]);
+ }
+ }
+
/**
* Removes all beta entitlements from the database
*/
@@ -68,6 +170,7 @@
$date = Carbon::now();
$debit = 0;
$entitlementTransactions = [];
+
foreach ($wallet->entitlements as $entitlement) {
if ($entitlement->cost) {
$debit += $entitlement->cost;
@@ -78,28 +181,35 @@
}
}
- $transaction = Transaction::create([
+ $transaction = Transaction::create(
+ [
'user_email' => 'jeroen@jeroen.jeroen',
'object_id' => $wallet->id,
'object_type' => \App\Wallet::class,
'type' => Transaction::WALLET_DEBIT,
'amount' => $debit * -1,
'description' => 'Payment',
- ]);
+ ]
+ );
+
$result[] = $transaction;
Transaction::whereIn('id', $entitlementTransactions)->update(['transaction_id' => $transaction->id]);
- $transaction = Transaction::create([
+ $transaction = Transaction::create(
+ [
'user_email' => null,
'object_id' => $wallet->id,
'object_type' => \App\Wallet::class,
'type' => Transaction::WALLET_CREDIT,
'amount' => 2000,
'description' => 'Payment',
- ]);
+ ]
+ );
+
$transaction->created_at = $date->next(Carbon::MONDAY);
$transaction->save();
+
$result[] = $transaction;
$types = [
@@ -119,14 +229,21 @@
'amount' => 11 * (count($result) + 1) * ($type == Transaction::WALLET_PENALTY ? -1 : 1),
'description' => 'TRANS' . $loops,
]);
+
$transaction->created_at = $date->next(Carbon::MONDAY);
$transaction->save();
+
$result[] = $transaction;
}
return $result;
}
+ /**
+ * Delete a test domain whatever it takes.
+ *
+ * @coversNothing
+ */
protected function deleteTestDomain($name)
{
Queue::fake();
@@ -143,6 +260,11 @@
$domain->forceDelete();
}
+ /**
+ * Delete a test group whatever it takes.
+ *
+ * @coversNothing
+ */
protected function deleteTestGroup($email)
{
Queue::fake();
@@ -159,6 +281,11 @@
$group->forceDelete();
}
+ /**
+ * Delete a test user whatever it takes.
+ *
+ * @coversNothing
+ */
protected function deleteTestUser($email)
{
Queue::fake();
@@ -176,8 +303,22 @@
}
/**
+ * Helper to access protected property of an object
+ */
+ protected static function getObjectProperty($object, $property_name)
+ {
+ $reflection = new \ReflectionClass($object);
+ $property = $reflection->getProperty($property_name);
+ $property->setAccessible(true);
+
+ return $property->getValue($object);
+ }
+
+ /**
* Get Domain object by namespace, create it if needed.
* Skip LDAP jobs.
+ *
+ * @coversNothing
*/
protected function getTestDomain($name, $attrib = [])
{
@@ -200,6 +341,8 @@
/**
* Get User object by email, create it if needed.
* Skip LDAP jobs.
+ *
+ * @coversNothing
*/
protected function getTestUser($email, $attrib = [])
{
@@ -217,18 +360,6 @@
}
/**
- * Helper to access protected property of an object
- */
- protected static function getObjectProperty($object, $property_name)
- {
- $reflection = new \ReflectionClass($object);
- $property = $reflection->getProperty($property_name);
- $property->setAccessible(true);
-
- return $property->getValue($object);
- }
-
- /**
* Call protected/private method of a class.
*
* @param object $object Instantiated object that we will run method on.
@@ -245,4 +376,95 @@
return $method->invokeArgs($object, $parameters);
}
+
+ protected function setUpTest()
+ {
+ $this->userPassword = \App\Utils::generatePassphrase();
+
+ $this->domainHosted = $this->getTestDomain(
+ 'test.domain',
+ [
+ 'type' => \App\Domain::TYPE_EXTERNAL,
+ 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED
+ ]
+ );
+
+ $packageKolab = \App\Package::where('title', 'kolab')->first();
+
+ $this->domainOwner = $this->getTestUser('john@test.domain', ['password' => $this->userPassword]);
+ $this->domainOwner->assignPackage($packageKolab);
+ $this->domainOwner->setSettings($this->domainOwnerSettings);
+
+ // separate for regular user
+ $this->jack = $this->getTestUser('jack@test.domain', ['password' => $this->userPassword]);
+
+ // separate for wallet controller
+ $this->jane = $this->getTestUser('jane@test.domain', ['password' => $this->userPassword]);
+
+ $this->joe = $this->getTestUser('joe@test.domain', ['password' => $this->userPassword]);
+
+ $this->domainUsers[] = $this->jack;
+ $this->domainUsers[] = $this->jane;
+ $this->domainUsers[] = $this->joe;
+ $this->domainUsers[] = $this->getTestUser('jill@test.domain', ['password' => $this->userPassword]);
+
+ foreach ($this->domainUsers as $user) {
+ $this->domainOwner->assignPackage($packageKolab, $user);
+ }
+
+ $this->domainUsers[] = $this->domainOwner;
+
+ // assign second factor to joe
+ $this->joe->assignSku(\App\Sku::where('title', '2fa')->first());
+ \App\Auth\SecondFactor::seed($this->joe->email);
+
+ usort(
+ $this->domainUsers,
+ function ($a, $b) {
+ return $a->email > $b->email;
+ }
+ );
+
+ $this->domainHosted->assignPackage(
+ \App\Package::where('title', 'domain-hosting')->first(),
+ $this->domainOwner
+ );
+
+ $wallet = $this->domainOwner->wallets()->first();
+
+ $wallet->addController($this->jane);
+
+ $this->publicDomain = \App\Domain::where('type', \App\Domain::TYPE_PUBLIC)->first();
+ $this->publicDomainUser = $this->getTestUser(
+ 'john@' . $this->publicDomain->namespace,
+ ['password' => $this->userPassword]
+ );
+
+ $this->publicDomainUser->assignPackage($packageKolab);
+ }
+
+ public function tearDown(): void
+ {
+ foreach ($this->domainUsers as $user) {
+ if ($user == $this->domainOwner) {
+ continue;
+ }
+
+ $this->deleteTestUser($user->email);
+ }
+
+ if ($this->domainOwner) {
+ $this->deleteTestUser($this->domainOwner->email);
+ }
+
+ if ($this->domainHosted) {
+ $this->deleteTestDomain($this->domainHosted->namespace);
+ }
+
+ if ($this->publicDomainUser) {
+ $this->deleteTestUser($this->publicDomainUser->email);
+ }
+
+ parent::tearDown();
+ }
}

File Metadata

Mime Type
text/plain
Expires
Thu, Apr 2, 8:53 PM (13 h, 49 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18820814
Default Alt Text
D2623.1775163230.diff (144 KB)

Event Timeline