Page MenuHomePhorge

D4157.1775157526.diff
No OneTemporary

Authored By
Unknown
Size
50 KB
Referenced Files
None
Subscribers
None

D4157.1775157526.diff

diff --git a/src/app/Backends/Amavis/Policy.php b/src/app/Backends/Amavis/Policy.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/Amavis/Policy.php
@@ -0,0 +1,118 @@
+<?php
+
+namespace App\Backends\Amavis;
+
+use Dyrynda\Database\Support\NullableFields;
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * The eloquent definition of a Amavis Policy.
+ *
+ * @property int $id
+ * @property float $spam_tag_level
+ * @property float $spam_tag2_level
+ * @property float $spam_tag3_level
+ * @property float $spam_kill_level
+ */
+class Policy extends Model
+{
+ use NullableFields;
+
+ /** @var array<string, string> The attributes that should be cast */
+ protected $casts = [
+ 'spam_tag_level' => 'float',
+ 'spam_tag2_level' => 'float',
+ 'spam_tag3_level' => 'float',
+ 'spam_kill_level' => 'float',
+ ];
+
+ /** @var array<int, string> The attributes that can be null */
+ protected $nullable = [
+ 'spam_tag_level',
+ 'spam_tag2_level',
+ 'spam_tag3_level',
+ 'spam_kill_level',
+ ];
+
+ /** @var string Database table name */
+ protected $table = 'amavis_policy';
+
+ /**
+ * Return default settings.
+ */
+ public static function defaults(): array
+ {
+ return [
+ // TODO: Get default values from config?
+ ];
+ }
+
+ /**
+ * Returns all supported settings with their type
+ *
+ * @return array
+ */
+ public static function policyDefinition()
+ {
+ return [
+ 'spam_tag_level' => 'float',
+ 'spam_tag2_level' => 'float',
+ 'spam_tag3_level' => 'float',
+ 'spam_kill_level' => 'float',
+ ];
+ }
+
+ /**
+ * Saves/Updates Amavis policy for a user.
+ *
+ * @param string $email Email address
+ * @param array $prefs Policy settings
+ */
+ public static function saveFor(string $email, array $prefs)
+ {
+ if (empty($prefs)) {
+ return;
+ }
+
+ $user = User::where('email', $email)->first();
+
+ $policy = $user ? $user->policy : new self();
+
+ foreach ($prefs as $key => $value) {
+ $policy->{$key} = $value;
+ }
+
+ $policy->save();
+
+ if (empty($user)) {
+ User::create([
+ 'email' => $email,
+ 'policy_id' => $policy->id,
+ ]);
+ }
+ }
+
+ /**
+ * Validate policy setting value.
+ *
+ * @param string $name Setting name
+ * @param mixed $value Setting value
+ */
+ public static function validate($name, $value): bool
+ {
+ $definition = self::policyDefinition();
+
+ if (array_key_exists($name, $definition)) {
+ if ($value === null) {
+ return true;
+ }
+
+ switch ($definition[$name]) {
+ case 'float':
+ return preg_match('/^[0-9]+(\.[0-9]+)?$/', $value) === 1;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/app/Backends/Amavis/User.php b/src/app/Backends/Amavis/User.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/Amavis/User.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Backends\Amavis;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * The eloquent definition of a Amavis User.
+ *
+ * @property string $email Email address or special mask value
+ * @property int $id User identifier
+ * @property int $policy_id Policy identifier
+ */
+class User extends Model
+{
+ public $timestamps = false;
+
+ /** @var string Database table name */
+ protected $table = 'amavis_users';
+
+ /** @var array<int, string> The attributes that are mass assignable */
+ protected $fillable = [
+ 'email',
+ 'policy_id'
+ ];
+
+ /**
+ * The policy assigned to the user.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function policy()
+ {
+ return $this->belongsTo(Policy::class);
+ }
+}
diff --git a/src/app/Backends/Spamassassin/Userpref.php b/src/app/Backends/Spamassassin/Userpref.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/Spamassassin/Userpref.php
@@ -0,0 +1,171 @@
+<?php
+
+namespace App\Backends\Spamassassin;
+
+use App\Rules\EmailPattern;
+use Dyrynda\Database\Support\NullableFields;
+use Illuminate\Database\Eloquent\Casts\Attribute;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\Validator;
+
+/**
+ * The eloquent definition of a Spamassassin user preference.
+ *
+ * @property int $id Preference identifier
+ * @property string $username Email address or special mask value
+ * @property string $preference Preference name
+ * @property mixed $value Preference value
+ */
+class Userpref extends Model
+{
+ use NullableFields;
+
+ /** @var string Database table name */
+ protected $table = 'spamassassin_userprefs';
+
+ /**
+ * Return default settings.
+ */
+ public static function defaults(): array
+ {
+ // TODO: Get default values from config?
+
+ return [
+ 'whitelist_from' => [],
+ 'blacklist_from' => [],
+ // 'ok_locales' => [],
+ ];
+ }
+
+ /**
+ * Interact with the preference value. Value type casting.
+ *
+ * @return \Illuminate\Database\Eloquent\Casts\Attribute
+ */
+ protected function value(): Attribute
+ {
+ return Attribute::make(
+ get: fn($value) => $this->formatValueOut($value),
+ set: fn($value) => $this->formatValueIn($value),
+ );
+ }
+
+ /**
+ * Convert input preference value into internal representation.
+ */
+ protected function formatValueIn($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ switch ($this->preference) {
+ case 'whitelist_from':
+ case 'blacklist_from':
+ case 'ok_locales':
+ return implode(' ', $value);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Convert preference value into an external representation/type.
+ */
+ protected function formatValueOut($value)
+ {
+ switch ($this->preference) {
+ case 'whitelist_from':
+ case 'blacklist_from':
+ case 'ok_locales':
+ return preg_split('/\s+/', $value, -1, PREG_SPLIT_NO_EMPTY);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Saves/Updates SpamAssassin preferences for specified user.
+ *
+ * @param string $username Username
+ * @param array $prefs Preferences (key -> value)
+ */
+ public static function saveFor(string $username, array $prefs)
+ {
+ if (empty($prefs)) {
+ return;
+ }
+
+ // Update/delete existing prefs
+ self::where('username', $username)->get()
+ ->each(function ($userpref) use (&$prefs) {
+ if (array_key_exists($userpref->preference, $prefs)) {
+ $value = $prefs[$userpref->preference];
+ unset($prefs[$userpref->preference]);
+
+ if ($value === null || (is_array($value) && empty($value))) {
+ $userpref->delete();
+ } else {
+ $userpref->value = $value;
+ $userpref->save();
+ }
+ }
+ });
+
+ // Create new prefs
+ foreach ($prefs as $key => $value) {
+ if (is_array($value) && empty($value)) {
+ // Ignore empty ones
+ continue;
+ }
+
+ $pref = new self();
+ $pref->username = $username;
+ $pref->preference = $key;
+ $pref->value = $value;
+ $pref->save();
+ }
+ }
+
+ /**
+ * Validate preference value.
+ *
+ * @param string $name Preference name
+ * @param mixed $value Preference value
+ * @param ?int $errorIndex If an array has an invalid element, it's the index of the first invalid element
+ *
+ * @return bool True if the value is valid, False otherwise
+ */
+ public static function validate($name, $value, &$errorIndex = null): bool
+ {
+ if ($value === null) {
+ return true;
+ }
+
+ switch ($name) {
+ case 'whitelist_from':
+ case 'blacklist_from':
+ if (!is_array($value)) {
+ return false;
+ }
+
+ $rule = [new EmailPattern()];
+
+ foreach ($value as $idx => $item) {
+ $v = Validator::make(['email' => $item], ['email' => $rule]);
+ if ($v->fails()) {
+ $errorIndex = $idx;
+ return false;
+ }
+ }
+
+ return true;
+
+ case 'ok_locales':
+ // TODO
+ return false;
+ }
+
+ return false;
+ }
+}
diff --git a/src/app/Rules/EmailPattern.php b/src/app/Rules/EmailPattern.php
new file mode 100644
--- /dev/null
+++ b/src/app/Rules/EmailPattern.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace App\Rules;
+
+use Illuminate\Contracts\Validation\Rule;
+use Illuminate\Support\Facades\Validator;
+
+class EmailPattern implements Rule
+{
+ protected $message;
+
+ /**
+ * Determine if the validation rule passes.
+ *
+ * Whitelist/Blacklist address patterns validation.
+ *
+ * @param string $attribute Attribute name
+ * @param mixed $input Email address pattern input
+ *
+ * @return bool
+ */
+ public function passes($attribute, $input): bool
+ {
+ // Spamassassin whitelist/blacklist addresses are file-glob-style patterns,
+ // so friend@somewhere.com, *@isp.com, or *.domain.net will all work.
+ // Specifically, * and ? are allowed, but all other metacharacters are not.
+
+ $input = str_replace(['?', '*'], ['a', 'aa'], $input);
+ if (!strpos($input, '@')) {
+ $input = "test@{$input}";
+ }
+
+ $v = Validator::make(['email' => $input], ['email' => 'required|email']);
+
+ if ($v->fails()) {
+ $this->message = \trans('validation.emailinvalid');
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the validation error message.
+ *
+ * @return string
+ */
+ public function message(): ?string
+ {
+ return $this->message;
+ }
+}
diff --git a/src/app/Traits/UserConfigTrait.php b/src/app/Traits/UserConfigTrait.php
--- a/src/app/Traits/UserConfigTrait.php
+++ b/src/app/Traits/UserConfigTrait.php
@@ -2,10 +2,33 @@
namespace App\Traits;
+use App\Backends\Amavis\Policy as AmavisPolicy;
+use App\Backends\Amavis\User as AmavisUser;
+use App\Backends\Spamassassin\Userpref as SpamPref;
use App\Policy\Greylist;
trait UserConfigTrait
{
+ /**
+ * Boot function from Laravel.
+ */
+ protected static function bootUserConfigTrait()
+ {
+ // Remove (external) settings on user delete
+ static::deleted(function ($user) {
+ // FIXME: Should we remove the settings on force-deleting only?
+ // if (!$user->isForceDeleting()) {
+ // return;
+ // }
+
+ SpamPref::where('username', $user->email)->delete();
+ AmavisUser::where('email', $user->email)->each(function ($amavis) {
+ $amavis->policy()->delete();
+ $amavis->delete();
+ });
+ });
+ }
+
/**
* A helper to get the user configuration.
*/
@@ -27,6 +50,28 @@
'password_policy' => $settings['password_policy'],
];
+ // Merge the Spamassassin/Amavis settings (defaults)
+ $config = array_merge(
+ $config,
+ collect(SpamPref::defaults())->mapWithKeys(fn($val, $key) => ["sa_{$key}" => $val])->all(),
+ collect(AmavisPolicy::defaults())->mapWithKeys(fn($val, $key) => ["amavis_{$key}" => $val])->all()
+ );
+
+ // Spamassassin settings
+ SpamPref::where('username', $this->email)->get()
+ ->each(function ($pref) use (&$config) {
+ $config["sa_{$pref->preference}"] = $pref->value;
+ });
+
+ // Amavis settings
+ if (($amavis_user = AmavisUser::where('email', $this->email)->first())
+ && ($policy = $amavis_user->policy()->first())
+ ) {
+ foreach (array_keys($policy->policyDefinition()) as $opt) {
+ $config["amavis_{$opt}"] = $policy->{$opt};
+ }
+ }
+
return $config;
}
@@ -40,9 +85,27 @@
public function setConfig(array $config): array
{
$errors = [];
+ $amavis = [];
+ $sa = [];
foreach ($config as $key => $value) {
- if ($key == 'greylist_enabled') {
+ if (strpos($key, 'sa_') === 0) {
+ $sa_key = substr($key, 3);
+ if (SpamPref::validate($sa_key, $value, $err_idx)) {
+ $sa[$sa_key] = $value;
+ } elseif ($err_idx !== null) {
+ $errors[$key] = [$err_idx => \trans('validation.option-invalid-value')];
+ } else {
+ $errors[$key] = \trans('validation.option-invalid-value');
+ }
+ } elseif (strpos($key, 'amavis_') === 0) {
+ $amavis_key = substr($key, 7);
+ if (AmavisPolicy::validate($amavis_key, $value)) {
+ $amavis[$amavis_key] = $value;
+ } else {
+ $errors[$key] = \trans('validation.option-invalid-value');
+ }
+ } elseif ($key == 'greylist_enabled') {
$this->setSetting($key, $value ? 'true' : 'false');
} elseif ($key == 'guam_enabled') {
$this->setSetting($key, $value ? 'true' : null);
@@ -93,6 +156,9 @@
}
}
+ SpamPref::saveFor($this->email, $sa);
+ AmavisPolicy::saveFor($this->email, $amavis);
+
return $errors;
}
diff --git a/src/database/migrations/2023_02_12_100000_amavis_tables.php b/src/database/migrations/2023_02_12_100000_amavis_tables.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2023_02_12_100000_amavis_tables.php
@@ -0,0 +1,48 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create('amavis_policy', function (Blueprint $table) {
+ $table->bigIncrements('id');
+ //$table->char('virus_lover', 1);
+ //$table->char('spam_lover', 1);
+ $table->float('spam_tag_level')->nullable();
+ $table->float('spam_tag2_level')->nullable();
+ $table->float('spam_tag3_level')->nullable();
+ $table->float('spam_kill_level')->nullable();
+ $table->timestamps();
+ });
+
+ Schema::create('amavis_users', function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->string('email')->unique();
+ $table->bigInteger('policy_id')->unsigned();
+ $table->smallInteger('priority')->default(7); // FIXME: do we need this at all?
+
+ $table->foreign('policy_id')->references('id')->on('amavis_policy')
+ ->onDelete('cascade')->onUpdate('cascade');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('amavis_users');
+ Schema::dropIfExists('amavis_policy');
+ }
+};
diff --git a/src/database/migrations/2023_02_14_100000_spamassassin_tables.php b/src/database/migrations/2023_02_14_100000_spamassassin_tables.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2023_02_14_100000_spamassassin_tables.php
@@ -0,0 +1,37 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create('spamassassin_userprefs', function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->string('username');
+ $table->string('preference', 64);
+ $table->text('value');
+ $table->timestamps();
+
+ $table->index('preference');
+ $table->unique(['username', 'preference']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('spamassassin_userprefs');
+ }
+};
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -537,9 +537,18 @@
'search' => "User email address or name",
'search-pl' => "User ID, email or domain",
'skureq' => "{sku} requires {list}.",
+ 'spam' => "Spam",
'subscription' => "Subscription",
'subscriptions-none' => "This user has no subscriptions.",
'users' => "Users",
+ 'whitelist' => "Whitelist",
+ 'whitelist-text' => "Use it to whitelist senders who send mail that is often tagged (incorrectly) as spam. "
+ . "The list entries are file-glob-style patterns, so friend@somewhere.com, *@isp.com, or *.domain.net will all work. "
+ . "Specifically, * and ? are allowed, but all other metacharacters are not.",
+ 'blacklist' => "Blacklist",
+ 'blacklist-text' => "Use it to specify senders who send mail that is often tagged (incorrectly) as non-spam, but which you don't want. "
+ . "The list entries are file-glob-style patterns, so friend@somewhere.com, *@isp.com, or *.domain.net will all work. "
+ . "Specifically, * and ? are allowed, but all other metacharacters are not.",
],
'wallet' => [
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
@@ -173,6 +173,7 @@
'password-policy-max-len-error' => 'Maximum password length cannot be more than :max.',
'password-policy-last-error' => 'The minimum value for last N passwords is :last.',
'signuptokeninvalid' => 'The signup token is invalid.',
+ 'option-invalid-value' => 'Invalid option value.',
/*
|--------------------------------------------------------------------------
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
@@ -19,6 +19,13 @@
}
}
+ &.scroll {
+ margin: 0 -5px 0 -5px;
+ padding: 5px;
+ overflow-y: scroll;
+ max-height: 15em;
+ }
+
input.is-invalid {
z-index: 2;
}
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
@@ -224,6 +224,18 @@
<btn v-if="user.config.limit_geo && user.config.limit_geo.length" class="btn-secondary btn-sm ms-2" @click="resetGeoLock">{{ $t('btn.reset') }}</btn>
</div>
</div>
+ <div class="row plaintext" v-if="user.config.sa_whitelist_from && user.config.sa_whitelist_from.length">
+ <label for="sa_whitelist_from" class="col-sm-4 col-form-label">{{ $t('user.whitelist') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="sa_whitelist_from">{{ user.config.sa_whitelist_from.join(', ') }}</span>
+ </div>
+ </div>
+ <div class="row plaintext" v-if="user.config.sa_blacklist_from && user.config.sa_blacklist_from.length">
+ <label for="sa_whitelist_from" class="col-sm-4 col-form-label">{{ $t('user.blacklist') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="sa_blacklist_from">{{ user.config.sa_blacklist_from.join(', ') }}</span>
+ </div>
+ </div>
</form>
</div>
</div>
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
@@ -93,7 +93,7 @@
<div class="row checkbox mb-3">
<label for="greylist_enabled" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label>
<div class="col-sm-8 pt-2">
- <input type="checkbox" id="greylist_enabled" name="greylist_enabled" value="1" class="form-check-input d-block mb-2" :checked="user.config.greylist_enabled">
+ <input type="checkbox" id="greylist_enabled" class="form-check-input d-block mb-2" v-model="user.config.greylist_enabled">
<small id="greylisting-hint" class="text-muted">
{{ $t('user.greylisting-text') }}
</small>
@@ -105,7 +105,7 @@
<sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup>
</label>
<div class="col-sm-8 pt-2">
- <input type="checkbox" id="guam_enabled" name="guam_enabled" value="1" class="form-check-input d-block mb-2" :checked="user.config.guam_enabled">
+ <input type="checkbox" id="guam_enabled" class="form-check-input d-block mb-2" v-model="user.config.guam_enabled">
<small id="guam-hint" class="text-muted">
{{ $t('user.imapproxy-text') }}
</small>
@@ -126,6 +126,29 @@
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
+ <div class="tab-pane" id="spam" role="tabpanel" aria-labelledby="tab-spam">
+ <form @submit.prevent="submitSpamSettings" class="card-body">
+ <div class="row mb-3">
+ <label for="sa_whitelist_from-input" class="col-sm-4 col-form-label">{{ $t('user.whitelist') }}</label>
+ <div class="col-sm-8">
+ <list-input id="sa_whitelist_from" :list="user.config.sa_whitelist_from" class="scroll"></list-input>
+ <small id="whitelist-hint" class="text-muted">
+ {{ $t('user.whitelist-text') }}
+ </small>
+ </div>
+ </div>
+ <div class="row mb-3">
+ <label for="sa_blacklist_from-input" class="col-sm-4 col-form-label">{{ $t('user.blacklist') }}</label>
+ <div class="col-sm-8">
+ <list-input id="sa_blacklist_from" :list="user.config.sa_blacklist_from" class="scroll"></list-input>
+ <small id="blacklist-hint" class="text-muted">
+ {{ $t('user.blacklist-text') }}
+ </small>
+ </div>
+ </div>
+ <btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
+ </form>
+ </div>
<div class="tab-pane" id="personal" role="tabpanel" aria-labelledby="tab-personal">
<form @submit.prevent="submitPersonalSettings" class="card-body">
<div class="row mb-3">
@@ -256,6 +279,7 @@
if (this.isController) {
tabs.push('form.settings')
+ tabs.push({ label: 'user.spam', beta: true })
}
tabs.push('form.personal')
@@ -399,21 +423,10 @@
})
},
submitSettings() {
- this.$root.clearFormValidation($('#settings form'))
-
- let post = this.$root.pick(this.user.config, ['limit_geo'])
-
- const checklist = ['greylist_enabled', 'guam_enabled']
- checklist.forEach(name => {
- if ($('#' + name).length) {
- post[name] = $('#' + name).prop('checked') ? 1 : 0
- }
- })
-
- axios.post('/api/v4/users/' + this.user_id + '/config', post)
- .then(response => {
- this.$toast.success(response.data.message)
- })
+ this.postConfig('settings', ['limit_geo', 'greylist_enabled', 'guam_enabled'])
+ },
+ submitSpamSettings() {
+ this.postConfig('spam', ['sa_whitelist_from', 'sa_blacklist_from'])
},
statusUpdate(user) {
this.user = Object.assign({}, this.user, user)
@@ -432,6 +445,16 @@
}
}
})
+ },
+ postConfig(section, options) {
+ this.$root.clearFormValidation($('#' + section + ' form'))
+
+ let post = this.$root.pick(this.user.config, options)
+
+ axios.post('/api/v4/users/' + this.user_id + '/config', post)
+ .then(response => {
+ this.$toast.success(response.data.message)
+ })
}
}
}
diff --git a/src/resources/vue/Widgets/Tabs.vue b/src/resources/vue/Widgets/Tabs.vue
--- a/src/resources/vue/Widgets/Tabs.vue
+++ b/src/resources/vue/Widgets/Tabs.vue
@@ -7,6 +7,7 @@
:href="'#' + tabKey(tab)"
>
{{ $t(tabLabel(tab)) + (typeof tab != 'string' && 'count' in tab ? ` (${tab.count})` : '') }}
+ <sup v-if="tab.beta" class="badge bg-primary">{{ $t('dashboard.beta') }}</sup>
</a>
</li>
</ul>
@@ -26,11 +27,12 @@
},
methods: {
tabClick(event) {
- event.preventDefault()
+ const target = $(event.target).closest('a')[0]
+ const key = target.id.replace('tab-', '')
- new Tab(event.target).show()
+ event.preventDefault()
- const key = event.target.id.replace('tab-', '')
+ new Tab(target).show()
if (key in this.clickHandlers) {
this.clickHandlers[key](event)
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
@@ -96,6 +96,10 @@
'organization' => null,
'guam_enabled' => null,
]);
+ $jack->setConfig([
+ 'sa_whitelist_from' => ['test1@domain.tld', 'test2'],
+ 'sa_blacklist_from' => ['test3@domain.tld', 'test4'],
+ ]);
$event1 = EventLog::createFor($jack, EventLog::TYPE_SUSPENDED, 'Event 1');
$event2 = EventLog::createFor($jack, EventLog::TYPE_UNSUSPENDED, 'Event 2', ['test' => 'test-data']);
@@ -210,13 +214,17 @@
$browser->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
->whenAvailable('@user-settings form', function (Browser $browser) {
- $browser->assertElementsCount('.row', 3)
+ $browser->assertElementsCount('.row', 5)
->assertSeeIn('.row:first-child label', 'Greylisting')
->assertSeeIn('.row:first-child .text-success', 'enabled')
->assertSeeIn('.row:nth-child(2) label', 'IMAP proxy')
->assertSeeIn('.row:nth-child(2) .text-danger', 'disabled')
->assertSeeIn('.row:nth-child(3) label', 'Geo-lockin')
->assertSeeIn('.row:nth-child(3) #limit_geo', 'No restrictions')
+ ->assertSeeIn('.row:nth-child(4) label', 'Whitelist')
+ ->assertSeeIn('.row:nth-child(4) #sa_whitelist_from', 'test1@domain.tld, test2')
+ ->assertSeeIn('.row:nth-child(5) label', 'Blacklist')
+ ->assertSeeIn('.row:nth-child(5) #sa_blacklist_from', 'test3@domain.tld, test4')
->assertMissing('#limit_geo + button');
});
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
@@ -44,6 +44,7 @@
'@general' => '#general',
'@personal' => '#personal',
'@skus' => '#user-skus',
+ '@spam' => '#spam',
'@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
@@ -72,6 +72,10 @@
$john = User::where('email', 'john@kolab.org')->first();
$john->setSettings($this->profile);
+ $john->setConfig([
+ 'sa_whitelist_from' => [],
+ 'sa_blacklist_from' => [],
+ ]);
UserAlias::where('user_id', $john->id)
->where('alias', 'john.test@kolab.org')->delete();
@@ -469,6 +473,64 @@
});
}
+ /**
+ * Test user page - Spam tab
+ *
+ * @depends testUserPersonalTab
+ */
+ public function testUserSpam(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $john->setConfig([
+ 'sa_whitelist_from' => ['testw'],
+ 'sa_blacklist_from' => ['testb'],
+ ]);
+
+ $this->browse(function (Browser $browser) use ($john) {
+ $browser->visit('/user/' . $john->id)
+ ->on(new UserInfo())
+ ->assertSeeIn('@nav #tab-spam', 'Spam')
+ ->click('@nav #tab-spam')
+ ->with('@spam form', function (Browser $browser) {
+ $browser->assertSeeIn('div.row:nth-child(1) label', 'Whitelist')
+ ->assertVisible('div.row:nth-child(1) small.text-muted')
+ ->with(new ListInput('#sa_whitelist_from'), function (Browser $browser) {
+ $browser->assertListInputValue(['testw'])
+ ->removeListEntry(1)
+ ->type('@input', 'a a');
+ })
+ ->assertSeeIn('div.row:nth-child(2) label', 'Blacklist')
+ ->assertVisible('div.row:nth-child(2) small.text-muted')
+ ->with(new ListInput('#sa_blacklist_from'), function (Browser $browser) {
+ $browser->assertListInputValue(['testb'])
+ ->removeListEntry(1)
+ ->type('@input', 'b b');
+ })
+ ->click('button[type=submit]')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->with(new ListInput('#sa_whitelist_from'), function (Browser $browser) {
+ $browser->assertFormError(1, 'Invalid option value')
+ ->removeListEntry(1)
+ ->addListEntry('test1')
+ ->addListEntry('test2');
+ })
+ ->with(new ListInput('#sa_blacklist_from'), function (Browser $browser) {
+ $browser->assertFormError(1, 'Invalid option value')
+ ->removeListEntry(1)
+ ->addListEntry('test3')
+ ->addListEntry('test4');
+ })
+ ->click('button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, 'User settings updated successfully.');
+ });
+ });
+
+ $config = $john->getConfig();
+
+ $this->assertSame(['test1', 'test2'], $config['sa_whitelist_from']);
+ $this->assertSame(['test3', 'test4'], $config['sa_blacklist_from']);
+ }
+
/**
* Test user adding page
*/
@@ -484,6 +546,7 @@
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'New user account')
->assertMissing('@nav #tab-settings')
+ ->assertMissing('@nav #tab-spam')
->assertMissing('@nav #tab-personal')
->with('@general', function (Browser $browser) {
// Assert form content
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
@@ -84,6 +84,10 @@
$user->save();
Plan::withEnvTenantContext()->where('title', 'individual')->update(['mode' => 'email']);
$user->setSettings(['plan_id' => null]);
+ $user->setConfig([
+ 'sa_whitelist_from' => [],
+ 'sa_blacklist_from' => [],
+ ]);
parent::tearDown();
}
@@ -685,6 +689,10 @@
$john->setSetting('guam_enabled', null);
$john->setSetting('password_policy', null);
$john->setSetting('max_password_age', null);
+ $john->setConfig([
+ 'sa_whitelist_from' => [],
+ 'sa_blacklist_from' => [],
+ ]);
// Test unknown user id
$post = ['greylist_enabled' => 1];
@@ -729,6 +737,8 @@
'guam_enabled' => 1,
'password_policy' => 'min:10,max:255,upper,lower,digit,special',
'max_password_age' => 6,
+ 'sa_whitelist_from' => ['test1', 'test2'],
+ 'sa_blacklist_from' => ['test3', 'test4'],
];
$response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post);
@@ -740,10 +750,14 @@
$this->assertSame('success', $json['status']);
$this->assertSame('User settings updated successfully.', $json['message']);
- $this->assertSame('true', $john->getSetting('greylist_enabled'));
- $this->assertSame('true', $john->getSetting('guam_enabled'));
- $this->assertSame('min:10,max:255,upper,lower,digit,special', $john->getSetting('password_policy'));
- $this->assertSame('6', $john->getSetting('max_password_age'));
+ $config = $john->getConfig();
+
+ $this->assertSame(true, $config['greylist_enabled']);
+ $this->assertSame(true, $config['guam_enabled']);
+ $this->assertSame('min:10,max:255,upper,lower,digit,special', $config['password_policy']);
+ $this->assertSame('6', $config['max_password_age']);
+ $this->assertSame(['test1', 'test2'], $config['sa_whitelist_from']);
+ $this->assertSame(['test3', 'test4'], $config['sa_blacklist_from']);
// Test some valid data, acting as another account controller
$ned = $this->getTestUser('ned@kolab.org');
diff --git a/src/tests/Feature/Traits/UserConfigTest.php b/src/tests/Feature/Traits/UserConfigTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Traits/UserConfigTest.php
@@ -0,0 +1,248 @@
+<?php
+
+namespace Tests\Feature\Traits;
+
+use App\Backends\Amavis\Policy as AmavisPolicy;
+use App\Backends\Amavis\User as AmavisUser;
+use App\Backends\Spamassassin\Userpref as SpamPref;
+use App\User;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class UserConfigTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('user-test@' . \config('app.domain'));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('user-test@' . \config('app.domain'));
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test User::getConfig() and setConfig() methods for UserSettings
+ */
+ public function testUserSettings(): void
+ {
+ $user = $this->getTestUser('user-test@' . \config('app.domain'));
+ $user->setSetting('greylist_enabled', null);
+ $user->setSetting('guam_enabled', null);
+ $user->setSetting('password_policy', null);
+ $user->setSetting('max_password_age', null);
+ $user->setSetting('limit_geo', null);
+
+ // greylist_enabled
+ $this->assertSame(true, $user->getConfig()['greylist_enabled']);
+
+ $result = $user->setConfig(['greylist_enabled' => false, 'unknown' => false]);
+
+ $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result);
+ $this->assertSame(false, $user->getConfig()['greylist_enabled']);
+ $this->assertSame('false', $user->getSetting('greylist_enabled'));
+
+ $result = $user->setConfig(['greylist_enabled' => true]);
+
+ $this->assertSame([], $result);
+ $this->assertSame(true, $user->getConfig()['greylist_enabled']);
+ $this->assertSame('true', $user->getSetting('greylist_enabled'));
+
+ // guam_enabled
+ $this->assertSame(false, $user->getConfig()['guam_enabled']);
+
+ $result = $user->setConfig(['guam_enabled' => false]);
+
+ $this->assertSame([], $result);
+ $this->assertSame(false, $user->getConfig()['guam_enabled']);
+ $this->assertSame(null, $user->getSetting('guam_enabled'));
+
+ $result = $user->setConfig(['guam_enabled' => true]);
+
+ $this->assertSame([], $result);
+ $this->assertSame(true, $user->getConfig()['guam_enabled']);
+ $this->assertSame('true', $user->getSetting('guam_enabled'));
+
+ // max_apssword_age
+ $this->assertSame(null, $user->getConfig()['max_password_age']);
+
+ $result = $user->setConfig(['max_password_age' => -1]);
+
+ $this->assertSame([], $result);
+ $this->assertSame(null, $user->getConfig()['max_password_age']);
+ $this->assertSame(null, $user->getSetting('max_password_age'));
+
+ $result = $user->setConfig(['max_password_age' => 12]);
+
+ $this->assertSame([], $result);
+ $this->assertSame('12', $user->getConfig()['max_password_age']);
+ $this->assertSame('12', $user->getSetting('max_password_age'));
+
+ // password_policy
+ $result = $user->setConfig(['password_policy' => true]);
+
+ $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
+ $this->assertSame(null, $user->getConfig()['password_policy']);
+ $this->assertSame(null, $user->getSetting('password_policy'));
+
+ $result = $user->setConfig(['password_policy' => 'min:-1']);
+
+ $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
+
+ $result = $user->setConfig(['password_policy' => 'min:-1']);
+
+ $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
+
+ $result = $user->setConfig(['password_policy' => 'min:10,unknown']);
+
+ $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
+
+ \config(['app.password_policy' => 'min:5,max:100']);
+ $result = $user->setConfig(['password_policy' => 'min:4,max:255']);
+
+ $this->assertSame(['password_policy' => "Minimum password length cannot be less than 5."], $result);
+
+ \config(['app.password_policy' => 'min:5,max:100']);
+ $result = $user->setConfig(['password_policy' => 'min:10,max:255']);
+
+ $this->assertSame(['password_policy' => "Maximum password length cannot be more than 100."], $result);
+
+ \config(['app.password_policy' => 'min:5,max:255']);
+ $result = $user->setConfig(['password_policy' => 'min:10,max:255']);
+
+ $this->assertSame([], $result);
+ $this->assertSame('min:10,max:255', $user->getConfig()['password_policy']);
+ $this->assertSame('min:10,max:255', $user->getSetting('password_policy'));
+
+ // limit_geo
+ $this->assertSame([], $user->getConfig()['limit_geo']);
+
+ $result = $user->setConfig(['limit_geo' => '']);
+
+ $err = "Specified configuration is invalid. Expected a list of two-letter country codes.";
+ $this->assertSame(['limit_geo' => $err], $result);
+ $this->assertSame(null, $user->getSetting('limit_geo'));
+
+ $result = $user->setConfig(['limit_geo' => ['usa']]);
+
+ $this->assertSame(['limit_geo' => $err], $result);
+ $this->assertSame(null, $user->getSetting('limit_geo'));
+
+ $result = $user->setConfig(['limit_geo' => []]);
+
+ $this->assertSame([], $result);
+ $this->assertSame(null, $user->getSetting('limit_geo'));
+
+ $result = $user->setConfig(['limit_geo' => ['US', 'ru']]);
+
+ $this->assertSame([], $result);
+ $this->assertSame(['US', 'RU'], $user->getConfig()['limit_geo']);
+ $this->assertSame('["US","RU"]', $user->getSetting('limit_geo'));
+ }
+
+ /**
+ * Test User::getConfig()/setConfig() methods with Amavis settings
+ */
+ public function testAmavisOptions(): void
+ {
+ $this->markTestIncomplete();
+ }
+
+ /**
+ * Test User::getConfig()/setConfig() methods with Spamassassin settings
+ */
+ public function testSpamassassinOptions(): void
+ {
+ $user = $this->getTestUser('user-test@' . \config('app.domain'));
+
+ SpamPref::where('username', $user->email)->delete();
+
+ // whitelist_from
+ $this->assertSame([], $user->getConfig()['sa_whitelist_from']);
+
+ $result = $user->setConfig(['sa_whitelist_from' => []]);
+
+ $this->assertSame([], $result);
+ $this->assertSame([], $user->getConfig()['sa_whitelist_from']);
+
+ $whitelist = ['test@test.com', '*@test.pl', '*.domain.net'];
+ $result = $user->setConfig(['sa_whitelist_from' => $whitelist]);
+
+ $this->assertSame([], $result);
+ $this->assertSame($whitelist, $user->getConfig()['sa_whitelist_from']);
+
+ $whitelist[] = 'test test';
+ $result = $user->setConfig(['sa_whitelist_from' => $whitelist]);
+
+ $this->assertSame(['sa_whitelist_from' => [3 => "Invalid option value."]], $result);
+ unset($whitelist[3]);
+ $this->assertSame($whitelist, $user->getConfig()['sa_whitelist_from']);
+
+ $result = $user->setConfig(['sa_whitelist_from' => []]);
+
+ $this->assertSame([], $result);
+ $this->assertSame([], $user->getConfig()['sa_whitelist_from']);
+
+ // blacklist_from
+ $this->assertSame([], $user->getConfig()['sa_blacklist_from']);
+
+ $result = $user->setConfig(['sa_blacklist_from' => []]);
+
+ $this->assertSame([], $result);
+ $this->assertSame([], $user->getConfig()['sa_blacklist_from']);
+
+ $blacklist = ['test@test.com', '*@test.pl', '*.domain.net'];
+ $result = $user->setConfig(['sa_blacklist_from' => $whitelist]);
+
+ $this->assertSame([], $result);
+ $this->assertSame($blacklist, $user->getConfig()['sa_blacklist_from']);
+
+ $blacklist[] = 'test test';
+ $result = $user->setConfig(['sa_blacklist_from' => $blacklist]);
+
+ $this->assertSame(['sa_blacklist_from' => [3 => "Invalid option value."]], $result);
+ unset($blacklist[3]);
+ $this->assertSame($blacklist, $user->getConfig()['sa_blacklist_from']);
+
+ $result = $user->setConfig(['sa_blacklist_from' => []]);
+
+ $this->assertSame([], $result);
+ $this->assertSame([], $user->getConfig()['sa_blacklist_from']);
+ }
+
+ /**
+ * Test UserConfigTrait actions on user deletion
+ */
+ public function testUserDelete(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser('user-test@' . \config('app.domain'));
+
+ AmavisUser::query()->delete();
+ AmavisPolicy::query()->delete();
+ SpamPref::saveFor($user->email, ['whitelist_from' => ['test']]);
+ AmavisPolicy::saveFor($user->email, ['spam_tag_level' => 5]);
+
+ $this->assertSame(1, SpamPref::where('username', $user->email)->count());
+ $this->assertSame(1, AmavisUser::where('email', $user->email)->count());
+ $this->assertSame(1, AmavisPolicy::count());
+
+ $user->delete();
+
+ $this->assertTrue($user->fresh()->trashed());
+ $this->assertSame(0, SpamPref::where('username', $user->email)->count());
+ $this->assertSame(0, AmavisUser::where('email', $user->email)->count());
+ $this->assertSame(0, AmavisPolicy::count());
+ }
+}
diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php
--- a/src/tests/Feature/UserTest.php
+++ b/src/tests/Feature/UserTest.php
@@ -504,125 +504,6 @@
$this->assertNotContains($domain->namespace, $domains);
}
- /**
- * Test User::getConfig() and setConfig() methods
- */
- public function testConfigTrait(): void
- {
- $user = $this->getTestUser('UserAccountA@UserAccount.com');
- $user->setSetting('greylist_enabled', null);
- $user->setSetting('guam_enabled', null);
- $user->setSetting('password_policy', null);
- $user->setSetting('max_password_age', null);
- $user->setSetting('limit_geo', null);
-
- // greylist_enabled
- $this->assertSame(true, $user->getConfig()['greylist_enabled']);
-
- $result = $user->setConfig(['greylist_enabled' => false, 'unknown' => false]);
-
- $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result);
- $this->assertSame(false, $user->getConfig()['greylist_enabled']);
- $this->assertSame('false', $user->getSetting('greylist_enabled'));
-
- $result = $user->setConfig(['greylist_enabled' => true]);
-
- $this->assertSame([], $result);
- $this->assertSame(true, $user->getConfig()['greylist_enabled']);
- $this->assertSame('true', $user->getSetting('greylist_enabled'));
-
- // guam_enabled
- $this->assertSame(false, $user->getConfig()['guam_enabled']);
-
- $result = $user->setConfig(['guam_enabled' => false]);
-
- $this->assertSame([], $result);
- $this->assertSame(false, $user->getConfig()['guam_enabled']);
- $this->assertSame(null, $user->getSetting('guam_enabled'));
-
- $result = $user->setConfig(['guam_enabled' => true]);
-
- $this->assertSame([], $result);
- $this->assertSame(true, $user->getConfig()['guam_enabled']);
- $this->assertSame('true', $user->getSetting('guam_enabled'));
-
- // max_apssword_age
- $this->assertSame(null, $user->getConfig()['max_password_age']);
-
- $result = $user->setConfig(['max_password_age' => -1]);
-
- $this->assertSame([], $result);
- $this->assertSame(null, $user->getConfig()['max_password_age']);
- $this->assertSame(null, $user->getSetting('max_password_age'));
-
- $result = $user->setConfig(['max_password_age' => 12]);
-
- $this->assertSame([], $result);
- $this->assertSame('12', $user->getConfig()['max_password_age']);
- $this->assertSame('12', $user->getSetting('max_password_age'));
-
- // password_policy
- $result = $user->setConfig(['password_policy' => true]);
-
- $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
- $this->assertSame(null, $user->getConfig()['password_policy']);
- $this->assertSame(null, $user->getSetting('password_policy'));
-
- $result = $user->setConfig(['password_policy' => 'min:-1']);
-
- $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
-
- $result = $user->setConfig(['password_policy' => 'min:-1']);
-
- $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
-
- $result = $user->setConfig(['password_policy' => 'min:10,unknown']);
-
- $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
-
- \config(['app.password_policy' => 'min:5,max:100']);
- $result = $user->setConfig(['password_policy' => 'min:4,max:255']);
-
- $this->assertSame(['password_policy' => "Minimum password length cannot be less than 5."], $result);
-
- \config(['app.password_policy' => 'min:5,max:100']);
- $result = $user->setConfig(['password_policy' => 'min:10,max:255']);
-
- $this->assertSame(['password_policy' => "Maximum password length cannot be more than 100."], $result);
-
- \config(['app.password_policy' => 'min:5,max:255']);
- $result = $user->setConfig(['password_policy' => 'min:10,max:255']);
-
- $this->assertSame([], $result);
- $this->assertSame('min:10,max:255', $user->getConfig()['password_policy']);
- $this->assertSame('min:10,max:255', $user->getSetting('password_policy'));
-
- // limit_geo
- $this->assertSame([], $user->getConfig()['limit_geo']);
-
- $result = $user->setConfig(['limit_geo' => '']);
-
- $err = "Specified configuration is invalid. Expected a list of two-letter country codes.";
- $this->assertSame(['limit_geo' => $err], $result);
- $this->assertSame(null, $user->getSetting('limit_geo'));
-
- $result = $user->setConfig(['limit_geo' => ['usa']]);
-
- $this->assertSame(['limit_geo' => $err], $result);
- $this->assertSame(null, $user->getSetting('limit_geo'));
-
- $result = $user->setConfig(['limit_geo' => []]);
-
- $this->assertSame([], $result);
- $this->assertSame(null, $user->getSetting('limit_geo'));
-
- $result = $user->setConfig(['limit_geo' => ['US', 'ru']]);
-
- $this->assertSame([], $result);
- $this->assertSame(['US', 'RU'], $user->getConfig()['limit_geo']);
- $this->assertSame('["US","RU"]', $user->getSetting('limit_geo'));
- }
-
/**
* Test user account degradation and un-degradation
*/

File Metadata

Mime Type
text/plain
Expires
Thu, Apr 2, 7:18 PM (2 d, 4 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18820459
Default Alt Text
D4157.1775157526.diff (50 KB)

Event Timeline