Skip to content

Commit

Permalink
Check email validity (#324)
Browse files Browse the repository at this point in the history
Check is made when : 
- Sending a feedback request
- Giving a spontaneous feedback
  • Loading branch information
avine authored Jan 30, 2024
1 parent b70c613 commit efba1e5
Show file tree
Hide file tree
Showing 12 changed files with 141 additions and 49 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,16 @@ <h1 class="gbl-page-title">
<ng-container i18n="@@Title.GiveFeedback">Donner du feedZback</ng-container>
</h1>

<app-message type="danger" [(visible)]="showError" i18n="@@Message.ErrorOccured"
>Une erreur s'est produite.</app-message
>
<app-message type="danger" [(visible)]="showError">
@switch (errorType) {
@case ('error') {
<ng-container i18n="@@Message.ErrorOccured">Une erreur s'est produite.</ng-container>
}
@case ('invalid_email') {
<ng-container i18n="@@Message.InvalidEmail">L'adresse email est invalide.</ng-container>
}
}
</app-message>

<app-message type="success" [(visible)]="showDraft" i18n="@@Message.DraftSaved">Brouillon enregistré.</app-message>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export class GiveFeedbackComponent implements OnDestroy {

showError = false;

errorType: null | 'error' | 'invalid_email' = null;

showDraft = false;

feedbackId?: string;
Expand Down Expand Up @@ -116,16 +118,18 @@ export class GiveFeedbackComponent implements OnDestroy {
return;
}
this.showError = false;
this.errorType = null;
this.disableForm(true);

const { receiverEmail, positive, negative, comment, shared } = this.form.value as Required<typeof this.form.value>;

this.feedbackService.give({ receiverEmail, positive, negative, comment, shared }).subscribe(({ id }) => {
this.showError = !id;
if (!id) {
this.feedbackService.give({ receiverEmail, positive, negative, comment, shared }).subscribe((result) => {
if (result.id === undefined) {
this.showError = true;
this.errorType = result.message === 'invalid_email' ? 'invalid_email' : 'error';
this.disableForm(false);
} else {
this.feedbackId = id;
this.feedbackId = result.id;
this.feedbackDraftService.delete(receiverEmail).subscribe();
this.navigateToSuccess();
}
Expand Down
41 changes: 28 additions & 13 deletions client/src/app/request-feedback/request-feedback.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,35 @@ <h1 class="gbl-page-title">
<ng-container i18n="@@Title.RequestFeedback">Demander du feedZback</ng-container>
</h1>

@if (form.enabled && sentEmails.length) {
<app-message type="success" closable="false">
<ng-container i18n="@@Component.RequestFeedback.Success">Votre demande a bien été envoyée à :</ng-container>
<br />
{{ sentEmails.join(', ') }}
</app-message>
}
@if (form.enabled) {
@if (sentEmails.length) {
<app-message type="success" closable="false">
<ng-container i18n="@@Component.RequestFeedback.Success">Votre demande a bien été envoyée à :</ng-container>
<br />
{{ sentEmails.join(', ') }}
</app-message>
}

@if (remainingUnsentEmails.length) {
<app-message type="danger" closable="false">
<ng-container i18n="@@Component.RequestFeedback.Error"
>Une erreur s'est produite lors de l'envoi à :</ng-container
>
<br />
{{ remainingUnsentEmails.join(', ') }}
</app-message>
}

@if (form.enabled && remainingUnsentEmails.length) {
<app-message type="danger" closable="false">
<ng-container i18n="@@Component.RequestFeedback.Error">Une erreur s'est produite lors de l'envoi à :</ng-container>
<br />
{{ remainingUnsentEmails.join(', ') }}
</app-message>
@if (remainingInvalidEmails.length) {
<app-message type="danger" closable="false">
<ng-container i18n="@@Message.InvalidEmailPlural">{remainingInvalidEmails.length, plural,
=one {L'adresse email suivante est invalide :}
=other {Les adresses emails suivantes sont invalides :}
}</ng-container>
<br />
{{ remainingInvalidEmails.join(', ') }}
</app-message>
}
}

@if (!sentEmails.length || remainingUnsentEmails.length) {
Expand Down
13 changes: 9 additions & 4 deletions client/src/app/request-feedback/request-feedback.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ export class RequestFeedbackComponent {

protected remainingUnsentEmails: string[] = [];

protected remainingInvalidEmails: string[] = [];

protected applyTemplate(message: string | undefined) {
this.form.controls.message.setValue(message ?? '');
this.form.controls.message.updateValueAndValidity();
Expand All @@ -91,12 +93,15 @@ export class RequestFeedbackComponent {
toArray(),
)
.subscribe((results) => {
this.sentEmails = [...this.sentEmails, ...recipients.filter((_, index) => results[index])];
this.remainingUnsentEmails = recipients.filter((_, index) => !results[index]);
this.sentEmails = [...this.sentEmails, ...recipients.filter((_, index) => !results[index].error)];
this.remainingUnsentEmails = recipients.filter((_, index) => results[index].error && !results[index].message);
this.remainingInvalidEmails = recipients.filter(
(_, index) => results[index].error && results[index].message === 'invalid_email',
);

this.setRecipients(this.remainingUnsentEmails);
this.setRecipients([...this.remainingUnsentEmails, ...this.remainingInvalidEmails]);

if (this.remainingUnsentEmails.length) {
if (this.remainingUnsentEmails.length || this.remainingInvalidEmails.length) {
this.form.enable();
} else {
this.navigateToSuccess();
Expand Down
22 changes: 14 additions & 8 deletions client/src/app/shared/feedback/feedback.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable, catchError, map, of } from 'rxjs';
import { environment } from '../../../environments/environment';
Expand Down Expand Up @@ -28,11 +28,11 @@ export class FeedbackService {

// ----- Request feedback and give requested feedback -----

request(dto: FeedbackRequestDto) {
request(dto: FeedbackRequestDto): Observable<{ error: boolean; message?: 'invalid_email' }> {
return this.authService.withBearerIdToken((headers) =>
this.httpClient.post<void>(`${this.apiBaseUrl}/feedback/request`, dto, { headers }).pipe(
map(() => true),
catchError(() => of(false)),
map(() => ({ error: false })),
catchError(({ error }: HttpErrorResponse) => of({ error: true, message: error?.message })),
),
);
}
Expand Down Expand Up @@ -76,11 +76,17 @@ export class FeedbackService {
);
}

give(dto: GiveFeedbackDto): Observable<Partial<IdObject>> {
give(dto: GiveFeedbackDto): Observable<IdObject | { id: undefined; error: true; message?: 'invalid_email' }> {
return this.authService.withBearerIdToken((headers) =>
this.httpClient
.post<IdObject>(`${this.apiBaseUrl}/feedback/give`, dto, { headers })
.pipe(catchError(() => of({ id: undefined } as Partial<IdObject>))),
this.httpClient.post<IdObject>(`${this.apiBaseUrl}/feedback/give`, dto, { headers }).pipe(
catchError(({ error }: HttpErrorResponse) =>
of({
id: undefined,
error: true as const,
message: error.message,
}),
),
),
);
}

Expand Down
2 changes: 2 additions & 0 deletions client/src/locales/messages.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@
"Message.FeedbackIsSharedWithTheManagerOfYourColleague": "This feedZback is shared with the manager of your colleague",
"Message.FeedbackIsSharedWithYourManager": "This feedZback is shared with your manager",
"Message.FieldsRequired": "Fields marked with * are required",
"Message.InvalidEmail": "The email address is invalid.",
"Message.InvalidEmailPlural": "{VAR_PLURAL, plural, =one {The following email address is invalid:} =other {The following email addresses are invalid:}}",
"Message.NoResultsFor": "No results for \"{$INTERPOLATION}\"",
"PageNotFound.Message": "Oops !{$LINE_BREAK}This page cannot be found",
"PageNotFound.Title": "Page not found",
Expand Down
2 changes: 2 additions & 0 deletions client/src/locales/messages.fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@
"Message.FeedbackIsSharedWithTheManagerOfYourColleague": " Ce feedZback est partagé avec le manager de votre collègue ",
"Message.FeedbackIsSharedWithYourManager": " Ce feedZback est partagé avec votre manager ",
"Message.FieldsRequired": "Les champs marqués d'une * sont obligatoires",
"Message.InvalidEmail": "L'adresse email est invalide.",
"Message.InvalidEmailPlural": "{VAR_PLURAL, plural, =one {L'adresse email suivante est invalide :} =other {Les adresses emails suivantes sont invalides :}}",
"Message.NoResultsFor": " Aucun résultat pour \"{$INTERPOLATION}\" ",
"PageNotFound.Message": "Oups !{$LINE_BREAK}Cette page est introuvable",
"PageNotFound.Title": "Page introuvable",
Expand Down
3 changes: 3 additions & 0 deletions server/src/core/email/email.params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { MailgunMessageData } from 'mailgun.js';

export type SendParams = Required<Pick<MailgunMessageData, 'from' | 'subject' | 'html'> & { to: string }>;
43 changes: 39 additions & 4 deletions server/src/core/email/email.service.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,55 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as FormData from 'form-data';
import Mailgun, { MailgunMessageData } from 'mailgun.js';
import Mailgun from 'mailgun.js';
import { AppConfig } from '../config';
import { SendParams } from './email.params';

@Injectable()
export class EmailService {
private logger = new Logger('EmailService');

private clientOptions = this.configService.get('mailgunClientOptions', { infer: true })!;

private client = new Mailgun(FormData).client(this.clientOptions);

private domain = this.configService.get('mailgunDomain', { infer: true })!;

private appEnv = this.configService.get('appEnv', { infer: true })!;

constructor(private configService: ConfigService<AppConfig>) {}

async send({ from, to, subject, html }: Required<Pick<MailgunMessageData, 'from' | 'to' | 'subject' | 'html'>>) {
await this.client.messages.create(this.domain, { from, to, subject, html });
async validate(email: string, allowRoleAdress = false) {
// Email validation is only available in the Mailgun production environment.
if (this.appEnv === 'developement') {
return true;
}
try {
// API reference: https://documentation.mailgun.com/en/latest/api-email-validation.html#single-validation
const validation = await this.client.validate.get(email);
this.logger.log(validation);

return (
validation.result === 'deliverable' &&
validation.is_disposable_address === false &&
(allowRoleAdress || validation.is_role_address === false)
);
} catch (err) {
this.logger.error(err);

return false;
}
}

async send(params: SendParams) {
try {
await this.client.messages.create(this.domain, params);

return true;
} catch (err) {
this.logger.error(err);

return false;
}
}
}
8 changes: 4 additions & 4 deletions server/src/feedback/feedback-email/feedback-email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export class FeedbackEmailService {
async requested(giverEmail: string, receiverEmail: string, message: string, tokenId: string) {
const { subject, html } = await this.feedbackEmailBuilderService.requested(receiverEmail, message, tokenId);

this.emailService.send({
return this.emailService.send({
from: EMAIL_DEFAULT_FROM_FIELD,
to: this.getToField(giverEmail),
subject,
Expand All @@ -29,7 +29,7 @@ export class FeedbackEmailService {
async given(giverEmail: string, receiverEmail: string, feedbackId: string) {
const { subject, html } = await this.feedbackEmailBuilderService.given(giverEmail, feedbackId);

this.emailService.send({
return this.emailService.send({
from: EMAIL_DEFAULT_FROM_FIELD,
to: this.getToField(receiverEmail),
subject,
Expand All @@ -40,15 +40,15 @@ export class FeedbackEmailService {
async shared(managerEmail: string, managedEmail: string, feedbackId: string) {
const { subject, html } = await this.feedbackEmailBuilderService.shared(managedEmail, feedbackId);

this.emailService.send({
return this.emailService.send({
from: EMAIL_DEFAULT_FROM_FIELD,
to: this.getToField(managerEmail),
subject,
html,
});
}

private getToField(toField: string | string[]): string | string[] {
private getToField(toField: string): string {
return this.appEnv === 'production' ? toField : EMAIL_DEV_TO_FIELD;
}
}
28 changes: 20 additions & 8 deletions server/src/feedback/feedback.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { BadRequestException, Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
import { AuthGuard, AuthService } from '../core/auth';
import { EmailService } from '../core/email';
import { EmployeeDbService } from '../employee/employee-db';
import { FeedbackDbService, FeedbackRequestDraftType, TokenObject } from './feedback-db';
import { FeedbackEmailService } from './feedback-email/feedback-email.service';
Expand All @@ -15,6 +16,7 @@ import {
export class FeedbackController {
constructor(
private authService: AuthService,
private emailService: EmailService,
private feedbackDbService: FeedbackDbService,
private feedbackEmailService: FeedbackEmailService,
private employeeDbService: EmployeeDbService,
Expand All @@ -30,6 +32,10 @@ export class FeedbackController {
@UseGuards(AuthGuard)
@Post('request')
async request(@Body() { recipient: giverEmail, message, shared }: FeedbackRequestDto) {
const isGiverEmailValid = await this.emailService.validate(giverEmail);
if (!isGiverEmailValid) {
throw new BadRequestException('invalid_email');
}
const receiverEmail = this.authService.userEmail!;
if (receiverEmail === giverEmail) {
throw new BadRequestException();
Expand Down Expand Up @@ -103,6 +109,10 @@ export class FeedbackController {
@UseGuards(AuthGuard)
@Post('give')
async give(@Body() dto: GiveFeedbackDto) {
const isReceiverEmailValid = await this.emailService.validate(dto.receiverEmail);
if (!isReceiverEmailValid) {
throw new BadRequestException('invalid_email');
}
const giverEmail = this.authService.userEmail!;
if (giverEmail === dto.receiverEmail) {
throw new BadRequestException();
Expand Down Expand Up @@ -162,14 +172,16 @@ export class FeedbackController {
// ----- Shared tasks -----

private async sendEmailsOnGiven(giverEmail: string, receiverEmail: string, feedbackId: string, shared: boolean) {
await this.feedbackEmailService.given(giverEmail, receiverEmail, feedbackId);
if (!shared) {
return;
const success = await this.feedbackEmailService.given(giverEmail, receiverEmail, feedbackId);

if (shared) {
const managerEmail = (await this.employeeDbService.get(receiverEmail))?.managerEmail;
if (managerEmail) {
// Note: even if the email to the manager fails, the process is considered successful.
await this.feedbackEmailService.shared(managerEmail, receiverEmail, feedbackId);
}
}
const managerEmail = (await this.employeeDbService.get(receiverEmail))?.managerEmail;
if (!managerEmail) {
return;
}
await this.feedbackEmailService.shared(managerEmail, receiverEmail, feedbackId);

return success;
}
}
3 changes: 2 additions & 1 deletion server/src/feedback/feedback.module.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../core/auth';
import { EmailModule } from '../core/email';
import { EmployeeModule } from '../employee';
import { FeedbackDbModule } from './feedback-db';
import { FeedbackEmailModule } from './feedback-email';
import { FeedbackController } from './feedback.controller';

@Module({
imports: [AuthModule, FeedbackDbModule, FeedbackEmailModule, EmployeeModule],
imports: [AuthModule, EmailModule, FeedbackDbModule, FeedbackEmailModule, EmployeeModule],
controllers: [FeedbackController],
})
export class FeedbackModule {}

0 comments on commit efba1e5

Please sign in to comment.