diff --git a/flake.nix b/flake.nix index d6dcb7e..3be0416 100644 --- a/flake.nix +++ b/flake.nix @@ -73,7 +73,7 @@ ]; text = '' rm -rf node_modules - ${pkgs.nodejs_24}/bin/npm install --save + ${pkgs.nodejs_24}/bin/npm install --save --no-audit --no-fund --prefer-offline --ignore-scripts --no-bin-links DEPS_HASH=$(prefetch-npm-deps package-lock.json) TMPFILE=$(mktemp) jq '.npmDepsHash = "'"$DEPS_HASH"'"' hashes.json > "$TMPFILE" diff --git a/package-lock.json b/package-lock.json index d1db30a..f2ec33f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "AGPL-3.0-only", "dependencies": { "@aws-sdk/client-s3": "^3.899.0", + "@sendgrid/mail": "*", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "amqplib": "^0.10.9", @@ -88,12 +89,12 @@ "typescript": "^5.9.2" }, "optionalDependencies": { + "@sendgrid/mail": "^8.1.6", "@yukikaze-bot/erlpack": "^1.0.1", "jimp": "^1.6.0", "mysql": "^2.18.1", "nodemailer-mailgun-transport": "^2.1.5", "nodemailer-mailjet-transport": "github:n0script22/nodemailer-mailjet-transport", - "nodemailer-sendgrid-transport": "github:Maria-Golomb/nodemailer-sendgrid-transport", "pg": "^8.16.3", "sqlite3": "^5.1.7" } @@ -2563,54 +2564,44 @@ "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@sendgrid/client": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-7.7.0.tgz", - "integrity": "sha512-SxH+y8jeAQSnDavrTD0uGDXYIIkFylCo+eDofVmZLQ0f862nnqbC3Vd1ej6b7Le7lboyzQF6F7Fodv02rYspuA==", + "version": "8.1.6", + "resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-8.1.6.tgz", + "integrity": "sha512-/BHu0hqwXNHr2aLhcXU7RmmlVqrdfrbY9KpaNj00KZHlVOVoRxRVrpOCabIB+91ISXJ6+mLM9vpaVUhK6TwBWA==", "license": "MIT", "optional": true, "dependencies": { - "@sendgrid/helpers": "^7.7.0", - "axios": "^0.26.0" + "@sendgrid/helpers": "^8.0.0", + "axios": "^1.12.0" }, "engines": { - "node": "6.* || 8.* || >=10.*" - } - }, - "node_modules/@sendgrid/client/node_modules/axios": { - "version": "0.26.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz", - "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==", - "license": "MIT", - "optional": true, - "dependencies": { - "follow-redirects": "^1.14.8" + "node": ">=12.*" } }, "node_modules/@sendgrid/helpers": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-7.7.0.tgz", - "integrity": "sha512-3AsAxfN3GDBcXoZ/y1mzAAbKzTtUZ5+ZrHOmWQ279AuaFXUNCh9bPnRpN504bgveTqoW+11IzPg3I0WVgDINpw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-8.0.0.tgz", + "integrity": "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==", "license": "MIT", "optional": true, "dependencies": { "deepmerge": "^4.2.2" }, "engines": { - "node": ">= 6.0.0" + "node": ">= 12.0.0" } }, "node_modules/@sendgrid/mail": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-7.7.0.tgz", - "integrity": "sha512-5+nApPE9wINBvHSUxwOxkkQqM/IAAaBYoP9hw7WwgDNQPxraruVqHizeTitVtKGiqWCKm2mnjh4XGN3fvFLqaw==", + "version": "8.1.6", + "resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-8.1.6.tgz", + "integrity": "sha512-/ZqxUvKeEztU9drOoPC/8opEPOk+jLlB2q4+xpx6HVLq6aFu3pMpalkTpAQz8XfRfpLp8O25bh6pGPcHDCYpqg==", "license": "MIT", "optional": true, "dependencies": { - "@sendgrid/client": "^7.7.0", - "@sendgrid/helpers": "^7.7.0" + "@sendgrid/client": "^8.1.5", + "@sendgrid/helpers": "^8.0.0" }, "engines": { - "node": "6.* || 8.* || >=10.*" + "node": ">=12.*" } }, "node_modules/@smithy/abort-controller": { @@ -8602,15 +8593,6 @@ "node": ">=10" } }, - "node_modules/nodemailer-sendgrid-transport": { - "version": "0.2.0", - "resolved": "git+ssh://git@github.com/Maria-Golomb/nodemailer-sendgrid-transport.git#a9cee4346d10aa02988948086f3a746f8e804bc3", - "license": "MIT", - "optional": true, - "dependencies": { - "@sendgrid/mail": "^7.4.5" - } - }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", diff --git a/package.json b/package.json index a7bde5a..5d59c52 100644 --- a/package.json +++ b/package.json @@ -123,12 +123,12 @@ "@spacebar/webrtc": "dist/webrtc" }, "optionalDependencies": { + "@sendgrid/mail": "^8.1.6", "@yukikaze-bot/erlpack": "^1.0.1", "jimp": "^1.6.0", "mysql": "^2.18.1", "nodemailer-mailgun-transport": "^2.1.5", "nodemailer-mailjet-transport": "github:n0script22/nodemailer-mailjet-transport", - "nodemailer-sendgrid-transport": "github:Maria-Golomb/nodemailer-sendgrid-transport", "pg": "^8.16.3", "sqlite3": "^5.1.7" } diff --git a/src/util/util/email/clients/IEmailClient.ts b/src/util/util/email/clients/IEmailClient.ts new file mode 100644 index 0000000..64f662b --- /dev/null +++ b/src/util/util/email/clients/IEmailClient.ts @@ -0,0 +1,25 @@ +import { SentMessageInfo, Transporter } from "nodemailer"; +import { User } from "@spacebar/util*"; + +export interface IEmail { + from: string; + to: string; + subject: string; + text: string; + html: string; +} +export interface IEmailClient { + init: () => Promise; + sendMail: ( + email: IEmail, + ) => Promise; +} + +export class BaseEmailClient implements IEmailClient { + async init(): Promise { + return; + } + sendMail(email: IEmail): Promise { + throw new Error("Method not implemented."); + } +} \ No newline at end of file diff --git a/src/util/util/email/clients/SendGridEmailClient.ts b/src/util/util/email/clients/SendGridEmailClient.ts new file mode 100644 index 0000000..cb30ea8 --- /dev/null +++ b/src/util/util/email/clients/SendGridEmailClient.ts @@ -0,0 +1,41 @@ +import { BaseEmailClient, IEmail } from "./IEmailClient"; +import { Config } from "@spacebar/util*"; + +export class SendGridEmailClient extends BaseEmailClient { + // sendGrid?: unknown; + sendGrid?: typeof import("@sendgrid/mail"); + override async init(): Promise { + // get configuration + const { apiKey } = Config.get().email.sendgrid; + + // ensure all required configuration values are set + if (!apiKey) + return console.error( + "[Email] SendGrid has not been configured correctly.", + ); + + try { + // try to import the transporter package + this.sendGrid = (await import("@sendgrid/mail")).default; + } catch { + // if the package is not installed, log an error and return void so we don't set the transporter + console.error( + "[Email] SendGrid transport is not installed. Please run `npm install Maria-Golomb/nodemailer-sendgrid-transport --save-optional` to install it.", + ); + return; + } + this.sendGrid.setApiKey(apiKey); + } + + override async sendMail(email: IEmail): Promise { + if (!this.sendGrid) throw new Error("SendGrid not initialized"); + + await this.sendGrid.send({ + to: email.to, + from: email.from, + subject: email.subject, + text: email.text, + html: email.html, + }); + } +} \ No newline at end of file diff --git a/src/util/util/email/index.ts b/src/util/util/email/index.ts index 00ff2cd..64acc3c 100644 --- a/src/util/util/email/index.ts +++ b/src/util/util/email/index.ts @@ -22,10 +22,8 @@ import { User } from "../../entities"; import { Config } from "../Config"; import { generateToken } from "../Token"; -import MailGun from "./transports/MailGun"; -import MailJet from "./transports/MailJet"; -import SMTP from "./transports/SMTP"; -import SendGrid from "./transports/SendGrid"; +import { BaseEmailClient, IEmail, IEmailClient } from "./clients/IEmailClient"; +import { SendGridEmailClient } from "./clients/SendGridEmailClient"; const ASSET_FOLDER_PATH = path.join( __dirname, @@ -36,23 +34,14 @@ "assets", ); -enum MailTypes { +export enum MailTypes { verifyEmail = "verifyEmail", resetPassword = "resetPassword", changePassword = "changePassword", } -const transporters: { - [key: string]: () => Promise | void>; -} = { - smtp: SMTP, - mailgun: MailGun, - mailjet: MailJet, - sendgrid: SendGrid, -}; - export const Email: { - transporter: Transporter | null; + transporter: IEmailClient | null; init: () => Promise; generateLink: ( type: Omit, @@ -63,13 +52,13 @@ type: MailTypes, user: User, email: string, - ) => Promise; - sendVerifyEmail: (user: User, email: string) => Promise; - sendResetPassword: (user: User, email: string) => Promise; + ) => Promise; + sendVerifyEmail: (user: User, email: string) => Promise; + sendResetPassword: (user: User, email: string) => Promise; sendPasswordChanged: ( user: User, email: string, - ) => Promise; + ) => Promise; doReplacements: ( template: string, user: User, @@ -87,15 +76,31 @@ const { provider } = Config.get().email; if (!provider) return; - const transporterFn = transporters[provider]; - if (!transporterFn) + switch (provider) { + case "smtp": + this.transporter = new BaseEmailClient(); + break; + case "sendgrid": + this.transporter = new SendGridEmailClient(); + break; + case "mailgun": + this.transporter = new BaseEmailClient(); + break; + case "mailjet": + this.transporter = new BaseEmailClient(); + break; + default: + console.error(`[Email] Invalid provider: ${provider}`); + return; + } + + if (!this.transporter) return console.error(`[Email] Invalid provider: ${provider}`); console.log(`[Email] Initializing ${provider} transport...`); - const transporter = await transporterFn(); - if (!transporter) return; - this.transporter = transporter; + await this.transporter.init(); console.log(`[Email] ${provider} transport initialized.`); }, + /** * Replaces all placeholders in an email template with the correct values */ @@ -134,6 +139,7 @@ return template; }, + /** * * @param id user id @@ -160,24 +166,48 @@ sendMail: async function (type, user, email) { if (!this.transporter) return; - const templateNames: { [key in MailTypes]: string } = { + const htmlTemplateNames: { [key in MailTypes]: string } = { verifyEmail: "verify_email.html", resetPassword: "password_reset_request.html", changePassword: "password_changed.html", }; - const template = await fs.readFile( + const textTemplateNames: { [key in MailTypes]: string } = { + verifyEmail: "verify_email.txt", + resetPassword: "password_reset_request.txt", + changePassword: "password_changed.txt", + }; + + const htmlTemplate = await fs.readFile( path.join( ASSET_FOLDER_PATH, "email_templates", - templateNames[type], + htmlTemplateNames[type], + ), + { encoding: "utf-8" }, + ); + + const textTemplate = await fs.readFile( + path.join( + ASSET_FOLDER_PATH, + "email_templates", + textTemplateNames[type], ), { encoding: "utf-8" }, ); // replace email template placeholders const html = this.doReplacements( - template, + htmlTemplate, + user, + // password change emails don't have links + type != MailTypes.changePassword + ? await this.generateLink(type, user.id, email) + : undefined, + ); + + const text = this.doReplacements( + textTemplate, user, // password change emails don't have links type != MailTypes.changePassword @@ -188,13 +218,14 @@ // extract the title from the email template to use as the email subject const subject = html.match(/(.*)<\/title>/)?.[1] || ""; - const message = { + const message: IEmail = { from: Config.get().email.senderAddress || Config.get().general.correspondenceEmail || "noreply@localhost", to: email, subject, + text, html, }; @@ -207,12 +238,14 @@ sendVerifyEmail: async function (user, email) { return this.sendMail(MailTypes.verifyEmail, user, email); }, + /** * Sends an email to the user with a link to reset their password */ sendResetPassword: async function (user, email) { return this.sendMail(MailTypes.resetPassword, user, email); }, + /** * Sends an email to the user notifying them that their password has been changed */ diff --git a/src/util/util/email/transports/SendGrid.ts b/src/util/util/email/transports/SendGrid.ts deleted file mode 100644 index a863d3e..0000000 --- a/src/util/util/email/transports/SendGrid.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - Spacebar: A FOSS re-implementation and extension of the Discord.com backend. - Copyright (C) 2023 Spacebar and Spacebar Contributors - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <https://www.gnu.org/licenses/>. -*/ - -import { Config } from "@spacebar/util"; -import nodemailer from "nodemailer"; - -export default async function () { - // get configuration - const { apiKey } = Config.get().email.sendgrid; - - // ensure all required configuration values are set - if (!apiKey) - return console.error( - "[Email] SendGrid has not been configured correctly.", - ); - - let sg; - try { - // try to import the transporter package - sg = require("nodemailer-sendgrid-transport"); - } catch { - // if the package is not installed, log an error and return void so we don't set the transporter - console.error( - "[Email] SendGrid transport is not installed. Please run `npm install Maria-Golomb/nodemailer-sendgrid-transport --save-optional` to install it.", - ); - return; - } - - // create the transporter configuration object - const auth = { - auth: { - api_key: apiKey, - }, - }; - - // create the transporter and return it - return nodemailer.createTransport(sg(auth)); -}