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/).
This commit is contained in:
Bruno Bernardino
2025-06-10 10:28:13 +01:00
committed by GitHub
parent 3038461fb7
commit 111321e9c6
13 changed files with 611 additions and 170 deletions

View File

@@ -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<Config> {
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<Config['email']> {
await this.loadConfig();
return this.config.email;
}
}

560
lib/models/email.ts Normal file
View File

@@ -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 = `
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<title>${escapeHtml(emailTitle)}</title>
<style type="text/css" rel="stylesheet" media="all">
/* Base ------------------------------ */
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
a {
color: #3869D4;
}
a img {
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
/* Type ------------------------------ */
body,
td,
th {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
h2 {
margin-top: 0;
color: #333333;
font-size: 16px;
font-weight: bold;
text-align: left;
}
h3 {
margin-top: 0;
color: #333333;
font-size: 14px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p,
ul,
ol,
blockquote {
margin: .4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
font-size: 13px;
}
/* Utilities ------------------------------ */
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
.u-margin-bottom-none {
margin-bottom: 0;
}
/* Buttons ------------------------------ */
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
border-right: 18px solid #3869D4;
border-bottom: 10px solid #3869D4;
border-left: 18px solid #3869D4;
display: inline-block;
color: #FFF;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
border-right: 18px solid #22BC66;
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
.button--red {
background-color: #FF6136;
border-top: 10px solid #FF6136;
border-right: 18px solid #FF6136;
border-bottom: 10px solid #FF6136;
border-left: 18px solid #FF6136;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
/* Attribute list ------------------------------ */
.attributes {
margin: 0 0 21px;
}
.attributes_content {
background-color: #F4F4F7;
padding: 16px;
}
.attributes_item {
padding: 0;
}
/* Related Items ------------------------------ */
.related {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.related_item {
padding: 10px 0;
color: #CBCCCF;
font-size: 15px;
line-height: 18px;
}
.related_item-title {
display: block;
margin: .5em 0 0;
}
.related_item-thumb {
display: block;
padding-bottom: 10px;
}
.related_heading {
border-top: 1px solid #CBCCCF;
text-align: center;
padding: 25px 0 10px;
}
/* Discount Code ------------------------------ */
.discount {
width: 100%;
margin: 0;
padding: 24px;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F4F4F7;
border: 2px dashed #CBCCCF;
}
.discount_heading {
text-align: center;
}
.discount_body {
text-align: center;
font-size: 15px;
}
/* Social Icons ------------------------------ */
.social {
width: auto;
}
.social td {
padding: 0;
width: auto;
}
.social_icon {
height: 20px;
margin: 0 8px 10px 8px;
padding: 0;
}
/* Data table ------------------------------ */
.purchase {
width: 100%;
margin: 0;
padding: 35px 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_content {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_item {
padding: 10px 0;
color: #51545E;
font-size: 15px;
line-height: 18px;
}
.purchase_heading {
padding-bottom: 8px;
border-bottom: 1px solid #EAEAEC;
}
.purchase_heading p {
margin: 0;
color: #85878E;
font-size: 12px;
}
.purchase_footer {
padding-top: 15px;
border-top: 1px solid #EAEAEC;
}
.purchase_total {
margin: 0;
text-align: right;
font-weight: bold;
color: #333333;
}
.purchase_total--label {
padding: 0 15px 0 0;
}
body {
background-color: #F2F4F6;
color: #51545E;
}
p {
color: #51545E;
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F2F4F6;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
/* Masthead ----------------------- */
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead_logo {
width: 94px;
}
.email-masthead_name {
font-size: 16px;
font-weight: bold;
color: #A8AAAF;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
/* Body ------------------------------ */
.email-body {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-body_inner {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #FFFFFF;
}
.email-footer {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.email-footer p {
color: #A8AAAF;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 45px;
}
/*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) {
.email-body_inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body,
.email-body,
.email-body_inner,
.email-content,
.email-wrapper,
.email-masthead,
.email-footer {
background-color: #333333 !important;
color: #FFF !important;
}
p,
ul,
ol,
blockquote,
h1,
h2,
h3,
span,
.purchase_item {
color: #FFF !important;
}
.attributes_content,
.discount {
background-color: #222 !important;
}
.email-masthead_name {
text-shadow: none !important;
}
}
:root {
color-scheme: light dark;
supported-color-schemes: light dark;
}
</style>
<!--[if mso]>
<style type="text/css">
.f-fallback {
font-family: Arial, sans-serif;
}
</style>
<![endif]-->
</head>
<body>
<span class="preheader">Use this link to reset your password. The link is only valid for 24 hours.</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="email-masthead">
<a href="https://bewcloud.com" class="f-fallback email-masthead_name">
bewCloud
</a>
</td>
</tr>
<tr>
<td class="email-body" width="570" cellpadding="0" cellspacing="0">
<table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell">
<div class="f-fallback">
<h1>${escapeHtml(emailTitle)}</h1>
<p>You or someone who knows your email is trying to verify it in bewCloud.</p>
<p>Here's the verification code:</p>
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<span class="f-fallback button button--green">${escapeHtml(verificationCode)}</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>This code will expire in 30 minutes.</p>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`;
await this.send(email, emailTitle, htmlBody, textBody);
}
}

View File

@@ -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<string, any> | 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);
}

View File

@@ -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';