Merge pull request #1479 from Budibase/smtp-configuration

Smtp configuration
This commit is contained in:
Martin McKeaveney 2021-05-12 12:27:20 +01:00 committed by GitHub
commit 306dd298d6
47 changed files with 2593 additions and 2210 deletions

View File

@ -16,7 +16,7 @@ services:
MINIO_URL: http://minio-service:9000 MINIO_URL: http://minio-service:9000
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY} MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
HOSTING_KEY: ${HOSTING_KEY} INTERNAL_API_KEY: ${INTERNAL_API_KEY}
BUDIBASE_ENVIRONMENT: ${BUDIBASE_ENVIRONMENT} BUDIBASE_ENVIRONMENT: ${BUDIBASE_ENVIRONMENT}
PORT: 4002 PORT: 4002
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
@ -44,7 +44,7 @@ services:
COUCH_DB_USERNAME: ${COUCH_DB_USER} COUCH_DB_USERNAME: ${COUCH_DB_USER}
COUCH_DB_PASSWORD: ${COUCH_DB_PASSWORD} COUCH_DB_PASSWORD: ${COUCH_DB_PASSWORD}
COUCH_DB_URL: http://${COUCH_DB_USER}:${COUCH_DB_PASSWORD}@couchdb-service:5984 COUCH_DB_URL: http://${COUCH_DB_USER}:${COUCH_DB_PASSWORD}@couchdb-service:5984
SELF_HOST_KEY: ${HOSTING_KEY} INTERNAL_API_KEY: ${INTERNAL_API_KEY}
REDIS_URL: redis-service:6379 REDIS_URL: redis-service:6379
REDIS_PASSWORD: ${REDIS_PASSWORD} REDIS_PASSWORD: ${REDIS_PASSWORD}
depends_on: depends_on:

View File

@ -1,10 +1,6 @@
# Use the main port in the builder for your self hosting URL, e.g. localhost:10000 # Use the main port in the builder for your self hosting URL, e.g. localhost:10000
MAIN_PORT=10000 MAIN_PORT=10000
# Use this password when configuring your self hosting settings
# This should be updated
HOSTING_KEY=budibase
# This section contains all secrets pertaining to the system # This section contains all secrets pertaining to the system
# These should be updated # These should be updated
JWT_SECRET=testsecret JWT_SECRET=testsecret
@ -13,6 +9,7 @@ MINIO_SECRET_KEY=budibase
COUCH_DB_PASSWORD=budibase COUCH_DB_PASSWORD=budibase
COUCH_DB_USER=budibase COUCH_DB_USER=budibase
REDIS_PASSWORD=budibase REDIS_PASSWORD=budibase
INTERNAL_API_KEY=budibase
# This section contains variables that do not need to be altered under normal circumstances # This section contains variables that do not need to be altered under normal circumstances
APP_PORT=4002 APP_PORT=4002

View File

