diff --git a/package-lock.json b/package-lock.json index 9b9fe63..4758230 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,13 +13,15 @@ "bcryptjs": "^2.4.3", "express": "^4.18.2", "express-session": "^1.17.3", - "markdown-it": "^14.3.0" + "markdown-it": "^14.3.0", + "nodemailer": "^9.0.3" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/express": "^4.17.21", "@types/express-session": "^1.17.10", "@types/node": "^20.11.0", + "@types/nodemailer": "^8.0.1", "ts-node": "^10.9.2", "typescript": "^5.3.3" } @@ -230,6 +232,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -1024,6 +1036,15 @@ "node": ">= 0.6" } }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", diff --git a/package.json b/package.json index e83d052..a9eb6de 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,15 @@ "bcryptjs": "^2.4.3", "express": "^4.18.2", "express-session": "^1.17.3", - "markdown-it": "^14.3.0" + "markdown-it": "^14.3.0", + "nodemailer": "^9.0.3" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/express": "^4.17.21", "@types/express-session": "^1.17.10", "@types/node": "^20.11.0", + "@types/nodemailer": "^8.0.1", "ts-node": "^10.9.2", "typescript": "^5.3.3" } diff --git a/source/server/email/EmailSender.ts b/source/server/email/EmailSender.ts new file mode 100644 index 0000000..01e5f14 --- /dev/null +++ b/source/server/email/EmailSender.ts @@ -0,0 +1,4 @@ +export interface EmailSender +{ + sendEmail( to: string, subject: string, body: string ): Promise; +} diff --git a/source/server/email/EmailService.ts b/source/server/email/EmailService.ts new file mode 100644 index 0000000..d3bfb21 --- /dev/null +++ b/source/server/email/EmailService.ts @@ -0,0 +1,20 @@ +import { EmailSender } from './EmailSender'; +import { SMTPEmailSender } from './SMTPEmailSender'; + +export class EmailService +{ + private static sender: EmailSender = new SMTPEmailSender( + { + host: process.env.SMTP_HOST ?? '', + port: Number( process.env.SMTP_PORT ?? 587 ), + secure: process.env.SMTP_SECURE === 'true', + user: process.env.SMTP_USER ?? '', + pass: process.env.SMTP_PASS ?? '', + from: process.env.SMTP_FROM ?? '' + } ); + + static async sendEmail( to: string, subject: string, body: string ): Promise + { + return EmailService.sender.sendEmail( to, subject, body ); + } +} diff --git a/source/server/email/SMTPEmailSender.ts b/source/server/email/SMTPEmailSender.ts new file mode 100644 index 0000000..f907a9e --- /dev/null +++ b/source/server/email/SMTPEmailSender.ts @@ -0,0 +1,45 @@ +import nodemailer from 'nodemailer'; +import { EmailSender } from './EmailSender'; + +export interface SMTPConfig +{ + host: string; + port: number; + secure: boolean; + user: string; + pass: string; + from: string; +} + +export class SMTPEmailSender implements EmailSender +{ + private transporter: nodemailer.Transporter; + private from: string; + + constructor( config: SMTPConfig ) + { + this.from = config.from; + this.transporter = nodemailer.createTransport( + { + host: config.host, + port: config.port, + secure: config.secure, + auth: + { + user: config.user, + pass: config.pass + } + } ); + } + + async sendEmail( to: string, subject: string, body: string ): Promise + { + await this.transporter.sendMail( + { + from: this.from, + to, + subject, + text: body + } ); + } +} diff --git a/workspace/history/2026/07-July/12-Sunday/index.html b/workspace/history/2026/07-July/12-Sunday/index.html index 9f68076..6faffea 100644 --- a/workspace/history/2026/07-July/12-Sunday/index.html +++ b/workspace/history/2026/07-July/12-Sunday/index.html @@ -95,6 +95,26 @@ +
+

EmailService — SMTP email sending

+

+ Added a thin email layer to the backend under + source/server/email/: an EmailSender interface, + an SMTPEmailSender implementation using Nodemailer, and an + EmailService static facade that instantiates the sender from + environment variables (SMTP_HOST, SMTP_PORT, + SMTP_SECURE, SMTP_USER, SMTP_PASS, + SMTP_FROM). Swapping the implementation requires changing one + line in EmailService.ts. +

+
+ Nodemailer + EmailSender interface + static facade + env vars +
+
+
diff --git a/workspace/history/index.html b/workspace/history/index.html index ba750e9..30cdef6 100644 --- a/workspace/history/index.html +++ b/workspace/history/index.html @@ -21,7 +21,7 @@

Sunday, 12 July 2026

-

Full directory restructure and first production deployment — source/, build/, source/pages/; Roject live at roject.rokojori.com via nginx + systemd.

+

Full directory restructure and first production deployment — source/, build/, source/pages/; Roject live at roject.rokojori.com via nginx + systemd. EmailService SMTP layer added.

diff --git a/workspace/outline/index.html b/workspace/outline/index.html index bfd085f..e42f87e 100644 --- a/workspace/outline/index.html +++ b/workspace/outline/index.html @@ -70,6 +70,10 @@ conversation session in memory. A reusable <confirm-dialog> component replaces browser confirm() for destructive actions (currently project deletion). + An EmailService static facade (source/server/email/) + provides a single sendEmail() entry point backed by a swappable + EmailSender interface; the default implementation is + SMTPEmailSender (Nodemailer), configured via environment variables.

The app is deployed and publicly accessible at