diff --git a/src/.env.example b/src/.env.example
index cadae2fd..c8b9aced 100644
--- a/src/.env.example
+++ b/src/.env.example
@@ -1,167 +1,170 @@
 APP_NAME=Kolab
 APP_ENV=local
 APP_KEY=
 APP_DEBUG=true
 APP_URL=http://127.0.0.1:8000
 #APP_PASSPHRASE=
 APP_PUBLIC_URL=
 APP_DOMAIN=kolabnow.com
 APP_WEBSITE_DOMAIN=kolabnow.com
 APP_THEME=default
 APP_TENANT_ID=5
 APP_LOCALE=en
 APP_LOCALES=
 
 APP_WITH_ADMIN=1
 APP_WITH_RESELLER=1
 APP_WITH_SERVICES=1
 
+SIGNUP_LIMIT_EMAIL=0
+SIGNUP_LIMIT_IP=0
+
 ASSET_URL=http://127.0.0.1:8000
 
 WEBMAIL_URL=/apps
 SUPPORT_URL=/support
 SUPPORT_EMAIL=
 
 LOG_CHANNEL=stack
 LOG_SLOW_REQUESTS=5
 
 DB_CONNECTION=mysql
 DB_DATABASE=kolabdev
 DB_HOST=127.0.0.1
 DB_PASSWORD=kolab
 DB_PORT=3306
 DB_USERNAME=kolabdev
 
 BROADCAST_DRIVER=redis
 CACHE_DRIVER=redis
 
 QUEUE_CONNECTION=redis
 
 SESSION_DRIVER=file
 SESSION_LIFETIME=120
 
 OPENEXCHANGERATES_API_KEY="from openexchangerates.org"
 
 MFA_DSN=mysql://roundcube:Welcome2KolabSystems@127.0.0.1/roundcube
 MFA_TOTP_DIGITS=6
 MFA_TOTP_INTERVAL=30
 MFA_TOTP_DIGEST=sha1
 
 IMAP_URI=ssl://127.0.0.1:11993
 IMAP_ADMIN_LOGIN=cyrus-admin
 IMAP_ADMIN_PASSWORD=Welcome2KolabSystems
 IMAP_VERIFY_HOST=false
 IMAP_VERIFY_PEER=false
 
 LDAP_BASE_DN="dc=mgmt,dc=com"
 LDAP_DOMAIN_BASE_DN="ou=Domains,dc=mgmt,dc=com"
 LDAP_HOSTS=127.0.0.1
 LDAP_PORT=389
 LDAP_SERVICE_BIND_DN="uid=kolab-service,ou=Special Users,dc=mgmt,dc=com"
 LDAP_SERVICE_BIND_PW="Welcome2KolabSystems"
 LDAP_USE_SSL=false
 LDAP_USE_TLS=false
 
 # Administrative
 LDAP_ADMIN_BIND_DN="cn=Directory Manager"
 LDAP_ADMIN_BIND_PW="Welcome2KolabSystems"
 LDAP_ADMIN_ROOT_DN="dc=mgmt,dc=com"
 
 # Hosted (public registration)
 LDAP_HOSTED_BIND_DN="uid=hosted-kolab-service,ou=Special Users,dc=mgmt,dc=com"
 LDAP_HOSTED_BIND_PW="Welcome2KolabSystems"
 LDAP_HOSTED_ROOT_DN="dc=hosted,dc=com"
 
 OPENVIDU_API_PASSWORD=MY_SECRET
 OPENVIDU_API_URL=http://localhost:8080/api/
 OPENVIDU_API_USERNAME=OPENVIDUAPP
 OPENVIDU_API_VERIFY_TLS=true
 OPENVIDU_COTURN_IP=127.0.0.1
 OPENVIDU_COTURN_REDIS_DATABASE=2
 OPENVIDU_COTURN_REDIS_IP=127.0.0.1
 OPENVIDU_COTURN_REDIS_PASSWORD=turn
 # Used as COTURN_IP, TURN_PUBLIC_IP, for KMS_TURN_URL
 OPENVIDU_PUBLIC_IP=127.0.0.1
 OPENVIDU_PUBLIC_PORT=3478
 OPENVIDU_SERVER_PORT=8080
 OPENVIDU_WEBHOOK=true
 OPENVIDU_WEBHOOK_ENDPOINT=http://127.0.0.1:8000/webhooks/meet/openvidu
 
 # "CDR" events, see https://docs.openvidu.io/en/2.13.0/reference-docs/openvidu-server-cdr/
 #OPENVIDU_WEBHOOK_EVENTS=[sessionCreated,sessionDestroyed,participantJoined,participantLeft,webrtcConnectionCreated,webrtcConnectionDestroyed,recordingStatusChanged,filterEventDispatched,mediaNodeStatusChanged]
 #OPENVIDU_WEBHOOK_HEADERS=[\"Authorization:\ Basic\ SOMETHING\"]
 
 PGP_ENABLED=
 PGP_BINARY=
 PGP_AGENT=
 PGP_GPGCONF=
 PGP_LENGTH=
 
 REDIS_HOST=127.0.0.1
 REDIS_PASSWORD=null
 REDIS_PORT=6379
 
 SWOOLE_HOT_RELOAD_ENABLE=true
 SWOOLE_HTTP_ACCESS_LOG=true
 SWOOLE_HTTP_HOST=127.0.0.1
 SWOOLE_HTTP_PORT=8000
 SWOOLE_HTTP_REACTOR_NUM=1
 SWOOLE_HTTP_WEBSOCKET=true
 SWOOLE_HTTP_WORKER_NUM=1
 SWOOLE_OB_OUTPUT=true
 
 PAYMENT_PROVIDER=
 MOLLIE_KEY=
 STRIPE_KEY=
 STRIPE_PUBLIC_KEY=
 STRIPE_WEBHOOK_SECRET=
 
 MAIL_DRIVER=smtp
 MAIL_HOST=smtp.mailtrap.io
 MAIL_PORT=2525
 MAIL_USERNAME=null
 MAIL_PASSWORD=null
 MAIL_ENCRYPTION=null
 MAIL_FROM_ADDRESS="noreply@example.com"
 MAIL_FROM_NAME="Example.com"
 MAIL_REPLYTO_ADDRESS="replyto@example.com"
 MAIL_REPLYTO_NAME=null
 
 DNS_TTL=3600
 DNS_SPF="v=spf1 mx -all"
 DNS_STATIC="%s.    MX  10 ext-mx01.mykolab.com."
 DNS_COPY_FROM=null
 
 AWS_ACCESS_KEY_ID=
 AWS_SECRET_ACCESS_KEY=
 AWS_DEFAULT_REGION=us-east-1
 AWS_BUCKET=
 
 PUSHER_APP_ID=
 PUSHER_APP_KEY=
 PUSHER_APP_SECRET=
 PUSHER_APP_CLUSTER=mt1
 
 MIX_ASSET_PATH='/'
 MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
 MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
 
 # Generate with ./artisan passport:client --password
 #PASSPORT_PROXY_OAUTH_CLIENT_ID=
 #PASSPORT_PROXY_OAUTH_CLIENT_SECRET=
 
 PASSPORT_PRIVATE_KEY=
 PASSPORT_PUBLIC_KEY=
 
 COMPANY_NAME=
 COMPANY_ADDRESS=
 COMPANY_DETAILS=
 COMPANY_EMAIL=
 COMPANY_LOGO=
 COMPANY_FOOTER=
 
 VAT_COUNTRIES=CH,LI
 VAT_RATE=7.7
 
 KB_ACCOUNT_DELETE=
 KB_ACCOUNT_SUSPENDED=
diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php
index 0983f03c..8a84238f 100644
--- a/src/app/Http/Controllers/API/SignupController.php
+++ b/src/app/Http/Controllers/API/SignupController.php
@@ -1,448 +1,448 @@
 <?php
 
 namespace App\Http\Controllers\API;
 
 use App\Http\Controllers\Controller;
 use App\Jobs\SignupVerificationEmail;
 use App\Jobs\SignupVerificationSMS;
 use App\Discount;
 use App\Domain;
 use App\Plan;
-use App\Rules\ExternalEmail;
+use App\Rules\SignupExternalEmail;
 use App\Rules\UserEmailDomain;
 use App\Rules\UserEmailLocal;
 use App\SignupCode;
 use App\SignupInvitation;
 use App\User;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Validator;
 use Illuminate\Support\Str;
 
 /**
  * Signup process API
  */
 class SignupController extends Controller
 {
     /** @var ?\App\SignupCode A verification code object */
     protected $code;
 
     /** @var ?\App\Plan Signup plan object */
     protected $plan;
 
 
     /**
      * Returns plans definitions for signup.
      *
      * @param \Illuminate\Http\Request $request HTTP request
      *
      * @return \Illuminate\Http\JsonResponse JSON response
      */
     public function plans(Request $request)
     {
         $plans = [];
 
         // Use reverse order just to have individual on left, group on right ;)
         Plan::withEnvTenantContext()->orderByDesc('title')->get()
             ->map(function ($plan) use (&$plans) {
                 $plans[] = [
                     'title' => $plan->title,
                     'name' => $plan->name,
                     'button' => \trans('app.planbutton', ['plan' => $plan->name]),
                     'description' => $plan->description,
                 ];
             });
 
         return response()->json(['status' => 'success', 'plans' => $plans]);
     }
 
     /**
      * Starts signup process.
      *
      * Verifies user name and email/phone, sends verification email/sms message.
      * Returns the verification code.
      *
      * @param \Illuminate\Http\Request $request HTTP request
      *
      * @return \Illuminate\Http\JsonResponse JSON response
      */
     public function init(Request $request)
     {
         // Check required fields
         $v = Validator::make(
             $request->all(),
             [
                 'email' => 'required',
                 'first_name' => 'max:128',
                 'last_name' => 'max:128',
                 'plan' => 'nullable|alpha_num|max:128',
                 'voucher' => 'max:32',
             ]
         );
 
         $is_phone = false;
         $errors = $v->fails() ? $v->errors()->toArray() : [];
 
         // Validate user email (or phone)
         if (empty($errors['email'])) {
             if ($error = $this->validatePhoneOrEmail($request->email, $is_phone)) {
                 $errors['email'] = $error;
             }
         }
 
         if (!empty($errors)) {
             return response()->json(['status' => 'error', 'errors' => $errors], 422);
         }
 
         // Generate the verification code
         $code = SignupCode::create([
                 'email' => $request->email,
                 'first_name' => $request->first_name,
                 'last_name' => $request->last_name,
                 'plan' => $request->plan,
                 'voucher' => $request->voucher,
         ]);
 
         // Send email/sms message
         if ($is_phone) {
             SignupVerificationSMS::dispatch($code);
         } else {
             SignupVerificationEmail::dispatch($code);
         }
 
         return response()->json(['status' => 'success', 'code' => $code->code]);
     }
 
     /**
      * Returns signup invitation information.
      *
      * @param string $id Signup invitation identifier
      *
      * @return \Illuminate\Http\JsonResponse|void
      */
     public function invitation($id)
     {
         $invitation = SignupInvitation::withEnvTenantContext()->find($id);
 
         if (empty($invitation) || $invitation->isCompleted()) {
             return $this->errorResponse(404);
         }
 
         $has_domain = $this->getPlan()->hasDomain();
 
         $result = [
             'id' => $id,
             'is_domain' => $has_domain,
             'domains' => $has_domain ? [] : Domain::getPublicDomains(),
         ];
 
         return response()->json($result);
     }
 
 
     /**
      * Validation of the verification code.
      *
      * @param \Illuminate\Http\Request $request HTTP request
      *
      * @return \Illuminate\Http\JsonResponse JSON response
      */
     public function verify(Request $request)
     {
         // Validate the request args
         $v = Validator::make(
             $request->all(),
             [
                 'code' => 'required',
                 'short_code' => 'required',
             ]
         );
 
         if ($v->fails()) {
             return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
         }
 
         // Validate the verification code
         $code = SignupCode::find($request->code);
 
         if (
             empty($code)
             || $code->isExpired()
             || Str::upper($request->short_code) !== Str::upper($code->short_code)
         ) {
             $errors = ['short_code' => "The code is invalid or expired."];
             return response()->json(['status' => 'error', 'errors' => $errors], 422);
         }
 
         // For signup last-step mode remember the code object, so we can delete it
         // with single SQL query (->delete()) instead of two (::destroy())
         $this->code = $code;
 
         $has_domain = $this->getPlan()->hasDomain();
 
         // Return user name and email/phone/voucher from the codes database,
         // domains list for selection and "plan type" flag
         return response()->json([
                 'status' => 'success',
                 'email' => $code->email,
                 'first_name' => $code->first_name,
                 'last_name' => $code->last_name,
                 'voucher' => $code->voucher,
                 'is_domain' => $has_domain,
                 'domains' => $has_domain ? [] : Domain::getPublicDomains(),
         ]);
     }
 
     /**
      * Finishes the signup process by creating the user account.
      *
      * @param \Illuminate\Http\Request $request HTTP request
      *
      * @return \Illuminate\Http\JsonResponse JSON response
      */
     public function signup(Request $request)
     {
         // Validate input
         $v = Validator::make(
             $request->all(),
             [
                 'login' => 'required|min:2',
                 'password' => 'required|min:4|confirmed',
                 'domain' => 'required',
                 'voucher' => 'max:32',
             ]
         );
 
         if ($v->fails()) {
             return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
         }
 
         // Signup via invitation
         if ($request->invitation) {
             $invitation = SignupInvitation::withEnvTenantContext()->find($request->invitation);
 
             if (empty($invitation) || $invitation->isCompleted()) {
                 return $this->errorResponse(404);
             }
 
             // Check required fields
             $v = Validator::make(
                 $request->all(),
                 [
                     'first_name' => 'max:128',
                     'last_name' => 'max:128',
                     'voucher' => 'max:32',
                 ]
             );
 
             $errors = $v->fails() ? $v->errors()->toArray() : [];
 
             if (!empty($errors)) {
                 return response()->json(['status' => 'error', 'errors' => $errors], 422);
             }
 
             $settings = [
                 'external_email' => $invitation->email,
                 'first_name' => $request->first_name,
                 'last_name' => $request->last_name,
             ];
         } else {
             // Validate verification codes (again)
             $v = $this->verify($request);
             if ($v->status() !== 200) {
                 return $v;
             }
 
             // Get user name/email from the verification code database
             $code_data = $v->getData();
 
             $settings = [
                 'external_email' => $code_data->email,
                 'first_name' => $code_data->first_name,
                 'last_name' => $code_data->last_name,
             ];
         }
 
         // Find the voucher discount
         if ($request->voucher) {
             $discount = Discount::where('code', \strtoupper($request->voucher))
                 ->where('active', true)->first();
 
             if (!$discount) {
                 $errors = ['voucher' => \trans('validation.voucherinvalid')];
                 return response()->json(['status' => 'error', 'errors' => $errors], 422);
             }
         }
 
         // Get the plan
         $plan = $this->getPlan();
         $is_domain = $plan->hasDomain();
 
         $login = $request->login;
         $domain_name = $request->domain;
 
         // Validate login
         if ($errors = self::validateLogin($login, $domain_name, $is_domain)) {
             return response()->json(['status' => 'error', 'errors' => $errors], 422);
         }
 
         // We allow only ASCII, so we can safely lower-case the email address
         $login = Str::lower($login);
         $domain_name = Str::lower($domain_name);
         $domain = null;
 
         DB::beginTransaction();
 
         // Create domain record
         if ($is_domain) {
             $domain = Domain::create([
                     'namespace' => $domain_name,
                     'status' => Domain::STATUS_NEW,
                     'type' => Domain::TYPE_EXTERNAL,
             ]);
         }
 
         // Create user record
         $user = User::create([
                 'email' => $login . '@' . $domain_name,
                 'password' => $request->password,
         ]);
 
         if (!empty($discount)) {
             $wallet = $user->wallets()->first();
             $wallet->discount()->associate($discount);
             $wallet->save();
         }
 
         $user->assignPlan($plan, $domain);
 
         // Save the external email and plan in user settings
         $user->setSettings($settings);
 
         // Update the invitation
         if (!empty($invitation)) {
             $invitation->status = SignupInvitation::STATUS_COMPLETED;
             $invitation->user_id = $user->id;
             $invitation->save();
         }
 
         // Remove the verification code
         if ($this->code) {
             $this->code->delete();
         }
 
         DB::commit();
 
         return AuthController::logonResponse($user, $request->password);
     }
 
     /**
      * Returns plan for the signup process
      *
      * @returns \App\Plan Plan object selected for current signup process
      */
     protected function getPlan()
     {
         if (!$this->plan) {
             // Get the plan if specified and exists...
             if ($this->code && $this->code->plan) {
                 $plan = Plan::withEnvTenantContext()->where('title', $this->code->plan)->first();
             }
 
             // ...otherwise use the default plan
             if (empty($plan)) {
                 // TODO: Get default plan title from config
                 $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
             }
 
             $this->plan = $plan;
         }
 
         return $this->plan;
     }
 
     /**
      * Checks if the input string is a valid email address or a phone number
      *
      * @param string $input    Email address or phone number
      * @param bool   $is_phone Will have been set to True if the string is valid phone number
      *
      * @return string Error message on validation error
      */
     protected static function validatePhoneOrEmail($input, &$is_phone = false): ?string
     {
         $is_phone = false;
 
         $v = Validator::make(
             ['email' => $input],
-            ['email' => ['required', 'string', new ExternalEmail()]]
+            ['email' => ['required', 'string', new SignupExternalEmail()]]
         );
 
         if ($v->fails()) {
             return $v->errors()->toArray()['email'][0];
         }
 
         // TODO: Phone number support
 /*
         $input = str_replace(array('-', ' '), '', $input);
 
         if (!preg_match('/^\+?[0-9]{9,12}$/', $input)) {
             return \trans('validation.noemailorphone');
         }
 
         $is_phone = true;
 */
         return null;
     }
 
     /**
      * Login (kolab identity) validation
      *
      * @param string $login    Login (local part of an email address)
      * @param string $domain   Domain name
      * @param bool   $external Enables additional checks for domain part
      *
      * @return array Error messages on validation error
      */
     protected static function validateLogin($login, $domain, $external = false): ?array
     {
         // Validate login part alone
         $v = Validator::make(
             ['login' => $login],
             ['login' => ['required', 'string', new UserEmailLocal($external)]]
         );
 
         if ($v->fails()) {
             return ['login' => $v->errors()->toArray()['login'][0]];
         }
 
         $domains = $external ? null : Domain::getPublicDomains();
 
         // Validate the domain
         $v = Validator::make(
             ['domain' => $domain],
             ['domain' => ['required', 'string', new UserEmailDomain($domains)]]
         );
 
         if ($v->fails()) {
             return ['domain' => $v->errors()->toArray()['domain'][0]];
         }
 
         $domain = Str::lower($domain);
 
         // Check if domain is already registered with us
         if ($external) {
             if (Domain::where('namespace', $domain)->first()) {
                 return ['domain' => \trans('validation.domainexists')];
             }
         }
 
         // Check if user with specified login already exists
         $email = $login . '@' . $domain;
         if (User::emailExists($email) || User::aliasExists($email) || \App\Group::emailExists($email)) {
             return ['login' => \trans('validation.loginexists')];
         }
 
         return null;
     }
 }
