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
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);
});