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]);
+}