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

@@ -40,4 +40,4 @@ spec:
- name: db - name: db
engine: PG engine: PG
production: false production: false
version: '15' version: '17'

View File

@@ -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_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) 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

View File

@@ -5,7 +5,7 @@ const config: PartialDeep<Config> = {
auth: { 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") 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 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 enableForeverSignup: true, // If true, all signups become active for 100 years
enableMultiFactor: false, // If true, users can enable multi-factor authentication (TOTP or Passkeys) 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 // 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<Config> = {
// description: 'This is my own cloud!', // description: 'This is my own cloud!',
// helpEmail: '', // helpEmail: '',
// }, // },
// email: {
// from: 'help@bewcloud.com',
// host: 'localhost',
// port: 465,
// },
}; };
export default config; export default config;

View File

@@ -27,21 +27,22 @@
"mrmime": "https://deno.land/x/mrmime@v2.0.0/mod.ts", "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/",
"$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/": "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": "https://esm.sh/*@preact/signals@1.3.0",
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.8.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", "chart.js": "https://esm.sh/chart.js@4.4.9/auto",
"otpauth": "https://esm.sh/otpauth@9.4.0", "otpauth": "https://esm.sh/otpauth@9.4.0",
"qrcode": "https://esm.sh/qrcode@1.5.4", "qrcode": "https://esm.sh/qrcode@1.5.4",
"openid-client": "https://esm.sh/openid-client@6.5.0", "openid-client": "https://esm.sh/openid-client@6.5.0",
"@simplewebauthn/server": "jsr:@simplewebauthn/server@13.1.1", "@simplewebauthn/server": "jsr:@simplewebauthn/server@13.1.1",
"@simplewebauthn/server/helpers": "jsr:@simplewebauthn/server@13.1.1/helpers", "@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"
} }
} }

View File

@@ -1,6 +1,6 @@
services: services:
postgresql: postgresql:
image: postgres:15 image: postgres:17
environment: environment:
- POSTGRES_USER=postgres - POSTGRES_USER=postgres
- POSTGRES_PASSWORD=fake - POSTGRES_PASSWORD=fake

View File

@@ -1,6 +1,6 @@
services: services:
website: 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 restart: always
ports: ports:
- 127.0.0.1:8000:8000 - 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 # - ./bewcloud.config.ts:/app/bewcloud.config.ts # uncomment if you need to override the default config
postgresql: postgresql:
image: postgres:15 image: postgres:17
environment: environment:
- POSTGRES_USER=postgres - POSTGRES_USER=postgres
- POSTGRES_PASSWORD=fake - POSTGRES_PASSWORD=fake
- POSTGRES_DB=bewcloud - POSTGRES_DB=bewcloud
restart: on-failure restart: always
volumes: volumes:
- bewcloud-db:/var/lib/postgresql/data - bewcloud-db:/var/lib/postgresql/data
ports: ports:

View File

