Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117819156
D2434.1775290128.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
151 KB
Referenced Files
None
Subscribers
None
D2434.1775290128.diff
View Options
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/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_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/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\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\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;
@@ -13,6 +15,8 @@
*/
class Domain extends Model
{
+ use DomainConfigTrait;
+ use SettingsTrait;
use SoftDeletes;
// we've simply never heard of this domain
@@ -361,6 +365,16 @@
return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash;
}
+ /**
+ * 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.
*
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/Greylist/Connect.php b/src/app/Greylist/Connect.php
new file mode 100644
--- /dev/null
+++ b/src/app/Greylist/Connect.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace App\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/Greylist/Request.php b/src/app/Greylist/Request.php
new file mode 100644
--- /dev/null
+++ b/src/app/Greylist/Request.php
@@ -0,0 +1,299 @@
+<?php
+
+namespace App\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 = \App\Greylist\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 = \App\Greylist\Setting::where(
+ [
+ 'object_id' => $this->recipientID,
+ 'object_type' => $this->recipientType,
+ 'key' => 'greylist_enabled'
+ ]
+ )->first();
+
+ if (!$setting) {
+ $setting = \App\Greylist\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 = \App\Greylist\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 = \App\Greylist\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 = \App\Greylist\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 = \App\Greylist\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 = \App\Greylist\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 = \App\Greylist\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/Greylist/Setting.php b/src/app/Greylist/Setting.php
new file mode 100644
--- /dev/null
+++ b/src/app/Greylist/Setting.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace App\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/Greylist/Whitelist.php b/src/app/Greylist/Whitelist.php
new file mode 100644
--- /dev/null
+++ b/src/app/Greylist/Whitelist.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace App\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/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
@@ -96,6 +96,39 @@
return $this->errorResponse(404);
}
+ /**
+ * 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 (!Auth::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.
*
@@ -133,7 +166,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,168 @@
+<?php
+
+namespace App\Http\Controllers\API\V4;
+
+use App\Http\Controllers\Controller;
+use App\Providers\PaymentProvider;
+use App\Wallet;
+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\Response The response
+ */
+ public function greylist()
+ {
+ $data = \request()->input();
+
+ list($local, $domainName) = explode('@', $data['recipient']);
+
+ $request = new \App\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 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\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\SPF\Cache::set($cacheKey, serialize($result));
+ } else {
+ $result = unserialize($result);
+ }
+
+ $fail = false;
+
+ 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 @@
return response()->json($result);
}
+ /**
+ * 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.
*
@@ -119,6 +150,8 @@
];
}
+ $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
@@ -43,7 +43,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/SPF/Cache.php b/src/app/SPF/Cache.php
new file mode 100644
--- /dev/null
+++ b/src/app/SPF/Cache.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace App\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/SPF/Policy.php b/src/app/SPF/Policy.php
new file mode 100644
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,60 @@
+<?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) {
+ if (empty($v)) {
+ unset($value[$i]);
+ continue;
+ }
+
+ 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;
@@ -26,6 +27,7 @@
{
use Notifiable;
use NullableFields;
+ use UserConfigTrait;
use UserAliasesTrait;
use SettingsTrait;
use SoftDeletes;
@@ -563,6 +565,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
@@ -44,7 +44,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(?)
@@ -158,6 +158,107 @@
fclose($fp);
}
+ /**
+ * 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 [];
+ }
+
+ /**
+ * Generate a passphrase. Not intended for use in production, so limited to environments that are not production.
+ *
+ * @return string
+ */
+ public static function generatePassphrase()
+ {
+ $alphaLow = 'abcdefghijklmnopqrstuvwxyz';
+ $alphaUp = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+ $num = '0123456789';
+ $stdSpecial = '~`!@#$%^&*()-_+=[{]}\\|\'";:/?.>,<';
+
+ $source = $alphaLow . $alphaUp . $num . $stdSpecial;
+
+ $result = '';
+
+ for ($x = 0; $x < 16; $x++) {
+ $result .= substr($source, rand(0, (strlen($source) - 1)), 1);
+ }
+
+ return $result;
+ }
+
+ /**
+ * 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.
*
@@ -213,6 +314,32 @@
return $lastaddrstr;
}
+ /**
+ * 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.
*
diff --git a/src/composer.json b/src/composer.json
--- a/src/composer.json
+++ b/src/composer.json
@@ -22,6 +22,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
@@ -127,7 +127,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,39 @@
+<?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()
+ {
+ 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
@@ -182,6 +182,10 @@
isLoading() {
return isLoading > 0
},
+ tab(e) {
+ e.preventDefault()
+ $(e.target).tab('show')
+ },
errorPage(code, msg) {
// 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
@@ -34,6 +34,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.',
@@ -41,6 +42,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-foundxusers' => ':x user accounts have been found.',
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
@@ -135,6 +135,8 @@
'entryexists' => 'The specified :attribute is not available.',
'minamount' => 'Minimum amount for a single payment is :amount.',
'minamountdebt' => 'The specified amount does not cover the balance on the account.',
+ '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
@@ -335,13 +335,8 @@
padding: 0.5rem 0;
}
- .form-group {
- margin-bottom: 0.5rem;
- }
-
.nav-tabs {
flex-wrap: nowrap;
- overflow-x: auto;
.nav-link {
white-space: nowrap;
@@ -349,27 +344,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;
@@ -450,3 +424,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
@@ -31,10 +31,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">
Configuration
</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">
+ Settings
+ </a>
+ </li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="domain-config" role="tabpanel" aria-labelledby="tab-config">
@@ -43,7 +48,23 @@
<p>Domain DNS verification sample:</p>
<p><pre id="dns-verify">{{ domain.dns.join("\n") }}</pre></p>
<p>Domain DNS configuration sample:</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">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
@@ -108,6 +108,11 @@
Users ({{ users.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">
@@ -259,6 +264,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">Greylisting</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="greylisting">
+ <span v-if="user.config.greylisting" class="text-success">enabled</span>
+ <span v-else class="text-danger">disabled</span>
+ </span>
+ </div>
+ </div>
+ </form>
+ </div>
+ </div>
+ </div>
</div>
<div id="discount-dialog" class="modal" tabindex="-1" role="dialog">
@@ -405,6 +427,7 @@
users: [],
user: {
aliases: [],
+ config: {},
wallet: {},
skus: {},
}
@@ -494,10 +517,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,34 +2,76 @@
<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">Domain verification</div>
+ <div class="card-title">{{ domain.namespace }}</div>
<div class="card-text">
- <p>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.</p>
- <p>The domain <b>must have one of the following entries</b> in DNS:
- <ul>
- <li>TXT entry with value: <code>{{ domain.hash_text }}</code></li>
- <li>or CNAME entry: <code>{{ domain.hash_cname }}.{{ domain.namespace }}. IN CNAME {{ domain.hash_code }}.{{ domain.namespace }}.</code></li>
- </ul>
- When this is done press the button below to start the verification.</p>
- <p>Here's a sample zone file for your domain: <pre>{{ domain.dns.join("\n") }}</pre></p>
- <button class="btn btn-primary" type="button" @click="confirm"><svg-icon icon="sync-alt"></svg-icon> Verify</button>
- </div>
- </div>
- </div>
- <div v-if="domain && domain.isConfirmed" class="card" id="domain-config">
- <div class="card-body">
- <div class="card-title">Domain configuration</div>
- <div class="card-text">
- <p>In order to let {{ $root.appName }} receive email traffic for your domain you need to adjust
- the DNS settings, more precisely the MX entries, accordingly.</p>
- <p>Edit your domain's zone file and replace existing MX
- entries with the following values: <pre>{{ domain.config.join("\n") }}</pre></p>
- <p>If you don't know how to set DNS entries for your domain,
- please contact the registration service where you registered
- the domain or your web hosting provider.</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">
+ Domain verification
+ </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">
+ Domain configuration
+ </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">
+ 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>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.</p>
+ <p>The domain <b>must have one of the following entries</b> in DNS:
+ <ul>
+ <li>TXT entry with value: <code>{{ domain.hash_text }}</code></li>
+ <li>or CNAME entry: <code>{{ domain.hash_cname }}.{{ domain.namespace }}. IN CNAME {{ domain.hash_code }}.{{ domain.namespace }}.</code></li>
+ </ul>
+ When this is done press the button below to start the verification.</p>
+ <p>Here's a sample zone file for your domain: <pre>{{ domain.dns.join("\n") }}</pre></p>
+ <button class="btn btn-primary" type="button" @click="confirm"><svg-icon icon="sync-alt"></svg-icon> Verify</button>
+ </div>
+ </div>
+ <div v-if="domain.isConfirmed" class="card-body" id="domain-config">
+ <div class="card-text">
+ <p>In order to let {{ app_name }} receive email traffic for your domain you need to adjust
+ the DNS settings, more precisely the MX entries, accordingly.</p>
+ <p>Edit your domain's zone file and replace existing MX
+ entries with the following values: <pre>{{ domain.mx.join("\n") }}</pre></p>
+ <p>If you don't know how to set DNS entries for your domain,
+ please contact the registration service where you registered
+ the domain or your web hosting provider.</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">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">
+ 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.
+ <span class="d-block">
+ Here you can specify a list of allowed servers, for example: <var>.ess.barracuda.com</var>.
+ </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>
@@ -37,16 +79,20 @@
</template>
<script>
+ import ListInput from '../Widgets/ListInput'
import StatusComponent from '../Widgets/Status'
export default {
components: {
+ ListInput,
StatusComponent
},
data() {
return {
domain_id: null,
domain: null,
+ app_name: window.config['app.name'],
+ spf_whitelist: [],
status: {}
}
},
@@ -58,6 +104,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()
@@ -86,6 +133,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
@@ -7,151 +7,187 @@
<div class="card-title" v-if="user_id !== 'new'">User account</div>
<div class="card-title" v-if="user_id === 'new'">New user account</div>
<div class="card-text">
- <form @submit.prevent="submit">
- <div v-if="user_id !== 'new'" class="form-group row plaintext">
- <label for="first_name" class="col-sm-4 col-form-label">Status</label>
- <div class="col-sm-8">
- <span :class="$root.userStatusClass(user) + ' form-control-plaintext'" id="status">{{ $root.userStatusText(user) }}</span>
+ <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">
+ 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">
+ 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="first_name" class="col-sm-4 col-form-label">Status</label>
+ <div class="col-sm-8">
+ <span :class="$root.userStatusClass(user) + ' form-control-plaintext'" id="status">{{ $root.userStatusText(user) }}</span>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="first_name" class="col-sm-4 col-form-label">First name</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="first_name" v-model="user.first_name">
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="last_name" class="col-sm-4 col-form-label">Last name</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="last_name" v-model="user.last_name">
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="organization" class="col-sm-4 col-form-label">Organization</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="organization" v-model="user.organization">
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="email" class="col-sm-4 col-form-label">Email</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="email" :disabled="user_id !== 'new'" required v-model="user.email">
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="aliases-input" class="col-sm-4 col-form-label">Email aliases</label>
+ <div class="col-sm-8">
+ <list-input id="aliases" :list="user.aliases"></list-input>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="password" class="col-sm-4 col-form-label">Password</label>
+ <div class="col-sm-8">
+ <input type="password" class="form-control" id="password" v-model="user.password" :required="user_id === 'new'">
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="password_confirmaton" class="col-sm-4 col-form-label">Confirm password</label>
+ <div class="col-sm-8">
+ <input type="password" class="form-control" id="password_confirmation" v-model="user.password_confirmation" :required="user_id === 'new'">
+ </div>
+ </div>
+ <div v-if="user_id === 'new'" id="user-packages" class="form-group row">
+ <label class="col-sm-4 col-form-label">Package</label>
+ <div class="col-sm-8">
+ <table class="table table-sm form-list">
+ <thead class="thead-light sr-only">
+ <tr>
+ <th scope="col"></th>
+ <th scope="col">Package</th>
+ <th scope="col">Price</th>
+ <th scope="col"></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="pkg in packages" :id="'p' + pkg.id" :key="pkg.id">
+ <td class="selection">
+ <input type="checkbox" @click="selectPackage"
+ :value="pkg.id"
+ :checked="pkg.id == package_id"
+ :id="'pkg-input-' + pkg.id"
+ >
+ </td>
+ <td class="name">
+ <label :for="'pkg-input-' + pkg.id">{{ pkg.name }}</label>
+ </td>
+ <td class="price text-nowrap">
+ {{ $root.priceLabel(pkg.cost, 1, discount) }}
+ </td>
+ <td class="buttons">
+ <button v-if="pkg.description" type="button" class="btn btn-link btn-lg p-0" v-tooltip.click="pkg.description">
+ <svg-icon icon="info-circle"></svg-icon>
+ <span class="sr-only">More information</span>
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ <small v-if="discount > 0" class="hint">
+ <hr class="m-0">
+ ¹ applied discount: {{ discount }}% - {{ discount_description }}
+ </small>
+ </div>
+ </div>
+ <div v-if="user_id !== 'new'" id="user-skus" class="form-group row">
+ <label class="col-sm-4 col-form-label">Subscriptions</label>
+ <div class="col-sm-8">
+ <table class="table table-sm form-list">
+ <thead class="thead-light sr-only">
+ <tr>
+ <th scope="col"></th>
+ <th scope="col">Subscription</th>
+ <th scope="col">Price</th>
+ <th scope="col"></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="sku in skus" :id="'s' + sku.id" :key="sku.id">
+ <td class="selection">
+ <input type="checkbox" @input="onInputSku"
+ :value="sku.id"
+ :disabled="sku.readonly"
+ :checked="sku.enabled"
+ :id="'sku-input-' + sku.title"
+ >
+ </td>
+ <td class="name">
+ <label :for="'sku-input-' + sku.title">{{ sku.name }}</label>
+ <div v-if="sku.range" class="range-input">
+ <label class="text-nowrap">{{ sku.range.min }} {{ sku.range.unit }}</label>
+ <input
+ type="range" class="custom-range" @input="rangeUpdate"
+ :value="sku.value || sku.range.min"
+ :min="sku.range.min"
+ :max="sku.range.max"
+ >
+ </div>
+ </td>
+ <td class="price text-nowrap">
+ {{ $root.priceLabel(sku.cost, 1, discount) }}
+ </td>
+ <td class="buttons">
+ <button v-if="sku.description" type="button" class="btn btn-link btn-lg p-0" v-tooltip.click="sku.description">
+ <svg-icon icon="info-circle"></svg-icon>
+ <span class="sr-only">More information</span>
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ <small v-if="discount > 0" class="hint">
+ <hr class="m-0">
+ ¹ applied discount: {{ discount }}% - {{ discount_description }}
+ </small>
+ </div>
+ </div>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
+ </form>
</div>
</div>
- <div class="form-group row">
- <label for="first_name" class="col-sm-4 col-form-label">First name</label>
- <div class="col-sm-8">
- <input type="text" class="form-control" id="first_name" v-model="user.first_name">
+ <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">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">
+ 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.
+ </small>
+ </div>
+ </div>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
+ </form>
</div>
</div>
- <div class="form-group row">
- <label for="last_name" class="col-sm-4 col-form-label">Last name</label>
- <div class="col-sm-8">
- <input type="text" class="form-control" id="last_name" v-model="user.last_name">
- </div>
- </div>
- <div class="form-group row">
- <label for="organization" class="col-sm-4 col-form-label">Organization</label>
- <div class="col-sm-8">
- <input type="text" class="form-control" id="organization" v-model="user.organization">
- </div>
- </div>
- <div class="form-group row">
- <label for="email" class="col-sm-4 col-form-label">Email</label>
- <div class="col-sm-8">
- <input type="text" class="form-control" id="email" :disabled="user_id !== 'new'" required v-model="user.email">
- </div>
- </div>
- <div class="form-group row">
- <label for="aliases-input" class="col-sm-4 col-form-label">Email aliases</label>
- <div class="col-sm-8">
- <list-input id="aliases" :list="user.aliases"></list-input>
- </div>
- </div>
- <div class="form-group row">
- <label for="password" class="col-sm-4 col-form-label">Password</label>
- <div class="col-sm-8">
- <input type="password" class="form-control" id="password" v-model="user.password" :required="user_id === 'new'">
- </div>
- </div>
- <div class="form-group row">
- <label for="password_confirmaton" class="col-sm-4 col-form-label">Confirm password</label>
- <div class="col-sm-8">
- <input type="password" class="form-control" id="password_confirmation" v-model="user.password_confirmation" :required="user_id === 'new'">
- </div>
- </div>
- <div v-if="user_id === 'new'" id="user-packages" class="form-group row">
- <label class="col-sm-4 col-form-label">Package</label>
- <div class="col-sm-8">
- <table class="table table-sm form-list">
- <thead class="thead-light sr-only">
- <tr>
- <th scope="col"></th>
- <th scope="col">Package</th>
- <th scope="col">Price</th>
- <th scope="col"></th>
- </tr>
- </thead>
- <tbody>
- <tr v-for="pkg in packages" :id="'p' + pkg.id" :key="pkg.id">
- <td class="selection">
- <input type="checkbox" @click="selectPackage"
- :value="pkg.id"
- :checked="pkg.id == package_id"
- :id="'pkg-input-' + pkg.id"
- >
- </td>
- <td class="name">
- <label :for="'pkg-input-' + pkg.id">{{ pkg.name }}</label>
- </td>
- <td class="price text-nowrap">
- {{ $root.priceLabel(pkg.cost, 1, discount) }}
- </td>
- <td class="buttons">
- <button v-if="pkg.description" type="button" class="btn btn-link btn-lg p-0" v-tooltip.click="pkg.description">
- <svg-icon icon="info-circle"></svg-icon>
- <span class="sr-only">More information</span>
- </button>
- </td>
- </tr>
- </tbody>
- </table>
- <small v-if="discount > 0" class="hint">
- <hr class="m-0">
- ¹ applied discount: {{ discount }}% - {{ discount_description }}
- </small>
- </div>
- </div>
- <div v-if="user_id !== 'new'" id="user-skus" class="form-group row">
- <label class="col-sm-4 col-form-label">Subscriptions</label>
- <div class="col-sm-8">
- <table class="table table-sm form-list">
- <thead class="thead-light sr-only">
- <tr>
- <th scope="col"></th>
- <th scope="col">Subscription</th>
- <th scope="col">Price</th>
- <th scope="col"></th>
- </tr>
- </thead>
- <tbody>
- <tr v-for="sku in skus" :id="'s' + sku.id" :key="sku.id">
- <td class="selection">
- <input type="checkbox" @input="onInputSku"
- :value="sku.id"
- :disabled="sku.readonly"
- :checked="sku.enabled"
- :id="'sku-input-' + sku.title"
- >
- </td>
- <td class="name">
- <label :for="'sku-input-' + sku.title">{{ sku.name }}</label>
- <div v-if="sku.range" class="range-input">
- <label class="text-nowrap">{{ sku.range.min }} {{ sku.range.unit }}</label>
- <input
- type="range" class="custom-range" @input="rangeUpdate"
- :value="sku.value || sku.range.min"
- :min="sku.range.min"
- :max="sku.range.max"
- >
- </div>
- </td>
- <td class="price text-nowrap">
- {{ $root.priceLabel(sku.cost, 1, discount) }}
- </td>
- <td class="buttons">
- <button v-if="sku.description" type="button" class="btn btn-link btn-lg p-0" v-tooltip.click="sku.description">
- <svg-icon icon="info-circle"></svg-icon>
- <span class="sr-only">More information</span>
- </button>
- </td>
- </tr>
- </tbody>
- </table>
- <small v-if="discount > 0" class="hint">
- <hr class="m-0">
- ¹ applied discount: {{ discount }}% - {{ discount_description }}
- </small>
- </div>
- </div>
- <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
- </form>
+ </div>
</div>
</div>
</div>
@@ -172,7 +208,7 @@
discount: 0,
discount_description: '',
user_id: null,
- user: { aliases: [] },
+ user: { aliases: [], config: [] },
packages: [],
package_id: null,
skus: [],
@@ -280,6 +316,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
@@ -49,10 +49,13 @@
if (focus !== false) {
this.input.focus()
}
+
+ this.$emit('change', this.$el)
}
},
deleteItem(index) {
this.$delete(this.list, index)
+ this.$emit('change', this.$el)
if (this.list.length == 1) {
$(this.$el).removeClass('is-invalid')
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -62,11 +62,13 @@
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('entitlements', API\V4\EntitlementsController::class);
Route::apiResource('packages', API\V4\PackagesController::class);
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');
@@ -128,7 +130,7 @@
Route::group(
[
'domain' => \config('app.domain'),
- 'prefix' => $prefix . 'api/webhooks',
+ 'prefix' => $prefix . 'api/webhooks'
],
function () {
Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook');
@@ -136,6 +138,17 @@
}
);
+Route::group(
+ [
+ 'domain' => 'services.' . \config('app.domain'),
+ 'prefix' => $prefix . 'api/webhooks'
+ ],
+ function () {
+ Route::post('greylist', 'API\V4\PolicyController@greylist');
+ Route::post('spf', 'API\V4\PolicyController@senderPolicyFramework');
+ }
+);
+
Route::group(
[
'domain' => 'admin.' . \config('app.domain'),
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', 'jeroen', 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
@@ -111,7 +111,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 5);
+ ->assertElementsCount('@nav a', 6);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -155,6 +155,15 @@
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users 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');
+ });
});
}
@@ -173,6 +182,7 @@
$wallet->discount()->associate($discount);
$wallet->debit(2010);
$wallet->save();
+ $john->setSetting('greylisting', null);
// Click the managed-by link on Jack's page
$browser->click('@user-info #manager a')
@@ -207,7 +217,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 5);
+ ->assertElementsCount('@nav a', 6);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -267,6 +277,7 @@
$this->browse(function (Browser $browser) {
$ned = $this->getTestUser('ned@kolab.org');
$page = new UserPage($ned->id);
+ $ned->setSetting('greylisting', 'false');
$browser->click('@user-users tbody tr:nth-child(4) td:first-child a')
->on($page);
@@ -280,7 +291,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 5);
+ ->assertElementsCount('@nav a', 6);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -328,6 +339,15 @@
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users 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) {
@@ -82,6 +85,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)
*/
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
@@ -58,6 +58,7 @@
'@user-subscriptions' => '#user-subscriptions',
'@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/UsersTest.php b/src/tests/Browser/UsersTest.php
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -326,6 +326,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
*
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();
}
@@ -124,6 +127,81 @@
$this->assertSame('kolab.org', $json[0]['namespace']);
}
+ /**
+ * 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
*/
@@ -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);
@@ -461,6 +463,75 @@
$this->assertSame(['beta', 'meet'], $result['skus']);
}
+ /**
+ * 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)
*/
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,606 @@
+<?php
+
+namespace Tests\Feature\Stories;
+
+use Illuminate\Support\Facades\DB;
+use Tests\TestCase;
+
+class GreylistTest extends TestCase
+{
+ private $requests = [];
+
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $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 \App\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 \App\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 \App\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 = \App\Greylist\Whitelist::where('sender_domain', 'sender.domain')->first();
+
+ $this->assertNull($whitelist);
+
+ for ($i = 0; $i < 5; $i++) {
+ $request = new \App\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 = \App\Greylist\Whitelist::where('sender_domain', 'sender.domain')->first();
+
+ $this->assertNotNull($whitelist);
+
+ $request = new \App\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 = \App\Greylist\Whitelist::where('sender_domain', 'sender.domain')->first();
+
+ $this->assertNull($whitelist);
+
+ for ($i = 0; $i < 5; $i++) {
+ $request = new \App\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 = \App\Greylist\Whitelist::where('sender_domain', 'sender.domain')->first();
+
+ $this->assertNotNull($whitelist);
+
+ $request = new \App\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/greylist', $data);
+
+ $response->assertStatus(403);
+ }
+
+ public function testRetry()
+ {
+ $connect = \App\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 \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testDomainDisabled()
+ {
+ $setting = \App\Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainHosted->id,
+ 'object_type' => \App\Domain::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testDomainEnabled()
+ {
+ $connect = \App\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 = \App\Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainHosted->id,
+ 'object_type' => \App\Domain::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'true'
+ ]
+ );
+
+ $request = new \App\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 = \App\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 = \App\Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainHosted->id,
+ 'object_type' => \App\Domain::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ $settingUser = \App\Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainOwner->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testDomainDisabledUserEnabled()
+ {
+ $connect = \App\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 = \App\Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainHosted->id,
+ 'object_type' => \App\Domain::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ $settingUser = \App\Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainOwner->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'true'
+ ]
+ );
+
+ $request = new \App\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 = \App\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 \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => 'not.someone@that.exists',
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ public function testInvalidUser()
+ {
+ $connect = \App\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 \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => 'not.someone@that.exists',
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertTrue($request->shouldDefer());
+ }
+
+ public function testUserDisabled()
+ {
+ $connect = \App\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 = \App\Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainOwner->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'false'
+ ]
+ );
+
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+
+ public function testUserEnabled()
+ {
+ $connect = \App\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 = \App\Greylist\Setting::create(
+ [
+ 'object_id' => $this->domainOwner->id,
+ 'object_type' => \App\User::class,
+ 'key' => 'greylist_enabled',
+ 'value' => 'true'
+ ]
+ );
+
+ $request = new \App\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 \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ foreach ($this->domainUsers as $user) {
+ \App\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
+ ]
+ );
+
+ \App\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 \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $user->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ $this->assertFalse($request->shouldDefer());
+ }
+ }
+
+ public function testMultipleUsersAnyEnabled()
+ {
+ $request = new \App\Greylist\Request(
+ [
+ 'sender' => 'someone@sender.domain',
+ 'recipient' => $this->domainOwner->email,
+ 'client_address' => $this->clientAddress
+ ]
+ );
+
+ foreach ($this->domainUsers as $user) {
+ \App\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
+ ]
+ );
+
+ \App\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 \App\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,306 @@
+<?php
+
+namespace Tests\Feature\Stories;
+
+use Tests\TestCase;
+
+class SenderPolicyFrameworkTest extends TestCase
+{
+/*
+ TODO:
+ - with cache,
+ - without cache,
+ - with expired cache?
+*/
+ 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/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/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/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/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/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/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/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/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/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/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['the.only.acceptable.helo']));
+
+ $response = $this->post('/api/webhooks/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/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['helo.some.relayservice.domain']));
+
+ $response = $this->post('/api/webhooks/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/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['/a\.domain/']));
+
+ $response = $this->post('/api/webhooks/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/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['/relayservice\.domain/']));
+
+ $response = $this->post('/api/webhooks/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/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['.helo.some.relayservice.domain']));
+
+ $response = $this->post('/api/webhooks/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/spf', $data);
+
+ $response->assertStatus(403);
+
+ $this->domainOwner->setSetting('spf_whitelist', json_encode(['.some.relayservice.domain']));
+
+ $response = $this->post('/api/webhooks/spf', $data);
+
+ $response->assertStatus(200);
+
+ $this->domainOwner->removeSetting('spf_whitelist');
+ }
+}
diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php
--- a/src/tests/TestCase.php
+++ b/src/tests/TestCase.php
@@ -9,19 +9,6 @@
use TestCaseTrait;
use TestCaseMeetTrait;
- protected function backdateEntitlements($entitlements, $targetDate)
- {
- foreach ($entitlements as $entitlement) {
- $entitlement->created_at = $targetDate;
- $entitlement->updated_at = $targetDate;
- $entitlement->save();
-
- $owner = $entitlement->wallet->owner;
- $owner->created_at = $targetDate;
- $owner->save();
- }
- }
-
/**
* Set baseURL to the admin UI location
*/
diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php
--- a/src/tests/TestCaseTrait.php
+++ b/src/tests/TestCaseTrait.php
@@ -65,6 +65,7 @@
$date = Carbon::now();
$debit = 0;
$entitlementTransactions = [];
+
foreach ($wallet->entitlements as $entitlement) {
if ($entitlement->cost) {
$debit += $entitlement->cost;
@@ -75,28 +76,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 = [
@@ -118,12 +126,18 @@
]);
$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();
@@ -172,9 +186,23 @@
$user->forceDelete();
}
+ /**
+ * 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 = [])
{
@@ -197,6 +225,8 @@
/**
* Get User object by email, create it if needed.
* Skip LDAP jobs.
+ *
+ * @coversNothing
*/
protected function getTestUser($email, $attrib = [])
{
@@ -213,18 +243,6 @@
return $user;
}
- /**
- * 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.
*
@@ -242,4 +260,90 @@
return $method->invokeArgs($object, $parameters);
}
+
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $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);
+ }
+
+ $this->deleteTestUser($this->domainOwner->email);
+ $this->deleteTestDomain($this->domainHosted->namespace);
+
+ $this->deleteTestUser($this->publicDomainUser->email);
+
+ parent::tearDown();
+ }
}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 8:08 AM (11 h, 4 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18828622
Default Alt Text
D2434.1775290128.diff (151 KB)
Attached To
Mode
D2434: User controls over greylisting on the individual user basis, and SPF whitelisting for domains
Attached
Detach File
Event Timeline