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:
parent
5dcd4ff8cb
commit
504c7a0f2f
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Requests\Workspace\CustomDomainRequest;
|
use App\Http\Requests\Workspace\CustomDomainRequest;
|
||||||
|
use App\Http\Requests\Workspace\EmailSettingsRequest;
|
||||||
use App\Http\Resources\WorkspaceResource;
|
use App\Http\Resources\WorkspaceResource;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -24,12 +25,31 @@ class WorkspaceController extends Controller
|
||||||
|
|
||||||
public function saveCustomDomain(CustomDomainRequest $request)
|
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->custom_domains = $request->customDomains;
|
||||||
$request->workspace->save();
|
$request->workspace->save();
|
||||||
|
|
||||||
return new WorkspaceResource($request->workspace);
|
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)
|
public function delete($id)
|
||||||
{
|
{
|
||||||
$workspace = Workspace::findOrFail($id);
|
$workspace = Workspace::findOrFail($id);
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
use App\Notifications\Forms\FormSubmissionNotification;
|
use App\Notifications\Forms\FormSubmissionNotification;
|
||||||
|
|
||||||
class EmailIntegration extends AbstractIntegrationHandler
|
class EmailIntegration extends AbstractEmailIntegrationHandler
|
||||||
{
|
{
|
||||||
public static function getValidationRules(): array
|
public static function getValidationRules(): array
|
||||||
{
|
{
|
||||||
|
|
@ -36,10 +36,11 @@ class EmailIntegration extends AbstractIntegrationHandler
|
||||||
'recipients' => $subscribers->toArray(),
|
'recipients' => $subscribers->toArray(),
|
||||||
'form_id' => $this->form->id,
|
'form_id' => $this->form->id,
|
||||||
'form_slug' => $this->form->slug,
|
'form_slug' => $this->form->slug,
|
||||||
|
'mailer' => $this->mailer
|
||||||
]);
|
]);
|
||||||
$subscribers->each(function ($subscriber) {
|
$subscribers->each(function ($subscriber) {
|
||||||
Notification::route('mail', $subscriber)->notify(
|
Notification::route('mail', $subscriber)->notify(
|
||||||
new FormSubmissionNotification($this->event, $this->integrationData)
|
new FormSubmissionNotification($this->event, $this->integrationData, $this->mailer)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ use Stevebauman\Purify\Facades\Purify;
|
||||||
/**
|
/**
|
||||||
* Sends a confirmation to form respondant that form was submitted
|
* Sends a confirmation to form respondant that form was submitted
|
||||||
*/
|
*/
|
||||||
class SubmissionConfirmationIntegration extends AbstractIntegrationHandler
|
class SubmissionConfirmationIntegration extends AbstractEmailIntegrationHandler
|
||||||
{
|
{
|
||||||
public const RISKY_USERS_LIMIT = 120;
|
public const RISKY_USERS_LIMIT = 120;
|
||||||
|
|
||||||
|
|
@ -54,8 +54,9 @@ class SubmissionConfirmationIntegration extends AbstractIntegrationHandler
|
||||||
'recipient' => $email,
|
'recipient' => $email,
|
||||||
'form_id' => $this->form->id,
|
'form_id' => $this->form->id,
|
||||||
'form_slug' => $this->form->slug,
|
'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()
|
private function getRespondentEmail()
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ class WorkspaceUsersUpdated implements ShouldQueue
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
// If self-hosted, no need to update billing
|
// If self-hosted, no need to update billing
|
||||||
if (!pricing_enabled()) {
|
if (!pricing_enabled() || \App::environment('testing')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ class Workspace extends Model implements CachableAttributes
|
||||||
'icon',
|
'icon',
|
||||||
'user_id',
|
'user_id',
|
||||||
'custom_domain',
|
'custom_domain',
|
||||||
|
'settings'
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $appends = [
|
protected $appends = [
|
||||||
|
|
@ -33,10 +34,11 @@ class Workspace extends Model implements CachableAttributes
|
||||||
'is_enterprise',
|
'is_enterprise',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts()
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'custom_domains' => 'array',
|
'custom_domains' => 'array',
|
||||||
|
'settings' => 'array'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,5 +203,4 @@ class Workspace extends Model implements CachableAttributes
|
||||||
{
|
{
|
||||||
return $this->hasMany(Form::class);
|
return $this->hasMany(Form::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,17 @@ class FormSubmissionNotification extends Notification implements ShouldQueue
|
||||||
use Queueable;
|
use Queueable;
|
||||||
|
|
||||||
public FormSubmitted $event;
|
public FormSubmitted $event;
|
||||||
|
private $mailer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new notification instance.
|
* Create a new notification instance.
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function __construct(FormSubmitted $event, private $integrationData)
|
public function __construct(FormSubmitted $event, private $integrationData, string $mailer)
|
||||||
{
|
{
|
||||||
$this->event = $event;
|
$this->event = $event;
|
||||||
|
$this->mailer = $mailer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -52,6 +54,7 @@ class FormSubmissionNotification extends Notification implements ShouldQueue
|
||||||
->useSignedUrlForFiles();
|
->useSignedUrlForFiles();
|
||||||
|
|
||||||
return (new MailMessage())
|
return (new MailMessage())
|
||||||
|
->mailer($this->mailer)
|
||||||
->replyTo($this->getReplyToEmail($notifiable->routes['mail']))
|
->replyTo($this->getReplyToEmail($notifiable->routes['mail']))
|
||||||
->from($this->getFromEmail(), config('app.name'))
|
->from($this->getFromEmail(), config('app.name'))
|
||||||
->subject('New form submission for "' . $this->event->form->title . '"')
|
->subject('New form submission for "' . $this->event->form->title . '"')
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,6 @@ if (!function_exists('front_url')) {
|
||||||
if (!function_exists('pricing_enabled')) {
|
if (!function_exists('pricing_enabled')) {
|
||||||
function pricing_enabled(): bool
|
function pricing_enabled(): bool
|
||||||
{
|
{
|
||||||
return App::environment() !== 'testing' && !is_null(config('cashier.key'));
|
return !is_null(config('cashier.key'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -45,6 +45,17 @@ return [
|
||||||
'auth_mode' => null,
|
'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' => [
|
'ses' => [
|
||||||
'transport' => 'ses',
|
'transport' => 'ses',
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
<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">
|
||||||
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
|
|
||||||
bootstrap="vendor/autoload.php"
|
|
||||||
colors="true"
|
|
||||||
>
|
|
||||||
<testsuites>
|
<testsuites>
|
||||||
<testsuite name="Unit">
|
<testsuite name="Unit">
|
||||||
<directory suffix="Test.php">./tests/Unit</directory>
|
<directory suffix="Test.php">./tests/Unit</directory>
|
||||||
|
|
@ -15,11 +11,6 @@
|
||||||
<!-- <directory suffix="Test.php">./tests/Browser</directory>-->
|
<!-- <directory suffix="Test.php">./tests/Browser</directory>-->
|
||||||
<!-- </testsuite>-->
|
<!-- </testsuite>-->
|
||||||
</testsuites>
|
</testsuites>
|
||||||
<coverage>
|
|
||||||
<include>
|
|
||||||
<directory suffix=".php">./app</directory>
|
|
||||||
</include>
|
|
||||||
</coverage>
|
|
||||||
<php>
|
<php>
|
||||||
<env name="APP_KEY" value="AckfSECXIvnK5r28GVIWUAxmbBSjTsmF"/>
|
<env name="APP_KEY" value="AckfSECXIvnK5r28GVIWUAxmbBSjTsmF"/>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<env name="APP_ENV" value="testing"/>
|
||||||
|
|
@ -34,5 +25,12 @@
|
||||||
<env name="SELF_HOSTED" value="false"/>
|
<env name="SELF_HOSTED" value="false"/>
|
||||||
<env name="TEMPLATE_EDITOR_EMAILS" value="admin@opnform.com"/>
|
<env name="TEMPLATE_EDITOR_EMAILS" value="admin@opnform.com"/>
|
||||||
<env name="JWT_SECRET" value="9K6whOetAFaokQgSIdbMQZuJuDV5uS2Y"/>
|
<env name="JWT_SECRET" value="9K6whOetAFaokQgSIdbMQZuJuDV5uS2Y"/>
|
||||||
|
<env name="STRIPE_KEY" value="TEST_KEY"/>
|
||||||
|
<env name="STRIPE_SECRET" value="TEST_SECRET"/>
|
||||||
</php>
|
</php>
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory suffix=".php">./app</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@ Route::group(['middleware' => 'auth:api'], function () {
|
||||||
[FormController::class, 'index']
|
[FormController::class, 'index']
|
||||||
)->name('forms.index');
|
)->name('forms.index');
|
||||||
Route::put('/custom-domains', [WorkspaceController::class, 'saveCustomDomain'])->name('save-custom-domains');
|
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::delete('/', [WorkspaceController::class, 'delete'])->name('delete');
|
||||||
|
|
||||||
Route::middleware('pro-form')->group(function () {
|
Route::middleware('pro-form')->group(function () {
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -3,16 +3,19 @@
|
||||||
use App\Models\UserInvite;
|
use App\Models\UserInvite;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->user = $this->actingAsProUser();
|
||||||
|
$this->workspace = $this->createUserWorkspace($this->user);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
it('can register with invite token', function () {
|
it('can register with invite token', function () {
|
||||||
$this->withoutExceptionHandling();
|
|
||||||
$user = $this->actingAsUser();
|
|
||||||
$workspace = $this->createUserWorkspace($user);
|
|
||||||
$email = 'invitee@gmail.com';
|
$email = 'invitee@gmail.com';
|
||||||
$inviteData = ['email' => $email, 'role' => 'user'];
|
$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();
|
->assertSuccessful();
|
||||||
|
|
||||||
expect($workspace->invites()->count())->toBe(1);
|
expect($this->workspace->invites()->count())->toBe(1);
|
||||||
$userInvite = UserInvite::latest()->first();
|
$userInvite = UserInvite::latest()->first();
|
||||||
$token = $userInvite->token;
|
$token = $userInvite->token;
|
||||||
|
|
||||||
|
|
@ -30,18 +33,16 @@ it('can register with invite token', function () {
|
||||||
'invite_token' => $token,
|
'invite_token' => $token,
|
||||||
]);
|
]);
|
||||||
$response->assertSuccessful();
|
$response->assertSuccessful();
|
||||||
expect($workspace->users()->count())->toBe(2);
|
expect($this->workspace->users()->count())->toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cannot register with expired invite token', function () {
|
it('cannot register with expired invite token', function () {
|
||||||
$user = $this->actingAsUser();
|
|
||||||
$workspace = $this->createUserWorkspace($user);
|
|
||||||
$email = 'invitee@gmail.com';
|
$email = 'invitee@gmail.com';
|
||||||
$inviteData = ['email' => $email, 'role' => 'user'];
|
$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();
|
->assertSuccessful();
|
||||||
|
|
||||||
expect($workspace->invites()->count())->toBe(1);
|
expect($this->workspace->invites()->count())->toBe(1);
|
||||||
$userInvite = UserInvite::latest()->first();
|
$userInvite = UserInvite::latest()->first();
|
||||||
$token = $userInvite->token;
|
$token = $userInvite->token;
|
||||||
|
|
||||||
|
|
@ -62,18 +63,16 @@ it('cannot register with expired invite token', function () {
|
||||||
$response->assertStatus(400)->assertJson([
|
$response->assertStatus(400)->assertJson([
|
||||||
'message' => 'Invite token has expired.',
|
'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 () {
|
it('cannot re-register with accepted invite token', function () {
|
||||||
$user = $this->actingAsUser();
|
|
||||||
$workspace = $this->createUserWorkspace($user);
|
|
||||||
$email = 'invitee@gmail.com';
|
$email = 'invitee@gmail.com';
|
||||||
$inviteData = ['email' => $email, 'role' => 'user'];
|
$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();
|
->assertSuccessful();
|
||||||
|
|
||||||
expect($workspace->invites()->count())->toBe(1);
|
expect($this->workspace->invites()->count())->toBe(1);
|
||||||
$userInvite = UserInvite::latest()->first();
|
$userInvite = UserInvite::latest()->first();
|
||||||
$token = $userInvite->token;
|
$token = $userInvite->token;
|
||||||
|
|
||||||
|
|
@ -91,7 +90,7 @@ it('cannot re-register with accepted invite token', function () {
|
||||||
'invite_token' => $token,
|
'invite_token' => $token,
|
||||||
]);
|
]);
|
||||||
$response->assertSuccessful();
|
$response->assertSuccessful();
|
||||||
expect($workspace->users()->count())->toBe(2);
|
expect($this->workspace->users()->count())->toBe(2);
|
||||||
|
|
||||||
$this->postJson('/logout')
|
$this->postJson('/logout')
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
|
|
@ -110,23 +109,21 @@ it('cannot re-register with accepted invite token', function () {
|
||||||
$response->assertStatus(422)->assertJson([
|
$response->assertStatus(422)->assertJson([
|
||||||
'message' => 'The email has already been taken.',
|
'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 () {
|
it('can cancel user invite', function () {
|
||||||
$user = $this->actingAsUser();
|
|
||||||
$workspace = $this->createUserWorkspace($user);
|
|
||||||
$email = 'invitee@gmail.com';
|
$email = 'invitee@gmail.com';
|
||||||
$inviteData = ['email' => $email, 'role' => 'user'];
|
$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();
|
->assertSuccessful();
|
||||||
|
|
||||||
expect($workspace->invites()->count())->toBe(1);
|
expect($this->workspace->invites()->count())->toBe(1);
|
||||||
$userInvite = UserInvite::latest()->first();
|
$userInvite = UserInvite::latest()->first();
|
||||||
$token = $userInvite->token;
|
$token = $userInvite->token;
|
||||||
|
|
||||||
// Cancel the invite
|
// 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();
|
->assertSuccessful();
|
||||||
|
|
||||||
$this->postJson('/logout')
|
$this->postJson('/logout')
|
||||||
|
|
@ -146,5 +143,5 @@ it('can cancel user invite', function () {
|
||||||
'message' => 'Invite token is invalid.',
|
'message' => 'Invite token is invalid.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect($workspace->users()->count())->toBe(1);
|
expect($this->workspace->users()->count())->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
@ -4,6 +4,16 @@
|
||||||
:integration="props.integration"
|
:integration="props.integration"
|
||||||
:form="form"
|
: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
|
<text-area-input
|
||||||
:form="integrationData"
|
:form="integrationData"
|
||||||
name="settings.notification_emails"
|
name="settings.notification_emails"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,19 @@
|
||||||
:integration="props.integration"
|
:integration="props.integration"
|
||||||
:form="form"
|
: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">
|
<div v-if="emailSubmissionConfirmationField">
|
||||||
<text-input
|
<text-input
|
||||||
|
|
|
||||||
|
|
@ -212,7 +212,8 @@ export default {
|
||||||
"Larger file uploads (50mb)",
|
"Larger file uploads (50mb)",
|
||||||
"Remove OpnForm branding",
|
"Remove OpnForm branding",
|
||||||
"Priority support",
|
"Priority support",
|
||||||
"Form Analytics"
|
"Form Analytics",
|
||||||
|
"Custom sender email (SMTP)"
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
>
|
>
|
||||||
<UButton
|
<UButton
|
||||||
color="gray"
|
color="gray"
|
||||||
label="Manage Custom Domains"
|
label="Custom Domains Settings"
|
||||||
icon="i-heroicons-globe-alt"
|
icon="i-heroicons-globe-alt"
|
||||||
@click="showCustomDomainModal = !showCustomDomainModal"
|
@click="showCustomDomainModal = !showCustomDomainModal"
|
||||||
/>
|
/>
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
@close="showCustomDomainModal = false"
|
@close="showCustomDomainModal = false"
|
||||||
>
|
>
|
||||||
<h4 class="mb-4 font-medium">
|
<h4 class="mb-4 font-medium">
|
||||||
Manage your custom domains
|
Custom Domains Settings
|
||||||
</h4>
|
</h4>
|
||||||
<UAlert
|
<UAlert
|
||||||
v-if="!workspace.is_pro"
|
v-if="!workspace.is_pro"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -8,8 +8,9 @@
|
||||||
<small class="text-gray-500">You're currently editing the settings for the workspace "{{ workspace.name }}".
|
<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>
|
You can switch to another workspace in top left corner of the page.</small>
|
||||||
</div>
|
</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" />
|
<WorkSpaceCustomDomains v-if="useFeatureFlag('custom_domains') && !loading" />
|
||||||
|
<WorkSpaceEmailSettings v-if="!loading" />
|
||||||
<UButton
|
<UButton
|
||||||
label="New Workspace"
|
label="New Workspace"
|
||||||
icon="i-heroicons-plus"
|
icon="i-heroicons-plus"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue