feat: send test mail (#7968)

# Which Problems Are Solved

- Zitadel doesn't have a way to test SMTP settings either before
creating a new provider or once the SMTP provider has been created.
- Zitadel SMTP messages can be more informative for usual errors

# How the Problems Are Solved

- A new step is added to the new/update SMTP provider wizard that allows
us to test a configuration. The result is shown in a text area.
- From the table of SMTP providers you can test your settings too.
- The email address to send the email is by default the email address
for the logged in user as suggested.
- Some of the SMTP error messages have been changed to give more
information about the possible situation. For example: could not contact
with the SMTP server, check the port, firewall issues... instead of
could not dial

Here's a video showing this new option in action:


https://github.com/zitadel/zitadel/assets/30386061/50128ba1-c9fa-4481-8eec-e79a3ca69bda

# Additional Changes

Replace this example text with a concise list of additional changes that
this PR introduces, that are not directly solving the initial problem
but are related.
For example:
- The docs explicitly describe that the property XY is mandatory
- Adds missing translations for validations.

# Additional Context

- Closes #4504
This commit is contained in:
Miguel Cabrerizo
2024-06-20 19:51:42 +00:00
committed by GitHub
parent 00b5e55565
commit 3635320ce8
51 changed files with 1362 additions and 46 deletions
@@ -2,7 +2,7 @@
title="{{
id ? ('SMTP.DETAIL.TITLE' | translate) : ('SMTP.CREATE.STEPS.TITLE' | translate: { value: providerDefaultSetting.name })
}}"
[createSteps]="3"
[createSteps]="4"
[currentCreateStep]="currentCreateStep"
(closed)="close()"
>
@@ -93,7 +93,7 @@
[disabled]="firstFormGroup.invalid"
color="primary"
matStepperNext
data-e2e="continue-button"
data-e2e="continue-to-2nd-form"
>
{{ 'ACTIONS.CONTINUE' | translate }}
</button>
@@ -130,19 +130,74 @@
<button mat-stroked-button matStepperPrevious class="bck-button">{{ 'ACTIONS.BACK' | translate }}</button>
<button
mat-raised-button
class="create-button"
[disabled]="secondFormGroup.invalid"
color="primary"
data-e2e="create-button"
(click)="savePolicy(stepper)"
[disabled]="
firstFormGroup.invalid || secondFormGroup.invalid || (['iam.policy.write'] | hasRole | async) === false
"
matStepperNext
data-e2e="continue-button"
>
{{ !hasSMTPConfig ? ('ACTIONS.CREATE' | translate) : ('ACTIONS.SAVE' | translate) }}
{{ 'ACTIONS.CONTINUE' | translate }}
</button>
</div>
</mat-step>
<mat-step [editable]="true">
<form>
<ng-template matStepLabel>{{ 'SMTP.CREATE.STEPS.SAVE_SETTINGS' | translate }}</ng-template>
<cnsl-info-section>
<div class="title-row">
<div class="left">
<h2 class="title">{{ 'SMTP.CREATE.STEPS.TEST.TITLE' | translate }}</h2>
</div>
<div class="right">
<button color="primary" mat-raised-button class="continue-button" (click)="testEmailConfiguration()">
{{ 'ACTIONS.TEST' | translate }}
</button>
</div>
</div>
<p class="cnsl-secondary-text description">{{ 'SMTP.CREATE.STEPS.TEST.DESCRIPTION' | translate }}</p>
<cnsl-form-field class="formfield">
<cnsl-label>{{ 'SMTP.LIST.DIALOG.TEST_EMAIL' | translate }}</cnsl-label>
<input
cnslInput
[(ngModel)]="email"
[ngModelOptions]="{ standalone: true }"
data-e2e="email-test-dialog-input"
/>
</cnsl-form-field>
<div class="is-loading" *ngIf="isLoading()">
<mat-spinner diameter="50"></mat-spinner>
</div>
<cnsl-form-field class="formfield" *ngIf="testResult">
<cnsl-label>{{ 'SMTP.LIST.DIALOG.TEST_RESULT' | translate }}</cnsl-label>
<textarea
cnslInput
class="{{ resultClass }}"
[(ngModel)]="testResult"
[ngModelOptions]="{ standalone: true }"
></textarea>
</cnsl-form-field>
</cnsl-info-section>
<div class="smtp-create-actions">
<button mat-stroked-button matStepperPrevious class="bck-button">{{ 'ACTIONS.BACK' | translate }}</button>
<button
mat-raised-button
class="create-button"
color="primary"
data-e2e="create-button"
(click)="savePolicy(stepper)"
[disabled]="
firstFormGroup.invalid || secondFormGroup.invalid || (['iam.policy.write'] | hasRole | async) === false
"
>
{{ !hasSMTPConfig ? ('ACTIONS.CREATE' | translate) : ('ACTIONS.SAVE' | translate) }}
</button>
</div>
</form>
</mat-step>
<mat-step [editable]="true">
<form>
<ng-template matStepLabel>{{ 'SMTP.CREATE.STEPS.NEXT_STEPS' | translate }}</ng-template>
@@ -1,8 +1,8 @@
import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes';
import { Location } from '@angular/common';
import { Component } from '@angular/core';
import { Component, signal } from '@angular/core';
import { AbstractControl, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Subject } from 'rxjs';
import { Subject, take } from 'rxjs';
import { StepperSelectionEvent } from '@angular/cdk/stepper';
import { Options } from 'src/app/proto/generated/zitadel/idp_pb';
import { requiredValidator } from '../form-field/validators/validators';
@@ -11,6 +11,7 @@ import { PolicyComponentServiceType } from '../policies/policy-component-types.e
import {
AddSMTPConfigRequest,
AddSMTPConfigResponse,
TestSMTPConfigRequest,
UpdateSMTPConfigRequest,
UpdateSMTPConfigResponse,
} from 'src/app/proto/generated/zitadel/admin_pb';
@@ -31,8 +32,10 @@ import {
OutlookDefaultSettings,
SendgridDefaultSettings,
} from './known-smtp-providers-settings';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { MatStepper } from '@angular/material/stepper';
import { SMTPConfigState } from 'src/app/proto/generated/zitadel/settings_pb';
import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'cnsl-smtp-provider',
@@ -61,6 +64,11 @@ export class SMTPProviderComponent {
public senderEmailPlaceholder = '[email protected]';
public resultClass = 'test-success';
public isLoading = signal(false);
public email: string = '';
public testResult: string = '';
constructor(
private service: AdminService,
private _location: Location,
@@ -68,6 +76,8 @@ export class SMTPProviderComponent {
private toast: ToastService,
private router: Router,
private route: ActivatedRoute,
private authService: GrpcAuthService,
private translate: TranslateService,
) {
this.route.parent?.url.subscribe((urlPath) => {
const providerName = urlPath[urlPath.length - 1].path;
@@ -136,6 +146,17 @@ export class SMTPProviderComponent {
this.fetchData(this.id);
}
}
this.authService
.getMyUser()
.then((resp) => {
if (resp.user) {
this.email = resp.user.human?.email?.email || '';
}
})
.catch((error) => {
this.toast.showError(error);
});
});
}
@@ -281,6 +302,38 @@ export class SMTPProviderComponent {
});
}
public testEmailConfiguration(): void {
this.isLoading.set(true);
const req = new TestSMTPConfigRequest();
req.setSenderAddress(this.senderAddress?.value ?? '');
req.setSenderName(this.senderName?.value ?? '');
req.setHost(this.hostAndPort?.value ?? '');
req.setUser(this.user?.value);
req.setPassword(this.password?.value ?? '');
req.setTls(this.tls?.value ?? false);
req.setId(this.id ?? '');
req.setReceiverAddress(this.email ?? '');
this.service
.testSMTPConfig(req)
.then(() => {
this.resultClass = 'test-success';
this.isLoading.set(false);
this.translate
.get('SMTP.CREATE.STEPS.TEST.RESULT')
.pipe(take(1))
.subscribe((msg) => {
this.testResult = msg;
});
})
.catch((error) => {
this.resultClass = 'test-error';
this.isLoading.set(false);
this.testResult = error;
});
}
public get description(): AbstractControl | null {
return this.firstFormGroup.get('description');
}
@@ -96,4 +96,26 @@
width: 1.2rem;
}
}
.continue-button {
margin-top: 3rem;
display: block;
height: 3.5rem;
padding: 0 4rem;
}
.is-loading {
display: flex;
justify-content: center;
}
.test-error {
border-color: #ff3b5b !important;
min-height: 200px;
}
.test-success {
border-color: #10b981 !important;
min-height: 200px;
}
}
@@ -93,6 +93,18 @@
<i class="las la-times-circle"></i>
</button>
<button
actions
[disabled]="(['iam.write'] | hasRole | async) === false"
mat-icon-button
color="primary"
matTooltip="{{ 'SMTP.LIST.TEST' | translate }}"
data-e2e="test-provider-button"
(click)="testSMTPConfig(config.id); $event.stopPropagation()"
>
<i class="las la-flask"></i>
</button>
<button
actions
[disabled]="(['iam.write'] | hasRole | async) === false"
@@ -16,6 +16,7 @@ import { SMTPConfig } from 'src/app/proto/generated/zitadel/settings_pb';
import { WarnDialogComponent } from '../warn-dialog/warn-dialog.component';
import { MatTableDataSource } from '@angular/material/table';
import { MatDialog } from '@angular/material/dialog';
import { SmtpTestDialogComponent } from '../smtp-test-dialog/smtp-test-dialog.component';
@Component({
selector: 'cnsl-smtp-table',
@@ -149,6 +150,21 @@ export class SMTPTableComponent implements OnInit {
});
}
public testSMTPConfig(id: string): void {
this.dialog.open(SmtpTestDialogComponent, {
data: {
id: id,
confirmKey: 'ACTIONS.TEST',
cancelKey: 'ACTIONS.CLOSE',
titleKey: 'SMTP.LIST.DIALOG.TEST_TITLE',
descriptionKey: 'SMTP.LIST.DIALOG.TEST_DESCRIPTION',
emailKey: 'SMTP.LIST.DIALOG.TEST_EMAIL',
testResultKey: 'SMTP.LIST.DIALOG.TEST_RESULT',
},
width: '500px',
});
}
private async getData(limit: number, offset: number): Promise<void> {
this.loadingSubject.next(true);
@@ -18,6 +18,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatTableModule } from '@angular/material/table';
import { SmtpTestDialogModule } from '../smtp-test-dialog/smtp-test-dialog.module';
@NgModule({
declarations: [SMTPTableComponent],
@@ -40,6 +41,7 @@ import { MatTableModule } from '@angular/material/table';
HasRoleModule,
HasRolePipeModule,
TruncatePipeModule,
SmtpTestDialogModule,
],
exports: [SMTPTableComponent],
})
@@ -0,0 +1,47 @@
<h1 mat-dialog-title>
<span>{{ data.titleKey | translate: data.titleParam }}</span>
</h1>
<div mat-dialog-content>
<div class="icon-wrapper" *ngIf="data.icon">
<i class="icon {{ data.icon }}"></i>
</div>
<p class="desc cnsl-secondary-text">{{ data.descriptionKey | translate: data.descriptionParam }}</p>
<cnsl-info-section *ngIf="data.warnSectionKey" [type]="InfoSectionType.WARN">
{{ data.warnSectionKey | translate }}
</cnsl-info-section>
<p *ngIf="data.hintKey" class="desc cnsl-secondary-text">{{ data.hintKey | translate: { value: data.confirmation } }}</p>
<cnsl-form-field class="formfield">
<cnsl-label>{{ data.emailKey | translate }}</cnsl-label>
<input cnslInput [(ngModel)]="email" data-e2e="email-test-dialog-input" />
</cnsl-form-field>
<div class="is-loading" *ngIf="isLoading()">
<mat-spinner diameter="50"></mat-spinner>
</div>
<cnsl-form-field class="formfield" *ngIf="testResult">
<cnsl-label>{{ data.testResultKey | translate }}</cnsl-label>
<textarea cnslInput class="{{ resultClass }}" [(ngModel)]="testResult"></textarea>
</cnsl-form-field>
</div>
<div class="action" mat-dialog-actions>
<button *ngIf="data.cancelKey" mat-stroked-button (click)="closeDialog()">
{{ data.cancelKey | translate }}
</button>
<span class="fill-space"></span>
<button
color="primary"
[disabled]="!email || isLoading()"
mat-raised-button
class="ok-button"
(click)="testEmailConfiguration()"
data-e2e="test-email-button"
>
{{ data.confirmKey | translate }}
</button>
</div>
@@ -0,0 +1,51 @@
h1 {
font-size: 1.5rem;
margin: 0;
}
.icon-wrapper {
display: flex;
justify-content: center;
padding: 1rem;
margin: 1rem;
align-items: center;
.icon {
font-size: 3rem;
}
}
.desc {
font-size: 0.9rem;
}
.action {
display: flex;
button {
border-radius: 0.5rem;
}
.ok-button {
margin-left: 0.5rem;
}
.fill-space {
flex: 1;
}
}
.is-loading {
display: flex;
justify-content: center;
}
.test-error {
border-color: #ff3b5b;
min-height: 200px;
}
.test-success {
border-color: #10b981;
min-height: 200px;
}
@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { SmtpTestDialogComponent } from './smtp-test-dialog.component';
describe('SmtpTestDialogComponent', () => {
let component: SmtpTestDialogComponent;
let fixture: ComponentFixture<SmtpTestDialogComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [SmtpTestDialogComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SmtpTestDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,72 @@
import { Component, Inject, signal } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { InfoSectionType } from '../info-section/info-section.component';
import { AdminService } from 'src/app/services/admin.service';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { ToastService } from 'src/app/services/toast.service';
import { TranslateService } from '@ngx-translate/core';
import { TestSMTPConfigByIdRequest } from 'src/app/proto/generated/zitadel/admin_pb';
import { take } from 'rxjs';
@Component({
selector: 'cnsl-smtp-test-dialog',
templateUrl: './smtp-test-dialog.component.html',
styleUrls: ['./smtp-test-dialog.component.scss'],
})
export class SmtpTestDialogComponent {
public resultClass = 'test-success';
public isLoading = signal(false);
public email: string = '';
public testResult: string = '';
InfoSectionType: any = InfoSectionType;
constructor(
public dialogRef: MatDialogRef<SmtpTestDialogComponent>,
private adminService: AdminService,
private authService: GrpcAuthService,
private toast: ToastService,
private translate: TranslateService,
@Inject(MAT_DIALOG_DATA) public data: any,
) {
this.authService
.getMyUser()
.then((resp) => {
if (resp.user) {
this.email = resp.user.human?.email?.email || '';
}
})
.catch((error) => {
this.toast.showError(error);
});
}
public testEmailConfiguration(): void {
this.isLoading.set(true);
const req = new TestSMTPConfigByIdRequest();
req.setId(this.data.id);
req.setReceiverAddress(this.email);
this.adminService
.testSMTPConfigById(req)
.then(() => {
this.resultClass = 'test-success';
this.isLoading.set(false);
this.translate
.get('SMTP.CREATE.STEPS.TEST.RESULT')
.pipe(take(1))
.subscribe((msg) => {
this.testResult = msg;
});
})
.catch((error) => {
this.resultClass = 'test-error';
this.isLoading.set(false);
this.testResult = error;
});
}
public closeDialog(): void {
this.dialogRef.close(false);
}
}
@@ -0,0 +1,26 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { TranslateModule } from '@ngx-translate/core';
import { MatDialogModule } from '@angular/material/dialog';
import { InfoSectionModule } from '../info-section/info-section.module';
import { InputModule } from '../input/input.module';
import { SmtpTestDialogComponent } from './smtp-test-dialog.component';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
@NgModule({
declarations: [SmtpTestDialogComponent],
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatProgressSpinnerModule,
TranslateModule,
InfoSectionModule,
MatButtonModule,
InputModule,
],
})
export class SmtpTestDialogModule {}