From 21c4a29f55103807c371223cbf4d7668af351512 Mon Sep 17 00:00:00 2001 From: forehalo Date: Wed, 19 Mar 2025 17:00:19 +0000 Subject: [PATCH] refactor(server): mail service (#10934) --- .github/actions/setup-node/action.yml | 1 - packages/backend/server/package.json | 1 - .../__snapshots__/mailer.spec.ts.snap | Bin 4251 -> 0 bytes .../{mailer.spec.ts.md => mails.spec.ts.md} | 2470 ++++++++--------- .../__snapshots__/mails.spec.ts.snap | Bin 0 -> 4173 bytes .../server/src/__tests__/auth/auth.e2e.ts | 238 +- .../src/__tests__/auth/controller.spec.ts | 61 +- .../server/src/__tests__/e2e/create-app.ts | 6 + .../server/src/__tests__/mailer.spec.ts | 61 - .../server/src/__tests__/mails.spec.ts | 11 + .../server/src/__tests__/mocks/index.ts | 3 + .../server/src/__tests__/mocks/mailer.mock.ts | 29 + .../backend/server/src/__tests__/team.e2e.ts | 8 +- .../server/src/__tests__/utils/testing-app.ts | 5 + .../src/__tests__/utils/testing-module.ts | 7 + .../src/__tests__/workspace-invite.e2e.ts | 80 +- packages/backend/server/src/app.module.ts | 11 +- packages/backend/server/src/base/index.ts | 1 - packages/backend/server/src/base/job/index.ts | 2 +- .../base/job/queue/__tests__/queue.spec.ts | 4 - .../backend/server/src/base/job/queue/def.ts | 4 + .../server/src/base/job/queue/executor.ts | 10 +- .../server/src/base/job/queue/index.ts | 2 +- .../server/src/base/job/queue/scanner.ts | 4 +- .../backend/server/src/base/mailer/index.ts | 24 - .../server/src/base/mailer/mail.service.ts | 193 -- .../backend/server/src/base/mailer/mailer.ts | 33 - .../server/src/core/auth/controller.ts | 12 +- .../backend/server/src/core/auth/index.ts | 3 +- .../backend/server/src/core/auth/resolver.ts | 14 +- .../backend/server/src/core/auth/service.ts | 62 +- .../src/{base/mailer => core/mail}/config.ts | 4 +- .../backend/server/src/core/mail/index.ts | 17 + packages/backend/server/src/core/mail/job.ts | 142 + .../backend/server/src/core/mail/mailer.ts | 17 + .../backend/server/src/core/mail/sender.ts | 89 + .../server/src/core/workspaces/event.ts | 5 +- .../server/src/core/workspaces/index.ts | 4 + .../src/core/workspaces/resolvers/service.ts | 319 ++- .../src/core/workspaces/resolvers/team.ts | 7 +- .../core/workspaces/resolvers/workspace.ts | 17 +- packages/backend/server/src/mails/index.tsx | 206 +- .../server/src/plugins/payment/index.ts | 2 + .../src/plugins/payment/manager/selfhost.ts | 15 +- packages/backend/server/tsconfig.json | 1 - tools/utils/src/workspace.gen.ts | 1 - yarn.lock | 1 - 47 files changed, 2076 insertions(+), 2131 deletions(-) delete mode 100644 packages/backend/server/src/__tests__/__snapshots__/mailer.spec.ts.snap rename packages/backend/server/src/__tests__/__snapshots__/{mailer.spec.ts.md => mails.spec.ts.md} (99%) create mode 100644 packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.snap delete mode 100644 packages/backend/server/src/__tests__/mailer.spec.ts create mode 100644 packages/backend/server/src/__tests__/mails.spec.ts create mode 100644 packages/backend/server/src/__tests__/mocks/mailer.mock.ts delete mode 100644 packages/backend/server/src/base/mailer/index.ts delete mode 100644 packages/backend/server/src/base/mailer/mail.service.ts delete mode 100644 packages/backend/server/src/base/mailer/mailer.ts rename packages/backend/server/src/{base/mailer => core/mail}/config.ts (74%) create mode 100644 packages/backend/server/src/core/mail/index.ts create mode 100644 packages/backend/server/src/core/mail/job.ts create mode 100644 packages/backend/server/src/core/mail/mailer.ts create mode 100644 packages/backend/server/src/core/mail/sender.ts diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml index 1a1d422f2c..8918c52308 100644 --- a/.github/actions/setup-node/action.yml +++ b/.github/actions/setup-node/action.yml @@ -127,7 +127,6 @@ runs: - name: yarn install if: ${{ inputs.package-install == 'true' }} - continue-on-error: true shell: bash working-directory: ${{ steps.workspace-path.outputs.workspace_path }} run: yarn ${{ inputs.extra-flags }} diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index 1a69deb9cc..aa1ad3e183 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -112,7 +112,6 @@ "zod": "^3.24.1" }, "devDependencies": { - "@affine-test/kit": "workspace:*", "@affine-tools/cli": "workspace:*", "@affine-tools/utils": "workspace:*", "@affine/server-native": "workspace:*", diff --git a/packages/backend/server/src/__tests__/__snapshots__/mailer.spec.ts.snap b/packages/backend/server/src/__tests__/__snapshots__/mailer.spec.ts.snap deleted file mode 100644 index d6bbe8967f151fc211708a634729cbf55f43c91e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4251 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 Change your email address - - `␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Verify your current email for AFFiNE␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - You recently requested to change the email address associated␊ - with your AFFiNE account.
To complete this process, please␊ - click on the verification link below.␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - This magic link will expire in␊ - 30 minutes.␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Verify and set up a new email address␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ` - -> Account email address changed - - `␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Verify your current email for AFFiNE␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - As per your request, we have changed your email. Please make sure␊ - you're using␊ - test@affine.pro to log in the␊ - next time.␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ` - -> Modify your AFFiNE password - - `␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Modify your AFFiNE password␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Click the button below to reset your password. The magic link␊ - will expire in 30 minutes.␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Set new password␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ` - -> Your request to join Test Workspace has been approved - - `␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Welcome to the workspace!␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Your request to join␊ - Test Workspace␊ - has been accepted. You can now access the team workspace and␊ - collaborate with other members.␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ` - -> Your request to join Test Workspace was declined - - `␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Request declined␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Your request to join␊ - Test Workspace␊ - has been declined by the workspace admin.␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ` - -> New request to join Test Workspace - - `␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Request to join␊ - Test Workspace␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - test@test.com has requested␊ - to join␊ - Test Workspace.
As a workspace owner/admin, you can approve or decline␊ - this request.␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Review request␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ` - -> test@test.com accepted your invitation - - `␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - test@test.com␊ - accepted your invitation␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - test@test.com has joined␊ - Test Workspace␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ` - -> test@test.com invited you to join Test Workspace - - `␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - You are invited!␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - test@test.com invited you␊ - to join␊ - Test Workspace␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Click button to join this workspace␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Accept & Join␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ` - -> test@test.com left Test Workspace - - `␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Member left␊ - Test Workspace␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - test@test.com has left workspace␊ - Test Workspace␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ` - -> You have been removed from Test Workspace - - `␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Workspace access removed␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - You have been removed from␊ - Test Workspace. You no longer have access to this workspace.␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ` - -> You are now the owner of Test Workspace - - `␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Welcome, new workspace owner!␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - You have been assigned as the owner ofTest Workspace. As a workspace owner, you have full control over this workspace.␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ` - -> Your ownership of Test Workspace has been transferred - - `␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Ownership transferred␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - You have transferred ownership of␊ - Test Workspace. You are now a collaborator in this workspace.␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ` - -> Set your AFFiNE password - - `␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Set your AFFiNE password␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Click the button below to set your password. The magic link will␊ - expire in 30 minutes.␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Sign in to AFFiNE␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ` - > Sign in to AFFiNE `␊ @@ -1164,6 +270,1178 @@ Generated by [AVA](https://avajs.dev). ␊ ` +> Set your AFFiNE password + + `␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Set your AFFiNE password␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Click the button below to set your password. The magic link will␊ + expire in 30 minutes.␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Sign in to AFFiNE␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + +> Modify your AFFiNE password + + `␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Modify your AFFiNE password␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Click the button below to reset your password. The magic link␊ + will expire in 30 minutes.␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Set new password␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + +> Verify your email address + + `␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Verify your email address␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + You recently requested to verify the email address associated␊ + with your AFFiNE account.
To complete this process, please␊ + click on the verification link below.␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + This magic link will expire in␊ + 30 minutes.␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Verify your email address␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + +> Change your email address + + `␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Verify your current email for AFFiNE␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + You recently requested to change the email address associated␊ + with your AFFiNE account.
To complete this process, please␊ + click on the verification link below.␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + This magic link will expire in␊ + 30 minutes.␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Verify and set up a new email address␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + +> Verify your new email address + + `␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Verify your new email address␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + You recently requested to change the email address associated␊ + with your AFFiNE account. To complete this process, please click␊ + on the verification link below. This magic link will expire in␊ + 30 minutes.␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Verify your new email address␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + +> Account email address changed + + `␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Verify your current email for AFFiNE␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + As per your request, we have changed your email. Please make sure␊ + you're using␊ + test@affine.pro to log in the␊ + next time.␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + +> test@test.com invited you to join Test Workspace + + `␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + You are invited!␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + test@test.com invited you␊ + to join␊ + Test Workspace␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Click button to join this workspace␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Accept & Join␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + +> test@test.com accepted your invitation + + `␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + test@test.com␊ + accepted your invitation␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + test@test.com has joined␊ + Test Workspace␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + +> test@test.com left Test Workspace + + `␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Member left␊ + Test Workspace␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + test@test.com has left workspace␊ + Test Workspace␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + +> New request to join Test Workspace + + `␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Request to join␊ + Test Workspace␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + test@test.com has requested␊ + to join␊ + Test Workspace.
As a workspace owner/admin, you can approve or decline␊ + this request.␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Review request␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + +> Your request to join Test Workspace has been approved + + `␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Welcome to the workspace!␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Your request to join␊ + Test Workspace␊ + has been accepted. You can now access the team workspace and␊ + collaborate with other members.␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + +> Your request to join Test Workspace was declined + + `␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Request declined␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Your request to join␊ + Test Workspace␊ + has been declined by the workspace admin.␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + +> You have been removed from Test Workspace + + `␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Workspace access removed␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + You have been removed from␊ + Test Workspace. You no longer have access to this workspace.␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + +> Your ownership of Test Workspace has been transferred + + `␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Ownership transferred␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + You have transferred ownership of␊ + Test Workspace. You are now a collaborator in this workspace.␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + +> You are now the owner of Test Workspace + + `␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Welcome, new workspace owner!␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + You have been assigned as the owner ofTest Workspace. As a workspace owner, you have full control over this workspace.␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + +> Your workspace has been upgraded to team workspace! 🎉 + + `␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Welcome to the team workspace!␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Test Workspace␊ + has been upgraded to team workspace with the following␊ + benefits:
␊ + ✓ 100 GB initial storage + 20 GB per seat
␊ + ✓ 500 MB of maximum file size
␊ + ✓ Unlimited team members (10+ seats)
␊ + ✓ Multiple admin roles
␊ + ✓ Priority customer support␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Open Workspace␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + > You are now an admin of Test Workspace `␊ @@ -1348,114 +1626,6 @@ Generated by [AVA](https://avajs.dev). ␊ ` -> [Action Required] Important: Your workspace Test Workspace will be deleted soon - - `␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Take action to prevent data loss␊ -

␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Your␊ - Test Workspace␊ - team workspace expired on␊ - 2025-01-01. All workspace␊ - data will be permanently deleted on␊ - 2025-01-31 (180 days after␊ - expiration). To prevent data loss, please either:␊ -
  • ␊ - Renew your subscription to restore team features␊ -
  • ␊ -
  • ␊ - Export your workspace data from Workspace Settings >␊ - Export Workspace␊ -
  • ␊ -

    ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Go to Billing␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ` - > [Action Required] Final warning: Your workspace Test Workspace will be deleted in 24 hours `␊ @@ -1562,10 +1732,13 @@ Generated by [AVA](https://avajs.dev). ␊ ` -> Your AFFiNE Self-Hosted Team Workspace license is ready +> [Action Required] Important: Your workspace Test Workspace will be deleted soon `␊ - ␊ + ␊ - Here is your license key.␊ + Take action to prevent data loss␊

    ␊ ␊ ␊ @@ -1603,10 +1776,35 @@ Generated by [AVA](https://avajs.dev). role="presentation">␊ ␊ ␊ - ␊ - xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx␊ + ␊ + Your␊ + Test Workspace␊ + team workspace expired on␊ + 2025-01-01. All workspace␊ + data will be permanently deleted on␊ + 2025-01-31 (180 days after␊ + expiration). To prevent data loss, please either:␊ +
  • ␊ + Renew your subscription to restore team features␊ +
  • ␊ +
  • ␊ + Export your workspace data from Workspace Settings >␊ + Export Workspace␊ +
  • ␊ +

    ␊ ␊ ␊ ␊ @@ -1619,13 +1817,19 @@ Generated by [AVA](https://avajs.dev). role="presentation">␊ ␊ ␊ - ␊ - You can use this key to upgrade your selfhost workspace in␊ - Settings > Workspace > License.␊ -

    ␊ + Go to Billing␊ ␊ ␊ ␊ @@ -1909,103 +2113,7 @@ Generated by [AVA](https://avajs.dev). ␊ ` -> Your workspace has been upgraded to team workspace! 🎉 - - `␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Welcome to the team workspace!␊ -

    ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Test Workspace␊ - has been upgraded to team workspace with the following␊ - benefits:
    ␊ - ✓ 100 GB initial storage + 20 GB per seat
    ␊ - ✓ 500 MB of maximum file size
    ␊ - ✓ Unlimited team members (10+ seats)
    ␊ - ✓ Multiple admin roles
    ␊ - ✓ Priority customer support␊ -

    ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Open Workspace␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ` - -> Verify your new email address +> Your AFFiNE Self-Hosted Team Workspace license is ready `␊ ␊ @@ -2021,7 +2129,7 @@ Generated by [AVA](https://avajs.dev). ␊ ␊ - Verify your new email address␊ + Here is your license key.␊

    ␊ ␊ ␊ @@ -2046,13 +2154,10 @@ Generated by [AVA](https://avajs.dev). role="presentation">␊ ␊ ␊ - ␊ - You recently requested to change the email address associated␊ - with your AFFiNE account. To complete this process, please click␊ - on the verification link below. This magic link will expire in␊ - 30 minutes.␊ -

    ␊ + ␊ + xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx␊ ␊ ␊ ␊ @@ -2065,118 +2170,13 @@ Generated by [AVA](https://avajs.dev). role="presentation">␊ ␊ ␊ - Verify your new email address␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ` - -> Verify your email address - - `␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Verify your email address␊ -

    ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - You recently requested to verify the email address associated␊ - with your AFFiNE account.
    To complete this process, please␊ - click on the verification link below.␊ -

    ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - This magic link will expire in␊ - 30 minutes.␊ -

    ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Verify your email address␊ + ␊ + You can use this key to upgrade your selfhost workspace in␊ + Settings > Workspace > License.␊ +

    ␊ ␊ ␊ ␊ diff --git a/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.snap b/packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..aab688d27e930aefc5a7f6130e0a3eedd2454200 GIT binary patch literal 4173 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 XK2bWqFnbC+2`B##fURou=@S6}eEc#} literal 0 HcmV?d00001 diff --git a/packages/backend/server/src/__tests__/auth/auth.e2e.ts b/packages/backend/server/src/__tests__/auth/auth.e2e.ts index f1aad7a23e..a294c2c810 100644 --- a/packages/backend/server/src/__tests__/auth/auth.e2e.ts +++ b/packages/backend/server/src/__tests__/auth/auth.e2e.ts @@ -1,13 +1,8 @@ import { randomBytes } from 'node:crypto'; -import { - getCurrentMailMessageCount, - getTokenFromLatestMailMessage, -} from '@affine-test/kit/utils/cloud'; import type { TestFn } from 'ava'; import ava from 'ava'; -import { MailService } from '../../base/mailer'; import { changeEmail, changePassword, @@ -21,14 +16,11 @@ import { const test = ava as TestFn<{ app: TestingApp; - mail: MailService; }>; test.beforeEach(async t => { const app = await createTestingApp(); - const mail = app.get(MailService); t.context.app = app; - t.context.mail = mail; }); test.afterEach.always(async t => { @@ -36,182 +28,106 @@ test.afterEach.always(async t => { }); test('change email', async t => { - const { mail, app } = t.context; - if (mail.hasConfigured()) { - const u1Email = 'u1@affine.pro'; - const u2Email = 'u2@affine.pro'; + const { app } = t.context; + const u1Email = 'u1@affine.pro'; + const u2Email = 'u2@affine.pro'; - await app.signupV1(u1Email); - const primitiveMailCount = await getCurrentMailMessageCount(); - await sendChangeEmail(app, u1Email, 'affine.pro'); + const user = await app.signupV1(u1Email); + await sendChangeEmail(app, u1Email, 'affine.pro'); - const afterSendChangeMailCount = await getCurrentMailMessageCount(); - t.is( - primitiveMailCount + 1, - afterSendChangeMailCount, - 'failed to send change email' - ); + const changeMail = app.mails.last('ChangeEmail'); - const changeEmailToken = await getTokenFromLatestMailMessage(); + t.is(changeMail.to, u1Email); - t.not( - changeEmailToken, - null, - 'fail to get change email token from email content' - ); + let link = new URL(changeMail.props.url); - await sendVerifyChangeEmail( - app, - changeEmailToken as string, - u2Email, - 'affine.pro' - ); + const changeEmailToken = link.searchParams.get('token'); - const afterSendVerifyMailCount = await getCurrentMailMessageCount(); + t.not( + changeEmailToken, + null, + 'fail to get change email token from email content' + ); - t.is( - afterSendChangeMailCount + 1, - afterSendVerifyMailCount, - 'failed to send verify email' - ); + await sendVerifyChangeEmail( + app, + changeEmailToken as string, + u2Email, + 'affine.pro' + ); - const verifyEmailToken = await getTokenFromLatestMailMessage(); + const verifyMail = app.mails.last('VerifyChangeEmail'); - t.not( - verifyEmailToken, - null, - 'fail to get verify change email token from email content' - ); + t.is(verifyMail.to, u2Email); - await changeEmail(app, verifyEmailToken as string, u2Email); + link = new URL(verifyMail.props.url); - const afterNotificationMailCount = await getCurrentMailMessageCount(); + const verifyEmailToken = link.searchParams.get('token'); - t.is( - afterSendVerifyMailCount + 1, - afterNotificationMailCount, - 'failed to send notification email' - ); - } - t.pass(); + t.not( + verifyEmailToken, + null, + 'fail to get verify change email token from email content' + ); + + await changeEmail(app, verifyEmailToken as string, u2Email); + + const changedMail = app.mails.last('EmailChanged'); + + t.is(changedMail.to, u2Email); + t.is(changedMail.props.to, u2Email); + + await app.logout(); + await app.login({ + ...user, + email: u2Email, + }); + + const me = await currentUser(app); + + t.not(me, null, 'failed to get current user'); + t.is(me?.email, u2Email, 'failed to get current user'); }); test('set and change password', async t => { - const { mail, app } = t.context; - if (mail.hasConfigured()) { - const u1Email = 'u1@affine.pro'; + const { app } = t.context; + const u1Email = 'u1@affine.pro'; - const u1 = await app.signupV1(u1Email); + const u1 = await app.signupV1(u1Email); + await sendSetPasswordEmail(app, u1Email, 'affine.pro'); - const primitiveMailCount = await getCurrentMailMessageCount(); + const setPasswordMail = app.mails.last('ChangePassword'); + const link = new URL(setPasswordMail.props.url); + const setPasswordToken = link.searchParams.get('token'); - await sendSetPasswordEmail(app, u1Email, 'affine.pro'); + t.is(setPasswordMail.to, u1Email); + t.not( + setPasswordToken, + null, + 'fail to get set password token from email content' + ); - const afterSendSetMailCount = await getCurrentMailMessageCount(); + const newPassword = randomBytes(16).toString('hex'); + const success = await changePassword( + app, + u1.id, + setPasswordToken as string, + newPassword + ); - t.is( - primitiveMailCount + 1, - afterSendSetMailCount, - 'failed to send set email' - ); + t.true(success, 'failed to change password'); - const setPasswordToken = await getTokenFromLatestMailMessage(); + let user = await currentUser(app); - t.not( - setPasswordToken, - null, - 'fail to get set password token from email content' - ); + t.is(user, null); - const newPassword = randomBytes(16).toString('hex'); - const success = await changePassword( - app, - u1.id, - setPasswordToken as string, - newPassword - ); + await app.login({ + ...u1, + password: newPassword, + }); - t.true(success, 'failed to change password'); + user = await currentUser(app); - await app.login({ - ...u1, - password: newPassword, - }); - - const user = await currentUser(app); - - t.not(user, null, 'failed to get current user'); - t.is(user?.email, u1Email, 'failed to get current user'); - } - t.pass(); -}); -test('should revoke token after change user identify', async t => { - const { mail, app } = t.context; - if (mail.hasConfigured()) { - // change email - { - const u1Email = 'u1@affine.pro'; - const u2Email = 'u2@affine.pro'; - - const u1 = await app.signupV1(u1Email); - - { - const user = await currentUser(app); - t.is(user?.email, u1Email, 'failed to get current user'); - } - - await sendChangeEmail(app, u1Email, 'affine.pro'); - - const changeEmailToken = await getTokenFromLatestMailMessage(); - await sendVerifyChangeEmail( - app, - changeEmailToken as string, - u2Email, - 'affine.pro' - ); - - const verifyEmailToken = await getTokenFromLatestMailMessage(); - await changeEmail(app, verifyEmailToken as string, u2Email); - - let user = await currentUser(app); - t.is(user, null, 'token should be revoked'); - - await app.login({ - ...u1, - email: u2Email, - }); - - user = await currentUser(app); - t.is(user?.email, u2Email, 'failed to sign in with new email'); - } - - // change password - { - const u3Email = 'u3333@affine.pro'; - - await app.logout(); - const u3 = await app.signupV1(u3Email); - - { - const user = await currentUser(app); - t.is(user?.email, u3Email, 'failed to get current user'); - } - - await sendSetPasswordEmail(app, u3Email, 'affine.pro'); - const token = await getTokenFromLatestMailMessage(); - const newPassword = randomBytes(16).toString('hex'); - await changePassword(app, u3.id, token as string, newPassword); - - let user = await currentUser(app); - t.is(user, null, 'token should be revoked'); - - await app.login({ - ...u3, - password: newPassword, - }); - user = await currentUser(app); - t.is(user?.email, u3Email, 'failed to sign in with new password'); - } - } - t.pass(); + t.not(user, null, 'failed to get current user'); + t.is(user?.email, u1Email, 'failed to get current user'); }); diff --git a/packages/backend/server/src/__tests__/auth/controller.spec.ts b/packages/backend/server/src/__tests__/auth/controller.spec.ts index f61f53cf36..82aba5a860 100644 --- a/packages/backend/server/src/__tests__/auth/controller.spec.ts +++ b/packages/backend/server/src/__tests__/auth/controller.spec.ts @@ -5,7 +5,6 @@ import { PrismaClient } from '@prisma/client'; import ava, { TestFn } from 'ava'; import Sinon from 'sinon'; -import { MailService } from '../../base'; import { AuthModule } from '../../core/auth'; import { AuthService } from '../../core/auth/service'; import { FeatureModule } from '../../core/features'; @@ -20,26 +19,16 @@ import { const test = ava as TestFn<{ auth: AuthService; db: PrismaClient; - mailer: Sinon.SinonStubbedInstance; app: TestingApp; }>; test.before(async t => { const app = await createTestingApp({ imports: [FeatureModule, UserModule, AuthModule], - tapModule: m => { - m.overrideProvider(MailService).useValue( - Sinon.stub( - // @ts-expect-error safe - new MailService() - ) - ); - }, }); t.context.auth = app.get(AuthService); t.context.db = app.get(PrismaClient); - t.context.mailer = app.get(MailService); t.context.app = app; }); @@ -67,11 +56,9 @@ test('should be able to sign in with credential', async t => { }); test('should be able to sign in with email', async t => { - const { app, mailer } = t.context; + const { app } = t.context; const u1 = await app.createUser('u1@affine.pro'); - // @ts-expect-error mock - mailer.sendSignInMail.resolves({ rejected: [] }); const res = await app .POST('/api/auth/sign-in') @@ -79,10 +66,11 @@ test('should be able to sign in with email', async t => { .expect(200); t.is(res.body.email, u1.email); - t.true(mailer.sendSignInMail.calledOnce); + const signInMail = app.mails.last('SignIn'); - const [, { url: signInLink }] = mailer.sendSignInMail.firstCall.args; - const url = new URL(signInLink); + t.is(signInMail.to, u1.email); + + const url = new URL(signInMail.props.url); const email = url.searchParams.get('email'); const token = url.searchParams.get('token'); @@ -93,10 +81,7 @@ test('should be able to sign in with email', async t => { }); test('should be able to sign up with email', async t => { - const { app, mailer } = t.context; - - // @ts-expect-error mock - mailer.sendSignUpMail.resolves({ rejected: [] }); + const { app } = t.context; const res = await app .POST('/api/auth/sign-in') @@ -104,10 +89,11 @@ test('should be able to sign up with email', async t => { .expect(200); t.is(res.body.email, 'u2@affine.pro'); - t.true(mailer.sendSignUpMail.calledOnce); + const signUpMail = app.mails.last('SignUp'); - const [, { url: signUpLink }] = mailer.sendSignUpMail.firstCall.args; - const url = new URL(signUpLink); + t.is(signUpMail.to, 'u2@affine.pro'); + + const url = new URL(signUpMail.props.url); const email = url.searchParams.get('email'); const token = url.searchParams.get('token'); @@ -129,7 +115,7 @@ test('should not be able to sign in if email is invalid', async t => { }); test('should not be able to sign in if forbidden', async t => { - const { app, auth, mailer } = t.context; + const { app, auth } = t.context; const u1 = await app.createUser('u1@affine.pro'); const canSignInStub = Sinon.stub(auth, 'canSignIn').resolves(false); @@ -139,9 +125,8 @@ test('should not be able to sign in if forbidden', async t => { .send({ email: u1.email }) .expect(HttpStatus.FORBIDDEN); - t.true(mailer.sendSignInMail.notCalled); - canSignInStub.restore(); + t.pass(); }); test('should be able to sign out', async t => { @@ -253,12 +238,10 @@ test('should be able to sign out multiple accounts in one session', async t => { }); test('should be able to sign in with email and client nonce', async t => { - const { app, mailer } = t.context; + const { app } = t.context; const clientNonce = randomUUID(); const u1 = await app.createUser(); - // @ts-expect-error mock - mailer.sendSignInMail.resolves({ rejected: [] }); const res = await app .POST('/api/auth/sign-in') @@ -266,10 +249,11 @@ test('should be able to sign in with email and client nonce', async t => { .expect(200); t.is(res.body.email, u1.email); - t.true(mailer.sendSignInMail.calledOnce); + const signInMail = app.mails.last('SignIn'); - const [, { url: signInLink }] = mailer.sendSignInMail.firstCall.args; - const url = new URL(signInLink); + t.is(signInMail.to, u1.email); + + const url = new URL(signInMail.props.url); const email = url.searchParams.get('email'); const token = url.searchParams.get('token'); @@ -283,12 +267,10 @@ test('should be able to sign in with email and client nonce', async t => { }); test('should not be able to sign in with email and client nonce if invalid', async t => { - const { app, mailer } = t.context; + const { app } = t.context; const clientNonce = randomUUID(); const u1 = await app.createUser(); - // @ts-expect-error mock - mailer.sendSignInMail.resolves({ rejected: [] }); const res = await app .POST('/api/auth/sign-in') @@ -296,10 +278,11 @@ test('should not be able to sign in with email and client nonce if invalid', asy .expect(200); t.is(res.body.email, u1.email); - t.true(mailer.sendSignInMail.calledOnce); + const signInMail = app.mails.last('SignIn'); - const [, { url: signInLink }] = mailer.sendSignInMail.firstCall.args; - const url = new URL(signInLink); + t.is(signInMail.to, u1.email); + + const url = new URL(signInMail.props.url); const email = url.searchParams.get('email'); const token = url.searchParams.get('token'); diff --git a/packages/backend/server/src/__tests__/e2e/create-app.ts b/packages/backend/server/src/__tests__/e2e/create-app.ts index 714d4a9216..b92ed3f43c 100644 --- a/packages/backend/server/src/__tests__/e2e/create-app.ts +++ b/packages/backend/server/src/__tests__/e2e/create-app.ts @@ -13,6 +13,8 @@ import { } from '../../base'; import { SocketIoAdapter } from '../../base/websocket'; import { AuthGuard } from '../../core/auth'; +import { Mailer } from '../../core/mail'; +import { MockMailer } from '../mocks'; import { TEST_LOG_LEVEL } from '../utils'; interface TestingAppMetadata { @@ -21,6 +23,10 @@ interface TestingAppMetadata { } export class TestingApp extends NestApplication { + get mails() { + return this.get(Mailer, { strict: false }) as MockMailer; + } + async [Symbol.asyncDispose]() { await this.close(); } diff --git a/packages/backend/server/src/__tests__/mailer.spec.ts b/packages/backend/server/src/__tests__/mailer.spec.ts deleted file mode 100644 index b91ff4dd96..0000000000 --- a/packages/backend/server/src/__tests__/mailer.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { TestFn } from 'ava'; -import ava from 'ava'; -import Sinon from 'sinon'; - -import { MailService } from '../base/mailer'; -import { - createTestingApp, - createWorkspace, - inviteUser, - TestingApp, -} from './utils'; -const test = ava as TestFn<{ - app: TestingApp; - mail: MailService; -}>; -import * as renderers from '../mails'; - -test.beforeEach(async t => { - const app = await createTestingApp(); - - const mail = app.get(MailService); - t.context.app = app; - t.context.mail = mail; -}); - -test.afterEach.always(async t => { - await t.context.app.close(); -}); - -test('should send invite email', async t => { - const { mail, app } = t.context; - - if (mail.hasConfigured()) { - const u2 = await app.signupV1('u2@affine.pro'); - const u1 = await app.signupV1('u1@affine.pro'); - const stub = Sinon.stub(mail, 'send'); - - const workspace = await createWorkspace(app); - await inviteUser(app, workspace.id, u2.email, true); - - t.true(stub.calledOnce); - - const args = stub.args[0][0]; - - t.is(args.to, u2.email); - t.true( - args.subject!.startsWith( - `${u1.email} invited you to join` /* we don't know the name of mocked workspace */ - ) - ); - } - t.pass(); -}); - -test('should render emails', async t => { - for (const render of Object.values(renderers)) { - // @ts-expect-error use [PreviewProps] - const content = await render(); - t.snapshot(content.html, content.subject); - } -}); diff --git a/packages/backend/server/src/__tests__/mails.spec.ts b/packages/backend/server/src/__tests__/mails.spec.ts new file mode 100644 index 0000000000..19a79e27c9 --- /dev/null +++ b/packages/backend/server/src/__tests__/mails.spec.ts @@ -0,0 +1,11 @@ +import test from 'ava'; + +import { Renderers } from '../mails'; + +test('should render emails', async t => { + for (const render of Object.values(Renderers)) { + // @ts-expect-error use [PreviewProps] + const content = await render(); + t.snapshot(content.html, content.subject); + } +}); diff --git a/packages/backend/server/src/__tests__/mocks/index.ts b/packages/backend/server/src/__tests__/mocks/index.ts index 212b716342..4e8dba884d 100644 --- a/packages/backend/server/src/__tests__/mocks/index.ts +++ b/packages/backend/server/src/__tests__/mocks/index.ts @@ -3,6 +3,7 @@ export * from './team-workspace.mock'; export * from './user.mock'; export * from './workspace.mock'; +import { MockMailer } from './mailer.mock'; import { MockTeamWorkspace } from './team-workspace.mock'; import { MockUser } from './user.mock'; import { MockWorkspace } from './workspace.mock'; @@ -12,3 +13,5 @@ export const Mockers = { Workspace: MockWorkspace, TeamWorkspace: MockTeamWorkspace, }; + +export { MockMailer }; diff --git a/packages/backend/server/src/__tests__/mocks/mailer.mock.ts b/packages/backend/server/src/__tests__/mocks/mailer.mock.ts new file mode 100644 index 0000000000..b377ebecf0 --- /dev/null +++ b/packages/backend/server/src/__tests__/mocks/mailer.mock.ts @@ -0,0 +1,29 @@ +import Sinon from 'sinon'; + +import { Mailer } from '../../core/mail'; +import { MailName } from '../../mails'; + +export class MockMailer { + send = Sinon.createStubInstance(Mailer).send.resolves(true); + + last( + name: Mail + ): Extract { + const last = this.send.lastCall.args[0]; + + if (!last) { + throw new Error('No mail ever sent'); + } + + if (last.name !== name) { + throw new Error(`Mail name mismatch: ${last.name} !== ${name}`); + } + + return last as any; + } + + count(name: MailName) { + return this.send.getCalls().filter(call => call.args[0].name === name) + .length; + } +} diff --git a/packages/backend/server/src/__tests__/team.e2e.ts b/packages/backend/server/src/__tests__/team.e2e.ts index b5cd982fab..a64ddd9b73 100644 --- a/packages/backend/server/src/__tests__/team.e2e.ts +++ b/packages/backend/server/src/__tests__/team.e2e.ts @@ -1,6 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { getCurrentMailMessageCount } from '@affine-test/kit/utils/cloud'; import { User, WorkspaceMemberStatus } from '@prisma/client'; import type { TestFn } from 'ava'; import ava from 'ava'; @@ -619,12 +618,9 @@ test('should be able to invite by link', async t => { test('should be able to send mails', async t => { const { app } = t.context; const { inviteBatch } = await init(app, 5); - const primitiveMailCount = await getCurrentMailMessageCount(); - { - await inviteBatch(['m3@affine.pro', 'm4@affine.pro'], true); - t.is(await getCurrentMailMessageCount(), primitiveMailCount + 2); - } + await inviteBatch(['m3@affine.pro', 'm4@affine.pro'], true); + t.is(app.mails.count('MemberInvitation'), 2); }); test('should be able to emit events', async t => { diff --git a/packages/backend/server/src/__tests__/utils/testing-app.ts b/packages/backend/server/src/__tests__/utils/testing-app.ts index 695d7d6735..eb40474560 100644 --- a/packages/backend/server/src/__tests__/utils/testing-app.ts +++ b/packages/backend/server/src/__tests__/utils/testing-app.ts @@ -10,8 +10,10 @@ import supertest from 'supertest'; import { AFFiNELogger, ApplyType, GlobalExceptionFilter } from '../../base'; import { AuthService } from '../../core/auth'; +import { Mailer } from '../../core/mail'; import { UserModel } from '../../models'; import { createFactory, MockedUser, MockUser, MockUserInput } from '../mocks'; +import { MockMailer } from '../mocks/mailer.mock'; import { createTestingModule } from './testing-module'; import { initTestingDB, TEST_LOG_LEVEL } from './utils'; @@ -82,6 +84,7 @@ export class TestingApp extends ApplyType() { private readonly userCookies: Set = new Set(); readonly create!: ReturnType; + readonly mails!: MockMailer; [Symbol.asyncDispose](): Promise { return this.close(); @@ -280,6 +283,8 @@ function makeTestingApp(app: INestApplication): TestingApp { // @ts-expect-error allow testingApp.create = createFactory(app.get(PrismaClient, { strict: false })); + // @ts-expect-error allow + testingApp.mails = app.get(Mailer, { strict: false }) as MockMailer; return new Proxy(testingApp, { get(target, prop) { diff --git a/packages/backend/server/src/__tests__/utils/testing-module.ts b/packages/backend/server/src/__tests__/utils/testing-module.ts index a784f53b69..4df3b15c94 100644 --- a/packages/backend/server/src/__tests__/utils/testing-module.ts +++ b/packages/backend/server/src/__tests__/utils/testing-module.ts @@ -12,11 +12,13 @@ import { AppModule, FunctionalityModules } from '../../app.module'; import { AFFiNELogger, Runtime } from '../../base'; import { GqlModule } from '../../base/graphql'; import { AuthGuard, AuthModule } from '../../core/auth'; +import { Mailer, MailModule } from '../../core/mail'; import { ModelsModule } from '../../models'; // for jsdoc inference // oxlint-disable-next-line no-unused-vars import type { createModule } from '../create-module'; import { createFactory } from '../mocks'; +import { MockMailer } from '../mocks/mailer.mock'; import { initTestingDB, TEST_LOG_LEVEL } from './utils'; interface TestingModuleMeatdata extends ModuleMetadata { @@ -26,6 +28,7 @@ interface TestingModuleMeatdata extends ModuleMetadata { export interface TestingModule extends BaseTestingModule { initTestingDB(): Promise; create: ReturnType; + mails: MockMailer; [Symbol.asyncDispose](): Promise; } @@ -68,6 +71,7 @@ export async function createTestingModule( ModelsModule, AuthModule, GqlModule, + MailModule, ...imports, ]); @@ -87,6 +91,7 @@ export async function createTestingModule( if (moduleDef.tapModule) { moduleDef.tapModule(builder); } + builder.overrideProvider(Mailer).useClass(MockMailer); const module = await builder.compile(); @@ -108,6 +113,8 @@ export async function createTestingModule( await module.close(); }; + testingModule.mails = module.get(Mailer, { strict: false }) as MockMailer; + const logger = new AFFiNELogger(); // we got a lot smoking tests try to break nestjs // can't tolerate the noisy logs diff --git a/packages/backend/server/src/__tests__/workspace-invite.e2e.ts b/packages/backend/server/src/__tests__/workspace-invite.e2e.ts index 8135215800..6dcdf4d062 100644 --- a/packages/backend/server/src/__tests__/workspace-invite.e2e.ts +++ b/packages/backend/server/src/__tests__/workspace-invite.e2e.ts @@ -1,12 +1,7 @@ -import { - getCurrentMailMessageCount, - getLatestMailMessage, -} from '@affine-test/kit/utils/cloud'; import { PrismaClient } from '@prisma/client'; import type { TestFn } from 'ava'; import ava from 'ava'; -import { MailService } from '../base/mailer'; import { AuthService } from '../core/auth/service'; import { Models } from '../models'; import { @@ -24,7 +19,6 @@ const test = ava as TestFn<{ app: TestingApp; client: PrismaClient; auth: AuthService; - mail: MailService; models: Models; }>; @@ -33,7 +27,6 @@ test.before(async t => { t.context.app = app; t.context.client = app.get(PrismaClient); t.context.auth = app.get(AuthService); - t.context.mail = app.get(MailService); t.context.models = app.get(Models); }); @@ -125,70 +118,31 @@ test('should invite a user by link', async t => { }); test('should send email', async t => { - const { mail, app } = t.context; - if (mail.hasConfigured()) { - const u2 = await app.signupV1('u2@affine.pro'); - await app.signupV1('u1@affine.pro'); + const { app } = t.context; + const u2 = await app.signupV1('u2@affine.pro'); + const u1 = await app.signupV1('u1@affine.pro'); - const workspace = await createWorkspace(app); - const primitiveMailCount = await getCurrentMailMessageCount(); + const workspace = await createWorkspace(app); + const invite = await inviteUser(app, workspace.id, u2.email, true); - const invite = await inviteUser(app, workspace.id, u2.email, true); + const invitationMail = app.mails.last('MemberInvitation'); - const afterInviteMailCount = await getCurrentMailMessageCount(); - t.is( - primitiveMailCount + 1, - afterInviteMailCount, - 'failed to send invite email' - ); - const inviteEmailContent = await getLatestMailMessage(); + t.is(invitationMail.name, 'MemberInvitation'); + t.is(invitationMail.to, u2.email); - t.not( - inviteEmailContent.To.find((item: any) => { - return item.Mailbox === 'u2'; - }), - undefined, - 'invite email address was incorrectly sent' - ); + app.switchUser(u2.id); + await acceptInviteById(app, workspace.id, invite, true); - app.switchUser(u2.id); - const accept = await acceptInviteById(app, workspace.id, invite, true); - t.true(accept, 'failed to accept invite'); + const acceptedMail = app.mails.last('MemberAccepted'); + t.is(acceptedMail.to, u1.email); + t.is(acceptedMail.props.user.$$userId, u2.id); - const afterAcceptMailCount = await getCurrentMailMessageCount(); - t.is( - afterInviteMailCount + 1, - afterAcceptMailCount, - 'failed to send accepted email to owner' - ); - const acceptEmailContent = await getLatestMailMessage(); - t.not( - acceptEmailContent.To.find((item: any) => { - return item.Mailbox === 'u1'; - }), - undefined, - 'accept email address was incorrectly sent' - ); + await leaveWorkspace(app, workspace.id, true); - await leaveWorkspace(app, workspace.id, true); + const leaveMail = app.mails.last('MemberLeave'); - // TODO(@darkskygit): enable this after cluster event system is ready - // const afterLeaveMailCount = await getCurrentMailMessageCount(); - // t.is( - // afterAcceptMailCount + 1, - // afterLeaveMailCount, - // 'failed to send leave email to owner' - // ); - const leaveEmailContent = await getLatestMailMessage(); - t.not( - leaveEmailContent.To.find((item: any) => { - return item.Mailbox === 'u1'; - }), - undefined, - 'leave email address was incorrectly sent' - ); - } - t.pass(); + t.is(leaveMail.to, u1.email); + t.is(leaveMail.props.user.$$userId, u2.id); }); test('should support pagination for member', async t => { diff --git a/packages/backend/server/src/app.module.ts b/packages/backend/server/src/app.module.ts index 1cfc2d5f2b..762a0159c7 100644 --- a/packages/backend/server/src/app.module.ts +++ b/packages/backend/server/src/app.module.ts @@ -28,7 +28,6 @@ import { GqlModule } from './base/graphql'; import { HelpersModule } from './base/helpers'; import { JobModule } from './base/job'; import { LoggerModule } from './base/logger'; -import { MailModule } from './base/mailer'; import { MetricsModule } from './base/metrics'; import { MutexModule } from './base/mutex'; import { PrismaModule } from './base/prisma'; @@ -43,6 +42,7 @@ import { DocStorageModule } from './core/doc'; import { DocRendererModule } from './core/doc-renderer'; import { DocServiceModule } from './core/doc-service'; import { FeatureModule } from './core/features'; +import { MailModule } from './core/mail'; import { NotificationModule } from './core/notification'; import { PermissionModule } from './core/permission'; import { QuotaModule } from './core/quota'; @@ -101,7 +101,6 @@ export const FunctionalityModules = [ PrismaModule, MetricsModule, RateLimiterModule, - MailModule, StorageProviderModule, HelpersModule, ErrorModule, @@ -219,7 +218,13 @@ export function buildAppModule() { .use(UserModule, AuthModule, PermissionModule) // business modules - .use(FeatureModule, QuotaModule, DocStorageModule, NotificationModule) + .use( + FeatureModule, + QuotaModule, + DocStorageModule, + NotificationModule, + MailModule + ) // sync server only .useIf(config => config.flavor.sync, SyncModule) diff --git a/packages/backend/server/src/base/index.ts b/packages/backend/server/src/base/index.ts index add6b5b2ac..8e6ba4421a 100644 --- a/packages/backend/server/src/base/index.ts +++ b/packages/backend/server/src/base/index.ts @@ -26,7 +26,6 @@ export * from './guard'; export { CryptoHelper, URLHelper } from './helpers'; export * from './job'; export { AFFiNELogger } from './logger'; -export { MailService } from './mailer'; export { CallMetric, metrics } from './metrics'; export { Lock, Locker, Mutex, RequestMutex } from './mutex'; export * from './nestjs'; diff --git a/packages/backend/server/src/base/job/index.ts b/packages/backend/server/src/base/job/index.ts index b815378bf7..eb4f57bd48 100644 --- a/packages/backend/server/src/base/job/index.ts +++ b/packages/backend/server/src/base/job/index.ts @@ -1 +1 @@ -export { JobModule, JobQueue, OnJob } from './queue'; +export { JOB_SIGNAL, JobModule, JobQueue, OnJob } from './queue'; 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 5e9abda8b9..14f2e18fe6 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 @@ -93,10 +93,6 @@ test('should register job handler', async t => { t.is(handler!.name, 'JobHandlers.handleJob'); t.is(typeof handler!.fn, 'function'); - - const result = await handler!.fn({ name: 'test' }); - - t.is(result, 'test'); }); // #endregion diff --git a/packages/backend/server/src/base/job/queue/def.ts b/packages/backend/server/src/base/job/queue/def.ts index 01e9b70050..6db33e2908 100644 --- a/packages/backend/server/src/base/job/queue/def.ts +++ b/packages/backend/server/src/base/job/queue/def.ts @@ -63,3 +63,7 @@ If you want to introduce new job queue, please modify the Queue enum first in ${ export function getJobHandlerMetadata(target: any): JobName[] { return sliceMetadata(JOB_METADATA, target); } + +export enum JOB_SIGNAL { + RETRY = 'retry', +} diff --git a/packages/backend/server/src/base/job/queue/executor.ts b/packages/backend/server/src/base/job/queue/executor.ts index 10efd8aaed..a9e019b0c3 100644 --- a/packages/backend/server/src/base/job/queue/executor.ts +++ b/packages/backend/server/src/base/job/queue/executor.ts @@ -13,7 +13,7 @@ import { metrics, wrapCallMetric } from '../../metrics'; import { QueueRedis } from '../../redis'; import { Runtime } from '../../runtime'; import { genRequestId } from '../../utils'; -import { namespace, Queue, QUEUES } from './def'; +import { JOB_SIGNAL, namespace, Queue, QUEUES } from './def'; import { JobHandlerScanner } from './scanner'; @Injectable() @@ -69,8 +69,12 @@ export class JobExecutor try { this.logger.debug(`Job started: ${signature}`); const result = await handler.fn(payload); + + if (result === JOB_SIGNAL.RETRY) { + throw new Error(`Manually job retry`); + } + this.logger.debug(`Job finished: ${signature}`); - return result; } catch (e) { this.logger.error(`Job failed: ${signature}`, e); throw e; @@ -88,7 +92,7 @@ export class JobExecutor const activeJobs = metrics.queue.counter('active_jobs'); activeJobs.add(1, { queue: ns }); try { - return await fn(); + await fn(); } finally { activeJobs.add(-1, { queue: ns }); } diff --git a/packages/backend/server/src/base/job/queue/index.ts b/packages/backend/server/src/base/job/queue/index.ts index 3cde80a531..70124a6364 100644 --- a/packages/backend/server/src/base/job/queue/index.ts +++ b/packages/backend/server/src/base/job/queue/index.ts @@ -42,4 +42,4 @@ export class JobModule { } export { JobQueue }; -export { OnJob } from './def'; +export { JOB_SIGNAL, OnJob } from './def'; diff --git a/packages/backend/server/src/base/job/queue/scanner.ts b/packages/backend/server/src/base/job/queue/scanner.ts index 22aee769bf..cf53a053da 100644 --- a/packages/backend/server/src/base/job/queue/scanner.ts +++ b/packages/backend/server/src/base/job/queue/scanner.ts @@ -1,11 +1,11 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; import { ModuleScanner } from '../../nestjs'; -import { getJobHandlerMetadata } from './def'; +import { getJobHandlerMetadata, JOB_SIGNAL } from './def'; interface JobHandler { name: string; - fn: (payload: any) => any; + fn: (payload: any) => Promise; } @Injectable() diff --git a/packages/backend/server/src/base/mailer/index.ts b/packages/backend/server/src/base/mailer/index.ts deleted file mode 100644 index d6de678a55..0000000000 --- a/packages/backend/server/src/base/mailer/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import './config'; - -import { Global, Module } from '@nestjs/common'; - -import { OptionalModule } from '../nestjs'; -import { MailService } from './mail.service'; -import { MAILER } from './mailer'; - -@Global() -@OptionalModule({ - providers: [MAILER], - exports: [MAILER], - requires: ['mailer.host'], -}) -class MailerModule {} - -@Global() -@Module({ - imports: [MailerModule], - providers: [MailService], - exports: [MailService], -}) -export class MailModule {} -export { MailService }; diff --git a/packages/backend/server/src/base/mailer/mail.service.ts b/packages/backend/server/src/base/mailer/mail.service.ts deleted file mode 100644 index 5dc8533216..0000000000 --- a/packages/backend/server/src/base/mailer/mail.service.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { Inject, Injectable, Optional } from '@nestjs/common'; -import SMTPTransport from 'nodemailer/lib/smtp-transport'; - -import { - EmailRenderer, - renderChangeEmailMail, - renderChangeEmailNotificationMail, - renderChangePasswordMail, - renderLinkInvitationApproveMail, - renderLinkInvitationDeclineMail, - renderLinkInvitationReviewRequestMail, - renderMemberAcceptedMail, - renderMemberInvitationMail, - renderMemberLeaveMail, - renderMemberRemovedMail, - renderOwnershipReceivedMail, - renderOwnershipTransferredMail, - renderSetPasswordMail, - renderSignInMail, - renderSignUpMail, - renderTeamBecomeAdminMail, - renderTeamBecomeCollaboratorMail, - renderTeamDeleteIn1MonthMail, - renderTeamDeleteIn24HoursMail, - renderTeamLicenseMail, - renderTeamWorkspaceDeletedMail, - renderTeamWorkspaceExpiredMail, - renderTeamWorkspaceExpireSoonMail, - renderTeamWorkspaceUpgradedMail, - renderVerifyChangeEmailMail, - renderVerifyEmailMail, -} from '../../mails'; -import { WorkspaceProps } from '../../mails/components'; -import { Config } from '../config'; -import { MailerServiceIsNotConfigured } from '../error'; -import { metrics } from '../metrics'; -import type { MailerService, Options } from './mailer'; -import { MAILER_SERVICE } from './mailer'; - -type Props> = - T extends EmailRenderer ? P : never; -type Sender> = ( - to: string, - props: Props -) => Promise; - -function make>( - sender: { - send: (options: Options) => Promise; - }, - renderer: T, - factory?: (props: Props) => { - props: Props; - options: Partial; - } -): Sender { - return async (to, props) => { - const { props: overrideProps, options } = factory - ? factory(props) - : { props, options: {} }; - - const { html, subject } = await renderer(overrideProps); - return sender.send({ - to, - subject, - html, - ...options, - }); - }; -} - -@Injectable() -export class MailService { - constructor( - private readonly config: Config, - @Optional() @Inject(MAILER_SERVICE) private readonly mailer?: MailerService - ) {} - - readonly send = async (options: Options) => { - if (!this.mailer) { - throw new MailerServiceIsNotConfigured(); - } - - metrics.mail.counter('total').add(1); - try { - const result = await this.mailer.sendMail({ - from: this.config.mailer?.from, - ...options, - }); - - metrics.mail.counter('sent').add(1); - - return result; - } catch (e) { - metrics.mail.counter('error').add(1); - throw e; - } - }; - - private make>( - renderer: T, - factory?: (props: Props) => { - props: Props; - options: Partial; - } - ) { - return make(this, renderer, factory); - } - - private readonly convertWorkspaceProps = < - T extends { workspace: WorkspaceProps }, - >( - props: T - ) => { - return { - props: { - ...props, - workspace: { - ...props.workspace, - avatar: 'cid:workspaceAvatar', - }, - }, - options: { - attachments: [ - { - cid: 'workspaceAvatar', - filename: 'workspaceAvatar', - content: props.workspace.avatar, - encoding: 'base64', - }, - ], - }, - }; - }; - - private makeWorkspace>(renderer: T) { - return this.make(renderer, this.convertWorkspaceProps); - } - - hasConfigured() { - return !!this.mailer; - } - - // User mails - sendSignUpMail = this.make(renderSignUpMail); - sendSignInMail = this.make(renderSignInMail); - sendChangePasswordMail = this.make(renderChangePasswordMail); - sendSetPasswordMail = this.make(renderSetPasswordMail); - sendChangeEmailMail = this.make(renderChangeEmailMail); - sendVerifyChangeEmail = this.make(renderVerifyChangeEmailMail); - sendVerifyEmail = this.make(renderVerifyEmailMail); - sendNotificationChangeEmail = make(this, renderChangeEmailNotificationMail); - - // =================== Workspace Mails =================== - sendMemberInviteMail = this.makeWorkspace(renderMemberInvitationMail); - sendMemberAcceptedEmail = this.makeWorkspace(renderMemberAcceptedMail); - sendMemberLeaveEmail = this.makeWorkspace(renderMemberLeaveMail); - sendLinkInvitationReviewRequestMail = this.makeWorkspace( - renderLinkInvitationReviewRequestMail - ); - sendLinkInvitationApproveMail = this.makeWorkspace( - renderLinkInvitationApproveMail - ); - sendLinkInvitationDeclineMail = this.makeWorkspace( - renderLinkInvitationDeclineMail - ); - sendMemberRemovedMail = this.makeWorkspace(renderMemberRemovedMail); - sendOwnershipTransferredMail = this.makeWorkspace( - renderOwnershipTransferredMail - ); - sendOwnershipReceivedMail = this.makeWorkspace(renderOwnershipReceivedMail); - - // =================== Team Workspace Mails =================== - sendTeamWorkspaceUpgradedEmail = this.makeWorkspace( - renderTeamWorkspaceUpgradedMail - ); - sendTeamBecomeAdminMail = this.makeWorkspace(renderTeamBecomeAdminMail); - sendTeamBecomeCollaboratorMail = this.makeWorkspace( - renderTeamBecomeCollaboratorMail - ); - sendTeamDeleteIn24HoursMail = this.makeWorkspace( - renderTeamDeleteIn24HoursMail - ); - sendTeamDeleteIn1MonthMail = this.makeWorkspace(renderTeamDeleteIn1MonthMail); - sendTeamWorkspaceDeletedMail = this.makeWorkspace( - renderTeamWorkspaceDeletedMail - ); - sendTeamExpireSoonMail = this.makeWorkspace( - renderTeamWorkspaceExpireSoonMail - ); - sendTeamExpiredMail = this.makeWorkspace(renderTeamWorkspaceExpiredMail); - sendTeamLicenseMail = this.make(renderTeamLicenseMail); -} diff --git a/packages/backend/server/src/base/mailer/mailer.ts b/packages/backend/server/src/base/mailer/mailer.ts deleted file mode 100644 index eed4a4f4d9..0000000000 --- a/packages/backend/server/src/base/mailer/mailer.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { FactoryProvider, Logger } from '@nestjs/common'; -import { createTransport, Transporter } from 'nodemailer'; -import SMTPTransport from 'nodemailer/lib/smtp-transport'; - -import { Config } from '../config'; - -export const MAILER_SERVICE = Symbol('MAILER_SERVICE'); - -export type MailerService = Transporter; -export type Response = SMTPTransport.SentMessageInfo; -export type Options = SMTPTransport.Options; - -export const MAILER: FactoryProvider< - Transporter | undefined -> = { - provide: MAILER_SERVICE, - useFactory: (config: Config) => { - if (config.mailer) { - const logger = new Logger('Mailer'); - const auth = config.mailer.auth; - if (auth && auth.user && !('pass' in auth)) { - logger.warn( - 'Mailer service has not configured password, please make sure your mailer service allow empty password.' - ); - } - - return createTransport(config.mailer); - } else { - return undefined; - } - }, - inject: [Config], -}; diff --git a/packages/backend/server/src/core/auth/controller.ts b/packages/backend/server/src/core/auth/controller.ts index a5b067fad9..02b7280333 100644 --- a/packages/backend/server/src/core/auth/controller.ts +++ b/packages/backend/server/src/core/auth/controller.ts @@ -20,7 +20,6 @@ import { CryptoHelper, EarlyAccessRequired, EmailTokenNotFound, - InternalServerError, InvalidAuthState, InvalidEmail, InvalidEmailToken, @@ -235,16 +234,7 @@ export class AuthController { this.logger.debug(`Magic link: ${magicLink}`); } - const result = await this.auth.sendSignInEmail( - email, - magicLink, - otp, - !user - ); - - if (result.rejected.length) { - throw new InternalServerError('Failed to send sign-in email.'); - } + await this.auth.sendSignInEmail(email, magicLink, otp, !user); res.status(HttpStatus.OK).send({ email: email, diff --git a/packages/backend/server/src/core/auth/index.ts b/packages/backend/server/src/core/auth/index.ts index 94a0e45019..506bf79eea 100644 --- a/packages/backend/server/src/core/auth/index.ts +++ b/packages/backend/server/src/core/auth/index.ts @@ -3,6 +3,7 @@ import './config'; import { Module } from '@nestjs/common'; import { FeatureModule } from '../features'; +import { MailModule } from '../mail'; import { QuotaModule } from '../quota'; import { UserModule } from '../user'; import { AuthController } from './controller'; @@ -12,7 +13,7 @@ import { AuthResolver } from './resolver'; import { AuthService } from './service'; @Module({ - imports: [FeatureModule, UserModule, QuotaModule], + imports: [FeatureModule, UserModule, QuotaModule, MailModule], providers: [ AuthService, AuthResolver, diff --git a/packages/backend/server/src/core/auth/resolver.ts b/packages/backend/server/src/core/auth/resolver.ts index c7c60767a9..065d978210 100644 --- a/packages/backend/server/src/core/auth/resolver.ts +++ b/packages/backend/server/src/core/auth/resolver.ts @@ -161,9 +161,7 @@ export class AuthResolver { const url = this.url.link(callbackUrl, { userId: user.id, token }); - const res = await this.auth.sendChangePasswordEmail(user.email, url); - - return !res.rejected.length; + return await this.auth.sendChangePasswordEmail(user.email, url); } @Mutation(() => Boolean) @@ -204,8 +202,7 @@ export class AuthResolver { const url = this.url.link(callbackUrl, { token }); - const res = await this.auth.sendChangeEmail(user.email, url); - return !res.rejected.length; + return await this.auth.sendChangeEmail(user.email, url); } @Mutation(() => Boolean) @@ -248,9 +245,7 @@ export class AuthResolver { ); const url = this.url.link(callbackUrl, { token: verifyEmailToken, email }); - const res = await this.auth.sendVerifyChangeEmail(email, url); - - return !res.rejected.length; + return await this.auth.sendVerifyChangeEmail(email, url); } @Mutation(() => Boolean) @@ -265,8 +260,7 @@ export class AuthResolver { const url = this.url.link(callbackUrl, { token }); - const res = await this.auth.sendVerifyEmail(user.email, url); - return !res.rejected.length; + return await this.auth.sendVerifyEmail(user.email, url); } @Mutation(() => Boolean) diff --git a/packages/backend/server/src/core/auth/service.ts b/packages/backend/server/src/core/auth/service.ts index 3aede5a09e..141efe30fc 100644 --- a/packages/backend/server/src/core/auth/service.ts +++ b/packages/backend/server/src/core/auth/service.ts @@ -2,7 +2,7 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; import type { CookieOptions, Request, Response } from 'express'; import { assign, pick } from 'lodash-es'; -import { Config, MailService, SignUpForbidden } from '../../base'; +import { Config, SignUpForbidden } from '../../base'; import { Models, type User, @@ -10,6 +10,7 @@ import { type UserSession, } from '../../models'; import { FeatureService } from '../features'; +import { Mailer } from '../mail/mailer'; import type { CurrentUser } from './session'; export function sessionUser( @@ -47,7 +48,7 @@ export class AuthService implements OnApplicationBootstrap { constructor( private readonly config: Config, private readonly models: Models, - private readonly mailer: MailService, + private readonly mailer: Mailer, private readonly feature: FeatureService ) {} @@ -325,23 +326,57 @@ export class AuthService implements OnApplicationBootstrap { } async sendChangePasswordEmail(email: string, callbackUrl: string) { - return this.mailer.sendChangePasswordMail(email, { url: callbackUrl }); + return await this.mailer.send({ + name: 'ChangePassword', + to: email, + props: { + url: callbackUrl, + }, + }); } async sendSetPasswordEmail(email: string, callbackUrl: string) { - return this.mailer.sendSetPasswordMail(email, { url: callbackUrl }); + return await this.mailer.send({ + name: 'SetPassword', + to: email, + props: { + url: callbackUrl, + }, + }); } async sendChangeEmail(email: string, callbackUrl: string) { - return this.mailer.sendChangeEmailMail(email, { url: callbackUrl }); + return await this.mailer.send({ + name: 'ChangeEmail', + to: email, + props: { + url: callbackUrl, + }, + }); } async sendVerifyChangeEmail(email: string, callbackUrl: string) { - return this.mailer.sendVerifyChangeEmail(email, { url: callbackUrl }); + return await this.mailer.send({ + name: 'VerifyChangeEmail', + to: email, + props: { + url: callbackUrl, + }, + }); } async sendVerifyEmail(email: string, callbackUrl: string) { - return this.mailer.sendVerifyEmail(email, { url: callbackUrl }); + return await this.mailer.send({ + name: 'VerifyEmail', + to: email, + props: { + url: callbackUrl, + }, + }); } async sendNotificationChangeEmail(email: string) { - return this.mailer.sendNotificationChangeEmail(email, { + return await this.mailer.send({ + name: 'EmailChanged', to: email, + props: { + to: email, + }, }); } @@ -351,8 +386,13 @@ export class AuthService implements OnApplicationBootstrap { otp: string, signUp: boolean ) { - return signUp - ? await this.mailer.sendSignUpMail(email, { url: link, otp }) - : await this.mailer.sendSignInMail(email, { url: link, otp }); + return await this.mailer.send({ + name: signUp ? 'SignUp' : 'SignIn', + to: email, + props: { + url: link, + otp, + }, + }); } } diff --git a/packages/backend/server/src/base/mailer/config.ts b/packages/backend/server/src/core/mail/config.ts similarity index 74% rename from packages/backend/server/src/base/mailer/config.ts rename to packages/backend/server/src/core/mail/config.ts index a65891bbed..b2a2754512 100644 --- a/packages/backend/server/src/base/mailer/config.ts +++ b/packages/backend/server/src/core/mail/config.ts @@ -1,8 +1,8 @@ import SMTPTransport from 'nodemailer/lib/smtp-transport'; -import { defineStartupConfig, ModuleConfig } from '../config'; +import { defineStartupConfig, ModuleConfig } from '../../base/config'; -declare module '../config' { +declare module '../../base/config' { interface AppConfig { /** * Configurations for mail service used to post auth or bussiness mails. diff --git a/packages/backend/server/src/core/mail/index.ts b/packages/backend/server/src/core/mail/index.ts new file mode 100644 index 0000000000..834c25c74d --- /dev/null +++ b/packages/backend/server/src/core/mail/index.ts @@ -0,0 +1,17 @@ +import './config'; + +import { Module } from '@nestjs/common'; + +import { DocStorageModule } from '../doc'; +import { StorageModule } from '../storage'; +import { MailJob } from './job'; +import { Mailer } from './mailer'; +import { MailSender } from './sender'; + +@Module({ + imports: [DocStorageModule, StorageModule], + providers: [MailSender, Mailer, MailJob], + exports: [Mailer], +}) +export class MailModule {} +export { Mailer }; diff --git a/packages/backend/server/src/core/mail/job.ts b/packages/backend/server/src/core/mail/job.ts new file mode 100644 index 0000000000..61317081a1 --- /dev/null +++ b/packages/backend/server/src/core/mail/job.ts @@ -0,0 +1,142 @@ +import { Injectable } from '@nestjs/common'; +import { getStreamAsBuffer } from 'get-stream'; + +import { JOB_SIGNAL, OnJob } 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 { MailSender, SendOptions } from './sender'; + +type DynamicallyFetchedProps = { + [Key in keyof Props]: Props[Key] extends infer Prop + ? Prop extends UserProps + ? { + $$userId: string; + } & Omit + : Prop extends WorkspaceProps + ? { + $$workspaceId: string; + } & Omit + : Prop + : never; +}; + +type SendMailJob> = { + name: Mail; + to: string; + // NOTE(@forehalo): + // workspace avatar currently send as base64 img instead of a avatar url, + // so the content might be too large to be put in job payload. + props: DynamicallyFetchedProps; +}; + +declare global { + interface Jobs { + 'notification.sendMail': { + [K in MailName]: SendMailJob; + }[MailName]; + } +} + +@Injectable() +export class MailJob { + constructor( + private readonly sender: MailSender, + private readonly doc: DocReader, + private readonly workspaceBlob: WorkspaceBlobStorage, + private readonly models: Models + ) {} + + @OnJob('notification.sendMail') + async sendMail({ name, to, props }: Jobs['notification.sendMail']) { + let options: Partial = {}; + + for (const key in props) { + // @ts-expect-error allow + const val = props[key]; + if (val && typeof val === 'object') { + if ('$$workspaceId' in val) { + const workspaceProps = await this.fetchWorkspaceProps( + val.$$workspaceId + ); + + if (!workspaceProps) { + return; + } + + if (workspaceProps.avatar) { + workspaceProps.avatar = 'cid:workspaceAvatar'; + options.attachments = [ + { + cid: 'workspaceAvatar', + filename: 'workspaceAvatar', + content: workspaceProps.avatar, + encoding: 'base64', + }, + ]; + } + // @ts-expect-error replacement + props[key] = workspaceProps; + } else if ('$$userId' in val) { + const userProps = await this.fetchUserProps(val.$$userId); + + if (!userProps) { + return; + } + + // @ts-expect-error replacement + props[key] = userProps; + } + } + } + + const result = await this.sender.send(name, { + to, + ...(await Renderers[name]( + // @ts-expect-error the job trigger part has been typechecked + props + )), + ...options, + }); + + return result === false ? JOB_SIGNAL.RETRY : undefined; + } + + private async fetchWorkspaceProps(workspaceId: string) { + const workspace = await this.doc.getWorkspaceContent(workspaceId); + + if (!workspace) { + return; + } + + const props: WorkspaceProps = { + name: workspace.name, + }; + + if (workspace.avatarKey) { + const avatar = await this.workspaceBlob.get( + workspace.id, + workspace.avatarKey + ); + + if (avatar.body) { + props.avatar = (await getStreamAsBuffer(avatar.body)).toString( + 'base64' + ); + } + } + + return props; + } + + private async fetchUserProps(userId: string) { + const user = await this.models.user.getWorkspaceUser(userId); + if (!user) { + return; + } + + return { email: user.email } satisfies UserProps; + } +} diff --git a/packages/backend/server/src/core/mail/mailer.ts b/packages/backend/server/src/core/mail/mailer.ts new file mode 100644 index 0000000000..3263ee1b24 --- /dev/null +++ b/packages/backend/server/src/core/mail/mailer.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; + +import { JobQueue } from '../../base'; + +@Injectable() +export class Mailer { + constructor(private readonly queue: JobQueue) {} + + async send(command: Jobs['notification.sendMail']) { + try { + await this.queue.add('notification.sendMail', command); + return true; + } catch { + return false; + } + } +} diff --git a/packages/backend/server/src/core/mail/sender.ts b/packages/backend/server/src/core/mail/sender.ts new file mode 100644 index 0000000000..e07ab5e07d --- /dev/null +++ b/packages/backend/server/src/core/mail/sender.ts @@ -0,0 +1,89 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { + createTestAccount, + createTransport, + getTestMessageUrl, + SendMailOptions, + Transporter, +} from 'nodemailer'; +import SMTPTransport from 'nodemailer/lib/smtp-transport'; + +import { Config, metrics } from '../../base'; + +export type SendOptions = Omit & { + to: string; + subject: string; + html: string; +}; + +@Injectable() +export class MailSender implements OnModuleInit { + private readonly logger = new Logger(MailSender.name); + private smtp: Transporter | null = null; + private usingTestAccount = false; + constructor(private readonly config: Config) {} + + onModuleInit() { + this.createSMTP(this.config.mailer); + } + + createSMTP(config: SMTPTransport.Options) { + if (config.host) { + this.smtp = createTransport(config); + } else if (this.config.node.dev) { + createTestAccount((err, account) => { + if (!err) { + this.smtp = createTransport({ + from: 'noreply@toeverything.info', + ...this.config.mailer, + ...account.smtp, + auth: { + user: account.user, + pass: account.pass, + }, + }); + this.usingTestAccount = true; + } + }); + } else { + this.logger.warn('Mailer SMTP transport is not configured.'); + } + } + + async send(name: string, options: SendOptions) { + if (!this.smtp) { + this.logger.warn(`Mailer SMTP transport is not configured to send mail.`); + return null; + } + + metrics.mail.counter('send_total').add(1, { name }); + try { + const result = await this.smtp.sendMail({ + from: this.config.mailer.from, + ...options, + }); + + if (result.rejected.length > 0) { + metrics.mail.counter('rejected_total').add(1, { name }); + this.logger.error( + `Mail [${name}] rejected with response: ${result.response}` + ); + return false; + } + + metrics.mail.counter('accepted_total').add(1, { name }); + this.logger.log(`Mail [${name}] sent successfully.`); + if (this.usingTestAccount) { + this.logger.debug( + ` ⚙️ Mail preview url: ${getTestMessageUrl(result)}` + ); + } + + return true; + } catch (e) { + metrics.mail.counter('failed_total').add(1, { name }); + this.logger.error(`Failed to send mail [${name}].`, e); + return false; + } + } +} diff --git a/packages/backend/server/src/core/workspaces/event.ts b/packages/backend/server/src/core/workspaces/event.ts index 309afd72d0..ff9930b1a6 100644 --- a/packages/backend/server/src/core/workspaces/event.ts +++ b/packages/backend/server/src/core/workspaces/event.ts @@ -33,9 +33,12 @@ export class WorkspaceEvents { workspaceId, }: Events['workspace.members.requestDeclined']) { const user = await this.models.user.getWorkspaceUser(userId); + if (!user) { + return; + } // send decline mail await this.workspaceService.sendReviewDeclinedEmail( - user?.email, + user.email, workspaceId ); } diff --git a/packages/backend/server/src/core/workspaces/index.ts b/packages/backend/server/src/core/workspaces/index.ts index 0879672f0a..f7efd06725 100644 --- a/packages/backend/server/src/core/workspaces/index.ts +++ b/packages/backend/server/src/core/workspaces/index.ts @@ -3,6 +3,8 @@ import { Module } from '@nestjs/common'; import { DocStorageModule } from '../doc'; import { DocRendererModule } from '../doc-renderer'; import { FeatureModule } from '../features'; +import { MailModule } from '../mail'; +import { NotificationModule } from '../notification'; import { PermissionModule } from '../permission'; import { QuotaModule } from '../quota'; import { StorageModule } from '../storage'; @@ -28,6 +30,8 @@ import { StorageModule, UserModule, PermissionModule, + NotificationModule, + MailModule, ], controllers: [WorkspacesController], providers: [ diff --git a/packages/backend/server/src/core/workspaces/resolvers/service.ts b/packages/backend/server/src/core/workspaces/resolvers/service.ts index 1a38099213..51575bac6a 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/service.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/service.ts @@ -1,21 +1,14 @@ import { Injectable, Logger } from '@nestjs/common'; import { getStreamAsBuffer } from 'get-stream'; -import { - Cache, - Config, - MailService, - NotFound, - OnEvent, - URLHelper, - UserNotFound, -} from '../../../base'; +import { Cache, NotFound, OnEvent, URLHelper } from '../../../base'; import { Models } from '../../../models'; import { DocReader } from '../../doc'; +import { Mailer } from '../../mail'; import { WorkspaceRole } from '../../permission'; import { WorkspaceBlobStorage } from '../../storage'; -export const defaultWorkspaceAvatar = +export const DEFAULT_WORKSPACE_AVATAR = 'iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAQtSURBVHgBfVa9jhxFEK6q7rkf+4T2AgdIIC0ZoXkBuNQJtngBuIzs1hIRye1FhL438D0CRgKRGUeE6wwkhHYlkE2AtGdkbN/MdJe/qu7Z27PWnnG5Znq7v/rqd47pHddkNh/918tR1/FBamXc9zxOPVFKfJ4yP86qD1LD3/986/3F2zB40+LXv83HrHq/6+gAoNS1kF4odUz2nhJRTkI5E6mD6Bk1crLJkLy5cHc+P4ohzxLng8RKLqKUq6hkUtBSe8Zvdmfir7TT2a0fnkzeaeCbv/44ztSfZskjP2ygVRM0mbYTpgHMMMS8CsIIj/c+//Hp8UYD3z758whQUwdeEwPjAZQLqJhI0VxB2MVco+kXP/0zuZKD6dP5uM397ELzqEtMba/UJ4t7iXeq8U94z52Q+js09qjlIXMxAEsRDJpI59dVPzlDTooHko7BdlR2FcYmAtbGMmAt2mFI4yDQkIjtEQkxUAMKAPD9SiOK4b578N0S7Nt+fqFKbTbmRD1YGXurEmdtnjjz4kFuIV0gtWewV62hMHBY2gpEOw3Rnmztx9jnO72xzTV/YkzgNmgkiypeYJdCLjonqyAAg7VCshVpjTbD08HbxrySdhKxcDvoJTA5gLvpeXVQ+K340WKea9UkNeZVqGSba/IbF6athj+LUeRmRCyiAVnlAKhJJQfmugGZ28ZWna24RGzwNUNUqpWGf6HkajvAgNA4NsSjHgcb9obx+k5c3DUttcwd3NcHxpVurXQ2d4MZACGw9TwEHsdtbEwytL1xywAGcxavjoH1quLVywuGi+aBhFWexRilFSwK0QzgdUdkkVMeKw4wijrgxjzz2CefCRZn+21ViOWW4Ym9nNnyFLMbMS8ivNhGP8RdlgUojBkuBLDpEPi+5LpWiDURgFkKOIIckJTgN/sZ84KtKkKpDnsOZiTQ47jD4ZGwHghbw6AXIL3lo5Zg6Tp2AwIAyYJ8BRzGfmfPl6kI7HOLUdN2LIg+4IfL5SiFdvkK4blI6h50qda7jQI0CUMLdEhFIkqtQciMvXsgpaZ1pWtVUfrIa+TX5/8+RBcftAhTa91r8ycXA5ZxBqhAh2zgVagUAddxMkxfF/JxfvbpB+8d2jhBtsPhtuqsE0HJlhxYeHKdkCU8xUCos8dmkDdnGaOlJ1yy9dM52J2spqldvz9fTgB4z+aQd2kqjUY2KU2s4dTT7ezD0AqDAbvZiKF/VO9+fGPv9IoBu+b/P5ti6djDY+JlSg4ug1jc6fJbMAx9/3b4CNGTD/evT698D9avv188m4gKvko8MiMeJC3jmOvU9MSuHXZohAVpOrmxd+10HW/jR3/58uU45TRFt35ZR2XpY61DzW+tH3z/7xdM8sP93d3Fm1gbDawbEtU7CMtt/JVxEw01Kh7RAmoBE4+u7eycYv38bRivAZbdHBtPrwOHAAAAAElFTkSuQmCC'; export type InviteInfo = { @@ -29,13 +22,12 @@ export class WorkspaceService { private readonly logger = new Logger(WorkspaceService.name); constructor( - private readonly blobStorage: WorkspaceBlobStorage, private readonly cache: Cache, - private readonly doc: DocReader, - private readonly mailer: MailService, private readonly models: Models, private readonly url: URLHelper, - private readonly config: Config + private readonly doc: DocReader, + private readonly blobStorage: WorkspaceBlobStorage, + private readonly mailer: Mailer ) {} async getInviteInfo(inviteId: string): Promise { @@ -62,7 +54,7 @@ export class WorkspaceService { async getWorkspaceInfo(workspaceId: string) { const workspaceContent = await this.doc.getWorkspaceContent(workspaceId); - let avatar = defaultWorkspaceAvatar; + let avatar = DEFAULT_WORKSPACE_AVATAR; if (workspaceContent?.avatarKey) { const avatarBlob = await this.blobStorage.get( workspaceId, @@ -81,70 +73,58 @@ export class WorkspaceService { }; } - private async getInviteeEmailTarget(inviteId: string) { - const { workspaceId, inviteeUserId } = await this.getInviteInfo(inviteId); - if (!inviteeUserId) { - this.logger.error(`Invitee user not found for inviteId: ${inviteId}`); - return; - } - const workspace = await this.getWorkspaceInfo(workspaceId); - const invitee = await this.models.user.getWorkspaceUser(inviteeUserId); - if (!invitee) { - this.logger.error( - `Invitee user not found in workspace: ${workspaceId}, userId: ${inviteeUserId}` - ); - return; - } - - return { - email: invitee.email, - workspace, - }; - } - async sendAcceptedEmail(inviteId: string) { const { workspaceId, inviterUserId, inviteeUserId } = await this.getInviteInfo(inviteId); - const workspace = await this.getWorkspaceInfo(workspaceId); - const invitee = inviteeUserId - ? await this.models.user.getWorkspaceUser(inviteeUserId) - : null; + const inviter = inviterUserId ? await this.models.user.getWorkspaceUser(inviterUserId) : await this.models.workspaceUser.getOwner(workspaceId); - if (!inviter || !invitee) { - this.logger.error( + if (!inviter || !inviteeUserId) { + this.logger.warn( `Inviter or invitee user not found for inviteId: ${inviteId}` ); return false; } - await this.mailer.sendMemberAcceptedEmail(inviter.email, { - user: invitee, - workspace, + return await this.mailer.send({ + name: 'MemberAccepted', + to: inviter.email, + props: { + user: { + $$userId: inviteeUserId, + }, + workspace: { + $$workspaceId: workspaceId, + }, + }, }); - return true; } - async sendInviteEmail(inviteId: string) { - const target = await this.getInviteeEmailTarget(inviteId); - - if (!target) { - return; - } - - const owner = await this.models.workspaceUser.getOwner(target.workspace.id); - - const inviteUrl = this.url.link(`/invite/${inviteId}`); - if (this.config.node.dev) { - // make it easier to test in dev mode - this.logger.debug(`Invite link: ${inviteUrl}`); - } - await this.mailer.sendMemberInviteMail(target.email, { - workspace: target.workspace, - user: owner, - url: inviteUrl, + async sendInviteEmail({ + workspaceId, + inviteeEmail, + inviterUserId, + inviteId, + }: { + inviterUserId: string; + inviteeEmail: string; + inviteId: string; + workspaceId: string; + }) { + return await this.mailer.send({ + name: 'MemberInvitation', + to: inviteeEmail, + props: { + workspace: { + $$workspaceId: workspaceId, + }, + user: { + $$userId: inviterUserId, + }, + url: this.url.link(`/invite/${inviteId}`), + }, }); } @@ -154,23 +134,37 @@ export class WorkspaceService { } async sendTeamWorkspaceUpgradedEmail(workspaceId: string) { - const workspace = await this.getWorkspaceInfo(workspaceId); const owner = await this.models.workspaceUser.getOwner(workspaceId); const admins = await this.models.workspaceUser.getAdmins(workspaceId); - await this.mailer.sendTeamWorkspaceUpgradedEmail(owner.email, { - workspace, - isOwner: true, - url: this.url.link(`/workspace/${workspaceId}`), + const link = this.url.link(`/workspace/${workspaceId}`); + await this.mailer.send({ + name: 'TeamWorkspaceUpgraded', + to: owner.email, + props: { + workspace: { + $$workspaceId: workspaceId, + }, + isOwner: true, + url: link, + }, }); - for (const user of admins) { - await this.mailer.sendTeamWorkspaceUpgradedEmail(user.email, { - workspace, - isOwner: false, - url: this.url.link(`/workspace/${workspaceId}`), - }); - } + await Promise.allSettled( + admins.map(async user => { + await this.mailer.send({ + name: 'TeamWorkspaceUpgraded', + to: user.email, + props: { + workspace: { + $$workspaceId: workspaceId, + }, + isOwner: false, + url: link, + }, + }); + }) + ); } async sendReviewRequestedEmail(inviteId: string) { @@ -180,46 +174,63 @@ export class WorkspaceService { return; } - const invitee = await this.models.user.getWorkspaceUser(inviteeUserId); - if (!invitee) { - this.logger.error( - `Invitee user not found for inviteId: ${inviteId}, userId: ${inviteeUserId}` - ); - return; - } - - const workspace = await this.getWorkspaceInfo(workspaceId); const owner = await this.models.workspaceUser.getOwner(workspaceId); - const admin = await this.models.workspaceUser.getAdmins(workspaceId); + const admins = await this.models.workspaceUser.getAdmins(workspaceId); - for (const user of [owner, ...admin]) { - await this.mailer.sendLinkInvitationReviewRequestMail(user.email, { - workspace, - user: invitee, - url: this.url.link(`/workspace/${workspace.id}`), - }); - } + await Promise.allSettled( + [owner, ...admins].map(async receiver => { + await this.mailer.send({ + name: 'LinkInvitationReviewRequest', + to: receiver.email, + props: { + user: { + $$userId: inviteeUserId, + }, + workspace: { + $$workspaceId: workspaceId, + }, + url: this.url.link(`/workspace/${workspaceId}`), + }, + }); + }) + ); } async sendReviewApproveEmail(inviteId: string) { - const target = await this.getInviteeEmailTarget(inviteId); - if (!target) return; + const invitation = await this.models.workspaceUser.getById(inviteId); + if (!invitation) { + this.logger.warn(`Invitation not found for inviteId: ${inviteId}`); + return; + } - await this.mailer.sendLinkInvitationApproveMail(target.email, { - workspace: target.workspace, - url: this.url.link(`/workspace/${target.workspace.id}`), + const user = await this.models.user.getWorkspaceUser(invitation.userId); + + if (!user) { + this.logger.warn(`Invitee user not found for inviteId: ${inviteId}`); + return; + } + + await this.mailer.send({ + name: 'LinkInvitationApprove', + to: user.email, + props: { + workspace: { + $$workspaceId: invitation.workspaceId, + }, + url: this.url.link(`/workspace/${invitation.workspaceId}`), + }, }); } - async sendReviewDeclinedEmail( - email: string | undefined, - workspaceId: string - ) { - if (!email) return; - - const workspace = await this.getWorkspaceInfo(workspaceId); - await this.mailer.sendLinkInvitationDeclineMail(email, { - workspace, + async sendReviewDeclinedEmail(email: string, workspaceId: string) { + await this.mailer.send({ + name: 'LinkInvitationDecline', + to: email, + props: { + workspace: { + $$workspaceId: workspaceId, + }, + }, }); } @@ -228,43 +239,75 @@ export class WorkspaceService { ws: { id: string; role: WorkspaceRole } ) { const user = await this.models.user.getWorkspaceUser(userId); - if (!user) throw new UserNotFound(); - - const workspace = await this.getWorkspaceInfo(ws.id); + if (!user) { + this.logger.warn( + `User not found for seeding role changed email: ${userId}` + ); + return; + } if (ws.role === WorkspaceRole.Admin) { - await this.mailer.sendTeamBecomeAdminMail(user.email, { - workspace, - url: this.url.link(`/workspace/${workspace.id}`), + await this.mailer.send({ + name: 'TeamBecomeAdmin', + to: user.email, + props: { + workspace: { + $$workspaceId: ws.id, + }, + url: this.url.link(`/workspace/${ws.id}`), + }, }); } else { - await this.mailer.sendTeamBecomeCollaboratorMail(user.email, { - workspace, - url: this.url.link(`/workspace/${workspace.id}`), + await this.mailer.send({ + name: 'TeamBecomeCollaborator', + to: user.email, + props: { + workspace: { + $$workspaceId: ws.id, + }, + url: this.url.link(`/workspace/${ws.id}`), + }, }); } } async sendOwnershipTransferredEmail(email: string, ws: { id: string }) { - const workspace = await this.getWorkspaceInfo(ws.id); - await this.mailer.sendOwnershipTransferredMail(email, { workspace }); + await this.mailer.send({ + name: 'OwnershipTransferred', + to: email, + props: { + workspace: { + $$workspaceId: ws.id, + }, + }, + }); } async sendOwnershipReceivedEmail(email: string, ws: { id: string }) { - const workspace = await this.getWorkspaceInfo(ws.id); - await this.mailer.sendOwnershipReceivedMail(email, { workspace }); + await this.mailer.send({ + name: 'OwnershipReceived', + to: email, + props: { + workspace: { + $$workspaceId: ws.id, + }, + }, + }); } - @OnEvent('workspace.members.leave') - async onMemberLeave({ - user, - workspaceId, - }: Events['workspace.members.leave']) { - const workspace = await this.getWorkspaceInfo(workspaceId); + async sendLeaveEmail(workspaceId: string, userId: string) { const owner = await this.models.workspaceUser.getOwner(workspaceId); - await this.mailer.sendMemberLeaveEmail(owner.email, { - workspace, - user, + await this.mailer.send({ + name: 'MemberLeave', + to: owner.email, + props: { + workspace: { + $$workspaceId: workspaceId, + }, + user: { + $$userId: userId, + }, + }, }); } @@ -274,9 +317,21 @@ export class WorkspaceService { workspaceId, }: Events['workspace.members.removed']) { const user = await this.models.user.get(userId); - if (!user) return; + if (!user) { + this.logger.warn( + `User not found for seeding member removed email: ${userId}` + ); + return; + } - const workspace = await this.getWorkspaceInfo(workspaceId); - await this.mailer.sendMemberRemovedMail(user.email, { workspace }); + await this.mailer.send({ + name: 'MemberRemoved', + to: user.email, + props: { + workspace: { + $$workspaceId: workspaceId, + }, + }, + }); } } diff --git a/packages/backend/server/src/core/workspaces/resolvers/team.ts b/packages/backend/server/src/core/workspaces/resolvers/team.ts index 2fbd3a1e47..e682eb2e3e 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/team.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/team.ts @@ -118,7 +118,12 @@ export class TeamWorkspaceResolver { // after user click the invite link, we can check again and reject if charge failed if (sendInviteMail) { try { - await this.workspaceService.sendInviteEmail(ret.inviteId); + await this.workspaceService.sendInviteEmail({ + workspaceId, + inviteeEmail: target.email, + inviterUserId: user.id, + inviteId: role.id, + }); ret.sentSuccess = true; } catch (e) { this.logger.warn( diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index 00aea3ec81..5541036ec2 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -493,7 +493,12 @@ export class WorkspaceResolver { if (sendInviteMail) { try { - await this.workspaceService.sendInviteEmail(role.id); + await this.workspaceService.sendInviteEmail({ + workspaceId, + inviteeEmail: email, + inviterUserId: user.id, + inviteId: role.id, + }); } catch (e) { await this.models.workspaceUser.delete(workspaceId, user.id); @@ -577,7 +582,7 @@ export class WorkspaceResolver { userId, workspaceId, }); - } else { + } else if (role.status === WorkspaceMemberStatus.Accepted) { this.event.emit('workspace.members.removed', { userId, workspaceId, @@ -688,13 +693,7 @@ export class WorkspaceResolver { await this.models.workspaceUser.delete(workspaceId, user.id); if (sendLeaveMail) { - this.event.emit('workspace.members.leave', { - workspaceId, - user: { - id: user.id, - email: user.email, - }, - }); + await this.workspaceService.sendLeaveEmail(workspaceId, user.id); } return true; diff --git a/packages/backend/server/src/mails/index.tsx b/packages/backend/server/src/mails/index.tsx index 3d91806573..4dc12c4828 100644 --- a/packages/backend/server/src/mails/index.tsx +++ b/packages/backend/server/src/mails/index.tsx @@ -63,121 +63,105 @@ function make>( }; } -// ================ User ================ -export const renderSignInMail = make(SignIn, 'Sign in to AFFiNE'); -export const renderSignUpMail = make( - SignUp, - 'Your AFFiNE account is waiting for you!' -); -export const renderSetPasswordMail = make( - SetPassword, - 'Set your AFFiNE password' -); -export const renderChangePasswordMail = make( - ChangePassword, - 'Modify your AFFiNE password' -); -export const renderVerifyEmailMail = make( - VerifyEmail, - 'Verify your email address' -); -export const renderChangeEmailMail = make( - ChangeEmail, - 'Change your email address' -); -export const renderVerifyChangeEmailMail = make( - VerifyChangeEmail, - 'Verify your new email address' -); -export const renderChangeEmailNotificationMail = make( - ChangeEmailNotification, - 'Account email address changed' -); +export const Renderers = { + //#region User + SignIn: make(SignIn, 'Sign in to AFFiNE'), + SignUp: make(SignUp, 'Your AFFiNE account is waiting for you!'), + SetPassword: make(SetPassword, 'Set your AFFiNE password'), + ChangePassword: make(ChangePassword, 'Modify your AFFiNE password'), + VerifyEmail: make(VerifyEmail, 'Verify your email address'), + ChangeEmail: make(ChangeEmail, 'Change your email address'), + VerifyChangeEmail: make(VerifyChangeEmail, 'Verify your new email address'), + EmailChanged: make(ChangeEmailNotification, 'Account email address changed'), + //#endregion -// ================ Workspace ================ -export const renderMemberInvitationMail = make( - Invitation, - props => `${props.user.email} invited you to join ${props.workspace.name}` -); -export const renderMemberAcceptedMail = make( - InvitationAccepted, - props => `${props.user.email} accepted your invitation` -); -export const renderMemberLeaveMail = make( - MemberLeave, - props => `${props.user.email} left ${props.workspace.name}` -); -export const renderLinkInvitationReviewRequestMail = make( - LinkInvitationReviewRequest, - props => `New request to join ${props.workspace.name}` -); -export const renderLinkInvitationApproveMail = make( - LinkInvitationApproved, - props => `Your request to join ${props.workspace.name} has been approved` -); -export const renderLinkInvitationDeclineMail = make( - LinkInvitationReviewDeclined, - props => `Your request to join ${props.workspace.name} was declined` -); -export const renderMemberRemovedMail = make( - MemberRemoved, - props => `You have been removed from ${props.workspace.name}` -); -export const renderOwnershipTransferredMail = make( - OwnershipTransferred, - props => `Your ownership of ${props.workspace.name} has been transferred` -); -export const renderOwnershipReceivedMail = make( - OwnershipReceived, - props => `You are now the owner of ${props.workspace.name}` -); + //#region Workspace + MemberInvitation: make( + Invitation, + props => `${props.user.email} invited you to join ${props.workspace.name}` + ), + MemberAccepted: make( + InvitationAccepted, + props => `${props.user.email} accepted your invitation` + ), + MemberLeave: make( + MemberLeave, + props => `${props.user.email} left ${props.workspace.name}` + ), + LinkInvitationReviewRequest: make( + LinkInvitationReviewRequest, + props => `New request to join ${props.workspace.name}` + ), + LinkInvitationApprove: make( + LinkInvitationApproved, + props => `Your request to join ${props.workspace.name} 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}` + ), + OwnershipTransferred: make( + OwnershipTransferred, + props => `Your ownership of ${props.workspace.name} has been transferred` + ), + OwnershipReceived: make( + OwnershipReceived, + props => `You are now the owner of ${props.workspace.name}` + ), + //#endregion -// ================ Team ================ -export const renderTeamWorkspaceUpgradedMail = make( - TeamWorkspaceUpgraded, - props => + //#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}` + ), + TeamBecomeCollaborator: make( + TeamBecomeCollaborator, + props => `Your role has been changed in ${props.workspace.name}` + ), + TeamDeleteIn24Hours: make( + TeamDeleteIn24Hours, + props => + `[Action Required] Final warning: Your workspace ${props.workspace.name} will be deleted in 24 hours` + ), + TeamDeleteInOneMonth: make( + TeamDeleteInOneMonth, + props => + `[Action Required] Important: Your workspace ${props.workspace.name} will be deleted soon` + ), + TeamWorkspaceDeleted: make( + TeamWorkspaceDeleted, + props => `Your workspace ${props.workspace.name} 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` + ), + //#endregion -export const renderTeamBecomeAdminMail = make( - TeamBecomeAdmin, - props => `You are now an admin of ${props.workspace.name}` -); + //#region License + TeamLicense: make( + TeamLicense, + 'Your AFFiNE Self-Hosted Team Workspace license is ready' + ), + //#endregion +} as const; -export const renderTeamBecomeCollaboratorMail = make( - TeamBecomeCollaborator, - props => `Your role has been changed in ${props.workspace.name}` -); -export const renderTeamDeleteIn24HoursMail = make( - TeamDeleteIn24Hours, - props => - `[Action Required] Final warning: Your workspace ${props.workspace.name} will be deleted in 24 hours` -); -export const renderTeamDeleteIn1MonthMail = make( - TeamDeleteInOneMonth, - props => - `[Action Required] Important: Your workspace ${props.workspace.name} will be deleted soon` -); - -export const renderTeamWorkspaceDeletedMail = make( - TeamWorkspaceDeleted, - props => `Your workspace ${props.workspace.name} has been deleted` -); - -export const renderTeamWorkspaceExpireSoonMail = make( - TeamExpireSoon, - props => - `[Action Required] Your ${props.workspace.name} team workspace will expire soon` -); - -export const renderTeamWorkspaceExpiredMail = make( - TeamExpired, - props => `Your ${props.workspace.name} team workspace has expired` -); - -export const renderTeamLicenseMail = make( - TeamLicense, - 'Your AFFiNE Self-Hosted Team Workspace license is ready' -); +export type MailName = keyof typeof Renderers; +export type MailProps = Parameters< + (typeof Renderers)[T] +>[0]; diff --git a/packages/backend/server/src/plugins/payment/index.ts b/packages/backend/server/src/plugins/payment/index.ts index 4504b7f233..cdcc9520cd 100644 --- a/packages/backend/server/src/plugins/payment/index.ts +++ b/packages/backend/server/src/plugins/payment/index.ts @@ -2,6 +2,7 @@ import './config'; import { ServerFeature } from '../../core/config'; import { FeatureModule } from '../../core/features'; +import { MailModule } from '../../core/mail'; import { PermissionModule } from '../../core/permission'; import { QuotaModule } from '../../core/quota'; import { UserModule } from '../../core/user'; @@ -33,6 +34,7 @@ import { StripeWebhook } from './webhook'; UserModule, PermissionModule, WorkspaceModule, + MailModule, ], providers: [ StripeProvider, diff --git a/packages/backend/server/src/plugins/payment/manager/selfhost.ts b/packages/backend/server/src/plugins/payment/manager/selfhost.ts index 917ce86219..6e0321428a 100644 --- a/packages/backend/server/src/plugins/payment/manager/selfhost.ts +++ b/packages/backend/server/src/plugins/payment/manager/selfhost.ts @@ -6,11 +6,8 @@ import { pick } from 'lodash-es'; import Stripe from 'stripe'; import { z } from 'zod'; -import { - MailService, - SubscriptionPlanNotFound, - URLHelper, -} from '../../../base'; +import { SubscriptionPlanNotFound, URLHelper } from '../../../base'; +import { Mailer } from '../../../core/mail'; import { KnownStripeInvoice, KnownStripePrice, @@ -49,7 +46,7 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager { stripe: Stripe, db: PrismaClient, private readonly url: URLHelper, - private readonly mailer: MailService + private readonly mailer: Mailer ) { super(stripe, db); } @@ -140,7 +137,11 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager { }), ]); - await this.mailer.sendTeamLicenseMail(userEmail, { license: key }); + await this.mailer.send({ + name: 'TeamLicense', + to: userEmail, + props: { license: key }, + }); return subscription; } else { diff --git a/packages/backend/server/tsconfig.json b/packages/backend/server/tsconfig.json index da980b0e2b..45ba2e7e23 100644 --- a/packages/backend/server/tsconfig.json +++ b/packages/backend/server/tsconfig.json @@ -12,7 +12,6 @@ }, "include": ["./src"], "references": [ - { "path": "../../../tests/kit" }, { "path": "../../../tools/cli" }, { "path": "../../../tools/utils" }, { "path": "../native" } diff --git a/tools/utils/src/workspace.gen.ts b/tools/utils/src/workspace.gen.ts index 3a14edf895..15e610d466 100644 --- a/tools/utils/src/workspace.gen.ts +++ b/tools/utils/src/workspace.gen.ts @@ -654,7 +654,6 @@ export const PackageList = [ location: 'packages/backend/server', name: '@affine/server', workspaceDependencies: [ - 'tests/kit', 'tools/cli', 'tools/utils', 'packages/backend/native', diff --git a/yarn.lock b/yarn.lock index 6c455411d1..52aa1e4103 100644 --- a/yarn.lock +++ b/yarn.lock @@ -842,7 +842,6 @@ __metadata: version: 0.0.0-use.local resolution: "@affine/server@workspace:packages/backend/server" dependencies: - "@affine-test/kit": "workspace:*" "@affine-tools/cli": "workspace:*" "@affine-tools/utils": "workspace:*" "@affine/server-native": "workspace:*"