1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-03 01:39:37 +02:00

Support variable in email subject/signature

This commit is contained in:
Chocobozzz 2025-06-17 10:25:29 +02:00
parent 614d906ca6
commit ce28c64750
No known key found for this signature in database
GPG key ID: 583A612D890159BE
14 changed files with 198 additions and 103 deletions

View file

@ -111,11 +111,44 @@
</div> </div>
</div> </div>
<div class="pt-two-cols">
<div class="title-col">
<h2 i18n>EMAIL</h2>
</div>
<ng-container formGroupName="email">
<div class="content-col">
<div class="form-group" formGroupName="subject">
<label i18n for="emailSubjectPrefix">Subject prefix</label>
<div class="form-group-description" i18n>Support <pre class="d-inline">{{ '{{instanceName}}' }}</pre> template variable</div>
<input
type="text" id="emailSubjectPrefix" class="form-control"
formControlName="prefix" [ngClass]="{ 'input-error': formErrors.email.subject.prefix }"
>
<div *ngIf="formErrors.email.subject.prefix" class="form-error" role="alert">{{ formErrors.email.subject.prefix }}</div>
</div>
<div class="form-group" formGroupName="body">
<label i18n for="emailBodySignature">Body signature</label>
<div class="form-group-description" i18n>Support <pre class="d-inline">{{ '{{instanceName}}' }}</pre> template variable</div>
<input
type="text" id="emailBodySignature" class="form-control"
formControlName="signature" [ngClass]="{ 'input-error': formErrors.email.body.signature }"
>
<div *ngIf="formErrors.email.body.signature" class="form-error" role="alert">{{ formErrors.email.body.signature }}</div>
</div>
</div>
</ng-container>
</div>
<div class="pt-two-cols mt-4"> <div class="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<div class="anchor" id="customizations"></div> <div class="anchor" id="customizations"></div>
<!-- customizations anchor --> <h2 i18n>ADVANCED</h2>
<h2 i18n>Advanced</h2>
<div i18n class="inner-form-description"> <div i18n class="inner-form-description">
Advanced modifications to your PeerTube platform if creating a plugin or a theme is overkill. Advanced modifications to your PeerTube platform if creating a plugin or a theme is overkill.
</div> </div>

View file

@ -38,6 +38,16 @@ type Form = {
}> }>
}> }>
email: FormGroup<{
subject: FormGroup<{
prefix: FormControl<string>
}>
body: FormGroup<{
signature: FormControl<string>
}>
}>
theme: FormGroup<{ theme: FormGroup<{
default: FormControl<string> default: FormControl<string>
@ -197,6 +207,14 @@ export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, Can
} }
} }
}, },
email: {
subject: {
prefix: null
},
body: {
signature: null
}
},
instance: { instance: {
customizations: { customizations: {
css: null, css: null,

View file

@ -115,12 +115,6 @@ smtp:
ca_file: null # Used for self signed certificates ca_file: null # Used for self signed certificates
from_address: 'admin@example.com' from_address: 'admin@example.com'
email:
body:
signature: 'PeerTube'
subject:
prefix: '[PeerTube]'
# From the project root directory # From the project root directory
storage: storage:
tmp: 'storage/tmp/' # Use to download data (imports etc), store uploaded files before and during processing... tmp: 'storage/tmp/' # Use to download data (imports etc), store uploaded files before and during processing...
@ -1136,3 +1130,11 @@ defaults:
player: player:
# By default, playback starts automatically when opening a video # By default, playback starts automatically when opening a video
auto_play: true auto_play: true
email:
body:
# Support {{instanceName}} template variable
signature: ''
subject:
# Support {{instanceName}} template variable
prefix: '[{{instanceName}}] '

View file

@ -113,12 +113,6 @@ smtp:
ca_file: null # Used for self signed certificates ca_file: null # Used for self signed certificates
from_address: 'admin@example.com' from_address: 'admin@example.com'
email:
body:
signature: 'PeerTube'
subject:
prefix: '[PeerTube]'
# From the project root directory # From the project root directory
storage: storage:
tmp: '/var/www/peertube/storage/tmp/' # Use to download data (imports etc), store uploaded files before and during processing... tmp: '/var/www/peertube/storage/tmp/' # Use to download data (imports etc), store uploaded files before and during processing...
@ -1146,3 +1140,11 @@ defaults:
player: player:
# By default, playback starts automatically when opening a video # By default, playback starts automatically when opening a video
auto_play: true auto_play: true
email:
body:
# Support {{instanceName}} template variable
signature: ''
subject:
# Support {{instanceName}} template variable
prefix: '[{{instanceName}}] '

View file

@ -122,6 +122,16 @@ export interface CustomConfig {
email: string email: string
} }
email: {
body: {
signature: string
}
subject: {
prefix: string
}
}
contactForm: { contactForm: {
enabled: boolean enabled: boolean
} }

View file

@ -26,6 +26,10 @@ interface SendEmailDefaultLocalsOptions {
instanceName: string instanceName: string
text: string text: string
subject: string subject: string
fg: string
bg: string
primary: string
} }
interface SendEmailDefaultMessageOptions { interface SendEmailDefaultMessageOptions {
@ -42,7 +46,7 @@ export type SendEmailDefaultOptions = {
locals: SendEmailDefaultLocalsOptions & { locals: SendEmailDefaultLocalsOptions & {
WEBSERVER: any WEBSERVER: any
EMAIL: any signature: string
} }
} }

View file

@ -154,6 +154,9 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
expect(data.defaults.p2p.embed.enabled).to.be.true expect(data.defaults.p2p.embed.enabled).to.be.true
expect(data.defaults.p2p.webapp.enabled).to.be.true expect(data.defaults.p2p.webapp.enabled).to.be.true
expect(data.defaults.player.autoPlay).to.be.true expect(data.defaults.player.autoPlay).to.be.true
expect(data.email.body.signature).to.equal('')
expect(data.email.subject.prefix).to.equal('[{{instanceName}}] ')
} }
function buildNewCustomConfig (server: PeerTubeServer): CustomConfig { function buildNewCustomConfig (server: PeerTubeServer): CustomConfig {
@ -449,6 +452,14 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig {
player: { player: {
autoPlay: false autoPlay: false
} }
},
email: {
body: {
signature: 'my signature'
},
subject: {
prefix: 'my prefix'
}
} }
} }
} }

