Skip to content

Commit

Permalink
feat: GEWIS email template (#236)
Browse files Browse the repository at this point in the history
* feat: all emails in GEWIS template

* chore: reorganize mail messages

* wip: add separate class to apply HTML body to GEWIS template

* chore: fix mails to adapt to new function specifications

* fix: Mailer test cases

* chore: remove old signatures

* chore: add all mail messages to index
  • Loading branch information
Yoronex authored Aug 1, 2024
1 parent 3f50086 commit 7ad5356
Show file tree
Hide file tree
Showing 30 changed files with 799 additions and 488 deletions.
4 changes: 2 additions & 2 deletions src/controller/authentication-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import AuthenticationResetTokenRequest from './request/authentication-reset-toke
import AuthenticationEanRequest from './request/authentication-ean-request';
import EanAuthenticator from '../entity/authenticator/ean-authenticator';
import Mailer from '../mailer';
import PasswordReset from '../mailer/templates/password-reset';
import PasswordReset from '../mailer/messages/password-reset';
import AuthenticationNfcRequest from './request/authentication-nfc-request';
import NfcAuthenticator from '../entity/authenticator/nfc-authenticator';
import AuthenticationKeyRequest from './request/authentication-key-request';
Expand Down Expand Up @@ -401,7 +401,7 @@ export default class AuthenticationController extends BaseController {
}

const resetTokenInfo = await AuthenticationService.createResetToken(user);
Mailer.getInstance().send(user, new PasswordReset({ email: user.email, name: user.firstName, resetTokenInfo }))
Mailer.getInstance().send(user, new PasswordReset({ email: user.email, resetTokenInfo }))
.then()
.catch((error) => this.logger.error(error));
// send email with link.
Expand Down
2 changes: 1 addition & 1 deletion src/controller/test-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import BaseController, { BaseControllerOptions } from './base-controller';
import Policy from './policy';
import { RequestWithToken } from '../middleware/token-middleware';
import Mailer from '../mailer';
import HelloWorld from '../mailer/templates/hello-world';
import HelloWorld from '../mailer/messages/hello-world';

export default class TestController extends BaseController {
/**
Expand Down
5 changes: 2 additions & 3 deletions src/gewis/service/gewisdb-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ import { webResponseToUpdate } from '../helpers/gewis-helper';
import UserService from '../../service/user-service';
import { UserResponse } from '../../controller/response/user-response';
import Mailer from '../../mailer';
import MembershipExpiryNotification from '../../mailer/templates/membership-expiry-notification';
import MembershipExpiryNotification from '../../mailer/messages/membership-expiry-notification';
import DineroTransformer from '../../entity/transformer/dinero-transformer';
import { Language } from '../../mailer/templates/mail-template';
import { Language } from '../../mailer/mail-message';
import BalanceService from '../../service/balance-service';

const GEWISDB_API_URL = process.env.GEWISDB_API_URL;
Expand Down Expand Up @@ -124,7 +124,6 @@ export default class GewisDBService {
const isZero = currentBalance.amount.amount === 0;
const user = await UserService.closeUser(gewisUser.user.id, isZero);
Mailer.getInstance().send(gewisUser.user, new MembershipExpiryNotification({
name: user.firstName,
balance: DineroTransformer.Instance.from(currentBalance.amount.amount),
}), Language.ENGLISH, { bcc: process.env.FINANCIAL_RESPONSIBLE }).catch((e) => getLogger('User').error(e));
return user;
Expand Down
2 changes: 1 addition & 1 deletion src/mailer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@


export { default } from './mailer';
export * from './templates';
export * from './messages';
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,20 @@
*/

import Mail from 'nodemailer/lib/mailer';
import MailContent from './mail-content';
import MailContentBuilder from './messages/mail-content-builder';
import MailBodyGenerator from './template/mail-body-generator';
import User from '../entity/user/user';

export enum Language {
DUTCH = 'dutch',
ENGLISH = 'english',
DUTCH = 'nl-NL',
ENGLISH = 'en-US',
}

export type MailLanguageMap<T> = {
[key in Language]: MailContent<T>;
[key in Language]: MailContentBuilder<T>;
};

export default class MailTemplate<T> {
export default class MailMessage<T> {
protected baseMailOptions: Mail.Options = {
from: process.env.SMTP_FROM,
};
Expand All @@ -45,12 +47,15 @@ export default class MailTemplate<T> {
/**
* Get the base options
*/
getOptions(language: Language): Mail.Options {
getOptions(to: User, language: Language): Mail.Options {
if (this.mailContents[language] === undefined) throw new Error(`Unknown language: ${language}`);
const { text, html, subject } = this.mailContents[language].getContent(this.contentOptions);

const { text, html, subject } = new MailBodyGenerator(language)
.getContents(this.mailContents, this.contentOptions, to);

return {
...this.baseMailOptions,
to: to.email,
text,
html,
subject,
Expand Down
6 changes: 3 additions & 3 deletions src/mailer/mailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { Transporter } from 'nodemailer';
import log4js, { Logger } from 'log4js';
import createSMTPTransporter from './transporter';
import User from '../entity/user/user';
import MailTemplate, { Language } from './templates/mail-template';
import MailMessage, { Language } from './mail-message';
import Mail from 'nodemailer/lib/mailer';

export default class Mailer {
Expand All @@ -43,12 +43,12 @@ export default class Mailer {
}

async send<T>(
to: User, template: MailTemplate<T>, language: Language = Language.ENGLISH, extraOptions?: Mail.Options,
to: User, template: MailMessage<T>, language: Language = Language.ENGLISH, extraOptions?: Mail.Options,
) {
this.logger.trace('Send email', template.constructor.name, 'to user');
try {
await this.transporter.sendMail({
...template.getOptions(language),
...template.getOptions(to, language),
to: to.email,
...extraOptions,
});
Expand Down
47 changes: 47 additions & 0 deletions src/mailer/messages/changed-pin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* SudoSOS back-end API service.
* Copyright (C) 2024 Study association GEWIS
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

import MailMessage, { Language, MailLanguageMap } from '../mail-message';
import MailContentBuilder from './mail-content-builder';

interface ChangedPinOptions {}

const changedPinDutch = new MailContentBuilder<ChangedPinOptions>({
getHTML: '<p>De pincode van je account in SudoSOS is zojuist veranderd.</p>',
getSubject: 'Je pincode is veranderd',
getText: 'De pincode van je account in SudoSOS is zojuist veranderd.',
getTitle: 'PIN gewijzigd',
});

const changedPinEnglish = new MailContentBuilder<ChangedPinOptions>({
getSubject: 'Your PIN has changed',
getText: 'The PIN number of your account in SudoSOS has just been changed.',
getHTML: '<p>The PIN number of your account in SudoSOS has just been changed.</p>',
getTitle: 'PIN changed',
});

const mailContents: MailLanguageMap<ChangedPinOptions> = {
[Language.DUTCH]: changedPinDutch,
[Language.ENGLISH]: changedPinEnglish,
};

export default class ChangedPin extends MailMessage<ChangedPinOptions> {
public constructor(options: ChangedPinOptions) {
super(options, mailContents);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,54 +16,50 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

import MailTemplate, { Language, MailLanguageMap } from './mail-template';
import MailContent from './mail-content';
import MailMessage, { Language, MailLanguageMap } from '../mail-message';
import MailContentBuilder from './mail-content-builder';

export interface ForgotEventPlanningOptions {
name: string;
eventName: string;
}

const forgotEventPlanningEnglish = new MailContent<ForgotEventPlanningOptions>({
getHTML: (context) => `<p>Dear ${context.name},</p>
<p>What is this? Have you not yet filled in the borrel planning for ${context.eventName}? Shame on you!<br>
const forgotEventPlanningEnglish = new MailContentBuilder<ForgotEventPlanningOptions>({
getHTML: (context) => `<p>What is this? Have you not yet filled in the borrel planning for ${context.eventName}? Shame on you!<br>
Go quickly to SudoSOS to fix your mistakes!</p>
<p>Hugs,<br>
The SudoSOS borrel planning robot</p>`,
getText: (context) => `Dear ${context.name},
What is this? Have you not yet filled in the borrel planning for ${context.eventName}? Shame on you!
getText: (context) => `What is this? Have you not yet filled in the borrel planning for ${context.eventName}? Shame on you!
Go quickly to SudoSOS to fix your mistakes!
Hugs,
The SudoSOS borrel planning robot`,
getSubject: ({ eventName }) => `Borrel planning ${eventName}`,
getTitle: 'Planningnotificatie',
});

const forgotEventPlanningDutch = new MailContent<ForgotEventPlanningOptions>({
getHTML: (context) => `<p>Beste ${context.name},</p>
<p>Wat is dit nou? Heb je het borrelrooster voor ${context.eventName} nog niet ingevuld? Foei!<br>
const forgotEventPlanningDutch = new MailContentBuilder<ForgotEventPlanningOptions>({
getHTML: (context) => `<p>Wat is dit nou? Heb je het borrelrooster voor ${context.eventName} nog niet ingevuld? Foei!<br>
Ga snel naar SudoSOS om je fouten recht te zetten!</p>
<p>Kusjes,<br>
De SudoSOS borrelrooster invulrobot</p>`,
getText: (context) => `Beste ${context.name},
Wat is dit nou? Heb je het borrelrooster voor ${context.eventName} nog niet ingevuld? Foei!
getText: (context) => `Wat is dit nou? Heb je het borrelrooster voor ${context.eventName} nog niet ingevuld? Foei!
Ga snel naar SudoSOS om je fouten recht te zetten!
Kusjes,
De SudoSOS borrelrooster invulrobot`,
getSubject: ({ eventName }) => `Borrelrooster ${eventName}`,
getTitle: 'Planning notification',
});

const mailContents: MailLanguageMap<ForgotEventPlanningOptions> = {
[Language.DUTCH]: forgotEventPlanningDutch,
[Language.ENGLISH]: forgotEventPlanningEnglish,
};

export default class ForgotEventPlanning extends MailTemplate<ForgotEventPlanningOptions> {
export default class ForgotEventPlanning extends MailMessage<ForgotEventPlanningOptions> {
public constructor(options: ForgotEventPlanningOptions) {
super(options, mailContents);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,31 +16,33 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

import MailTemplate, { Language, MailLanguageMap } from './mail-template';
import MailContent from './mail-content';
import MailMessage, { Language, MailLanguageMap } from '../mail-message';
import MailContentBuilder from './mail-content-builder';

export interface HelloWorldOptions {
name: string;
}

const helloWorldEnglish = new MailContent<HelloWorldOptions>({
const helloWorldEnglish = new MailContentBuilder<HelloWorldOptions>({
getHTML: (context) => `<p>Hello world, ${context.name}!</p>`,
getText: (context) => `Hello world, ${context.name}!`,
getSubject: () => 'Hello world!',
getSubject: 'Hello world!',
getTitle: 'Hello world!',
});

const helloWorldDutch = new MailContent<HelloWorldOptions>({
const helloWorldDutch = new MailContentBuilder<HelloWorldOptions>({
getHTML: (context) => `<p>Hallo wereld, ${context.name}!</p>`,
getText: (context) => `Hallo wereld, ${context.name}!`,
getSubject: () => 'Hallo wereld!',
getSubject: 'Hallo wereld!',
getTitle: 'Hallo wereld!',
});

const mailContents: MailLanguageMap<HelloWorldOptions> = {
[Language.DUTCH]: helloWorldDutch,
[Language.ENGLISH]: helloWorldEnglish,
};

export default class HelloWorld extends MailTemplate<HelloWorldOptions> {
export default class HelloWorld extends MailMessage<HelloWorldOptions> {
public constructor(options: HelloWorldOptions) {
super(options, mailContents);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,13 @@
*/


export * from './changed-pin';
export * from './forgot-event-planning';
export * from './hello-world';
export * from './membership-expiry-notification';
export * from './password-reset';
export * from './user-debt-notification';
export * from './user-got-fined';
export * from './user-will-get-fined';
export * from './welcome-to-sudosos';
export * from './welcome-with-reset';
75 changes: 75 additions & 0 deletions src/mailer/messages/mail-content-builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* SudoSOS back-end API service.
* Copyright (C) 2024 Study association GEWIS
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

type MailContentFunction<T> = string | ((context: T) => string);

export interface MailContentFunctions<T> {
getHTML: MailContentFunction<T>;
getText: MailContentFunction<T>;
getSubject: MailContentFunction<T>;

/**
* Short title used as the header in a templated email
*/
getTitle: MailContentFunction<T>;

/**
* Short, bottom text explaining in the templated email why
* the received got this email.
*/
getReasonForEmail?: MailContentFunction<T>;
}

export interface MailContent {
text: string;
html: string;
subject: string;
/**
* Short title used as the header in a templated email
*/
title: string;
/**
* Short, bottom text explaining in the templated email why
* the received got this email.
*/
reason?: string;
}

export default class MailContentBuilder<T> {
constructor(mail: MailContentFunctions<T>) {
this.mail = mail;
}

protected mail: MailContentFunctions<T>;

public getContent(context: T): MailContent {
const text = typeof this.mail.getText === 'string' ? this.mail.getText : this.mail.getText(context);
const html = typeof this.mail.getHTML === 'string' ? this.mail.getHTML : this.mail.getHTML(context);
const subject = typeof this.mail.getSubject === 'string' ? this.mail.getSubject : this.mail.getSubject(context);
const title = typeof this.mail.getTitle === 'string' ? this.mail.getTitle : this.mail.getTitle(context);

let reason: string | undefined;
if (this.mail.getReasonForEmail !== undefined && typeof this.mail.getReasonForEmail === 'string') {
reason = this.mail.getReasonForEmail;
} else if (this.mail.getReasonForEmail !== undefined && typeof this.mail.getReasonForEmail !== 'string') {
reason = this.mail.getReasonForEmail(context);
}

return { text, html, subject, title, reason };
}
}
Loading

0 comments on commit 7ad5356

Please sign in to comment.