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
      */