@ -71,7 +71,7 @@ exports.getGlobalUserParams = (globalId, otherProps = {}) => {
* @param ownerId The owner/user of the template, this could be global or a group level. * @param ownerId The owner/user of the template, this could be global or a group level.
*/ */
exports.generateTemplateID = ownerId => { exports.generateTemplateID = ownerId => {
return `${DocumentTypes.TEMPLATE}${SEPARATOR}${ownerId}${newid()}` return `${DocumentTypes.TEMPLATE}${SEPARATOR}${ownerId}${SEPARATOR}${newid()}`
} }
/** /**

View File

@ -15,5 +15,6 @@ module.exports = {
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY, MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY, MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
MINIO_URL: process.env.MINIO_URL, MINIO_URL: process.env.MINIO_URL,
INTERNAL_API_KEY: process.env.INTERNAL_API_KEY,
isTest, isTest,
} }

View File

@ -2,6 +2,7 @@ const { Cookies } = require("../constants")
const database = require("../db") const database = require("../db")
const { getCookie, clearCookie } = require("../utils") const { getCookie, clearCookie } = require("../utils")
const { StaticDatabases } = require("../db/utils") const { StaticDatabases } = require("../db/utils")
const env = require("../environment")
const PARAM_REGEX = /\/:(.*?)\//g const PARAM_REGEX = /\/:(.*?)\//g
@ -35,10 +36,14 @@ module.exports = (noAuthPatterns = [], opts) => {
return next() return next()
} }
try { try {
const apiKey = ctx.request.headers["x-budibase-api-key"]
// check the actual user is authenticated first // check the actual user is authenticated first
const authCookie = getCookie(ctx, Cookies.Auth) const authCookie = getCookie(ctx, Cookies.Auth)
if (authCookie) { // this is an internal request, no user made it
if (apiKey && apiKey === env.INTERNAL_API_KEY) {
ctx.isAuthenticated = true
} else if (authCookie) {
try { try {
const db = database.getDB(StaticDatabases.GLOBAL.name) const db = database.getDB(StaticDatabases.GLOBAL.name)
const user = await db.get(authCookie.userId) const user = await db.get(authCookie.userId)

View File

@ -15,6 +15,11 @@
</p> </p>
<style> <style>
p {
margin-top: 0.75em;
margin-bottom: 0.75em;
}
.noPadding { .noPadding {
padding: 0; padding: 0;
margin: 0; margin: 0;

View File

@ -7,6 +7,10 @@
import WebhookDisplay from "../Shared/WebhookDisplay.svelte" import WebhookDisplay from "../Shared/WebhookDisplay.svelte"
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte" import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
import CodeEditorModal from "./CodeEditorModal.svelte"
import QuerySelector from "./QuerySelector.svelte"
import QueryParamSelector from "./QueryParamSelector.svelte"
import Editor from "components/integration/QueryEditor.svelte"
export let block export let block
export let webhookModal export let webhookModal
@ -70,6 +74,10 @@
on:change={e => (block.inputs[key] = e.detail)} on:change={e => (block.inputs[key] = e.detail)}
{bindings} {bindings}
/> />
{:else if value.customType === "query"}
<QuerySelector bind:value={block.inputs[key]} />
{:else if value.customType === "queryParams"}
<QueryParamSelector bind:value={block.inputs[key]} {bindings} />
{:else if value.customType === "table"} {:else if value.customType === "table"}
<TableSelector bind:value={block.inputs[key]} /> <TableSelector bind:value={block.inputs[key]} />
{:else if value.customType === "row"} {:else if value.customType === "row"}
@ -78,6 +86,17 @@
<WebhookDisplay value={block.inputs[key]} /> <WebhookDisplay value={block.inputs[key]} />
{:else if value.customType === "triggerSchema"} {:else if value.customType === "triggerSchema"}
<SchemaSetup bind:value={block.inputs[key]} /> <SchemaSetup bind:value={block.inputs[key]} />
{:else if value.customType === "code"}
<CodeEditorModal>
<pre>{JSON.stringify(bindings, null, 2)}</pre>
<Editor
mode="javascript"
on:change={e => {
block.inputs[key] = e.detail.value
}}
value={block.inputs[key]}
/>
</CodeEditorModal>
{:else if value.type === "string" || value.type === "number"} {:else if value.type === "string" || value.type === "number"}
<DrawerBindableInput <DrawerBindableInput
panel={AutomationBindingPanel} panel={AutomationBindingPanel}

View File

@ -11,8 +11,9 @@
} }
</script> </script>
<Modal bind:this={modal} width="60%"> <Modal bind:this={modal}>
<ModalContent <ModalContent
size="XL"
title="Edit Code" title="Edit Code"
showConfirmButton={false} showConfirmButton={false}
showCancelButton={false} showCancelButton={false}

View File

@ -1,8 +1,8 @@
<script> <script>
import { queries } from "stores/backend" import { queries } from "stores/backend"
import { Select } from "@budibase/bbui" import { Select } from "@budibase/bbui"
import DrawerBindableInput from "../../common/DrawerBindableInput.svelte" import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
import AutomationBindingPanel from "./AutomationBindingPanel.svelte" import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
export let value export let value
export let bindings export let bindings
@ -13,7 +13,6 @@
// Ensure any nullish queryId values get set to empty string so // Ensure any nullish queryId values get set to empty string so
// that the select works // that the select works
$: if (value?.queryId == null) value = { queryId: "" } $: if (value?.queryId == null) value = { queryId: "" }
$: console.log("daValuz", value)
</script> </script>
<div class="block-field"> <div class="block-field">

View File

@ -117,7 +117,7 @@
readOnly, readOnly,
autoCloseBrackets: true, autoCloseBrackets: true,
autoCloseTags: true, autoCloseTags: true,
theme: $themeStore.darkMode ? THEMES.DARK : THEMES.LIGHT, theme: THEMES.DARK,
} }
if (!tab) if (!tab)

View File

@ -161,7 +161,5 @@
} }
.content { .content {
overflow: auto; overflow: auto;
display: grid;
grid-template-rows: 1fr;
} }
</style> </style>

View File

@ -0,0 +1,22 @@
<script>
import {
Body,
Menu,
MenuItem,
Detail,
MenuSection,
DetailSummary,
} from "@budibase/bbui"
export let bindings
export let onBindingClick = () => {}
</script>
<Menu>
{#each bindings as binding}
<MenuItem on:click={() => onBindingClick(binding)}>
<Detail size="M">{binding.name}</Detail>
<Body size="XS" noPadding>{binding.description}</Body>
</MenuItem>
{/each}
</Menu>

View File

@ -0,0 +1,13 @@
<script>
import { goto } from "@roxi/routify"
export let value
</script>
<span on:click={() => $goto(`./${value}`)}>{value}</span>
<style>
span {
text-transform: capitalize;
}
</style>

View File

@ -0,0 +1,142 @@
<script>
import {
Menu,
MenuItem,
Button,
Detail,
Heading,
Divider,
Label,
Modal,
ModalContent,
notifications,
Layout,
Icon,
Body,
Page,
Select,
Tabs,
Tab,
MenuSection,
MenuSeparator,
} from "@budibase/bbui"
import { goto } from "@roxi/routify"
import { onMount } from "svelte"
import { fade } from "svelte/transition"
import { email } from "stores/portal"
import Editor from "components/integration/QueryEditor.svelte"
import TemplateBindings from "./TemplateBindings.svelte"
import api from "builderStore/api"
const ConfigTypes = {
SMTP: "smtp",
}
export let template
let selected = "Edit"
let selectedBindingTab = "Template"
let htmlEditor
$: selectedTemplate = $email.templates.find(
({ purpose }) => purpose === template
)
$: templateBindings =
$email.definitions?.bindings[selectedTemplate.purpose] || []
async function saveTemplate() {
try {
// Save your template config
await email.templates.save(selectedTemplate)
notifications.success(`Template saved.`)
} catch (err) {
notifications.error(`Failed to update template settings. ${err}`)
}
}
function setTemplateBinding(binding) {
htmlEditor.update((selectedTemplate.contents += `{{ ${binding.name} }}`))
}
</script>
<Page wide gap="L">
<div class="backbutton" on:click={() => $goto("./")}>
<Icon name="BackAndroid" />
<span>Back</span>
</div>
<header>
<Heading>
Email Template: {template}
</Heading>
<Button cta on:click={saveTemplate}>Save</Button>
</header>
<Tabs {selected}>
<Tab title="Edit">
<div class="template-editor">
<Editor
editorHeight={800}
bind:this={htmlEditor}
mode="handlebars"
on:change={e => {
selectedTemplate.contents = e.detail.value
}}
value={selectedTemplate.contents}
/>
<div class="bindings-editor">
<Detail size="L">Bindings</Detail>
<Tabs selected={selectedBindingTab}>
<Tab title="Template">
<TemplateBindings
title="Template Bindings"
bindings={templateBindings}
onBindingClick={setTemplateBinding}
/>
</Tab>
<Tab title="Common">
<TemplateBindings
title="Common Bindings"
bindings={$email.definitions.bindings.common}
onBindingClick={setTemplateBinding}
/>
</Tab>
</Tabs>
</div>
</div></Tab
>
<Tab title="Preview">
<div class="preview" transition:fade>
{@html selectedTemplate.contents}
</div>
</Tab>
</Tabs>
</Page>
<style>
.template-editor {
display: grid;
grid-template-columns: 1fr 20%;
grid-gap: var(--spacing-xl);
margin-top: var(--spacing-xl);
}
header {
display: flex;
width: 100%;
justify-content: space-between;
margin-bottom: var(--spacing-l);
margin-top: var(--spacing-l);
}
.preview {
background: white;
height: 800px;
padding: var(--spacing-xl);
}
.backbutton {
display: flex;
gap: var(--spacing-m);
margin-bottom: var(--spacing-xl);
cursor: pointer;
}
</style>

View File

@ -0,0 +1,191 @@
<script>
import { goto } from "@roxi/routify"
import {
Menu,
MenuItem,
Button,
Heading,
Divider,
Label,
Modal,
ModalContent,
notifications,
Layout,
Input,
TextArea,
Body,
Page,
Select,
MenuSection,
MenuSeparator,
Table,
} from "@budibase/bbui"
import { onMount } from "svelte"
import { email } from "stores/portal"
import Editor from "components/integration/QueryEditor.svelte"
import TemplateBindings from "./TemplateBindings.svelte"
import TemplateLink from "./TemplateLink.svelte"
import api from "builderStore/api"
const ConfigTypes = {
SMTP: "smtp",
}
const templateSchema = {
purpose: {
displayName: "Email",
editable: false,
},
}
const customRenderers = [
{
column: "purpose",
component: TemplateLink,
},
]
let smtpConfig
let bindingsOpen = false
let htmlModal
let htmlEditor
let loading
async function saveSmtp() {
try {
// Save your SMTP config
const response = await api.post(`/api/admin/configs`, smtpConfig)
const json = await response.json()
if (response.status !== 200) throw new Error(json.message)
smtpConfig._rev = json._rev
smtpConfig._id = json._id
notifications.success(`Settings saved.`)
} catch (err) {
notifications.error(`Failed to save email settings. ${err}`)
}
}
async function saveTemplate() {
try {
await email.templates.save(selectedTemplate)
notifications.success(`Template saved.`)
} catch (err) {
notifications.error(`Failed to update template settings. ${err}`)
}
}
async function fetchSmtp() {
// fetch the configs for smtp
const smtpResponse = await api.get(`/api/admin/configs/${ConfigTypes.SMTP}`)
const smtpDoc = await smtpResponse.json()
if (!smtpDoc._id) {
smtpConfig = {
type: ConfigTypes.SMTP,
config: {
auth: {
type: "login",
},
},
}
} else {
smtpConfig = smtpDoc
}
}
onMount(async () => {
loading = true
await fetchSmtp()
await email.templates.fetch()
loading = false
})
</script>
<Page>
<header>
<Heading size="M">Email</Heading>
<Body size="S">
Sending email is not required, but highly recommended for processes such
as password recovery. To setup automated auth emails, simply add the
values below and click activate.
</Body>
</header>
<Divider />
{#if smtpConfig}
<div class="config-form">
<Heading size="S">SMTP</Heading>
<Body size="S">
To allow your app to benefit from automated auth emails, add your SMTP
details below.
</Body>
<Layout gap="S">
<Heading size="S">
<span />
</Heading>
<div class="form-row">
<Label>Host</Label>
<Input bind:value={smtpConfig.config.host} />
</div>
<div class="form-row">
<Label>Port</Label>
<Input type="number" bind:value={smtpConfig.config.port} />
</div>
<div class="form-row">
<Label>User</Label>
<Input bind:value={smtpConfig.config.auth.user} />
</div>
<div class="form-row">
<Label>Password</Label>
<Input type="password" bind:value={smtpConfig.config.auth.pass} />
</div>
<div class="form-row">
<Label>From email address</Label>
<Input type="email" bind:value={smtpConfig.config.from} />
</div>
</Layout>
<Button cta on:click={saveSmtp}>Save</Button>
</div>
<Divider />
<div class="config-form">
<Heading size="S">Templates</Heading>
<Body size="S">
Budibase comes out of the box with ready-made email templates to help
with user onboarding. Please refrain from changing the links.
</Body>
</div>
<Table
{customRenderers}
data={$email.templates}
schema={templateSchema}
{loading}
allowEditRows={false}
allowSelectRows={false}
allowEditColumns={false}
/>
{/if}
</Page>
<style>
.config-form {
margin-top: 42px;
margin-bottom: 42px;
}
.form-row {
display: grid;
grid-template-columns: 20% 1fr;
grid-gap: var(--spacing-l);
}
span {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
header {
margin-bottom: 42px;
}
</style>

View File

@ -91,7 +91,7 @@
</div> </div>
{/each} {/each}
<div> <div>
<Button primary on:click={() => save(google)}>Save</Button> <Button cta on:click={() => save(google)}>Save</Button>
</div> </div>
<Divider /> <Divider />
{/if} {/if}

View File

@ -0,0 +1,44 @@
import { writable } from "svelte/store"
import api from "builderStore/api"
export function createEmailStore() {
const store = writable([])
return {
subscribe: store.subscribe,
templates: {
fetch: async () => {
// fetch the email template definitions
const response = await api.get(`/api/admin/template/definitions`)
const definitions = await response.json()
// fetch the email templates themselves
const templatesResponse = await api.get(`/api/admin/template/email`)
const templates = await templatesResponse.json()
store.set({
definitions,
templates,
})
},
save: async template => {
// Save your template config
const response = await api.post(`/api/admin/template`, template)
const json = await response.json()
if (response.status !== 200) throw new Error(json.message)
template._rev = json._rev
template._id = json._id
store.update(state => {
const currentIdx = state.templates.findIndex(
template => template.purpose === json.purpose
)
state.templates.splice(currentIdx, 1, template)
return state
})
},
},
}
}
export const email = createEmailStore()

View File

@ -1,3 +1,4 @@
export { organisation } from "./organisation" export { organisation } from "./organisation"
export { admin } from "./admin" export { admin } from "./admin"
export { apps } from "./apps" export { apps } from "./apps"
export { email } from "./email"

View File

@ -1,4 +1,4 @@
const { string, number } = require("../questions") const { number } = require("../questions")
const { success } = require("../utils") const { success } = require("../utils")
const fs = require("fs") const fs = require("fs")
const path = require("path") const path = require("path")
@ -6,14 +6,11 @@ const randomString = require("randomstring")
const FILE_PATH = path.resolve("./.env") const FILE_PATH = path.resolve("./.env")
function getContents(port, hostingKey) { function getContents(port) {
return ` return `
# Use the main port in the builder for your self hosting URL, e.g. localhost:10000 # Use the main port in the builder for your self hosting URL, e.g. localhost:10000
MAIN_PORT=${port} MAIN_PORT=${port}
# Use this password when configuring your self hosting settings
HOSTING_KEY=${hostingKey}
# This section contains all secrets pertaining to the system # This section contains all secrets pertaining to the system
JWT_SECRET=${randomString.generate()} JWT_SECRET=${randomString.generate()}
MINIO_ACCESS_KEY=${randomString.generate()} MINIO_ACCESS_KEY=${randomString.generate()}
@ -21,6 +18,7 @@ MINIO_SECRET_KEY=${randomString.generate()}
COUCH_DB_PASSWORD=${randomString.generate()} COUCH_DB_PASSWORD=${randomString.generate()}
COUCH_DB_USER=${randomString.generate()} COUCH_DB_USER=${randomString.generate()}
REDIS_PASSWORD=${randomString.generate()} REDIS_PASSWORD=${randomString.generate()}
INTERNAL_API_KEY=${randomString.generate()}
# This section contains variables that do not need to be altered under normal circumstances # This section contains variables that do not need to be altered under normal circumstances
APP_PORT=4002 APP_PORT=4002
@ -33,7 +31,6 @@ BUDIBASE_ENVIRONMENT=PRODUCTION`
module.exports.filePath = FILE_PATH module.exports.filePath = FILE_PATH
module.exports.ConfigMap = { module.exports.ConfigMap = {
HOSTING_KEY: "key",
MAIN_PORT: "port", MAIN_PORT: "port",
} }
module.exports.QUICK_CONFIG = { module.exports.QUICK_CONFIG = {
@ -42,18 +39,13 @@ module.exports.QUICK_CONFIG = {
} }
module.exports.make = async (inputs = {}) => { module.exports.make = async (inputs = {}) => {
const hostingKey =
inputs.key ||
(await string(
"Please input the password you'd like to use as your hosting key: "
))
const hostingPort = const hostingPort =
inputs.port || inputs.port ||
(await number( (await number(
"Please enter the port on which you want your installation to run: ", "Please enter the port on which you want your installation to run: ",
10000 10000
)) ))
const fileContents = getContents(hostingPort, hostingKey) const fileContents = getContents(hostingPort)
fs.writeFileSync(FILE_PATH, fileContents) fs.writeFileSync(FILE_PATH, fileContents)
console.log( console.log(
success( success(

File diff suppressed because it is too large Load Diff

View File

@ -39,6 +39,7 @@ async function init() {
COUCH_DB_URL: "http://budibase:budibase@localhost:10000/db/", COUCH_DB_URL: "http://budibase:budibase@localhost:10000/db/",
REDIS_URL: "localhost:6379", REDIS_URL: "localhost:6379",
WORKER_URL: "http://localhost:4002", WORKER_URL: "http://localhost:4002",
INTERNAL_API_KEY: "budibase",
JWT_SECRET: "testsecret", JWT_SECRET: "testsecret",
REDIS_PASSWORD: "budibase", REDIS_PASSWORD: "budibase",
MINIO_ACCESS_KEY: "budibase", MINIO_ACCESS_KEY: "budibase",

View File

@ -1,4 +1,5 @@
const sendEmail = require("./steps/sendEmail") const sendgridEmail = require("./steps/sendgridEmail")
const sendSmtpEmail = require("./steps/sendSmtpEmail")
const createRow = require("./steps/createRow") const createRow = require("./steps/createRow")
const updateRow = require("./steps/updateRow") const updateRow = require("./steps/updateRow")
const deleteRow = require("./steps/deleteRow") const deleteRow = require("./steps/deleteRow")
@ -14,7 +15,8 @@ const {
} = require("../utilities/fileSystem") } = require("../utilities/fileSystem")
const BUILTIN_ACTIONS = { const BUILTIN_ACTIONS = {
SEND_EMAIL: sendEmail.run, SEND_EMAIL: sendgridEmail.run,
SEND_EMAIL_SMTP: sendSmtpEmail.run,
CREATE_ROW: createRow.run, CREATE_ROW: createRow.run,
UPDATE_ROW: updateRow.run, UPDATE_ROW: updateRow.run,
DELETE_ROW: deleteRow.run, DELETE_ROW: deleteRow.run,
@ -24,7 +26,8 @@ const BUILTIN_ACTIONS = {
EXECUTE_QUERY: executeQuery.run, EXECUTE_QUERY: executeQuery.run,
} }
const BUILTIN_DEFINITIONS = { const BUILTIN_DEFINITIONS = {
SEND_EMAIL: sendEmail.definition, SEND_EMAIL: sendgridEmail.definition,
SEND_EMAIL_SMTP: sendSmtpEmail.definition,
CREATE_ROW: createRow.definition, CREATE_ROW: createRow.definition,
UPDATE_ROW: updateRow.definition, UPDATE_ROW: updateRow.definition,
DELETE_ROW: deleteRow.definition, DELETE_ROW: deleteRow.definition,

View File

@ -0,0 +1,67 @@
const { sendSmtpEmail } = require("../../utilities/workerRequests")
module.exports.definition = {
description: "Send an email using SMTP",
tagline: "Send SMTP email to {{inputs.to}}",
icon: "ri-mail-open-line",
name: "Send Email (SMTP)",
type: "ACTION",
stepId: "SEND_EMAIL_SMTP",
inputs: {},
schema: {
inputs: {
properties: {
to: {
type: "string",
title: "Send To",
},
from: {
type: "string",
title: "Send From",
},
subject: {
type: "string",
title: "Email Subject",
},
contents: {
type: "string",
title: "HTML Contents",
},
},
required: ["to", "from", "subject", "contents"],
},
outputs: {
properties: {
success: {
type: "boolean",
description: "Whether the email was sent",
},
response: {
type: "object",
description: "A response from the email client, this may be an error",
},
},
required: ["success"],
},
},
}
module.exports.run = async function ({ inputs }) {
let { to, from, subject, contents } = inputs
if (!contents) {
contents = "<h1>No content</h1>"
}
try {
let response = await sendSmtpEmail(to, from, subject, contents)
return {
success: true,
response,
}
} catch (err) {
console.error(err)
return {
success: false,
response: err,
}
}
}

View File

@ -1,8 +1,8 @@
module.exports.definition = { module.exports.definition = {
description: "Send an email", description: "Send an email using SendGrid",
tagline: "Send email to {{inputs.to}}", tagline: "Send email to {{inputs.to}}",
icon: "ri-mail-open-line", icon: "ri-mail-open-line",
name: "Send Email", name: "Send Email (SendGrid)",
type: "ACTION", type: "ACTION",
stepId: "SEND_EMAIL", stepId: "SEND_EMAIL",
inputs: {}, inputs: {},

View File

@ -34,6 +34,7 @@ module.exports = {
USE_QUOTAS: process.env.USE_QUOTAS, USE_QUOTAS: process.env.USE_QUOTAS,
REDIS_URL: process.env.REDIS_URL, REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD, REDIS_PASSWORD: process.env.REDIS_PASSWORD,
INTERNAL_API_KEY: process.env.INTERNAL_API_KEY,
// environment // environment
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
JEST_WORKER_ID: process.env.JEST_WORKER_ID, JEST_WORKER_ID: process.env.JEST_WORKER_ID,
@ -53,7 +54,6 @@ module.exports = {
BUDIBASE_API_KEY: process.env.BUDIBASE_API_KEY, BUDIBASE_API_KEY: process.env.BUDIBASE_API_KEY,
USERID_API_KEY: process.env.USERID_API_KEY, USERID_API_KEY: process.env.USERID_API_KEY,
DEPLOYMENT_CREDENTIALS_URL: process.env.DEPLOYMENT_CREDENTIALS_URL, DEPLOYMENT_CREDENTIALS_URL: process.env.DEPLOYMENT_CREDENTIALS_URL,
HOSTING_KEY: process.env.HOSTING_KEY,
_set(key, value) { _set(key, value) {
process.env[key] = value process.env[key] = value
module.exports[key] = value module.exports[key] = value

View File

@ -28,7 +28,7 @@ function request(ctx, request) {
} else { } else {
delete request.body delete request.body
} }
if (ctx.headers) { if (ctx && ctx.headers) {
request.headers.cookie = ctx.headers.cookie request.headers.cookie = ctx.headers.cookie
} }
return request return request
@ -36,6 +36,31 @@ function request(ctx, request) {
exports.request = request exports.request = request
exports.sendSmtpEmail = async (to, from, subject, contents) => {
const response = await fetch(
checkSlashesInUrl(env.WORKER_URL + `/api/admin/email/send`),
request(null, {
method: "POST",
headers: {
"x-budibase-api-key": env.INTERNAL_API_KEY,
},
body: {
email: to,
from,
contents,
subject,
purpose: "custom",
},
})
)
const json = await response.json()
if (json.status !== 200 && response.status !== 200) {
throw "Unable to send email."
}
return json
}
exports.getDeployedApps = async ctx => { exports.getDeployedApps = async ctx => {
if (!env.SELF_HOSTED) { if (!env.SELF_HOSTED) {
throw "Can only check apps for self hosted environments" throw "Can only check apps for self hosted environments"

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ async function init() {
SELF_HOSTED: 1, SELF_HOSTED: 1,
PORT: 4002, PORT: 4002,
JWT_SECRET: "testsecret", JWT_SECRET: "testsecret",
INTERNAL_API_KEY: "budibase",
MINIO_ACCESS_KEY: "budibase", MINIO_ACCESS_KEY: "budibase",
MINIO_SECRET_KEY: "budibase", MINIO_SECRET_KEY: "budibase",
COUCH_DB_USER: "budibase", COUCH_DB_USER: "budibase",

View File

@ -54,7 +54,10 @@ exports.reset = async ctx => {
} }
try { try {
const user = await getGlobalUserByEmail(email) const user = await getGlobalUserByEmail(email)
await sendEmail(email, EmailTemplatePurpose.PASSWORD_RECOVERY, { user }) await sendEmail(email, EmailTemplatePurpose.PASSWORD_RECOVERY, {
user,
subject: "{{ company }} platform password reset",
})
} catch (err) { } catch (err) {
// don't throw any kind of error to the user, this might give away something // don't throw any kind of error to the user, this might give away something
} }

View File

@ -5,13 +5,27 @@ const authPkg = require("@budibase/auth")
const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name
exports.sendEmail = async ctx => { exports.sendEmail = async ctx => {
const { groupId, email, userId, purpose } = ctx.request.body const {
groupId,
email,
userId,
purpose,
contents,
from,
subject,
} = ctx.request.body
let user let user
if (userId) { if (userId) {
const db = new CouchDB(GLOBAL_DB) const db = new CouchDB(GLOBAL_DB)
user = await db.get(userId) user = await db.get(userId)
} }
const response = await sendEmail(email, purpose, { groupId, user }) const response = await sendEmail(email, purpose, {
groupId,
user,
contents,
from,
subject,
})
ctx.body = { ctx.body = {
...response, ...response,
message: `Email sent to ${email}.`, message: `Email sent to ${email}.`,

View File

@ -1,5 +1,5 @@
const { generateTemplateID, StaticDatabases } = require("@budibase/auth").db const { generateTemplateID, StaticDatabases } = require("@budibase/auth").db
const { CouchDB } = require("../../../db") const CouchDB = require("../../../db")
const { const {
TemplateMetadata, TemplateMetadata,
TemplateBindings, TemplateBindings,
@ -11,7 +11,6 @@ const GLOBAL_DB = StaticDatabases.GLOBAL.name
exports.save = async ctx => { exports.save = async ctx => {
const db = new CouchDB(GLOBAL_DB) const db = new CouchDB(GLOBAL_DB)
const type = ctx.params.type
let template = ctx.request.body let template = ctx.request.body
if (!template.ownerId) { if (!template.ownerId) {
template.ownerId = GLOBAL_OWNER template.ownerId = GLOBAL_OWNER
@ -20,10 +19,7 @@ exports.save = async ctx => {
template._id = generateTemplateID(template.ownerId) template._id = generateTemplateID(template.ownerId)
} }
const response = await db.put({ const response = await db.put(template)
...template,
type,
})
ctx.body = { ctx.body = {
...template, ...template,
_rev: response.rev, _rev: response.rev,
@ -31,9 +27,17 @@ exports.save = async ctx => {
} }
exports.definitions = async ctx => { exports.definitions = async ctx => {
const bindings = {}
for (let template of TemplateMetadata.email) {
bindings[template.purpose] = template.bindings
}
ctx.body = { ctx.body = {
purpose: TemplateMetadata, bindings: {
bindings: Object.values(TemplateBindings), ...bindings,
common: Object.values(TemplateBindings),
},
} }
} }

View File

@ -136,7 +136,9 @@ exports.invite = async ctx => {
if (existing) { if (existing) {
ctx.throw(400, "Email address already in use.") ctx.throw(400, "Email address already in use.")
} }
await sendEmail(email, EmailTemplatePurpose.INVITATION) await sendEmail(email, EmailTemplatePurpose.INVITATION, {
subject: "{{ company }} platform invitation",
})
ctx.body = { ctx.body = {
message: "Invitation has been sent.", message: "Invitation has been sent.",
} }

View File

@ -0,0 +1,93 @@
const authPkg = require("@budibase/auth")
const { google } = require("@budibase/auth/src/middleware")
const { Configs } = require("../../constants")
const CouchDB = require("../../db")
const { clearCookie } = authPkg.utils
const { Cookies } = authPkg.constants
const { passport } = authPkg.auth
const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name
exports.authenticate = async (ctx, next) => {
return passport.authenticate("local", async (err, user) => {
if (err) {
return ctx.throw(403, "Unauthorized")
}
const expires = new Date()
expires.setDate(expires.getDate() + 1)
if (!user) {
return ctx.throw(403, "Unauthorized")
}
ctx.cookies.set(Cookies.Auth, user.token, {
expires,
path: "/",
httpOnly: false,
overwrite: true,
})
delete user.token
ctx.body = { user }
})(ctx, next)
}
exports.logout = async ctx => {
clearCookie(ctx, Cookies.Auth)
ctx.body = { message: "User logged out" }
}
/**
* The initial call that google authentication makes to take you to the google login screen.
* On a successful login, you will be redirected to the googleAuth callback route.
*/
exports.googlePreAuth = async (ctx, next) => {
const db = new CouchDB(GLOBAL_DB)
const config = await authPkg.db.getScopedFullConfig(db, {
type: Configs.GOOGLE,
group: ctx.query.group,
})
const strategy = await google.strategyFactory(config)
return passport.authenticate(strategy, {
scope: ["profile", "email"],
})(ctx, next)
}
exports.googleAuth = async (ctx, next) => {
const db = new CouchDB(GLOBAL_DB)
const config = await authPkg.db.getScopedFullConfig(db, {
type: Configs.GOOGLE,
group: ctx.query.group,
})
const strategy = await google.strategyFactory(config)
return passport.authenticate(
strategy,
{ successRedirect: "/", failureRedirect: "/error" },
async (err, user) => {
if (err) {
return ctx.throw(403, "Unauthorized")
}
const expires = new Date()
expires.setDate(expires.getDate() + 1)
if (!user) {
return ctx.throw(403, "Unauthorized")
}
ctx.cookies.set(Cookies.Auth, user.token, {
expires,
path: "/",
httpOnly: false,
overwrite: true,
})
ctx.redirect("/")
}
)(ctx, next)
}

View File

@ -53,9 +53,9 @@ router
.use(buildAuthMiddleware(PUBLIC_ENDPOINTS)) .use(buildAuthMiddleware(PUBLIC_ENDPOINTS))
// for now no public access is allowed to worker (bar health check) // for now no public access is allowed to worker (bar health check)
.use((ctx, next) => { .use((ctx, next) => {
if (!ctx.isAuthenticated) { // if (!ctx.isAuthenticated) {
ctx.throw(403, "Unauthorized - no public worker access") // ctx.throw(403, "Unauthorized - no public worker access")
} // }
return next() return next()
}) })

View File

@ -17,7 +17,7 @@ function smtpValidation() {
auth: Joi.object({ auth: Joi.object({
type: Joi.string().valid("login", "oauth2", null), type: Joi.string().valid("login", "oauth2", null),
user: Joi.string().required(), user: Joi.string().required(),
pass: Joi.string().valid("", null), pass: Joi.string().allow("", null),
}).optional(), }).optional(),
}).unknown(true) }).unknown(true)
} }

