diff --git a/package-lock.json b/package-lock.json index 582560c045dc42a10650f27c5a3eb9bea809ac19..cb212b45f7d998d47a238758a464d884719ee713 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,9 @@ "@prisma/client": "^4.11.0", "@sveltejs/adapter-node": "^1.2.1", "cookie": "^0.5.0", + "highlight.js": "^11.8.0", + "markdown-it": "^13.0.1", + "markdown-it-highlightjs": "^4.0.1", "nanoid": "^4.0.1", "nodemailer": "^6.9.4", "prisma": "^4.11.0" @@ -816,8 +819,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/balanced-match": { "version": "1.0.2", @@ -979,6 +981,17 @@ "node": ">=6.0.0" } }, + "node_modules/entities": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", + "integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.18.18", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.18.tgz", @@ -1387,6 +1400,14 @@ "node": ">=8" } }, + "node_modules/highlight.js": { + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz", + "integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", @@ -1564,6 +1585,14 @@ "node": ">= 0.8.0" } }, + "node_modules/linkify-it": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz", + "integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==", + "dependencies": { + "uc.micro": "^1.0.1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -1596,6 +1625,34 @@ "node": ">=12" } }, + "node_modules/markdown-it": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.1.tgz", + "integrity": "sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q==", + "dependencies": { + "argparse": "^2.0.1", + "entities": "~3.0.1", + "linkify-it": "^4.0.1", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + }, + "bin": { + "markdown-it": "bin/markdown-it.js" + } + }, + "node_modules/markdown-it-highlightjs": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/markdown-it-highlightjs/-/markdown-it-highlightjs-4.0.1.tgz", + "integrity": "sha512-EPXwFEN6P5nqR3G4KjT20r20xbGYKMMA/360hhSYFmeoGXTE6hsLtJAiB/8ID8slVH4CWHHEL7GX0YenyIstVQ==", + "dependencies": { + "highlight.js": "^11.5.1" + } + }, + "node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==" + }, "node_modules/mime": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", @@ -2230,6 +2287,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" + }, "node_modules/undici": { "version": "5.22.1", "resolved": "https://registry.npmjs.org/undici/-/undici-5.22.1.tgz", @@ -2787,8 +2849,7 @@ "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "balanced-match": { "version": "1.0.2", @@ -2909,6 +2970,11 @@ "esutils": "^2.0.2" } }, + "entities": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", + "integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==" + }, "esbuild": { "version": "0.18.18", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.18.tgz", @@ -3216,6 +3282,11 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, + "highlight.js": { + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz", + "integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==" + }, "ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", @@ -3350,6 +3421,14 @@ "type-check": "~0.4.0" } }, + "linkify-it": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz", + "integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==", + "requires": { + "uc.micro": "^1.0.1" + } + }, "locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3373,6 +3452,31 @@ "@jridgewell/sourcemap-codec": "^1.4.13" } }, + "markdown-it": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.1.tgz", + "integrity": "sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q==", + "requires": { + "argparse": "^2.0.1", + "entities": "~3.0.1", + "linkify-it": "^4.0.1", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + } + }, + "markdown-it-highlightjs": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/markdown-it-highlightjs/-/markdown-it-highlightjs-4.0.1.tgz", + "integrity": "sha512-EPXwFEN6P5nqR3G4KjT20r20xbGYKMMA/360hhSYFmeoGXTE6hsLtJAiB/8ID8slVH4CWHHEL7GX0YenyIstVQ==", + "requires": { + "highlight.js": "^11.5.1" + } + }, + "mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==" + }, "mime": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", @@ -3768,6 +3872,11 @@ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true }, + "uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" + }, "undici": { "version": "5.22.1", "resolved": "https://registry.npmjs.org/undici/-/undici-5.22.1.tgz", diff --git a/package.json b/package.json index 25ee4fb7c12e51f987b390c518db6c186ceb94b6..373a3d3af120ee60b85f9f85d74309b0b41f8e10 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,9 @@ "@prisma/client": "^4.11.0", "@sveltejs/adapter-node": "^1.2.1", "cookie": "^0.5.0", + "highlight.js": "^11.8.0", + "markdown-it": "^13.0.1", + "markdown-it-highlightjs": "^4.0.1", "nanoid": "^4.0.1", "nodemailer": "^6.9.4", "prisma": "^4.11.0" diff --git a/src/lib/server/mail.ts b/src/lib/server/mail.ts index 109a116b4def709354b86db6154f20b7d4a2c650..5ac84de0e54799458a4abf0e559a144a633af219 100644 --- a/src/lib/server/mail.ts +++ b/src/lib/server/mail.ts @@ -2,6 +2,7 @@ import nodemailer from "nodemailer"; import type { Address } from "nodemailer/lib/mailer"; const transporter = nodemailer.createTransport({ + pool: true, host: process.env.MAIL_HOST, port: parseInt(process.env.MAIL_PORT), auth: { diff --git a/src/lib/server/notifications/emailHandler.ts b/src/lib/server/notifications/emailHandler.ts index 2b69aa65b8dc23edc0a453e050e4d32815fe2c62..8729e9f000bc585bea25c648eba6bea5671a10a0 100644 --- a/src/lib/server/notifications/emailHandler.ts +++ b/src/lib/server/notifications/emailHandler.ts @@ -10,7 +10,6 @@ export async function handleEmail<T extends PossibleNotificationType>(recipient: html: formatMessageToHtml(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/routes/admin/+page.svelte b/src/routes/admin/+page.svelte index 3edce41ff80e2bb50cf4dd413b0d9ff7e6a72127..f31394299d3a6636eccab8fe6eda1bfe7cdbfc4f 100644 --- a/src/routes/admin/+page.svelte +++ b/src/routes/admin/+page.svelte @@ -3,6 +3,7 @@ <a href="/admin/history">Transaktionsverlauf</a><br> <a href="/admin/restock">Nachschub</a><br> <a href="/admin/vouchers">Gutscheine</a><br> +<a href="/admin/mail">Mail</a><br> <br> <a href="/logout">Abmelden</a> diff --git a/src/routes/admin/api/users.ts b/src/routes/admin/api/users.ts index fdd1a723784a5af19327ea831ed0ea4ecacc700e..417153a59476503cd59fbce57e7e21e821e9793f 100644 --- a/src/routes/admin/api/users.ts +++ b/src/routes/admin/api/users.ts @@ -17,8 +17,14 @@ export async function updateUser(id, name, email, comment){ }); } -export async function getUsers(){ - return db.user.findMany(); +export async function getUsers(userIds: number[] = undefined){ + if(Array.isArray(userIds)){ + return db.user.findMany({ + where: { id: { in: userIds } } + }); + }else{ + return db.user.findMany(); + } } export async function getUser(id, includeCards=false, includeNotificationChannels=false){ diff --git a/src/routes/admin/mail/+page.server.ts b/src/routes/admin/mail/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..bce0dc30c818ae0e808467bad44f549815f2ac01 --- /dev/null +++ b/src/routes/admin/mail/+page.server.ts @@ -0,0 +1,36 @@ +import { error, type Actions } from "@sveltejs/kit"; +import { getUsers } from "../api/users"; +import { sendMail } from "$lib/server/mail"; +import { format } from "./formatter"; + +export async function load(){ + const users = await getUsers(); + return { users }; +} + +export const actions: Actions = { + send: async event=>{ + const data = await event.request.formData(); + const subject = data.get("subject"); + const message = data.get("content"); + const users = data.get("users"); + if(typeof subject !== "string" || typeof message !== "string" || typeof users !== "string") throw error(400, "Invalid request"); + const userIDs = users.split(",").map(id => parseInt(id)); + const usersToSend = await getUsers(userIDs); + const result: {accepted: number[], rejected: number[], error: {userId: number, error: any}[]} = { accepted: [], rejected: [], error: [] }; + for(const user of usersToSend){ + const mail = format(subject, message, user); + try{ + const r = await sendMail(mail); + if(r.accepted.length > 0) result.accepted.push(user.id); + else if(r.rejected.length > 0) result.rejected.push(user.id); + }catch(err){ + result.error.push({ + userId: user.id, + error: err + }); + } + } + return result; + } +}; diff --git a/src/routes/admin/mail/+page.svelte b/src/routes/admin/mail/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..b1ea9f4c41049a14d21e33992b8c282ec4770706 --- /dev/null +++ b/src/routes/admin/mail/+page.svelte @@ -0,0 +1,74 @@ +<script lang="ts"> + import type { User } from "@prisma/client"; + import { format } from "./formatter"; + import { enhance } from "$app/forms"; + import { MessageType, addMessage } from "$lib/messages"; + + export let data: {users: User[]}; + + let subject = "", content = ""; + let minBalance, maxBalance; + $: filter = (user: User) => { + if(minBalance != null && user.balance < minBalance) return false; + if(maxBalance != null && user.balance > maxBalance) return false; + return true; + }; + $: matchedUsers = data.users.filter(filter); + $: exampleUser = matchedUsers[Math.floor(Math.random()*matchedUsers.length)]; + $: preview = exampleUser ? format(subject, content, exampleUser) : null; +</script> + +<style> + form input, form textarea, form button { + width: 100%; + } + textarea { + height: 15em; + } + .preview { + padding-bottom: 2em; + } +</style> + +<a href="/admin">Zurück</a> + +<h2>Empfänger</h2> +<label>Minimales Guthaben: <input type="number" bind:value={minBalance} /></label><br> +<label>Maximales Guthaben: <input type="number" bind:value={maxBalance} /></label><br> + +<h2>Mail</h2> +<form method="post" action="?/send" use:enhance={({form, data, cancel})=>{ + return async ({result})=>{ + if(result.type === "success"){ + const { rejected, accepted, error } = result.data; + if(error.length){ + console.error("Error sending mails", error); + addMessage(MessageType.ERROR, "Fehler beim Senden der Mails an: " + error.map(e=>e.userId).join(", ")); + } + if(rejected.length){ + addMessage(MessageType.ERROR, "Mails konnten nicht an: " + rejected.map(e=>e.userId).join(", ") + " gesendet werden"); + } + if(accepted.length){ + addMessage(MessageType.SUCCESS, "Mails erfolgreich an " + accepted.length + " Nutzer gesendet"); + } + } + }; +}}> + <input type="text" name="subject" placeholder="Betreff" bind:value={subject} required /><br> + <textarea name="content" placeholder="Inhalt" bind:value={content} required></textarea><br> + <input type="hidden" name="users" value={matchedUsers.map(u=>u.id).join(",")} /> + <button type="submit">Senden</button> +</form> + +<h2>Vorschau</h2> +{#if preview} +<div class="preview"> + <p><b>An: </b>{matchedUsers.map(u=>u.email).join(", ")}</p> + <h3>{preview.subject}</h3> + <div> + {@html preview.html} + </div> +</div> +{:else} +<p class="preview">Die Mail wird an keinen Nutzer gesendet</p> +{/if} diff --git a/src/routes/admin/mail/formatter.ts b/src/routes/admin/mail/formatter.ts new file mode 100644 index 0000000000000000000000000000000000000000..138cb7a565581b790c56089a571ed3ba2354a98f --- /dev/null +++ b/src/routes/admin/mail/formatter.ts @@ -0,0 +1,61 @@ +import MarkdownIt from "markdown-it"; +import MarkdownItHighlight from "markdown-it-highlightjs"; +import hljs from "highlight.js"; +import hljsCSS from "highlight.js/styles/github.css?inline"; +import type { User } from "@prisma/client"; + +const md = new MarkdownIt({ + breaks: true, + html: true, // allow HTML in emails, we trust admins to not do anything stupid + typographer: true +}).use(MarkdownItHighlight, { + auto: false, + ignoreIllegals: true, + code: true, + inline: true, + hljs +}); +md.core.ruler.push("replacePlaceholders", state=>{ + for(const token of state.tokens){ + handleToken(token, state.env.user); + } +}); + +function handleToken(token, user){ + if(token.type==="text"){ + token.content = replacePlaceholders(token.content, user); + } + if(token.children) + for(const child of token.children) + handleToken(child, user); +} + +type Mail = { + to: { + name: string, + address: string + }, + subject: string, + text: string, + html: string +}; + +export function format(subject: string, content: string, user: User): Mail { + return { + to: { name: user.name, address: user.email }, + subject: replacePlaceholders(subject, user), + text: replacePlaceholders(content, user), + html: `<style>${hljsCSS}</style>${md.render(content, {user})}` + }; +} + +function replacePlaceholders(content: string, user: User): string { + const placeholders = { + "{{name}}": user.name, + "{{email}}": user.email, + "{{balance}}": (user.balance/100).toFixed(2), + "{{balanceSigned}}": (user.balance >= 0 ? "+" : "") + (user.balance/100).toFixed(2), + "{{id}}": user.id.toString(), + }; + return content.replace(/\{\{name\}\}|\{\{email\}\}|\{\{balanceSigned\}\}|\{\{balance\}\}|\{\{id\}\}/g, match=>placeholders[match]); +}