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', {