diff --git a/.env.example b/.env.example index 45274f36057e014a4ab5448a568feffb2e5eae15..0a9d99796da68c294f59c9b4437917eb6d035fe2 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 dc70a894a2cb9bc15234d46e134e80517a5e1dc4..b80af6026e3a4c9acd6064db5e2d92d446c723a6 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 1dce469b35e98e52fb221191ddca416aef195a07..b9c1726e1a01bd63bd59af1ae46ba3a1857ddbfe 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 a6cb806f9209143dcf3320555d65733dac4353cf..25ee4fb7c12e51f987b390c518db6c186ceb94b6 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 ffbf120472a1eed547104364d6177124199144d9..766048e9c1aae7d3791e927407ae54b5e68d08f5 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 e5e6fc7ce07c7643e2e77a12171ba2df9c2cead8..0000000000000000000000000000000000000000 --- 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 0000000000000000000000000000000000000000..a1af296bc4ccd2b376a276bdbf0c474296d8e6c8 --- /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 0000000000000000000000000000000000000000..b06fc3c68e2e69551b831050e9af1c37be6d1ff3 --- /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 1bb7a2d78e221005849bc8955629cfa392a34b94..f8a7425aa2a881cfffdb1cb386cf016e3df63e2a 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 3ee3f4c986366e0abbe2905e0106e2f194d186bc..0ca34a4828657d3b136c98a2e4e4e54d0109a5c9 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 9a5704adb50e338230db8ac6004f6850c622b973..30b234e339fd4325aba98cc3e05a8f7261954334 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 d8e27f6e2e19b6106406383d8ca90bed96198a07..fdd1a723784a5af19327ea831ed0ea4ecacc700e 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 c7fb2b4d025f7b7465c56d42e0160bfff13f49fe..654a367ab723e65dbdae77c58b1828871129ca54 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 869bd2c38e2ba50816d147e839eeeb6dfde3baf8..aecaf5a93bd308ab3555e31e2108487c356df952 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 6b9dc20fec9629870dcdf613fbdc070d0afd8f69..04fdd9fb3c25ed77149bac51edd6d9e1d8579df1 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 cce8af454d0cab5eab7a554bed375a78eac4d3f3..be346c13d504bb6fa88c540d3f0980c29bb002fe 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 21cc3a02171c4146fb9fe7b2058247086c34058c..5b0f3765f88e50a55bef7cf0a8cf27792018a317 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}}); }