import { ServerInfo, UserRecord } from '@/modules/core/helpers/types' import { ServerInviteRecord } from '@/modules/serverinvites/domain/types' import { getServerInfo } from '@/modules/core/services/generic' import { ResourceTargets, isServerInvite } from '@/modules/serverinvites/helpers/inviteHelper' import { getRegistrationRoute, getStreamRoute } from '@/modules/core/helpers/routeHelper' import { getFrontendOrigin } from '@/modules/shared/helpers/envHelper' import { InviteCreateValidationError } from '@/modules/serverinvites/errors' import { EmailTemplateParams, renderEmail } from '@/modules/emails/services/emailRendering' import sanitizeHtml from 'sanitize-html' import { CreateInviteParams } from '@/modules/serverinvites/domain/operations' type InviteOrInputParams = | CreateInviteParams | Pick< ServerInviteRecord, | 'id' | 'target' | 'inviterId' | 'message' | 'resourceTarget' | 'resourceId' | 'role' | 'token' | 'serverRole' > /** * Build invite email contents */ export async function buildEmailContents( invite: Pick< ServerInviteRecord, | 'id' | 'target' | 'inviterId' | 'message' | 'resourceTarget' | 'resourceId' | 'role' | 'token' | 'serverRole' >, inviter: UserRecord, resource?: { name: string } | null, targetUser?: UserRecord | null ): Promise<{ to: string; subject: string; text: string; html: string }> { const email = targetUser ? targetUser.email : invite.target const serverInfo = await getServerInfo() const inviteLink = buildInviteLink(invite) const resourceName = resolveResourceName(invite, resource) const templateParams = buildEmailTemplateParams( invite, inviter, serverInfo, inviteLink, resourceName ) const subject = buildEmailSubject(invite, inviter, resourceName) const { text, html } = await renderEmail( templateParams, serverInfo, targetUser || null ) return { to: email, subject, text, html } } function buildInviteLink( invite: Pick< ServerInviteRecord, | 'id' | 'target' | 'inviterId' | 'message' | 'resourceTarget' | 'resourceId' | 'role' | 'token' | 'serverRole' > ) { const { resourceTarget, resourceId, token } = invite if (isServerInvite(invite)) { return new URL( `${getRegistrationRoute()}?token=${token}`, getFrontendOrigin() ).toString() } if (resourceTarget === 'streams') { return new URL( `${getStreamRoute(resourceId!)}?token=${token}&accept=true`, getFrontendOrigin() ).toString() } else { throw new InviteCreateValidationError('Unexpected resource target type') } } /** * Build the email subject line */ function buildEmailSubject( invite: Pick< ServerInviteRecord, | 'id' | 'target' | 'inviterId' | 'message' | 'resourceTarget' | 'resourceId' | 'role' | 'token' | 'serverRole' >, inviter: UserRecord, resourceName: string | null ): string { const { resourceTarget } = invite if (isServerInvite(invite)) { return 'Speckle Invitation from ' + inviter.name } if (resourceTarget === 'streams') { return `${inviter.name} wants to share the project "${resourceName}" on Speckle with you` } else { throw new InviteCreateValidationError('Unexpected resource target type') } } function buildEmailTemplateParams( invite: Pick< ServerInviteRecord, | 'id' | 'target' | 'inviterId' | 'message' | 'resourceTarget' | 'resourceId' | 'role' | 'token' | 'serverRole' >, inviter: UserRecord, serverInfo: ServerInfo, inviteLink: string, resourceName: string | null ): EmailTemplateParams { return { mjml: buildMjmlPreamble(invite, inviter, serverInfo, resourceName), // TODO: what happens when resourceName is null? text: buildTextPreamble(invite, inviter, serverInfo, resourceName), // TODO: what happens when resourceName is null? cta: { title: 'Accept the invitation', url: inviteLink } } } function buildMjmlPreamble( invite: Pick< ServerInviteRecord, | 'id' | 'target' | 'inviterId' | 'message' | 'resourceTarget' | 'resourceId' | 'role' | 'token' | 'serverRole' >, inviter: UserRecord, serverInfo: ServerInfo, resourceName: string | null ) { const { message } = invite const forServer = isServerInvite(invite) const dynamicText = forServer ? `join the ${serverInfo.name} Speckle Server` : `become a collaborator on the ${resourceName} project` 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: Pick< ServerInviteRecord, | 'id' | 'target' | 'inviterId' | 'message' | 'resourceTarget' | 'resourceId' | 'role' | 'token' | 'serverRole' >, inviter: UserRecord, serverInfo: ServerInfo, resourceName: string | null ) { const { message } = invite const forServer = isServerInvite(invite) const dynamicText = forServer ? `join the ${serverInfo.name} Speckle Server` : `become a collaborator on the "${resourceName}" project` 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.' } } /** * Sanitize message that potentially has HTML in it */ function sanitizeMessage(message: string, stripAll: boolean = false): string { return sanitizeHtml(message, { allowedTags: stripAll ? [] : ['b', 'i', 'em', 'strong'] }) } function resolveResourceName( params: InviteOrInputParams, resource?: { name: string } | null ) { const { resourceTarget } = params if (resourceTarget === ResourceTargets.Streams) { return resource?.name || null } return null }