View File

@ -10,8 +10,11 @@ function buildEmailSendValidation() {
// prettier-ignore // prettier-ignore
return joiValidator.body(Joi.object({ return joiValidator.body(Joi.object({
email: Joi.string().email(), email: Joi.string().email(),
purpose: Joi.string().valid(...Object.values(EmailTemplatePurpose)),
groupId: Joi.string().allow("", null), groupId: Joi.string().allow("", null),
purpose: Joi.string().allow(...Object.values(EmailTemplatePurpose)), fromt: Joi.string().allow("", null),
contents: Joi.string().allow("", null),
subject: Joi.string().allow("", null),
}).required().unknown(true)) }).required().unknown(true))
} }

View File

@ -2,8 +2,13 @@ const setup = require("./utilities")
const { EmailTemplatePurpose } = require("../../../constants") const { EmailTemplatePurpose } = require("../../../constants")
// mock the email system // mock the email system
const sendMailMock = jest.fn()
jest.mock("nodemailer") jest.mock("nodemailer")
const sendMailMock = setup.emailMock() const nodemailer = require("nodemailer")
nodemailer.createTransport.mockReturnValue({
sendMail: sendMailMock,
verify: jest.fn()
})
describe("/api/admin/email", () => { describe("/api/admin/email", () => {
let request = setup.getRequest() let request = setup.getRequest()

View File

@ -29,6 +29,7 @@ describe("/api/admin/email", () => {
.expect(200) .expect(200)
expect(res.body.message).toBeDefined() expect(res.body.message).toBeDefined()
const testUrl = nodemailer.getTestMessageUrl(res.body) const testUrl = nodemailer.getTestMessageUrl(res.body)
console.log(`${purpose} URL: ${testUrl}`)
expect(testUrl).toBeDefined() expect(testUrl).toBeDefined()
const response = await fetch(testUrl) const response = await fetch(testUrl)
const text = await response.text() const text = await response.text()

View File

@ -27,7 +27,6 @@ const TemplateTypes = {
const EmailTemplatePurpose = { const EmailTemplatePurpose = {
BASE: "base", BASE: "base",
STYLES: "styles",
PASSWORD_RECOVERY: "password_recovery", PASSWORD_RECOVERY: "password_recovery",
INVITATION: "invitation", INVITATION: "invitation",
WELCOME: "welcome", WELCOME: "welcome",
@ -35,47 +34,105 @@ const EmailTemplatePurpose = {
} }
const TemplateBindings = { const TemplateBindings = {
PLATFORM_URL: "platformUrl", PLATFORM_URL: {
COMPANY: "company", name: "platformUrl",
LOGO_URL: "logoUrl", description: "The URL used to access the budibase platform",
STYLES: "styles", },
BODY: "body", COMPANY: {
REGISTRATION_URL: "registrationUrl", name: "company",
EMAIL: "email", description: "The name of your organization",
RESET_URL: "resetUrl", },
USER: "user", LOGO_URL: {
REQUEST: "request", name: "logoUrl",
DOCS_URL: "docsUrl", description: "The URL of your organizations logo.",
LOGIN_URL: "loginUrl", },
CURRENT_YEAR: "currentYear", EMAIL: {
CURRENT_DATE: "currentDate", name: "email",
RESET_CODE: "resetCode", description: "The recipients email address.",
INVITE_CODE: "inviteCode", },
USER: {
name: "user",
description: "The recipients user object.",
},
REQUEST: {
name: "request",
description: "Additional request metadata.",
},
DOCS_URL: {
name: "docsUrl",
description: "Organization documentation URL.",
},
LOGIN_URL: {
name: "loginUrl",
description: "The URL used to log into the organization budibase instance.",
},
CURRENT_YEAR: {
name: "currentYear",
description: "The current year.",
},
CURRENT_DATE: {
name: "currentDate",
description: "The current date.",
},
} }
const TemplateMetadata = { const TemplateMetadata = {
[TemplateTypes.EMAIL]: [ [TemplateTypes.EMAIL]: [
{
name: "Styling",
purpose: EmailTemplatePurpose.STYLES,
bindings: ["url", "company", "companyUrl", "styles", "body"],
},
{ {
name: "Base Format", name: "Base Format",
purpose: EmailTemplatePurpose.BASE, purpose: EmailTemplatePurpose.BASE,
bindings: ["company", "registrationUrl"], bindings: [
{
name: "body",
description: "The main body of another email template.",
},
{
name: "styles",
description: "The contents of the Styling email template.",
},
],
}, },
{ {
name: "Password Recovery", name: "Password Recovery",
purpose: EmailTemplatePurpose.PASSWORD_RECOVERY, purpose: EmailTemplatePurpose.PASSWORD_RECOVERY,
bindings: [
{
name: "resetUrl",
description:
"The URL the recipient must click to reset their password.",
},
{
name: "resetCode",
description:
"The temporary password reset code used in the recipients password reset URL.",
},
],
}, },
{ {
name: "New User Invitation", name: "New User Invitation",
purpose: EmailTemplatePurpose.INVITATION, purpose: EmailTemplatePurpose.INVITATION,
bindings: [
{
name: "inviteUrl",
description:
"The URL the recipient must click to accept the invitation and activate their account.",
},
{
name: "inviteCode",
description:
"The temporary invite code used in the recipients invitation URL.",
},
],
}, },
{ {
name: "Custom", name: "Custom",
purpose: EmailTemplatePurpose.CUSTOM, purpose: EmailTemplatePurpose.CUSTOM,
bindings: [
{
name: "contents",
description: "Custom content body.",
},
],
}, },
], ],
} }

View File

@ -9,7 +9,426 @@
<meta name="supported-color-schemes" content="light dark" /> <meta name="supported-color-schemes" content="light dark" />
<title></title> <title></title>
<style type="text/css" rel="stylesheet" media="all"> <style type="text/css" rel="stylesheet" media="all">
{{ styles }} @import url('https://fonts.googleapis.com/css2?family=Source+Sans+Pro&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: "Source Sans Pro", 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;
}
/* 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: #FFF;
color: #333;
}
p {
color: #333;
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.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;
}
.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: 35px;
}
/*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) {
.email-body_inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body {
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> </style>
<!--[if mso]> <!--[if mso]>
<style type="text/css"> <style type="text/css">

View File

@ -0,0 +1,14 @@
<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">
<!-- Body content -->
<tr>
<td class="content-cell">
<div class="f-fallback">
{{ contents }}
</div>
</td>
</tr>
</table>
</td>
</tr>

View File

@ -17,10 +17,10 @@ exports.EmailTemplates = {
join(__dirname, "invitation.hbs") join(__dirname, "invitation.hbs")
), ),
[EmailTemplatePurpose.BASE]: readStaticFile(join(__dirname, "base.hbs")), [EmailTemplatePurpose.BASE]: readStaticFile(join(__dirname, "base.hbs")),
[EmailTemplatePurpose.STYLES]: readStaticFile(join(__dirname, "style.hbs")),
[EmailTemplatePurpose.WELCOME]: readStaticFile( [EmailTemplatePurpose.WELCOME]: readStaticFile(
join(__dirname, "welcome.hbs") join(__dirname, "welcome.hbs")
), ),
[EmailTemplatePurpose.CUSTOM]: readStaticFile(join(__dirname, "custom.hbs")),
} }
exports.addBaseTemplates = (templates, type = null) => { exports.addBaseTemplates = (templates, type = null) => {

View File

@ -22,7 +22,7 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation"> <table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr> <tr>
<td align="center"> <td align="center">
<a href="{{ registrationUrl }}" class="f-fallback button" target="_blank">Set up account</a> <a href="{{ inviteUrl }}" class="f-fallback button" target="_blank">Set up account</a>
</td> </td>
</tr> </tr>
</table> </table>
@ -38,7 +38,7 @@
<tr> <tr>
<td> <td>
<p class="f-fallback sub">If youre having trouble with the button above, copy and paste the URL below into your web browser.</p> <p class="f-fallback sub">If youre having trouble with the button above, copy and paste the URL below into your web browser.</p>
<p class="f-fallback sub">{{ registrationUrl }}</p> <p class="f-fallback sub">{{ inviteUrl }}</p>
</td> </td>
</tr> </tr>
</table> </table>

View File

@ -1,408 +0,0 @@
/* Based on templates: https://github.com/wildbit/postmark-templates/blob/master/templates/plain */
/* Base ------------------------------ */
@import url('https://fonts.googleapis.com/css2?family=Source+Sans+Pro&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: "Source Sans Pro", 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;
}
/* 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: #FFF;
color: #333;
}
p {
color: #333;
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.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;
}
.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: 35px;
}
/*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) {
.email-body_inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body {
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;
}

View File

@ -28,8 +28,8 @@ module.exports = {
SALT_ROUNDS: process.env.SALT_ROUNDS, SALT_ROUNDS: process.env.SALT_ROUNDS,
REDIS_URL: process.env.REDIS_URL, REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD, REDIS_PASSWORD: process.env.REDIS_PASSWORD,
INTERNAL_API_KEY: process.env.INTERNAL_API_KEY,
/* TODO: to remove - once deployment removed */ /* TODO: to remove - once deployment removed */
SELF_HOST_KEY: process.env.SELF_HOST_KEY,
COUCH_DB_USERNAME: process.env.COUCH_DB_USERNAME, COUCH_DB_USERNAME: process.env.COUCH_DB_USERNAME,
COUCH_DB_PASSWORD: process.env.COUCH_DB_PASSWORD, COUCH_DB_PASSWORD: process.env.COUCH_DB_PASSWORD,
_set(key, value) { _set(key, value) {

View File

@ -7,6 +7,7 @@ const { getSettingsTemplateContext } = require("./templates")
const { processString } = require("@budibase/string-templates") const { processString } = require("@budibase/string-templates")
const { getResetPasswordCode, getInviteCode } = require("../utilities/redis") const { getResetPasswordCode, getInviteCode } = require("../utilities/redis")
const TEST_MODE = false
const GLOBAL_DB = StaticDatabases.GLOBAL.name const GLOBAL_DB = StaticDatabases.GLOBAL.name
const TYPE = TemplateTypes.EMAIL const TYPE = TemplateTypes.EMAIL
@ -14,18 +15,32 @@ const FULL_EMAIL_PURPOSES = [
EmailTemplatePurpose.INVITATION, EmailTemplatePurpose.INVITATION,
EmailTemplatePurpose.PASSWORD_RECOVERY, EmailTemplatePurpose.PASSWORD_RECOVERY,
EmailTemplatePurpose.WELCOME, EmailTemplatePurpose.WELCOME,
EmailTemplatePurpose.CUSTOM,
] ]
function createSMTPTransport(config) { function createSMTPTransport(config) {
const options = { let options
port: config.port, if (!TEST_MODE) {
host: config.host, options = {
secure: config.secure || false, port: config.port,
auth: config.auth, host: config.host,
} secure: config.secure || false,
if (config.selfSigned) { auth: config.auth,
options.tls = { }
rejectUnauthorized: false, if (config.selfSigned) {
options.tls = {
rejectUnauthorized: false,
}
}
} else {
options = {
port: 587,
host: "smtp.ethereal.email",
secure: false,
auth: {
user: "don.bahringer@ethereal.email",
pass: "yCKSH8rWyUPbnhGYk9",
},
} }
} }
return nodemailer.createTransport(options) return nodemailer.createTransport(options)
@ -46,40 +61,36 @@ async function getLinkCode(purpose, email, user) {
* Builds an email using handlebars and the templates found in the system (default or otherwise). * Builds an email using handlebars and the templates found in the system (default or otherwise).
* @param {string} purpose the purpose of the email being built, e.g. invitation, password reset. * @param {string} purpose the purpose of the email being built, e.g. invitation, password reset.
* @param {string} email the address which it is being sent to for contextual purposes. * @param {string} email the address which it is being sent to for contextual purposes.
* @param {object|null} user If being sent to an existing user then the object can be provided for context. * @param {object} context the context which is being used for building the email (hbs context).
* @param {object|null} user if being sent to an existing user then the object can be provided for context.
* @param {string|null} contents if using a custom template can supply contents for context.
* @return {Promise<string>} returns the built email HTML if all provided parameters were valid. * @return {Promise<string>} returns the built email HTML if all provided parameters were valid.
*/ */
async function buildEmail(purpose, email, user) { async function buildEmail(purpose, email, context, { user, contents } = {}) {
// this isn't a full email // this isn't a full email
if (FULL_EMAIL_PURPOSES.indexOf(purpose) === -1) { if (FULL_EMAIL_PURPOSES.indexOf(purpose) === -1) {
throw `Unable to build an email of type ${purpose}` throw `Unable to build an email of type ${purpose}`
} }
let [base, styles, body] = await Promise.all([ let [base, body] = await Promise.all([
getTemplateByPurpose(TYPE, EmailTemplatePurpose.BASE), getTemplateByPurpose(TYPE, EmailTemplatePurpose.BASE),
getTemplateByPurpose(TYPE, EmailTemplatePurpose.STYLES),
getTemplateByPurpose(TYPE, purpose), getTemplateByPurpose(TYPE, purpose),
]) ])
if (!base || !styles || !body) { if (!base || !body) {
throw "Unable to build email, missing base components" throw "Unable to build email, missing base components"
} }
base = base.contents base = base.contents
styles = styles.contents
body = body.contents body = body.contents
context = {
// if there is a link code needed this will retrieve it ...context,
const code = await getLinkCode(purpose, email, user) contents,
const context = {
...(await getSettingsTemplateContext(purpose, code)),
email, email,
user: user || {}, user: user || {},
} }
body = await processString(body, context) body = await processString(body, context)
styles = await processString(styles, context)
// this should now be the complete email HTML // this should now be the complete email HTML
return processString(base, { return processString(base, {
...context, ...context,
styles,
body, body,
}) })
} }
@ -117,24 +128,38 @@ exports.isEmailConfigured = async (groupId = null) => {
* @param {string} email The email address to send to. * @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} purpose The purpose of the email being sent (e.g. reset password).
* @param {string|undefined} groupId If finer grain controls being used then this will lookup config for group. * @param {string|undefined} groupId If finer grain controls being used then this will lookup config for group.
* @param {object|undefined} user if sending to an existing user the object can be provided, this is used in the context. * @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.
* @return {Promise<object>} returns details about the attempt to send email, e.g. if it is successful; based on * @return {Promise<object>} returns details about the attempt to send email, e.g. if it is successful; based on
* nodemailer response. * nodemailer response.
*/ */
exports.sendEmail = async (email, purpose, { groupId, user } = {}) => { exports.sendEmail = async (
email,
purpose,
{ groupId, user, from, contents, subject } = {}
) => {
const db = new CouchDB(GLOBAL_DB) const db = new CouchDB(GLOBAL_DB)
const config = await getSmtpConfiguration(db, groupId) let config = (await getSmtpConfiguration(db, groupId)) || {}
if (!config) { if (Object.keys(config).length === 0 && !TEST_MODE) {
throw "Unable to find SMTP configuration." throw "Unable to find SMTP configuration."
} }
const transport = createSMTPTransport(config) const transport = createSMTPTransport(config)
// if there is a link code needed this will retrieve it
const code = await getLinkCode(purpose, email, user)
const context = await getSettingsTemplateContext(purpose, code)
const message = { const message = {
from: config.from, from: from || config.from,
subject: config.subject, subject: await processString(subject || config.subject, context),
to: email, to: email,
html: await buildEmail(purpose, email, user), html: await buildEmail(purpose, email, context, { user, contents }),
} }
return transport.sendMail(message) const response = await transport.sendMail(message)
if (TEST_MODE) {
console.log("Test email URL: " + nodemailer.getTestMessageUrl(response))
}
return response
} }
/** /**

View File

@ -15,8 +15,8 @@ const BASE_COMPANY = "Budibase"
exports.getSettingsTemplateContext = async (purpose, code = null) => { exports.getSettingsTemplateContext = async (purpose, code = null) => {
const db = new CouchDB(StaticDatabases.GLOBAL.name) const db = new CouchDB(StaticDatabases.GLOBAL.name)
// TODO: use more granular settings in the future if required // TODO: use more granular settings in the future if required
const settings = await getScopedConfig(db, { type: Configs.SETTINGS }) let settings = (await getScopedConfig(db, { type: Configs.SETTINGS })) || {}
if (!settings.platformUrl) { if (!settings || !settings.platformUrl) {
settings.platformUrl = LOCAL_URL settings.platformUrl = LOCAL_URL
} }
const URL = settings.platformUrl const URL = settings.platformUrl
@ -41,7 +41,7 @@ exports.getSettingsTemplateContext = async (purpose, code = null) => {
break break
case EmailTemplatePurpose.INVITATION: case EmailTemplatePurpose.INVITATION:
context[TemplateBindings.INVITE_CODE] = code context[TemplateBindings.INVITE_CODE] = code
context[TemplateBindings.REGISTRATION_URL] = checkSlashesInUrl( context[TemplateBindings.INVITE_URL] = checkSlashesInUrl(
`${URL}/invite?code=${code}` `${URL}/invite?code=${code}`
) )
break break