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 aab688d27e..a08dfddcd3 100644 Binary files a/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.snap and b/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.snap differ 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