From 682104b7ca4f1fc40f1d1e9ee6a6c4205fa2fb0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Sch=C3=A4fer?= Date: Tue, 25 Jun 2024 09:44:47 +0200 Subject: [PATCH 1/3] Prepare the settings for email sending --- README.md | 2 + docker-compose.yml | 9 +++- docker/compose/.env.template | 12 +++++ server/.env.template | 6 +++ server/src/settings.ts | 87 ++++++++++++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 391b167..7afb605 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ A blog software written in Typescript, with an Express.js backend and a Vue3 fro * [5010](http://localhost:5010): dev client * [5020](http://localhost:5020): dev postgres * [5030](https://localhost:5030): login portal (fake OAuth) +* [5040](https://localhost:5040): dev SMTP server (HTTP port) +* [5041](https://localhost:5041): dev SMTP server (SMTP port) * [5100](http://localhost:5100): production app * [5120](http://localhost:5120): production postgres diff --git a/docker-compose.yml b/docker-compose.yml index ff743d3..0ff7850 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,5 @@ -version: '3' +name: "fublog-development" + services: postgres-dev: @@ -13,3 +14,9 @@ services: POSTGRES_DB: "fublog" ports: - "5020:5432" + + mailhog: + image: mailhog/mailhog:v1.0.1 + ports: + - "5041:1025" # SMTP port + - "5040:8025" # HTTP port diff --git a/docker/compose/.env.template b/docker/compose/.env.template index 16150b8..e52fa19 100644 --- a/docker/compose/.env.template +++ b/docker/compose/.env.template @@ -32,6 +32,18 @@ APP_MAIN_WEBSITE_URL=https://fumix.de # OAUTH_GITLAB_2_ISSUER= # … +### +### E-Mail settings +### +EMAIL_SMTP_HOST= +EMAIL_SMTP_FROM= +EMAIL_SMTP_USER= +EMAIL_SMTP_PASSWORD= +## Allowed values: "TLS" or "STARTTLS". If not set, defaults to "TLS". +# EMAIL_SMTP_SECURITY=TLS +## Custom SMTP port. If not explicitly set, the default port is 465. Except when `EMAIL_SMTP_SECURITY=STARTTLS` is set, then the default is 587. +# EMAIL_SMTP_PORT=465 + ### ### As soon as the first user is registered in the blog, you can ### delete everything in this file after this line. diff --git a/server/.env.template b/server/.env.template index ac2edef..103f5ae 100644 --- a/server/.env.template +++ b/server/.env.template @@ -8,3 +8,9 @@ # OAUTH_FAKE_CLIENT_ID=… # Custom client ID (default value is `ID`) # OAUTH_FAKE_CLIENT_SECRET=… # Custom client secret (default value is `secret`) # OAUTH_FAKE_ISSUER=localhost:42 # Custom domain (default value is `localhost:5030`) + +# For development the user and password can be omitted +EMAIL_SMTP_HOST=localhost +EMAIL_SMTP_FROM=fublog@localhost +EMAIL_SMTP_SECURITY=STARTTLS +EMAIL_SMTP_PORT=5041 diff --git a/server/src/settings.ts b/server/src/settings.ts index bff7f48..33db60b 100644 --- a/server/src/settings.ts +++ b/server/src/settings.ts @@ -1,4 +1,8 @@ import { AppSettingsDto, asHyperlinkDto, isOAuthType, OAUTH_TYPES, OAuthProvider, OAuthType } from "@fumix/fu-blog-common"; +import { createTransport } from "nodemailer"; +import SMTPTransport from "nodemailer/lib/smtp-transport/index.js"; +import { LeveledLogMethod } from "winston"; +import logger from "./logger.js"; import console from "console"; import { readFileSync } from "fs"; import path, { dirname } from "path"; @@ -50,6 +54,65 @@ export class DatabaseSettings { static readonly NAME: string = process.env.DATABASE_NAME ?? "fublog"; } +const securityTypes = ["STARTTLS", "TLS"] as const; +type SmtpSecurity = (typeof securityTypes)[number]; + +function isSmtpSecurity(s: string): s is SmtpSecurity { + return securityTypes.includes(s as SmtpSecurity); +} + +export class EmailSettings { + static readonly SMTP_HOST: string | undefined = toNonBlankString(process.env.EMAIL_SMTP_HOST); + static readonly SMTP_FROM: string | undefined = toNonBlankString(process.env.EMAIL_SMTP_FROM); + static readonly SMTP_USER: string | undefined = toNonBlankString(process.env.EMAIL_SMTP_USER); + static readonly SMTP_PASSWORD: string | undefined = toNonBlankString(process.env.EMAIL_SMTP_PASSWORD); + static readonly SMTP_SECURITY: SmtpSecurity | undefined = toEnumValue( + process.env.EMAIL_SMTP_SECURITY, + (it) => (isSmtpSecurity(it) ? it : undefined), + "TLS", + ); + static readonly SMTP_PORT: number = toNumberOrDefault( + process.env.EMAIL_SMTP_PORT, + EmailSettings.SMTP_SECURITY === "STARTTLS" ? 587 : 465, + ); + + static readonly SMTP_OPTIONS: SMTPTransport.Options = { + host: this.SMTP_HOST, + port: this.SMTP_PORT, + secure: this.SMTP_SECURITY !== "STARTTLS", + requireTLS: AppSettings.IS_PRODUCTION, + from: this.SMTP_FROM, + auth: + this.SMTP_USER && this.SMTP_PASSWORD + ? { + user: this.SMTP_USER, + pass: this.SMTP_PASSWORD, + } + : undefined, + } as const; + + static { + if (!this.SMTP_HOST || !this.SMTP_FROM) { + logger.warn(" 📭 ❓ No email server and/or email from address specified! The blog won't send any email notifications."); + } else { + if (AppSettings.IS_PRODUCTION && (!this.SMTP_USER || !this.SMTP_PASSWORD)) { + logger.warn( + " 📭 🗝 No username/password set for sending email! Are you sure, that your SMTP server does not need any authentication?", + ); + } + createTransport(this.SMTP_OPTIONS).verify(function (error, success) { + const message = error + ? " 📭 ❌ Failed to establish SMTP connection for sending e-mails!" + : " 📬 ✅ Checked that SMTP connection for sending e-mails can be established"; + const log: LeveledLogMethod = error ? logger.error : logger.info; + log( + `${message}: ${EmailSettings.SMTP_HOST}:${EmailSettings.SMTP_PORT} (${EmailSettings.SMTP_SECURITY}) ${EmailSettings.SMTP_OPTIONS.auth ? "with username/password" : "with NO AUTHENTICATION!"}`, + ); + }); + } + } +} + export class ServerSettings { static readonly API_PATH: string = process.env.SERVER_API_PATH ?? "/api"; static readonly PORT: number = toNumberOrDefault(process.env.SERVER_PORT, 5000); @@ -106,9 +169,33 @@ export class OpenAISettings { static readonly API_KEY: string | undefined = process.env.OPENAI_API_KEY; } +function toNonBlankString(value: string | undefined | null): string | undefined { + const trimmed = value?.trim(); + if ((trimmed?.length ?? 0) > 0) { + return trimmed; + } +} + function toNumberOrDefault(value: string | undefined | null, defaultValue: number): number { if (value === undefined || value === null) { return defaultValue; } return Number(value); } + +/** + * Converts a string value to an "enum value" (string union type). + * + * @template T the string union type, can include undefined in case the cast function + * or the default value can yield `undefined` + * @param {string | undefined | null} value - The value to be converted. + * @param {(value: string) => T} cast - The function used to cast the value to the enum type. + * @param {T} defaultValue - The default value to be returned if the input value is undefined or null. + * @return {T} - The converted enum value. + */ +function toEnumValue(value: string | undefined | null, cast: (v: string) => T, defaultValue: T): T { + if (value === undefined || value === null) { + return defaultValue; + } + return cast(value); +} From 23183cb4073424cd2426dde6f9b2672f17c78dea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Sch=C3=A4fer?= Date: Tue, 25 Jun 2024 09:49:42 +0200 Subject: [PATCH 2/3] Send email to all admins when a new user has registered --- client/src/App.vue | 3 +- client/src/i18n/de.json | 5 +-- client/src/i18n/en.json | 5 +-- client/src/util/api-client.ts | 2 +- server/src/routes/auth.ts | 8 +++-- server/src/routes/posts.ts | 2 +- server/src/service/email-service.ts | 56 +++++++++++++++++++++++++++++ 7 files changed, 71 insertions(+), 10 deletions(-) create mode 100644 server/src/service/email-service.ts diff --git a/client/src/App.vue b/client/src/App.vue index 9110b82..39c0ada 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -52,7 +52,7 @@ - +