fix(server): mail test & retry (#15044)

#### PR Dependency Tree


* **PR #15044** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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_stack_entry_start -->

[![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)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-05-31 00:06:29 +08:00
committed by GitHub
parent 2bd920fea6
commit b05c387f96
27 changed files with 702 additions and 117 deletions
@@ -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
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -973,7 +973,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> test@test.com accepted your invitation
> Your workspace invitation was accepted
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -1072,7 +1072,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> test@test.com left Test Workspace
> A workspace member left
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -1132,7 +1132,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> New request to join Test Workspace
> New request to join a workspace
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -1239,7 +1239,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> Your request to join Test Workspace has been approved
> Your request to join a workspace has been approved
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -1294,7 +1294,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> Your request to join Test Workspace was declined
> Your request to join a workspace was declined
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -1348,7 +1348,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> You have been removed from Test Workspace
> You have been removed from a workspace
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -1401,7 +1401,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> Your ownership of Test Workspace has been transferred
> Your workspace ownership has been transferred
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -1454,7 +1454,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> You are now the owner of Test Workspace
> You are now the owner of a workspace
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -1506,7 +1506,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> test@test.com mentioned you in Test Doc
> You were mentioned in AFFiNE
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<!--$-->␊
@@ -1601,7 +1601,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> test@test.com commented on Test Doc
> New comment in AFFiNE
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<!--$-->␊
@@ -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
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<!--$-->␊
@@ -1894,7 +1894,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> You are now an admin of Test Workspace
> You are now a workspace admin
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -1993,7 +1993,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> Your role has been changed in Test Workspace
> Your workspace role has been changed
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -2094,7 +2094,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> [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
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -2208,7 +2208,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> [Action Required] Important: Your workspace Test Workspace will be deleted soon
> [Action Required] Important: Your workspace will be deleted soon
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -2324,7 +2324,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> Your workspace Test Workspace has been deleted
> Your workspace has been deleted
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -2408,7 +2408,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> [Action Required] Your Test Workspace team workspace will expire soon
> [Action Required] Your team workspace will expire soon
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -2511,7 +2511,7 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊
`
> Your Test Workspace team workspace has expired
> Your team workspace has expired
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<link␊
@@ -2689,7 +2689,7 @@ Generated by [AVA](https://avajs.dev).
## should render mention email with empty doc title
> test@test.com mentioned you in
> You were mentioned in AFFiNE
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<!--$-->␊
@@ -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);
@@ -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<Job extends JobName>(name: Job): { name: Job; payload: Jobs[Job] } {
const addJobName = this.add.lastCall?.args[0];
@@ -19,6 +19,7 @@ export class MockUser extends Mocker<MockUserInput, MockedUser> {
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,
@@ -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
@@ -280,6 +280,7 @@ export class TestingApp extends ApplyType<INestApplication>() {
password: '1',
name: email,
emailVerifiedAt: new Date(),
createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000),
...override,
});
@@ -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 ?? ''))
);
}