View file

@ -9,6 +9,7 @@ import {
setAccessTokensToServers, setAccessTokensToServers,
waitJobs waitJobs
} from '@peertube/peertube-server-commands' } from '@peertube/peertube-server-commands'
import { expectStartWith } from '@tests/shared/checks.js'
import { MockSmtpServer } from '@tests/shared/mock-servers/index.js' import { MockSmtpServer } from '@tests/shared/mock-servers/index.js'
import { SQLCommand } from '@tests/shared/sql-command.js' import { SQLCommand } from '@tests/shared/sql-command.js'
import { expect } from 'chai' import { expect } from 'chai'
@ -79,7 +80,6 @@ describe('Test emails', function () {
}) })
describe('When resetting user password', function () { describe('When resetting user password', function () {
it('Should ask to reset the password', async function () { it('Should ask to reset the password', async function () {
await server.users.askResetPassword({ email: 'user_1@example.com' }) await server.users.askResetPassword({ email: 'user_1@example.com' })
@ -140,7 +140,6 @@ describe('Test emails', function () {
}) })
describe('When creating a user without password', function () { describe('When creating a user without password', function () {
it('Should send a create password email', async function () { it('Should send a create password email', async function () {
await server.users.create({ username: 'create_password', password: '' }) await server.users.create({ username: 'create_password', password: '' })
@ -193,7 +192,6 @@ describe('Test emails', function () {
}) })
describe('When creating an abuse', function () { describe('When creating an abuse', function () {
it('Should send the notification email', async function () { it('Should send the notification email', async function () {
const reason = 'my super bad reason' const reason = 'my super bad reason'
await server.abuses.report({ token: userAccessToken, videoId, reason }) await server.abuses.report({ token: userAccessToken, videoId, reason })
@ -212,7 +210,6 @@ describe('Test emails', function () {
}) })
describe('When blocking/unblocking user', function () { describe('When blocking/unblocking user', function () {
it('Should send the notification email when blocking a user', async function () { it('Should send the notification email when blocking a user', async function () {
const reason = 'my super bad reason' const reason = 'my super bad reason'
await server.users.banUser({ userId, reason }) await server.users.banUser({ userId, reason })
@ -286,7 +283,6 @@ describe('Test emails', function () {
}) })
describe('When verifying a user email', function () { describe('When verifying a user email', function () {
it('Should fail with wrong capitalization when multiple users with similar email exists', async function () { it('Should fail with wrong capitalization when multiple users with similar email exists', async function () {
await server.users.askSendVerifyEmail({ await server.users.askSendVerifyEmail({
email: similarUsers[0].username.toUpperCase(), email: similarUsers[0].username.toUpperCase(),
@ -388,6 +384,34 @@ describe('Test emails', function () {
}) })
}) })
describe('Email config', function () {
it('Should configure email subject prefix and body signature', async function () {
await server.config.updateExistingConfig({
newConfig: {
instance: {
name: 'My tube'
},
email: {
subject: {
prefix: 'My custom prefix {{instanceName}}'
},
body: {
signature: 'My custom signature {{instanceName}}'
}
}
}
})
await server.users.banUser({ userId })
await waitJobs(server)
const email = emails[emails.length - 1]
expectStartWith(email['subject'], 'My custom prefix My tube')
expect(email['text']).to.contain('My custom signature My tube')
})
})
after(async function () { after(async function () {
MockSmtpServer.Instance.kill() MockSmtpServer.Instance.kill()

View file

@ -3,8 +3,6 @@
1. body tag: for most email clients 1. body tag: for most email clients
2. center tag: for Gmail and Inbox mobile apps and web versions of Gmail, GSuite, Inbox, Yahoo, AOL, Libero, Comcast, freenet, Mail.ru, Orange.fr 2. center tag: for Gmail and Inbox mobile apps and web versions of Gmail, GSuite, Inbox, Yahoo, AOL, Libero, Comcast, freenet, Mail.ru, Orange.fr
3. mso conditional: For Windows 10 Mail 3. mso conditional: For Windows 10 Mail
- var backgroundColor = "#fff";
- var mainColor = "#f2690d";
doctype html doctype html
head head
// This template is heavily adapted from the Cerberus Fluid template. Kudos to them! // This template is heavily adapted from the Cerberus Fluid template. Kudos to them!
@ -74,15 +72,15 @@ head
img { img {
-ms-interpolation-mode:bicubic; -ms-interpolation-mode:bicubic;
} }
/* What it does: Prevents Windows 10 Mail from underlining links despite inline CSS. Styles for underlined links should be inline. */
a { a {
text-decoration: none; color: #{fg};
} }
a:not(.nocolor) { a:not(.no-color) {
color: #{mainColor}; font-weight: 600;
} text-decoration: underline;
a.nocolor { text-decoration-color: #{primary};
color: inherit !important; text-underline-offset: 0.25em;
text-decoration-thickness: 0.15em;
} }
/* What it does: A work-around for email clients meddling in triggered links. */ /* What it does: A work-around for email clients meddling in triggered links. */
a[x-apple-data-detectors], /* iOS */ a[x-apple-data-detectors], /* iOS */
@ -135,33 +133,13 @@ head
style. style.
blockquote { blockquote {
margin-left: 0; margin-left: 0;
padding-left: 20px; padding-left: 10px;
border-left: 2px solid #f2690d; border-left: 2px solid #{primary};
} }
//- CSS for PeerTube : END //- CSS for PeerTube : END
//- Progressive Enhancements : BEGIN
style.
/* What it does: Hover styles for buttons */
.button-td,
.button-a {
transition: all 100ms ease-in;
}
.button-td-primary:hover,
.button-a-primary:hover {
background: #555555 !important;
border-color: #555555 !important;
}
/* Media Queries */
@media screen and (max-width: 600px) {
/* What it does: Adjust typography on small screens to improve readability */
.email-container p {
font-size: 17px !important;
}
}
//- Progressive Enhancements : END
body(width="100%" style="margin: 0; padding: 0 !important; mso-line-height-rule: exactly; background-color: #{backgroundColor};") body(width="100%" style="margin: 0; padding: 0 !important; mso-line-height-rule: exactly; color: #{fg}; background-color: #{bg};")
center(role='article' aria-roledescription='email' lang='en' style='width: 100%; background-color: #{backgroundColor};') center(role='article' aria-roledescription='email' lang='en' style='width: 100%; background-color: #{bg};')
//if mso | IE //if mso | IE
table(role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%' style='background-color: #fff;') table(role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%' style='background-color: #fff;')
tr tr
@ -190,16 +168,16 @@ body(width="100%" style="margin: 0; padding: 0 !important; mso-line-height-rule:
table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style='margin: auto;') table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style='margin: auto;')
//- 1 Column Text + Button : BEGIN //- 1 Column Text + Button : BEGIN
tr tr
td(style='background-color: #ffffff;') td
table(role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%') table(role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%')
tr tr
td(style='padding: 20px; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #555555;') td(style='padding: 20px; font-family: sans-serif; font-size: 15px; line-height: 1.5')
table(role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%") table(role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%")
tr tr
td(width="40px") td(width="40px")
img(src=`${WEBSERVER.URL}/client/assets/images/icons/icon-192x192.png` width="auto" height="30px" alt="" border="0" style="height: 30px; background: #ffffff; font-family: sans-serif; font-size: 15px; line-height: 15px; color: #555555;") img(src=`${WEBSERVER.URL}/client/assets/images/icons/icon-192x192.png` width="auto" height="30px" alt="" border="0" style="height: 30px; font-family: sans-serif; font-size: 15px; line-height: 15px;")
td td
h1(style='margin: 10px 0 10px 0; font-family: sans-serif; font-size: 25px; line-height: 30px; color: #333333; font-weight: normal;') h1(style='margin: 10px 0 10px 0; font-family: sans-serif; font-size: 25px; line-height: 30px; font-weight: normal;')
block title block title
if title if title
| #{title} | #{title}
@ -213,8 +191,8 @@ body(width="100%" style="margin: 0; padding: 0 !important; mso-line-height-rule:
//- Button : BEGIN //- Button : BEGIN
table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' style='margin: auto;') table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' style='margin: auto;')
tr tr
td.button-td.button-td-primary(style='border-radius: 4px; background: #222222;') td(style=`border-radius: 4px; background: ${primary};`)
a.button-a.button-a-primary(href=action.url style='background: #222222; border: 1px solid #000000; font-family: sans-serif; font-size: 15px; line-height: 15px; text-decoration: none; padding: 13px 17px; color: #ffffff; display: block; border-radius: 4px;') #{action.text} a.no-color(href=action.url style=`background: ${primary}; font-family: sans-serif; font-size: 15px; line-height: 15px; text-decoration: none; padding: 13px 17px; display: block; border-radius: 4px; font-weight: bold;`) #{action.text}
//- Button : END //- Button : END
//- 1 Column Text + Button : END //- 1 Column Text + Button : END
//- Clear Spacer : BEGIN //- Clear Spacer : BEGIN
@ -227,32 +205,15 @@ body(width="100%" style="margin: 0; padding: 0 !important; mso-line-height-rule:
unless hideNotificationPreferencesLink unless hideNotificationPreferencesLink
table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style='margin: auto;') table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style='margin: auto;')
tr tr
td(style='padding: 20px; padding-bottom: 0px; font-family: sans-serif; font-size: 12px; line-height: 15px; text-align: center; color: #888888;') td(style='padding: 20px; padding-bottom: 0px; font-family: sans-serif; font-size: 12px; text-align: center;')
webversion webversion
a.nocolor(href=`${WEBSERVER.URL}/my-account/notifications` style='color: #cccccc; font-weight: bold;') View in your notifications a.no-color(href=`${WEBSERVER.URL}/my-account/notifications` style='font-weight: bold;') View in your notifications
br br
tr tr
td(style='padding: 20px; padding-top: 10px; font-family: sans-serif; font-size: 12px; line-height: 15px; text-align: center; color: #888888;') td(style='padding: 20px; padding-top: 10px; font-family: sans-serif; font-size: 12px; text-align: center;')
unsubscribe unsubscribe
a.nocolor(href=`${WEBSERVER.URL}/my-account/settings#notifications` style='color: #888888;') Manage your notification preferences in your profile a.no-color(href=`${WEBSERVER.URL}/my-account/settings#notifications`) Manage your notification preferences in your profile
br br
//- Email Footer : END //- Email Footer : END
//if mso //if mso
//- Full Bleed Background Section : BEGIN
table(role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style=`background-color: ${mainColor};`)
tr
td
.email-container(align='center' style='max-width: 600px; margin: auto;')
//if mso
table(role='presentation' cellspacing='0' cellpadding='0' border='0' width='600' align='center')
tr
td
table(role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%')
tr
td(style='padding: 20px; text-align: left; font-family: sans-serif; font-size: 12px; line-height: 20px; color: #ffffff;')
table(role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%")
tr
td(valign="top") #[a(href="https://github.com/Chocobozzz/PeerTube" style="color: white !important") PeerTube © 2015-#{new Date().getFullYear()}] #[a(href="https://github.com/Chocobozzz/PeerTube/blob/master/CREDITS.md" style="color: white !important") PeerTube Contributors]
//if mso
//- Full Bleed Background Section : END
//if mso | IE //if mso | IE

View file

@ -3,9 +3,9 @@ extends base
block body block body
if username if username
p Hi #{username}, p Hi #{username},
else
p Hi,
block content block content
p
| Cheers,#[br] if signature
| #{EMAIL.BODY.SIGNATURE} p
| #{signature}

View file

@ -528,6 +528,15 @@ function customConfig (): CustomConfig {
player: { player: {
autoPlay: CONFIG.DEFAULTS.PLAYER.AUTO_PLAY autoPlay: CONFIG.DEFAULTS.PLAYER.AUTO_PLAY
} }
},
email: {
body: {
signature: CONFIG.EMAIL.BODY.SIGNATURE
},
subject: {
prefix: CONFIG.EMAIL.SUBJECT.PREFIX
}
} }
} }
} }

View file

@ -65,14 +65,6 @@ const CONFIG = {
CA_FILE: config.get<string>('smtp.ca_file'), CA_FILE: config.get<string>('smtp.ca_file'),
FROM_ADDRESS: config.get<string>('smtp.from_address') FROM_ADDRESS: config.get<string>('smtp.from_address')
}, },
EMAIL: {
BODY: {
SIGNATURE: config.get<string>('email.body.signature')
},
SUBJECT: {
PREFIX: config.get<string>('email.subject.prefix') + ' '
}
},
NSFW_FLAGS_SETTINGS: { NSFW_FLAGS_SETTINGS: {
ENABLED: config.get<boolean>('nsfw_flags_settings.enabled') ENABLED: config.get<boolean>('nsfw_flags_settings.enabled')
@ -1081,6 +1073,18 @@ const CONFIG = {
get ENABLED () { get ENABLED () {
return config.get<boolean>('storyboards.enabled') return config.get<boolean>('storyboards.enabled')
} }
},
EMAIL: {
BODY: {
get SIGNATURE () {
return config.get<string>('email.body.signature')
}
},
SUBJECT: {
get PREFIX () {
return config.get<string>('email.subject.prefix')
}
}
} }
} }

View file

@ -14,7 +14,7 @@ import { MRegistration, MUser, MUserExport, MUserImport } from '../types/models/
import { JobQueue } from './job-queue/index.js' import { JobQueue } from './job-queue/index.js'
import { Hooks } from './plugins/hooks.js' import { Hooks } from './plugins/hooks.js'
class Emailer { export class Emailer {
private static instance: Emailer private static instance: Emailer
private initialized = false private initialized = false
private transporter: Transporter private transporter: Transporter
@ -299,7 +299,7 @@ class Emailer {
from: `"${fromDisplayName}" <${CONFIG.SMTP.FROM_ADDRESS}>` from: `"${fromDisplayName}" <${CONFIG.SMTP.FROM_ADDRESS}>`
}, },
transport: this.transporter, transport: this.transporter,
subjectPrefix: CONFIG.EMAIL.SUBJECT.PREFIX subjectPrefix: this.buildSubjectPrefix()
}) })
const subject = await Hooks.wrapObject( const subject = await Hooks.wrapObject(
options.subject, options.subject,
@ -322,10 +322,13 @@ class Emailer {
}, },
locals: { // default variables available in all templates locals: { // default variables available in all templates
WEBSERVER, WEBSERVER,
EMAIL: CONFIG.EMAIL,
instanceName: CONFIG.INSTANCE.NAME, instanceName: CONFIG.INSTANCE.NAME,
text: options.text, text: options.text,
subject subject,
signature: this.buildSignature(),
fg: CONFIG.THEME.CUSTOMIZATION.FOREGROUND_COLOR || '#000',
bg: CONFIG.THEME.CUSTOMIZATION.BACKGROUND_COLOR || '#fff',
primary: CONFIG.THEME.CUSTOMIZATION.PRIMARY_COLOR || '#FF8F37'
} }
} }
@ -396,13 +399,24 @@ class Emailer {
}) })
} }
private buildSubjectPrefix () {
let prefix = CONFIG.EMAIL.SUBJECT.PREFIX
if (!prefix) return prefix
prefix = prefix.replace(/{{instanceName}}/g, CONFIG.INSTANCE.NAME)
if (prefix.endsWith(' ')) return prefix
return prefix + ' '
}
private buildSignature () {
const signature = CONFIG.EMAIL.BODY.SIGNATURE
if (!signature) return signature
return signature.replace(/{{instanceName}}/g, CONFIG.INSTANCE.NAME)
}
static get Instance () { static get Instance () {
return this.instance || (this.instance = new this()) return this.instance || (this.instance = new this())
} }
} }
// ---------------------------------------------------------------------------
export {
Emailer
}

View file

@ -143,6 +143,9 @@ const customConfigUpdateValidator = [
body('defaults.p2p.embed.enabled').isBoolean(), body('defaults.p2p.embed.enabled').isBoolean(),
body('defaults.player.autoPlay').isBoolean(), body('defaults.player.autoPlay').isBoolean(),
body('email.body.signature').exists(),
body('email.subject.prefix').exists(),
(req: express.Request, res: express.Response, next: express.NextFunction) => { (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return if (areValidationErrors(req, res)) return
if (!checkInvalidConfigIfEmailDisabled(req.body, res)) return if (!checkInvalidConfigIfEmailDisabled(req.body, res)) return