diff --git a/packages/models/src/plugins/server/server-hook.model.ts b/packages/models/src/plugins/server/server-hook.model.ts index 1d4f7917a..f58fc6a81 100644 --- a/packages/models/src/plugins/server/server-hook.model.ts +++ b/packages/models/src/plugins/server/server-hook.model.ts @@ -145,7 +145,11 @@ export const serverFilterHookObject = { // Peertube >= 7.1 'filter:oauth.password-grant.get-user.params': true, 'filter:api.email-verification.ask-send-verify-email.body': true, - 'filter:api.users.ask-reset-password.body': true + 'filter:api.users.ask-reset-password.body': true, + + // Peertube >= 7.2 + 'filter:email.subject.result': true, + 'filter:email.template-path.result': true } export type ServerFilterHookName = keyof typeof serverFilterHookObject diff --git a/packages/tests/fixtures/peertube-plugin-test/emails/password-reset.pug b/packages/tests/fixtures/peertube-plugin-test/emails/password-reset.pug new file mode 100644 index 000000000..ddc952024 --- /dev/null +++ b/packages/tests/fixtures/peertube-plugin-test/emails/password-reset.pug @@ -0,0 +1 @@ +p Custom password reset email \ No newline at end of file diff --git a/packages/tests/fixtures/peertube-plugin-test/main.js b/packages/tests/fixtures/peertube-plugin-test/main.js index e031d2b1c..11669d326 100644 --- a/packages/tests/fixtures/peertube-plugin-test/main.js +++ b/packages/tests/fixtures/peertube-plugin-test/main.js @@ -1,3 +1,5 @@ +const path = require('path') + async function register ({ registerHook, registerSetting, settingsManager, storageManager, peertubeHelpers }) { { registerSetting({ @@ -445,6 +447,28 @@ async function register ({ registerHook, registerSetting, settingsManager, stora } }) + registerHook({ + target: 'filter:email.template-path.result', + handler: (templatePath, { view }) => { + if (view === 'password-reset/html') { + return path.join(__dirname, 'emails', 'password-reset.pug') + } + + return templatePath + } + }) + + registerHook({ + target: 'filter:email.subject.result', + handler: (subject, { template }) => { + if (template === 'password-reset') { + return 'Custom subject' + } + + return subject + } + }) + // Upload/import/live attributes for (const target of [ 'filter:api.video.upload.video-attribute.result', diff --git a/packages/tests/src/plugins/filter-hooks.ts b/packages/tests/src/plugins/filter-hooks.ts index 497fbcc9f..8cca71909 100644 --- a/packages/tests/src/plugins/filter-hooks.ts +++ b/packages/tests/src/plugins/filter-hooks.ts @@ -11,6 +11,7 @@ import { VideoPrivacy } from '@peertube/peertube-models' import { + ConfigCommand, PeerTubeServer, PluginsCommand, cleanupTests, @@ -26,6 +27,7 @@ import { import { expectEndWith } from '@tests/shared/checks.js' import { expect } from 'chai' import { FIXTURE_URLS } from '../shared/fixture-urls.js' +import { MockSmtpServer } from '@tests/shared/mock-servers/index.js' describe('Test plugin filter hooks', function () { let servers: PeerTubeServer[] @@ -33,11 +35,13 @@ describe('Test plugin filter hooks', function () { let threadId: number let videoPlaylistUUID: string let importUserToken: string + const emails: object[] = [] before(async function () { this.timeout(120000) - servers = await createMultipleServers(2) + const emailPort = await MockSmtpServer.Instance.collectEmails(emails) + servers = await createMultipleServers(2, ConfigCommand.getEmailOverrideConfig(emailPort)) await setAccessTokensToServers(servers) await setDefaultVideoChannel(servers) await doubleFollow(servers[0], servers[1]) @@ -931,6 +935,40 @@ describe('Test plugin filter hooks', function () { }) }) + describe('Emails', function () { + let server: PeerTubeServer + const emailAddress = 'plugin-admin@example.com' + + before(async function () { + server = servers[0] + await server.users.create({ username: 'plugin-admin', email: emailAddress }) + }) + + it('Should run filter:email.template-path.result', async function () { + const preEmailCount = emails.length + await server.users.askResetPassword({ email: emailAddress }) + + await waitJobs(server) + expect(emails).to.have.lengthOf(preEmailCount + 1) + + const email = emails[preEmailCount] + + expect(email['html']).to.contain('Custom password reset email') + }) + + it('Should run filter:email.subject.result', async function () { + const preEmailCount = emails.length + await server.users.askResetPassword({ email: emailAddress }) + + await waitJobs(server) + expect(emails).to.have.lengthOf(preEmailCount + 1) + + const email = emails[preEmailCount] + + expect(email['subject']).to.contain('Custom subject') + }) + }) + after(async function () { await cleanupTests(servers) }) diff --git a/server/core/lib/emailer.ts b/server/core/lib/emailer.ts index 035fcdb12..fe0b9fc05 100644 --- a/server/core/lib/emailer.ts +++ b/server/core/lib/emailer.ts @@ -6,11 +6,13 @@ import { readFileSync } from 'fs' import merge from 'lodash-es/merge.js' import { Transporter, createTransport } from 'nodemailer' import { join } from 'path' +import pug from 'pug' import { bunyanLogger, logger } from '../helpers/logger.js' import { CONFIG, isEmailEnabled } from '../initializers/config.js' import { WEBSERVER } from '../initializers/constants.js' import { MRegistration, MUser, MUserExport, MUserImport } from '../types/models/index.js' import { JobQueue } from './job-queue/index.js' +import { Hooks } from './plugins/hooks.js' class Emailer { @@ -260,15 +262,30 @@ class Emailer { { selector: 'a', options: { hideLinkHrefIfSameAsText: true } } ] }, + render: async (view: string, locals: Record) => { + if (view.split('/').pop() !== 'html') return undefined + + const templatePath = await Hooks.wrapObject( + join(root(), 'dist', 'core', 'assets', 'email-templates', view + '.pug'), + 'filter:email.template-path.result', + { view } + ) + const compiledTemplate = pug.compileFile(templatePath) + const html = await email.juiceResources(compiledTemplate(locals)) + + return html + }, message: { from: `"${fromDisplayName}" <${CONFIG.SMTP.FROM_ADDRESS}>` }, transport: this.transporter, - views: { - root: join(root(), 'dist', 'core', 'assets', 'email-templates') - }, subjectPrefix: CONFIG.EMAIL.SUBJECT.PREFIX }) + const subject = await Hooks.wrapObject( + options.subject, + 'filter:email.subject.result', + { template: 'template' in options ? options.template : undefined } + ) const toEmails = arrayify(options.to) @@ -280,7 +297,7 @@ class Emailer { message: { to, from: options.from, - subject: options.subject, + subject, replyTo: options.replyTo }, locals: { // default variables available in all templates @@ -288,7 +305,7 @@ class Emailer { EMAIL: CONFIG.EMAIL, instanceName: CONFIG.INSTANCE.NAME, text: options.text, - subject: options.subject + subject } }