Skip to content
Snippets Groups Projects
Commit 514a8546 authored by Aaron Dötsch's avatar Aaron Dötsch
Browse files

Implement bulk mail sending to admin panel

parent 0b882111
No related branches found
No related tags found
No related merge requests found
......@@ -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",
......
......@@ -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: {
......
......@@ -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";
});
......
......@@ -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>
......@@ -17,9 +17,15 @@ export async function updateUser(id, name, email, comment){
});
}
export async function getUsers(){
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){
return db.user.findUnique({
......
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;
}
};
<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}
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]);
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment