feat(server): new base email template + implementation for invites emails (#903)

Co-authored-by: Dimitrie Stefanescu <didimitrie@gmail.com>
This commit is contained in:
Kristaps Fabians Geikins
2022-08-11 11:00:01 +03:00
committed by GitHub
parent 8585347a6f
commit 0427f5cfd1
22 changed files with 1194 additions and 284 deletions
+4
View File
@@ -46,3 +46,7 @@ events.json
# VSCode log files
packages/server/.vscode/*.log
# ST workspace files
./speckle.sublime-project
./speckle.sublime-workspace
@@ -0,0 +1,650 @@
<!DOCTYPE html>
<html
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<title></title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix {
width: 100% !important;
}
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width: 480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width: 480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
<style type="text/css"></style>
</head>
<body style="word-spacing: normal">
<div style="">
<!-- Header -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="linear-gradient(90deg, rgba(0,143,233,1) 0%, rgba(0,76,235,1) 100%)" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div
style="
background: linear-gradient(
90deg,
rgba(0, 143, 233, 1) 0%,
rgba(0, 76, 235, 1) 100%
);
background-color: linear-gradient(
90deg,
rgba(0, 143, 233, 1) 0%,
rgba(0, 76, 235, 1) 100%
);
margin: 0px auto;
border-radius: 8px;
max-width: 600px;
"
>
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
background: linear-gradient(
90deg,
rgba(0, 143, 233, 1) 0%,
rgba(0, 76, 235, 1) 100%
);
background-color: linear-gradient(
90deg,
rgba(0, 143, 233, 1) 0%,
rgba(0, 76, 235, 1) 100%
);
width: 100%;
border-radius: 8px;
"
>
<tbody>
<tr>
<td
style="direction: ltr; font-size: 0px; padding: 0; text-align: center"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="vertical-align: top"
width="100%"
>
<tbody>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="border-collapse: collapse; border-spacing: 0px"
>
<tbody>
<tr>
<td style="width: 100px">
<img
alt="Speckle"
height="auto"
src="https://i.imgur.com/KAvbGEj.png"
style="
border: 0;
display: block;
outline: none;
text-decoration: none;
height: auto;
width: 100%;
font-size: 16px;
"
width="100"
/>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Body - Start -->
<% if (params.html?.bodyStart?.length) { %>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%"
>
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 20px 0;
text-align: center;
"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="vertical-align: top"
width="100%"
>
<tbody>
<!-- Some example text -->
<tr>
<td
align="left"
style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"
>
<div
style="
font-family: Helvetica;
font-size: 16px;
line-height: 20px;
text-align: left;
color: #000000;
"
>
<%- params.html.bodyStart -%>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<% } %>
<!-- CTA -->
<% if (params.cta?.url && params.cta?.title) { %>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%"
>
<tbody>
<tr>
<td
style="direction: ltr; font-size: 0px; padding: 0; text-align: center"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="vertical-align: top"
width="100%"
>
<tbody>
<tr>
<td
align="center"
vertical-align="middle"
style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="border-collapse: separate; line-height: 100%"
>
<tbody>
<tr>
<td
align="center"
bgcolor="linear-gradient(90deg, rgba(0,143,233,1) 0%, rgba(0,76,235,1) 100%)"
role="presentation"
style="
border: none;
border-radius: 8px;
cursor: auto;
mso-padding-alt: 25px 55px;
background: linear-gradient(
90deg,
rgba(0, 143, 233, 1) 0%,
rgba(0, 76, 235, 1) 100%
);
"
valign="middle"
>
<a
href="<%= params.cta.url %>"
title="<%= params.cta.altTitle || params.cta.title %>"
rel="notrack"
style="
display: inline-block;
background: linear-gradient(
90deg,
rgba(0, 143, 233, 1) 0%,
rgba(0, 76, 235, 1) 100%
);
color: white;
font-family: Helvetica;
font-size: 16px;
font-weight: bold;
line-height: 20px;
margin: 0;
text-decoration: none;
text-transform: none;
padding: 25px 55px;
mso-padding-alt: 0px;
border-radius: 8px;
"
target="_blank"
>
<%- params.cta.title -%>
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<% } %>
<!-- Body - End -->
<% if (params.html?.bodyEnd?.length) { %>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%"
>
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 20px 0;
text-align: center;
"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="vertical-align: top"
width="100%"
>
<tbody>
<!-- Some example finishing text -->
<tr>
<td
align="left"
style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"
>
<div
style="
font-family: Helvetica;
font-size: 16px;
line-height: 20px;
text-align: left;
color: #000000;
"
>
<%- params.html.bodyEnd -%>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<% } %>
<!-- Footer -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%"
>
<tbody>
<tr>
<td
style="
border-bottom: 1px solid #e0e0e0;
direction: ltr;
font-size: 0px;
padding: 20px 0;
padding-top: 0;
text-align: center;
"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="vertical-align: top"
width="100%"
>
<tbody>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"
>
<div
style="
font-family: Helvetica;
font-size: 14px;
line-height: 1;
text-align: center;
color: #999999;
"
>
Sent from <%= params.server.name || 'Speckle Server' %> at
<a
href="<%= params.server.url %>"
title="<%= params.server?.name || 'Speckle Server' %>"
>
<%= params.server.url %>
</a>
, deployed and managed by <%= params.server.company %>. Your
admin contact is <%= params.server.contact %>.
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%"
>
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 20px 0;
padding-top: 20px;
text-align: center;
"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="vertical-align: top"
width="100%"
>
<tbody>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"
>
<div
style="
font-family: Helvetica;
font-size: 12px;
line-height: 18px;
text-align: center;
color: #e0e0e0;
"
>
Brought to you by
<a href="https://speckle.systems" target="_blank">
Speckle
</a>
, the Open Source Data Platform for 3D Data
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
@@ -0,0 +1,6 @@
<%_ if (params.text?.bodyStart?.length) print("\n" + params.text.bodyStart + "\n"); _%>
<%_ if (params.cta) print("\n" + params.cta.title + ': ' + params.cta.url + "\n"); _%>
<%_ if (params.text?.bodyEnd?.length) print("\n" + params.text.bodyEnd + "\n"); _%>
------------------------------------------------------
Sent from <%= params.server.name || 'Speckle Server' %> at <%= params.server.url %>, deployed and managed by <%= params.server.company %>. Your admin contact is <%= params.server.contact %>.
+6 -6
View File
@@ -3,17 +3,17 @@
* Bootstrap module that should be imported at the very top of each entry point module
*/
// Conditionally change appRoot and repoRoot according to whether we're running from /dist/ or not (ts-node)
// Conditionally change appRoot and packageRoot according to whether we're running from /dist/ or not (ts-node)
const path = require('path')
const isTsNode = !!process[Symbol.for('ts-node.register.instance')]
const appRoot = __dirname
const repoRoot = isTsNode ? appRoot : path.resolve(__dirname, '../')
const packageRoot = isTsNode ? appRoot : path.resolve(__dirname, '../')
// Initializing module aliases for absolute import paths
const moduleAlias = require('module-alias')
moduleAlias.addAliases({
'@': appRoot,
'#': repoRoot
'#': packageRoot
})
// Initializing env vars
@@ -32,7 +32,7 @@ if (isApolloMonitoringEnabled() && !getApolloServerVersion()) {
// If running in test env, load .env.test first
// (appRoot necessary, cause env files aren't loaded through require() calls)
if (isTestEnv()) {
const { error } = dotenv.config({ path: `${repoRoot}/.env.test` })
const { error } = dotenv.config({ path: `${packageRoot}/.env.test` })
if (error) {
const e = new Error(
'Attempting to run tests without an .env.test file properly set up! Check readme!'
@@ -42,9 +42,9 @@ if (isTestEnv()) {
}
}
dotenv.config({ path: `${repoRoot}/.env` })
dotenv.config({ path: `${packageRoot}/.env` })
module.exports = {
appRoot,
repoRoot
packageRoot
}
+2 -2
View File
@@ -2,7 +2,7 @@
/* istanbul ignore file */
'use strict'
const { repoRoot } = require('./bootstrap')
const { packageRoot } = require('./bootstrap')
const fs = require('fs')
const path = require('path')
const { isTestEnv } = require('@/modules/shared/helpers/envHelper')
@@ -26,7 +26,7 @@ function walk(dir) {
// The only exception is when running tests in the test DB, cause the stakes are way lower there and we always
// run them through ts-node anyway, so it doesn't make sense forcing the app to be built
const migrationModulesDir = path.resolve(
repoRoot,
packageRoot,
isTestEnv() ? './modules' : './dist/modules'
)
if (!fs.existsSync(migrationModulesDir)) {
@@ -6,7 +6,7 @@ const commentsServiceMock = mockRequireModule(
)
const path = require('path')
const { repoRoot } = require('@/bootstrap')
const { packageRoot } = require('@/bootstrap')
const expect = require('chai').expect
const crs = require('crypto-random-string')
const { beforeEachContext, truncateTables } = require('@/test/hooks')
@@ -1018,7 +1018,7 @@ describe('Comments @comments', () => {
// Upload a small blob
blob1 = await uploadBlob(
app,
path.resolve(repoRoot, './test/assets/testimage1.jpg'),
path.resolve(packageRoot, './test/assets/testimage1.jpg'),
stream.id,
{
authToken: userToken
-107
View File
@@ -1,107 +0,0 @@
const knex = require('@/db/knex')
/**
* Single source of truth for DB schema in the codebase
*/
/**
* TODO:
* ServerInvites:
* - Get rid of the 'used' field, it's not used anymore
*
* TODO: Redo this when we have TS support with nice typing, ability to get columns with/without aliases
*/
module.exports = {
Streams: {
name: 'streams',
knex: () => knex('streams'),
col: {
id: 'streams.id',
name: 'streams.name',
description: 'streams.description',
isPublic: 'streams.isPublic',
clonedFrom: 'streams.clonedFrom',
createdAt: 'streams.createdAt',
updatedAt: 'streams.updatedAt'
}
},
StreamAcl: {
name: 'stream_acl',
knex: () => knex('stream_acl'),
col: {
userId: 'stream_acl.userId',
resourceId: 'stream_acl.resourceId',
role: 'stream_acl.role'
}
},
StreamFavorites: {
name: 'stream_favorites',
knex: () => knex('stream_favorites'),
col: {
streamId: 'stream_favorites.streamId',
userId: 'stream_favorites.userId',
createdAt: 'stream_favorites.createdAt',
cursor: 'stream_favorites.cursor'
}
},
Users: {
name: 'users',
knex: () => knex('users'),
col: {
id: 'users.id',
suuid: 'users.suuid',
createdAt: 'users.createdAt',
name: 'users.name',
bio: 'users.bio',
company: 'users.company',
email: 'users.email',
verified: 'users.verified',
avatar: 'users.avatar',
profiles: 'users.profiles',
passwordDigest: 'users.passwordDigest',
ip: 'users.ip'
}
},
ServerAcl: {
name: 'server_acl',
knex: () => knex('server_acl'),
col: {
userId: 'server_acl.userId',
role: 'server_acl.role'
}
},
Comments: {
name: 'comments',
knex: () => knex('comments'),
col: {
id: 'comments.id',
streamId: 'comments.streamId',
authorId: 'comments.authorId',
createdAt: 'comments.createdAt',
updatedAt: 'comments.updatedAt',
text: 'comments.text',
screenshot: 'comments.screenshot',
data: 'comments.data',
archived: 'comments.archived',
parentComment: 'comments.parentComment'
}
},
ServerInvites: {
name: 'server_invites',
knex: () => knex('server_invites'),
col: {
id: 'server_invites.id',
target: 'server_invites.target',
inviterId: 'server_invites.inviterId',
createdAt: 'server_invites.createdAt',
used: 'server_invites.used',
message: 'server_invites.message',
resourceTarget: 'server_invites.resourceTarget',
resourceId: 'server_invites.resourceId',
role: 'server_invites.role',
token: 'server_invites.token'
}
},
knex
}
+168
View File
@@ -0,0 +1,168 @@
import knex from '@/db/knex'
import { Knex } from 'knex'
import { reduce } from 'lodash'
/**
* TODO:
* ServerInvites:
* - Get rid of the 'used' field, it's not used anymore
*/
type SchemaConfig<T extends string, C extends string> = InnerSchemaConfig<T, C> & {
/**
* Return schema helper with custom configuration options
*/
with: (params?: SchemaConfigParams) => InnerSchemaConfig<T, C>
}
type InnerSchemaConfig<T extends string, C extends string> = {
/**
* Table name
*/
name: T
/**
* Get `knex(tableName)` QueryBuilder instance
*/
knex: () => Knex.QueryBuilder
/**
* Get names of table columns. The names can be prefixed with the table name or not, depending
* on whether `withoutTablePrefix` was set when accessing the helper.
*/
col: {
[colName in C]: string
}
}
type SchemaConfigParams = {
/**
* Configure `col` properties to not have the table name prefixed. For the most part you want the prefix,
* cause this helps in queries with JOINS (when multiple tables have a col with the same name), but you don't
* want the prefix when triggering UPDATE queries, because the `SET <name> = <value>` syntax doesn't support
* column names with table prefixes.
*/
withoutTablePrefix?: boolean
}
function buildTableHelper<T extends string, C extends string>(
tableName: T,
columns: C[]
): SchemaConfig<T, C> {
function buildInnerSchemaConfig(
params: SchemaConfigParams = {}
): InnerSchemaConfig<T, C> {
return {
name: tableName,
knex: () => knex(tableName),
col: reduce(
columns,
(prev, curr) => {
prev[curr] = params.withoutTablePrefix ? curr : `${tableName}.${curr}`
return prev
},
{} as Record<C, string>
)
}
}
return {
...buildInnerSchemaConfig(),
with: buildInnerSchemaConfig
}
}
/*
* TABLE RECORD TYPES
*/
export type ServerInviteRecord = {
id: string
target: string
inviterId: string
createdAt?: Date
used?: boolean
message?: string
resourceTarget?: string
resourceId?: string
role?: string
token: string
}
/*
* TABLE HELPERS
* The generated helpers are used like this:
*
* Streams.name - TableName
* Streams.col.id - Get column names
* Streams.knex() - Get knex() instance for this specific table
*
* Streams.with({...}) - configure helper, e.g. disable table name being prefixed to col names:
* Streams.with({withoutTablePrefix: true}).col.id
*/
export const Streams = buildTableHelper('streams', [
'id',
'name',
'description',
'isPublic',
'clonedFrom',
'createdAt',
'updatedAt'
])
export const StreamAcl = buildTableHelper('stream_acl', [
'userId',
'resourceId',
'role'
])
export const StreamFavorites = buildTableHelper('stream_favorites', [
'streamId',
'userId',
'createdAt',
'cursor'
])
export const Users = buildTableHelper('users', [
'id',
'suuid',
'createdAt',
'name',
'bio',
'company',
'email',
'verified',
'avatar',
'profiles',
'passwordDigest',
'ip'
])
export const ServerAcl = buildTableHelper('server_acl', ['userId', 'role'])
export const Comments = buildTableHelper('comments', [
'id',
'streamId',
'authorId',
'createdAt',
'updatedAt',
'text',
'screenshot',
'data',
'archived',
'parentComment'
])
export const ServerInvites = buildTableHelper('server_invites', [
'id',
'target',
'inviterId',
'createdAt',
'used',
'message',
'resourceTarget',
'resourceId',
'role',
'token'
])
export { knex }
@@ -13,7 +13,7 @@ const { createPersonalAccessToken } = require('../services/tokens')
const { beforeEachContext } = require(`@/test/hooks`)
const { sleep, noErrors } = require('@/test/helpers')
const { repoRoot } = require('@/bootstrap')
const { packageRoot } = require('@/bootstrap')
const {
addOrUpdateStreamCollaborator
} = require('@/modules/core/services/streams/streamAccessService')
@@ -70,7 +70,7 @@ describe('GraphQL API Subscriptions @gql-subscriptions', () => {
serverProcess = childProcess.spawn(
/^win/.test(process.platform) ? 'npm.cmd' : 'npm',
['run', 'dev:server:test'],
{ cwd: repoRoot.path }
{ cwd: packageRoot }
)
const reg = /running at 0.0.0.0:([0-9]*)/
@@ -1,4 +1,9 @@
import { ServerInvites, Streams, Users } from '@/modules/core/dbSchema'
import {
ServerInviteRecord,
ServerInvites,
Streams,
Users
} from '@/modules/core/dbSchema'
import { truncateTables } from '@/test/hooks'
import { createUser } from '@/modules/core/services/users'
import { createStream } from '@/modules/core/services/streams'
@@ -25,11 +30,13 @@ async function getOrderedInviteIds() {
await ServerInvites.knex()
.select(ServerInvites.col.id)
.where(ServerInvites.col.target, 'NOT ILIKE', `@%`)
).map((o) => o.id)
).map((o: Pick<ServerInviteRecord, 'id'>) => o.id)
}
async function getOrderedUserIds() {
return (await Users.knex().select(Users.col.id)).map((o) => o.id)
return (await Users.knex().select(Users.col.id)).map(
(o: Pick<ServerInviteRecord, 'id'>) => o.id
)
}
describe('[Admin users list]', () => {
-71
View File
@@ -1,71 +0,0 @@
/* istanbul ignore file */
'use strict'
const debug = require('debug')('speckle')
const nodemailer = require('nodemailer')
const modulesDebug = debug.extend('modules')
const errorDebug = debug.extend('errors')
let transporter
const createJsonEchoTransporter = () =>
nodemailer.createTransport({
jsonTransport: true
})
const initSmtpTransporter = async () => {
try {
const smtpTransporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: process.env.EMAIL_PORT || 587,
secure: process.env.EMAIL_SECURE === 'true',
auth: {
user: process.env.EMAIL_USERNAME,
pass: process.env.EMAIL_PASSWORD
}
})
await smtpTransporter.verify()
return smtpTransporter
} catch {
errorDebug('📧 Email provider is misconfigured, check config variables.')
}
}
const initTransporter = async () => {
if (process.env.NODE_ENV === 'test') return createJsonEchoTransporter()
if (process.env.EMAIL === 'true') return await initSmtpTransporter()
modulesDebug(
'📧 Email provider is not configured. Server functionality will be limited.'
)
}
exports.init = async (app) => {
modulesDebug('📧 Init emails module')
transporter = await initTransporter()
require('./rest')(app)
}
exports.finalize = async () => {
// Nothing to do here.
}
exports.sendEmail = async ({ from, to, subject, text, html }) => {
// note, the transporter is only initialized with the app init step
if (!transporter) {
errorDebug('No email transport present. Cannot send emails.')
return false
}
try {
const emailFrom = process.env.EMAIL_FROM || 'no-reply@speckle.systems'
return await transporter.sendMail({
from: from || `"Speckle" <${emailFrom}>`,
to,
subject,
text,
html
})
} catch (error) {
errorDebug(error)
}
}
+44
View File
@@ -0,0 +1,44 @@
/* istanbul ignore file */
import * as SendingService from '@/modules/emails/services/sending'
import { initializeTransporter } from '@/modules/emails/utils/transporter'
import { SpeckleModule } from '@/modules/shared/helpers/typeHelper'
import dbg from 'debug'
import { noop } from 'lodash'
const debug = dbg('speckle')
const modulesDebug = debug.extend('modules')
const emailsModule: SpeckleModule = {
init: async (app) => {
modulesDebug('📧 Init emails module')
// init transporter
await initializeTransporter()
// init rest api
;(await import('./rest')).default(app)
},
finalize: noop
}
async function sendEmail({
from,
to,
subject,
text,
html
}: {
from?: string
to: string
subject: string
text: string
html: string
}) {
return SendingService.sendEmail({ from, to, subject, text, html })
}
export = {
...emailsModule,
sendEmail
}
@@ -0,0 +1,42 @@
import { getTransporter } from '@/modules/emails/utils/transporter'
import dbg from 'debug'
const debug = dbg('speckle')
const errorDebug = debug.extend('errors')
/**
* Send out an e-mail
*/
export async function sendEmail({
from,
to,
subject,
text,
html
}: {
from?: string
to: string
subject: string
text: string
html: string
}): Promise<boolean> {
const transporter = getTransporter()
if (!transporter) {
errorDebug('No email transport present. Cannot send emails.')
return false
}
try {
const emailFrom = process.env.EMAIL_FROM || 'no-reply@speckle.systems'
return await transporter.sendMail({
from: from || `"Speckle" <${emailFrom}>`,
to,
subject,
text,
html
})
} catch (error) {
errorDebug(error)
}
return false
}
@@ -0,0 +1,52 @@
import { packageRoot } from '@/bootstrap'
import path from 'path'
import ejs from 'ejs'
type MultiTypeEmailBody = {
text: string
html: string
}
export type BasicEmailTemplateParams = {
html: { bodyStart?: string; bodyEnd?: string }
text: { bodyStart?: string; bodyEnd?: string }
cta?: {
url: string
title: string
altTitle?: string
}
server: {
name: string
url: string
company: string
contact: string
}
}
function getPathToTemplatesDir(): string {
return path.resolve(packageRoot, './assets/emails/templates/')
}
function buildTemplatePath(name: string, ext: string): string {
return path.resolve(getPathToTemplatesDir(), `./${name}/${name}.${ext}`)
}
/**
* Build an e-mail body using the 'basic' template
*/
export async function buildBasicTemplateEmail(
params: BasicEmailTemplateParams
): Promise<MultiTypeEmailBody> {
const textPath = buildTemplatePath('basic', 'txt')
const htmlPath = buildTemplatePath('basic', 'html')
const [text, html] = await Promise.all([
ejs.renderFile(textPath, { params }, { cache: true, outputFunctionName: 'print' }),
ejs.renderFile(htmlPath, { params }, { cache: true, outputFunctionName: 'print' })
])
return {
text,
html
}
}
@@ -0,0 +1,62 @@
const debug = require('debug')('speckle')
const nodemailer = require('nodemailer')
const modulesDebug = debug.extend('modules')
const errorDebug = debug.extend('errors')
/** @type {import('nodemailer').Transporter | undefined} */
let transporter = undefined
const createJsonEchoTransporter = () =>
nodemailer.createTransport({
jsonTransport: true
})
const initSmtpTransporter = async () => {
try {
const smtpTransporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: process.env.EMAIL_PORT || 587,
secure: process.env.EMAIL_SECURE === 'true',
auth: {
user: process.env.EMAIL_USERNAME,
pass: process.env.EMAIL_PASSWORD
}
})
await smtpTransporter.verify()
return smtpTransporter
} catch {
errorDebug('📧 Email provider is misconfigured, check config variables.')
}
}
/**
* @returns {import('nodemailer').Transporter | undefined}
*/
async function initializeTransporter() {
let newTransporter = undefined
if (process.env.NODE_ENV === 'test') newTransporter = createJsonEchoTransporter()
if (process.env.EMAIL === 'true') newTransporter = await initSmtpTransporter()
if (!newTransporter) {
modulesDebug(
'📧 Email provider is not configured. Server functionality will be limited.'
)
}
transporter = newTransporter
return newTransporter
}
/**
* @returns {import('nodemailer').Transporter | undefined}
*/
function getTransporter() {
return transporter
}
module.exports = {
initializeTransporter,
getTransporter
}
+3 -3
View File
@@ -1,7 +1,7 @@
'use strict'
const fs = require('fs')
const path = require('path')
const { appRoot, repoRoot } = require('@/bootstrap')
const { appRoot, packageRoot } = require('@/bootstrap')
const { values, merge, camelCase } = require('lodash')
const baseTypeDefs = require('@/modules/core/graph/schema/baseTypeDefs')
const { scalarResolvers } = require('./core/graph/scalars')
@@ -61,9 +61,9 @@ exports.graph = () => {
let schemaDirectives = {}
// load typedefs from /assets
const assetModuleDirs = fs.readdirSync(`${repoRoot}/assets`)
const assetModuleDirs = fs.readdirSync(`${packageRoot}/assets`)
assetModuleDirs.forEach((dir) => {
const typeDefDirPath = path.join(`${repoRoot}/assets`, dir, 'typedefs')
const typeDefDirPath = path.join(`${packageRoot}/assets`, dir, 'typedefs')
if (fs.existsSync(typeDefDirPath)) {
const moduleSchemas = fs.readdirSync(typeDefDirPath)
moduleSchemas.forEach((schema) => {
@@ -26,6 +26,7 @@ const { getStream } = require('@/modules/core/repositories/streams')
*/
/**
*
* Resolve resource from invite
* @param {import('@/modules/serverinvites/helpers/inviteHelper').InviteResourceData} invite
* @returns {Promise<Object>}
@@ -126,9 +127,10 @@ async function updateAllInviteTargets(oldTargets, newTarget) {
if (!oldTargets.length) return
// PostgreSQL doesn't support aliases in update calls for some reason...
const ServerInvitesCols = ServerInvites.with({ withoutTablePrefix: true }).col
await ServerInvites.knex()
.whereIn(ServerInvites.col.target, oldTargets)
.update('target', newTarget.toLowerCase())
.whereIn(ServerInvitesCols.target, oldTargets)
.update(ServerInvitesCols.target, newTarget.toLowerCase())
}
/**
@@ -25,6 +25,9 @@ const { getUsers, getUser } = require('@/modules/core/repositories/users')
const {
addStreamInviteSentOutActivity
} = require('@/modules/activitystream/services/streamActivityService')
const {
buildBasicTemplateEmail
} = require('@/modules/emails/services/templateFormatting')
/**
* @typedef {{
@@ -162,85 +165,12 @@ async function validateInput(params, inviter, targetUser, resource) {
* @param {string} message
* @returns {string}
*/
function sanitizeMessage(message) {
function sanitizeMessage(message, stripAll = false) {
return sanitizeHtml(message, {
allowedTags: ['b', 'i', 'em', 'strong']
allowedTags: stripAll ? [] : [('b', 'i', 'em', 'strong')]
})
}
/**
* Build email text version body
*/
function buildEmailTextBody(invite, inviter, serverInfo, inviteLink, resourceName) {
const { message } = invite
const forServer = isServerInvite(invite)
const dynamicText = forServer
? `join the ${serverInfo.name} Speckle Server (${process.env.CANONICAL_URL})`
: `become a collaborator on the ${serverInfo.name} Speckle Server (${process.env.CANONICAL_URL}) stream - "${resourceName}"`
return `
Hello!
${
inviter.name
} has just sent you this invitation to ${dynamicText}! To accept the invitation, open the following URL in your browser:
${inviteLink}
${message ? inviter.name + ' said: "' + message + '"' : ''}
Warm regards,
Speckle
---
This email was sent from ${serverInfo.name} at ${
process.env.CANONICAL_URL
}, deployed and managed by ${serverInfo.company}. Your admin contact is ${
serverInfo.adminContact ? serverInfo.adminContact : '[not provided]'
}.
`
}
/**
* Build email HTML version body
*/
function buildEmailHtmlBody(invite, inviter, serverInfo, inviteLink, resourceName) {
const { message } = invite
const forServer = isServerInvite(invite)
const dynamicText = forServer
? `join the <a href="${process.env.CANONICAL_URL}" rel="notrack">${serverInfo.name} Speckle Server</a>`
: `become a collaborator on the <a href="${process.env.CANONICAL_URL}" rel="notrack">${serverInfo.name} Speckle Server</a> stream - "${resourceName}"`
return `
Hello!
<br>
<br>
${inviter.name} has just sent you this invitation to ${dynamicText}!
To accept the invitation, <a href="${inviteLink}" rel="notrack">click here</a>!
<br>
<br>
${message ? inviter.name + ' said: <em>"' + message + '"</em><br><br>' : ''}
Warm regards,
<br>
Speckle (on behalf of ${inviter.name})
<br>
<img src="https://speckle.systems/content/images/2021/02/logo_big-1.png" style="width:30px; height:30px;">
<br>
<br>
<caption style="size:8px; color:#7F7F7F; width:400px; text-align: left;">
This email was sent from ${serverInfo.name} at <a href="${
process.env.CANONICAL_URL
}" rel="notrack">${process.env.CANONICAL_URL}</a>, deployed and managed by ${
serverInfo.company
}. Your admin contact is ${
serverInfo.adminContact ? serverInfo.adminContact : '[not provided]'
}.
</caption>
`
}
/**
* Build the email subject line
* @param {import('@/modules/serverinvites/repositories').ServerInviteRecord} invite
@@ -287,6 +217,77 @@ function buildInviteLink(invite) {
}
}
function buildHtmlPreamble(invite, inviter, serverInfo, resourceName) {
const { message } = invite
const forServer = isServerInvite(invite)
const dynamicText = forServer
? `join the <b>${serverInfo.name}</b> Speckle Server`
: `become a collaborator on the <b>${resourceName}</b> stream`
const bodyStart = `
Hello!
<br />
<br />
${inviter.name} has just sent you this invitation to ${dynamicText}!
${message ? inviter.name + ' said: <em>"' + message + '"</em>' : ''}`
return {
bodyStart,
bodyEnd: 'Feel free to ignore this invite if you do not know the person sending it.'
}
}
function buildTextPreamble(invite, inviter, serverInfo, resourceName) {
const { message } = invite
const forServer = isServerInvite(invite)
const dynamicText = forServer
? `join the ${serverInfo.name} Speckle Server`
: `become a collaborator on the "${resourceName}" stream`
const bodyStart = `Hello!
${inviter.name} has just sent you this invitation to ${dynamicText}!
${message ? inviter.name + ' said: "' + sanitizeMessage(message, true) + '"' : ''}`
return {
bodyStart,
bodyEnd: 'Feel free to ignore this invite if you do not know the person sending it.'
}
}
/**
* @param {import('@/modules/serverinvites/repositories').ServerInviteRecord} invite
* @param {import('@/modules/core/helpers/userHelper').UserRecord} inviter
* @param {{name: string, company: string, adminContact: string}} serverInfo
* @param {string} resourceName
* @returns {import('@/modules/emails/services/templateFormatting').BasicEmailTemplateParams}
*/
function buildEmailTemplateParams(
invite,
inviter,
serverInfo,
inviteLink,
resourceName
) {
return {
html: buildHtmlPreamble(invite, inviter, serverInfo, resourceName),
text: buildTextPreamble(invite, inviter, serverInfo, resourceName),
cta: {
title: 'Accept the invitation',
url: inviteLink
},
server: {
name: serverInfo.name,
url: process.env.CANONICAL_URL,
company: serverInfo.company,
contact: serverInfo.adminContact
}
}
}
/**
* Build invite email contents
* @param {import('@/modules/serverinvites/repositories').ServerInviteRecord} invite
@@ -301,27 +302,21 @@ async function buildEmailContents(invite, inviter, targetUser, resource) {
const inviteLink = buildInviteLink(invite)
const resourceName = resolveResourceName(invite, resource)
const bodyText = buildEmailTextBody(
invite,
inviter,
serverInfo,
inviteLink,
resourceName
)
const bodyHtml = buildEmailHtmlBody(
const templateParams = buildEmailTemplateParams(
invite,
inviter,
serverInfo,
inviteLink,
resourceName
)
const { html, text } = await buildBasicTemplateEmail(templateParams)
const subject = buildEmailSubject(invite, inviter, resourceName)
return {
to: email,
subject,
text: bodyText,
html: bodyHtml
text,
html
}
}
@@ -1,2 +1,10 @@
import { Express } from 'express'
export type Nullable<T> = T | null
export type Optional<T> = T | undefined
export type MaybeAsync<T> = T | Promise<T>
export type SpeckleModule = {
init: (app: Express) => MaybeAsync<void>
finalize: (app: Express) => MaybeAsync<void>
}
+2
View File
@@ -46,6 +46,7 @@
"dataloader": "^2.0.0",
"debug": "^4.3.1",
"dotenv": "^8.2.0",
"ejs": "^3.1.8",
"express": "^4.17.3",
"express-async-errors": "^3.1.1",
"express-session": "^1.17.1",
@@ -92,6 +93,7 @@
"@swc/core": "^1.2.222",
"@types/compression": "^1.7.2",
"@types/debug": "^4.1.7",
"@types/ejs": "^3.1.1",
"@types/express": "^4.17.13",
"@types/lodash": "^4.14.180",
"@types/mocha": "^7.0.2",
+3
View File
@@ -57,6 +57,9 @@
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"search.useParentIgnoreFiles": true,
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
},
"extensions": {
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
+44 -1
View File
@@ -5467,6 +5467,7 @@ __metadata:
"@swc/core": ^1.2.222
"@types/compression": ^1.7.2
"@types/debug": ^4.1.7
"@types/ejs": ^3.1.1
"@types/express": ^4.17.13
"@types/lodash": ^4.14.180
"@types/mocha": ^7.0.2
@@ -5497,6 +5498,7 @@ __metadata:
debug: ^4.3.1
deep-equal-in-any-order: ^1.1.15
dotenv: ^8.2.0
ejs: ^3.1.8
eslint: ^8.11.0
eslint-config-prettier: ^8.5.0
express: ^4.17.3
@@ -6195,6 +6197,13 @@ __metadata:
languageName: node
linkType: hard
"@types/ejs@npm:^3.1.1":
version: 3.1.1
resolution: "@types/ejs@npm:3.1.1"
checksum: 12fa444920aaa70af2fae4424fa62b49c23b31a37129c428b7c9f9068e58c0696b4e5601b0449f87bae8794e039c679a43651c356c34f17d1bb460456dd41441
languageName: node
linkType: hard
"@types/eslint-scope@npm:^3.7.3":
version: 3.7.3
resolution: "@types/eslint-scope@npm:3.7.3"
@@ -10623,7 +10632,7 @@ __metadata:
languageName: node
linkType: hard
"chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2":
"chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2":
version: 4.1.2
resolution: "chalk@npm:4.1.2"
dependencies:
@@ -13238,6 +13247,17 @@ __metadata:
languageName: node
linkType: hard
"ejs@npm:^3.1.8":
version: 3.1.8
resolution: "ejs@npm:3.1.8"
dependencies:
jake: ^10.8.5
bin:
ejs: bin/cli.js
checksum: 1d40d198ad52e315ccf37e577bdec06e24eefdc4e3c27aafa47751a03a0c7f0ec4310254c9277a5f14763c3cd4bbacce27497332b2d87c74232b9b1defef8efc
languageName: node
linkType: hard
"electron-to-chromium@npm:^1.4.118":
version: 1.4.134
resolution: "electron-to-chromium@npm:1.4.134"
@@ -14748,6 +14768,15 @@ __metadata:
languageName: node
linkType: hard
"filelist@npm:^1.0.1":
version: 1.0.4
resolution: "filelist@npm:1.0.4"
dependencies:
minimatch: ^5.0.1
checksum: a303573b0821e17f2d5e9783688ab6fbfce5d52aaac842790ae85e704a6f5e4e3538660a63183d6453834dedf1e0f19a9dadcebfa3e926c72397694ea11f5160
languageName: node
linkType: hard
"filename-reserved-regex@npm:^2.0.0":
version: 2.0.0
resolution: "filename-reserved-regex@npm:2.0.0"
@@ -18115,6 +18144,20 @@ __metadata:
languageName: node
linkType: hard
"jake@npm:^10.8.5":
version: 10.8.5
resolution: "jake@npm:10.8.5"
dependencies:
async: ^3.2.3
chalk: ^4.0.2
filelist: ^1.0.1
minimatch: ^3.0.4
bin:
jake: ./bin/cli.js
checksum: 56c913ecf5a8d74325d0af9bc17a233bad50977438d44864d925bb6c45c946e0fee8c4c1f5fe2225471ef40df5222e943047982717ebff0d624770564d3c46ba
languageName: node
linkType: hard
"javascript-stringify@npm:^1.6.0":
version: 1.6.0
resolution: "javascript-stringify@npm:1.6.0"