From c4c9e3c36d0b9835ea3793416b92ca7f75cc49ab Mon Sep 17 00:00:00 2001 From: DarkSky Date: Sat, 30 May 2026 17:27:36 +0800 Subject: [PATCH] fix: retry --- .../__tests__/__snapshots__/mails.spec.ts.md | 40 ++++---- .../__snapshots__/mails.spec.ts.snap | Bin 4808 -> 4831 bytes .../src/core/mail/__tests__/job.spec.ts | 94 ++++++++++++++++++ packages/backend/server/src/core/mail/job.ts | 58 +++++++++-- .../notification/__tests__/service.spec.ts | 23 +++++ .../server/src/core/notification/service.ts | 11 ++ .../server/src/core/workspaces/abuse.ts | 12 +++ .../src/core/workspaces/resolvers/doc.ts | 12 +++ .../src/core/workspaces/resolvers/member.ts | 24 +++++ packages/backend/server/src/mails/index.tsx | 65 ++++-------- 10 files changed, 267 insertions(+), 72 deletions(-) create mode 100644 packages/backend/server/src/core/workspaces/abuse.ts 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 dc531a626b..d67f5b0cd8 100644 --- a/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.md +++ b/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.md @@ -858,7 +858,7 @@ Generated by [AVA](https://avajs.dev). ␊ ` -> test@test.com invited you to join Test Workspace +> You were invited to join a workspace on AFFiNE `␊ ␊ ` -> test@test.com accepted your invitation +> Your workspace invitation was accepted `␊ ␊ ` -> test@test.com left Test Workspace +> A workspace member left `␊ ␊ ` -> New request to join Test Workspace +> New request to join a workspace `␊ ␊ ` -> Your request to join Test Workspace has been approved +> Your request to join a workspace has been approved `␊ ␊ ` -> Your request to join Test Workspace was declined +> Your request to join a workspace was declined `␊ ␊ ` -> You have been removed from Test Workspace +> You have been removed from a workspace `␊ ␊ ` -> Your ownership of Test Workspace has been transferred +> Your workspace ownership has been transferred `␊ ␊ ` -> You are now the owner of Test Workspace +> You are now the owner of a workspace `␊ ␊ ` -> test@test.com mentioned you in Test Doc +> You were mentioned in AFFiNE `␊ ␊ @@ -1601,7 +1601,7 @@ Generated by [AVA](https://avajs.dev). ␊ ` -> test@test.com commented on Test Doc +> New comment in AFFiNE `␊ ␊ @@ -1695,7 +1695,7 @@ Generated by [AVA](https://avajs.dev). ␊ ` -> test@test.com mentioned you in a comment on Test Doc +> You were mentioned in a comment `␊ ␊ @@ -1894,7 +1894,7 @@ Generated by [AVA](https://avajs.dev). ␊ ` -> You are now an admin of Test Workspace +> You are now a workspace admin `␊ ␊ ` -> Your role has been changed in Test Workspace +> Your workspace role has been changed `␊ ␊ ` -> [Action Required] Final warning: Your workspace Test Workspace will be deleted in 24 hours +> [Action Required] Final warning: Your workspace will be deleted in 24 hours `␊ ␊ ` -> [Action Required] Important: Your workspace Test Workspace will be deleted soon +> [Action Required] Important: Your workspace will be deleted soon `␊ ␊ ` -> Your workspace Test Workspace has been deleted +> Your workspace has been deleted `␊ ␊ ` -> [Action Required] Your Test Workspace team workspace will expire soon +> [Action Required] Your team workspace will expire soon `␊ ␊ ` -> Your Test Workspace team workspace has expired +> Your team workspace has expired `␊ test@test.com mentioned you in +> You were mentioned in AFFiNE `␊ ␊ 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 a8e784b77e01d8520368bd1df1a09f6943e17637..17eed3c5e8f991e9288b4fe17181b232ac22c5c2 100644 GIT binary patch literal 4831 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 diff --git a/packages/backend/server/src/core/mail/__tests__/job.spec.ts b/packages/backend/server/src/core/mail/__tests__/job.spec.ts index 2bdbfc0573..9e2283f1b7 100644 --- a/packages/backend/server/src/core/mail/__tests__/job.spec.ts +++ b/packages/backend/server/src/core/mail/__tests__/job.spec.ts @@ -106,6 +106,44 @@ test('should drop expired mail retry', async t => { t.false(send.called); }); +test('should drop time-sensitive mail after its business expiration', async t => { + const send = Sinon.stub(sender, 'send').resolves(true); + + await mailJob.sendMail({ + startTime: Date.now() - 31 * 60 * 1000, + name: 'SignIn', + to: 'expired-sign-in@example.com', + props: { + url: 'https://affine.pro/sign-in', + otp: '123456', + }, + }); + + t.false(send.called); +}); + +test('should use explicit mail expiration when provided', async t => { + const send = Sinon.stub(sender, 'send').resolves(true); + + await mailJob.sendMail({ + startTime: Date.now(), + expiresAt: Date.now() - 1, + name: 'MemberInvitation', + to: 'expired-invitation@example.com', + props: { + user: { + $$userId: 'owner-id', + }, + workspace: { + $$workspaceId: 'workspace-id', + }, + url: 'https://affine.pro/invite/test', + }, + }); + + t.false(send.called); +}); + test('should drop mail retry after max attempts', async t => { const send = Sinon.stub(sender, 'send').resolves(true); @@ -147,3 +185,59 @@ test('should requeue legacy stringified retry mail', async t => { t.true(module.queue.add.calledWith('notification.sendMail', job)); t.is(await cache.mapGet(retryMailKey, cacheKey), undefined); }); + +test('should skip member invitation mail when rendered workspace name contains domain', async t => { + const owner = await module.create(Mockers.User); + const member = await module.create(Mockers.User); + const workspace = await module.create(Mockers.Workspace, { + owner: { id: owner.id }, + name: 'BTC https://spam.example', + }); + const send = Sinon.stub(sender, 'send').resolves(true); + + await mailJob.sendMail({ + startTime: Date.now(), + name: 'MemberInvitation', + to: member.email, + props: { + user: { + $$userId: owner.id, + }, + workspace: { + $$workspaceId: workspace.id, + }, + url: 'https://affine.pro/invite/test', + }, + }); + + t.false(send.called); +}); + +test('should keep dynamic mail props untouched for retry', async t => { + const owner = await module.create(Mockers.User); + const member = await module.create(Mockers.User); + const workspace = await module.create(Mockers.Workspace, { + owner: { id: owner.id }, + name: 'Safe Workspace', + }); + Sinon.stub(sender, 'send').resolves(false); + const job: Jobs['notification.sendMail'] = { + startTime: Date.now(), + name: 'MemberInvitation', + to: member.email, + props: { + user: { + $$userId: owner.id, + }, + workspace: { + $$workspaceId: workspace.id, + }, + url: 'https://affine.pro/invite/test', + }, + }; + + await mailJob.sendMail(job); + + t.deepEqual(job.props.user, { $$userId: owner.id }); + t.deepEqual(job.props.workspace, { $$workspaceId: workspace.id }); +}); diff --git a/packages/backend/server/src/core/mail/job.ts b/packages/backend/server/src/core/mail/job.ts index bd2e08cd8e..434a63d767 100644 --- a/packages/backend/server/src/core/mail/job.ts +++ b/packages/backend/server/src/core/mail/job.ts @@ -8,6 +8,7 @@ import { UserProps, WorkspaceProps } from '../../mails/components'; import { Models } from '../../models'; import { DocReader } from '../doc/reader'; import { WorkspaceBlobStorage } from '../storage'; +import { containsUrlOrDomain } from '../workspaces/abuse'; import { MailSender, SendOptions } from './sender'; type DynamicallyFetchedProps = { @@ -35,7 +36,11 @@ type SendMailJob> = { declare global { interface Jobs { - 'notification.sendMail': { startTime: number; retryCount?: number } & { + 'notification.sendMail': { + startTime: number; + retryCount?: number; + expiresAt?: number; + } & { [K in MailName]: SendMailJob; }[MailName]; } @@ -49,6 +54,17 @@ const retryMaxPerTick = 20; const retryFirstTime = 3; const retryMaxAttempts = 12; const retryMaxAge = 24 * 60 * 60 * 1000; +const magicLinkExpiresIn = 30 * 60 * 1000; + +const mailExpiresIn: Partial> = { + SignIn: magicLinkExpiresIn, + SignUp: magicLinkExpiresIn, + SetPassword: magicLinkExpiresIn, + ChangePassword: magicLinkExpiresIn, + VerifyEmail: magicLinkExpiresIn, + ChangeEmail: magicLinkExpiresIn, + VerifyChangeEmail: magicLinkExpiresIn, +}; @Injectable() export class MailJob { @@ -71,8 +87,12 @@ export class MailJob { private getRetryExhaustedReason({ startTime, retryCount, + expiresAt, + name, }: Jobs['notification.sendMail']) { - if (Date.now() - startTime > retryMaxAge) { + const expiredAt = + expiresAt ?? startTime + (mailExpiresIn[name] ?? retryMaxAge); + if (Date.now() > expiredAt) { return 'expired'; } @@ -118,10 +138,11 @@ export class MailJob { } let options: Partial = {}; + const renderedProps = { ...props }; - for (const key in props) { + for (const key in renderedProps) { // @ts-expect-error allow - const val = props[key]; + const val = renderedProps[key]; if (val && typeof val === 'object') { if ('$$workspaceId' in val) { const workspaceProps = await this.fetchWorkspaceProps( @@ -132,6 +153,16 @@ export class MailJob { return; } + if ( + name === 'MemberInvitation' && + containsUrlOrDomain(workspaceProps.name) + ) { + this.logger.warn( + `Skip mail [${name}] to [${to}], reason=workspace name contains url or domain` + ); + return; + } + if (workspaceProps.avatar) { options.attachments = [ { @@ -144,7 +175,7 @@ export class MailJob { workspaceProps.avatar = 'cid:workspaceAvatar'; } // @ts-expect-error replacement - props[key] = workspaceProps; + renderedProps[key] = workspaceProps; } else if ('$$userId' in val) { const userProps = await this.fetchUserProps(val.$$userId); @@ -153,17 +184,30 @@ export class MailJob { } // @ts-expect-error replacement - props[key] = userProps; + renderedProps[key] = userProps; } } } + if ( + name === 'MemberInvitation' && + 'workspace' in renderedProps && + containsUrlOrDomain( + (renderedProps.workspace as WorkspaceProps | undefined)?.name + ) + ) { + this.logger.warn( + `Skip mail [${name}] to [${to}], reason=workspace name contains url or domain` + ); + return; + } + try { const result = await this.sender.send(name, { to, ...(await Renderers[name]( // @ts-expect-error the job trigger part has been typechecked - props + renderedProps )), ...options, }); 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 dd1f4fd540..ba82af220b 100644 --- a/packages/backend/server/src/core/notification/__tests__/service.spec.ts +++ b/packages/backend/server/src/core/notification/__tests__/service.spec.ts @@ -87,6 +87,29 @@ test('should create invitation notification and email', async t => { t.is(invitationMail.payload.name, 'MemberInvitation'); }); +test('should not send invitation email when workspace name contains domain', async t => { + const spamWorkspace = await module.create(Mockers.Workspace, { + owner: { + id: owner.id, + }, + name: 'BTC https://spam.example', + }); + const inviteId = randomUUID(); + const invitationMailCount = module.queue.count('notification.sendMail'); + + const notification = await notificationService.createInvitation({ + userId: member.id, + body: { + workspaceId: spamWorkspace.id, + createdByUserId: owner.id, + inviteId, + }, + }); + + t.truthy(notification); + t.is(module.queue.count('notification.sendMail'), invitationMailCount); +}); + test('should not send invitation email if user setting is not to receive invitation email', async t => { const inviteId = randomUUID(); await module.create(Mockers.UserSettings, { diff --git a/packages/backend/server/src/core/notification/service.ts b/packages/backend/server/src/core/notification/service.ts index 529d8397b1..7f1509a118 100644 --- a/packages/backend/server/src/core/notification/service.ts +++ b/packages/backend/server/src/core/notification/service.ts @@ -22,6 +22,7 @@ import { generateWorkspaceSettingsPath, WorkspaceSettingsTab, } from '../utils/workspace'; +import { containsUrlOrDomain } from '../workspaces/abuse'; @Injectable() export class NotificationService { @@ -151,6 +152,16 @@ export class NotificationService { } private async sendInvitationEmail(input: InvitationNotificationCreate) { + const workspace = await this.docReader.getWorkspaceContent( + input.body.workspaceId + ); + if (containsUrlOrDomain(workspace?.name)) { + this.logger.warn( + `Skip invitation email for workspace ${input.body.workspaceId}, reason=workspace name contains url or domain` + ); + return; + } + const inviteUrl = this.url.link(`/invite/${input.body.inviteId}`); if (env.dev) { // make it easier to test in dev mode diff --git a/packages/backend/server/src/core/workspaces/abuse.ts b/packages/backend/server/src/core/workspaces/abuse.ts new file mode 100644 index 0000000000..8dccb7a1f7 --- /dev/null +++ b/packages/backend/server/src/core/workspaces/abuse.ts @@ -0,0 +1,12 @@ +export const SHARE_ACTION_ACCOUNT_AGE_MS = 24 * 60 * 60 * 1000; + +const URL_OR_DOMAIN_PATTERN = + /(?:https?:\/\/|www\.|(?= SHARE_ACTION_ACCOUNT_AGE_MS; +} diff --git a/packages/backend/server/src/core/workspaces/resolvers/doc.ts b/packages/backend/server/src/core/workspaces/resolvers/doc.ts index 026e727652..b62081d3a8 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/doc.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/doc.ts @@ -15,6 +15,7 @@ import { PrismaClient } from '@prisma/client'; import { SafeIntResolver } from 'graphql-scalars'; import { + ActionForbidden, Cache, DocActionDenied, DocDefaultRoleCanNotBeOwner, @@ -40,6 +41,7 @@ import { DocRole, } from '../../permission'; import { PublicUserType, WorkspaceUserType } from '../../user'; +import { isUserOldEnoughForShareActions } from '../abuse'; import { WorkspaceType } from '../types'; import { TimeBucket, TimeWindow } from './analytics-types'; import { @@ -299,6 +301,15 @@ export class WorkspaceDocResolver { private readonly cache: Cache ) {} + private async assertCanShare(userId: string) { + const user = await this.models.user.get(userId); + if (!user || !isUserOldEnoughForShareActions(user)) { + throw new ActionForbidden( + 'Sharing links is unavailable during the first 24 hours after signup.' + ); + } + } + @ResolveField(() => WorkspaceDocMeta, { description: 'Cloud page metadata of workspace', complexity: 2, @@ -413,6 +424,7 @@ export class WorkspaceDocResolver { } await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish'); + await this.assertCanShare(user.id); const doc = await this.models.doc.publish(workspaceId, docId, mode); diff --git a/packages/backend/server/src/core/workspaces/resolvers/member.ts b/packages/backend/server/src/core/workspaces/resolvers/member.ts index 2256089580..92970e7e1d 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/member.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/member.ts @@ -15,6 +15,7 @@ import { import { nanoid } from 'nanoid'; import { + ActionForbidden, ActionForbiddenOnNonTeamWorkspace, AlreadyInSpace, AuthenticationRequired, @@ -40,6 +41,7 @@ import { AccessController, WorkspaceRole } from '../../permission'; import { QuotaService } from '../../quota'; import { UserType } from '../../user'; import { validators } from '../../utils/validators'; +import { containsUrlOrDomain, isUserOldEnoughForShareActions } from '../abuse'; import { WorkspaceService } from '../service'; import { InvitationType, @@ -68,6 +70,24 @@ export class WorkspaceMemberResolver { private readonly quota: QuotaService ) {} + private async assertCanInviteOrShare(userId: string) { + const user = await this.models.user.get(userId); + if (!user || !isUserOldEnoughForShareActions(user)) { + throw new ActionForbidden( + 'Inviting members and creating share links are unavailable during the first 24 hours after signup.' + ); + } + } + + private async assertWorkspaceNameCanInvite(workspaceId: string) { + const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId); + if (containsUrlOrDomain(workspace.name)) { + throw new ActionForbidden( + 'Workspace names containing links or domains cannot be used to invite members.' + ); + } + } + @ResolveField(() => UserType, { description: 'Owner of workspace', complexity: 2, @@ -141,6 +161,8 @@ export class WorkspaceMemberResolver { .user(me.id) .workspace(workspaceId) .assert('Workspace.Users.Manage'); + await this.assertCanInviteOrShare(me.id); + await this.assertWorkspaceNameCanInvite(workspaceId); if (emails.length > 512) { throw new TooManyRequest(); @@ -272,6 +294,8 @@ export class WorkspaceMemberResolver { .user(user.id) .workspace(workspaceId) .assert('Workspace.Users.Manage'); + await this.assertCanInviteOrShare(user.id); + await this.assertWorkspaceNameCanInvite(workspaceId); const cacheWorkspaceId = `workspace:inviteLink:${workspaceId}`; const invite = await this.cache.get<{ inviteId: string }>(cacheWorkspaceId); diff --git a/packages/backend/server/src/mails/index.tsx b/packages/backend/server/src/mails/index.tsx index be72c08aef..e0f597679d 100644 --- a/packages/backend/server/src/mails/index.tsx +++ b/packages/backend/server/src/mails/index.tsx @@ -83,95 +83,70 @@ export const Renderers = { //#region Workspace MemberInvitation: make( Invitation, - props => `${props.user.email} invited you to join ${props.workspace.name}` + 'You were invited to join a workspace on AFFiNE' ), MemberAccepted: make( InvitationAccepted, - props => `${props.user.email} accepted your invitation` - ), - MemberLeave: make( - MemberLeave, - props => `${props.user.email} left ${props.workspace.name}` + 'Your workspace invitation was accepted' ), + MemberLeave: make(MemberLeave, 'A workspace member left'), LinkInvitationReviewRequest: make( LinkInvitationReviewRequest, - props => `New request to join ${props.workspace.name}` + 'New request to join a workspace' ), LinkInvitationApprove: make( LinkInvitationApproved, - props => `Your request to join ${props.workspace.name} has been approved` + 'Your request to join a workspace has been approved' ), LinkInvitationDecline: make( LinkInvitationReviewDeclined, - props => `Your request to join ${props.workspace.name} was declined` - ), - MemberRemoved: make( - MemberRemoved, - props => `You have been removed from ${props.workspace.name}` + 'Your request to join a workspace was declined' ), + MemberRemoved: make(MemberRemoved, 'You have been removed from a workspace'), OwnershipTransferred: make( OwnershipTransferred, - props => `Your ownership of ${props.workspace.name} has been transferred` + 'Your workspace ownership has been transferred' ), OwnershipReceived: make( OwnershipReceived, - props => `You are now the owner of ${props.workspace.name}` + 'You are now the owner of a workspace' ), //#endregion //#region Doc - Mention: make( - Mention, - props => `${props.user.email} mentioned you in ${props.doc.title}` - ), - Comment: make( - Comment, - props => `${props.user.email} commented on ${props.doc.title}` - ), - CommentMention: make( - CommentMention, - props => - `${props.user.email} mentioned you in a comment on ${props.doc.title}` - ), + Mention: make(Mention, 'You were mentioned in AFFiNE'), + Comment: make(Comment, 'New comment in AFFiNE'), + CommentMention: make(CommentMention, 'You were mentioned in a comment'), //#endregion //#region Team TeamWorkspaceUpgraded: make(TeamWorkspaceUpgraded, props => props.isOwner ? 'Your workspace has been upgraded to team workspace! 🎉' - : `${props.workspace.name} has been upgraded to team workspace! 🎉` - ), - TeamBecomeAdmin: make( - TeamBecomeAdmin, - props => `You are now an admin of ${props.workspace.name}` + : 'A workspace has been upgraded to team workspace! 🎉' ), + TeamBecomeAdmin: make(TeamBecomeAdmin, 'You are now a workspace admin'), TeamBecomeCollaborator: make( TeamBecomeCollaborator, - props => `Your role has been changed in ${props.workspace.name}` + 'Your workspace role has been changed' ), TeamDeleteIn24Hours: make( TeamDeleteIn24Hours, - props => - `[Action Required] Final warning: Your workspace ${props.workspace.name} will be deleted in 24 hours` + '[Action Required] Final warning: Your workspace will be deleted in 24 hours' ), TeamDeleteInOneMonth: make( TeamDeleteInOneMonth, - props => - `[Action Required] Important: Your workspace ${props.workspace.name} will be deleted soon` + '[Action Required] Important: Your workspace will be deleted soon' ), TeamWorkspaceDeleted: make( TeamWorkspaceDeleted, - props => `Your workspace ${props.workspace.name} has been deleted` + 'Your workspace has been deleted' ), TeamWorkspaceExpireSoon: make( TeamExpireSoon, - props => - `[Action Required] Your ${props.workspace.name} team workspace will expire soon` - ), - TeamWorkspaceExpired: make( - TeamExpired, - props => `Your ${props.workspace.name} team workspace has expired` + '[Action Required] Your team workspace will expire soon' ), + TeamWorkspaceExpired: make(TeamExpired, 'Your team workspace has expired'), //#endregion //#region License