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 --> [](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:
@@ -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">␊
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
<link␊
|
<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">␊
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
<link␊
|
<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">␊
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
<link␊
|
<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">␊
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
<link␊
|
<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">␊
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
<link␊
|
<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">␊
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
<link␊
|
<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">␊
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
<link␊
|
<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">␊
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
<link␊
|
<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">␊
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
<link␊
|
<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">␊
|
`<!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">␊
|
`<!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">␊
|
`<!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">␊
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
<link␊
|
<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">␊
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
<link␊
|
<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">␊
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
<link␊
|
<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">␊
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
<link␊
|
<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">␊
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
<link␊
|
<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">␊
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
<link␊
|
<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">␊
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
<link␊
|
<link␊
|
||||||
@@ -2689,7 +2689,7 @@ Generated by [AVA](https://avajs.dev).
|
|||||||
|
|
||||||
## should render mention email with empty doc title
|
## 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">␊
|
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
|
||||||
<!--$-->␊
|
<!--$-->␊
|
||||||
|
|||||||
Binary file not shown.
@@ -31,6 +31,7 @@ test('should normalize valid SMTP HELO hostnames', t => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should reject invalid 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(' '), undefined);
|
t.is(normalizeSMTPHeloHostname(' '), undefined);
|
||||||
t.is(normalizeSMTPHeloHostname('AFFiNE Server'), undefined);
|
t.is(normalizeSMTPHeloHostname('AFFiNE Server'), undefined);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { JobQueue } from '../../base';
|
|||||||
export class MockJobQueue {
|
export class MockJobQueue {
|
||||||
add = Sinon.createStubInstance(JobQueue).add.resolves();
|
add = Sinon.createStubInstance(JobQueue).add.resolves();
|
||||||
remove = Sinon.createStubInstance(JobQueue).remove.resolves();
|
remove = Sinon.createStubInstance(JobQueue).remove.resolves();
|
||||||
|
removeWhere = Sinon.createStubInstance(JobQueue).removeWhere.resolves([]);
|
||||||
|
|
||||||
last<Job extends JobName>(name: Job): { name: Job; payload: Jobs[Job] } {
|
last<Job extends JobName>(name: Job): { name: Job; payload: Jobs[Job] } {
|
||||||
const addJobName = this.add.lastCall?.args[0];
|
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 password = input?.password ?? faker.internet.password();
|
||||||
const user = await this.db.user.create({
|
const user = await this.db.user.create({
|
||||||
data: {
|
data: {
|
||||||
|
createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000),
|
||||||
email: faker.internet.email(),
|
email: faker.internet.email(),
|
||||||
name: faker.person.fullName(),
|
name: faker.person.fullName(),
|
||||||
password: password ? hashSync(password) : undefined,
|
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 => {
|
test('should get correct pro plan price for checking out', async t => {
|
||||||
const { app, service, u1, stripe, feature } = t.context;
|
const { app, service, u1, stripe, feature } = t.context;
|
||||||
// non-ea user
|
// non-ea user
|
||||||
|
|||||||
@@ -280,6 +280,7 @@ export class TestingApp extends ApplyType<INestApplication>() {
|
|||||||
password: '1',
|
password: '1',
|
||||||
name: email,
|
name: email,
|
||||||
emailVerifiedAt: new Date(),
|
emailVerifiedAt: new Date(),
|
||||||
|
createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000),
|
||||||
...override,
|
...override,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,39 @@ async function flushDB(client: PrismaClient) {
|
|||||||
FROM pg_catalog.pg_tables
|
FROM pg_catalog.pg_tables
|
||||||
WHERE schemaname != 'pg_catalog'
|
WHERE schemaname != 'pg_catalog'
|
||||||
AND schemaname != 'information_schema'`;
|
AND schemaname != 'information_schema'`;
|
||||||
|
const query = `TRUNCATE TABLE ${result
|
||||||
|
.map(({ tablename }) => tablename)
|
||||||
|
.filter(name => !name.includes('migrations'))
|
||||||
|
.join(', ')}`;
|
||||||
|
|
||||||
// remove all table data
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
await client.$executeRawUnsafe(
|
try {
|
||||||
`TRUNCATE TABLE ${result
|
// remove all table data
|
||||||
.map(({ tablename }) => tablename)
|
await client.$executeRawUnsafe(query);
|
||||||
.filter(name => !name.includes('migrations'))
|
return;
|
||||||
.join(', ')}`
|
} 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 ?? ''))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,24 @@ test('should remove job from queue', async t => {
|
|||||||
t.is(nullData, undefined);
|
t.is(nullData, undefined);
|
||||||
t.is(nullJob, 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
|
// #endregion
|
||||||
|
|
||||||
// #region executor
|
// #region executor
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ interface JobData<T extends JobName> {
|
|||||||
payload: Jobs[T];
|
payload: Jobs[T];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removableJobStates = [
|
||||||
|
'waiting',
|
||||||
|
'delayed',
|
||||||
|
'prioritized',
|
||||||
|
'paused',
|
||||||
|
'waiting-children',
|
||||||
|
] as const;
|
||||||
|
const removeWhereBatchSize = 100;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JobQueue {
|
export class JobQueue {
|
||||||
private readonly logger = new Logger(JobQueue.name);
|
private readonly logger = new Logger(JobQueue.name);
|
||||||
@@ -55,6 +64,59 @@ export class JobQueue {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async removeWhere<T extends JobName>(
|
||||||
|
jobName: T,
|
||||||
|
predicate: (payload: Jobs[T]) => boolean | Promise<boolean>
|
||||||
|
): Promise<Jobs[T][]> {
|
||||||
|
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<JobData<T>>[];
|
||||||
|
|
||||||
|
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<T extends JobName>(jobId: string, jobName: T) {
|
async get<T extends JobName>(jobId: string, jobName: T) {
|
||||||
const ns = namespace(jobName);
|
const ns = namespace(jobName);
|
||||||
const queue = this.getQueue(ns);
|
const queue = this.getQueue(ns);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
parseDocToMarkdownFromDocSnapshot,
|
parseDocToMarkdownFromDocSnapshot,
|
||||||
parsePageDoc,
|
parsePageDoc,
|
||||||
parseWorkspaceDoc,
|
parseWorkspaceDoc,
|
||||||
|
type WorkspaceDocContent,
|
||||||
} from '../utils/blocksuite';
|
} from '../utils/blocksuite';
|
||||||
import { PgWorkspaceDocStorageAdapter } from './adapters/workspace';
|
import { PgWorkspaceDocStorageAdapter } from './adapters/workspace';
|
||||||
import { type DocDiff, type DocRecord } from './storage';
|
import { type DocDiff, type DocRecord } from './storage';
|
||||||
@@ -242,10 +243,17 @@ export class DatabaseDocReader extends DocReader {
|
|||||||
if (!docRecord) {
|
if (!docRecord) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const content = this.parseWorkspaceContent(docRecord.bin);
|
let content: WorkspaceDocContent | null;
|
||||||
if (!content) {
|
try {
|
||||||
|
content = this.parseWorkspaceContent(docRecord.bin);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to parse workspace ${workspaceId} content`,
|
||||||
|
error as Error
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (!content) return null;
|
||||||
let avatarUrl: string | undefined;
|
let avatarUrl: string | undefined;
|
||||||
if (content.avatarKey) {
|
if (content.avatarKey) {
|
||||||
avatarUrl = this.blobStorage.getAvatarUrl(workspaceId, content.avatarKey);
|
avatarUrl = this.blobStorage.getAvatarUrl(workspaceId, content.avatarKey);
|
||||||
|
|||||||
243
packages/backend/server/src/core/mail/__tests__/job.spec.ts
Normal file
243
packages/backend/server/src/core/mail/__tests__/job.spec.ts
Normal file
@@ -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<ReturnType<typeof createTestingModule>>;
|
||||||
|
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 });
|
||||||
|
});
|
||||||
@@ -2,12 +2,13 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { getStreamAsBuffer } from 'get-stream';
|
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 { type MailName, MailProps, Renderers } from '../../mails';
|
||||||
import { UserProps, WorkspaceProps } from '../../mails/components';
|
import { UserProps, WorkspaceProps } from '../../mails/components';
|
||||||
import { Models } from '../../models';
|
import { Models } from '../../models';
|
||||||
import { DocReader } from '../doc/reader';
|
import { DocReader } from '../doc/reader';
|
||||||
import { WorkspaceBlobStorage } from '../storage';
|
import { WorkspaceBlobStorage } from '../storage';
|
||||||
|
import { containsUrlOrDomain } from '../workspaces/abuse';
|
||||||
import { MailSender, SendOptions } from './sender';
|
import { MailSender, SendOptions } from './sender';
|
||||||
|
|
||||||
type DynamicallyFetchedProps<Props> = {
|
type DynamicallyFetchedProps<Props> = {
|
||||||
@@ -35,7 +36,11 @@ type SendMailJob<Mail extends MailName = MailName, Props = MailProps<Mail>> = {
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Jobs {
|
interface Jobs {
|
||||||
'notification.sendMail': { startTime: number } & {
|
'notification.sendMail': {
|
||||||
|
startTime: number;
|
||||||
|
retryCount?: number;
|
||||||
|
expiresAt?: number;
|
||||||
|
} & {
|
||||||
[K in MailName]: SendMailJob<K>;
|
[K in MailName]: SendMailJob<K>;
|
||||||
}[MailName];
|
}[MailName];
|
||||||
}
|
}
|
||||||
@@ -47,6 +52,19 @@ const sendMailCacheKey = (name: string, to: string) =>
|
|||||||
`${sendMailKey}:${name}:${to}`;
|
`${sendMailKey}:${name}:${to}`;
|
||||||
const retryMaxPerTick = 20;
|
const retryMaxPerTick = 20;
|
||||||
const retryFirstTime = 3;
|
const retryFirstTime = 3;
|
||||||
|
const retryMaxAttempts = 12;
|
||||||
|
const retryMaxAge = 24 * 60 * 60 * 1000;
|
||||||
|
const magicLinkExpiresIn = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
const mailExpiresIn: Partial<Record<MailName, number>> = {
|
||||||
|
SignIn: magicLinkExpiresIn,
|
||||||
|
SignUp: magicLinkExpiresIn,
|
||||||
|
SetPassword: magicLinkExpiresIn,
|
||||||
|
ChangePassword: magicLinkExpiresIn,
|
||||||
|
VerifyEmail: magicLinkExpiresIn,
|
||||||
|
ChangeEmail: magicLinkExpiresIn,
|
||||||
|
VerifyChangeEmail: magicLinkExpiresIn,
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MailJob {
|
export class MailJob {
|
||||||
@@ -66,17 +84,65 @@ export class MailJob {
|
|||||||
return Math.min(30 * 1000, Math.round(elapsed / 2000) * 1000);
|
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({
|
private async sendMailInternal({
|
||||||
startTime,
|
startTime,
|
||||||
name,
|
name,
|
||||||
to,
|
to,
|
||||||
props,
|
props,
|
||||||
}: Jobs['notification.sendMail']) {
|
}: Jobs['notification.sendMail']) {
|
||||||
let options: Partial<SendOptions> = {};
|
if (await this.shouldSkipRecipient(to)) {
|
||||||
|
this.logger.debug(`Skip mail [${name}] to disabled user [${to}]`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const key in props) {
|
let options: Partial<SendOptions> = {};
|
||||||
|
const renderedProps = { ...props };
|
||||||
|
|
||||||
|
for (const key in renderedProps) {
|
||||||
// @ts-expect-error allow
|
// @ts-expect-error allow
|
||||||
const val = props[key];
|
const val = renderedProps[key];
|
||||||
if (val && typeof val === 'object') {
|
if (val && typeof val === 'object') {
|
||||||
if ('$$workspaceId' in val) {
|
if ('$$workspaceId' in val) {
|
||||||
const workspaceProps = await this.fetchWorkspaceProps(
|
const workspaceProps = await this.fetchWorkspaceProps(
|
||||||
@@ -87,6 +153,16 @@ export class MailJob {
|
|||||||
return;
|
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) {
|
if (workspaceProps.avatar) {
|
||||||
options.attachments = [
|
options.attachments = [
|
||||||
{
|
{
|
||||||
@@ -99,7 +175,7 @@ export class MailJob {
|
|||||||
workspaceProps.avatar = 'cid:workspaceAvatar';
|
workspaceProps.avatar = 'cid:workspaceAvatar';
|
||||||
}
|
}
|
||||||
// @ts-expect-error replacement
|
// @ts-expect-error replacement
|
||||||
props[key] = workspaceProps;
|
renderedProps[key] = workspaceProps;
|
||||||
} else if ('$$userId' in val) {
|
} else if ('$$userId' in val) {
|
||||||
const userProps = await this.fetchUserProps(val.$$userId);
|
const userProps = await this.fetchUserProps(val.$$userId);
|
||||||
|
|
||||||
@@ -108,17 +184,30 @@ export class MailJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @ts-expect-error replacement
|
// @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 {
|
try {
|
||||||
const result = await this.sender.send(name, {
|
const result = await this.sender.send(name, {
|
||||||
to,
|
to,
|
||||||
...(await Renderers[name](
|
...(await Renderers[name](
|
||||||
// @ts-expect-error the job trigger part has been typechecked
|
// @ts-expect-error the job trigger part has been typechecked
|
||||||
props
|
renderedProps
|
||||||
)),
|
)),
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
@@ -177,17 +266,41 @@ export class MailJob {
|
|||||||
@OnJob('notification.sendMail')
|
@OnJob('notification.sendMail')
|
||||||
async sendMail(job: Jobs['notification.sendMail']) {
|
async sendMail(job: Jobs['notification.sendMail']) {
|
||||||
const cacheKey = sendMailCacheKey(job.name, job.to);
|
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);
|
const retried = await this.cache.mapIncrease(sendMailKey, cacheKey, 1);
|
||||||
if (retried <= retryFirstTime) {
|
if (retried <= retryFirstTime) {
|
||||||
const ret = await this.sendMailInternal(job);
|
const ret = await this.sendMailInternal(job);
|
||||||
if (!ret) await this.cache.mapDelete(sendMailKey, cacheKey);
|
if (!ret) await this.cache.mapDelete(sendMailKey, cacheKey);
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
await this.cache.mapSet(retryMailKey, cacheKey, JSON.stringify(job));
|
await this.cache.mapSet(retryMailKey, cacheKey, job);
|
||||||
await this.cache.mapDelete(sendMailKey, cacheKey);
|
await this.cache.mapDelete(sendMailKey, cacheKey);
|
||||||
return undefined;
|
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)
|
@Cron(CronExpression.EVERY_MINUTE)
|
||||||
async sendRetryMails() {
|
async sendRetryMails() {
|
||||||
// pick random one from the retry map
|
// pick random one from the retry map
|
||||||
@@ -195,9 +308,14 @@ export class MailJob {
|
|||||||
let key = await this.cache.mapRandomKey(retryMailKey);
|
let key = await this.cache.mapRandomKey(retryMailKey);
|
||||||
while (key && processed < retryMaxPerTick) {
|
while (key && processed < retryMaxPerTick) {
|
||||||
try {
|
try {
|
||||||
const job = await this.cache.mapGet<string>(retryMailKey, key);
|
const job = await this.cache.mapGet<
|
||||||
|
Jobs['notification.sendMail'] | string
|
||||||
|
>(retryMailKey, key);
|
||||||
if (job) {
|
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);
|
await this.queue.add('notification.sendMail', jobData);
|
||||||
// wait for a while before retrying
|
// wait for a while before retrying
|
||||||
const retryDelay = this.calculateRetryDelay(jobData.startTime);
|
const retryDelay = this.calculateRetryDelay(jobData.startTime);
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ function isValidSMTPAddressLiteral(hostname: string) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeSMTPHeloHostname(hostname: string) {
|
export function normalizeSMTPHeloHostname(hostname?: string) {
|
||||||
|
if (!hostname) return undefined;
|
||||||
|
|
||||||
const normalized = hostname.trim().replace(/\.$/, '');
|
const normalized = hostname.trim().replace(/\.$/, '');
|
||||||
if (!normalized) return undefined;
|
if (!normalized) return undefined;
|
||||||
if (isValidSMTPAddressLiteral(normalized)) return normalized;
|
if (isValidSMTPAddressLiteral(normalized)) return normalized;
|
||||||
|
|||||||
@@ -87,6 +87,29 @@ test('should create invitation notification and email', async t => {
|
|||||||
t.is(invitationMail.payload.name, 'MemberInvitation');
|
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 => {
|
test('should not send invitation email if user setting is not to receive invitation email', async t => {
|
||||||
const inviteId = randomUUID();
|
const inviteId = randomUUID();
|
||||||
await module.create(Mockers.UserSettings, {
|
await module.create(Mockers.UserSettings, {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
generateWorkspaceSettingsPath,
|
generateWorkspaceSettingsPath,
|
||||||
WorkspaceSettingsTab,
|
WorkspaceSettingsTab,
|
||||||
} from '../utils/workspace';
|
} from '../utils/workspace';
|
||||||
|
import { containsUrlOrDomain } from '../workspaces/abuse';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationService {
|
export class NotificationService {
|
||||||
@@ -166,6 +167,16 @@ export class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async sendInvitationEmail(input: InvitationNotificationCreate) {
|
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}`);
|
const inviteUrl = this.url.link(`/invite/${input.body.inviteId}`);
|
||||||
if (env.dev) {
|
if (env.dev) {
|
||||||
// make it easier to test in dev mode
|
// make it easier to test in dev mode
|
||||||
|
|||||||
@@ -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),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
12
packages/backend/server/src/core/workspaces/abuse.ts
Normal file
12
packages/backend/server/src/core/workspaces/abuse.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export const SHARE_ACTION_ACCOUNT_AGE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const URL_OR_DOMAIN_PATTERN =
|
||||||
|
/(?:https?:\/\/|www\.|(?<![@\w-])(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}(?=$|[^\p{L}\p{N}._-]))/iu;
|
||||||
|
|
||||||
|
export function containsUrlOrDomain(value: string | null | undefined) {
|
||||||
|
return URL_OR_DOMAIN_PATTERN.test(value ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUserOldEnoughForShareActions(user: { createdAt: Date }) {
|
||||||
|
return Date.now() - user.createdAt.getTime() >= SHARE_ACTION_ACCOUNT_AGE_MS;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { Prisma, PrismaClient } from '@prisma/client';
|
|||||||
import { SafeIntResolver } from 'graphql-scalars';
|
import { SafeIntResolver } from 'graphql-scalars';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ActionForbidden,
|
||||||
Cache,
|
Cache,
|
||||||
DocActionDenied,
|
DocActionDenied,
|
||||||
DocDefaultRoleCanNotBeOwner,
|
DocDefaultRoleCanNotBeOwner,
|
||||||
@@ -44,6 +45,7 @@ import {
|
|||||||
PermissionService,
|
PermissionService,
|
||||||
} from '../../permission';
|
} from '../../permission';
|
||||||
import { PublicUserType, WorkspaceUserType } from '../../user';
|
import { PublicUserType, WorkspaceUserType } from '../../user';
|
||||||
|
import { isUserOldEnoughForShareActions } from '../abuse';
|
||||||
import { DocGrantsService } from '../doc-grants';
|
import { DocGrantsService } from '../doc-grants';
|
||||||
import { WorkspaceType } from '../types';
|
import { WorkspaceType } from '../types';
|
||||||
import { TimeBucket, TimeWindow } from './analytics-types';
|
import { TimeBucket, TimeWindow } from './analytics-types';
|
||||||
@@ -302,6 +304,15 @@ export class WorkspaceDocResolver {
|
|||||||
private readonly event: EventBus
|
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, {
|
@ResolveField(() => WorkspaceDocMeta, {
|
||||||
description: 'Cloud page metadata of workspace',
|
description: 'Cloud page metadata of workspace',
|
||||||
complexity: 2,
|
complexity: 2,
|
||||||
@@ -441,6 +452,7 @@ export class WorkspaceDocResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish');
|
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);
|
const doc = await this.models.doc.publish(workspaceId, docId, mode);
|
||||||
this.event.emit('doc.public_state.changed', { workspaceId, docId });
|
this.event.emit('doc.public_state.changed', { workspaceId, docId });
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ActionForbidden,
|
||||||
ActionForbiddenOnNonTeamWorkspace,
|
ActionForbiddenOnNonTeamWorkspace,
|
||||||
AlreadyInSpace,
|
AlreadyInSpace,
|
||||||
AuthenticationRequired,
|
AuthenticationRequired,
|
||||||
@@ -45,6 +46,7 @@ import {
|
|||||||
import { QuotaService } from '../../quota';
|
import { QuotaService } from '../../quota';
|
||||||
import { UserType } from '../../user';
|
import { UserType } from '../../user';
|
||||||
import { validators } from '../../utils/validators';
|
import { validators } from '../../utils/validators';
|
||||||
|
import { containsUrlOrDomain, isUserOldEnoughForShareActions } from '../abuse';
|
||||||
import { WorkspaceService } from '../service';
|
import { WorkspaceService } from '../service';
|
||||||
import {
|
import {
|
||||||
InvitationType,
|
InvitationType,
|
||||||
@@ -74,6 +76,24 @@ export class WorkspaceMemberResolver {
|
|||||||
private readonly quota: QuotaService
|
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, {
|
@ResolveField(() => UserType, {
|
||||||
description: 'Owner of workspace',
|
description: 'Owner of workspace',
|
||||||
complexity: 2,
|
complexity: 2,
|
||||||
@@ -149,6 +169,8 @@ export class WorkspaceMemberResolver {
|
|||||||
.user(me.id)
|
.user(me.id)
|
||||||
.workspace(workspaceId)
|
.workspace(workspaceId)
|
||||||
.assert('Workspace.Users.Manage');
|
.assert('Workspace.Users.Manage');
|
||||||
|
await this.assertCanInviteOrShare(me.id);
|
||||||
|
await this.assertWorkspaceNameCanInvite(workspaceId);
|
||||||
|
|
||||||
if (emails.length > 512) {
|
if (emails.length > 512) {
|
||||||
throw new TooManyRequest();
|
throw new TooManyRequest();
|
||||||
@@ -280,6 +302,8 @@ export class WorkspaceMemberResolver {
|
|||||||
.user(user.id)
|
.user(user.id)
|
||||||
.workspace(workspaceId)
|
.workspace(workspaceId)
|
||||||
.assert('Workspace.Users.Manage');
|
.assert('Workspace.Users.Manage');
|
||||||
|
await this.assertCanInviteOrShare(user.id);
|
||||||
|
await this.assertWorkspaceNameCanInvite(workspaceId);
|
||||||
|
|
||||||
const cacheWorkspaceId = `workspace:inviteLink:${workspaceId}`;
|
const cacheWorkspaceId = `workspace:inviteLink:${workspaceId}`;
|
||||||
const invite = await this.cache.get<{ inviteId: string }>(cacheWorkspaceId);
|
const invite = await this.cache.get<{ inviteId: string }>(cacheWorkspaceId);
|
||||||
|
|||||||
@@ -83,95 +83,70 @@ export const Renderers = {
|
|||||||
//#region Workspace
|
//#region Workspace
|
||||||
MemberInvitation: make(
|
MemberInvitation: make(
|
||||||
Invitation,
|
Invitation,
|
||||||
props => `${props.user.email} invited you to join ${props.workspace.name}`
|
'You were invited to join a workspace on AFFiNE'
|
||||||
),
|
),
|
||||||
MemberAccepted: make(
|
MemberAccepted: make(
|
||||||
InvitationAccepted,
|
InvitationAccepted,
|
||||||
props => `${props.user.email} accepted your invitation`
|
'Your workspace invitation was accepted'
|
||||||
),
|
|
||||||
MemberLeave: make(
|
|
||||||
MemberLeave,
|
|
||||||
props => `${props.user.email} left ${props.workspace.name}`
|
|
||||||
),
|
),
|
||||||
|
MemberLeave: make(MemberLeave, 'A workspace member left'),
|
||||||
LinkInvitationReviewRequest: make(
|
LinkInvitationReviewRequest: make(
|
||||||
LinkInvitationReviewRequest,
|
LinkInvitationReviewRequest,
|
||||||
props => `New request to join ${props.workspace.name}`
|
'New request to join a workspace'
|
||||||
),
|
),
|
||||||
LinkInvitationApprove: make(
|
LinkInvitationApprove: make(
|
||||||
LinkInvitationApproved,
|
LinkInvitationApproved,
|
||||||
props => `Your request to join ${props.workspace.name} has been approved`
|
'Your request to join a workspace has been approved'
|
||||||
),
|
),
|
||||||
LinkInvitationDecline: make(
|
LinkInvitationDecline: make(
|
||||||
LinkInvitationReviewDeclined,
|
LinkInvitationReviewDeclined,
|
||||||
props => `Your request to join ${props.workspace.name} was declined`
|
'Your request to join a workspace was declined'
|
||||||
),
|
|
||||||
MemberRemoved: make(
|
|
||||||
MemberRemoved,
|
|
||||||
props => `You have been removed from ${props.workspace.name}`
|
|
||||||
),
|
),
|
||||||
|
MemberRemoved: make(MemberRemoved, 'You have been removed from a workspace'),
|
||||||
OwnershipTransferred: make(
|
OwnershipTransferred: make(
|
||||||
OwnershipTransferred,
|
OwnershipTransferred,
|
||||||
props => `Your ownership of ${props.workspace.name} has been transferred`
|
'Your workspace ownership has been transferred'
|
||||||
),
|
),
|
||||||
OwnershipReceived: make(
|
OwnershipReceived: make(
|
||||||
OwnershipReceived,
|
OwnershipReceived,
|
||||||
props => `You are now the owner of ${props.workspace.name}`
|
'You are now the owner of a workspace'
|
||||||
),
|
),
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
//#region Doc
|
//#region Doc
|
||||||
Mention: make(
|
Mention: make(Mention, 'You were mentioned in AFFiNE'),
|
||||||
Mention,
|
Comment: make(Comment, 'New comment in AFFiNE'),
|
||||||
props => `${props.user.email} mentioned you in ${props.doc.title}`
|
CommentMention: make(CommentMention, 'You were mentioned in a comment'),
|
||||||
),
|
|
||||||
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}`
|
|
||||||
),
|
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
//#region Team
|
//#region Team
|
||||||
TeamWorkspaceUpgraded: make(TeamWorkspaceUpgraded, props =>
|
TeamWorkspaceUpgraded: make(TeamWorkspaceUpgraded, props =>
|
||||||
props.isOwner
|
props.isOwner
|
||||||
? 'Your workspace has been upgraded to team workspace! 🎉'
|
? 'Your workspace has been upgraded to team workspace! 🎉'
|
||||||
: `${props.workspace.name} has been upgraded to team workspace! 🎉`
|
: 'A workspace has been upgraded to team workspace! 🎉'
|
||||||
),
|
|
||||||
TeamBecomeAdmin: make(
|
|
||||||
TeamBecomeAdmin,
|
|
||||||
props => `You are now an admin of ${props.workspace.name}`
|
|
||||||
),
|
),
|
||||||
|
TeamBecomeAdmin: make(TeamBecomeAdmin, 'You are now a workspace admin'),
|
||||||
TeamBecomeCollaborator: make(
|
TeamBecomeCollaborator: make(
|
||||||
TeamBecomeCollaborator,
|
TeamBecomeCollaborator,
|
||||||
props => `Your role has been changed in ${props.workspace.name}`
|
'Your workspace role has been changed'
|
||||||
),
|
),
|
||||||
TeamDeleteIn24Hours: make(
|
TeamDeleteIn24Hours: make(
|
||||||
TeamDeleteIn24Hours,
|
TeamDeleteIn24Hours,
|
||||||
props =>
|
'[Action Required] Final warning: Your workspace will be deleted in 24 hours'
|
||||||
`[Action Required] Final warning: Your workspace ${props.workspace.name} will be deleted in 24 hours`
|
|
||||||
),
|
),
|
||||||
TeamDeleteInOneMonth: make(
|
TeamDeleteInOneMonth: make(
|
||||||
TeamDeleteInOneMonth,
|
TeamDeleteInOneMonth,
|
||||||
props =>
|
'[Action Required] Important: Your workspace will be deleted soon'
|
||||||
`[Action Required] Important: Your workspace ${props.workspace.name} will be deleted soon`
|
|
||||||
),
|
),
|
||||||
TeamWorkspaceDeleted: make(
|
TeamWorkspaceDeleted: make(
|
||||||
TeamWorkspaceDeleted,
|
TeamWorkspaceDeleted,
|
||||||
props => `Your workspace ${props.workspace.name} has been deleted`
|
'Your workspace has been deleted'
|
||||||
),
|
),
|
||||||
TeamWorkspaceExpireSoon: make(
|
TeamWorkspaceExpireSoon: make(
|
||||||
TeamExpireSoon,
|
TeamExpireSoon,
|
||||||
props =>
|
'[Action Required] Your team workspace will expire soon'
|
||||||
`[Action Required] Your ${props.workspace.name} team workspace will expire soon`
|
|
||||||
),
|
|
||||||
TeamWorkspaceExpired: make(
|
|
||||||
TeamExpired,
|
|
||||||
props => `Your ${props.workspace.name} team workspace has expired`
|
|
||||||
),
|
),
|
||||||
|
TeamWorkspaceExpired: make(TeamExpired, 'Your team workspace has expired'),
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
//#region License
|
//#region License
|
||||||
|
|||||||
@@ -121,22 +121,17 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
|||||||
throw new ManagedByAppStoreOrPlay();
|
throw new ManagedByAppStoreOrPlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscription = await this.getSubscription({
|
|
||||||
plan: lookupKey.plan,
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
subscription &&
|
active &&
|
||||||
// do not allow to re-subscribe unless
|
// do not allow to re-subscribe unless
|
||||||
!(
|
!(
|
||||||
/* current subscription is a onetime subscription and so as the one that's checking out */
|
/* 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) ||
|
lookupKey.variant === SubscriptionVariant.Onetime) ||
|
||||||
/* current subscription is normal subscription and is checking-out a lifetime subscription */
|
/* current subscription is normal subscription and is checking-out a lifetime subscription */
|
||||||
(subscription.recurring !== SubscriptionRecurring.Lifetime &&
|
(active.recurring !== SubscriptionRecurring.Lifetime &&
|
||||||
subscription.variant !== SubscriptionVariant.Onetime &&
|
active.variant !== SubscriptionVariant.Onetime &&
|
||||||
lookupKey.recurring === SubscriptionRecurring.Lifetime)
|
lookupKey.recurring === SubscriptionRecurring.Lifetime)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
mutation sendTestEmail(
|
mutation sendTestEmail(
|
||||||
|
$name: String!
|
||||||
$host: String!
|
$host: String!
|
||||||
$port: Int!
|
$port: Int!
|
||||||
$sender: String!
|
$sender: String!
|
||||||
@@ -8,6 +9,7 @@ mutation sendTestEmail(
|
|||||||
) {
|
) {
|
||||||
sendTestEmail(
|
sendTestEmail(
|
||||||
config: {
|
config: {
|
||||||
|
name: $name
|
||||||
host: $host
|
host: $host
|
||||||
port: $port
|
port: $port
|
||||||
sender: $sender
|
sender: $sender
|
||||||
|
|||||||
@@ -448,9 +448,9 @@ export const listUsersQuery = {
|
|||||||
export const sendTestEmailMutation = {
|
export const sendTestEmailMutation = {
|
||||||
id: 'sendTestEmailMutation' as const,
|
id: 'sendTestEmailMutation' as const,
|
||||||
op: 'sendTestEmail',
|
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(
|
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}
|
||||||
)
|
)
|
||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4175,6 +4175,7 @@ export type ListUsersQuery = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type SendTestEmailMutationVariables = Exact<{
|
export type SendTestEmailMutationVariables = Exact<{
|
||||||
|
name: Scalars['String']['input'];
|
||||||
host: Scalars['String']['input'];
|
host: Scalars['String']['input'];
|
||||||
port: Scalars['Int']['input'];
|
port: Scalars['Int']['input'];
|
||||||
sender: Scalars['String']['input'];
|
sender: Scalars['String']['input'];
|
||||||
|
|||||||
@@ -328,25 +328,9 @@ test('also move children when dedent collapsed heading', async ({ page }) => {
|
|||||||
|
|
||||||
const paragraph = page.locator('affine-note affine-paragraph');
|
const paragraph = page.locator('affine-note affine-paragraph');
|
||||||
const subParagraph = paragraph.nth(0).locator('affine-paragraph');
|
const subParagraph = paragraph.nth(0).locator('affine-paragraph');
|
||||||
expect(await subParagraph.count()).toBe(2);
|
await expect.poll(() => subParagraph.count()).toBe(2);
|
||||||
expect(
|
await expectParagraphState(subParagraph, 0, 'h1', 'bbb');
|
||||||
await subParagraph
|
await expectParagraphState(subParagraph, 1, 'text', 'ccc');
|
||||||
.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();
|
|
||||||
|
|
||||||
expect(await subParagraph.nth(1).isVisible()).toBeTruthy();
|
expect(await subParagraph.nth(1).isVisible()).toBeTruthy();
|
||||||
await subParagraph
|
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 type(page, '# bbb\nddd');
|
||||||
await page.keyboard.press('ArrowUp');
|
await page.keyboard.press('ArrowUp');
|
||||||
await pressTab(page);
|
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 pressEnter(page);
|
||||||
await type(page, 'ccc');
|
await type(page, 'ccc');
|
||||||
|
|
||||||
@@ -453,7 +442,6 @@ test('unfold collapsed heading when its other blocks indented to be its sibling'
|
|||||||
* ddd
|
* ddd
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const paragraph = page.locator('affine-note affine-paragraph');
|
|
||||||
await expectParagraphVisibility(paragraph, 2, true);
|
await expectParagraphVisibility(paragraph, 2, true);
|
||||||
await expectParagraphState(paragraph, 2, 'text', 'ccc');
|
await expectParagraphState(paragraph, 2, 'text', 'ccc');
|
||||||
await paragraph.locator('blocksuite-toggle-button .toggle-icon').click();
|
await paragraph.locator('blocksuite-toggle-button .toggle-icon').click();
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export async function createRandomUser(): Promise<{
|
|||||||
data: {
|
data: {
|
||||||
...user,
|
...user,
|
||||||
emailVerifiedAt: new Date(),
|
emailVerifiedAt: new Date(),
|
||||||
|
createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000),
|
||||||
password: await hash(user.password),
|
password: await hash(user.password),
|
||||||
features: {
|
features: {
|
||||||
create: {
|
create: {
|
||||||
|
|||||||
Reference in New Issue
Block a user