From d26473c57b3e84f441688cfe26469cef1a3f9216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aaron=20D=C3=B6tsch?= <aaron@fsmpi.rwth-aachen.de> Date: Sat, 5 Aug 2023 18:21:07 +0200 Subject: [PATCH] Implement notification email handler --- .env.example | 9 +++++ docker-compose.yml.example | 9 +++++ package-lock.json | 14 ++++++++ package.json | 1 + src/lib/notifications/channelTypes.js | 4 +-- src/lib/notifications/emailHandler.ts | 8 ----- src/lib/server/mail.js | 34 +++++++++++++++++++ src/lib/server/notifications/emailHandler.ts | 16 +++++++++ .../{ => server}/notifications/formatter.ts | 2 +- src/lib/{ => server}/notifications/handler.ts | 4 +-- .../notifications/webhookHandler.ts | 2 +- src/routes/admin/api/users.js | 8 ++--- src/routes/admin/user/[id]/+page.server.js | 6 +++- src/routes/admin/user/[id]/+page.svelte | 8 ++--- .../user/[id]/NotificationChannelItem.svelte | 7 ++-- .../user/[id]/NotificationChannelList.svelte | 7 ++-- src/routes/api/users.js | 4 +-- 17 files changed, 111 insertions(+), 32 deletions(-) delete mode 100644 src/lib/notifications/emailHandler.ts create mode 100644 src/lib/server/mail.js create mode 100644 src/lib/server/notifications/emailHandler.ts rename src/lib/{ => server}/notifications/formatter.ts (98%) rename src/lib/{ => server}/notifications/handler.ts (92%) rename src/lib/{ => server}/notifications/webhookHandler.ts (90%) diff --git a/.env.example b/.env.example index 45274f3..0a9d997 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,12 @@ DB_USERNAME="postgres" DB_PASSWORD="postgres" DB_NAME="postgres" BODY_SIZE_LIMIT=10485760 # 10MiB +MAIL_HOST="domain.tld" +MAIL_PORT=465 +MAIL_USER="noreply@domain.tld" +MAIL_PASSWORD="secure-password" +MAIL_FROM_ADDRESS="noreply@domain.tld" +MAIL_FROM_NAME="Getränkekasse" +MAIL_TLS=true +MAIL_AUTH_METHOD="plain" +MAIL_REPLY_TO="getraenke@domain.tld" # optional, sets the reply-to header, useful if you don't read the mailbox of MAIL_USER diff --git a/docker-compose.yml.example b/docker-compose.yml.example index dc70a89..b80af60 100644 --- a/docker-compose.yml.example +++ b/docker-compose.yml.example @@ -29,6 +29,15 @@ services: environment: BODY_SIZE_LIMIT: ${BODY_SIZE_LIMIT} DATABASE_URL: postgresql://${DB_USERNAME}:${DB_PASSWORD}@postgres:5432/${DB_NAME} + MAIL_HOST: ${MAIL_HOST} + MAIL_PORT: ${MAIL_PORT} + MAIL_USER: ${MAIL_USER} + MAIL_PASSWORD: ${MAIL_PASSWORD} + MAIL_FROM_NAME: ${MAIL_FROM_NAME} + MAIL_FROM_ADDRESS: ${MAIL_FROM_ADDRESS} + MAIL_TLS: ${MAIL_TLS} + MAIL_AUTH_METHOD: ${MAIL_AUTH_METHOD} + MAIL_REPLY_TO: ${MAIL_REPLY_TO} volumes: - ./article-images:/app/article-images diff --git a/package-lock.json b/package-lock.json index 1dce469..b9c1726 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@sveltejs/adapter-node": "^1.2.1", "cookie": "^0.5.0", "nanoid": "^4.0.1", + "nodemailer": "^6.9.4", "prisma": "^4.11.0" }, "devDependencies": { @@ -1666,6 +1667,14 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/nodemailer": { + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.4.tgz", + "integrity": "sha512-CXjQvrQZV4+6X5wP6ZIgdehJamI63MFoYFGGPtHudWym9qaEHDNdPzaj5bfMCvxG1vhAileSWW90q7nL0N36mA==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3426,6 +3435,11 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "nodemailer": { + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.4.tgz", + "integrity": "sha512-CXjQvrQZV4+6X5wP6ZIgdehJamI63MFoYFGGPtHudWym9qaEHDNdPzaj5bfMCvxG1vhAileSWW90q7nL0N36mA==" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/package.json b/package.json index a6cb806..25ee4fb 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@sveltejs/adapter-node": "^1.2.1", "cookie": "^0.5.0", "nanoid": "^4.0.1", + "nodemailer": "^6.9.4", "prisma": "^4.11.0" } } diff --git a/src/lib/notifications/channelTypes.js b/src/lib/notifications/channelTypes.js index ffbf120..766048e 100644 --- a/src/lib/notifications/channelTypes.js +++ b/src/lib/notifications/channelTypes.js @@ -1,5 +1,5 @@ -import { handleWebhook } from "./webhookHandler"; -import { handleEmail } from "./emailHandler"; +import { handleWebhook } from "../server/notifications/webhookHandler"; +import { handleEmail } from "../server/notifications/emailHandler"; /** * @type {{ diff --git a/src/lib/notifications/emailHandler.ts b/src/lib/notifications/emailHandler.ts deleted file mode 100644 index e5e6fc7..0000000 --- a/src/lib/notifications/emailHandler.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { formatMessage, getMessageTitle } from "./formatter"; -import type { HandlerReturnType, NotificationData, NotificationType, PossibleNotificationType } from "./types"; - -export async function handleEmail<T extends PossibleNotificationType>(recipient: string, type: NotificationType<T>, data: NotificationData<T>): Promise<HandlerReturnType> { - const emailTitle = getMessageTitle(type); - const emailBody = formatMessage(type, data); - return Promise.resolve({status: "success"}); // TODO actually send email -}; diff --git a/src/lib/server/mail.js b/src/lib/server/mail.js new file mode 100644 index 0000000..a1af296 --- /dev/null +++ b/src/lib/server/mail.js @@ -0,0 +1,34 @@ +import nodemailer from "nodemailer"; + +const transporter = nodemailer.createTransport({ + host: process.env.MAIL_HOST, + port: parseInt(process.env.MAIL_PORT), + auth: { + user: process.env.MAIL_USER, + pass: process.env.MAIL_PASSWORD + }, + secure: process.env.MAIL_TLS === "true", + authMethod: process.env.MAIL_AUTH_METHOD +}); + +/** + * @param {Object} mailOptions + * @param {string} mailOptions.to The email address to send the email to + * @param {string} mailOptions.subject The subject of the email + * @param {string} mailOptions.text The content of the email + * @param {boolean} [mailOptions.html=false] `true` if `text` is HTML, `false` if `text` is plain text + * @returns + */ +export async function sendMail({to, subject, text, html=false}) { + const content = html ? {html: text} : {text}; + return transporter.sendMail({ + subject, + to, + from: { + address: process.env.MAIL_FROM_ADDRESS, + name: process.env.MAIL_FROM_NAME + }, + replyTo: process.env.MAIL_REPLY_TO, + ...content + }); +} diff --git a/src/lib/server/notifications/emailHandler.ts b/src/lib/server/notifications/emailHandler.ts new file mode 100644 index 0000000..b06fc3c --- /dev/null +++ b/src/lib/server/notifications/emailHandler.ts @@ -0,0 +1,16 @@ +import { sendMail } from "../mail"; +import { formatMessage, getMessageTitle } from "./formatter"; +import type { HandlerReturnType, NotificationData, NotificationType, PossibleNotificationType } from "../../notifications/types"; + +export async function handleEmail<T extends PossibleNotificationType>(recipient: string, type: NotificationType<T>, data: NotificationData<T>): Promise<HandlerReturnType> { + return sendMail({ + to: recipient, + subject: getMessageTitle(type), + text: formatMessage(type, data) + }).then(res=>{ + if(res.accepted.length > 0) return { status: "success" }; + else if(res.pending.length > 0) throw "Email is pending"; + else if(res.rejected.length > 0) throw "Email was rejected"; + else throw "Unknown error"; + }); +}; diff --git a/src/lib/notifications/formatter.ts b/src/lib/server/notifications/formatter.ts similarity index 98% rename from src/lib/notifications/formatter.ts rename to src/lib/server/notifications/formatter.ts index 1bb7a2d..f8a7425 100644 --- a/src/lib/notifications/formatter.ts +++ b/src/lib/server/notifications/formatter.ts @@ -1,4 +1,4 @@ -import type { NotificationData, NotificationType, PossibleNotificationType } from "./types"; +import type { NotificationData, NotificationType, PossibleNotificationType } from "../../notifications/types"; export function getMessageTitle<T extends PossibleNotificationType>(type: NotificationType<T>): string { switch(type.name){ diff --git a/src/lib/notifications/handler.ts b/src/lib/server/notifications/handler.ts similarity index 92% rename from src/lib/notifications/handler.ts rename to src/lib/server/notifications/handler.ts index 3ee3f4c..0ca34a4 100644 --- a/src/lib/notifications/handler.ts +++ b/src/lib/server/notifications/handler.ts @@ -1,6 +1,6 @@ import { db } from "$lib/server/database"; -import { ChannelType } from "./channelTypes"; -import type { HandlerReturnType, NotificationChannel, NotificationData, NotificationType, PossibleNotificationType } from "./types"; +import { ChannelType } from "../../notifications/channelTypes"; +import type { HandlerReturnType, NotificationChannel, NotificationData, NotificationType, PossibleNotificationType } from "../../notifications/types"; export async function sendNotification<T extends PossibleNotificationType>(user: { id: number, notificationChannels: NotificationChannel[] | undefined } | number, type: NotificationType<T>, data: NotificationData<T>): Promise<HandlerReturnType[]>{ let notificationChannels: NotificationChannel[]; diff --git a/src/lib/notifications/webhookHandler.ts b/src/lib/server/notifications/webhookHandler.ts similarity index 90% rename from src/lib/notifications/webhookHandler.ts rename to src/lib/server/notifications/webhookHandler.ts index 9a5704a..30b234e 100644 --- a/src/lib/notifications/webhookHandler.ts +++ b/src/lib/server/notifications/webhookHandler.ts @@ -1,5 +1,5 @@ import { formatMessage } from "./formatter"; -import type { HandlerReturnType, NotificationData, NotificationType, PossibleNotificationType } from "./types"; +import type { HandlerReturnType, NotificationData, NotificationType, PossibleNotificationType } from "../../notifications/types"; export async function handleWebhook<T extends PossibleNotificationType>(recipient: string, type: NotificationType<T>, data: NotificationData<T>): Promise<HandlerReturnType> { const body = JSON.stringify(Object.assign({}, data, {type: type.name, content: formatMessage(type, data)})); diff --git a/src/routes/admin/api/users.js b/src/routes/admin/api/users.js index d8e27f6..fdd1a72 100644 --- a/src/routes/admin/api/users.js +++ b/src/routes/admin/api/users.js @@ -22,12 +22,12 @@ export async function getUsers(){ } export async function getUser(id, includeCards=false, includeNotificationChannels=false){ - let include = includeCards || includeNotificationChannels ? {} : undefined; - if(includeCards) include.cards = true; - if(includeNotificationChannels) include.notificationChannels = true; return db.user.findUnique({ where: { id }, - include + include: { + notificationChannels: includeNotificationChannels, + cards: includeCards + } }); } diff --git a/src/routes/admin/user/[id]/+page.server.js b/src/routes/admin/user/[id]/+page.server.js index c7fb2b4..654a367 100644 --- a/src/routes/admin/user/[id]/+page.server.js +++ b/src/routes/admin/user/[id]/+page.server.js @@ -7,7 +7,11 @@ import { createNotificationChannel, updateNotificationChannel } from "../../api/ export const load = async ({ params }) => { const { id } = params; const user = await getUser(parseInt(id), true, true); - return { user }; + const channelTypes = {}; + for(const [name, {key}] of Object.entries(ChannelType)){ + channelTypes[name] = key; + } + return { user, channelTypes }; } export const actions = { diff --git a/src/routes/admin/user/[id]/+page.svelte b/src/routes/admin/user/[id]/+page.svelte index 869bd2c..aecaf5a 100644 --- a/src/routes/admin/user/[id]/+page.svelte +++ b/src/routes/admin/user/[id]/+page.svelte @@ -1,7 +1,7 @@ <script> -import CardList from "./CardList.svelte"; -import { enhance } from "$app/forms"; -import { addMessage, MessageType } from "$lib/messages"; + import CardList from "./CardList.svelte"; + import { enhance } from "$app/forms"; + import { addMessage, MessageType } from "$lib/messages"; import NotificationChannelList from "./NotificationChannelList.svelte"; export let data; @@ -40,4 +40,4 @@ import { addMessage, MessageType } from "$lib/messages"; </form> <h2>Benachrichtigungen</h2> -<NotificationChannelList bind:notificationChannels /> +<NotificationChannelList bind:notificationChannels channelTypes={data.channelTypes} /> diff --git a/src/routes/admin/user/[id]/NotificationChannelItem.svelte b/src/routes/admin/user/[id]/NotificationChannelItem.svelte index 6b9dc20..04fdd9f 100644 --- a/src/routes/admin/user/[id]/NotificationChannelItem.svelte +++ b/src/routes/admin/user/[id]/NotificationChannelItem.svelte @@ -1,16 +1,15 @@ <script> - import { ChannelType } from "$lib/notifications/channelTypes"; import { NotificationType } from "$lib/notifications/notificationTypes"; import BitfieldSelector from "../../../../components/BitfieldSelector.svelte"; - export let id, channelType, notificationTypes, recipient, edit, deleteChannel; + export let id, channelType, notificationTypes, recipient, edit, deleteChannel, channelTypes; </script> {#if edit} <tr> <td> <select name="channelType" form="notif-form{id}" required> - {#each Object.entries(ChannelType) as [name, {key}]} + {#each Object.entries(channelTypes) as [name, key]} <option value={key} selected={key==channelType}>{name}</option> {/each} </select> @@ -21,7 +20,7 @@ </tr> {:else} <tr> - <td>{Object.entries(ChannelType).find(([,type])=>type.key==channelType)?.[0]}</td> + <td>{Object.entries(channelTypes).find(([,type])=>type==channelType)?.[0]}</td> <td><span title={Object.values(NotificationType).filter(t=>t.key¬ificationTypes).map(t=>t.name).join(", ")}>{notificationTypes}</span></td> <td>{recipient}</td> <td><button type="button" on:click={() => edit = true}>Bearbeiten</button><button on:click={deleteChannel}>Löschen</button></td> diff --git a/src/routes/admin/user/[id]/NotificationChannelList.svelte b/src/routes/admin/user/[id]/NotificationChannelList.svelte index cce8af4..be346c1 100644 --- a/src/routes/admin/user/[id]/NotificationChannelList.svelte +++ b/src/routes/admin/user/[id]/NotificationChannelList.svelte @@ -1,7 +1,6 @@ <script> import { enhance } from "$app/forms"; import { MessageType, addMessage } from "$lib/messages"; - import { ChannelType } from "$lib/notifications/channelTypes"; import { NotificationType } from "$lib/notifications/notificationTypes"; import BitfieldSelector from "../../../../components/BitfieldSelector.svelte"; import NotificationChannelItem from "./NotificationChannelItem.svelte"; @@ -9,6 +8,8 @@ /** @type {import("$lib/notifications/types").NotificationChannel[]} */ export let notificationChannels; let edit = notificationChannels.map(() => false); + /** @type {Object<string, number>} */ + export let channelTypes; function deleteChannel(id){ fetch("/admin/api/notificationchannel", { @@ -41,12 +42,12 @@ <th></th> </tr> {#each notificationChannels as channel, i} - <NotificationChannelItem {...channel} bind:edit={edit[i]} deleteChannel={()=>deleteChannel(channel.id)} /> + <NotificationChannelItem {...channel} bind:edit={edit[i]} deleteChannel={()=>deleteChannel(channel.id)} {channelTypes} /> {/each} <tr> <td> <select name="channelType" form="notif-form-new" required> - {#each Object.entries(ChannelType) as [name, {key}]} + {#each Object.entries(channelTypes) as [name, key]} <option value={key}>{name}</option> {/each} </select> diff --git a/src/routes/api/users.js b/src/routes/api/users.js index 21cc3a0..5b0f376 100644 --- a/src/routes/api/users.js +++ b/src/routes/api/users.js @@ -6,6 +6,6 @@ export async function getUser(card) { return Object.assign(usercard.user, {card: usercard.card, perms: usercard.perms}); } -export async function getUserById(id, includeCards=false) { - return db.user.findUnique({where: {id}, include: {cards: includeCards}}); +export async function getUserById(id, includeCards=false, includeNotificationChannels=false) { + return db.user.findUnique({where: {id}, include: {cards: includeCards, notificationChannels: includeNotificationChannels}}); } -- GitLab