@@ -30,57 +30,10 @@ export class AppConfig {
description: '', description: '',
helpEmail: 'help@bewcloud.com', helpEmail: 'help@bewcloud.com',
}, },
}; email: {
} from: 'help@bewcloud.com',
host: 'localhost',
/** This allows for backwards-compatibility with the old config format, which was in the .env file. */ port: 465,
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,
}, },
}; };
} }
@@ -90,18 +43,7 @@ export class AppConfig {
return; return;
} }
let initialConfig = this.getDefaultConfig(); const 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 config: Config = { const config: Config = {
...initialConfig, ...initialConfig,
@@ -128,13 +70,17 @@ export class AppConfig {
...config.visuals, ...config.visuals,
...configFromFile.visuals, ...configFromFile.visuals,
}, },
email: {
...config.email,
...configFromFile.email,
},
}; };
console.info('\nConfig loaded from bewcloud.config.ts', JSON.stringify(this.config, null, 2), '\n'); console.info('\nConfig loaded from bewcloud.config.ts', JSON.stringify(this.config, null, 2), '\n');
return; return;
} catch (error) { } 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; this.config = config;
@@ -217,4 +163,10 @@ export class AppConfig {
return filesRootPath; 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; 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 */ /** 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; 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; enableEmailVerification: boolean;
/** If true, all signups become active for 100 years */ /** If true, all signups become active for 100 years */
enableForeverSignup: boolean; enableForeverSignup: boolean;
@@ -188,6 +188,14 @@ export interface Config {
/** The email address to contact for help. Empty will disable/hide the "need help" sections. */ /** The email address to contact for help. Empty will disable/hide the "need help" sections. */
helpEmail: string; 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'; export type MultiFactorAuthMethodType = 'totp' | 'passkey';

View File

@@ -4,7 +4,7 @@ import { generateHash, validateEmail } from '/lib/utils/misc.ts';
import { createSessionResponse, PASSWORD_SALT } from '/lib/auth.ts'; import { createSessionResponse, PASSWORD_SALT } from '/lib/auth.ts';
import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx'; import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx';
import { UserModel, VerificationCodeModel } from '/lib/models/user.ts'; 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 { FreshContextState } from '/lib/types.ts';
import { AppConfig } from '/lib/config.ts'; import { AppConfig } from '/lib/config.ts';
import { isMultiFactorAuthEnabledForUser } from '/lib/utils/multi-factor-auth.ts'; import { isMultiFactorAuthEnabledForUser } from '/lib/utils/multi-factor-auth.ts';
@@ -123,7 +123,7 @@ export const handler: Handlers<Data, FreshContextState> = {
if (!code) { if (!code) {
const verificationCode = await VerificationCodeModel.create(user, user.email, 'email'); 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.'); throw new Error('Email not verified. New code sent to verify your email.');
} else { } else {

View File

@@ -5,7 +5,7 @@ import { PASSWORD_SALT } from '/lib/auth.ts';
import { UserModel, VerificationCodeModel } from '/lib/models/user.ts'; import { UserModel, VerificationCodeModel } from '/lib/models/user.ts';
import { convertFormDataToObject, generateHash, validateEmail } from '/lib/utils/misc.ts'; import { convertFormDataToObject, generateHash, validateEmail } from '/lib/utils/misc.ts';
import { getFormDataField } from '/lib/form-utils.tsx'; 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 { AppConfig } from '/lib/config.ts';
import Settings, { Action, actionWords } from '/islands/Settings.tsx'; import Settings, { Action, actionWords } from '/islands/Settings.tsx';
@@ -93,7 +93,7 @@ export const handler: Handlers<Data, FreshContextState> = {
if (action === 'change-email' && (await AppConfig.isEmailVerificationEnabled())) { if (action === 'change-email' && (await AppConfig.isEmailVerificationEnabled())) {
const verificationCode = await VerificationCodeModel.create(user, email, 'email'); const verificationCode = await VerificationCodeModel.create(user, email, 'email');
await sendVerifyEmailEmail(email, verificationCode); await EmailModel.sendVerificationEmail(email, verificationCode);
successTitle = 'Verify your email!'; successTitle = 'Verify your email!';
successMessage = 'You have received a code in your new email. Use it to verify it here.'; successMessage = 'You have received a code in your new email. Use it to verify it here.';

View File

@@ -4,7 +4,7 @@ import { generateHash, validateEmail } from '/lib/utils/misc.ts';
import { PASSWORD_SALT } from '/lib/auth.ts'; import { PASSWORD_SALT } from '/lib/auth.ts';
import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx'; import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx';
import { UserModel, VerificationCodeModel } from '/lib/models/user.ts'; 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 { AppConfig } from '/lib/config.ts';
import { FreshContextState } from '/lib/types.ts'; import { FreshContextState } from '/lib/types.ts';
import { OidcModel } from '/lib/models/oidc.ts'; import { OidcModel } from '/lib/models/oidc.ts';
@@ -96,7 +96,7 @@ export const handler: Handlers<Data, FreshContextState> = {
if (isEmailVerificationEnabled) { if (isEmailVerificationEnabled) {
const verificationCode = await VerificationCodeModel.create(user, user.email, 'email'); 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', { return new Response('Signup successful', {