Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions apps/email/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
32 changes: 32 additions & 0 deletions apps/email/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@echo-webkom/email-service",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "dotenv -e ../../.env -- tsx watch src/index.ts",
"email:dev": "react-email dev --port 3001",
"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"
}
}
18 changes: 18 additions & 0 deletions apps/email/src/email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* eslint-disable no-console */
import { Resend } from "resend";

const FROM_EMAIL = "echo <ikkesvar@echo-webkom.no>";
const IS_PROD = process.env.ENVIRONMENT?.startsWith("p") && !!process.env.RESEND_API_KEY;

export async function sendEmail(to: Array<string>, subject: string, html: string): Promise<void> {
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 });
}
10 changes: 10 additions & 0 deletions apps/email/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { serve } from "@hono/node-server";

import { app } from "./routes";

const PORT = Number(process.env.EMAIL_PORT) || 6000;

serve({ fetch: app.fetch, port: PORT }, () => {
// oxlint-disable-next-line no-console
console.log(`Email service running on http://localhost:${PORT}`);
});
158 changes: 158 additions & 0 deletions apps/email/src/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { render } from "@react-email/render";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import * as React from "react";

import { sendEmail } from "./email";
import AccessDeniedEmail from "./emails/access-denied";
import AccessGrantedEmail from "./emails/access-granted";
import AccessRequestNotificationEmail from "./emails/access-request-notification";
import DeregistrationNotificationEmail from "./emails/deregistration-notification";
import EmailVerificationEmail from "./emails/email-verification";
import GotSpotNotificationEmail from "./emails/got-spot-notification";
import MagicLinkEmail from "./emails/magic-link";
import RegistrationConfirmationEmail from "./emails/registration-confirmation";
import StrikeNotificationEmail from "./emails/strike-notification";

const app = new Hono();

app.use(logger());
app.use(cors());

app.use("*", async (c, next) => {
// Allow unauthenticated access to the root path for health checks
if (c.req.path === "/") {
await next();
}
// For all other paths, require authentication
// Check if the Authorization header is present and matches the expected admin key
const authHeader = c.req.header("Authorization");
if (!authHeader || authHeader !== `Bearer ${process.env.ADMIN_KEY}`) {
return c.json({ error: "Unauthorized" }, 401);
}
await next();
});

app.get("/", (c) => c.json({ status: "ok" }));

app.post("/registration-confirmation", async (c) => {
const { to, subject, title, isBedpres } = await c.req.json<{
to: Array<string>;
subject: string;
title?: string;
isBedpres?: boolean;
}>();
const html = await render(
React.createElement(RegistrationConfirmationEmail, { title, isBedpres }),
);
await sendEmail(to, subject, html);
return c.json({ success: true });
});

app.post("/deregistration-notification", async (c) => {
const { to, subject, name, reason, happeningTitle } = await c.req.json<{
to: Array<string>;
subject: string;
name?: string;
reason?: string;
happeningTitle?: string;
}>();
const html = await render(
React.createElement(DeregistrationNotificationEmail, { name, reason, happeningTitle }),
);
await sendEmail(to, subject, html);
return c.json({ success: true });
});

app.post("/got-spot-notification", async (c) => {
const { to, subject, name, happeningTitle } = await c.req.json<{
to: Array<string>;
subject: string;
name?: string;
happeningTitle?: string;
}>();
const html = await render(
React.createElement(GotSpotNotificationEmail, { name, happeningTitle }),
);
await sendEmail(to, subject, html);
return c.json({ success: true });
});

app.post("/strike-notification", async (c) => {
const { to, subject, name, reason, amount, isBanned } = await c.req.json<{
to: Array<string>;
subject: string;
name?: string;
reason?: string;
amount?: number;
isBanned?: boolean;
}>();
const html = await render(
React.createElement(StrikeNotificationEmail, { name, reason, amount, isBanned }),
);
await sendEmail(to, subject, html);
return c.json({ success: true });
});

app.post("/access-granted", async (c) => {
const { to, subject } = await c.req.json<{
to: Array<string>;
subject: string;
}>();
const html = await render(React.createElement(AccessGrantedEmail));
await sendEmail(to, subject, html);
return c.json({ success: true });
});

app.post("/access-denied", async (c) => {
const { to, subject, reason } = await c.req.json<{
to: Array<string>;
subject: string;
reason?: string;
}>();
const html = await render(React.createElement(AccessDeniedEmail, { reason }));
await sendEmail(to, subject, html);
return c.json({ success: true });
});

app.post("/access-request-notification", async (c) => {
const { to, subject, email, reason } = await c.req.json<{
to: Array<string>;
subject: string;
email?: string;
reason?: string;
}>();
const html = await render(React.createElement(AccessRequestNotificationEmail, { email, reason }));
await sendEmail(to, subject, html);
return c.json({ success: true });
});

app.post("/email-verification", async (c) => {
const { to, subject, verificationUrl, firstName } = await c.req.json<{
to: Array<string>;
subject: string;
verificationUrl: string;
firstName?: string;
}>();
const html = await render(
React.createElement(EmailVerificationEmail, { verificationUrl, firstName }),
);
await sendEmail(to, subject, html);
return c.json({ success: true });
});

