diff --git a/server/config/email.ts b/server/config/email.ts index b55e8f993..081363661 100644 --- a/server/config/email.ts +++ b/server/config/email.ts @@ -35,7 +35,7 @@ async function createTestNodemailerTransport() { }); return transporter; } catch (error: any) { - console.error("Failed to create a testing account. " + error.message); + logger.error("E: Failed to create a testing account. " + error.message); } } interface MailContent { @@ -50,27 +50,34 @@ interface MailFrom { } /** - * Sends an e-mail to a single recipent or multiple recipents. + * Sends an e-mail to a single recipient or multiple recipients. * * @param {string|Array} mailAddresses A single email address of type string oder multiple addresses as an array of strings. * @param {object} content A object containing a "title" and a "text" property of type string. * @param {string} [replyTo] The replyTo address which will be added to the send e-mail. - * @returns Returns true if a mail was sucessfully delegated to the E-Mail Service Provider. + * @returns Returns true if a mail was successfully delegated to the E-Mail Service Provider. */ const sendMail = async ( mailAddresses: string | string[], - content: MailContent, + content: MailContent | undefined, replyTo?: string ) => { + if (!content) { + logger.error("E: email content is undefined"); + return; + } + if ( !content.title || !content.text || content.title.length == 0 || content.text.length == 0 - ) - throw new Error( - "content.title and content.text are not allowed to be empty" + ) { + logger.error( + "E: content.title and content.text are not allowed to be empty" ); + return; + } const message = createMessage( { diff --git a/server/constants/email-message-constants.js b/server/constants/email-message-constants.js index 54b40df34..b3c6e70d2 100644 --- a/server/constants/email-message-constants.js +++ b/server/constants/email-message-constants.js @@ -9,11 +9,11 @@ Du kannst direkt auf diese E-Mail antworten. module.exports.REGISTRATION_TITLE = "Deine Anmeldung bei XCCup.net"; -module.exports.REGISTRATION_TEXT = (firstName, activateLink) => +module.exports.REGISTRATION_TEXT = (firstName, activationLink) => `Hallo ${firstName}! Willkommen beim XCCup. Um dein Konto zu aktivieren klicke bitte auf den folgenden Link: -${activateLink} +${activationLink} Wir wünschen Dir allzeit gute Flüge und viel Spaß. diff --git a/server/controller/CacheManager.js b/server/controller/CacheManager.js index 0d8d9f7af..416bccac9 100644 --- a/server/controller/CacheManager.js +++ b/server/controller/CacheManager.js @@ -28,7 +28,7 @@ const deleteCache = (keysArrayToDelete) => { keysArrayToDelete.some((includeKey) => ck.includes(includeKey)) ); - logger.debug("Will delete these keys from cache: " + keysToDelete); + logger.debug("CM: Will delete these keys from cache: " + keysToDelete); return cache.del(keysToDelete); }; diff --git a/server/controller/UserController.js b/server/controller/UserController.js index 6e27ab9fc..ffa1a43cc 100644 --- a/server/controller/UserController.js +++ b/server/controller/UserController.js @@ -427,6 +427,12 @@ router.post( } ); +router.get("/testmail", async (req, res) => { + const user = await service.getById("cd1583d1-fb7f-4a93-b732-effd59e5c3ae"); + mailService.sendActivationMail(user); + res.sendStatus(OK); +}); + // @desc Edits a user // @route PUT /users/ // @access Only owner diff --git a/server/db/index.ts b/server/db/index.ts index f9da2ee4b..ab29d8a80 100644 --- a/server/db/index.ts +++ b/server/db/index.ts @@ -21,6 +21,7 @@ import { initSponsor } from "./models/Sponsor"; import { initTeam } from "./models/Team"; import { initToken } from "./models/Token"; import { initUser } from "./models/User"; +import { initMessage } from "./models/Message"; import { Models } from "../types/Models"; // Config @@ -63,6 +64,7 @@ const models: Models = { Team: initTeam(sequelize), Token: initToken(sequelize), User: initUser(sequelize), + Message: initMessage(sequelize), }; const db = { diff --git a/server/db/models/Message.ts b/server/db/models/Message.ts new file mode 100644 index 000000000..b0eac505a --- /dev/null +++ b/server/db/models/Message.ts @@ -0,0 +1,53 @@ +import { Sequelize, Model, DataTypes } from "sequelize"; +import { Models } from "../../types/Models"; + +export const MessageTypes = { + EMAIL: "email", + OTHER: "other", +} as const; + +export type MessageTypesType = typeof MessageTypes[keyof typeof MessageTypes]; + +export const EmailMessagePosition = { + TITLE: "title", + TEXT: "text", +} as const; + +export type EmailMessagePositionType = + typeof EmailMessagePosition[keyof typeof EmailMessagePosition]; + +interface MessageAttributes { + name: string; + content: string; + typeOfMessage: MessageTypesType; + position: EmailMessagePositionType; +} + +export interface MessageInstance + extends Model, + MessageAttributes { + createdAt?: Date; + updatedAt?: Date; +} + +export function initMessage(sequelize: Sequelize): Models["Message"] { + const MessageTemplate = sequelize.define("Message", { + name: { + type: DataTypes.STRING, + allowNull: false, + }, + content: { + type: DataTypes.STRING(512), + allowNull: false, + }, + typeOfMessage: { + type: DataTypes.STRING(64), + defaultValue: "other", + }, + position: { + type: DataTypes.STRING(64), + }, + }); + + return MessageTemplate; +} diff --git a/server/service/MailService.ts b/server/service/MailService.ts index 534a9e837..3af582fe2 100644 --- a/server/service/MailService.ts +++ b/server/service/MailService.ts @@ -45,6 +45,7 @@ import type { } from "../db/models/Flight"; import type { Comment } from "../types/Comment"; import { FlightCommentInstance } from "../db/models/FlightComment"; +import { getMessageByNameForEmail } from "./MessageService"; const clientUrl = config.get("clientUrl"); const userActivateLink = config.get("clientActivateProfil"); @@ -101,10 +102,10 @@ const service = { const activationLink = `${clientUrl}${userActivateLink}?userId=${user.id}&token=${user.token}`; - const content = { - title: REGISTRATION_TITLE, - text: REGISTRATION_TEXT(user.firstName, activationLink), - }; + const content = await getMessageByNameForEmail("REGISTRATION", { + firstName: user.firstName, + activationLink, + }); return sendMail(user.email, content); }, diff --git a/server/service/MessageService.ts b/server/service/MessageService.ts new file mode 100644 index 000000000..4ad39e9b7 --- /dev/null +++ b/server/service/MessageService.ts @@ -0,0 +1,117 @@ +import logger from "../config/logger"; +import { cache } from "../controller/CacheManager"; +import db from "../db"; +import { + EmailMessagePosition, + EmailMessagePositionType, + MessageTypes, + MessageTypesType, +} from "../db/models/Message"; + +const PLACEHOLDER_PREFIX = "$"; +const CACHE_PREFIX = "msg_template_"; + +interface PlaceholderReplacements { + [name: string]: string | undefined; +} + +interface EmailMessage { + title: string; + text: string; +} + +export async function getMessageByNameForEmail( + name: string, + placeholderReplacementsText?: PlaceholderReplacements, + placeholderReplacementsTitle?: PlaceholderReplacements +) { + const CACHE_KEY = CACHE_PREFIX + name; + + let message = cache.get(CACHE_KEY) as EmailMessage | null; + + if (!message) { + try { + message = await retrieveEmailParts(name); + } catch (error) { + logger.error("MS: " + error); + return; + } + + cache.set(CACHE_KEY, message, 0); + } + + console.log("JSON: ", JSON.stringify(message, null, 2)); + + try { + message.text = replacePlaceholder( + message.text, + placeholderReplacementsText + ); + message.title = replacePlaceholder( + message.title, + placeholderReplacementsTitle + ); + } catch (error) { + logger.error("MS: " + error); + return; + } + + return message; +} + +async function retrieveEmailParts(name: string) { + const [title, text] = await Promise.all([ + retrieveMessageFromDb(name, MessageTypes.EMAIL, EmailMessagePosition.TITLE), + retrieveMessageFromDb(name, MessageTypes.EMAIL, EmailMessagePosition.TEXT), + ]); + + if (!title) { + throw `No title for message with name ${name} found`; + } + + if (!text) { + throw `No text for message with name ${name} found`; + } + + return { title, text }; +} + +async function retrieveMessageFromDb( + name: string, + typeOfMessage?: MessageTypesType, + position?: EmailMessagePositionType +) { + const dbEntry = await db.Message.findOne({ + where: { name, typeOfMessage, position }, + raw: true, + attributes: ["content"], + }); + + return dbEntry?.content; +} + +/** + * Replaces the placeholder in the message template with a specified value. + * The name of the placeholder has to match the name of the property in the parameters object. + */ +function replacePlaceholder( + messageWithPlaceholders: string, + placeholderReplacements?: PlaceholderReplacements +) { + if (!placeholderReplacements) { + logger.debug("MS: No replacements specified"); + return messageWithPlaceholders; + } + + for (const [key, value] of Object.entries(placeholderReplacements)) { + if (!value) throw `Value of ${key} is undefined`; + console.log("K: " + key); + console.log("V: " + value); + messageWithPlaceholders = messageWithPlaceholders.replaceAll( + PLACEHOLDER_PREFIX + key, + value + ); + console.log("MWP: " + messageWithPlaceholders); + } + return messageWithPlaceholders; +} diff --git a/server/test/DbTestDataLoader.js b/server/test/DbTestDataLoader.js index 4ffc5e6b4..e9b5c7e62 100644 --- a/server/test/DbTestDataLoader.js +++ b/server/test/DbTestDataLoader.js @@ -7,6 +7,7 @@ const FlyingSite = require("../db")["FlyingSite"]; const FlightFixes = require("../db")["FlightFixes"]; const FlightPhoto = require("../db")["FlightPhoto"]; const SeasonDetail = require("../db")["SeasonDetail"]; +const Message = require("../db")["Message"]; const Airspace = require("../db")["Airspace"]; const News = require("../db")["News"]; const Sponsor = require("../db")["Sponsor"]; @@ -48,6 +49,7 @@ const dbTestData = { [Sponsor, require("./testdatasets/sponsors.json")], [Brand, require("./testdatasets/brands.json")], [Logo, require("./testdatasets/logos.json")], + [Message, require("./testdatasets/messages.json")], ]; // Test data with personal data if (config.get("serverImportTestData")) { diff --git a/server/test/testdatasets/messages.json b/server/test/testdatasets/messages.json new file mode 100644 index 000000000..b9d87b1d1 --- /dev/null +++ b/server/test/testdatasets/messages.json @@ -0,0 +1,14 @@ +[ + { + "name": "REGISTRATION", + "content": "Deine Anmeldung bei XCCup.net", + "typeOfMessage": "email", + "position": "title" + }, + { + "name": "REGISTRATION", + "content": "Hallo $firstName!\nWillkommen beim XCCup.\n\nUm dein Konto zu aktivieren klicke bitte auf den folgenden Link:\n$activationLink\n\nWir wünschen Dir allzeit gute Flüge und viel Spaß.\n\nDein XCCup Team", + "typeOfMessage": "email", + "position": "text" + } +] diff --git a/server/tsconfig.json b/server/tsconfig.json index cba288b6e..a8f98e304 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -4,6 +4,7 @@ "module": "commonjs", "sourceMap": true, "outDir": "./dist", + "lib": ["ES2021"], "strict": true, "moduleResolution": "node", "esModuleInterop": true, diff --git a/server/types/Models.ts b/server/types/Models.ts index 4259f1cc6..b96ea61e6 100644 --- a/server/types/Models.ts +++ b/server/types/Models.ts @@ -16,6 +16,7 @@ import { SponsorInstance } from "../db/models/Sponsor"; import { TeamInstance } from "../db/models/Team"; import { TokenInstance } from "../db/models/Token"; import { UserInstance } from "../db/models/User"; +import { MessageInstance } from "../db/models/Message"; interface extendedModel> extends ModelStatic { associate?: (props: Models) => void; @@ -39,4 +40,5 @@ export interface Models { Team: extendedModel; Token: extendedModel; User: extendedModel; + Message: extendedModel; }