diff --git a/.gitignore b/.gitignore index 232154cc3..457b904e7 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,7 @@ events.json # VSCode log files packages/server/.vscode/*.log + +# ST workspace files +./speckle.sublime-project +./speckle.sublime-workspace \ No newline at end of file diff --git a/packages/server/assets/emails/templates/basic/basic.html b/packages/server/assets/emails/templates/basic/basic.html new file mode 100644 index 000000000..03ab5a5bd --- /dev/null +++ b/packages/server/assets/emails/templates/basic/basic.html @@ -0,0 +1,650 @@ + + + + + + + + + + + + + + + + + + + +
+ + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ Speckle +
+
+
+ +
+
+ + + + <% if (params.html?.bodyStart?.length) { %> + +
+ + + + + + +
+ +
+ + + + + + + +
+
+ <%- params.html.bodyStart -%> +
+
+
+ +
+
+ + <% } %> + + + <% if (params.cta?.url && params.cta?.title) { %> + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + <%- params.cta.title -%> + +
+
+
+ +
+
+ + <% } %> + + + <% if (params.html?.bodyEnd?.length) { %> + +
+ + + + + + +
+ +
+ + + + + + + +
+
+ <%- params.html.bodyEnd -%> +
+
+
+ +
+
+ + <% } %> + + + +
+ + + + + + +
+ +
+ + + + + + +
+
+ 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 %>. +
+
+
+ +
+
+ + +
+ + + + + + +
+ +
+ + + + + + +
+
+ Brought to you by + + Speckle + + , the Open Source Data Platform for 3D Data +
+
+
+ +
+
+ +
+ + diff --git a/packages/server/assets/emails/templates/basic/basic.txt b/packages/server/assets/emails/templates/basic/basic.txt new file mode 100644 index 000000000..8eb4ab482 --- /dev/null +++ b/packages/server/assets/emails/templates/basic/basic.txt @@ -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 %>. \ No newline at end of file diff --git a/packages/server/bootstrap.js b/packages/server/bootstrap.js index 68a0d3c3e..a8baa809d 100644 --- a/packages/server/bootstrap.js +++ b/packages/server/bootstrap.js @@ -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 } diff --git a/packages/server/knexfile.js b/packages/server/knexfile.js index f0eec3b91..c895e7d70 100644 --- a/packages/server/knexfile.js +++ b/packages/server/knexfile.js @@ -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)) { diff --git a/packages/server/modules/comments/tests/comments.spec.js b/packages/server/modules/comments/tests/comments.spec.js index dae4b13d6..939e7dc3a 100644 --- a/packages/server/modules/comments/tests/comments.spec.js +++ b/packages/server/modules/comments/tests/comments.spec.js @@ -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 diff --git a/packages/server/modules/core/dbSchema.js b/packages/server/modules/core/dbSchema.js deleted file mode 100644 index 422cb0f6e..000000000 --- a/packages/server/modules/core/dbSchema.js +++ /dev/null @@ -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 -} diff --git a/packages/server/modules/core/dbSchema.ts b/packages/server/modules/core/dbSchema.ts new file mode 100644 index 000000000..f0d998de1 --- /dev/null +++ b/packages/server/modules/core/dbSchema.ts @@ -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 = InnerSchemaConfig & { + /** + * Return schema helper with custom configuration options + */ + with: (params?: SchemaConfigParams) => InnerSchemaConfig +} + +type InnerSchemaConfig = { + /** + * 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 = ` syntax doesn't support + * column names with table prefixes. + */ + withoutTablePrefix?: boolean +} + +function buildTableHelper( + tableName: T, + columns: C[] +): SchemaConfig { + function buildInnerSchemaConfig( + params: SchemaConfigParams = {} + ): InnerSchemaConfig { + return { + name: tableName, + knex: () => knex(tableName), + col: reduce( + columns, + (prev, curr) => { + prev[curr] = params.withoutTablePrefix ? curr : `${tableName}.${curr}` + return prev + }, + {} as Record + ) + } + } + + 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 } diff --git a/packages/server/modules/core/tests/graphSubs.spec.js b/packages/server/modules/core/tests/graphSubs.spec.js index 0a42afbdf..6d2b03d8a 100644 --- a/packages/server/modules/core/tests/graphSubs.spec.js +++ b/packages/server/modules/core/tests/graphSubs.spec.js @@ -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]*)/ diff --git a/packages/server/modules/core/tests/usersAdminList.spec.ts b/packages/server/modules/core/tests/usersAdminList.spec.ts index d6d3be917..0307ab777 100644 --- a/packages/server/modules/core/tests/usersAdminList.spec.ts +++ b/packages/server/modules/core/tests/usersAdminList.spec.ts @@ -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) => 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) => o.id + ) } describe('[Admin users list]', () => { diff --git a/packages/server/modules/emails/index.js b/packages/server/modules/emails/index.js deleted file mode 100644 index d99df4d14..000000000 --- a/packages/server/modules/emails/index.js +++ /dev/null @@ -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) - } -} diff --git a/packages/server/modules/emails/index.ts b/packages/server/modules/emails/index.ts new file mode 100644 index 000000000..a6beb5319 --- /dev/null +++ b/packages/server/modules/emails/index.ts @@ -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 +} diff --git a/packages/server/modules/emails/services/sending.ts b/packages/server/modules/emails/services/sending.ts new file mode 100644 index 000000000..9cc28cae4 --- /dev/null +++ b/packages/server/modules/emails/services/sending.ts @@ -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 { + 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 +} diff --git a/packages/server/modules/emails/services/templateFormatting.ts b/packages/server/modules/emails/services/templateFormatting.ts new file mode 100644 index 000000000..4c4b5262e --- /dev/null +++ b/packages/server/modules/emails/services/templateFormatting.ts @@ -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 { + 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 + } +} diff --git a/packages/server/modules/emails/utils/transporter.js b/packages/server/modules/emails/utils/transporter.js new file mode 100644 index 000000000..b9ec630c6 --- /dev/null +++ b/packages/server/modules/emails/utils/transporter.js @@ -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 +} diff --git a/packages/server/modules/index.js b/packages/server/modules/index.js index 01959cab7..50fef36e5 100644 --- a/packages/server/modules/index.js +++ b/packages/server/modules/index.js @@ -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) => { diff --git a/packages/server/modules/serverinvites/repositories/index.js b/packages/server/modules/serverinvites/repositories/index.js index ab3442c46..337754b35 100644 --- a/packages/server/modules/serverinvites/repositories/index.js +++ b/packages/server/modules/serverinvites/repositories/index.js @@ -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} @@ -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()) } /** diff --git a/packages/server/modules/serverinvites/services/inviteCreationService.js b/packages/server/modules/serverinvites/services/inviteCreationService.js index 3c3a34b33..f579a3ca1 100644 --- a/packages/server/modules/serverinvites/services/inviteCreationService.js +++ b/packages/server/modules/serverinvites/services/inviteCreationService.js @@ -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 ${serverInfo.name} Speckle Server` - : `become a collaborator on the ${serverInfo.name} Speckle Server stream - "${resourceName}"` - - return ` -Hello! -
-
-${inviter.name} has just sent you this invitation to ${dynamicText}! -To accept the invitation, click here! - -
-
-${message ? inviter.name + ' said: "' + message + '"

' : ''} - -Warm regards, -
-Speckle (on behalf of ${inviter.name}) -
- -
-
- -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 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 ${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: "' + message + '"' : ''}` + + 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 } } diff --git a/packages/server/modules/shared/helpers/typeHelper.ts b/packages/server/modules/shared/helpers/typeHelper.ts index 7fd3afb27..7a218b36c 100644 --- a/packages/server/modules/shared/helpers/typeHelper.ts +++ b/packages/server/modules/shared/helpers/typeHelper.ts @@ -1,2 +1,10 @@ +import { Express } from 'express' + export type Nullable = T | null export type Optional = T | undefined +export type MaybeAsync = T | Promise + +export type SpeckleModule = { + init: (app: Express) => MaybeAsync + finalize: (app: Express) => MaybeAsync +} diff --git a/packages/server/package.json b/packages/server/package.json index 2ee9c0af4..6e7980a53 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/workspace.code-workspace b/workspace.code-workspace index 945894315..4b6b0b763 100644 --- a/workspace.code-workspace +++ b/workspace.code-workspace @@ -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. diff --git a/yarn.lock b/yarn.lock index 13c053c9e..add3c7095 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"