From b05c387f96fe629aab69bd0e7cb15c978684f3d1 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Sun, 31 May 2026 00:06:29 +0800 Subject: [PATCH] fix(server): mail test & retry (#15044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### PR Dependency Tree * **PR #15044** ๐Ÿ‘ˆ This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## Summary by CodeRabbit * **Bug Fixes** * Stop sending notifications to disabled users; skip member invites when workspace names contain URLs/domains * Improve mail retry handling (per-recipient exhaustion, expiry, and cache cleanup) * Make many email headers/lead lines more generic and consistent * Fail-safe workspace content parsing to avoid crashes * **New Features** * 24-hour signup protection for sharing, invites, and invite-link creation * Job-queue: remove jobs by payload predicate * **Tests** * Expanded tests for mail jobs, SMTP hostname handling, payment checkout, job-queue removal, and abuse-detection utilities * Updated test fixtures to set createdAt timestamps for new users * **Chores** * Added required name input for test-email mutation * Database flush retry with deadlock detection/backoff [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/15044?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --- .../__tests__/__snapshots__/mails.spec.ts.md | 40 +-- .../__snapshots__/mails.spec.ts.snap | Bin 4808 -> 4815 bytes .../server/src/__tests__/mails.spec.ts | 1 + .../server/src/__tests__/mocks/queue.mock.ts | 1 + .../server/src/__tests__/mocks/user.mock.ts | 1 + .../src/__tests__/payment/service.spec.ts | 31 +++ .../server/src/__tests__/utils/testing-app.ts | 1 + .../server/src/__tests__/utils/utils.ts | 38 ++- .../base/job/queue/__tests__/queue.spec.ts | 18 ++ .../server/src/base/job/queue/queue.ts | 62 +++++ .../backend/server/src/core/doc/reader.ts | 12 +- .../src/core/mail/__tests__/job.spec.ts | 243 ++++++++++++++++++ packages/backend/server/src/core/mail/job.ts | 140 +++++++++- .../backend/server/src/core/mail/utils.ts | 4 +- .../notification/__tests__/service.spec.ts | 23 ++ .../server/src/core/notification/service.ts | 11 + .../core/workspaces/__tests__/abuse.spec.ts | 29 +++ .../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 ++--- .../src/plugins/payment/manager/user.ts | 13 +- .../src/graphql/admin/send-test-email.gql | 2 + packages/common/graphql/src/graphql/index.ts | 4 +- packages/common/graphql/src/schema.ts | 1 + .../e2e/blocksuite/paragraph.spec.ts | 30 +-- tests/kit/src/utils/cloud.ts | 1 + 27 files changed, 702 insertions(+), 117 deletions(-) create mode 100644 packages/backend/server/src/core/mail/__tests__/job.spec.ts create mode 100644 packages/backend/server/src/core/workspaces/__tests__/abuse.spec.ts 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..0b48451d26f33c6f230fe0053019b40f6384a76a 100644 GIT binary patch literal 4815 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 literal 4808 zcmV;(5;yHZRzVclEk{pt`$i zdrpyh^yxF8`UkPstr;JPQoZEtW!hyxOb72+@g z2@V`qTu?-2+8LQ?S9Oic<)4=&b=#SCIwRu6i|_m5#f!ggx0vux)gOLFG}Vk~)fGJ! zAW3*YBqAZEOi{&gq`E?@KmU!-f&gp$;?;k7^_8!@Vvb*U?Ui5t#eaBpZ{zWMk4|1Z zKSEs{GW7hzhfm*mgeqRG_VL|Ewc6v8$LM=cPM$qQjcOg8NX!-0RB+5{wWD_{sM6Is zYSwD~e!tqkTNScXJ9)o0F!yShFDEZOo%v=p&_U(k#@>eKebe&}ZtQ8?V&n!wn9&a3 zulR&(A}co#>eE1X_bZKh{kP29mXHCF`<1$R;}gar90ZhiGG|nT{qY$o7}>8xk|@G8 zHosmuxPj1~Zi!%MUYXZ2eqmk)=}Uz6qR~r)lpdNJwFTFnqCXqX98xxHzGKR?i4G;jY!f{p?2KsY<4vS6SDqr0ws%7;JCu7` zM9r=3Xpp{d39Uugtnd1Q3E8~e*fxKagUkb-5T((PX>`<*bQik?i`2T`sx=02%j zx}`QeuVy029x>4iR;%}?50xH; zhf9y#L#BuGu<22J=X*eVC?wmh6XYInVt}&JHJ7$GF<6Yb1h>u$%)u}$2Gj379+_8Z6beEtIcc@>u8>9RbwFnb^@M5zvEnKMOt|aY! z3piD?R>M(L#qBobq#8+4vkl{!hWKqQP6^+S`}!RmM!Obz?;8~UJLaFNFT%|v&x_0;Uk{qGe5#$$Vw zIL5}&ckUlObe-yhf>Rxzne3O7UzTx)jt1#H?Xx||ypKQ8$ev0bH+zbacKo4YJ83LC zMDJI=-(r}b7Ox-dsR;AJX>(!2^FE<%6e>}28KF-P_GoGbrt#v|u?@O?FKJL~4H8pD znvJar>e3(}Wj|(r@XqZATlKBo8X?M2jmEmOw^B@K|eaaXjgNPdD#?jq63MubtqNqQ`i*#ky{MHmh8QdrhJK?(yY45Y9XFtufMPSd2YUIbFu z^_vz*VIYNp6t-kiST6!845To?7a)b9MUlb=-+GaWJj1>(dR!ak*~e5HSl1TP;J=MT zA-nm|QONGz1chuR6f&Zn5X1;8)fW;ZG8iSUZQYXAls zpPE1N{?$pKD}~y}4%FTOsJ#+U`)##3{m-`majyd6{>XekY4~`_Jq`j% zlmfiH`~YWZ@wN#7mBh3T8zM=5*dt1lz~Jq(*j^13cgz)GrG!thCPCqDHm+b;oXV^2 zwIr$?oCxHLFk(a#q|MxAB!y3u+C&i}SQR58ZCDo)+&+bUAnMzu%tUhmN?&^;w)5b* zX_qnWD|{Si;P?vAK%jv@1J{HGPK2Pq2jf%o=YzjCu}xAo_lu35ihzC12MG6!W$S6Lm-9-h~ae;L$iv+1X=VV zgzbDGki^9xiH~B)HIXFV{Ha3{e*!^zD@PIw-4WQ~^5dSR1=e$7hv+p@_)1c@oIGiTVU}@fk^AZ;+wrRrEZDs3ATjNcEg_c0$Wm2`S}g zRFhz1wS3%q(z~TqHZI_O)BJ>~?@jF1IQ1g^5$Fe^Y^srlox(|kl=Ls`g-X7TnclD9 zGpw;JyX>@^^#W%w{7iwGaP zJM@f%Eg~T#AvraF;&aC(K9ewLLU+FW2M*ndz6^8+=+2}hq_%4#Aaz44|qV7&p6DvRR^7Hz)gpwC=839yeUAb0T=eZ(rL47Ug2N2%$2?29e8CuQ8FWbE z^bkUKi9}&M892MeZ1>8VJLlLlWr}G?y2$Od?>nykIk@`8aVv}F>Qm`3p|2~p%N}_6 zHT3YQDWlfVg)hjeH?won^@}*^K31bAO8s-k@z37_|GYR(VbT2a*pL#4B1ve5dhpZN z>8Io53f9{s?Sa}-gy5~Gd+Qi;bCVN-cZiH@F@@eDw^(v^e){dnEjB?bVUC0T6dd&8 zc!NcA(C?XfA=RZ3vaQ!9Qi6A01n-<_j(GCUHj-$AlfHal<9cx(wCdvQF(qIv8@-QKi$>~a(Qpl~mM=Z-Z^rD(^ncpS^Jwt0|f zgU>GY*=5lxah!^+(sqwAJEW@}~~fFL+!2Z8_u0SE#R zgry@0ADMMdk7B<{}qQIeC0Y5+X6wjx)}w-4KWaf)j>Mz zL=?`OT^L(7Fo|5*fk^<9044!U0+>Yh048xkCb50abVGLF%~Ok8KXs_ZPi{e!De#qx zSvK z6_V;9@_R~)&=jc`MMCO)*8>g+91u7laKNSGfbJ$Dzyjk_^Jjq%>|(=yacSY?fqVn~ z_h0?VAElE_KXaJiKf&&azyznvT-~`n*xK1mm$pSxghIzQ_61YYEM)7fC$w5xI_R#u zEEuP|WIib@Fv#3-(cI#8p(aPUJmOu#qFk=3c-dr#xnX$;>nhxD%5waqp@1m?Qv#+0 zOlj$u(szX!fq*LIPR*Yx-Eq>v@DV7Ou&w<6I>hKdfEWQWnwWc5FoJqfV3H|QJ_a$s zhL#^bEi)UEwyH|k_yh0B9bb6n%K%Cz9mMM64QnLQE)?F=}8?OA4x%nn3|`x-#aJ3oo>NzE2rLEn=boL(B=+ zY={EFh>pjdt!>m5J*i69l1*KqwN>A`=hYkD-A1xTtBOuse^a=j@jsg=VgxIKG(IJW zh9L>4;bhpiIKKE0j1~);JX8Kq$Oj-FfP4V*!LrH+4=H1mcR)OdPt9LE_`OdKEj+S&GEzx`Pth^un4vzF z+;no33tG+ZG?#$D65y9g4X2LO@cUl}sR5*h30&^PAom!rgTyetuu{MaBnFTeE+8?G zK}78?T72>p?S)i?`dCFcWUM6MBR$R)HNLozf!=C7sG|T66~b*zisocXZF}I`1<}J5 zIS4hIfP<<5ss^YUplYO_0#yT44FswNs2XKeygsVNdqq{_ozM(X*i2-2QYt1FQYF>|0><)Vtfdt&4E?pn2tnra2r@5|5R!dm$Aga~A*Lo1 zV4`;vZr2tPrJ_L)4|)Q^CcZF&7t`h4siQp z$e{_qZQ>kMK9e3}iF3A801zDbcbI}&P3TrgqB-vRGo6MyXX+-FFfihar*i&WMLNR0R+zOyg(Ck682hF~4fM)L; zpxIxL*&P)%d&~853~$O9ol)v<5r^=71cVO=-vtR@UWfypx3tI#cpmV)YYH`VWy-?+ zd^{p|lNj9;23NS<)Q~u!Zb03Dx&d{|Jr__npl&mrl$%b}%*z|vFswQdD!iDsV1IvJxwn95=ZC42?voB z*^T+lBAPkAXZ|V&HwMY!mHd+)d$nTy0rFluYHu@$_DrH06U}u>h6cnWdjKU=j<9xD zD4iKU=_Sc&8Srry^kjx?`}S$CU8Da5g35l0-*ggzjw?L8671lU2#{xsA1GD2rydf_#!FViY-z1Fm5Cy_V_8U}j9I39*>cs_^J+E1s?~c6Jvp+^{V6yC`G5tdKRT z&!jx{`#V8K0*6LpnYEN iuC9-T53*-`YW^hTH=W_v9e2&ziT?+EjwiqTV*vnRi79sg diff --git a/packages/backend/server/src/__tests__/mails.spec.ts b/packages/backend/server/src/__tests__/mails.spec.ts index c0ee374769..904c9e77af 100644 --- a/packages/backend/server/src/__tests__/mails.spec.ts +++ b/packages/backend/server/src/__tests__/mails.spec.ts @@ -31,6 +31,7 @@ test('should normalize valid SMTP HELO hostnames', t => { }); test('should reject invalid SMTP HELO hostnames', t => { + t.is(normalizeSMTPHeloHostname(), undefined); t.is(normalizeSMTPHeloHostname(''), undefined); t.is(normalizeSMTPHeloHostname(' '), undefined); t.is(normalizeSMTPHeloHostname('AFFiNE Server'), undefined); diff --git a/packages/backend/server/src/__tests__/mocks/queue.mock.ts b/packages/backend/server/src/__tests__/mocks/queue.mock.ts index d44a9b6321..a2ec86b33b 100644 --- a/packages/backend/server/src/__tests__/mocks/queue.mock.ts +++ b/packages/backend/server/src/__tests__/mocks/queue.mock.ts @@ -6,6 +6,7 @@ import { JobQueue } from '../../base'; export class MockJobQueue { add = Sinon.createStubInstance(JobQueue).add.resolves(); remove = Sinon.createStubInstance(JobQueue).remove.resolves(); + removeWhere = Sinon.createStubInstance(JobQueue).removeWhere.resolves([]); last(name: Job): { name: Job; payload: Jobs[Job] } { const addJobName = this.add.lastCall?.args[0]; diff --git a/packages/backend/server/src/__tests__/mocks/user.mock.ts b/packages/backend/server/src/__tests__/mocks/user.mock.ts index 195dcc62ac..4868875b78 100644 --- a/packages/backend/server/src/__tests__/mocks/user.mock.ts +++ b/packages/backend/server/src/__tests__/mocks/user.mock.ts @@ -19,6 +19,7 @@ export class MockUser extends Mocker { const password = input?.password ?? faker.internet.password(); const user = await this.db.user.create({ data: { + createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000), email: faker.internet.email(), name: faker.person.fullName(), password: password ? hashSync(password) : undefined, diff --git a/packages/backend/server/src/__tests__/payment/service.spec.ts b/packages/backend/server/src/__tests__/payment/service.spec.ts index 7fea956f2f..db798bbffb 100644 --- a/packages/backend/server/src/__tests__/payment/service.spec.ts +++ b/packages/backend/server/src/__tests__/payment/service.spec.ts @@ -438,6 +438,37 @@ test('should throw if user has subscription already', async t => { ); }); +test('should allow checkout after local subscription period ended', async t => { + const { service, u1, db, stripe } = t.context; + + await db.subscription.create({ + data: { + targetId: u1.id, + stripeSubscriptionId: 'sub_expired_ai', + plan: SubscriptionPlan.AI, + recurring: SubscriptionRecurring.Yearly, + status: SubscriptionStatus.Active, + start: new Date('2026-05-04T13:11:45.000Z'), + end: new Date('2026-05-11T13:11:45.000Z'), + }, + }); + + await service.checkout( + { + plan: SubscriptionPlan.AI, + recurring: SubscriptionRecurring.Yearly, + successCallbackLink: '', + }, + { user: u1 } + ); + + t.true(stripe.checkout.sessions.create.calledOnce); + t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), { + price: AI_YEARLY, + coupon: undefined, + }); +}); + test('should get correct pro plan price for checking out', async t => { const { app, service, u1, stripe, feature } = t.context; // non-ea user diff --git a/packages/backend/server/src/__tests__/utils/testing-app.ts b/packages/backend/server/src/__tests__/utils/testing-app.ts index 97f744e5a5..d6c88cb0e4 100644 --- a/packages/backend/server/src/__tests__/utils/testing-app.ts +++ b/packages/backend/server/src/__tests__/utils/testing-app.ts @@ -280,6 +280,7 @@ export class TestingApp extends ApplyType() { password: '1', name: email, emailVerifiedAt: new Date(), + createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000), ...override, }); diff --git a/packages/backend/server/src/__tests__/utils/utils.ts b/packages/backend/server/src/__tests__/utils/utils.ts index adbe1353f0..a797e4a197 100644 --- a/packages/backend/server/src/__tests__/utils/utils.ts +++ b/packages/backend/server/src/__tests__/utils/utils.ts @@ -11,13 +11,39 @@ async function flushDB(client: PrismaClient) { FROM pg_catalog.pg_tables WHERE schemaname != 'pg_catalog' AND schemaname != 'information_schema'`; + const query = `TRUNCATE TABLE ${result + .map(({ tablename }) => tablename) + .filter(name => !name.includes('migrations')) + .join(', ')}`; - // remove all table data - await client.$executeRawUnsafe( - `TRUNCATE TABLE ${result - .map(({ tablename }) => tablename) - .filter(name => !name.includes('migrations')) - .join(', ')}` + for (let attempt = 0; attempt < 3; attempt++) { + try { + // remove all table data + await client.$executeRawUnsafe(query); + return; + } catch (error) { + if (!isDeadlockError(error) || attempt === 2) { + throw error; + } + await sleep((attempt + 1) * 50); + } + } +} + +function isDeadlockError(error: unknown) { + if (typeof error !== 'object' || error === null || !('code' in error)) { + return false; + } + + const prismaError = error as { + code?: string; + meta?: { code?: string; message?: string }; + }; + + return ( + prismaError.code === 'P2010' && + (prismaError.meta?.code === '40P01' || + /deadlock detected/i.test(prismaError.meta?.message ?? '')) ); } diff --git a/packages/backend/server/src/base/job/queue/__tests__/queue.spec.ts b/packages/backend/server/src/base/job/queue/__tests__/queue.spec.ts index 1d770fac14..e4ba4cf19e 100644 --- a/packages/backend/server/src/base/job/queue/__tests__/queue.spec.ts +++ b/packages/backend/server/src/base/job/queue/__tests__/queue.spec.ts @@ -117,6 +117,24 @@ test('should remove job from queue', async t => { t.is(nullData, undefined); t.is(nullJob, undefined); }); + +test('should remove jobs by payload predicate', async t => { + const keep = await queue.add('nightly.__test__job', { name: 'keep' }); + const remove = await queue.add('nightly.__test__job', { name: 'remove' }); + const other = await queue.add('nightly.__test__job2', { name: 'remove' }); + const getJobs = Sinon.spy(bullmq, 'getJobs'); + + const removed = await queue.removeWhere( + 'nightly.__test__job', + job => job.name === 'remove' + ); + + t.deepEqual(getJobs.firstCall.args.slice(0, 3), [['waiting'], 0, 99]); + t.deepEqual(removed, [{ name: 'remove' }]); + t.truthy(await queue.get(keep.id!, 'nightly.__test__job')); + t.is(await queue.get(remove.id!, 'nightly.__test__job'), undefined); + t.truthy(await queue.get(other.id!, 'nightly.__test__job2')); +}); // #endregion // #region executor diff --git a/packages/backend/server/src/base/job/queue/queue.ts b/packages/backend/server/src/base/job/queue/queue.ts index 73c6e64725..c5729d968d 100644 --- a/packages/backend/server/src/base/job/queue/queue.ts +++ b/packages/backend/server/src/base/job/queue/queue.ts @@ -12,6 +12,15 @@ interface JobData { payload: Jobs[T]; } +const removableJobStates = [ + 'waiting', + 'delayed', + 'prioritized', + 'paused', + 'waiting-children', +] as const; +const removeWhereBatchSize = 100; + @Injectable() export class JobQueue { private readonly logger = new Logger(JobQueue.name); @@ -55,6 +64,59 @@ export class JobQueue { return undefined; } + async removeWhere( + jobName: T, + predicate: (payload: Jobs[T]) => boolean | Promise + ): Promise { + const ns = namespace(jobName); + const queue = this.getQueue(ns); + const removed: Jobs[T][] = []; + + for (const state of removableJobStates) { + let start = 0; + let removedFromBatch = false; + let hasMoreJobs = true; + + while (hasMoreJobs) { + removedFromBatch = false; + const jobs = (await queue.getJobs( + [state], + start, + start + removeWhereBatchSize - 1 + )) as Job>[]; + + if (!jobs.length) { + hasMoreJobs = false; + break; + } + + for (const job of jobs) { + if (job.name !== jobName) { + continue; + } + + const payload = job.data.payload; + if (!(await predicate(payload))) { + continue; + } + + await job.remove(); + this.logger.log( + `Job ${jobName}(id=${job.id}) removed from queue ${ns}` + ); + removed.push(payload); + removedFromBatch = true; + } + + if (!removedFromBatch) { + start += removeWhereBatchSize; + } + } + } + + return removed; + } + async get(jobId: string, jobName: T) { const ns = namespace(jobName); const queue = this.getQueue(ns); diff --git a/packages/backend/server/src/core/doc/reader.ts b/packages/backend/server/src/core/doc/reader.ts index c671f33171..d64912c137 100644 --- a/packages/backend/server/src/core/doc/reader.ts +++ b/packages/backend/server/src/core/doc/reader.ts @@ -16,6 +16,7 @@ import { parseDocToMarkdownFromDocSnapshot, parsePageDoc, parseWorkspaceDoc, + type WorkspaceDocContent, } from '../utils/blocksuite'; import { PgWorkspaceDocStorageAdapter } from './adapters/workspace'; import { type DocDiff, type DocRecord } from './storage'; @@ -242,10 +243,17 @@ export class DatabaseDocReader extends DocReader { if (!docRecord) { return null; } - const content = this.parseWorkspaceContent(docRecord.bin); - if (!content) { + let content: WorkspaceDocContent | null; + try { + content = this.parseWorkspaceContent(docRecord.bin); + } catch (error) { + this.logger.warn( + `Failed to parse workspace ${workspaceId} content`, + error as Error + ); return null; } + if (!content) return null; let avatarUrl: string | undefined; if (content.avatarKey) { avatarUrl = this.blobStorage.getAvatarUrl(workspaceId, content.avatarKey); diff --git a/packages/backend/server/src/core/mail/__tests__/job.spec.ts b/packages/backend/server/src/core/mail/__tests__/job.spec.ts new file mode 100644 index 0000000000..9e2283f1b7 --- /dev/null +++ b/packages/backend/server/src/core/mail/__tests__/job.spec.ts @@ -0,0 +1,243 @@ +import test from 'ava'; +import Sinon from 'sinon'; + +import { Mockers } from '../../../__tests__/mocks'; +import { createTestingModule } from '../../../__tests__/utils'; +import { Cache } from '../../../base'; +import { Models } from '../../../models'; +import { MailJob } from '../job'; +import { MailSender } from '../sender'; + +let module: Awaited>; +let cache: Cache; +let mailJob: MailJob; +let sender: MailSender; +let models: Models; + +test.before(async () => { + module = await createTestingModule(); + cache = module.get(Cache); + mailJob = module.get(MailJob); + sender = module.get(MailSender); + models = module.get(Models); +}); + +test.after.always(async () => { + await module.close(); +}); + +test.afterEach(() => { + Sinon.restore(); +}); + +test('should clear pending mail records when user is deleted', async t => { + const user = await module.create(Mockers.User); + const another = await module.create(Mockers.User); + const sendMailKey = 'mailjob:sendMail'; + const retryMailKey = 'mailjob:sendMail:retry'; + const userKey = `${sendMailKey}:SignIn:${user.email}`; + const userRetryKey = `${sendMailKey}:VerifyEmail:${user.email}`; + const anotherKey = `${sendMailKey}:SignIn:${another.email}`; + + await cache.mapSet(sendMailKey, userKey, 1); + await cache.mapSet(sendMailKey, anotherKey, 1); + await cache.mapSet( + retryMailKey, + userRetryKey, + JSON.stringify({ + startTime: Date.now(), + name: 'VerifyEmail', + to: user.email, + props: { url: 'https://affine.pro/verify' }, + }) + ); + + await mailJob.onUserDeleted({ ...user, ownedWorkspaces: [] }); + + t.true(module.queue.removeWhere.calledOnce); + t.is(module.queue.removeWhere.firstCall.args[0], 'notification.sendMail'); + const shouldRemove = module.queue.removeWhere.firstCall.args[1]; + t.true( + await shouldRemove({ + to: user.email, + } as Jobs['notification.sendMail']) + ); + t.false( + await shouldRemove({ + to: another.email, + } as Jobs['notification.sendMail']) + ); + t.is(await cache.mapGet(sendMailKey, userKey), undefined); + t.is(await cache.mapGet(retryMailKey, userRetryKey), undefined); + t.is(await cache.mapGet(sendMailKey, anotherKey), 1); +}); + +test('should skip queued mail for disabled recipient', async t => { + const user = await module.create(Mockers.User, { disabled: true }); + const send = Sinon.stub(sender, 'send').resolves(true); + + await mailJob.sendMail({ + startTime: Date.now(), + name: 'SignIn', + to: user.email, + props: { + url: 'https://affine.pro/sign-in', + otp: '123456', + }, + }); + + t.false(send.called); + t.truthy(await models.user.get(user.id, { withDisabled: true })); +}); + +test('should drop expired mail retry', async t => { + const send = Sinon.stub(sender, 'send').resolves(true); + + await mailJob.sendMail({ + startTime: Date.now() - 25 * 60 * 60 * 1000, + name: 'SignIn', + to: 'expired-retry@example.com', + props: { + url: 'https://affine.pro/sign-in', + otp: '123456', + }, + }); + + 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); + + await mailJob.sendMail({ + startTime: Date.now(), + retryCount: 12, + name: 'SignIn', + to: 'max-retry@example.com', + props: { + url: 'https://affine.pro/sign-in', + otp: '123456', + }, + }); + + t.false(send.called); +}); + +test('should requeue legacy stringified retry mail', async t => { + const retryMailKey = 'mailjob:sendMail:retry'; + const job: Jobs['notification.sendMail'] = { + startTime: Date.now(), + name: 'SignIn', + to: 'legacy-retry@example.com', + props: { + url: 'https://affine.pro/sign-in', + otp: '123456', + }, + }; + const cacheKey = `${retryMailKey}:SignIn:${job.to}`; + + Sinon.stub(cache, 'mapRandomKey') + .onFirstCall() + .resolves(cacheKey) + .onSecondCall() + .resolves(undefined); + await cache.mapSet(retryMailKey, cacheKey, JSON.stringify(job)); + await mailJob.sendRetryMails(); + + 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 fe427a219b..92dd2e5377 100644 --- a/packages/backend/server/src/core/mail/job.ts +++ b/packages/backend/server/src/core/mail/job.ts @@ -2,12 +2,13 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { getStreamAsBuffer } from 'get-stream'; -import { Cache, JOB_SIGNAL, JobQueue, OnJob, sleep } from '../../base'; +import { Cache, JOB_SIGNAL, JobQueue, OnEvent, OnJob, sleep } from '../../base'; import { type MailName, MailProps, Renderers } from '../../mails'; 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 } & { + 'notification.sendMail': { + startTime: number; + retryCount?: number; + expiresAt?: number; + } & { [K in MailName]: SendMailJob; }[MailName]; } @@ -47,6 +52,19 @@ const sendMailCacheKey = (name: string, to: string) => `${sendMailKey}:${name}:${to}`; 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 { @@ -66,17 +84,65 @@ export class MailJob { return Math.min(30 * 1000, Math.round(elapsed / 2000) * 1000); } + private getRetryExhaustedReason({ + startTime, + retryCount, + expiresAt, + name, + }: Jobs['notification.sendMail']) { + const expiredAt = + expiresAt ?? startTime + (mailExpiresIn[name] ?? retryMaxAge); + if (Date.now() > expiredAt) { + return 'expired'; + } + + if ((retryCount ?? 0) > retryMaxAttempts) { + return 'max attempts reached'; + } + + return; + } + + private async shouldSkipRecipient(to: string) { + const user = await this.models.user.getUserByEmail(to, { + withDisabled: true, + }); + + return user?.disabled === true; + } + + private async deleteRecipientMailCache(to: string) { + const suffix = `:${to}`; + + await Promise.all( + [sendMailKey, retryMailKey].map(async map => { + const keys = await this.cache.mapKeys(map); + await Promise.all( + keys + .filter(key => key.endsWith(suffix)) + .map(key => this.cache.mapDelete(map, key)) + ); + }) + ); + } + private async sendMailInternal({ startTime, name, to, props, }: Jobs['notification.sendMail']) { - let options: Partial = {}; + if (await this.shouldSkipRecipient(to)) { + this.logger.debug(`Skip mail [${name}] to disabled user [${to}]`); + return; + } - for (const key in props) { + let options: Partial = {}; + const renderedProps = { ...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( @@ -87,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 = [ { @@ -99,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); @@ -108,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, }); @@ -177,17 +266,41 @@ export class MailJob { @OnJob('notification.sendMail') async sendMail(job: Jobs['notification.sendMail']) { const cacheKey = sendMailCacheKey(job.name, job.to); + job.retryCount = (job.retryCount ?? 0) + 1; + const exhaustedReason = this.getRetryExhaustedReason(job); + if (exhaustedReason) { + this.logger.warn( + `Drop mail [${job.name}] to [${job.to}], reason=${exhaustedReason}` + ); + await Promise.all([ + this.cache.mapDelete(sendMailKey, cacheKey), + this.cache.mapDelete(retryMailKey, cacheKey), + ]); + return; + } + const retried = await this.cache.mapIncrease(sendMailKey, cacheKey, 1); if (retried <= retryFirstTime) { const ret = await this.sendMailInternal(job); if (!ret) await this.cache.mapDelete(sendMailKey, cacheKey); return ret; } - await this.cache.mapSet(retryMailKey, cacheKey, JSON.stringify(job)); + await this.cache.mapSet(retryMailKey, cacheKey, job); await this.cache.mapDelete(sendMailKey, cacheKey); return undefined; } + @OnEvent('user.deleted') + async onUserDeleted(user: Events['user.deleted']) { + await Promise.all([ + this.deleteRecipientMailCache(user.email), + this.queue.removeWhere( + 'notification.sendMail', + job => job.to === user.email + ), + ]); + } + @Cron(CronExpression.EVERY_MINUTE) async sendRetryMails() { // pick random one from the retry map @@ -195,9 +308,14 @@ export class MailJob { let key = await this.cache.mapRandomKey(retryMailKey); while (key && processed < retryMaxPerTick) { try { - const job = await this.cache.mapGet(retryMailKey, key); + const job = await this.cache.mapGet< + Jobs['notification.sendMail'] | string + >(retryMailKey, key); if (job) { - const jobData = JSON.parse(job) as Jobs['notification.sendMail']; + const jobData = + typeof job === 'string' + ? (JSON.parse(job) as Jobs['notification.sendMail']) + : job; await this.queue.add('notification.sendMail', jobData); // wait for a while before retrying const retryDelay = this.calculateRetryDelay(jobData.startTime); diff --git a/packages/backend/server/src/core/mail/utils.ts b/packages/backend/server/src/core/mail/utils.ts index 33adf329a5..572406b751 100644 --- a/packages/backend/server/src/core/mail/utils.ts +++ b/packages/backend/server/src/core/mail/utils.ts @@ -17,7 +17,9 @@ function isValidSMTPAddressLiteral(hostname: string) { return false; } -export function normalizeSMTPHeloHostname(hostname: string) { +export function normalizeSMTPHeloHostname(hostname?: string) { + if (!hostname) return undefined; + const normalized = hostname.trim().replace(/\.$/, ''); if (!normalized) return undefined; if (isValidSMTPAddressLiteral(normalized)) return normalized; 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 fa50273c7d..65e2b01715 100644 --- a/packages/backend/server/src/core/notification/service.ts +++ b/packages/backend/server/src/core/notification/service.ts @@ -23,6 +23,7 @@ import { generateWorkspaceSettingsPath, WorkspaceSettingsTab, } from '../utils/workspace'; +import { containsUrlOrDomain } from '../workspaces/abuse'; @Injectable() export class NotificationService { @@ -166,6 +167,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/__tests__/abuse.spec.ts b/packages/backend/server/src/core/workspaces/__tests__/abuse.spec.ts new file mode 100644 index 0000000000..26e6b4bc89 --- /dev/null +++ b/packages/backend/server/src/core/workspaces/__tests__/abuse.spec.ts @@ -0,0 +1,29 @@ +import test from 'ava'; + +import { + containsUrlOrDomain, + isUserOldEnoughForShareActions, + SHARE_ACTION_ACCOUNT_AGE_MS, +} from '../abuse'; + +test('should detect links and bare domains in workspace names', t => { + t.true(containsUrlOrDomain('BTC https://spam.example')); + t.true(containsUrlOrDomain('Join spam.example now')); + t.true(containsUrlOrDomain('Join spam.example, ltd')); + t.true(containsUrlOrDomain('Join spam.exampleใ€‚')); + t.true(containsUrlOrDomain('www.spam.example')); +}); + +test('should not detect email addresses or partial domain words', t => { + t.false(containsUrlOrDomain('Contact user@spam.example')); + t.false(containsUrlOrDomain('spam.example_btc')); +}); + +test('should check account age for share actions', t => { + t.false(isUserOldEnoughForShareActions({ createdAt: new Date() })); + t.true( + isUserOldEnoughForShareActions({ + createdAt: new Date(Date.now() - SHARE_ACTION_ACCOUNT_AGE_MS - 1), + }) + ); +}); 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..190899d9e9 --- /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 4ba9aa6036..353c8255bf 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 { Prisma, PrismaClient } from '@prisma/client'; import { SafeIntResolver } from 'graphql-scalars'; import { + ActionForbidden, Cache, DocActionDenied, DocDefaultRoleCanNotBeOwner, @@ -44,6 +45,7 @@ import { PermissionService, } from '../../permission'; import { PublicUserType, WorkspaceUserType } from '../../user'; +import { isUserOldEnoughForShareActions } from '../abuse'; import { DocGrantsService } from '../doc-grants'; import { WorkspaceType } from '../types'; import { TimeBucket, TimeWindow } from './analytics-types'; @@ -302,6 +304,15 @@ export class WorkspaceDocResolver { private readonly event: EventBus ) {} + 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, @@ -441,6 +452,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); this.event.emit('doc.public_state.changed', { workspaceId, docId }); diff --git a/packages/backend/server/src/core/workspaces/resolvers/member.ts b/packages/backend/server/src/core/workspaces/resolvers/member.ts index aa4eac875a..da43be2ddd 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, @@ -45,6 +46,7 @@ import { import { QuotaService } from '../../quota'; import { UserType } from '../../user'; import { validators } from '../../utils/validators'; +import { containsUrlOrDomain, isUserOldEnoughForShareActions } from '../abuse'; import { WorkspaceService } from '../service'; import { InvitationType, @@ -74,6 +76,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, @@ -149,6 +169,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(); @@ -280,6 +302,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 diff --git a/packages/backend/server/src/plugins/payment/manager/user.ts b/packages/backend/server/src/plugins/payment/manager/user.ts index 0ce760dcbc..4aa50ce8f3 100644 --- a/packages/backend/server/src/plugins/payment/manager/user.ts +++ b/packages/backend/server/src/plugins/payment/manager/user.ts @@ -121,22 +121,17 @@ export class UserSubscriptionManager extends SubscriptionManager { throw new ManagedByAppStoreOrPlay(); } - const subscription = await this.getSubscription({ - plan: lookupKey.plan, - userId: user.id, - }); - if ( - subscription && + active && // do not allow to re-subscribe unless !( /* current subscription is a onetime subscription and so as the one that's checking out */ ( - (subscription.variant === SubscriptionVariant.Onetime && + (active.variant === SubscriptionVariant.Onetime && lookupKey.variant === SubscriptionVariant.Onetime) || /* current subscription is normal subscription and is checking-out a lifetime subscription */ - (subscription.recurring !== SubscriptionRecurring.Lifetime && - subscription.variant !== SubscriptionVariant.Onetime && + (active.recurring !== SubscriptionRecurring.Lifetime && + active.variant !== SubscriptionVariant.Onetime && lookupKey.recurring === SubscriptionRecurring.Lifetime) ) ) diff --git a/packages/common/graphql/src/graphql/admin/send-test-email.gql b/packages/common/graphql/src/graphql/admin/send-test-email.gql index 2af841df26..8e861e500a 100644 --- a/packages/common/graphql/src/graphql/admin/send-test-email.gql +++ b/packages/common/graphql/src/graphql/admin/send-test-email.gql @@ -1,4 +1,5 @@ mutation sendTestEmail( + $name: String! $host: String! $port: Int! $sender: String! @@ -8,6 +9,7 @@ mutation sendTestEmail( ) { sendTestEmail( config: { + name: $name host: $host port: $port sender: $sender diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index 2235f10d3c..b587b45d92 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -448,9 +448,9 @@ export const listUsersQuery = { export const sendTestEmailMutation = { id: 'sendTestEmailMutation' as const, op: 'sendTestEmail', - query: `mutation sendTestEmail($host: String!, $port: Int!, $sender: String!, $username: String!, $password: String!, $ignoreTLS: Boolean!) { + query: `mutation sendTestEmail($name: String!, $host: String!, $port: Int!, $sender: String!, $username: String!, $password: String!, $ignoreTLS: Boolean!) { sendTestEmail( - config: {host: $host, port: $port, sender: $sender, username: $username, password: $password, ignoreTLS: $ignoreTLS} + config: {name: $name, host: $host, port: $port, sender: $sender, username: $username, password: $password, ignoreTLS: $ignoreTLS} ) }`, }; diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index b19a492081..6f626a4cda 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -4175,6 +4175,7 @@ export type ListUsersQuery = { }; export type SendTestEmailMutationVariables = Exact<{ + name: Scalars['String']['input']; host: Scalars['String']['input']; port: Scalars['Int']['input']; sender: Scalars['String']['input']; diff --git a/tests/affine-local/e2e/blocksuite/paragraph.spec.ts b/tests/affine-local/e2e/blocksuite/paragraph.spec.ts index cb0ecf2895..5c94ee4669 100644 --- a/tests/affine-local/e2e/blocksuite/paragraph.spec.ts +++ b/tests/affine-local/e2e/blocksuite/paragraph.spec.ts @@ -328,25 +328,9 @@ test('also move children when dedent collapsed heading', async ({ page }) => { const paragraph = page.locator('affine-note affine-paragraph'); const subParagraph = paragraph.nth(0).locator('affine-paragraph'); - expect(await subParagraph.count()).toBe(2); - expect( - await subParagraph - .nth(0) - .evaluate( - (block: ParagraphBlockComponent) => - block.model.props.type === 'h1' && - block.model.props.text.toString() === 'bbb' - ) - ).toBeTruthy(); - expect( - await subParagraph - .nth(1) - .evaluate( - (block: ParagraphBlockComponent) => - block.model.props.type === 'text' && - block.model.props.text.toString() === 'ccc' - ) - ).toBeTruthy(); + await expect.poll(() => subParagraph.count()).toBe(2); + await expectParagraphState(subParagraph, 0, 'h1', 'bbb'); + await expectParagraphState(subParagraph, 1, 'text', 'ccc'); expect(await subParagraph.nth(1).isVisible()).toBeTruthy(); await subParagraph @@ -442,7 +426,12 @@ test('unfold collapsed heading when its other blocks indented to be its sibling' await type(page, '# bbb\nddd'); await page.keyboard.press('ArrowUp'); await pressTab(page); - await page.keyboard.press('ArrowRight'); + const paragraph = page.locator('affine-note affine-paragraph'); + const subParagraph = paragraph.nth(0).locator('affine-paragraph'); + await expect.poll(() => subParagraph.count()).toBe(1); + await expectParagraphState(subParagraph, 0, 'h1', 'bbb'); + await subParagraph.nth(0).click(); + await page.keyboard.press('End'); await pressEnter(page); await type(page, 'ccc'); @@ -453,7 +442,6 @@ test('unfold collapsed heading when its other blocks indented to be its sibling' * ddd */ - const paragraph = page.locator('affine-note affine-paragraph'); await expectParagraphVisibility(paragraph, 2, true); await expectParagraphState(paragraph, 2, 'text', 'ccc'); await paragraph.locator('blocksuite-toggle-button .toggle-icon').click(); diff --git a/tests/kit/src/utils/cloud.ts b/tests/kit/src/utils/cloud.ts index 8bfcd2510b..74c34de157 100644 --- a/tests/kit/src/utils/cloud.ts +++ b/tests/kit/src/utils/cloud.ts @@ -116,6 +116,7 @@ export async function createRandomUser(): Promise<{ data: { ...user, emailVerifiedAt: new Date(), + createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000), password: await hash(user.password), features: { create: {