From 4d197898dba84c58e1f8f1341df60521db645067 Mon Sep 17 00:00:00 2001
From: Ole Magnus Fon Johnsen
Date: Tue, 7 Apr 2026 16:13:21 +0200
Subject: [PATCH 1/8] feat(email): add email service app with react email
templates
---
apps/email/Dockerfile | 31 ++++
apps/email/package.json | 31 ++++
apps/email/src/email.ts | 18 ++
apps/email/src/emails/access-denied.tsx | 64 +++++++
apps/email/src/emails/access-granted.tsx | 45 +++++
.../emails/access-request-notification.tsx | 62 +++++++
.../emails/deregistration-notification.tsx | 58 +++++++
apps/email/src/emails/email-verification.tsx | 75 +++++++++
.../src/emails/got-spot-notification.tsx | 52 ++++++
apps/email/src/emails/magic-link.tsx | 87 ++++++++++
.../src/emails/registration-confirmation.tsx | 52 ++++++
apps/email/src/emails/strike-notification.tsx | 73 ++++++++
apps/email/src/index.ts | 10 ++
apps/email/src/routes.ts | 158 ++++++++++++++++++
apps/email/tsconfig.json | 15 ++
15 files changed, 831 insertions(+)
create mode 100644 apps/email/Dockerfile
create mode 100644 apps/email/package.json
create mode 100644 apps/email/src/email.ts
create mode 100644 apps/email/src/emails/access-denied.tsx
create mode 100644 apps/email/src/emails/access-granted.tsx
create mode 100644 apps/email/src/emails/access-request-notification.tsx
create mode 100644 apps/email/src/emails/deregistration-notification.tsx
create mode 100644 apps/email/src/emails/email-verification.tsx
create mode 100644 apps/email/src/emails/got-spot-notification.tsx
create mode 100644 apps/email/src/emails/magic-link.tsx
create mode 100644 apps/email/src/emails/registration-confirmation.tsx
create mode 100644 apps/email/src/emails/strike-notification.tsx
create mode 100644 apps/email/src/index.ts
create mode 100644 apps/email/src/routes.ts
create mode 100644 apps/email/tsconfig.json
diff --git a/apps/email/Dockerfile b/apps/email/Dockerfile
new file mode 100644
index 0000000000..03f42d134f
--- /dev/null
+++ b/apps/email/Dockerfile
@@ -0,0 +1,31 @@
+FROM node:20-alpine AS base
+
+FROM base AS builder
+
+RUN apk add --no-cache libc6-compat
+
+WORKDIR /app
+
+COPY apps/email/package.json ./
+RUN npm install
+
+COPY apps/email/ ./
+
+RUN npm run build
+
+FROM node:20-alpine AS runner
+
+WORKDIR /app
+
+RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 -G nodejs nodeuser
+USER nodeuser
+
+COPY --from=builder /app/dist ./dist
+COPY --from=builder /app/node_modules ./node_modules
+
+ENV NODE_ENV=production
+ENV PORT=3001
+
+EXPOSE 3001
+
+CMD ["node", "dist/index.js"]
diff --git a/apps/email/package.json b/apps/email/package.json
new file mode 100644
index 0000000000..e6533a989e
--- /dev/null
+++ b/apps/email/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@echo-webkom/email-service",
+ "version": "0.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "dotenv -e ../../.env -- tsx watch src/index.ts",
+ "build": "tsdown src/index.ts --format cjs --out-dir dist",
+ "start": "node dist/index.js",
+ "check": "oxlint . && oxfmt --check . && tsc --noEmit",
+ "check:fix": "oxlint . --fix && oxfmt . && tsc --noEmit",
+ "clean": "rm -rf dist .turbo node_modules"
+ },
+ "dependencies": {
+ "@hono/node-server": "^1.13.7",
+ "@react-email/components": "1.0.6",
+ "@react-email/render": "2.0.4",
+ "hono": "^4.7.10",
+ "react": "19.2.4",
+ "react-dom": "19.2.4",
+ "resend": "6.9.1"
+ },
+ "devDependencies": {
+ "@types/node": "22.19.7",
+ "@types/react": "19.2.10",
+ "@types/react-dom": "19.2.3",
+ "dotenv-cli": "11.0.0",
+ "tsdown": "^0.21.7",
+ "tsx": "^4.19.4",
+ "typescript": "5.9.3"
+ }
+}
diff --git a/apps/email/src/email.ts b/apps/email/src/email.ts
new file mode 100644
index 0000000000..7493b57be1
--- /dev/null
+++ b/apps/email/src/email.ts
@@ -0,0 +1,18 @@
+/* eslint-disable no-console */
+import { Resend } from "resend";
+
+const FROM_EMAIL = "echo ";
+const IS_PROD = process.env.ENVIRONMENT?.startsWith("p") && !!process.env.RESEND_API_KEY;
+
+export async function sendEmail(to: Array, subject: string, html: string): Promise {
+ if (!IS_PROD) {
+ console.log("\n========== EMAIL SENT (DEV MODE) ==========");
+ console.log("TO:", to.join(", "));
+ console.log("SUBJECT:", subject);
+ console.log("==========================================\n");
+ return;
+ }
+
+ const resend = new Resend(process.env.RESEND_API_KEY);
+ await resend.emails.send({ from: FROM_EMAIL, to, subject, html });
+}
diff --git a/apps/email/src/emails/access-denied.tsx b/apps/email/src/emails/access-denied.tsx
new file mode 100644
index 0000000000..5524149cdc
--- /dev/null
+++ b/apps/email/src/emails/access-denied.tsx
@@ -0,0 +1,64 @@
+import {
+ Body,
+ Container,
+ Head,
+ Heading,
+ Html,
+ Img,
+ Preview,
+ Section,
+ Tailwind,
+} from "@react-email/components";
+import * as React from "react";
+
+type AccessDeniedProps = {
+ reason?: string;
+};
+
+export default function AccessDenied({ reason }: AccessDeniedProps) {
+ return (
+
+
+ Din forespørsel om tilgang til echo.uib.no har blitt avslått
+
+
+
+
+
+ Tilgang ikke godkjent
+
+
+ Din forespørsel om tilgang til{" "}
+
+ echo.uib.no
+ {" "}
+ har dessverre blitt avslått.
+
+
+ {reason && (
+
+ Begrunnelse:
+ {reason}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/email/src/emails/access-granted.tsx b/apps/email/src/emails/access-granted.tsx
new file mode 100644
index 0000000000..1f2ebf18ea
--- /dev/null
+++ b/apps/email/src/emails/access-granted.tsx
@@ -0,0 +1,45 @@
+import {
+ Body,
+ Container,
+ Head,
+ Heading,
+ Html,
+ Img,
+ Preview,
+ Section,
+ Tailwind,
+} from "@react-email/components";
+import * as React from "react";
+
+export default function AccessGranted() {
+ return (
+
+
+ Du har fått tilgang til echo.uib.no
+
+
+
+
+
+ Du har fått tilgang!
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/email/src/emails/access-request-notification.tsx b/apps/email/src/emails/access-request-notification.tsx
new file mode 100644
index 0000000000..e213702261
--- /dev/null
+++ b/apps/email/src/emails/access-request-notification.tsx
@@ -0,0 +1,62 @@
+import {
+ Body,
+ Container,
+ Head,
+ Heading,
+ Html,
+ Img,
+ Preview,
+ Section,
+ Tailwind,
+ Text,
+} from "@react-email/components";
+import * as React from "react";
+
+type DeregistrationNotificationEmailProps = {
+ email?: string;
+ reason?: string;
+};
+
+export default function AcccessRequestNotificationEmail({
+ email = "bo.salhus@echo.uib.no",
+ reason = "Jeg har blitt syk",
+}: DeregistrationNotificationEmailProps) {
+ return (
+
+
+ {email} ønsker tilgang til echo.uib.no
+
+
+
+
+
+
+ {email} ønsker tilgang til echo.uib.no
+
+
+
+
+ {email} ønsker tilgang til echo.uib.no med følgende grunn:
+
+
+ {reason}
+
+
+
+
+ Du kan godkjenne eller avvise forespørselen på whitelist-dashbordet.
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/email/src/emails/deregistration-notification.tsx b/apps/email/src/emails/deregistration-notification.tsx
new file mode 100644
index 0000000000..12fac597b1
--- /dev/null
+++ b/apps/email/src/emails/deregistration-notification.tsx
@@ -0,0 +1,58 @@
+import {
+ Body,
+ Container,
+ Head,
+ Heading,
+ Html,
+ Img,
+ Preview,
+ Section,
+ Tailwind,
+ Text,
+} from "@react-email/components";
+import * as React from "react";
+
+type DeregistrationNotificationEmailProps = {
+ name?: string;
+ reason?: string;
+ happeningTitle?: string;
+};
+
+export default function DeregistrationNotificationEmail({
+ name = "Bo Salhus",
+ reason = "Jeg har blitt syk",
+ happeningTitle = "Workshop med Webkom",
+}: DeregistrationNotificationEmailProps) {
+ return (
+
+
+
+ {name} har meldt seg av {happeningTitle}
+
+
+
+
+
+
+
+ {name} har meldt seg av {happeningTitle}
+
+
+
+ {name} har meldt seg av med følgende grunn:
+
+ {reason}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/email/src/emails/email-verification.tsx b/apps/email/src/emails/email-verification.tsx
new file mode 100644
index 0000000000..a50b6528aa
--- /dev/null
+++ b/apps/email/src/emails/email-verification.tsx
@@ -0,0 +1,75 @@
+import {
+ Body,
+ Button,
+ Container,
+ Head,
+ Heading,
+ Html,
+ Img,
+ Preview,
+ Section,
+ Tailwind,
+ Text,
+} from "@react-email/components";
+import * as React from "react";
+
+type EmailVerificationProps = {
+ verificationUrl: string;
+ firstName?: string;
+};
+
+export default function EmailVerificationEmail({
+ verificationUrl,
+ firstName = "der",
+}: EmailVerificationProps) {
+ return (
+
+