diff --git a/packages/worker/src/constants/index.ts b/packages/worker/src/constants/index.ts index 822864350e..4ed2c99714 100644 --- a/packages/worker/src/constants/index.ts +++ b/packages/worker/src/constants/index.ts @@ -1,97 +1,97 @@ -const { Config } = require("@budibase/backend-core/constants") +import { constants } from "@budibase/backend-core" -exports.LOGO_URL = +export const LOGO_URL = "https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg" -exports.UserStatus = { - ACTIVE: "active", - INACTIVE: "inactive", +export enum UserStatus { + ACTIVE = "active", + INACTIVE = "inactive", } -exports.Config = Config +export const Config = constants.Config -exports.ConfigUploads = { - LOGO: "logo", - OIDC_LOGO: "oidc_logo", +export enum ConfigUpload { + LOGO = "logo", + OIDC_LOGO = "oidc_logo", } -const TemplateTypes = { - EMAIL: "email", +export enum TemplateType { + EMAIL = "email", } -const EmailTemplatePurpose = { - BASE: "base", - PASSWORD_RECOVERY: "password_recovery", - INVITATION: "invitation", - WELCOME: "welcome", - CUSTOM: "custom", +export enum EmailTemplatePurpose { + BASE = "base", + PASSWORD_RECOVERY = "password_recovery", + INVITATION = "invitation", + WELCOME = "welcome", + CUSTOM = "custom", } -const InternalTemplateBindings = { - PLATFORM_URL: "platformUrl", - COMPANY: "company", - LOGO_URL: "logoUrl", - EMAIL: "email", - USER: "user", - REQUEST: "request", - DOCS_URL: "docsUrl", - LOGIN_URL: "loginUrl", - CURRENT_YEAR: "currentYear", - CURRENT_DATE: "currentDate", - BODY: "body", - STYLES: "styles", - RESET_URL: "resetUrl", - RESET_CODE: "resetCode", - INVITE_URL: "inviteUrl", - INVITE_CODE: "inviteUrl", - CONTENTS: "contents", +export enum InternalTemplateBinding { + PLATFORM_URL = "platformUrl", + COMPANY = "company", + LOGO_URL = "logoUrl", + EMAIL = "email", + USER = "user", + REQUEST = "request", + DOCS_URL = "docsUrl", + LOGIN_URL = "loginUrl", + CURRENT_YEAR = "currentYear", + CURRENT_DATE = "currentDate", + BODY = "body", + STYLES = "styles", + RESET_URL = "resetUrl", + RESET_CODE = "resetCode", + INVITE_URL = "inviteUrl", + INVITE_CODE = "inviteUrl", + CONTENTS = "contents", } -const TemplateBindings = { +export const TemplateBindings = { PLATFORM_URL: { - name: InternalTemplateBindings.PLATFORM_URL, + name: InternalTemplateBinding.PLATFORM_URL, description: "The URL used to access the budibase platform", }, COMPANY: { - name: InternalTemplateBindings.COMPANY, + name: InternalTemplateBinding.COMPANY, description: "The name of your organization", }, LOGO_URL: { - name: InternalTemplateBindings.LOGO_URL, + name: InternalTemplateBinding.LOGO_URL, description: "The URL of your organizations logo.", }, EMAIL: { - name: InternalTemplateBindings.EMAIL, + name: InternalTemplateBinding.EMAIL, description: "The recipients email address.", }, USER: { - name: InternalTemplateBindings.USER, + name: InternalTemplateBinding.USER, description: "The recipients user object.", }, REQUEST: { - name: InternalTemplateBindings.REQUEST, + name: InternalTemplateBinding.REQUEST, description: "Additional request metadata.", }, DOCS_URL: { - name: InternalTemplateBindings.DOCS_URL, + name: InternalTemplateBinding.DOCS_URL, description: "Organization documentation URL.", }, LOGIN_URL: { - name: InternalTemplateBindings.LOGIN_URL, + name: InternalTemplateBinding.LOGIN_URL, description: "The URL used to log into the organization budibase instance.", }, CURRENT_YEAR: { - name: InternalTemplateBindings.CURRENT_YEAR, + name: InternalTemplateBinding.CURRENT_YEAR, description: "The current year.", }, CURRENT_DATE: { - name: InternalTemplateBindings.CURRENT_DATE, + name: InternalTemplateBinding.CURRENT_DATE, description: "The current date.", }, } -const TemplateMetadata = { - [TemplateTypes.EMAIL]: [ +export const TemplateMetadata = { + [TemplateType.EMAIL]: [ { name: "Base format", description: @@ -100,11 +100,11 @@ const TemplateMetadata = { purpose: EmailTemplatePurpose.BASE, bindings: [ { - name: InternalTemplateBindings.BODY, + name: InternalTemplateBinding.BODY, description: "The main body of another email template.", }, { - name: InternalTemplateBindings.STYLES, + name: InternalTemplateBinding.STYLES, description: "The contents of the Styling email template.", }, ], @@ -117,12 +117,12 @@ const TemplateMetadata = { purpose: EmailTemplatePurpose.PASSWORD_RECOVERY, bindings: [ { - name: InternalTemplateBindings.RESET_URL, + name: InternalTemplateBinding.RESET_URL, description: "The URL the recipient must click to reset their password.", }, { - name: InternalTemplateBindings.RESET_CODE, + name: InternalTemplateBinding.RESET_CODE, description: "The temporary password reset code used in the recipients password reset URL.", }, @@ -144,12 +144,12 @@ const TemplateMetadata = { purpose: EmailTemplatePurpose.INVITATION, bindings: [ { - name: InternalTemplateBindings.INVITE_URL, + name: InternalTemplateBinding.INVITE_URL, description: "The URL the recipient must click to accept the invitation and activate their account.", }, { - name: InternalTemplateBindings.INVITE_CODE, + name: InternalTemplateBinding.INVITE_CODE, description: "The temporary invite code used in the recipients invitation URL.", }, @@ -163,7 +163,7 @@ const TemplateMetadata = { purpose: EmailTemplatePurpose.CUSTOM, bindings: [ { - name: InternalTemplateBindings.CONTENTS, + name: InternalTemplateBinding.CONTENTS, description: "Custom content body.", }, ], @@ -172,12 +172,5 @@ const TemplateMetadata = { } // all purpose combined -exports.TemplatePurpose = { - ...EmailTemplatePurpose, -} -exports.TemplateTypes = TemplateTypes -exports.EmailTemplatePurpose = EmailTemplatePurpose -exports.TemplateMetadata = TemplateMetadata -exports.TemplateBindings = TemplateBindings -exports.InternalTemplateBindings = InternalTemplateBindings -exports.GLOBAL_OWNER = "global" +export { EmailTemplatePurpose as TemplatePurpose } +export const GLOBAL_OWNER = "global" diff --git a/packages/worker/src/constants/templates/index.js b/packages/worker/src/constants/templates/index.js index cfca00f471..0631df7011 100644 --- a/packages/worker/src/constants/templates/index.js +++ b/packages/worker/src/constants/templates/index.js @@ -1,7 +1,7 @@ const { readStaticFile } = require("../../utilities/fileSystem") const { EmailTemplatePurpose, - TemplateTypes, + TemplateType, TemplatePurpose, GLOBAL_OWNER, } = require("../index") @@ -26,7 +26,7 @@ exports.EmailTemplates = { exports.addBaseTemplates = (templates, type = null) => { let purposeList switch (type) { - case TemplateTypes.EMAIL: + case TemplateType.EMAIL: purposeList = Object.values(EmailTemplatePurpose) break default: diff --git a/packages/worker/src/utilities/email.ts b/packages/worker/src/utilities/email.ts index dbf5abea93..c07604827d 100644 --- a/packages/worker/src/utilities/email.ts +++ b/packages/worker/src/utilities/email.ts @@ -1,15 +1,33 @@ +import env from "../environment" +import { EmailTemplatePurpose, TemplateType, Config } from "../constants" +import { getTemplateByPurpose } from "../constants/templates" +import { getSettingsTemplateContext } from "./templates" +import { processString } from "@budibase/string-templates" +import { getResetPasswordCode, getInviteCode } from "./redis" +import { User } from "@budibase/types" +import { tenancy, db as dbCore, PouchLike } from "@budibase/backend-core" const nodemailer = require("nodemailer") -const env = require("../environment") -const { getScopedConfig } = require("@budibase/backend-core/db") -const { EmailTemplatePurpose, TemplateTypes, Config } = require("../constants") -const { getTemplateByPurpose } = require("../constants/templates") -const { getSettingsTemplateContext } = require("./templates") -const { processString } = require("@budibase/string-templates") -const { getResetPasswordCode, getInviteCode } = require("../utilities/redis") -const { getGlobalDB } = require("@budibase/backend-core/tenancy") + +type SendEmailOpts = { + // workspaceId If finer grain controls being used then this will lookup config for workspace. + workspaceId?: string + // user If sending to an existing user the object can be provided, this is used in the context. + user: User + // from If sending from an address that is not what is configured in the SMTP config. + from?: string + // contents If sending a custom email then can supply contents which will be added to it. + contents?: string + // subject A custom subject can be specified if the config one is not desired. + subject?: string + // info Pass in a structure of information to be stored alongside the invitation. + info?: any + cc?: boolean + bcc?: boolean + automation?: boolean +} const TEST_MODE = false -const TYPE = TemplateTypes.EMAIL +const TYPE = TemplateType.EMAIL const FULL_EMAIL_PURPOSES = [ EmailTemplatePurpose.INVITATION, @@ -18,8 +36,8 @@ const FULL_EMAIL_PURPOSES = [ EmailTemplatePurpose.CUSTOM, ] -function createSMTPTransport(config) { - let options +function createSMTPTransport(config: any) { + let options: any let secure = config.secure // default it if not specified if (secure == null) { @@ -52,10 +70,15 @@ function createSMTPTransport(config) { return nodemailer.createTransport(options) } -async function getLinkCode(purpose, email, user, info = null) { +async function getLinkCode( + purpose: EmailTemplatePurpose, + email: string, + user: User, + info: any = null +) { switch (purpose) { case EmailTemplatePurpose.PASSWORD_RECOVERY: - return getResetPasswordCode(user._id, info) + return getResetPasswordCode(user._id!, info) case EmailTemplatePurpose.INVITATION: return getInviteCode(email, info) default: @@ -72,7 +95,12 @@ async function getLinkCode(purpose, email, user, info = null) { * @param {string|null} contents if using a custom template can supply contents for context. * @return {Promise} returns the built email HTML if all provided parameters were valid. */ -async function buildEmail(purpose, email, context, { user, contents } = {}) { +async function buildEmail( + purpose: EmailTemplatePurpose, + email: string, + context: any, + { user, contents }: any = {} +) { // this isn't a full email if (FULL_EMAIL_PURPOSES.indexOf(purpose) === -1) { throw `Unable to build an email of type ${purpose}` @@ -113,15 +141,19 @@ async function buildEmail(purpose, email, context, { user, contents } = {}) { * @param {boolean|null} automation Whether or not the configuration is being fetched for an email automation. * @return {Promise} returns the SMTP configuration if it exists */ -async function getSmtpConfiguration(db, workspaceId = null, automation) { - const params = { +async function getSmtpConfiguration( + db: PouchLike, + workspaceId?: string, + automation?: boolean +) { + const params: any = { type: Config.SMTP, } if (workspaceId) { params.workspace = workspaceId } - const customConfig = await getScopedConfig(db, params) + const customConfig = await dbCore.getScopedConfig(db, params) if (customConfig) { return customConfig @@ -146,12 +178,12 @@ async function getSmtpConfiguration(db, workspaceId = null, automation) { * Checks if a SMTP config exists based on passed in parameters. * @return {Promise} returns true if there is a configuration that can be used. */ -exports.isEmailConfigured = async (workspaceId = null) => { +export async function isEmailConfigured(workspaceId?: string) { // when "testing" or smtp fallback is enabled simply return true if (TEST_MODE || env.SMTP_FALLBACK_ENABLED) { return true } - const db = getGlobalDB() + const db = tenancy.getGlobalDB() const config = await getSmtpConfiguration(db, workspaceId) return config != null } @@ -161,48 +193,49 @@ exports.isEmailConfigured = async (workspaceId = null) => { * send an email using it. * @param {string} email The email address to send to. * @param {string} purpose The purpose of the email being sent (e.g. reset password). - * @param {string|undefined} workspaceId If finer grain controls being used then this will lookup config for workspace. - * @param {object|undefined} user If sending to an existing user the object can be provided, this is used in the context. - * @param {string|undefined} from If sending from an address that is not what is configured in the SMTP config. - * @param {string|undefined} contents If sending a custom email then can supply contents which will be added to it. - * @param {string|undefined} subject A custom subject can be specified if the config one is not desired. - * @param {object|undefined} info Pass in a structure of information to be stored alongside the invitation. - * @param {boolean|undefined} disableFallback Prevent email being sent from SMTP fallback to avoid spam. + * @param {object} opts The options for sending the email. * @return {Promise} returns details about the attempt to send email, e.g. if it is successful; based on * nodemailer response. */ -exports.sendEmail = async ( - email, - purpose, - { workspaceId, user, from, contents, subject, info, cc, bcc, automation } = {} -) => { - const db = getGlobalDB() - let config = (await getSmtpConfiguration(db, workspaceId, automation)) || {} +export async function sendEmail( + email: string, + purpose: EmailTemplatePurpose, + opts: SendEmailOpts +) { + const db = tenancy.getGlobalDB() + let config = + (await getSmtpConfiguration(db, opts?.workspaceId, opts?.automation)) || {} if (Object.keys(config).length === 0 && !TEST_MODE) { throw "Unable to find SMTP configuration." } const transport = createSMTPTransport(config) // if there is a link code needed this will retrieve it - const code = await getLinkCode(purpose, email, user, info) - const context = await getSettingsTemplateContext(purpose, code) + const code = await getLinkCode(purpose, email, opts.user, opts?.info) + let context + if (code) { + context = await getSettingsTemplateContext(purpose, code) + } - let message = { - from: from || config.from, + let message: any = { + from: opts?.from || config.from, html: await buildEmail(purpose, email, context, { - user, - contents, + user: opts?.user, + contents: opts?.contents, }), } message = { ...message, to: email, - cc: cc, - bcc: bcc, + cc: opts?.cc, + bcc: opts?.bcc, } - if (subject || config.subject) { - message.subject = await processString(subject || config.subject, context) + if (opts?.subject || config.subject) { + message.subject = await processString( + opts?.subject || config.subject, + context + ) } const response = await transport.sendMail(message) if (TEST_MODE) { @@ -216,7 +249,7 @@ exports.sendEmail = async ( * @param {object} config an SMTP configuration - this is based on the nodemailer API. * @return {Promise} returns true if the configuration is valid. */ -exports.verifyConfig = async config => { +export async function verifyConfig(config: any) { const transport = createSMTPTransport(config) await transport.verify() } diff --git a/packages/worker/src/utilities/redis.ts b/packages/worker/src/utilities/redis.ts index 7e474b2c28..893ec9f0a8 100644 --- a/packages/worker/src/utilities/redis.ts +++ b/packages/worker/src/utilities/redis.ts @@ -1,36 +1,35 @@ -const { Client, utils } = require("@budibase/backend-core/redis") -const { newid } = require("@budibase/backend-core/utils") +import { redis, utils } from "@budibase/backend-core" -function getExpirySecondsForDB(db) { +function getExpirySecondsForDB(db: string) { switch (db) { - case utils.Databases.PW_RESETS: + case redis.utils.Databases.PW_RESETS: // a hour return 3600 - case utils.Databases.INVITATIONS: + case redis.utils.Databases.INVITATIONS: // a day return 86400 } } -let pwResetClient, invitationClient +let pwResetClient: any, invitationClient: any -function getClient(db) { +function getClient(db: string) { switch (db) { - case utils.Databases.PW_RESETS: + case redis.utils.Databases.PW_RESETS: return pwResetClient - case utils.Databases.INVITATIONS: + case redis.utils.Databases.INVITATIONS: return invitationClient } } -async function writeACode(db, value) { +async function writeACode(db: string, value: any) { const client = await getClient(db) - const code = newid() + const code = utils.newid() await client.store(code, value, getExpirySecondsForDB(db)) return code } -async function getACode(db, code, deleteCode = true) { +async function getACode(db: string, code: string, deleteCode = true) { const client = await getClient(db) const value = await client.get(code) if (!value) { @@ -42,9 +41,9 @@ async function getACode(db, code, deleteCode = true) { return value } -exports.init = async () => { - pwResetClient = new Client(utils.Databases.PW_RESETS) - invitationClient = new Client(utils.Databases.INVITATIONS) +export async function init() { + pwResetClient = new redis.Client(redis.utils.Databases.PW_RESETS) + invitationClient = new redis.Client(redis.utils.Databases.INVITATIONS) await pwResetClient.init() await invitationClient.init() } @@ -52,7 +51,7 @@ exports.init = async () => { /** * make sure redis connection is closed. */ -exports.shutdown = async () => { +export async function shutdown() { if (pwResetClient) await pwResetClient.finish() if (invitationClient) await invitationClient.finish() console.log("Redis shutdown") @@ -65,8 +64,8 @@ exports.shutdown = async () => { * @param {object} info Info about the user/the reset process. * @return {Promise} returns the code that was stored to redis. */ -exports.getResetPasswordCode = async (userId, info) => { - return writeACode(utils.Databases.PW_RESETS, { userId, info }) +export async function getResetPasswordCode(userId: string, info: any) { + return writeACode(redis.utils.Databases.PW_RESETS, { userId, info }) } /** @@ -75,9 +74,12 @@ exports.getResetPasswordCode = async (userId, info) => { * @param {boolean} deleteCode If the code is used/finished with this will delete it - defaults to true. * @return {Promise} returns the user ID if it is found */ -exports.checkResetPasswordCode = async (resetCode, deleteCode = true) => { +export async function checkResetPasswordCode( + resetCode: string, + deleteCode = true +) { try { - return getACode(utils.Databases.PW_RESETS, resetCode, deleteCode) + return getACode(redis.utils.Databases.PW_RESETS, resetCode, deleteCode) } catch (err) { throw "Provided information is not valid, cannot reset password - please try again." } @@ -89,8 +91,8 @@ exports.checkResetPasswordCode = async (resetCode, deleteCode = true) => { * @param {object|null} info Information to be carried along with the invitation. * @return {Promise} returns the code that was stored to redis. */ -exports.getInviteCode = async (email, info) => { - return writeACode(utils.Databases.INVITATIONS, { email, info }) +export async function getInviteCode(email: string, info: any) { + return writeACode(redis.utils.Databases.INVITATIONS, { email, info }) } /** @@ -99,9 +101,12 @@ exports.getInviteCode = async (email, info) => { * @param {boolean} deleteCode whether or not the code should be deleted after retrieval - defaults to true. * @return {Promise} If the code is valid then an email address will be returned. */ -exports.checkInviteCode = async (inviteCode, deleteCode = true) => { +export async function checkInviteCode( + inviteCode: string, + deleteCode: boolean = true +) { try { - return getACode(utils.Databases.INVITATIONS, inviteCode, deleteCode) + return getACode(redis.utils.Databases.INVITATIONS, inviteCode, deleteCode) } catch (err) { throw "Invitation is not valid or has expired, please request a new one." } diff --git a/packages/worker/src/utilities/templates.ts b/packages/worker/src/utilities/templates.ts index 935495d428..ede95dbe4a 100644 --- a/packages/worker/src/utilities/templates.ts +++ b/packages/worker/src/utilities/templates.ts @@ -1,47 +1,47 @@ -const { getScopedConfig } = require("@budibase/backend-core/db") -const { +import { db as dbCore, tenancy } from "@budibase/backend-core" +import { Config, - InternalTemplateBindings, + InternalTemplateBinding, LOGO_URL, EmailTemplatePurpose, -} = require("../constants") -const { checkSlashesInUrl } = require("./index") -const { - getGlobalDB, - addTenantToUrl, -} = require("@budibase/backend-core/tenancy") +} from "../constants" +import { checkSlashesInUrl } from "./index" const BASE_COMPANY = "Budibase" -exports.getSettingsTemplateContext = async (purpose, code = null) => { - const db = getGlobalDB() +export async function getSettingsTemplateContext( + purpose: EmailTemplatePurpose, + code?: string +) { + const db = tenancy.getGlobalDB() // TODO: use more granular settings in the future if required - let settings = (await getScopedConfig(db, { type: Config.SETTINGS })) || {} + let settings = + (await dbCore.getScopedConfig(db, { type: Config.SETTINGS })) || {} const URL = settings.platformUrl - const context = { - [InternalTemplateBindings.LOGO_URL]: + const context: any = { + [InternalTemplateBinding.LOGO_URL]: checkSlashesInUrl(`${URL}/${settings.logoUrl}`) || LOGO_URL, - [InternalTemplateBindings.PLATFORM_URL]: URL, - [InternalTemplateBindings.COMPANY]: settings.company || BASE_COMPANY, - [InternalTemplateBindings.DOCS_URL]: + [InternalTemplateBinding.PLATFORM_URL]: URL, + [InternalTemplateBinding.COMPANY]: settings.company || BASE_COMPANY, + [InternalTemplateBinding.DOCS_URL]: settings.docsUrl || "https://docs.budibase.com/", - [InternalTemplateBindings.LOGIN_URL]: checkSlashesInUrl( - addTenantToUrl(`${URL}/login`) + [InternalTemplateBinding.LOGIN_URL]: checkSlashesInUrl( + tenancy.addTenantToUrl(`${URL}/login`) ), - [InternalTemplateBindings.CURRENT_DATE]: new Date().toISOString(), - [InternalTemplateBindings.CURRENT_YEAR]: new Date().getFullYear(), + [InternalTemplateBinding.CURRENT_DATE]: new Date().toISOString(), + [InternalTemplateBinding.CURRENT_YEAR]: new Date().getFullYear(), } // attach purpose specific context switch (purpose) { case EmailTemplatePurpose.PASSWORD_RECOVERY: - context[InternalTemplateBindings.RESET_CODE] = code - context[InternalTemplateBindings.RESET_URL] = checkSlashesInUrl( - addTenantToUrl(`${URL}/builder/auth/reset?code=${code}`) + context[InternalTemplateBinding.RESET_CODE] = code + context[InternalTemplateBinding.RESET_URL] = checkSlashesInUrl( + tenancy.addTenantToUrl(`${URL}/builder/auth/reset?code=${code}`) ) break case EmailTemplatePurpose.INVITATION: - context[InternalTemplateBindings.INVITE_CODE] = code - context[InternalTemplateBindings.INVITE_URL] = checkSlashesInUrl( - addTenantToUrl(`${URL}/builder/invite?code=${code}`) + context[InternalTemplateBinding.INVITE_CODE] = code + context[InternalTemplateBinding.INVITE_URL] = checkSlashesInUrl( + tenancy.addTenantToUrl(`${URL}/builder/invite?code=${code}`) ) break }