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.␊
+ ␊
+ ␊
+ ␊
+ ␊
+ ␊
+ | ␊
+
␊
+ ␊
+
␊
+ ␊
+ `
+
> 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 (
+
+ You are mentioned!
+
+
+ mentioned you in .
+
+
+
+
+ );
+}
+
+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