From 42745f059d2a7248de2eb6d1516fd6be2073ac60 Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Thu, 20 Mar 2025 09:26:42 +0000 Subject: [PATCH] feat(server): send mention email (#10859) close CLOUD-170 --- .../__tests__/__snapshots__/mails.spec.ts.md | 87 ++++++++++++++++++ .../__snapshots__/mails.spec.ts.snap | Bin 4173 -> 4295 bytes .../notification/__tests__/service.spec.ts | 44 +++++++++ .../server/src/core/notification/index.ts | 3 +- .../server/src/core/notification/service.ts | 51 +++++++++- .../src/core/utils/__tests__/doc.spec.ts | 45 ++++++++- packages/backend/server/src/core/utils/doc.ts | 30 +++++- packages/backend/server/src/mails/common.ts | 7 +- .../server/src/mails/components/doc.tsx | 16 ++++ .../server/src/mails/components/index.ts | 1 + .../backend/server/src/mails/docs/index.ts | 1 + .../backend/server/src/mails/docs/mention.tsx | 37 ++++++++ packages/backend/server/src/mails/index.tsx | 8 ++ 13 files changed, 323 insertions(+), 7 deletions(-) create mode 100644 packages/backend/server/src/mails/components/doc.tsx create mode 100644 packages/backend/server/src/mails/docs/index.ts create mode 100644 packages/backend/server/src/mails/docs/mention.tsx diff --git a/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.md b/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.md index 988d7bdb6c..e3e731d1dd 100644 --- a/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.md +++ b/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.md @@ -1346,6 +1346,93 @@ Generated by [AVA](https://avajs.dev). ␊ ` +> test@test.com mentioned you in Test Doc + + `␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + You are mentioned!␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + test@test.com mentioned you␊ + in␊ + Test Doc.␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Open Doc␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + > Your workspace has been upgraded to team workspace! 🎉 `␊ diff --git a/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.snap b/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.snap index aab688d27e930aefc5a7f6130e0a3eedd2454200..a08dfddcd3068bb0f32ebc1c388c8a3aaa84792a 100644 GIT binary patch literal 4295 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 literal 4173 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 XK2bWqFnbC+2`B##fURou=@S6}eEc#} diff --git a/packages/backend/server/src/core/notification/__tests__/service.spec.ts b/packages/backend/server/src/core/notification/__tests__/service.spec.ts index 7093a548fd..f7b16fb999 100644 --- a/packages/backend/server/src/core/notification/__tests__/service.spec.ts +++ b/packages/backend/server/src/core/notification/__tests__/service.spec.ts @@ -18,6 +18,7 @@ import { } from '../../../models'; import { DocReader } from '../../doc'; import { NotificationService } from '../service'; + interface Context { module: TestingModule; notificationService: NotificationService; @@ -337,3 +338,46 @@ test('should raw doc title in mention notification if no doc found', async t => t.is(body2.doc.title, 'doc-title-1'); t.is(body2.doc.mode, DocMode.page); }); + +test('should send mention email by user setting', async t => { + const { notificationService } = t.context; + const docId = randomUUID(); + const notification = await notificationService.createMention({ + userId: member.id, + body: { + workspaceId: workspace.id, + createdByUserId: owner.id, + doc: { + id: docId, + title: 'doc-title-1', + blockId: 'block-id-1', + mode: DocMode.page, + }, + }, + }); + t.truthy(notification); + // should send mention email + const mentionMail = t.context.module.mails.last('Mention'); + t.is(mentionMail.to, member.email); + + // update user setting to not receive mention email + const mentionMailCount = t.context.module.mails.count('Mention'); + await t.context.models.settings.set(member.id, { + receiveMentionEmail: false, + }); + await notificationService.createMention({ + userId: member.id, + body: { + workspaceId: workspace.id, + createdByUserId: owner.id, + doc: { + id: docId, + title: 'doc-title-2', + blockId: 'block-id-2', + mode: DocMode.page, + }, + }, + }); + // should not send mention email + t.is(t.context.module.mails.count('Mention'), mentionMailCount); +}); diff --git a/packages/backend/server/src/core/notification/index.ts b/packages/backend/server/src/core/notification/index.ts index 5616f83c39..1c1a11f87f 100644 --- a/packages/backend/server/src/core/notification/index.ts +++ b/packages/backend/server/src/core/notification/index.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { DocStorageModule } from '../doc'; +import { MailModule } from '../mail'; import { PermissionModule } from '../permission'; import { StorageModule } from '../storage'; import { NotificationJob } from './job'; @@ -8,7 +9,7 @@ import { NotificationResolver, UserNotificationResolver } from './resolver'; import { NotificationService } from './service'; @Module({ - imports: [PermissionModule, DocStorageModule, StorageModule], + imports: [PermissionModule, DocStorageModule, StorageModule, MailModule], providers: [ UserNotificationResolver, NotificationResolver, diff --git a/packages/backend/server/src/core/notification/service.ts b/packages/backend/server/src/core/notification/service.ts index 7a5c63a93b..50ca9c71bf 100644 --- a/packages/backend/server/src/core/notification/service.ts +++ b/packages/backend/server/src/core/notification/service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; -import { NotificationNotFound, PaginationInput } from '../../base'; +import { NotificationNotFound, PaginationInput, URLHelper } from '../../base'; import { InvitationNotificationCreate, MentionNotification, @@ -11,7 +11,9 @@ import { UnionNotificationBody, } from '../../models'; import { DocReader } from '../doc'; +import { Mailer } from '../mail'; import { WorkspaceBlobStorage } from '../storage'; +import { generateDocPath } from '../utils/doc'; @Injectable() export class NotificationService { @@ -20,7 +22,9 @@ export class NotificationService { constructor( private readonly models: Models, private readonly docReader: DocReader, - private readonly workspaceBlobStorage: WorkspaceBlobStorage + private readonly workspaceBlobStorage: WorkspaceBlobStorage, + private readonly mailer: Mailer, + private readonly url: URLHelper ) {} async cleanExpiredNotifications() { @@ -28,7 +32,48 @@ export class NotificationService { } async createMention(input: MentionNotificationCreate) { - return await this.models.notification.createMention(input); + const notification = await this.models.notification.createMention(input); + await this.sendMentionEmail(input); + return notification; + } + + private async sendMentionEmail(input: MentionNotificationCreate) { + const userSetting = await this.models.settings.get(input.userId); + if (!userSetting.receiveMentionEmail) { + return; + } + const receiver = await this.models.user.getWorkspaceUser(input.userId); + if (!receiver) { + return; + } + const doc = await this.models.doc.getMeta( + input.body.workspaceId, + input.body.doc.id + ); + const title = doc?.title ?? input.body.doc.title; + const url = this.url.link( + generateDocPath({ + workspaceId: input.body.workspaceId, + docId: input.body.doc.id, + mode: input.body.doc.mode, + blockId: input.body.doc.blockId, + elementId: input.body.doc.elementId, + }) + ); + await this.mailer.send({ + name: 'Mention', + to: receiver.email, + props: { + user: { + $$userId: input.body.createdByUserId, + }, + doc: { + title, + url, + }, + }, + }); + this.logger.log(`Mention email sent to user ${receiver.id}`); } async createInvitation(input: InvitationNotificationCreate) { diff --git a/packages/backend/server/src/core/utils/__tests__/doc.spec.ts b/packages/backend/server/src/core/utils/__tests__/doc.spec.ts index e39241f366..32ef47943b 100644 --- a/packages/backend/server/src/core/utils/__tests__/doc.spec.ts +++ b/packages/backend/server/src/core/utils/__tests__/doc.spec.ts @@ -1,6 +1,7 @@ import test from 'ava'; -import { DocID, DocVariant } from '../doc'; +import { DocMode } from '../../../models'; +import { DocID, DocVariant, generateDocPath } from '../doc'; test('can parse', t => { // workspace only @@ -80,3 +81,45 @@ test('special case: `wsId:space:page:pageId`', t => { t.throws(() => new DocID('ws:space:b:page')); t.throws(() => new DocID('ws:s:page:page')); }); + +test('should generate doc path', t => { + t.is( + generateDocPath({ + workspaceId: 'ws', + docId: 'doc', + mode: DocMode.page, + }), + '/workspace/ws/doc?mode=page' + ); + + t.is( + generateDocPath({ + workspaceId: 'ws', + docId: 'doc', + mode: DocMode.page, + blockId: 'block', + }), + '/workspace/ws/doc?mode=page&blockIds=block' + ); + + t.is( + generateDocPath({ + workspaceId: 'ws', + docId: 'doc', + mode: DocMode.page, + elementId: 'element.+?aaa$!@#', + }), + '/workspace/ws/doc?mode=page&elementIds=element.%2B%3Faaa%24%21%40%23' + ); + + t.is( + generateDocPath({ + workspaceId: 'ws', + docId: 'doc', + mode: DocMode.page, + blockId: 'block', + elementId: 'element', + }), + '/workspace/ws/doc?mode=page&elementIds=element&blockIds=block' + ); +}); diff --git a/packages/backend/server/src/core/utils/doc.ts b/packages/backend/server/src/core/utils/doc.ts index 34e535a8bc..dd5b788242 100644 --- a/packages/backend/server/src/core/utils/doc.ts +++ b/packages/backend/server/src/core/utils/doc.ts @@ -1,5 +1,7 @@ import { registerEnumType } from '@nestjs/graphql'; +import { DocMode } from '../../models'; + export enum DocVariant { Workspace = 'workspace', Page = 'page', @@ -33,7 +35,7 @@ export class DocID { return this.variant === DocVariant.Workspace ? this.workspace : // sub is always truthy when variant is not workspace - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // oxlint-disable-next-line @typescript-eslint/no-non-null-assertion this.sub!; } @@ -119,3 +121,29 @@ export class DocID { } } } + +type DocPathParams = { + workspaceId: string; + docId: string; + mode: DocMode; + blockId?: string; + elementId?: string; +}; + +/** + * To generate a doc url path like + * + * /workspace/{workspaceId}/{docId}?mode={DocMode}&elementIds={elementId}&blockIds={blockId} + */ +export function generateDocPath(params: DocPathParams) { + const search = new URLSearchParams({ + mode: params.mode, + }); + if (params.elementId) { + search.set('elementIds', params.elementId); + } + if (params.blockId) { + search.set('blockIds', params.blockId); + } + return `/workspace/${params.workspaceId}/${params.docId}?${search.toString()}`; +} diff --git a/packages/backend/server/src/mails/common.ts b/packages/backend/server/src/mails/common.ts index 0f84e31a57..6ffbd69339 100644 --- a/packages/backend/server/src/mails/common.ts +++ b/packages/backend/server/src/mails/common.ts @@ -1,4 +1,4 @@ -import { UserProps } from './components'; +import { DocProps, UserProps } from './components'; import { WorkspaceProps } from './components/workspace'; export const TEST_USER: UserProps = { @@ -9,3 +9,8 @@ export const TEST_WORKSPACE: WorkspaceProps = { name: 'Test Workspace', avatar: 'https://app.affine.pro/favicon-192.png', }; + +export const TEST_DOC: DocProps = { + title: 'Test Doc', + url: 'https://app.affine.pro', +}; diff --git a/packages/backend/server/src/mails/components/doc.tsx b/packages/backend/server/src/mails/components/doc.tsx new file mode 100644 index 0000000000..04c5557fd2 --- /dev/null +++ b/packages/backend/server/src/mails/components/doc.tsx @@ -0,0 +1,16 @@ +import { Link } from '@react-email/components'; + +import { Bold } from './template'; + +export interface DocProps { + title: string; + url: string; +} + +export const Doc = (props: DocProps) => { + return ( + + {props.title} + + ); +}; diff --git a/packages/backend/server/src/mails/components/index.ts b/packages/backend/server/src/mails/components/index.ts index bacdecaca3..a7ee973954 100644 --- a/packages/backend/server/src/mails/components/index.ts +++ b/packages/backend/server/src/mails/components/index.ts @@ -1,4 +1,5 @@ export * from './date'; +export * from './doc'; export * from './template'; export * from './user'; export * from './workspace'; diff --git a/packages/backend/server/src/mails/docs/index.ts b/packages/backend/server/src/mails/docs/index.ts new file mode 100644 index 0000000000..d672a68cb8 --- /dev/null +++ b/packages/backend/server/src/mails/docs/index.ts @@ -0,0 +1 @@ +export * from './mention'; diff --git a/packages/backend/server/src/mails/docs/mention.tsx b/packages/backend/server/src/mails/docs/mention.tsx new file mode 100644 index 0000000000..3113f3968d --- /dev/null +++ b/packages/backend/server/src/mails/docs/mention.tsx @@ -0,0 +1,37 @@ +import { TEST_DOC, TEST_USER } from '../common'; +import { + Button, + Content, + Doc, + type DocProps, + P, + Template, + Title, + User, + type UserProps, +} from '../components'; + +export type MentionProps = { + user: UserProps; + doc: DocProps; +}; + +export function Mention(props: MentionProps) { + const { user, doc } = props; + return ( + + ); +} + +Mention.PreviewProps = { + user: TEST_USER, + doc: TEST_DOC, +}; diff --git a/packages/backend/server/src/mails/index.tsx b/packages/backend/server/src/mails/index.tsx index 4dc12c4828..f92cceb09b 100644 --- a/packages/backend/server/src/mails/index.tsx +++ b/packages/backend/server/src/mails/index.tsx @@ -1,5 +1,6 @@ import { render as rawRender } from '@react-email/components'; +import { Mention } from './docs'; import { TeamBecomeAdmin, TeamBecomeCollaborator, @@ -114,6 +115,13 @@ export const Renderers = { ), //#endregion + //#region Doc + Mention: make( + Mention, + props => `${props.user.email} mentioned you in ${props.doc.title}` + ), + //#endregion + //#region Team TeamWorkspaceUpgraded: make(TeamWorkspaceUpgraded, props => props.isOwner