Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117739136
D4157.1775157526.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
50 KB
Referenced Files
None
Subscribers
None
D4157.1775157526.diff
View Options
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
Details
Attached
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)
Attached To
Mode
D4157: WIP: Amavis/Spamassassin Settings
Attached
Detach File
Event Timeline