app.post("/magic-link", async (c) => {
const { to, subject, magicLinkUrl, code, firstName } = await c.req.json<{
to: Array<string>;
subject: string;
magicLinkUrl: string;
code: string;
firstName?: string;
}>();
const html = await render(React.createElement(MagicLinkEmail, { magicLinkUrl, code, firstName }));
await sendEmail(to, subject, html);
return c.json({ success: true });
});

export { app };
15 changes: 15 additions & 0 deletions apps/email/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "es2022",
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
"jsx": "react-jsx",
"outDir": "dist"
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}
7 changes: 4 additions & 3 deletions apps/uno/bootstrap/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ func RunApi() {
weatherRepo := external.NewYrRepo(logger, redisClient)
databrusRepo := external.NewDatabrusRepo(logger, redisClient)
adventOfCodeRepo := external.NewAdventOfCodeClient(aocClient, logger, redisClient)
emailClient := external.NewEmailClient(cfg.EmailBaseURL)
groupRepo := postgres.NewGroupRepo(db, logger)
reactionRepo := postgres.NewReactionRepo(db, logger)
quoteRepo := postgres.NewQuoteRepo(db, logger)
Expand Down Expand Up @@ -105,13 +106,13 @@ func RunApi() {
VerificationTokenRepo: verificationTokenRepo,
SignInAttemptCache: cache.NewCache[service.SignInAttempt](redisClient, "sign-in-attempt", logger),
})
happeningService := service.NewHappeningService(happeningRepo, userRepo, registrationRepo, banInfoRepo, groupRepo)
happeningService := service.NewHappeningService(happeningRepo, userRepo, registrationRepo, banInfoRepo, groupRepo, cmsHappeningRepo, emailClient)
degreeService := service.NewDegreeService(degreeRepo)
siteFeedbackService := service.NewSiteFeedbackService(siteFeedbackRepo)
shoppingListService := service.NewShoppingListService(shoppingListItemRepo, usersToShoppingListItemRepo)
userService := service.NewUserService(userRepo, profilePictureRepo, groupRepo, degreeRepo)
strikeService := service.NewStrikeService(dotRepo, banInfoRepo, userRepo)
accessRequestService := service.NewAccessRequestService(accessRequestRepo)
strikeService := service.NewStrikeService(dotRepo, banInfoRepo, userRepo, emailClient)
accessRequestService := service.NewAccessRequestService(accessRequestRepo, whitelistRepo, emailClient)
whitelistService := service.NewWhitelistService(whitelistRepo)
commentService := service.NewCommentService(commentRepo)
weatherService := service.NewWeatherService(weatherRepo)
Expand Down
2 changes: 1 addition & 1 deletion apps/uno/bootstrap/cron.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ func RunCron(job string) {
logger.Warn(context.Background(), "file storage not configured, profile picture features disabled")
}
questionService := service.NewQuestionService(questionRepo)
strikeService := service.NewStrikeService(dotRepo, banInfoRepo, userRepo)
strikeService := service.NewStrikeService(dotRepo, banInfoRepo, userRepo, nil)
userService := service.NewUserService(userRepo, profilePictureRepo, nil, nil)

// Job to clean up sensitive questions and strikes every 6 months.
Expand Down
5 changes: 5 additions & 0 deletions apps/uno/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ type Config struct {
SanityAPIToken string
SanityAPIVersion string

// Email service configuration
EmailBaseURL string

// Storage configuration
DatabaseURL string
RedisURL string
Expand Down Expand Up @@ -104,6 +107,8 @@ func Load() *Config {
SanityAPIVersion: getEnvOrDefault("SANITY_API_VERSION", "2023-05-03"),
SanityAPIToken: os.Getenv("SANITY_API_TOKEN"),

EmailBaseURL: getEnvOrDefault("EMAIL_BASE_URL", "http://localhost:3001"),

DatabaseURL: os.Getenv("DATABASE_URL"),
RedisURL: os.Getenv("REDIS_URL"),
}
Expand Down
4 changes: 2 additions & 2 deletions apps/uno/cron/jobs/db_maintenance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func TestCleanupOldStrikesRun(t *testing.T) {
banInfoRepo := mocks.NewBanInfoRepo(t)
userRepo := mocks.NewUserRepo(t)

strikeService := service.NewStrikeService(dotRepo, banInfoRepo, userRepo)
strikeService := service.NewStrikeService(dotRepo, banInfoRepo, userRepo, nil)
job := NewCleanupOldStrikes(strikeService, &testutil.NoOpLogger{})

err := job.Run(t.Context())
Expand All @@ -77,7 +77,7 @@ func TestCleanupOldStrikesRunError(t *testing.T) {
banInfoRepo := mocks.NewBanInfoRepo(t)
userRepo := mocks.NewUserRepo(t)

strikeService := service.NewStrikeService(dotRepo, banInfoRepo, userRepo)
strikeService := service.NewStrikeService(dotRepo, banInfoRepo, userRepo, nil)
job := NewCleanupOldStrikes(strikeService, &testutil.NoOpLogger{})

err := job.Run(t.Context())
Expand Down
Loading
Loading