From 111321e9c6338e4ced4be1a127674e40e077e4ee Mon Sep 17 00:00:00 2001 From: Bruno Bernardino Date: Tue, 10 Jun 2025 10:28:13 +0100 Subject: [PATCH] Migrate email provider (from Brevo to generic SMTP) (#67) This means we now need to have the text and HTML content set in the code, which is arguably better. In order to avoid allowing legacy Brevo API Key support, this will also introduce breaking changes and will be released as v2.0.0. I took the opportunity to remove a few deprecated things (like legacy ENV-based config), upgrade PostgreSQL, and pin a specific version in `docker-compose.yml`, since I don't plan to do breaking releases anytime soon, and upgrading PostgreSQL should be fine from now on if the version is pinned. If you were using Brevo with an API Key, they support SMTP as well, just update your config. If you were using ENV-based config, check `bewcloud.config.sample.ts`to create your `bewcloud.config.ts`. If you need help upgrading you PostgreSQL container, I've written a simple guide [step-by-step guide](https://news.onbrn.com/step-by-step-guide-upgrading-postgresql-docker-containers/). --- .do/deploy.template.yaml | 2 +- .env.sample | 3 +- bewcloud.config.sample.ts | 7 +- deno.json | 13 +- docker-compose.dev.yml | 2 +- docker-compose.yml | 6 +- lib/config.ts | 80 ++---- lib/models/email.ts | 560 ++++++++++++++++++++++++++++++++++++++ lib/providers/brevo.ts | 86 ------ lib/types.ts | 10 +- routes/login.tsx | 4 +- routes/settings.tsx | 4 +- routes/signup.tsx | 4 +- 13 files changed, 611 insertions(+), 170 deletions(-) create mode 100644 lib/models/email.ts delete mode 100644 lib/providers/brevo.ts diff --git a/.do/deploy.template.yaml b/.do/deploy.template.yaml index 2b9d2fa..868b077 100644 --- a/.do/deploy.template.yaml +++ b/.do/deploy.template.yaml @@ -40,4 +40,4 @@ spec: - name: db engine: PG production: false - version: '15' + version: '17' diff --git a/.env.sample b/.env.sample index 2cbe1bf..0a501ae 100644 --- a/.env.sample +++ b/.env.sample @@ -16,4 +16,5 @@ MFA_SALT="fake" # optional, if you want to enable multi-factor authentication OIDC_CLIENT_ID="fake" # optional, if you want to enable SSO (Single Sign-On) OIDC_CLIENT_SECRET="fake" # optional, if you want to enable SSO (Single Sign-On) -BREVO_API_KEY="fake" # optional, if you want to enable signup email verification +SMTP_USERNAME="fake" # optional, if you want to enable signup email verification or multi-factor authentication via email +SMTP_PASSWORD="fake" # optional, if you want to enable signup email verification or multi-factor authentication via email diff --git a/bewcloud.config.sample.ts b/bewcloud.config.sample.ts index b000f41..e2de4e5 100644 --- a/bewcloud.config.sample.ts +++ b/bewcloud.config.sample.ts @@ -5,7 +5,7 @@ const config: PartialDeep = { auth: { baseUrl: 'http://localhost:8000', // The base URL of the application you use to access the app, i.e. "http://localhost:8000" or "https://cloud.example.com" (SSO redirect, if enabled, will be this + /oidc/callback, so "https://cloud.example.com/oidc/callback") allowSignups: false, // If true, anyone can sign up for an account. Note that it's always possible to sign up for the first user, and they will be an admin - enableEmailVerification: false, // If true, email verification will be required for signups (using Brevo) + enableEmailVerification: false, // If true, email verification will be required for signups (using SMTP settings below) enableForeverSignup: true, // If true, all signups become active for 100 years enableMultiFactor: false, // If true, users can enable multi-factor authentication (TOTP or Passkeys) // allowedCookieDomains: ['example.com', 'example.net'], // Can be set to allow more than the baseUrl's domain for session cookies @@ -22,6 +22,11 @@ const config: PartialDeep = { // description: 'This is my own cloud!', // helpEmail: '', // }, + // email: { + // from: 'help@bewcloud.com', + // host: 'localhost', + // port: 465, + // }, }; export default config; diff --git a/deno.json b/deno.json index 50aee6e..b0ce01e 100644 --- a/deno.json +++ b/deno.json @@ -27,21 +27,22 @@ "mrmime": "https://deno.land/x/mrmime@v2.0.0/mod.ts", "fresh/": "https://deno.land/x/fresh@1.7.3/", "$fresh/": "https://deno.land/x/fresh@1.7.3/", + "std/": "https://deno.land/std@0.224.0/", + "$std/": "https://deno.land/std@0.224.0/", "preact": "https://esm.sh/preact@10.23.2", "preact/": "https://esm.sh/preact@10.23.2/", "@preact/signals": "https://esm.sh/*@preact/signals@1.3.0", "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.8.0", - "tailwindcss": "npm:tailwindcss@3.4.17", - "tailwindcss/": "npm:/tailwindcss@3.4.17/", - "tailwindcss/plugin": "npm:/tailwindcss@3.4.17/plugin.js", - "std/": "https://deno.land/std@0.224.0/", - "$std/": "https://deno.land/std@0.224.0/", "chart.js": "https://esm.sh/chart.js@4.4.9/auto", "otpauth": "https://esm.sh/otpauth@9.4.0", "qrcode": "https://esm.sh/qrcode@1.5.4", "openid-client": "https://esm.sh/openid-client@6.5.0", "@simplewebauthn/server": "jsr:@simplewebauthn/server@13.1.1", "@simplewebauthn/server/helpers": "jsr:@simplewebauthn/server@13.1.1/helpers", - "@simplewebauthn/browser": "jsr:@simplewebauthn/browser@13.1.0" + "@simplewebauthn/browser": "jsr:@simplewebauthn/browser@13.1.0", + "tailwindcss": "npm:tailwindcss@3.4.17", + "tailwindcss/": "npm:/tailwindcss@3.4.17/", + "tailwindcss/plugin": "npm:/tailwindcss@3.4.17/plugin.js", + "nodemailer": "npm:nodemailer@7.0.3" } } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index dd7f967..c86293e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,6 +1,6 @@ services: postgresql: - image: postgres:15 + image: postgres:17 environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=fake diff --git a/docker-compose.yml b/docker-compose.yml index 77e3573..ffca259 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: website: - image: ghcr.io/bewcloud/bewcloud:main # alternatively, you can use a specific version/tag, for greater stability + image: ghcr.io/bewcloud/bewcloud:v2.0.0 restart: always ports: - 127.0.0.1:8000:8000 @@ -13,12 +13,12 @@ services: # - ./bewcloud.config.ts:/app/bewcloud.config.ts # uncomment if you need to override the default config postgresql: - image: postgres:15 + image: postgres:17 environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=fake - POSTGRES_DB=bewcloud - restart: on-failure + restart: always volumes: - bewcloud-db:/var/lib/postgresql/data ports: diff --git a/lib/config.ts b/lib/config.ts index 0c26280..9b93b4a 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -30,57 +30,10 @@ export class AppConfig { description: '', helpEmail: 'help@bewcloud.com', }, - }; - } - - /** This allows for backwards-compatibility with the old config format, which was in the .env file. */ - private static async getLegacyConfigFromEnv(): Promise { - const defaultConfig = this.getDefaultConfig(); - - if (typeof Deno === 'undefined') { - return defaultConfig; - } - - await import('std/dotenv/load.ts'); - - const baseUrl = Deno.env.get('BASE_URL') ?? defaultConfig.auth.baseUrl; - const allowSignups = Deno.env.get('CONFIG_ALLOW_SIGNUPS') === 'true'; - const enabledApps = (Deno.env.get('CONFIG_ENABLED_APPS') ?? '').split(',') as OptionalApp[]; - const filesRootPath = Deno.env.get('CONFIG_FILES_ROOT_PATH') ?? defaultConfig.files.rootPath; - const enableEmailVerification = (Deno.env.get('CONFIG_ENABLE_EMAILS') ?? 'false') === 'true'; - const enableForeverSignup = (Deno.env.get('CONFIG_ENABLE_FOREVER_SIGNUP') ?? 'true') === 'true'; - const allowedCookieDomains = (Deno.env.get('CONFIG_ALLOWED_COOKIE_DOMAINS') || '').split(',').filter( - Boolean, - ) as string[]; - const skipCookieDomainSecurity = Deno.env.get('CONFIG_SKIP_COOKIE_DOMAIN_SECURITY') === 'true'; - const title = Deno.env.get('CUSTOM_TITLE') ?? defaultConfig.visuals.title; - const description = Deno.env.get('CUSTOM_DESCRIPTION') ?? defaultConfig.visuals.description; - const helpEmail = Deno.env.get('HELP_EMAIL') ?? defaultConfig.visuals.helpEmail; - - return { - ...defaultConfig, - auth: { - ...defaultConfig.auth, - baseUrl, - allowSignups, - enableEmailVerification, - enableForeverSignup, - allowedCookieDomains, - skipCookieDomainSecurity, - }, - files: { - ...defaultConfig.files, - rootPath: filesRootPath, - }, - core: { - ...defaultConfig.core, - enabledApps, - }, - visuals: { - ...defaultConfig.visuals, - title, - description, - helpEmail, + email: { + from: 'help@bewcloud.com', + host: 'localhost', + port: 465, }, }; } @@ -90,18 +43,7 @@ export class AppConfig { return; } - let initialConfig = this.getDefaultConfig(); - - if ( - typeof Deno.env.get('BASE_URL') === 'string' || typeof Deno.env.get('CONFIG_ALLOW_SIGNUPS') === 'string' || - typeof Deno.env.get('CONFIG_ENABLED_APPS') === 'string' - ) { - console.warn( - '\nDEPRECATION WARNING: .env file has config variables. This will be used but is deprecated. Please use the bewcloud.config.ts file instead.', - ); - - initialConfig = await this.getLegacyConfigFromEnv(); - } + const initialConfig = this.getDefaultConfig(); const config: Config = { ...initialConfig, @@ -128,13 +70,17 @@ export class AppConfig { ...config.visuals, ...configFromFile.visuals, }, + email: { + ...config.email, + ...configFromFile.email, + }, }; console.info('\nConfig loaded from bewcloud.config.ts', JSON.stringify(this.config, null, 2), '\n'); return; } catch (error) { - console.error('Error loading config from bewcloud.config.ts. Using default and legacy config instead.', error); + console.error('Error loading config from bewcloud.config.ts. Using default config instead.', error); } this.config = config; @@ -217,4 +163,10 @@ export class AppConfig { return filesRootPath; } + + static async getEmailConfig(): Promise { + await this.loadConfig(); + + return this.config.email; + } } diff --git a/lib/models/email.ts b/lib/models/email.ts new file mode 100644 index 0000000..3c181d1 --- /dev/null +++ b/lib/models/email.ts @@ -0,0 +1,560 @@ +// deno-fmt-ignore-file +import nodemailer from 'nodemailer'; +import 'std/dotenv/load.ts'; + +import { escapeHtml } from '/lib/utils/misc.ts'; +import { AppConfig } from '/lib/config.ts'; + +const SMTP_USERNAME = Deno.env.get('SMTP_USERNAME') || ''; +const SMTP_PASSWORD = Deno.env.get('SMTP_PASSWORD') || ''; + +export class EmailModel { + private static async send(to: string, subject: string, htmlBody: string, textBody: string) { + const emailConfig = await AppConfig.getEmailConfig(); + + if (!emailConfig.from || !emailConfig.host || !emailConfig.port) { + throw new Error('config.email.from, config.email.host, or config.email.port is not set'); + } + + const transporterConfig = { + host: emailConfig.host, + port: emailConfig.port, + secure: Number(emailConfig.port) === 465, + auth: { + user: SMTP_USERNAME, + pass: SMTP_PASSWORD, + }, + }; + + const transporter = nodemailer.createTransport(transporterConfig); + + const mailOptions = { + from: emailConfig.from, + to, + subject, + html: htmlBody, + text: textBody, + }; + + try { + await transporter.sendMail(mailOptions); + console.log(`Email sent to "${to}", "${subject}"`); + } catch (error) { + console.log(error); + throw new Error(`Failed to send email to "${to}", "${subject}"`); + } + } + + static async sendVerificationEmail( + email: string, + verificationCode: string, + ) { + const emailTitle = 'Verify your email in bewCloud'; + + const textBody = ` +${emailTitle} +------------------------ + +You or someone who knows your email is trying to verify it in bewCloud. + +Here's the verification code: + +**${verificationCode}** +=============================== + +This code will expire in 30 minutes. + `; + + /** Based off of https://github.com/ActiveCampaign/postmark-templates/tree/main/templates-inlined/basic/password-reset */ + const htmlBody = ` + + + + + + + + + ${escapeHtml(emailTitle)} + + + + + Use this link to reset your password. The link is only valid for 24 hours. + + + + + + + + `; + + await this.send(email, emailTitle, htmlBody, textBody); + } +} diff --git a/lib/providers/brevo.ts b/lib/providers/brevo.ts deleted file mode 100644 index 6bc5041..0000000 --- a/lib/providers/brevo.ts +++ /dev/null @@ -1,86 +0,0 @@ -import 'std/dotenv/load.ts'; - -import { AppConfig } from '/lib/config.ts'; - -const BREVO_API_KEY = Deno.env.get('BREVO_API_KEY') || ''; - -enum BrevoTemplateId { - BEWCLOUD_VERIFY_EMAIL = 20, // NOTE: This will likely be different in your own Brevo account -} - -interface BrevoResponse { - messageId?: string; - code?: string; - message?: string; -} - -function getApiRequestHeaders() { - return { - 'Api-Key': BREVO_API_KEY, - 'Accept': 'application/json; charset=utf-8', - 'Content-Type': 'application/json; charset=utf-8', - }; -} - -interface BrevoRequestBody { - templateId?: number; - params: Record | null; - to: { email: string; name?: string }[]; - cc?: { email: string; name?: string }[]; - bcc?: { email: string; name?: string }[]; - htmlContent?: string; - textContent?: string; - subject?: string; - replyTo: { email: string; name?: string }; - tags?: string[]; - attachment?: { name: string; content: string; url: string }[]; -} - -async function sendEmailWithTemplate( - to: string, - templateId: BrevoTemplateId, - data: BrevoRequestBody['params'], - attachments: BrevoRequestBody['attachment'] = [], - cc?: string, -) { - const config = await AppConfig.getConfig(); - const helpEmail = config.visuals.helpEmail; - - const email: BrevoRequestBody = { - templateId, - params: data, - to: [{ email: to }], - replyTo: { email: helpEmail }, - }; - - if (attachments?.length) { - email.attachment = attachments; - } - - if (cc) { - email.cc = [{ email: cc }]; - } - - const brevoResponse = await fetch('https://api.brevo.com/v3/smtp/email', { - method: 'POST', - headers: getApiRequestHeaders(), - body: JSON.stringify(email), - }); - const brevoResult = (await brevoResponse.json()) as BrevoResponse; - - if (brevoResult.code || brevoResult.message) { - console.log(JSON.stringify({ brevoResult }, null, 2)); - throw new Error(`Failed to send email "${templateId}"`); - } -} - -export async function sendVerifyEmailEmail( - email: string, - verificationCode: string, -) { - const data = { - verificationCode, - }; - - await sendEmailWithTemplate(email, BrevoTemplateId.BEWCLOUD_VERIFY_EMAIL, data); -} diff --git a/lib/types.ts b/lib/types.ts index 6b89306..53358c2 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -153,7 +153,7 @@ export interface Config { baseUrl: string; /** If true, anyone can sign up for an account. Note that it's always possible to sign up for the first user, and they will be an admin */ allowSignups: boolean; - /** If true, email verification will be required for signups (using Brevo) */ + /** If true, email verification will be required for signups (using SMTP settings below) */ enableEmailVerification: boolean; /** If true, all signups become active for 100 years */ enableForeverSignup: boolean; @@ -188,6 +188,14 @@ export interface Config { /** The email address to contact for help. Empty will disable/hide the "need help" sections. */ helpEmail: string; }; + email: { + /** The email address to send emails from */ + from: string; + /** The SMTP host to send emails from */ + host: string; + /** The SMTP port to send emails from */ + port: number; + }; } export type MultiFactorAuthMethodType = 'totp' | 'passkey'; diff --git a/routes/login.tsx b/routes/login.tsx index a952d61..a19ef41 100644 --- a/routes/login.tsx +++ b/routes/login.tsx @@ -4,7 +4,7 @@ import { generateHash, validateEmail } from '/lib/utils/misc.ts'; import { createSessionResponse, PASSWORD_SALT } from '/lib/auth.ts'; import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx'; import { UserModel, VerificationCodeModel } from '/lib/models/user.ts'; -import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts'; +import { EmailModel } from '/lib/models/email.ts'; import { FreshContextState } from '/lib/types.ts'; import { AppConfig } from '/lib/config.ts'; import { isMultiFactorAuthEnabledForUser } from '/lib/utils/multi-factor-auth.ts'; @@ -123,7 +123,7 @@ export const handler: Handlers = { if (!code) { const verificationCode = await VerificationCodeModel.create(user, user.email, 'email'); - await sendVerifyEmailEmail(user.email, verificationCode); + await EmailModel.sendVerificationEmail(user.email, verificationCode); throw new Error('Email not verified. New code sent to verify your email.'); } else { diff --git a/routes/settings.tsx b/routes/settings.tsx index 12a2f61..b862bae 100644 --- a/routes/settings.tsx +++ b/routes/settings.tsx @@ -5,7 +5,7 @@ import { PASSWORD_SALT } from '/lib/auth.ts'; import { UserModel, VerificationCodeModel } from '/lib/models/user.ts'; import { convertFormDataToObject, generateHash, validateEmail } from '/lib/utils/misc.ts'; import { getFormDataField } from '/lib/form-utils.tsx'; -import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts'; +import { EmailModel } from '/lib/models/email.ts'; import { AppConfig } from '/lib/config.ts'; import Settings, { Action, actionWords } from '/islands/Settings.tsx'; @@ -93,7 +93,7 @@ export const handler: Handlers = { if (action === 'change-email' && (await AppConfig.isEmailVerificationEnabled())) { const verificationCode = await VerificationCodeModel.create(user, email, 'email'); - await sendVerifyEmailEmail(email, verificationCode); + await EmailModel.sendVerificationEmail(email, verificationCode); successTitle = 'Verify your email!'; successMessage = 'You have received a code in your new email. Use it to verify it here.'; diff --git a/routes/signup.tsx b/routes/signup.tsx index d231977..bb4ea9e 100644 --- a/routes/signup.tsx +++ b/routes/signup.tsx @@ -4,7 +4,7 @@ import { generateHash, validateEmail } from '/lib/utils/misc.ts'; import { PASSWORD_SALT } from '/lib/auth.ts'; import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx'; import { UserModel, VerificationCodeModel } from '/lib/models/user.ts'; -import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts'; +import { EmailModel } from '/lib/models/email.ts'; import { AppConfig } from '/lib/config.ts'; import { FreshContextState } from '/lib/types.ts'; import { OidcModel } from '/lib/models/oidc.ts'; @@ -96,7 +96,7 @@ export const handler: Handlers = { if (isEmailVerificationEnabled) { const verificationCode = await VerificationCodeModel.create(user, user.email, 'email'); - await sendVerifyEmailEmail(user.email, verificationCode); + await EmailModel.sendVerificationEmail(user.email, verificationCode); } return new Response('Signup successful', {