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;
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ class Google
|
|||
|
||||
public function getClient(): Client
|
||||
{
|
||||
if($this->client->isAccessTokenExpired()) {
|
||||
if ($this->client->isAccessTokenExpired()) {
|
||||
$this->refreshToken();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('@');
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,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>
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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 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);
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -212,7 +212,8 @@ export default {
|
|||
"Larger file uploads (50mb)",
|
||||
"Remove OpnForm branding",
|
||||
"Priority support",
|
||||
"Form Analytics"
|
||||
"Form Analytics",
|
||||
"Custom sender email (SMTP)"
|
||||
],
|
||||
}),
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }}".
|
||||
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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue