Custom SMTP Settings (#561)

* Custom SMTP Settings

* Fix lint

* Custom SMTP add in Pricing plan

* Allow reset email settings

* improve custom SMTP using seprate abstract class

* test case for custom SMTP

* fix test case

* UI improvement

* add CASHIER_KEY in phpunit for testcase

* Attempt to fix tests

* Run pint and attempt to fix cache tests

* Fix user management tests

* Fix code linters

* Merged main & fix linting

---------

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
Chirag Chhatrala 2024-09-24 15:46:20 +05:30 committed by GitHub
parent 5dcd4ff8cb
commit 504c7a0f2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 876 additions and 510 deletions

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Http\Requests\Workspace\CustomDomainRequest;
use App\Http\Requests\Workspace\EmailSettingsRequest;
use App\Http\Resources\WorkspaceResource;
use App\Models\Workspace;
use Illuminate\Http\Request;
@ -24,12 +25,31 @@ class WorkspaceController extends Controller
public function saveCustomDomain(CustomDomainRequest $request)
{
if (!$request->workspace->is_pro) {
return $this->error([
'message' => 'A Pro plan is required to use this feature.',
], 403);
}
$request->workspace->custom_domains = $request->customDomains;
$request->workspace->save();
return new WorkspaceResource($request->workspace);
}
public function saveEmailSettings(EmailSettingsRequest $request)
{
if (!$request->workspace->is_pro) {
return $this->error([
'message' => 'A Pro plan is required to use this feature.',
], 403);
}
$request->workspace->update(['settings' => array_merge($request->workspace->settings, ['email_settings' => $request->validated()])]);
return new WorkspaceResource($request->workspace);
}
public function delete($id)
{
$workspace = Workspace::findOrFail($id);

View File

@ -0,0 +1,65 @@
<?php
namespace App\Http\Requests\Workspace;
use App\Models\Workspace;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Request;
class EmailSettingsRequest extends FormRequest
{
public Workspace $workspace;
public function __construct(Request $request, Workspace $workspace)
{
$this->workspace = Workspace::findOrFail($request->workspaceId);
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules()
{
$allFieldsPresent = $this->filled(['host', 'port', 'username', 'password']);
return [
'host' => [
$allFieldsPresent ? 'required' : 'nullable',
'required_with:port,username,password',
'string',
],
'port' => [
$allFieldsPresent ? 'required' : 'nullable',
'required_with:host,username,password',
'integer',
],
'username' => [
$allFieldsPresent ? 'required' : 'nullable',
'required_with:host,port,password',
'string',
],
'password' => [
$allFieldsPresent ? 'required' : 'nullable',
'required_with:host,port,username',
'string',
],
];
}
/**
* Get the validation messages that apply to the request.
*
* @return array
*/
public function messages()
{
return [
'host.required_with' => 'The host field is required.',
'port.required_with' => 'The port field is required.',
'username.required_with' => 'The username field is required.',
'password.required_with' => 'The password field is required.',
];
}
}

View File

@ -27,7 +27,7 @@ class Google
public function getClient(): Client
{
if($this->client->isAccessTokenExpired()) {
if ($this->client->isAccessTokenExpired()) {
$this->refreshToken();
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Integrations\Handlers;
use App\Events\Forms\FormSubmitted;
use App\Models\Integration\FormIntegration;
use Illuminate\Support\Facades\Log;
abstract class AbstractEmailIntegrationHandler extends AbstractIntegrationHandler
{
protected $mailer;
public function __construct(FormSubmitted $event, FormIntegration $formIntegration, array $integration)
{
parent::__construct($event, $formIntegration, $integration);
$this->initializeMailer();
}
protected function initializeMailer()
{
$this->mailer = config('mail.default');
$this->setWorkspaceSMTPSettings();
if (!$this->mailer) {
Log::error('Mailer not specified', [
'form_id' => $this->form->id
]);
}
}
protected function setWorkspaceSMTPSettings()
{
$workspace = $this->form->workspace;
$emailSettings = $workspace->settings['email_settings'] ?? [];
if (!$workspace->is_pro || !$emailSettings || empty($emailSettings['host']) || empty($emailSettings['port']) || empty($emailSettings['username']) || empty($emailSettings['password'])) {
return;
}
config([
'mail.mailers.custom_smtp.host' => $emailSettings['host'],
'mail.mailers.custom_smtp.port' => $emailSettings['port'],
'mail.mailers.custom_smtp.username' => $emailSettings['username'],
'mail.mailers.custom_smtp.password' => $emailSettings['password']
]);
$this->mailer = 'custom_smtp';
}
}

View File

@ -7,7 +7,7 @@ use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use App\Notifications\Forms\FormSubmissionNotification;
class EmailIntegration extends AbstractIntegrationHandler
class EmailIntegration extends AbstractEmailIntegrationHandler
{
public static function getValidationRules(): array
{
@ -36,10 +36,11 @@ class EmailIntegration extends AbstractIntegrationHandler
'recipients' => $subscribers->toArray(),
'form_id' => $this->form->id,
'form_slug' => $this->form->slug,
'mailer' => $this->mailer
]);
$subscribers->each(function ($subscriber) {
Notification::route('mail', $subscriber)->notify(
new FormSubmissionNotification($this->event, $this->integrationData)
new FormSubmissionNotification($this->event, $this->integrationData, $this->mailer)
);
});
}

View File

@ -58,7 +58,7 @@ class GoogleSheetsIntegration extends AbstractIntegrationHandler
protected function getSpreadsheetId(): string
{
if(!isset($this->integrationData->spreadsheet_id)) {
if (!isset($this->integrationData->spreadsheet_id)) {
throw new Exception('The spreadsheed is not instantiated');
}

View File

@ -10,7 +10,7 @@ use Stevebauman\Purify\Facades\Purify;
/**
* Sends a confirmation to form respondant that form was submitted
*/
class SubmissionConfirmationIntegration extends AbstractIntegrationHandler
class SubmissionConfirmationIntegration extends AbstractEmailIntegrationHandler
{
public const RISKY_USERS_LIMIT = 120;
@ -54,8 +54,9 @@ class SubmissionConfirmationIntegration extends AbstractIntegrationHandler
'recipient' => $email,
'form_id' => $this->form->id,
'form_slug' => $this->form->slug,
'mailer' => $this->mailer
]);
Mail::to($email)->send(new SubmissionConfirmationMail($this->event, $this->integrationData));
Mail::mailer($this->mailer)->to($email)->send(new SubmissionConfirmationMail($this->event, $this->integrationData));
}
private function getRespondentEmail()

View File

@ -38,7 +38,7 @@ class WorkspaceUsersUpdated implements ShouldQueue
public function handle(): void
{
// If self-hosted, no need to update billing
if (!pricing_enabled()) {
if (!pricing_enabled() || \App::environment('testing')) {
return;
}

View File

@ -15,17 +15,17 @@ class FormIntegrationCreatedHandler implements ShouldQueue
{
$integration = FormIntegration::getIntegration($event->formIntegration->integration_id);
if(!$integration) {
if (!$integration) {
return;
}
if(!isset($integration['file_name'])) {
if (!isset($integration['file_name'])) {
return;
}
$className = 'App\Integrations\Handlers\Events\\' . $integration['file_name'] . 'Created';
if(!class_exists($className)) {
if (!class_exists($className)) {
return;
}

View File

@ -54,7 +54,7 @@ class SubmissionConfirmationMail extends OpenFormMail implements ShouldQueue
private function getFromEmail()
{
if(config('app.self_hosted')) {
if (config('app.self_hosted')) {
return config('mail.from.address');
}

View File

@ -25,6 +25,7 @@ class Workspace extends Model implements CachableAttributes
'icon',
'user_id',
'custom_domain',
'settings'
];
protected $appends = [
@ -33,10 +34,11 @@ class Workspace extends Model implements CachableAttributes
'is_enterprise',
];
protected function casts()
protected function casts(): array
{
return [
'custom_domains' => 'array',
'settings' => 'array'
];
}
@ -201,5 +203,4 @@ class Workspace extends Model implements CachableAttributes
{
return $this->hasMany(Form::class);
}
}

View File

@ -15,15 +15,17 @@ class FormSubmissionNotification extends Notification implements ShouldQueue
use Queueable;
public FormSubmitted $event;
private $mailer;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct(FormSubmitted $event, private $integrationData)
public function __construct(FormSubmitted $event, private $integrationData, string $mailer)
{
$this->event = $event;
$this->mailer = $mailer;
}
/**
@ -52,6 +54,7 @@ class FormSubmissionNotification extends Notification implements ShouldQueue
->useSignedUrlForFiles();
return (new MailMessage())
->mailer($this->mailer)
->replyTo($this->getReplyToEmail($notifiable->routes['mail']))
->from($this->getFromEmail(), config('app.name'))
->subject('New form submission for "' . $this->event->form->title . '"')
@ -63,7 +66,7 @@ class FormSubmissionNotification extends Notification implements ShouldQueue
private function getFromEmail()
{
if(config('app.self_hosted')) {
if (config('app.self_hosted')) {
return config('mail.from.address');
}
$originalFromAddress = Str::of(config('mail.from.address'))->explode('@');

View File

@ -156,7 +156,7 @@ class IntegrationLogicRule implements DataAwareRule, ValidationRule
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if(!$this->passes($attribute, $value)) {
if (!$this->passes($attribute, $value)) {
$fail($this->message());
}
}

View File

@ -41,7 +41,7 @@ class OneEmailPerLine implements ValidationRule
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if(!$this->passes($attribute, $value)) {
if (!$this->passes($attribute, $value)) {
$fail($this->message());
}
}

View File

@ -69,7 +69,7 @@ class StorageFile implements ValidationRule
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if(!$this->passes($attribute, $value)) {
if (!$this->passes($attribute, $value)) {
$fail($this->message());
}
}

View File

@ -34,7 +34,7 @@ class ValidHCaptcha implements ImplicitRule
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if(!$this->passes($attribute, $value)) {
if (!$this->passes($attribute, $value)) {
$fail($this->message());
}
}

View File

@ -95,11 +95,11 @@ class FormLogicConditionChecker
private function checkMatrixContains($condition, $fieldValue): bool
{
foreach($condition['value'] as $key => $value) {
if(!(array_key_exists($key, $condition['value']) && array_key_exists($key, $fieldValue))) {
foreach ($condition['value'] as $key => $value) {
if (!(array_key_exists($key, $condition['value']) && array_key_exists($key, $fieldValue))) {
return false;
}
if($condition['value'][$key] == $fieldValue[$key]) {
if ($condition['value'][$key] == $fieldValue[$key]) {
return true;
}
}
@ -108,8 +108,8 @@ class FormLogicConditionChecker
private function checkMatrixEquals($condition, $fieldValue): bool
{
foreach($condition['value'] as $key => $value) {
if($condition['value'][$key] !== $fieldValue[$key]) {
foreach ($condition['value'] as $key => $value) {
if ($condition['value'][$key] !== $fieldValue[$key]) {
return false;
}
}

View File

@ -15,6 +15,6 @@ if (!function_exists('front_url')) {
if (!function_exists('pricing_enabled')) {
function pricing_enabled(): bool
{
return App::environment() !== 'testing' && !is_null(config('cashier.key'));
return !is_null(config('cashier.key'));
}
}

768
api/composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -45,6 +45,17 @@ return [
'auth_mode' => null,
],
// Use the custom_smtp mailer for the user's own SMTP server.
// This configuration will be set dynamically by the AbstractEmailIntegrationHandler class.
'custom_smtp' => [
'transport' => 'smtp',
'host' => null,
'port' => 587,
'encryption' => 'tls',
'username' => null,
'password' => null,
],
'ses' => [
'transport' => 'ses',
],

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class () extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
$driver = DB::getDriverName();
Schema::table('workspaces', function (Blueprint $table) use ($driver) {
if ($driver === 'mysql') {
$table->json('settings')->default(new Expression('(JSON_OBJECT())'))->nullable(true);
} else {
$table->json('settings')->default('{}')->nullable(true);
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('workspaces', function (Blueprint $table) {
$table->dropColumn(['settings']);
});
}
};

View File

@ -1,38 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
<!-- <testsuite name="Browser">-->
<!-- <directory suffix="Test.php">./tests/Browser</directory>-->
<!-- </testsuite>-->
</testsuites>
<coverage>
<include>
<directory suffix=".php">./app</directory>
</include>
</coverage>
<php>
<env name="APP_KEY" value="AckfSECXIvnK5r28GVIWUAxmbBSjTsmF"/>
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="DB_FOREIGN_KEYS" value="(false)"/>
<env name="MAIL_MAILER" value="log"/>
<env name="MAIL_FROM_ADDRESS" value="notifications@notionforms.io"/>
<env name="MAIL_FROM_NAME" value="NotionForms"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="SELF_HOSTED" value="false"/>
<env name="TEMPLATE_EDITOR_EMAILS" value="admin@opnform.com"/>
<env name="JWT_SECRET" value="9K6whOetAFaokQgSIdbMQZuJuDV5uS2Y"/>
</php>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
<!-- <testsuite name="Browser">-->
<!-- <directory suffix="Test.php">./tests/Browser</directory>-->
<!-- </testsuite>-->
</testsuites>
<php>
<env name="APP_KEY" value="AckfSECXIvnK5r28GVIWUAxmbBSjTsmF"/>
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="DB_FOREIGN_KEYS" value="(false)"/>
<env name="MAIL_MAILER" value="log"/>
<env name="MAIL_FROM_ADDRESS" value="notifications@notionforms.io"/>
<env name="MAIL_FROM_NAME" value="NotionForms"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="SELF_HOSTED" value="false"/>
<env name="TEMPLATE_EDITOR_EMAILS" value="admin@opnform.com"/>
<env name="JWT_SECRET" value="9K6whOetAFaokQgSIdbMQZuJuDV5uS2Y"/>
<env name="STRIPE_KEY" value="TEST_KEY"/>
<env name="STRIPE_SECRET" value="TEST_SECRET"/>
</php>
<source>
<include>
<directory suffix=".php">./app</directory>
</include>
</source>
</phpunit>

View File

@ -142,6 +142,7 @@ Route::group(['middleware' => 'auth:api'], function () {
[FormController::class, 'index']
)->name('forms.index');
Route::put('/custom-domains', [WorkspaceController::class, 'saveCustomDomain'])->name('save-custom-domains');
Route::put('/email-settings', [WorkspaceController::class, 'saveEmailSettings'])->name('save-email-settings');
Route::delete('/', [WorkspaceController::class, 'delete'])->name('delete');
Route::middleware('pro-form')->group(function () {

View File

@ -0,0 +1,60 @@
<?php
use App\Mail\Forms\SubmissionConfirmationMail;
use Illuminate\Support\Facades\Mail;
it('can not save custom SMTP settings if not pro user', function () {
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$this->putJson(route('open.workspaces.save-email-settings', [$workspace->id]), [
'host' => 'custom.smtp.host',
'port' => '587',
'username' => 'custom_username',
'password' => 'custom_password',
])->assertStatus(403);
});
it('creates confirmation emails with custom SMTP settings', function () {
$user = $this->actingAsProUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace);
// Set custom SMTP settings
$this->putJson(route('open.workspaces.save-email-settings', [$workspace->id]), [
'host' => 'custom.smtp.host',
'port' => '587',
'username' => 'custom_username',
'password' => 'custom_password',
])->assertSuccessful();
$integrationData = $this->createFormIntegration('submission_confirmation', $form->id, [
'respondent_email' => true,
'notifications_include_submission' => true,
'notification_sender' => 'Custom Sender',
'notification_subject' => 'Custom SMTP Test',
'notification_body' => 'This email was sent using custom SMTP settings',
]);
$formData = [
collect($form->properties)->first(function ($property) {
return $property['type'] == 'email';
})['id'] => 'test@test.com',
];
Mail::fake();
$this->postJson(route('forms.answer', $form->slug), $formData)
->assertSuccessful()
->assertJson([
'type' => 'success',
'message' => 'Form submission saved.',
]);
Mail::assertQueued(
SubmissionConfirmationMail::class,
function (SubmissionConfirmationMail $mail) {
return $mail->hasTo('test@test.com') && $mail->mailer === 'custom_smtp';
}
);
});

View File

@ -3,16 +3,19 @@
use App\Models\UserInvite;
use Carbon\Carbon;
beforeEach(function () {
$this->user = $this->actingAsProUser();
$this->workspace = $this->createUserWorkspace($this->user);
});
it('can register with invite token', function () {
$this->withoutExceptionHandling();
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$email = 'invitee@gmail.com';
$inviteData = ['email' => $email, 'role' => 'user'];
$this->postJson(route('open.workspaces.users.add', $workspace->id), $inviteData)
$this->postJson(route('open.workspaces.users.add', $this->workspace->id), $inviteData)
->assertSuccessful();
expect($workspace->invites()->count())->toBe(1);
expect($this->workspace->invites()->count())->toBe(1);
$userInvite = UserInvite::latest()->first();
$token = $userInvite->token;
@ -30,18 +33,16 @@ it('can register with invite token', function () {
'invite_token' => $token,
]);
$response->assertSuccessful();
expect($workspace->users()->count())->toBe(2);
expect($this->workspace->users()->count())->toBe(2);
});
it('cannot register with expired invite token', function () {
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$email = 'invitee@gmail.com';
$inviteData = ['email' => $email, 'role' => 'user'];
$this->postJson(route('open.workspaces.users.add', $workspace->id), $inviteData)
$this->postJson(route('open.workspaces.users.add', $this->workspace->id), $inviteData)
->assertSuccessful();
expect($workspace->invites()->count())->toBe(1);
expect($this->workspace->invites()->count())->toBe(1);
$userInvite = UserInvite::latest()->first();
$token = $userInvite->token;
@ -62,18 +63,16 @@ it('cannot register with expired invite token', function () {
$response->assertStatus(400)->assertJson([
'message' => 'Invite token has expired.',
]);
expect($workspace->users()->count())->toBe(1);
expect($this->workspace->users()->count())->toBe(1);
});
it('cannot re-register with accepted invite token', function () {
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$email = 'invitee@gmail.com';
$inviteData = ['email' => $email, 'role' => 'user'];
$this->postJson(route('open.workspaces.users.add', $workspace->id), $inviteData)
$this->postJson(route('open.workspaces.users.add', $this->workspace->id), $inviteData)
->assertSuccessful();
expect($workspace->invites()->count())->toBe(1);
expect($this->workspace->invites()->count())->toBe(1);
$userInvite = UserInvite::latest()->first();
$token = $userInvite->token;
@ -91,10 +90,10 @@ it('cannot re-register with accepted invite token', function () {
'invite_token' => $token,
]);
$response->assertSuccessful();
expect($workspace->users()->count())->toBe(2);
expect($this->workspace->users()->count())->toBe(2);
$this->postJson('/logout')
->assertSuccessful();
->assertSuccessful();
// Register again with same used token
$response = $this->postJson('/register', [
@ -110,23 +109,21 @@ it('cannot re-register with accepted invite token', function () {
$response->assertStatus(422)->assertJson([
'message' => 'The email has already been taken.',
]);
expect($workspace->users()->count())->toBe(2);
expect($this->workspace->users()->count())->toBe(2);
});
it('can cancel user invite', function () {
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
$email = 'invitee@gmail.com';
$inviteData = ['email' => $email, 'role' => 'user'];
$response = $this->postJson(route('open.workspaces.users.add', $workspace->id), $inviteData)
$response = $this->postJson(route('open.workspaces.users.add', $this->workspace->id), $inviteData)
->assertSuccessful();
expect($workspace->invites()->count())->toBe(1);
expect($this->workspace->invites()->count())->toBe(1);
$userInvite = UserInvite::latest()->first();
$token = $userInvite->token;
// Cancel the invite
$this->deleteJson(route('open.workspaces.invites.cancel', ['workspaceId' => $workspace->id, 'inviteId' => $userInvite->id]))
$this->deleteJson(route('open.workspaces.invites.cancel', ['workspaceId' => $this->workspace->id, 'inviteId' => $userInvite->id]))
->assertSuccessful();
$this->postJson('/logout')
@ -146,5 +143,5 @@ it('can cancel user invite', function () {
'message' => 'Invite token is invalid.',
]);
expect($workspace->users()->count())->toBe(1);
expect($this->workspace->users()->count())->toBe(1);
});

View File

@ -4,6 +4,16 @@
:integration="props.integration"
:form="form"
>
<p class="text-gray-500 text-sm mb-3">
You can <NuxtLink
class="underline"
:to="{ name: 'settings-workspace' }"
target="_blank"
>
use our custom SMTP feature
</NuxtLink> to send emails from your own domain.
</p>
<text-area-input
:form="integrationData"
name="settings.notification_emails"

View File

@ -4,7 +4,19 @@
:integration="props.integration"
:form="form"
>
<div>{{ emailSubmissionConfirmationHelp }}</div>
<div class="text-gray-500 text-sm">
{{ emailSubmissionConfirmationHelp }}
</div>
<p class="text-gray-500 text-sm mb-3">
You can <NuxtLink
class="underline"
:to="{ name: 'settings-workspace' }"
target="_blank"
>
use our custom SMTP feature
</NuxtLink> to send emails from your own domain.
</p>
<div v-if="emailSubmissionConfirmationField">
<text-input

View File

@ -212,7 +212,8 @@ export default {
"Larger file uploads (50mb)",
"Remove OpnForm branding",
"Priority support",
"Form Analytics"
"Form Analytics",
"Custom sender email (SMTP)"
],
}),

View File

@ -5,7 +5,7 @@
>
<UButton
color="gray"
label="Manage Custom Domains"
label="Custom Domains Settings"
icon="i-heroicons-globe-alt"
@click="showCustomDomainModal = !showCustomDomainModal"
/>
@ -16,7 +16,7 @@
@close="showCustomDomainModal = false"
>
<h4 class="mb-4 font-medium">
Manage your custom domains
Custom Domains Settings
</h4>
<UAlert
v-if="!workspace.is_pro"

View File

@ -0,0 +1,175 @@
<template>
<div id="email-settings">
<UButton
color="gray"
label="Email Settings"
icon="i-heroicons-envelope"
@click="showEmailSettingsModal = !showEmailSettingsModal"
/>
<modal
:show="showEmailSettingsModal"
max-width="lg"
@close="showEmailSettingsModal = false"
>
<h4 class="font-medium">
Email Settings
</h4>
<p class="mb-4 text-gray-500 text-sm">
Customize email sender - connect your SMTP server.
</p>
<UAlert
v-if="!workspace.is_pro"
icon="i-heroicons-user-group-20-solid"
class="my-4 !text-orange-500"
color="orange"
variant="subtle"
title="Pro plan required"
>
<template #description>
Please <a
href="#"
class="text-orange-500 underline"
@click.prevent="openSubscriptionModal"
>
upgrade your account
</a> to setup an email settings.
</template>
</UAlert>
<TextInput
:form="emailSettingsForm"
name="host"
:required="true"
:disabled="!workspace.is_pro"
label="Host/Server"
class="mt-2"
placeholder="smtp.example.com"
/>
<TextInput
:form="emailSettingsForm"
name="port"
:required="true"
:disabled="!workspace.is_pro"
label="Port"
placeholder="587"
/>
<TextInput
:form="emailSettingsForm"
name="username"
:required="true"
:disabled="!workspace.is_pro"
label="Username"
placeholder="Username"
/>
<TextInput
:form="emailSettingsForm"
name="password"
native-type="password"
:required="true"
:disabled="!workspace.is_pro"
label="Password"
placeholder="Password"
/>
<div class="flex justify-between gap-2">
<UButton
class="mt-3 px-6"
:loading="emailSettingsLoading"
:disabled="!workspace.is_pro"
icon="i-heroicons-check"
@click="saveChanges"
>
Update
</UButton>
<UButton
class="mt-3 ml-2"
color="white"
size="sm"
:loading="emailSettingsLoading"
:disabled="!workspace.is_pro"
icon="i-heroicons-x-mark"
@click="clearEmailSettings"
>
Clear
</UButton>
</div>
</modal>
</div>
</template>
<script setup>
import {watch} from "vue"
const workspacesStore = useWorkspacesStore()
const workspace = computed(() => workspacesStore.getCurrent)
const subscriptionModalStore = useSubscriptionModalStore()
const openSubscriptionModal = () => {
showEmailSettingsModal.value = false
subscriptionModalStore.setModalContent('Upgrade to send emails using your own domain')
subscriptionModalStore.openModal()
}
const emailSettingsForm = useForm({
host: '',
port: '',
username: '',
password: ''
})
const emailSettingsLoading = ref(false)
const showEmailSettingsModal = ref(false)
onMounted(() => {
initEmailSettings()
})
watch(
() => workspace,
() => {
initEmailSettings()
},
)
const clearEmailSettings = () => {
emailSettingsForm.reset()
saveChanges()
}
const saveChanges = () => {
if (emailSettingsLoading.value) return
emailSettingsLoading.value = true
// Update the workspace Email Settings
emailSettingsForm
.put("/open/workspaces/" + workspace.value.id + "/email-settings", {
data: {
host: emailSettingsForm?.host,
port: emailSettingsForm?.port,
username: emailSettingsForm?.username,
password: emailSettingsForm?.password,
},
})
.then((data) => {
workspacesStore.save(data)
useAlert().success("Email settings saved.")
})
.catch((error) => {
useAlert().error(
"Failed to update email settings: " + error.response.data.message,
)
})
.finally(() => {
emailSettingsLoading.value = false
})
}
const initEmailSettings = () => {
if (!workspace || !workspace.value.settings.email_settings) return
const emailSettings = workspace.value?.settings?.email_settings
emailSettingsForm.host = emailSettings?.host
emailSettingsForm.port = emailSettings?.port
emailSettingsForm.username = emailSettings?.username
emailSettingsForm.password = emailSettings?.password
}
</script>

View File

@ -8,8 +8,9 @@
<small class="text-gray-500">You're currently editing the settings for the workspace "{{ workspace.name }}".
You can switch to another workspace in top left corner of the page.</small>
</div>
<div class="w-full flex flex-wrap justify-between gap-2">
<div class="w-full flex flex-wrap gap-2">
<WorkSpaceCustomDomains v-if="useFeatureFlag('custom_domains') && !loading" />
<WorkSpaceEmailSettings v-if="!loading" />
<UButton
label="New Workspace"
icon="i-heroicons-plus"