diff --git a/src/.env.example b/src/.env.example index fba2c27a..6da75f74 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,174 +1,175 @@ 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 APP_HEADER_CSP="connect-src 'self'; child-src 'self'; font-src 'self'; form-action 'self' data:; frame-ancestors 'self'; img-src blob: data: 'self' *; media-src 'self'; object-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-eval' 'unsafe-inline'; default-src 'self';" APP_HEADER_XFO=sameorigin 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 LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug 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" COTURN_PUBLIC_IP=127.0.0.1 COTURN_STATIC_SECRET="Welcome2KolabSystems" MEET_WEBHOOK_TOKEN=Welcome2KolabSystems MEET_SERVER_TOKEN=Welcome2KolabSystems MEET_SERVER_URLS=https://localhost:12443/meetmedia/api/ MEET_SERVER_VERIFY_TLS=true MEET_WEBRTC_LISTEN_IP= MEET_PUBLIC_DOMAIN=127.0.0.1:12443 MEET_TURN_SERVER='turn:127.0.0.1:3478?transport=tcp' PGP_ENABLED= PGP_BINARY= PGP_AGENT= PGP_GPGCONF= PGP_LENGTH= # Set these to IP addresses you serve WOAT with. # Have the domain owner point _woat. NS RRs refer to ns0{1,2}. WOAT_NS1=ns01.domain.tld WOAT_NS2=ns02.domain.tld REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 OCTANE_HTTP_HOST=127.0.0.1 SWOOLE_PACKAGE_MAX_LENGTH=10485760 PAYMENT_PROVIDER= MOLLIE_KEY= STRIPE_KEY= STRIPE_PUBLIC_KEY= STRIPE_WEBHOOK_SECRET= MAIL_MAILER=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= AWS_USE_PATH_STYLE_ENDPOINT=false 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= # Generate with ./artisan passport:client --password #PASSPORT_COMPANIONAPP_OAUTH_CLIENT_ID= #PASSPORT_COMPANIONAPP_OAUTH_CLIENT_SECRET= PASSPORT_PRIVATE_KEY= PASSPORT_PUBLIC_KEY= PASSWORD_POLICY= 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= +KB_PAYMENT_SYSTEM= diff --git a/src/app/Console/Commands/Wallet/TrialEndCommand.php b/src/app/Console/Commands/Wallet/TrialEndCommand.php new file mode 100644 index 00000000..79c85821 --- /dev/null +++ b/src/app/Console/Commands/Wallet/TrialEndCommand.php @@ -0,0 +1,53 @@ +join('users', 'users.id', '=', 'wallets.user_id') + ->leftJoin('wallet_settings', function ($join) { + $join->on('wallet_settings.wallet_id', '=', 'wallets.id') + ->where('wallet_settings.key', 'trial_end_notice'); + }) + ->withEnvTenantContext('users') + ->whereNull('users.deleted_at') + ->where('users.status', '&', \App\User::STATUS_IMAP_READY) + ->where('users.created_at', '>', \now()->subMonthsNoOverflow(2)) + ->whereNull('wallet_settings.value') + ->cursor(); + + foreach ($wallets as $wallet) { + // Send the email asynchronously + \App\Jobs\TrialEndEmail::dispatch($wallet->owner); + + // Store the timestamp + $wallet->setSetting('trial_end_notice', (string) \now()); + } + } +} diff --git a/src/app/Console/Kernel.php b/src/app/Console/Kernel.php index 130cf35d..3d239fbf 100644 --- a/src/app/Console/Kernel.php +++ b/src/app/Console/Kernel.php @@ -1,55 +1,50 @@ command('data:import')->dailyAt('05:00'); // This notifies users about coming password expiration $schedule->command('password:retention')->dailyAt('06:00'); - // These apply wallet charges - $schedule->command('wallet:charge')->dailyAt('00:00'); - $schedule->command('wallet:charge')->dailyAt('04:00'); - $schedule->command('wallet:charge')->dailyAt('08:00'); - $schedule->command('wallet:charge')->dailyAt('12:00'); - $schedule->command('wallet:charge')->dailyAt('16:00'); - $schedule->command('wallet:charge')->dailyAt('20:00'); + // This applies wallet charges + $schedule->command('wallet:charge')->everyFourHours(); - // this is a laravel 8-ism - //$schedule->command('wallet:charge')->everyFourHours(); - - // This command removes deleted storage files/file chunks from the filesystem + // This removes deleted storage files/file chunks from the filesystem $schedule->command('fs:expunge')->hourly(); + + // This notifies users about an end of the trial period + $schedule->command('wallet:trial-end')->dailyAt('07:00'); } /** * Register the commands for the application. * * @return void */ protected function commands() { $this->load(__DIR__ . '/Commands'); if (\app('env') == 'local') { $this->load(__DIR__ . '/Development'); } include base_path('routes/console.php'); } } diff --git a/src/app/Jobs/TrialEndEmail.php b/src/app/Jobs/TrialEndEmail.php new file mode 100644 index 00000000..526b1209 --- /dev/null +++ b/src/app/Jobs/TrialEndEmail.php @@ -0,0 +1,65 @@ +account = $account; + } + + /** + * Determine the time at which the job should timeout. + * + * @return \DateTime + */ + public function retryUntil() + { + // FIXME: I think it does not make sense to continue trying after 24 hours + return now()->addHours(24); + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + \App\Mail\Helper::sendMail( + new TrialEnd($this->account), + $this->account->tenant_id, + ['to' => $this->account->email] + ); + } +} diff --git a/src/app/Mail/DegradedAccountReminder.php b/src/app/Mail/DegradedAccountReminder.php index bed6247a..2f791783 100644 --- a/src/app/Mail/DegradedAccountReminder.php +++ b/src/app/Mail/DegradedAccountReminder.php @@ -1,82 +1,82 @@ wallet = $wallet; $this->user = $user; } /** * Build the message. * * @return $this */ public function build() { $appName = Tenant::getConfig($this->user->tenant_id, 'app.name'); $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url'); $subject = \trans('mail.degradedaccountreminder-subject', ['site' => $appName]); $this->view('emails.html.degraded_account_reminder') ->text('emails.plain.degraded_account_reminder') ->subject($subject) ->with([ 'site' => $appName, 'subject' => $subject, 'username' => $this->user->name(true), - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id), 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), 'dashboardUrl' => Utils::serviceUrl('/dashboard', $this->user->tenant_id), ]); return $this; } /** * Render the mail template with fake data * * @param string $type Output format ('html' or 'text') * * @return string HTML or Plain Text output */ public static function fakeRender(string $type = 'html'): string { $wallet = new Wallet(); $user = new User(); $mail = new self($wallet, $user); return Helper::render($mail, $type); } } diff --git a/src/app/Mail/NegativeBalance.php b/src/app/Mail/NegativeBalance.php index 3601d4d3..524ed06d 100644 --- a/src/app/Mail/NegativeBalance.php +++ b/src/app/Mail/NegativeBalance.php @@ -1,81 +1,81 @@ wallet = $wallet; $this->user = $user; } /** * Build the message. * * @return $this */ public function build() { $appName = Tenant::getConfig($this->user->tenant_id, 'app.name'); $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url'); $subject = \trans('mail.negativebalance-subject', ['site' => $appName]); $this->view('emails.html.negative_balance') ->text('emails.plain.negative_balance') ->subject($subject) ->with([ 'site' => $appName, 'subject' => $subject, 'username' => $this->user->name(true), - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id), 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), ]); return $this; } /** * Render the mail template with fake data * * @param string $type Output format ('html' or 'text') * * @return string HTML or Plain Text output */ public static function fakeRender(string $type = 'html'): string { $wallet = new Wallet(); $user = new User(); $mail = new self($wallet, $user); return Helper::render($mail, $type); } } diff --git a/src/app/Mail/NegativeBalanceBeforeDelete.php b/src/app/Mail/NegativeBalanceBeforeDelete.php index 1a9e7f56..b05c0398 100644 --- a/src/app/Mail/NegativeBalanceBeforeDelete.php +++ b/src/app/Mail/NegativeBalanceBeforeDelete.php @@ -1,84 +1,84 @@ wallet = $wallet; $this->user = $user; } /** * Build the message. * * @return $this */ public function build() { $threshold = WalletCheck::threshold($this->wallet, WalletCheck::THRESHOLD_DELETE); $appName = Tenant::getConfig($this->user->tenant_id, 'app.name'); $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url'); $subject = \trans('mail.negativebalancebeforedelete-subject', ['site' => $appName]); $this->view('emails.html.negative_balance_before_delete') ->text('emails.plain.negative_balance_before_delete') ->subject($subject) ->with([ 'site' => $appName, 'subject' => $subject, 'username' => $this->user->name(true), - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id), 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), 'date' => $threshold->toDateString(), ]); return $this; } /** * Render the mail template with fake data * * @param string $type Output format ('html' or 'text') * * @return string HTML or Plain Text output */ public static function fakeRender(string $type = 'html'): string { $wallet = new Wallet(); $user = new User(); $mail = new self($wallet, $user); return Helper::render($mail, $type); } } diff --git a/src/app/Mail/NegativeBalanceDegraded.php b/src/app/Mail/NegativeBalanceDegraded.php index 4caf562c..e50b3e34 100644 --- a/src/app/Mail/NegativeBalanceDegraded.php +++ b/src/app/Mail/NegativeBalanceDegraded.php @@ -1,82 +1,82 @@ wallet = $wallet; $this->user = $user; } /** * Build the message. * * @return $this */ public function build() { $appName = Tenant::getConfig($this->user->tenant_id, 'app.name'); $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url'); $subject = \trans('mail.negativebalancedegraded-subject', ['site' => $appName]); $this->view('emails.html.negative_balance_degraded') ->text('emails.plain.negative_balance_degraded') ->subject($subject) ->with([ 'site' => $appName, 'subject' => $subject, 'username' => $this->user->name(true), - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id), 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), ]); return $this; } /** * Render the mail template with fake data * * @param string $type Output format ('html' or 'text') * * @return string HTML or Plain Text output */ public static function fakeRender(string $type = 'html'): string { $wallet = new Wallet(); $user = new User(); $mail = new self($wallet, $user); return Helper::render($mail, $type); } } diff --git a/src/app/Mail/NegativeBalanceReminder.php b/src/app/Mail/NegativeBalanceReminder.php index f6455c00..211e7e21 100644 --- a/src/app/Mail/NegativeBalanceReminder.php +++ b/src/app/Mail/NegativeBalanceReminder.php @@ -1,84 +1,84 @@ wallet = $wallet; $this->user = $user; } /** * Build the message. * * @return $this */ public function build() { $threshold = WalletCheck::threshold($this->wallet, WalletCheck::THRESHOLD_SUSPEND); $appName = Tenant::getConfig($this->user->tenant_id, 'app.name'); $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url'); $subject = \trans('mail.negativebalancereminder-subject', ['site' => $appName]); $this->view('emails.html.negative_balance_reminder') ->text('emails.plain.negative_balance_reminder') ->subject($subject) ->with([ 'site' => $appName, 'subject' => $subject, 'username' => $this->user->name(true), - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id), 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), 'date' => $threshold->toDateString(), ]); return $this; } /** * Render the mail template with fake data * * @param string $type Output format ('html' or 'text') * * @return string HTML or Plain Text output */ public static function fakeRender(string $type = 'html'): string { $wallet = new Wallet(); $user = new User(); $mail = new self($wallet, $user); return Helper::render($mail, $type); } } diff --git a/src/app/Mail/NegativeBalanceReminderDegrade.php b/src/app/Mail/NegativeBalanceReminderDegrade.php index e2a9487e..e634655c 100644 --- a/src/app/Mail/NegativeBalanceReminderDegrade.php +++ b/src/app/Mail/NegativeBalanceReminderDegrade.php @@ -1,84 +1,84 @@ wallet = $wallet; $this->user = $user; } /** * Build the message. * * @return $this */ public function build() { $appName = Tenant::getConfig($this->user->tenant_id, 'app.name'); $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url'); $threshold = WalletCheck::threshold($this->wallet, WalletCheck::THRESHOLD_DEGRADE); $subject = \trans('mail.negativebalancereminder-subject', ['site' => $appName]); $this->view('emails.html.negative_balance_reminder_degrade') ->text('emails.plain.negative_balance_reminder_degrade') ->subject($subject) ->with([ 'site' => $appName, 'subject' => $subject, 'username' => $this->user->name(true), - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id), 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), 'date' => $threshold->toDateString(), ]); return $this; } /** * Render the mail template with fake data * * @param string $type Output format ('html' or 'text') * * @return string HTML or Plain Text output */ public static function fakeRender(string $type = 'html'): string { $wallet = new Wallet(); $user = new User(); $mail = new self($wallet, $user); return Helper::render($mail, $type); } } diff --git a/src/app/Mail/NegativeBalanceSuspended.php b/src/app/Mail/NegativeBalanceSuspended.php index 63f69145..b83d5d17 100644 --- a/src/app/Mail/NegativeBalanceSuspended.php +++ b/src/app/Mail/NegativeBalanceSuspended.php @@ -1,84 +1,84 @@ wallet = $wallet; $this->user = $user; } /** * Build the message. * * @return $this */ public function build() { $threshold = WalletCheck::threshold($this->wallet, WalletCheck::THRESHOLD_DELETE); $appName = Tenant::getConfig($this->user->tenant_id, 'app.name'); $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url'); $subject = \trans('mail.negativebalancesuspended-subject', ['site' => $appName]); $this->view('emails.html.negative_balance_suspended') ->text('emails.plain.negative_balance_suspended') ->subject($subject) ->with([ 'site' => $appName, 'subject' => $subject, 'username' => $this->user->name(true), - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id), 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), 'date' => $threshold->toDateString(), ]); return $this; } /** * Render the mail template with fake data * * @param string $type Output format ('html' or 'text') * * @return string HTML or Plain Text output */ public static function fakeRender(string $type = 'html'): string { $wallet = new Wallet(); $user = new User(); $mail = new self($wallet, $user); return Helper::render($mail, $type); } } diff --git a/src/app/Mail/PasswordExpirationReminder.php b/src/app/Mail/PasswordExpirationReminder.php index 7640cb9c..b6c7bc47 100644 --- a/src/app/Mail/PasswordExpirationReminder.php +++ b/src/app/Mail/PasswordExpirationReminder.php @@ -1,82 +1,81 @@ user = $user; $this->expiresOn = $expiresOn; } /** * Build the message. * * @return $this */ public function build() { $appName = Tenant::getConfig($this->user->tenant_id, 'app.name'); - $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url'); $href = Utils::serviceUrl('profile', $this->user->tenant_id); $params = [ 'site' => $appName, 'date' => $this->expiresOn, 'link' => sprintf('%s', $href, $href), 'username' => $this->user->name(true), ]; $this->view('emails.html.password_expiration_reminder') ->text('emails.plain.password_expiration_reminder') ->subject(\trans('mail.passwordexpiration-subject', $params)) ->with($params); return $this; } /** * Render the mail template with fake data * * @param string $type Output format ('html' or 'text') * * @return string HTML or Plain Text output */ public static function fakeRender(string $type = 'html'): string { $user = new User([ 'email' => 'test@' . \config('app.domain'), ]); $mail = new self($user, now()->copy()->addDays(14)->toDateString()); return Helper::render($mail, $type); } } diff --git a/src/app/Mail/PasswordReset.php b/src/app/Mail/PasswordReset.php index 51338efc..a773b1a9 100644 --- a/src/app/Mail/PasswordReset.php +++ b/src/app/Mail/PasswordReset.php @@ -1,87 +1,86 @@ code = $code; } /** * Build the message. * * @return $this */ public function build() { $appName = Tenant::getConfig($this->code->user->tenant_id, 'app.name'); - $supportUrl = Tenant::getConfig($this->code->user->tenant_id, 'app.support_url'); $href = Utils::serviceUrl( sprintf('/password-reset/%s-%s', $this->code->short_code, $this->code->code), $this->code->user->tenant_id ); $this->view('emails.html.password_reset') ->text('emails.plain.password_reset') ->subject(\trans('mail.passwordreset-subject', ['site' => $appName])) ->with([ 'site' => $appName, 'code' => $this->code->code, 'short_code' => $this->code->short_code, 'link' => sprintf('%s', $href, $href), 'username' => $this->code->user->name(true) ]); return $this; } /** * Render the mail template with fake data * * @param string $type Output format ('html' or 'text') * * @return string HTML or Plain Text output */ public static function fakeRender(string $type = 'html'): string { $code = new VerificationCode([ 'code' => Str::random(VerificationCode::CODE_LENGTH), 'short_code' => VerificationCode::generateShortCode(), ]); // @phpstan-ignore-next-line $code->user = new User([ 'email' => 'test@' . \config('app.domain'), ]); $mail = new self($code); return Helper::render($mail, $type); } } diff --git a/src/app/Mail/PaymentFailure.php b/src/app/Mail/PaymentFailure.php index f4568b6d..b77b1971 100644 --- a/src/app/Mail/PaymentFailure.php +++ b/src/app/Mail/PaymentFailure.php @@ -1,83 +1,83 @@ payment = $payment; $this->user = $user; } /** * Build the message. * * @return $this */ public function build() { $appName = Tenant::getConfig($this->user->tenant_id, 'app.name'); $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url'); $subject = \trans('mail.paymentfailure-subject', ['site' => $appName]); $this->view('emails.html.payment_failure') ->text('emails.plain.payment_failure') ->subject($subject) ->with([ 'site' => $appName, 'subject' => $subject, 'username' => $this->user->name(true), 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id), ]); return $this; } /** * Render the mail template with fake data * * @param string $type Output format ('html' or 'text') * * @return string HTML or Plain Text output */ public static function fakeRender(string $type = 'mail'): string { $payment = new Payment(); $user = new User([ 'email' => 'test@' . \config('app.domain'), ]); $mail = new self($payment, $user); return Helper::render($mail, $type); } } diff --git a/src/app/Mail/PaymentMandateDisabled.php b/src/app/Mail/PaymentMandateDisabled.php index 288cee5a..c5e97042 100644 --- a/src/app/Mail/PaymentMandateDisabled.php +++ b/src/app/Mail/PaymentMandateDisabled.php @@ -1,83 +1,83 @@ wallet = $wallet; $this->user = $user; } /** * Build the message. * * @return $this */ public function build() { $appName = Tenant::getConfig($this->user->tenant_id, 'app.name'); $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url'); $subject = \trans('mail.paymentmandatedisabled-subject', ['site' => $appName]); $this->view('emails.html.payment_mandate_disabled') ->text('emails.plain.payment_mandate_disabled') ->subject($subject) ->with([ 'site' => $appName, 'subject' => $subject, 'username' => $this->user->name(true), 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id), ]); return $this; } /** * Render the mail template with fake data * * @param string $type Output format ('html' or 'text') * * @return string HTML or Plain Text output */ public static function fakeRender(string $type = 'html'): string { $wallet = new Wallet(); $user = new User([ 'email' => 'test@' . \config('app.domain'), ]); $mail = new self($wallet, $user); return Helper::render($mail, $type); } } diff --git a/src/app/Mail/PaymentSuccess.php b/src/app/Mail/PaymentSuccess.php index 189db37a..4616ca94 100644 --- a/src/app/Mail/PaymentSuccess.php +++ b/src/app/Mail/PaymentSuccess.php @@ -1,83 +1,83 @@ payment = $payment; $this->user = $user; } /** * Build the message. * * @return $this */ public function build() { $appName = Tenant::getConfig($this->user->tenant_id, 'app.name'); $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url'); $subject = \trans('mail.paymentsuccess-subject', ['site' => $appName]); $this->view('emails.html.payment_success') ->text('emails.plain.payment_success') ->subject($subject) ->with([ 'site' => $appName, 'subject' => $subject, 'username' => $this->user->name(true), 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->user->tenant_id), ]); return $this; } /** * Render the mail template with fake data * * @param string $type Output format ('html' or 'text') * * @return string HTML or Plain Text output */ public static function fakeRender(string $type = 'html'): string { $payment = new Payment(); $user = new User([ 'email' => 'test@' . \config('app.domain'), ]); $mail = new self($payment, $user); return Helper::render($mail, $type); } } diff --git a/src/app/Mail/SuspendedDebtor.php b/src/app/Mail/SuspendedDebtor.php index e1d271c7..0840b935 100644 --- a/src/app/Mail/SuspendedDebtor.php +++ b/src/app/Mail/SuspendedDebtor.php @@ -1,86 +1,86 @@ account = $account; } /** * Build the message. * * @return $this */ public function build() { $appName = Tenant::getConfig($this->account->tenant_id, 'app.name'); $supportUrl = Tenant::getConfig($this->account->tenant_id, 'app.support_url'); $cancelUrl = Tenant::getConfig($this->account->tenant_id, 'app.kb.account_delete'); $subject = \trans('mail.suspendeddebtor-subject', ['site' => $appName]); $moreInfoHtml = null; $moreInfoText = null; if ($moreInfoUrl = Tenant::getConfig($this->account->tenant_id, 'app.kb.account_suspended')) { $moreInfoHtml = \trans('mail.more-info-html', ['href' => $moreInfoUrl]); $moreInfoText = \trans('mail.more-info-text', ['href' => $moreInfoUrl]); } $this->view('emails.html.suspended_debtor') ->text('emails.plain.suspended_debtor') ->subject($subject) ->with([ 'site' => $appName, 'subject' => $subject, 'username' => $this->account->name(true), 'cancelUrl' => $cancelUrl, - 'supportUrl' => $supportUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->account->tenant_id), 'walletUrl' => Utils::serviceUrl('/wallet', $this->account->tenant_id), 'moreInfoHtml' => $moreInfoHtml, 'moreInfoText' => $moreInfoText, 'days' => 14 // TODO: Configurable ]); return $this; } /** * Render the mail template with fake data * * @param string $type Output format ('html' or 'text') * * @return string HTML or Plain Text output */ public static function fakeRender(string $type = 'html'): string { $user = new User(); $mail = new self($user); return Helper::render($mail, $type); } } diff --git a/src/app/Mail/SuspendedDebtor.php b/src/app/Mail/TrialEnd.php similarity index 54% copy from src/app/Mail/SuspendedDebtor.php copy to src/app/Mail/TrialEnd.php index e1d271c7..d0eaff1b 100644 --- a/src/app/Mail/SuspendedDebtor.php +++ b/src/app/Mail/TrialEnd.php @@ -1,86 +1,75 @@ account = $account; } /** * Build the message. * * @return $this */ public function build() { $appName = Tenant::getConfig($this->account->tenant_id, 'app.name'); + $paymentUrl = Tenant::getConfig($this->account->tenant_id, 'app.kb.payment_system'); $supportUrl = Tenant::getConfig($this->account->tenant_id, 'app.support_url'); - $cancelUrl = Tenant::getConfig($this->account->tenant_id, 'app.kb.account_delete'); - $subject = \trans('mail.suspendeddebtor-subject', ['site' => $appName]); + $subject = \trans('mail.trialend-subject', ['site' => $appName]); - $moreInfoHtml = null; - $moreInfoText = null; - if ($moreInfoUrl = Tenant::getConfig($this->account->tenant_id, 'app.kb.account_suspended')) { - $moreInfoHtml = \trans('mail.more-info-html', ['href' => $moreInfoUrl]); - $moreInfoText = \trans('mail.more-info-text', ['href' => $moreInfoUrl]); - } - - $this->view('emails.html.suspended_debtor') - ->text('emails.plain.suspended_debtor') + $this->view('emails.html.trial_end') + ->text('emails.plain.trial_end') ->subject($subject) ->with([ 'site' => $appName, 'subject' => $subject, 'username' => $this->account->name(true), - 'cancelUrl' => $cancelUrl, - 'supportUrl' => $supportUrl, - 'walletUrl' => Utils::serviceUrl('/wallet', $this->account->tenant_id), - 'moreInfoHtml' => $moreInfoHtml, - 'moreInfoText' => $moreInfoText, - 'days' => 14 // TODO: Configurable + 'paymentUrl' => $paymentUrl, + 'supportUrl' => Utils::serviceUrl($supportUrl, $this->account->tenant_id), ]); return $this; } /** * Render the mail template with fake data * * @param string $type Output format ('html' or 'text') * * @return string HTML or Plain Text output */ public static function fakeRender(string $type = 'html'): string { $user = new User(); $mail = new self($user); return Helper::render($mail, $type); } } diff --git a/src/app/Utils.php b/src/app/Utils.php index 5e33b354..f3ed690f 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,571 +1,575 @@ country ? $net->country : 'CH'; } /** * Return the country ISO code for the current request. */ public static function countryForRequest() { $request = \request(); $ip = $request->ip(); return self::countryForIP($ip); } /** * Return the number of days in the month prior to this one. * * @return int */ public static function daysInLastMonth() { $start = new Carbon('first day of last month'); $end = new Carbon('last day of last month'); return $start->diffInDays($end) + 1; } /** * Download a file from the interwebz and store it locally. * * @param string $source The source location * @param string $target The target location * @param bool $force Force the download (and overwrite target) * * @return void */ public static function downloadFile($source, $target, $force = false) { if (is_file($target) && !$force) { return; } \Log::info("Retrieving {$source}"); $fp = fopen($target, 'w'); $curl = curl_init(); curl_setopt($curl, CURLOPT_URL, $source); curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); curl_setopt($curl, CURLOPT_FILE, $fp); curl_exec($curl); if (curl_errno($curl)) { \Log::error("Request error on {$source}: " . curl_error($curl)); curl_close($curl); fclose($fp); unlink($target); return; } curl_close($curl); fclose($fp); } /** * Converts an email address to lower case. Keeps the LMTP shared folder * addresses character case intact. * * @param string $email Email address * * @return string Email address */ public static function emailToLower(string $email): string { // For LMTP shared folder address lower case the domain part only if (str_starts_with($email, 'shared+shared/')) { $pos = strrpos($email, '@'); $domain = substr($email, $pos + 1); $local = substr($email, 0, strlen($email) - strlen($domain) - 1); return $local . '@' . strtolower($domain); } return strtolower($email); } /** * Generate a passphrase. Not intended for use in production, so limited to environments that are not production. * * @return string */ public static function generatePassphrase() { if (\config('app.env') == 'production') { throw new \Exception("Thou shall not pass!"); } if (\config('app.passphrase')) { return \config('app.passphrase'); } $alphaLow = 'abcdefghijklmnopqrstuvwxyz'; $alphaUp = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; $num = '0123456789'; $stdSpecial = '~`!@#$%^&*()-_+=[{]}\\|\'";:/?.>,<'; $source = $alphaLow . $alphaUp . $num . $stdSpecial; $result = ''; for ($x = 0; $x < 16; $x++) { $result .= substr($source, rand(0, (strlen($source) - 1)), 1); } return $result; } /** * Find an object that is the recipient for the specified address. * * @param string $address * * @return array */ public static function findObjectsByRecipientAddress($address) { $address = \App\Utils::normalizeAddress($address); list($local, $domainName) = explode('@', $address); $domain = \App\Domain::where('namespace', $domainName)->first(); if (!$domain) { return []; } $user = \App\User::where('email', $address)->first(); if ($user) { return [$user]; } $userAliases = \App\UserAlias::where('alias', $address)->get(); if (count($userAliases) > 0) { $users = []; foreach ($userAliases as $userAlias) { $users[] = $userAlias->user; } return $users; } $userAliases = \App\UserAlias::where('alias', "catchall@{$domain->namespace}")->get(); if (count($userAliases) > 0) { $users = []; foreach ($userAliases as $userAlias) { $users[] = $userAlias->user; } return $users; } return []; } /** * Retrieve the network ID and Type from a client address * * @param string $clientAddress The IPv4 or IPv6 address. * * @return array An array of ID and class or null and null. */ public static function getNetFromAddress($clientAddress) { if (strpos($clientAddress, ':') === false) { $net = \App\IP4Net::getNet($clientAddress); if ($net) { return [$net->id, \App\IP4Net::class]; } } else { $net = \App\IP6Net::getNet($clientAddress); if ($net) { return [$net->id, \App\IP6Net::class]; } } return [null, null]; } /** * Calculate the broadcast address provided a net number and a prefix. * * @param string $net A valid IPv6 network number. * @param int $prefix The network prefix. * * @return string */ public static function ip6Broadcast($net, $prefix) { $netHex = bin2hex(inet_pton($net)); // Overwriting first address string to make sure notation is optimal $net = inet_ntop(hex2bin($netHex)); // Calculate the number of 'flexible' bits $flexbits = 128 - $prefix; // Build the hexadecimal string of the last address $lastAddrHex = $netHex; // We start at the end of the string (which is always 32 characters long) $pos = 31; while ($flexbits > 0) { // Get the character at this position $orig = substr($lastAddrHex, $pos, 1); // Convert it to an integer $origval = hexdec($orig); // OR it with (2^flexbits)-1, with flexbits limited to 4 at a time $newval = $origval | (pow(2, min(4, $flexbits)) - 1); // Convert it back to a hexadecimal character $new = dechex($newval); // And put that character back in the string $lastAddrHex = substr_replace($lastAddrHex, $new, $pos, 1); // We processed one nibble, move to previous position $flexbits -= 4; $pos -= 1; } // Convert the hexadecimal string to a binary string $lastaddrbin = hex2bin($lastAddrHex); // And create an IPv6 address from the binary string $lastaddrstr = inet_ntop($lastaddrbin); return $lastaddrstr; } /** * Normalize an email address. * * This means to lowercase and strip components separated with recipient delimiters. * * @param ?string $address The address to normalize * @param bool $asArray Return an array with local and domain part * * @return string|array Normalized email address as string or array */ public static function normalizeAddress(?string $address, bool $asArray = false) { if ($address === null || $address === '') { return $asArray ? ['', ''] : ''; } $address = self::emailToLower($address); if (strpos($address, '@') === false) { return $asArray ? [$address, ''] : $address; } list($local, $domain) = explode('@', $address); if (strpos($local, '+') !== false) { $local = explode('+', $local)[0]; } return $asArray ? [$local, $domain] : "{$local}@{$domain}"; } /** * Provide all unique combinations of elements in $input, with order and duplicates irrelevant. * * @param array $input The input array of elements. * * @return array[] */ public static function powerSet(array $input): array { $output = []; for ($x = 0; $x < count($input); $x++) { self::combine($input, $x + 1, 0, [], 0, $output); } return $output; } /** * Returns the current user's email address or null. * * @return string */ public static function userEmailOrNull(): ?string { $user = Auth::user(); if (!$user) { return null; } return $user->email; } /** * Returns a random string consisting of a quantity of segments of a certain length joined. * * Example: * * ```php * $roomName = strtolower(\App\Utils::randStr(3, 3, '-'); * // $roomName == '3qb-7cs-cjj' * ``` * * @param int $length The length of each segment * @param int $qty The quantity of segments * @param string $join The string to use to join the segments * * @return string */ public static function randStr($length, $qty = 1, $join = '') { $chars = env('SHORTCODE_CHARS', self::CHARS); $randStrs = []; for ($x = 0; $x < $qty; $x++) { $randStrs[$x] = []; for ($y = 0; $y < $length; $y++) { $randStrs[$x][] = $chars[rand(0, strlen($chars) - 1)]; } shuffle($randStrs[$x]); $randStrs[$x] = implode('', $randStrs[$x]); } return implode($join, $randStrs); } /** * Returns a UUID in the form of an integer. * * @return integer */ public static function uuidInt(): int { $hex = Uuid::uuid4(); $bin = pack('h*', str_replace('-', '', $hex)); $ids = unpack('L', $bin); $id = array_shift($ids); return $id; } /** * Returns a UUID in the form of a string. * * @return string */ public static function uuidStr(): string { return Uuid::uuid4()->toString(); } private static function combine($input, $r, $index, $data, $i, &$output): void { $n = count($input); // Current cobination is ready if ($index == $r) { $output[] = array_slice($data, 0, $r); return; } // When no more elements are there to put in data[] if ($i >= $n) { return; } // current is included, put next at next location $data[$index] = $input[$i]; self::combine($input, $r, $index + 1, $data, $i + 1, $output); // current is excluded, replace it with next (Note that i+1 // is passed, but index is not changed) self::combine($input, $r, $index, $data, $i + 1, $output); } /** * Create self URL * - * @param string $route Route/Path + * @param string $route Route/Path/URL * @param int|null $tenantId Current tenant * * @todo Move this to App\Http\Controllers\Controller * * @return string Full URL */ public static function serviceUrl(string $route, $tenantId = null): string { + if (preg_match('|^https?://|i', $route)) { + return $route; + } + $url = \App\Tenant::getConfig($tenantId, 'app.public_url'); if (!$url) { $url = \App\Tenant::getConfig($tenantId, 'app.url'); } return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/'); } /** * Create a configuration/environment data to be passed to * the UI * * @todo Move this to App\Http\Controllers\Controller * * @return array Configuration data */ public static function uiEnv(): array { $countries = include resource_path('countries.php'); $req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost()); $sys_domain = \config('app.domain'); $opts = [ 'app.name', 'app.url', 'app.domain', 'app.theme', 'app.webmail_url', 'app.support_email', 'mail.from.address' ]; $env = \app('config')->getMany($opts); $env['countries'] = $countries ?: []; $env['view'] = 'root'; $env['jsapp'] = 'user.js'; if ($req_domain == "admin.$sys_domain") { $env['jsapp'] = 'admin.js'; } elseif ($req_domain == "reseller.$sys_domain") { $env['jsapp'] = 'reseller.js'; } $env['paymentProvider'] = \config('services.payment_provider'); $env['stripePK'] = \config('services.stripe.public_key'); $env['languages'] = \App\Http\Controllers\ContentController::locales(); $env['menu'] = \App\Http\Controllers\ContentController::menu(); return $env; } /** * Set test exchange rates. * * @param array $rates: Exchange rates */ public static function setTestExchangeRates(array $rates): void { self::$testRates = $rates; } /** * Retrieve an exchange rate. * * @param string $sourceCurrency: Currency from which to convert * @param string $targetCurrency: Currency to convert to * * @return float Exchange rate */ public static function exchangeRate(string $sourceCurrency, string $targetCurrency): float { if (strcasecmp($sourceCurrency, $targetCurrency) == 0) { return 1.0; } if (isset(self::$testRates[$targetCurrency])) { return floatval(self::$testRates[$targetCurrency]); } $currencyFile = resource_path("exchangerates-$sourceCurrency.php"); //Attempt to find the reverse exchange rate, if we don't have the file for the source currency if (!file_exists($currencyFile)) { $rates = include resource_path("exchangerates-$targetCurrency.php"); if (!isset($rates[$sourceCurrency])) { throw new \Exception("Failed to find the reverse exchange rate for " . $sourceCurrency); } return 1.0 / floatval($rates[$sourceCurrency]); } $rates = include $currencyFile; if (!isset($rates[$targetCurrency])) { throw new \Exception("Failed to find exchange rate for " . $targetCurrency); } return floatval($rates[$targetCurrency]); } } diff --git a/src/config/app.php b/src/config/app.php index e9015e2b..13041230 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -1,274 +1,276 @@ 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'), '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' => \Illuminate\Support\Facades\Facade::defaultAliases()->merge([ 'PDF' => Barryvdh\DomPDF\Facade::class, ])->toArray(), 'headers' => [ 'csp' => env('APP_HEADER_CSP', ""), 'xfo' => env('APP_HEADER_XFO', ""), ], // 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'), + // An article about the payment system + 'payment_system' => env('KB_PAYMENT_SYSTEM'), ], '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'), ], 'password_policy' => env('PASSWORD_POLICY') ?: 'min:6,max:255', '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_files' => (bool) env('APP_WITH_FILES', 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), ], 'woat_ns1' => env('WOAT_NS1', 'ns01.' . env('APP_DOMAIN')), 'woat_ns2' => env('WOAT_NS2', 'ns02.' . env('APP_DOMAIN')), 'ratelimit_whitelist' => explode(',', env('RATELIMIT_WHITELIST', '')) ]; diff --git a/src/resources/lang/en/mail.php b/src/resources/lang/en/mail.php index 3d0a77e1..6ee4b9eb 100644 --- a/src/resources/lang/en/mail.php +++ b/src/resources/lang/en/mail.php @@ -1,110 +1,123 @@ "Dear :name,", 'footer1' => "Best regards,", 'footer2' => "Your :site Team", 'more-info-html' => "See here for more information.", 'more-info-text' => "See :href for more information.", 'degradedaccountreminder-subject' => ":site Reminder: Your account is free", 'degradedaccountreminder-body1' => "Thanks for sticking around, we remind you your account is a free " . "account and restricted to receiving email, and use of the web client and cockpit only.", 'degradedaccountreminder-body2' => "This leaves you with an ideal account to use for account registration with third parties " . "and password resets, notifications or even just subscriptions to newsletters and the like.", 'degradedaccountreminder-body3' => "To regain functionality such as sending email, calendars, address books, phone synchronization " . "and voice & video conferencing, log on to the cockpit and make sure you have a positive account balance.", 'degradedaccountreminder-body4' => "You can also delete your account there, making sure your data disappears from our systems.", 'degradedaccountreminder-body5' => "Thank you for your consideration!", 'negativebalance-subject' => ":site Payment Required", 'negativebalance-body' => "This is a notification to let you know that your :site account balance has run into the negative and requires your attention. " . "Consider setting up an automatic payment to avoid messages like this in the future.", 'negativebalance-body-ext' => "Settle up to keep your account running:", 'negativebalancereminder-subject' => ":site Payment Reminder", 'negativebalancereminder-body' => "It has probably skipped your attention that you are behind on paying for your :site account. " . "Consider setting up an automatic payment to avoid messages like this in the future.", 'negativebalancereminder-body-ext' => "Settle up to keep your account running:", 'negativebalancereminder-body-warning' => "Please, be aware that your account will be suspended " . "if your account balance is not settled by :date.", 'negativebalancereminderdegrade-body-warning' => "Please, be aware that your account will be degraded " . "if your account balance is not settled by :date.", 'negativebalancedegraded-subject' => ":site Account Degraded", 'negativebalancedegraded-body' => "Your :site account has been degraded for having a negative balance for too long. " . "Consider setting up an automatic payment to avoid messages like this in the future.", 'negativebalancedegraded-body-ext' => "Settle up now to undegrade your account:", 'negativebalancesuspended-subject' => ":site Account Suspended", 'negativebalancesuspended-body' => "Your :site account has been suspended for having a negative balance for too long. " . "Consider setting up an automatic payment to avoid messages like this in the future.", 'negativebalancesuspended-body-ext' => "Settle up now to unsuspend your account:", 'negativebalancesuspended-body-warning' => "Please, be aware that your account and all its data will be deleted " . "if your account balance is not settled by :date.", 'negativebalancebeforedelete-subject' => ":site Final Warning", 'negativebalancebeforedelete-body' => "This is a final reminder to settle your :site account balance. " . "Your account and all its data will be deleted if your account balance is not settled by :date.", 'negativebalancebeforedelete-body-ext' => "Settle up now to keep your account:", 'passwordreset-subject' => ":site Password Reset", 'passwordreset-body1' => "Someone recently asked to change your :site password.", 'passwordreset-body2' => "If this was you, use this verification code to complete the process:", 'passwordreset-body3' => "You can also click the link below:", 'passwordreset-body4' => "If you did not make such a request, you can either ignore this message or get in touch with us about this incident.", 'passwordexpiration-subject' => ":site password expires on :date", 'passwordexpiration-body' => "Your password will expire on :date. You can change it here:", 'paymentmandatedisabled-subject' => ":site Auto-payment Problem", 'paymentmandatedisabled-body' => "Your :site account balance is negative " . "and the configured amount for automatically topping up the balance does not cover " . "the costs of subscriptions consumed.", 'paymentmandatedisabled-body-ext' => "Charging you multiple times for the same amount in short succession " . "could lead to issues with the payment provider. " . "In order to not cause any problems, we suspended auto-payment for your account. " . "To resolve this issue, login to your account settings and adjust your auto-payment amount.", 'paymentfailure-subject' => ":site Payment Failed", 'paymentfailure-body' => "Something went wrong with auto-payment for your :site account.\n" . "We tried to charge you via your preferred payment method, but the charge did not go through.", 'paymentfailure-body-ext' => "In order to not cause any further issues, we suspended auto-payment for your account. " . "To resolve this issue, login to your account settings at", 'paymentfailure-body-rest' => "There you can pay manually for your account and " . "change your auto-payment settings.", 'paymentsuccess-subject' => ":site Payment Succeeded", 'paymentsuccess-body' => "The auto-payment for your :site account went through without issues. " . "You can check your new account balance and more details here:", 'support' => "Special circumstances? Something is wrong with a charge?\n" . ":site Support is here to help.", 'signupcode-subject' => ":site Registration", 'signupcode-body1' => "This is your verification code for the :site registration process:", 'signupcode-body2' => "You can also click the link below to continue the registration process:", 'signupinvitation-subject' => ":site Invitation", 'signupinvitation-header' => "Hi,", 'signupinvitation-body1' => "You have been invited to join :site. Click the link below to sign up.", 'signupinvitation-body2' => "", 'suspendeddebtor-subject' => ":site Account Suspended", 'suspendeddebtor-body' => "You have been behind on paying for your :site account " . "for over :days days. Your account has been suspended.", 'suspendeddebtor-middle' => "Settle up now to reactivate your account.", 'suspendeddebtor-cancel' => "Don't want to be our customer anymore? " . "Here is how you can cancel your account:", + + 'trialend-subject' => ":site: Your trial phase has ended", + 'trialend-intro' => "We hope you enjoyed the 30 days of free :site trial." + . " Your subscriptions become active after the first month of use and the fee is due after another month.", + 'trialend-kb' => "You can read about how to pay the subscription fee in this knowledge base article:", + 'trialend-body1' => "You can leave :site at any time, there is no contractual minimum period" + . " and your account will NOT be deleted automatically." + . " You can delete your account via the red [Delete account] button in your profile." + . " This will end your subscription and delete all relevant data.", + 'trialend-body2' => "THIS OPERATION IS IRREVERSIBLE!", + 'trialend-body3' => "When data is deleted it can not be recovered." + . " Please make sure that you have saved all data that you need before pressing the red button." + . " Do not hesitate to contact Support with any questions or concerns.", ]; diff --git a/src/resources/views/emails/html/trial_end.blade.php b/src/resources/views/emails/html/trial_end.blade.php new file mode 100644 index 00000000..abf669f6 --- /dev/null +++ b/src/resources/views/emails/html/trial_end.blade.php @@ -0,0 +1,21 @@ + + + + + + +

{{ __('mail.header', ['name' => $username]) }}

+

{{ __('mail.trialend-intro', ['site' => $site]) }}

+@if ($paymentUrl) +

{{ __('mail.trialend-kb', ['site' => $site]) }}

+

{{ $paymentUrl }}

+@endif +

{{ __('mail.trialend-body1', ['site' => $site]) }}

+

{{ __('mail.trialend-body2', ['site' => $site]) }}

+

{{ __('mail.trialend-body3', ['site' => $site]) }}

+

{{ $supportUrl }}

+ +

{{ __('mail.footer1') }}

+

{{ __('mail.footer2', ['site' => $site]) }}

+ + diff --git a/src/resources/views/emails/plain/trial_end.blade.php b/src/resources/views/emails/plain/trial_end.blade.php new file mode 100644 index 00000000..7212cde3 --- /dev/null +++ b/src/resources/views/emails/plain/trial_end.blade.php @@ -0,0 +1,19 @@ +{!! __('mail.header', ['name' => $username]) !!} + +{!! __('mail.trialend-intro', ['site' => $site]) !!} +@if ($paymentUrl) + +{!! __('mail.trialend-kb', ['site' => $site]) !!} {!! $paymentUrl !!} +@endif + +{!! __('mail.trialend-body1', ['site' => $site]) !!} + +{!! __('mail.trialend-body2', ['site' => $site]) !!} + +{!! __('mail.trialend-body3', ['site' => $site]) !!} + +{!! $supportUrl !!} + +-- +{!! __('mail.footer1') !!} +{!! __('mail.footer2', ['site' => $site]) !!} diff --git a/src/tests/Feature/Console/Wallet/TrialEndTest.php b/src/tests/Feature/Console/Wallet/TrialEndTest.php new file mode 100644 index 00000000..329ed38d --- /dev/null +++ b/src/tests/Feature/Console/Wallet/TrialEndTest.php @@ -0,0 +1,91 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run + */ + public function testHandle(): void + { + Queue::fake(); + + $user = $this->getTestUser('wallets-controller@kolabnow.com', [ + 'status' => User::STATUS_IMAP_READY | User::STATUS_LDAP_READY | User::STATUS_ACTIVE, + ]); + $wallet = $user->wallets()->first(); + + DB::table('users')->update(['created_at' => \now()->clone()->subMonthsNoOverflow(2)->subHours(1)]); + + // Expect no wallets in after-trial state + Queue::fake(); + $code = \Artisan::call("wallet:trial-end"); + Queue::assertNothingPushed(); + + // Test an email sent + $user->created_at = \now()->clone()->subMonthNoOverflow(); + $user->save(); + + Queue::fake(); + $code = \Artisan::call("wallet:trial-end"); + Queue::assertPushed(\App\Jobs\TrialEndEmail::class, 1); + Queue::assertPushed(\App\Jobs\TrialEndEmail::class, function ($job) use ($user) { + $job_user = TestCase::getObjectProperty($job, 'account'); + return $job_user->id === $user->id; + }); + + $dt = $wallet->getSetting('trial_end_notice'); + $this->assertMatchesRegularExpression('/^' . date('Y-m-d') . ' [0-9]{2}:[0-9]{2}:[0-9]{2}$/', $dt); + + // Test no duplicate email sent for the same wallet + Queue::fake(); + $code = \Artisan::call("wallet:trial-end"); + Queue::assertNothingPushed(); + + // Test not imap ready user - no email sent + $wallet->setSetting('trial_end_notice', null); + $user->status = User::STATUS_NEW | User::STATUS_LDAP_READY | User::STATUS_ACTIVE; + $user->save(); + + Queue::fake(); + $code = \Artisan::call("wallet:trial-end"); + Queue::assertNothingPushed(); + + // Test deleted user - no email sent + $user->status = User::STATUS_NEW | User::STATUS_LDAP_READY | User::STATUS_ACTIVE | User::STATUS_IMAP_READY; + $user->save(); + $user->delete(); + + Queue::fake(); + $code = \Artisan::call("wallet:trial-end"); + Queue::assertNothingPushed(); + + $this->assertNull($wallet->getSetting('trial_end_notice')); + } +} diff --git a/src/tests/Feature/Jobs/TrialEndEmailTest.php b/src/tests/Feature/Jobs/TrialEndEmailTest.php new file mode 100644 index 00000000..017e3d9a --- /dev/null +++ b/src/tests/Feature/Jobs/TrialEndEmailTest.php @@ -0,0 +1,62 @@ +deleteTestUser('PaymentEmail@UserAccount.com'); + } + + /** + * {@inheritDoc} + * + * @return void + */ + public function tearDown(): void + { + $this->deleteTestUser('PaymentEmail@UserAccount.com'); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @return void + */ + public function testHandle() + { + $user = $this->getTestUser('PaymentEmail@UserAccount.com'); + $user->setSetting('external_email', 'ext@email.tld'); + + Mail::fake(); + + // Assert that no jobs were pushed... + Mail::assertNothingSent(); + + $job = new TrialEndEmail($user); + $job->handle(); + + // Assert the email sending job was pushed once + Mail::assertSent(TrialEnd::class, 1); + + // Assert the mail was sent to the user's email + Mail::assertSent(TrialEnd::class, function ($mail) { + return $mail->hasTo('paymentemail@useraccount.com') && !$mail->hasCc('ext@email.tld'); + }); + } +} diff --git a/src/tests/Unit/Mail/TrialEndTest.php b/src/tests/Unit/Mail/TrialEndTest.php new file mode 100644 index 00000000..c275ea0d --- /dev/null +++ b/src/tests/Unit/Mail/TrialEndTest.php @@ -0,0 +1,49 @@ + 'https://kolab.org/support', + 'app.kb.payment_system' => 'https://kb.kolab.org/payment-system', + ]); + + $mail = $this->renderMail(new TrialEnd($user)); + + $html = $mail['html']; + $plain = $mail['plain']; + + $supportUrl = \config('app.support_url'); + $supportLink = sprintf('%s', $supportUrl, $supportUrl); + $paymentUrl = \config('app.kb.payment_system'); + $paymentLink = sprintf('%s', $paymentUrl, $paymentUrl); + $appName = \config('app.name'); + + $this->assertSame("$appName: Your trial phase has ended", $mail['subject']); + + $this->assertStringStartsWith('', $html); + $this->assertTrue(strpos($html, $user->name(true)) > 0); + $this->assertTrue(strpos($html, $supportLink) > 0); + $this->assertTrue(strpos($html, $paymentLink) > 0); + $this->assertTrue(strpos($html, "30 days of free $appName trial") > 0); + $this->assertTrue(strpos($html, "$appName Team") > 0); + + $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); + $this->assertTrue(strpos($plain, $supportUrl) > 0); + $this->assertTrue(strpos($plain, $paymentUrl) > 0); + $this->assertTrue(strpos($plain, "30 days of free $appName trial") > 0); + $this->assertTrue(strpos($plain, "$appName Team") > 0); + } +}