diff --git a/src/app/Rules/ExternalEmail.php b/src/app/Rules/ExternalEmail.php
index de112d2a..b0ee7a24 100644
--- a/src/app/Rules/ExternalEmail.php
+++ b/src/app/Rules/ExternalEmail.php
@@ -1,52 +1,52 @@
 <?php
 
 namespace App\Rules;
 
 use Illuminate\Contracts\Validation\Rule;
 use Illuminate\Support\Facades\Validator;
 
 class ExternalEmail implements Rule
 {
-    private $message;
+    protected $message;
 
     /**
      * Determine if the validation rule passes.
      *
      * Email address validation with some more strict rules
      * than the default Laravel's 'email' rule
      *
      * @param string $attribute Attribute name
      * @param mixed  $email     Email address input
      *
      * @return bool
      */
     public function passes($attribute, $email): bool
     {
         $v = Validator::make(['email' => $email], ['email' => 'required|email']);
 
         if ($v->fails()) {
             $this->message = \trans('validation.emailinvalid');
             return false;
         }
 
         list($local, $domain) = explode('@', $email);
 
         // don't allow @localhost and other no-fqdn
         if (strpos($domain, '.') === false) {
             $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/Rules/SignupExternalEmail.php b/src/app/Rules/SignupExternalEmail.php
new file mode 100644
index 00000000..957dec6c
--- /dev/null
+++ b/src/app/Rules/SignupExternalEmail.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace App\Rules;
+
+use App\SignupCode;
+
+class SignupExternalEmail extends ExternalEmail
+{
+    /**
+     * {@inheritDoc}
+     */
+    public function passes($attribute, $email): bool
+    {
+        if (!parent::passes($attribute, $email)) {
+            return false;
+        }
+
+        // Check the max length, according to the database column length
+        if (strlen($email) > 191) {
+            $this->message = \trans('validation.emailinvalid');
+            return false;
+        }
+
+        // Don't allow multiple open registrations against the same email address
+        if (($limit = \config('app.signup.email_limit')) > 0) {
+            $signups = SignupCode::where('email', $email)
+                ->whereDate('expires_at', '>', \Carbon\Carbon::now());
+
+            if ($signups->count() >= $limit) {
+                // @kanarip: this is deliberately an "email invalid" message
+                $this->message = \trans('validation.emailinvalid');
+                return false;
+            }
+        }
+
+        // Don't allow multiple open registrations against the same source ip address
+        if (($limit = \config('app.signup.ip_limit')) > 0) {
+            $signups = SignupCode::where('ip_address', request()->ip())
+                ->whereDate('expires_at', '>', \Carbon\Carbon::now());
+
+            if ($signups->count() >= $limit) {
+                // @kanarip: this is deliberately an "email invalid" message
+                $this->message = \trans('validation.emailinvalid');
+                return false;
+            }
+        }
+
+        return true;
+    }
+}
diff --git a/src/config/app.php b/src/config/app.php
index 41755949..ed3c5ab6 100644
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -1,291 +1,296 @@
 <?php
 
 return [
 
     /*
     |--------------------------------------------------------------------------
     | Application Name
     |--------------------------------------------------------------------------
     |
     | This value is the name of your application. This value is used when the
     | framework needs to place the application's name in a notification or
     | any other location as required by the application or its packages.
     |
     */
 
     'name' => env('APP_NAME', 'Laravel'),
 
     /*
     |--------------------------------------------------------------------------
     | Application Environment
     |--------------------------------------------------------------------------
     |
     | This value determines the "environment" your application is currently
     | running in. This may determine how you prefer to configure various
     | services the application utilizes. Set this in your ".env" file.
     |
     */
 
     'env' => env('APP_ENV', 'production'),
 
     /*
     |--------------------------------------------------------------------------
     | Application Debug Mode
     |--------------------------------------------------------------------------
     |
     | When your application is in debug mode, detailed error messages with
     | stack traces will be shown on every error that occurs within your
     | application. If disabled, a simple generic error page is shown.
     |
     */
 
     'debug' => env('APP_DEBUG', false),
 
     /*
     |--------------------------------------------------------------------------
     | Application URL
     |--------------------------------------------------------------------------
     |
     | This URL is used by the console to properly generate URLs when using
     | the Artisan command line tool. You should set this to the root of
     | your application so that it is used when running Artisan tasks.
     */
 
     'url' => env('APP_URL', 'http://localhost'),
 
     'passphrase' => env('APP_PASSPHRASE', null),
 
     'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')),
 
     'asset_url' => env('ASSET_URL', null),
 
     'support_url' => env('SUPPORT_URL', null),
 
     'support_email' => env('SUPPORT_EMAIL', null),
 
     'webmail_url' => env('WEBMAIL_URL', null),
 
     'theme' => env('APP_THEME', 'default'),
 
     'tenant_id' => env('APP_TENANT_ID', null),
 
     'currency' => \strtoupper(env('APP_CURRENCY', 'CHF')),
 
     /*
     |--------------------------------------------------------------------------
     | Application Domain
     |--------------------------------------------------------------------------
     |
     | System domain used for user signup (kolab identity)
     */
     'domain' => env('APP_DOMAIN', 'domain.tld'),
 
     'website_domain' => env('APP_WEBSITE_DOMAIN', env('APP_DOMAIN', 'domain.tld')),
 
     /*
     |--------------------------------------------------------------------------
     | Application Timezone
     |--------------------------------------------------------------------------
     |
     | Here you may specify the default timezone for your application, which
     | will be used by the PHP date and date-time functions. We have gone
     | ahead and set this to a sensible default for you out of the box.
     |
     */
 
     'timezone' => 'UTC',
 
     /*
     |--------------------------------------------------------------------------
     | Application Locale Configuration
     |--------------------------------------------------------------------------
     |
     | The application locale determines the default locale that will be used
     | by the translation service provider. You are free to set this value
     | to any of the locales which will be supported by the application.
     |
     */
 
     'locale' => env('APP_LOCALE', 'en'),
 
     /*
     |--------------------------------------------------------------------------
     | Application Fallback Locale
     |--------------------------------------------------------------------------
     |
     | The fallback locale determines the locale to use when the current one
     | is not available. You may change the value to correspond to any of
     | the language folders that are provided through your application.
     |
     */
 
     'fallback_locale' => 'en',
 
     /*
     |--------------------------------------------------------------------------
     | Faker Locale
     |--------------------------------------------------------------------------
     |
     | This locale will be used by the Faker PHP library when generating fake
     | data for your database seeds. For example, this will be used to get
     | localized telephone numbers, street address information and more.
     |
     */
 
     'faker_locale' => 'en_US',
 
     /*
     |--------------------------------------------------------------------------
     | Encryption Key
     |--------------------------------------------------------------------------
     |
     | This key is used by the Illuminate encrypter service and should be set
     | to a random, 32 character string, otherwise these encrypted strings
     | will not be safe. Please do this before deploying an application!
     |
     */
 
     'key' => env('APP_KEY'),
 
     'cipher' => 'AES-256-CBC',
 
     /*
     |--------------------------------------------------------------------------
     | Autoloaded Service Providers
     |--------------------------------------------------------------------------
     |
     | The service providers listed here will be automatically loaded on the
     | request to your application. Feel free to add your own services to
     | this array to grant expanded functionality to your applications.
     |
     */
 
     'providers' => [
         /*
          * Laravel Framework Service Providers...
          */
         Illuminate\Auth\AuthServiceProvider::class,
         Illuminate\Broadcasting\BroadcastServiceProvider::class,
         Illuminate\Bus\BusServiceProvider::class,
         Illuminate\Cache\CacheServiceProvider::class,
         Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
         Illuminate\Cookie\CookieServiceProvider::class,
         Illuminate\Database\DatabaseServiceProvider::class,
         Illuminate\Encryption\EncryptionServiceProvider::class,
         Illuminate\Filesystem\FilesystemServiceProvider::class,
         Illuminate\Foundation\Providers\FoundationServiceProvider::class,
         Illuminate\Hashing\HashServiceProvider::class,
         Illuminate\Mail\MailServiceProvider::class,
         Illuminate\Notifications\NotificationServiceProvider::class,
         Illuminate\Pagination\PaginationServiceProvider::class,
         Illuminate\Pipeline\PipelineServiceProvider::class,
         Illuminate\Queue\QueueServiceProvider::class,
         Illuminate\Redis\RedisServiceProvider::class,
         Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
         Illuminate\Session\SessionServiceProvider::class,
         Illuminate\Translation\TranslationServiceProvider::class,
         Illuminate\Validation\ValidationServiceProvider::class,
         Illuminate\View\ViewServiceProvider::class,
 
         /*
          * Package Service Providers...
          */
         Barryvdh\DomPDF\ServiceProvider::class,
 
         /*
          * Application Service Providers...
          */
         App\Providers\AppServiceProvider::class,
         App\Providers\AuthServiceProvider::class,
         // App\Providers\BroadcastServiceProvider::class,
         App\Providers\EventServiceProvider::class,
         App\Providers\HorizonServiceProvider::class,
         App\Providers\PassportServiceProvider::class,
         App\Providers\RouteServiceProvider::class,
     ],
 
     /*
     |--------------------------------------------------------------------------
     | Class Aliases
     |--------------------------------------------------------------------------
     |
     | This array of class aliases will be registered when this application
     | is started. However, feel free to register as many as you wish as
     | the aliases are "lazy" loaded so they don't hinder performance.
     |
     */
 
     'aliases' => [
         'App' => Illuminate\Support\Facades\App::class,
         'Arr' => Illuminate\Support\Arr::class,
         'Artisan' => Illuminate\Support\Facades\Artisan::class,
         'Auth' => Illuminate\Support\Facades\Auth::class,
         'Blade' => Illuminate\Support\Facades\Blade::class,
         'Broadcast' => Illuminate\Support\Facades\Broadcast::class,
         'Bus' => Illuminate\Support\Facades\Bus::class,
         'Cache' => Illuminate\Support\Facades\Cache::class,
         'Config' => Illuminate\Support\Facades\Config::class,
         'Cookie' => Illuminate\Support\Facades\Cookie::class,
         'Crypt' => Illuminate\Support\Facades\Crypt::class,
         'DB' => Illuminate\Support\Facades\DB::class,
         'Eloquent' => Illuminate\Database\Eloquent\Model::class,
         'Event' => Illuminate\Support\Facades\Event::class,
         'File' => Illuminate\Support\Facades\File::class,
         'Gate' => Illuminate\Support\Facades\Gate::class,
         'Hash' => Illuminate\Support\Facades\Hash::class,
         'Lang' => Illuminate\Support\Facades\Lang::class,
         'Log' => Illuminate\Support\Facades\Log::class,
         'Mail' => Illuminate\Support\Facades\Mail::class,
         'Notification' => Illuminate\Support\Facades\Notification::class,
         'Password' => Illuminate\Support\Facades\Password::class,
         'PDF' => Barryvdh\DomPDF\Facade::class,
         'Queue' => Illuminate\Support\Facades\Queue::class,
         'Redirect' => Illuminate\Support\Facades\Redirect::class,
         'Redis' => Illuminate\Support\Facades\Redis::class,
         'Request' => Illuminate\Support\Facades\Request::class,
         'Response' => Illuminate\Support\Facades\Response::class,
         'Route' => Illuminate\Support\Facades\Route::class,
         'Schema' => Illuminate\Support\Facades\Schema::class,
         'Session' => Illuminate\Support\Facades\Session::class,
         'Storage' => Illuminate\Support\Facades\Storage::class,
         'Str' => Illuminate\Support\Str::class,
         'URL' => Illuminate\Support\Facades\URL::class,
         'Validator' => Illuminate\Support\Facades\Validator::class,
         'View' => Illuminate\Support\Facades\View::class,
     ],
 
     // Locations of knowledge base articles
     'kb' => [
         // An article about suspended accounts
         'account_suspended' => env('KB_ACCOUNT_SUSPENDED'),
         // An article about a way to delete an owned account
         'account_delete' => env('KB_ACCOUNT_DELETE'),
     ],
 
     'company' => [
         'name' => env('COMPANY_NAME'),
         'address' => env('COMPANY_ADDRESS'),
         'details' => env('COMPANY_DETAILS'),
         'email' => env('COMPANY_EMAIL'),
         'logo' => env('COMPANY_LOGO'),
         'footer' => env('COMPANY_FOOTER', env('COMPANY_DETAILS')),
     ],
 
     'storage' => [
         'min_qty' => (int) env('STORAGE_MIN_QTY', 5), // in GB
     ],
 
     'vat' => [
         'countries' => env('VAT_COUNTRIES'),
         'rate' => (float) env('VAT_RATE'),
     ],
 
     'payment' => [
         'methods_oneoff' => env('PAYMENT_METHODS_ONEOFF', "creditcard,paypal,banktransfer"),
         'methods_recurring' => env('PAYMENT_METHODS_RECURRING', "creditcard"),
     ],
 
     'with_admin' => (bool) env('APP_WITH_ADMIN', false),
     'with_reseller' => (bool) env('APP_WITH_RESELLER', false),
     'with_services' => (bool) env('APP_WITH_SERVICES', false),
+
+    'signup' => [
+        'email_limit' => (int) env('SIGNUP_LIMIT_EMAIL', 0),
+        'ip_limit' => (int) env('SIGNUP_LIMIT_IP', 0),
+    ],
 ];
diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php
index b67ceb35..306070f9 100644
--- a/src/tests/Feature/Controller/SignupTest.php
+++ b/src/tests/Feature/Controller/SignupTest.php
@@ -1,782 +1,852 @@
 <?php
 
 namespace Tests\Feature\Controller;
 
 use App\Http\Controllers\API\SignupController;
 use App\Discount;
 use App\Domain;
 use App\SignupCode;
 use App\SignupInvitation as SI;
 use App\User;
 use Illuminate\Support\Facades\Queue;
 use Tests\TestCase;
 
 class SignupTest extends TestCase
 {
     private $domain;
 
     /**
      * {@inheritDoc}
      */
     public function setUp(): void
     {
         parent::setUp();
 
         // TODO: Some tests depend on existence of individual and group plans,
         //       we should probably create plans here to not depend on that
         $this->domain = $this->getPublicDomain();
 
         $this->deleteTestUser("SignupControllerTest1@$this->domain");
         $this->deleteTestUser("signuplogin@$this->domain");
         $this->deleteTestUser("admin@external.com");
         $this->deleteTestUser("test-inv@kolabnow.com");
 
         $this->deleteTestDomain('external.com');
         $this->deleteTestDomain('signup-domain.com');
 
         $this->deleteTestGroup('group-test@kolabnow.com');
         SI::truncate();
     }
 
     /**
      * {@inheritDoc}
      */
     public function tearDown(): void
     {
         $this->deleteTestUser("SignupControllerTest1@$this->domain");
         $this->deleteTestUser("signuplogin@$this->domain");
         $this->deleteTestUser("admin@external.com");
         $this->deleteTestUser("test-inv@kolabnow.com");
 
         $this->deleteTestDomain('external.com');
         $this->deleteTestDomain('signup-domain.com');
 
         $this->deleteTestGroup('group-test@kolabnow.com');
         SI::truncate();
 
         parent::tearDown();
     }
 
     /**
      * Return a public domain for signup tests
      */
     private function getPublicDomain(): string
     {
         if (!$this->domain) {
             $this->refreshApplication();
             $public_domains = Domain::getPublicDomains();
             $this->domain = reset($public_domains);
 
             if (empty($this->domain)) {
                 $this->domain = 'signup-domain.com';
                 Domain::create([
                         'namespace' => $this->domain,
                         'status' => Domain::STATUS_ACTIVE,
                         'type' => Domain::TYPE_PUBLIC,
                 ]);
             }
         }
 
         return $this->domain;
     }
 
     /**
      * Test fetching plans for signup
      */
     public function testSignupPlans(): void
     {
         $response = $this->get('/api/auth/signup/plans');
         $json = $response->json();
 
         $response->assertStatus(200);
 
         $this->assertSame('success', $json['status']);
         $this->assertCount(2, $json['plans']);
         $this->assertArrayHasKey('title', $json['plans'][0]);
         $this->assertArrayHasKey('name', $json['plans'][0]);
         $this->assertArrayHasKey('description', $json['plans'][0]);
         $this->assertArrayHasKey('button', $json['plans'][0]);
     }
 
     /**
      * Test fetching invitation
      */
     public function testSignupInvitations(): void
     {
         Queue::fake();
 
         $invitation = SI::create(['email' => 'email1@ext.com']);
 
         // Test existing invitation
         $response = $this->get("/api/auth/signup/invitations/{$invitation->id}");
         $response->assertStatus(200);
 
         $json = $response->json();
 
         $this->assertSame($invitation->id, $json['id']);
 
         // Test non-existing invitation
         $response = $this->get("/api/auth/signup/invitations/abc");
         $response->assertStatus(404);
 
         // Test completed invitation
         SI::where('id', $invitation->id)->update(['status' => SI::STATUS_COMPLETED]);
         $response = $this->get("/api/auth/signup/invitations/{$invitation->id}");
         $response->assertStatus(404);
     }
 
     /**
      * Test signup initialization with invalid input
      */
     public function testSignupInitInvalidInput(): void
     {
         // Empty input data
         $data = [];
 
         $response = $this->post('/api/auth/signup/init', $data);
         $json = $response->json();
 
         $response->assertStatus(422);
 
         $this->assertSame('error', $json['status']);
         $this->assertCount(1, $json['errors']);
         $this->assertArrayHasKey('email', $json['errors']);
 
         // Data with missing name
         $data = [
             'email' => 'UsersApiControllerTest1@UsersApiControllerTest.com',
             'first_name' => str_repeat('a', 250),
             'last_name' => str_repeat('a', 250),
         ];
 
         $response = $this->post('/api/auth/signup/init', $data);
         $json = $response->json();
 
         $response->assertStatus(422);
 
         $this->assertSame('error', $json['status']);
         $this->assertCount(2, $json['errors']);
         $this->assertArrayHasKey('first_name', $json['errors']);
         $this->assertArrayHasKey('last_name', $json['errors']);
 
         // Data with invalid email (but not phone number)
         $data = [
             'email' => '@example.org',
             'first_name' => 'Signup',
             'last_name' => 'User',
         ];
 
         $response = $this->post('/api/auth/signup/init', $data);
         $json = $response->json();
 
         $response->assertStatus(422);
 
         $this->assertSame('error', $json['status']);
         $this->assertCount(1, $json['errors']);
         $this->assertArrayHasKey('email', $json['errors']);
 
         // Sanity check on voucher code, last/first name is optional
         $data = [
             'voucher' => '123456789012345678901234567890123',
             'email' => 'valid@email.com',
         ];
 
         $response = $this->post('/api/auth/signup/init', $data);
         $json = $response->json();
 
         $response->assertStatus(422);
 
         $this->assertSame('error', $json['status']);
         $this->assertCount(1, $json['errors']);
         $this->assertArrayHasKey('voucher', $json['errors']);
 
+        // Email address too long
+        $data = [
+            'email' => str_repeat('a', 190) . '@example.org',
+            'first_name' => 'Signup',
+            'last_name' => 'User',
+        ];
+
+        $response = $this->post('/api/auth/signup/init', $data);
+        $json = $response->json();
+
+        $response->assertStatus(422);
+
+        $this->assertSame('error', $json['status']);
+        $this->assertCount(1, $json['errors']);
+        $this->assertSame("The specified email address is invalid.", $json['errors']['email']);
+
+        SignupCode::truncate();
+
+        // Email address limit check
+        $data = [
+            'email' => 'test@example.org',
+            'first_name' => 'Signup',
+            'last_name' => 'User',
+        ];
+
+        \config(['app.signup.email_limit' => 0]);
+
+        $response = $this->post('/api/auth/signup/init', $data);
+        $json = $response->json();
+
+        $response->assertStatus(200);
+
+        \config(['app.signup.email_limit' => 1]);
+
+        $response = $this->post('/api/auth/signup/init', $data);
+        $json = $response->json();
+
+        $response->assertStatus(422);
+        $this->assertSame('error', $json['status']);
+        $this->assertCount(1, $json['errors']);
+        // TODO: This probably should be a different message?
+        $this->assertSame("The specified email address is invalid.", $json['errors']['email']);
+
+        // IP address limit check
+        $data = [
+            'email' => 'ip@example.org',
+            'first_name' => 'Signup',
+            'last_name' => 'User',
+        ];
+
+        \config(['app.signup.email_limit' => 0]);
+        \config(['app.signup.ip_limit' => 0]);
+
+        $response = $this->post('/api/auth/signup/init', $data, ['REMOTE_ADDR' => '10.1.1.1']);
+        $json = $response->json();
+
+        $response->assertStatus(200);
+
+        \config(['app.signup.ip_limit' => 1]);
+
+        $response = $this->post('/api/auth/signup/init', $data, ['REMOTE_ADDR' => '10.1.1.1']);
+        $json = $response->json();
+
+        $response->assertStatus(422);
+
+        $this->assertSame('error', $json['status']);
+        $this->assertCount(1, $json['errors']);
+        // TODO: This probably should be a different message?
+        $this->assertSame("The specified email address is invalid.", $json['errors']['email']);
+
         // TODO: Test phone validation
     }
 
     /**
      * Test signup initialization with valid input
      */
     public function testSignupInitValidInput(): array
     {
         Queue::fake();
 
         // Assert that no jobs were pushed...
         Queue::assertNothingPushed();
 
         $data = [
             'email' => 'testuser@external.com',
             'first_name' => 'Signup',
             'last_name' => 'User',
             'plan' => 'individual',
         ];
 
         $response = $this->post('/api/auth/signup/init', $data);
         $json = $response->json();
 
         $response->assertStatus(200);
         $this->assertCount(2, $json);
         $this->assertSame('success', $json['status']);
         $this->assertNotEmpty($json['code']);
 
         // Assert the email sending job was pushed once
         Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, 1);
 
         // Assert the job has proper data assigned
         Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) {
             $code = TestCase::getObjectProperty($job, 'code');
 
             return $code->code === $json['code']
                 && $code->plan === $data['plan']
                 && $code->email === $data['email']
                 && $code->first_name === $data['first_name']
                 && $code->last_name === $data['last_name'];
         });
 
         // Try the same with voucher
         $data['voucher'] = 'TEST';
 
         $response = $this->post('/api/auth/signup/init', $data);
         $json = $response->json();
 
         $response->assertStatus(200);
         $this->assertCount(2, $json);
         $this->assertSame('success', $json['status']);
         $this->assertNotEmpty($json['code']);
 
         // Assert the job has proper data assigned
         Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) {
             $code = TestCase::getObjectProperty($job, 'code');
 
             return $code->code === $json['code']
                 && $code->plan === $data['plan']
                 && $code->email === $data['email']
                 && $code->voucher === $data['voucher']
                 && $code->first_name === $data['first_name']
                 && $code->last_name === $data['last_name'];
         });
 
         return [
             'code' => $json['code'],
             'email' => $data['email'],
             'first_name' => $data['first_name'],
             'last_name' => $data['last_name'],
             'plan' => $data['plan'],
             'voucher' => $data['voucher']
         ];
     }
 
     /**
      * Test signup code verification with invalid input
      *
      * @depends testSignupInitValidInput
      */
     public function testSignupVerifyInvalidInput(array $result): void
     {
         // Empty data
         $data = [];
 
         $response = $this->post('/api/auth/signup/verify', $data);
         $json = $response->json();
 
         $response->assertStatus(422);
         $this->assertSame('error', $json['status']);
         $this->assertCount(2, $json['errors']);
         $this->assertArrayHasKey('code', $json['errors']);
         $this->assertArrayHasKey('short_code', $json['errors']);
 
         // Data with existing code but missing short_code
         $data = [
             'code' => $result['code'],
         ];
 
         $response = $this->post('/api/auth/signup/verify', $data);
         $json = $response->json();
 
         $response->assertStatus(422);
         $this->assertSame('error', $json['status']);
         $this->assertCount(1, $json['errors']);
         $this->assertArrayHasKey('short_code', $json['errors']);
 
         // Data with invalid short_code
         $data = [
             'code' => $result['code'],
             'short_code' => 'XXXX',
         ];
 
         $response = $this->post('/api/auth/signup/verify', $data);
         $json = $response->json();
 
         $response->assertStatus(422);
         $this->assertSame('error', $json['status']);
         $this->assertCount(1, $json['errors']);
         $this->assertArrayHasKey('short_code', $json['errors']);
 
         // TODO: Test expired code
     }
 
     /**
      * Test signup code verification with valid input
      *
      * @depends testSignupInitValidInput
      */
     public function testSignupVerifyValidInput(array $result): array
     {
         $code = SignupCode::find($result['code']);
         $data = [
             'code' => $code->code,
             'short_code' => $code->short_code,
         ];
 
         $response = $this->post('/api/auth/signup/verify', $data);
         $json = $response->json();
 
         $response->assertStatus(200);
         $this->assertCount(7, $json);
         $this->assertSame('success', $json['status']);
         $this->assertSame($result['email'], $json['email']);
         $this->assertSame($result['first_name'], $json['first_name']);
         $this->assertSame($result['last_name'], $json['last_name']);
         $this->assertSame($result['voucher'], $json['voucher']);
         $this->assertSame(false, $json['is_domain']);
         $this->assertTrue(is_array($json['domains']) && !empty($json['domains']));
 
         return $result;
     }
 
     /**
      * Test last signup step with invalid input
      *
      * @depends testSignupVerifyValidInput
      */
     public function testSignupInvalidInput(array $result): void
     {
         // Empty data
         $data = [];
 
         $response = $this->post('/api/auth/signup', $data);
         $json = $response->json();
 
         $response->assertStatus(422);
         $this->assertSame('error', $json['status']);
         $this->assertCount(3, $json['errors']);
         $this->assertArrayHasKey('login', $json['errors']);
         $this->assertArrayHasKey('password', $json['errors']);
         $this->assertArrayHasKey('domain', $json['errors']);
 
         $domain = $this->getPublicDomain();
 
         // Passwords do not match and missing domain
         $data = [
             'login' => 'test',
             'password' => 'test',
             'password_confirmation' => 'test2',
         ];
 
         $response = $this->post('/api/auth/signup', $data);
         $json = $response->json();
 
         $response->assertStatus(422);
         $this->assertSame('error', $json['status']);
         $this->assertCount(2, $json['errors']);
         $this->assertArrayHasKey('password', $json['errors']);
         $this->assertArrayHasKey('domain', $json['errors']);
 
         $domain = $this->getPublicDomain();
 
         // Login too short
         $data = [
             'login' => '1',
             'domain' => $domain,
             'password' => 'test',
             'password_confirmation' => 'test',
         ];
 
         $response = $this->post('/api/auth/signup', $data);
         $json = $response->json();
 
         $response->assertStatus(422);
         $this->assertSame('error', $json['status']);
         $this->assertCount(1, $json['errors']);
         $this->assertArrayHasKey('login', $json['errors']);
 
         // Missing codes
         $data = [
             'login' => 'login-valid',
             'domain' => $domain,
             'password' => 'test',
             'password_confirmation' => 'test',
         ];
 
         $response = $this->post('/api/auth/signup', $data);
         $json = $response->json();
 
         $response->assertStatus(422);
         $this->assertSame('error', $json['status']);
         $this->assertCount(2, $json['errors']);
         $this->assertArrayHasKey('code', $json['errors']);
         $this->assertArrayHasKey('short_code', $json['errors']);
 
         // Data with invalid short_code
         $data = [
             'login' => 'TestLogin',
             'domain' => $domain,
             'password' => 'test',
             'password_confirmation' => 'test',
             'code' => $result['code'],
             'short_code' => 'XXXX',
         ];
 
         $response = $this->post('/api/auth/signup', $data);
         $json = $response->json();
 
         $response->assertStatus(422);
         $this->assertSame('error', $json['status']);
         $this->assertCount(1, $json['errors']);
         $this->assertArrayHasKey('short_code', $json['errors']);
 
         $code = SignupCode::find($result['code']);
 
         // Data with invalid voucher
         $data = [
             'login' => 'TestLogin',
             'domain' => $domain,
             'password' => 'test',
             'password_confirmation' => 'test',
             'code' => $result['code'],
             'short_code' => $code->short_code,
             'voucher' => 'XXX',
         ];
 
         $response = $this->post('/api/auth/signup', $data);
         $json = $response->json();
 
         $response->assertStatus(422);
         $this->assertSame('error', $json['status']);
         $this->assertCount(1, $json['errors']);
         $this->assertArrayHasKey('voucher', $json['errors']);
 
         // Valid code, invalid login
         $data = [
             'login' => 'żżżżżż',
             'domain' => $domain,
             'password' => 'test',
             'password_confirmation' => 'test',
             'code' => $result['code'],
             'short_code' => $code->short_code,
         ];
 
         $response = $this->post('/api/auth/signup', $data);
         $json = $response->json();
 
         $response->assertStatus(422);
         $this->assertSame('error', $json['status']);
         $this->assertCount(1, $json['errors']);
         $this->assertArrayHasKey('login', $json['errors']);
     }
 
     /**
      * Test last signup step with valid input (user creation)
      *
      * @depends testSignupVerifyValidInput
      */
     public function testSignupValidInput(array $result): void
     {
         $queue = Queue::fake();
 
         $domain = $this->getPublicDomain();
         $identity = \strtolower('SignupLogin@') . $domain;
         $code = SignupCode::find($result['code']);
         $data = [
             'login' => 'SignupLogin',
             'domain' => $domain,
             'password' => 'test',
             'password_confirmation' => 'test',
             'code' => $code->code,
             'short_code' => $code->short_code,
             'voucher' => 'TEST',
         ];
 
         $response = $this->post('/api/auth/signup', $data);
         $json = $response->json();
 
         $response->assertStatus(200);
         $this->assertSame('success', $json['status']);
         $this->assertSame('bearer', $json['token_type']);
         $this->assertTrue(!empty($json['expires_in']) && is_int($json['expires_in']) && $json['expires_in'] > 0);
         $this->assertNotEmpty($json['access_token']);
         $this->assertSame($identity, $json['email']);
 
         Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1);
 
         Queue::assertPushed(
             \App\Jobs\User\CreateJob::class,
             function ($job) use ($data) {
                 $userEmail = TestCase::getObjectProperty($job, 'userEmail');
                 return $userEmail === \strtolower($data['login'] . '@' . $data['domain']);
             }
         );
 
         // Check if the code has been removed
         $this->assertNull(SignupCode::where('code', $result['code'])->first());
 
         // Check if the user has been created
         $user = User::where('email', $identity)->first();
 
         $this->assertNotEmpty($user);
         $this->assertSame($identity, $user->email);
 
         // Check user settings
         $this->assertSame($result['first_name'], $user->getSetting('first_name'));
         $this->assertSame($result['last_name'], $user->getSetting('last_name'));
         $this->assertSame($result['email'], $user->getSetting('external_email'));
 
         // Discount
         $discount = Discount::where('code', 'TEST')->first();
         $this->assertSame($discount->id, $user->wallets()->first()->discount_id);
 
         // TODO: Check SKUs/Plan
 
         // TODO: Check if the access token works
     }
 
     /**
      * Test signup for a group (custom domain) account
      */
     public function testSignupGroupAccount(): void
     {
         Queue::fake();
 
         // Initial signup request
         $user_data = $data = [
             'email' => 'testuser@external.com',
             'first_name' => 'Signup',
             'last_name' => 'User',
             'plan' => 'group',
         ];
 
         $response = $this->withoutMiddleware()->post('/api/auth/signup/init', $data);
         $json = $response->json();
 
         $response->assertStatus(200);
         $this->assertCount(2, $json);
         $this->assertSame('success', $json['status']);
         $this->assertNotEmpty($json['code']);
 
         // Assert the email sending job was pushed once
         Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, 1);
 
         // Assert the job has proper data assigned
         Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) {
             $code = TestCase::getObjectProperty($job, 'code');
 
             return $code->code === $json['code']
                 && $code->plan === $data['plan']
                 && $code->email === $data['email']
                 && $code->first_name === $data['first_name']
                 && $code->last_name === $data['last_name'];
         });
 
         // Verify the code
         $code = SignupCode::find($json['code']);
         $data = [
             'code' => $code->code,
             'short_code' => $code->short_code,
         ];
 
         $response = $this->post('/api/auth/signup/verify', $data);
         $result = $response->json();
 
         $response->assertStatus(200);
         $this->assertCount(7, $result);
         $this->assertSame('success', $result['status']);
         $this->assertSame($user_data['email'], $result['email']);
         $this->assertSame($user_data['first_name'], $result['first_name']);
         $this->assertSame($user_data['last_name'], $result['last_name']);
         $this->assertSame(null, $result['voucher']);
         $this->assertSame(true, $result['is_domain']);
         $this->assertSame([], $result['domains']);
 
         // Final signup request
         $login = 'admin';
         $domain = 'external.com';
         $data = [
             'login' => $login,
             'domain' => $domain,
             'password' => 'test',
             'password_confirmation' => 'test',
             'code' => $code->code,
             'short_code' => $code->short_code,
         ];
 
         $response = $this->post('/api/auth/signup', $data);
         $result = $response->json();
 
         $response->assertStatus(200);
         $this->assertSame('success', $result['status']);
         $this->assertSame('bearer', $result['token_type']);
         $this->assertTrue(!empty($result['expires_in']) && is_int($result['expires_in']) && $result['expires_in'] > 0);
         $this->assertNotEmpty($result['access_token']);
         $this->assertSame("$login@$domain", $result['email']);
 
         Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1);
 
         Queue::assertPushed(
             \App\Jobs\Domain\CreateJob::class,
             function ($job) use ($domain) {
                 $domainNamespace = TestCase::getObjectProperty($job, 'domainNamespace');
 
                 return $domainNamespace === $domain;
             }
         );
 
         Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1);
 
         Queue::assertPushed(
             \App\Jobs\User\CreateJob::class,
             function ($job) use ($data) {
                 $userEmail = TestCase::getObjectProperty($job, 'userEmail');
 
                 return $userEmail === $data['login'] . '@' . $data['domain'];
             }
         );
 
         // Check if the code has been removed
         $this->assertNull(SignupCode::find($code->id));
 
         // Check if the user has been created
         $user = User::where('email', $login . '@' . $domain)->first();
 
         $this->assertNotEmpty($user);
 
         // Check user settings
         $this->assertSame($user_data['email'], $user->getSetting('external_email'));
         $this->assertSame($user_data['first_name'], $user->getSetting('first_name'));
         $this->assertSame($user_data['last_name'], $user->getSetting('last_name'));
 
         // TODO: Check domain record
 
         // TODO: Check SKUs/Plan
 
         // TODO: Check if the access token works
     }
 
     /**
      * Test signup via invitation
      */
     public function testSignupViaInvitation(): void
     {
         Queue::fake();
 
         $invitation = SI::create(['email' => 'email1@ext.com']);
 
         $post = [
             'invitation' => 'abc',
             'first_name' => 'Signup',
             'last_name' => 'User',
             'login' => 'test-inv',
             'domain' => 'kolabnow.com',
             'password' => 'test',
             'password_confirmation' => 'test',
         ];
 
         // Test invalid invitation identifier
         $response = $this->post('/api/auth/signup', $post);
         $response->assertStatus(404);
 
         // Test valid input
         $post['invitation'] = $invitation->id;
         $response = $this->post('/api/auth/signup', $post);
         $result = $response->json();
 
         $response->assertStatus(200);
         $this->assertSame('success', $result['status']);
         $this->assertSame('bearer', $result['token_type']);
         $this->assertTrue(!empty($result['expires_in']) && is_int($result['expires_in']) && $result['expires_in'] > 0);
         $this->assertNotEmpty($result['access_token']);
         $this->assertSame('test-inv@kolabnow.com', $result['email']);
 
         // Check if the user has been created
         $user = User::where('email', 'test-inv@kolabnow.com')->first();
 
         $this->assertNotEmpty($user);
 
         // Check user settings
         $this->assertSame($invitation->email, $user->getSetting('external_email'));
         $this->assertSame($post['first_name'], $user->getSetting('first_name'));
         $this->assertSame($post['last_name'], $user->getSetting('last_name'));
 
         $invitation->refresh();
 
         $this->assertSame($user->id, $invitation->user_id);
         $this->assertTrue($invitation->isCompleted());
 
         // TODO: Test POST params validation
     }
 
     /**
      * List of login/domain validation cases for testValidateLogin()
      *
      * @return array Arguments for testValidateLogin()
      */
     public function dataValidateLogin(): array
     {
         $domain = $this->getPublicDomain();
 
         return [
             // Individual account
             ['', $domain, false, ['login' => 'The login field is required.']],
             ['test123456', 'localhost', false, ['domain' => 'The specified domain is invalid.']],
             ['test123456', 'unknown-domain.org', false, ['domain' => 'The specified domain is invalid.']],
             ['test.test', $domain, false, null],
             ['test_test', $domain, false, null],
             ['test-test', $domain, false, null],
             ['admin', $domain, false, ['login' => 'The specified login is not available.']],
             ['administrator', $domain, false, ['login' => 'The specified login is not available.']],
             ['sales', $domain, false, ['login' => 'The specified login is not available.']],
             ['root', $domain, false, ['login' => 'The specified login is not available.']],
 
             // TODO existing (public domain) user
             // ['signuplogin', $domain, false, ['login' => 'The specified login is not available.']],
 
             // Domain account
             ['admin', 'kolabsys.com', true, null],
             ['testnonsystemdomain', 'invalid', true, ['domain' => 'The specified domain is invalid.']],
             ['testnonsystemdomain', '.com', true, ['domain' => 'The specified domain is invalid.']],
 
             // existing custom domain
             ['jack', 'kolab.org', true, ['domain' => 'The specified domain is not available.']],
         ];
     }
 
     /**
      * Signup login/domain validation.
      *
      * Note: Technically these include unit tests, but let's keep it here for now.
      * FIXME: Shall we do a http request for each case?
      *
      * @dataProvider dataValidateLogin
      */
     public function testValidateLogin($login, $domain, $external, $expected_result): void
     {
         $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]);
 
         $this->assertSame($expected_result, $result);
     }
 
     /**
      * Signup login/domain validation, more cases
      *
      * Note: Technically these include unit tests, but let's keep it here for now.
      */
     public function testValidateLoginMore(): void
     {
         $group = $this->getTestGroup('group-test@kolabnow.com');
         $login = 'group-test';
         $domain = 'kolabnow.com';
         $external = false;
 
         $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]);
 
         $this->assertSame(['login' => 'The specified login is not available.'], $result);
     }
 }