Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Registration notification email #101

Merged
merged 3 commits into from
Jul 1, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion client/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
</div>
</nav>

<RouterView :userPermissions="loggedInUserInfo?.permissions" />
<RouterView :userPermissions="loggedInUserInfo?.permissions ?? DEFAULT_ROLE" />
</div>
<footer class="page-footer">
<div class="container">
Expand Down Expand Up @@ -118,6 +118,7 @@ import { saveIdToken } from "@client/util/storage.js";
import { faGithub } from "@fortawesome/free-brands-svg-icons";
import { faExternalLink } from "@fortawesome/free-solid-svg-icons";
import type { AppSettingsDto, LoggedInUserInfo } from "@fumix/fu-blog-common";
import { DEFAULT_ROLE } from "@fumix/fu-blog-common";
import { onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
Expand Down
5 changes: 3 additions & 2 deletions client/src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
"not-available": {
"title": "Post nicht vorhanden",
"message": "Der Post ist nicht verfügbar oder wurde gelöscht. Sie können mit dem Button zur Startseite zurück, oder nutzen sie die Suchfunktion um einen bestimmten Post zu finden."
}
}
}
},
"admin": {
Expand Down Expand Up @@ -105,7 +105,8 @@
"email": "E-mail",
"username": "Benutzername",
"fullname": "Dein Name"
}
},
"username-requirements": "Der Benutzername muss zwischen {min} und {max} Zeichen lang sein (ist {length} Zeichen lang) und darf nur aus den Buchstaben a-z/A-Z, den Zahlen 0-9 und den Zeichen ._- bestehen."
},
"user_not_found": "Benutzer nicht gefunden."
}
Expand Down
5 changes: 3 additions & 2 deletions client/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,9 @@
"email": "Email",
"username": "Username",
"fullname": "Your name"
}
},
"username-requirements": "The username must be between 3 and 32 characters long and can only consist of letters a-z/A-Z, digits 0-9 and characters ._-"
},
"user_not_found": "User not found."
}
}
}
2 changes: 1 addition & 1 deletion client/src/util/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export class PostEndpoints {

static async findPosts(pageIndex: number, itemsPerPage = 12, search: string | undefined = undefined, operator: "and" | "or" = "and") {
return callServer<void, JsonMimeType, { data: [PublicPost[], number | null] }>(
`/api/posts/page/${pageIndex}/count/${itemsPerPage}${search ? `/search/${encodeURIComponent(search)}/operator/${operator}` : ""}###`,
`/api/posts/page/${pageIndex}/count/${itemsPerPage}${search ? `/search/${encodeURIComponent(search)}/operator/${operator}` : ""}`,
"GET",
"application/json",
null,
Expand Down
9 changes: 8 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
version: '3'
name: "fublog-development"

services:

postgres-dev:
Expand All @@ -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
12 changes: 12 additions & 0 deletions docker/compose/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions server/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 5 additions & 3 deletions server/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
OAuthUserInfoDto,
UserInfoOAuthToken,
} from "@fumix/fu-blog-common";
import { sendNotificationEmailAboutNewRegistration } from "../service/email-service.js";
import express, { Request, Response, Router } from "express";
import fetch from "node-fetch";
import { BaseClient, Issuer, TokenSet } from "openid-client";
Expand Down Expand Up @@ -232,9 +233,10 @@ router.post("/userinfo/register", async (req, res, next) => {
oauthId: oauthUserId,
user,
};
await mgr
.insert(OAuthAccountEntity, [oauthAccount])
.then((it) => logger.info("New OAuth account created: " + JSON.stringify(oauthAccount)));
await mgr.insert(OAuthAccountEntity, [oauthAccount]).then((it) => {
sendNotificationEmailAboutNewRegistration(oauthAccount.user.username);
logger.info("New OAuth account created: " + JSON.stringify(oauthAccount));
});
})
.then(async () => {
const result: OAuthUserInfoDto = {
Expand Down
2 changes: 1 addition & 1 deletion server/src/routes/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
NewPostRequestDto,
PostRequestDto,
} from "@fumix/fu-blog-common";
import logger from "@server/logger.js";
import logger from "../logger.js";
import express, { NextFunction, Request, Response, Router } from "express";
import { In } from "typeorm";
import { AppDataSource } from "../data-source.js";
Expand Down
56 changes: 56 additions & 0 deletions server/src/service/email-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import console from "console";
import { createTransport } from "nodemailer";
import { AppDataSource } from "../data-source.js";
import { UserEntity } from "../entity/User.entity.js";
import logger from "../logger.js";
import { ClientSettings, EmailSettings } from "../settings.js";

export async function sendNotificationEmailAboutNewRegistration(newUsername: string) {
const admin_emails = await AppDataSource.manager
.getRepository(UserEntity)
.createQueryBuilder("find all admins")
.where("roles @> :role", { role: ["ADMIN"] }) // https://www.postgresql.org/docs/16/functions-array.html#ARRAY-OPERATORS-TABLE
.getMany()
.then((users) => {
console.log("Found users", users);
return users.map((user) => user.email);
})
.catch((e) => {
console.error("Failed to get admins", e);
return [];
});

if (admin_emails.length <= 0) {
logger.warn(`There are no admins yet. No notification email is sent about new user '${newUsername}'.`);
} else {
logger.debug(`Sending notification email about registration of new user '${newUsername}' to ${admin_emails.join(", ")}`);

await sendEmail(
admin_emails,
"New user registered",
`Hi admins,
a new user '${encodeURIComponent(newUsername)}' registered at ${ClientSettings.BASE_URL} .
Visit ${ClientSettings.BASE_URL}/administration in order to give them some permissions.

Kind regards,
Your fuBlog`,
);
}
}

function sendEmail(to: string[], subject: string, text: string) {
if (!(EmailSettings.SMTP_HOST ?? EmailSettings.SMTP_PORT)) {
throw new Error("No SMTP email server set! Cannot send email.");
}

createTransport(EmailSettings.SMTP_OPTIONS)
.sendMail({
from: "[email protected]",
to,
subject,
text,
})
.catch(() => {
logger.error("Failed to send email notification!");
});
}
87 changes: 87 additions & 0 deletions server/src/settings.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<T extends string | undefined>(value: string | undefined | null, cast: (v: string) => T, defaultValue: T): T {
if (value === undefined || value === null) {
return defaultValue;
}
return cast(value);
}
Loading