feat(server): send mention email (#10859)

close CLOUD-170
This commit is contained in:
fengmk2
2025-03-20 09:26:42 +00:00
parent 5ea65b1709
commit 42745f059d
13 changed files with 323 additions and 7 deletions

View File

@@ -1346,6 +1346,93 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> test@test.com mentioned you in Test Doc
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<!--$-->␊
<table␊
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation">␊
<tbody>␊
<tr>␊
<td>␊
<p␊
style="font-size:20px;line-height:28px;margin:24px 0 0;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414">␊
You are mentioned!␊
</p>␊
</td>␊
</tr>␊
</tbody>␊
</table>␊
<table␊
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation">␊
<tbody>␊
<tr>␊
<td>␊
<table␊
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation">␊
<tbody style="width:100%">␊
<tr style="width:100%">␊
<p␊
style="font-size:15px;line-height:24px;margin:24px 0 0;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414">␊
<span style="font-weight:600">test@test.com</span> mentioned you␊
in␊
<a␊
href="https://app.affine.pro"␊
style="color:#067df7;text-decoration-line:none"␊
target="_blank"␊
><span style="font-weight:600">Test Doc</span></a␊
>.␊
</p>␊
</tr>␊
</tbody>␊
</table>␊
<table␊
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation">␊
<tbody style="width:100%">␊
<tr style="width:100%">␊
<a␊
href="https://app.affine.pro"␊
style="line-height:24px;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;font-size:15px;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;margin:24px 0 0;color:#FFFFFF;background-color:#1E96EB;padding:8px 18px 8px 18px;border-radius:8px;border:1px solid rgba(0,0,0,.1);margin-right:4px"␊
target="_blank"␊
><span␊
><!--[if mso]><i style="mso-font-width:450%;mso-text-raise:12" hidden>&#8202;&#8202;</i><![endif]--></span␊
><span␊
style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:6px"␊
>Open Doc</span␊
><span␊
><!--[if mso]><i style="mso-font-width:450%" hidden>&#8202;&#8202;&#8203;</i><![endif]--></span␊
></a␊
>␊
</tr>␊
</tbody>␊
</table>␊
</td>␊
</tr>␊
</tbody>␊
</table>␊
<!--/$-->␊
`
> Your workspace has been upgraded to team workspace! 🎉
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊

View File

@@ -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);
});

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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'
);
});

View File

@@ -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()}`;
}

View File

@@ -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',
};

View File

@@ -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 (
<Link href={props.url}>
<Bold>{props.title}</Bold>
</Link>
);
};

View File

@@ -1,4 +1,5 @@
export * from './date';
export * from './doc';
export * from './template';
export * from './user';
export * from './workspace';

View File

@@ -0,0 +1 @@
export * from './mention';

View File

@@ -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 (
<Template>
<Title>You are mentioned!</Title>
<Content>
<P>
<User {...user} /> mentioned you in <Doc {...doc} />.
</P>
<Button href={doc.url}>Open Doc</Button>
</Content>
</Template>
);
}
Mention.PreviewProps = {
user: TEST_USER,
doc: TEST_DOC,
};

View File

@@ -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