Switch emails to pug templates and provide richer html/text-only versions

This commit is contained in:
Rigel Kent 2020-05-05 20:22:22 +02:00 committed by Rigel Kent
parent 91b8e675e2
commit df4c603dea
30 changed files with 1682 additions and 274 deletions

View File

@ -22,7 +22,7 @@
<th i18n>Follower handle</th> <th i18n>Follower handle</th>
<th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th> <th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
<th style="width: 100px;" i18n pSortableColumn="score">Score <p-sortIcon field="score"></p-sortIcon></th> <th style="width: 100px;" i18n pSortableColumn="score">Score <p-sortIcon field="score"></p-sortIcon></th>
<th style="width: 140px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
<th style="width: 100px;"></th> <th style="width: 100px;"></th>
</tr> </tr>
</ng-template> </ng-template>

View File

@ -25,7 +25,7 @@
<tr> <tr>
<th i18n>Host</th> <th i18n>Host</th>
<th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th> <th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
<th style="width: 140px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
<th style="width: 160px;" i18n pSortableColumn="redundancyAllowed">Redundancy allowed <p-sortIcon field="redundancyAllowed"></p-sortIcon></th> <th style="width: 160px;" i18n pSortableColumn="redundancyAllowed">Redundancy allowed <p-sortIcon field="redundancyAllowed"></p-sortIcon></th>
<th style="width: 100px;"></th> <th style="width: 100px;"></th>
</tr> </tr>

View File

@ -20,7 +20,7 @@
<ng-template pTemplate="header"> <ng-template pTemplate="header">
<tr> <tr>
<th style="width: 100%;" i18n>Account</th> <th style="width: 100%;" i18n>Account</th>
<th style="width: 140px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th> <th style="width: 150px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
<th style="width: 100px;"></th> <!-- column for action buttons --> <th style="width: 100px;"></th> <!-- column for action buttons -->
</tr> </tr>
</ng-template> </ng-template>

View File

@ -24,7 +24,7 @@
<ng-template pTemplate="header"> <ng-template pTemplate="header">
<tr> <tr>
<th style="width: 100%;" i18n>Instance</th> <th style="width: 100%;" i18n>Instance</th>
<th style="width: 140px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th> <th style="width: 150px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
<th style="width: 100px;"></th> <!-- column for action buttons --> <th style="width: 100px;"></th> <!-- column for action buttons -->
</tr> </tr>
</ng-template> </ng-template>

View File

@ -39,7 +39,7 @@
<th style="width: 40px;"></th> <th style="width: 40px;"></th>
<th style="width: 20%;" pResizableColumn i18n>Reporter</th> <th style="width: 20%;" pResizableColumn i18n>Reporter</th>
<th i18n>Video</th> <th i18n>Video</th>
<th style="width: 140px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
<th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th> <th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th>
<th style="width: 120px;"></th> <th style="width: 120px;"></th>
</tr> </tr>

View File

@ -24,7 +24,7 @@
<th i18n pSortableColumn="name">Video <p-sortIcon field="name"></p-sortIcon></th> <th i18n pSortableColumn="name">Video <p-sortIcon field="name"></p-sortIcon></th>
<th style="width: 100px;" i18n>Sensitive</th> <th style="width: 100px;" i18n>Sensitive</th>
<th style="width: 120px;" i18n>Unfederated</th> <th style="width: 120px;" i18n>Unfederated</th>
<th style="width: 140px;" i18n pSortableColumn="createdAt">Date <p-sortIcon field="createdAt"></p-sortIcon></th> <th style="width: 150px;" i18n pSortableColumn="createdAt">Date <p-sortIcon field="createdAt"></p-sortIcon></th>
<th style="width: 120px;"></th> <th style="width: 120px;"></th>
</tr> </tr>
</ng-template> </ng-template>

View File

@ -9,7 +9,7 @@
<p-table <p-table
[value]="users" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions" [value]="users" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
[sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" [resizableColumns]="true"
[(selection)]="selectedUsers" [(selection)]="selectedUsers"
[showCurrentPageReport]="true" i18n-currentPageReportTemplate [showCurrentPageReport]="true" i18n-currentPageReportTemplate
currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} users" currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} users"
@ -42,12 +42,12 @@
<p-tableHeaderCheckbox></p-tableHeaderCheckbox> <p-tableHeaderCheckbox></p-tableHeaderCheckbox>
</th> </th>
<th style="width: 40px"></th> <th style="width: 40px"></th>
<th i18n pSortableColumn="username">Username <p-sortIcon field="username"></p-sortIcon></th> <th pResizableColumn i18n pSortableColumn="username">Username <p-sortIcon field="username"></p-sortIcon></th>
<th i18n>Email</th> <th i18n>Email</th>
<th i18n pSortableColumn="videoQuotaUsed">Video quota <p-sortIcon field="videoQuotaUsed"></p-sortIcon></th> <th style="width: 140px;" i18n pSortableColumn="videoQuotaUsed">Video quota <p-sortIcon field="videoQuotaUsed"></p-sortIcon></th>
<th i18n>Role</th> <th style="width: 120px;" i18n>Role</th>
<th i18n>Auth plugin</th> <th style="width: 140px;" pResizableColumn i18n>Auth plugin</th>
<th i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
<th style="width: 50px;"></th> <th style="width: 50px;"></th>
</tr> </tr>
</ng-template> </ng-template>
@ -103,7 +103,7 @@
<ng-container *ngIf="user.pluginAuth">{{ user.pluginAuth }}</ng-container> <ng-container *ngIf="user.pluginAuth">{{ user.pluginAuth }}</ng-container>
</td> </td>
<td [title]="user.createdAt">{{ user.createdAt }}</td> <td [title]="user.createdAt">{{ user.createdAt | date: 'short' }}</td>
<td class="action-cell"> <td class="action-cell">
<my-user-moderation-dropdown *ngIf="!isInSelectionMode()" [user]="user" (userChanged)="onUserChanged()" (userDeleted)="onUserChanged()"> <my-user-moderation-dropdown *ngIf="!isInSelectionMode()" [user]="user" (userChanged)="onUserChanged()" (userDeleted)="onUserChanged()">

View File

@ -98,6 +98,7 @@
"cors": "^2.8.1", "cors": "^2.8.1",
"create-torrent": "^4.0.0", "create-torrent": "^4.0.0",
"deep-object-diff": "^1.1.0", "deep-object-diff": "^1.1.0",
"email-templates": "^7.0.4",
"express": "^4.12.4", "express": "^4.12.4",
"express-oauth-server": "^2.0.0", "express-oauth-server": "^2.0.0",
"express-rate-limit": "^5.0.0", "express-rate-limit": "^5.0.0",
@ -127,6 +128,7 @@
"pfeed": "1.1.11", "pfeed": "1.1.11",
"pg": "^7.4.1", "pg": "^7.4.1",
"prompt": "^1.0.0", "prompt": "^1.0.0",
"pug": "^2.0.4",
"redis": "^3.0.2", "redis": "^3.0.2",
"reflect-metadata": "^0.1.12", "reflect-metadata": "^0.1.12",
"request": "^2.81.0", "request": "^2.81.0",

View File

@ -1,5 +1,5 @@
import * as express from 'express' import * as express from 'express'
import { UserRight, VideoAbuseCreate, VideoAbuseState } from '../../../../shared' import { UserRight, VideoAbuseCreate, VideoAbuseState, VideoAbuse } from '../../../../shared'
import { logger } from '../../../helpers/logger' import { logger } from '../../../helpers/logger'
import { getFormattedObjects } from '../../../helpers/utils' import { getFormattedObjects } from '../../../helpers/utils'
import { sequelizeTypescript } from '../../../initializers/database' import { sequelizeTypescript } from '../../../initializers/database'
@ -24,6 +24,7 @@ import { Notifier } from '../../../lib/notifier'
import { sendVideoAbuse } from '../../../lib/activitypub/send/send-flag' import { sendVideoAbuse } from '../../../lib/activitypub/send/send-flag'
import { MVideoAbuseAccountVideo } from '../../../typings/models/video' import { MVideoAbuseAccountVideo } from '../../../typings/models/video'
import { getServerActor } from '@server/models/application/application' import { getServerActor } from '@server/models/application/application'
import { MAccountDefault } from '@server/typings/models'
const auditLogger = auditLoggerFactory('abuse') const auditLogger = auditLoggerFactory('abuse')
const abuseVideoRouter = express.Router() const abuseVideoRouter = express.Router()
@ -117,9 +118,11 @@ async function deleteVideoAbuse (req: express.Request, res: express.Response) {
async function reportVideoAbuse (req: express.Request, res: express.Response) { async function reportVideoAbuse (req: express.Request, res: express.Response) {
const videoInstance = res.locals.videoAll const videoInstance = res.locals.videoAll
const body: VideoAbuseCreate = req.body const body: VideoAbuseCreate = req.body
let reporterAccount: MAccountDefault
let videoAbuseJSON: VideoAbuse
const videoAbuse = await sequelizeTypescript.transaction(async t => { const videoAbuseInstance = await sequelizeTypescript.transaction(async t => {
const reporterAccount = await AccountModel.load(res.locals.oauth.token.User.Account.id, t) reporterAccount = await AccountModel.load(res.locals.oauth.token.User.Account.id, t)
const abuseToCreate = { const abuseToCreate = {
reporterAccountId: reporterAccount.id, reporterAccountId: reporterAccount.id,
@ -137,14 +140,19 @@ async function reportVideoAbuse (req: express.Request, res: express.Response) {
await sendVideoAbuse(reporterAccount.Actor, videoAbuseInstance, videoInstance, t) await sendVideoAbuse(reporterAccount.Actor, videoAbuseInstance, videoInstance, t)
} }
auditLogger.create(reporterAccount.Actor.getIdentifier(), new VideoAbuseAuditView(videoAbuseInstance.toFormattedJSON())) videoAbuseJSON = videoAbuseInstance.toFormattedJSON()
auditLogger.create(reporterAccount.Actor.getIdentifier(), new VideoAbuseAuditView(videoAbuseJSON))
return videoAbuseInstance return videoAbuseInstance
}) })
Notifier.Instance.notifyOnNewVideoAbuse(videoAbuse) Notifier.Instance.notifyOnNewVideoAbuse({
videoAbuse: videoAbuseJSON,
videoAbuseInstance,
reporter: reporterAccount.Actor.getIdentifier()
})
logger.info('Abuse report for video %s created.', videoInstance.name) logger.info('Abuse report for video %s created.', videoInstance.name)
return res.json({ videoAbuse: videoAbuse.toFormattedJSON() }).end() return res.json({ videoAbuseJSON }).end()
} }

View File

@ -8,7 +8,8 @@ import { getOrCreateVideoAndAccountAndChannel } from '../videos'
import { Notifier } from '../../notifier' import { Notifier } from '../../notifier'
import { getAPId } from '../../../helpers/activitypub' import { getAPId } from '../../../helpers/activitypub'
import { APProcessorOptions } from '../../../typings/activitypub-processor.model' import { APProcessorOptions } from '../../../typings/activitypub-processor.model'
import { MActorSignature, MVideoAbuseVideo } from '../../../typings/models' import { MActorSignature, MVideoAbuseAccountVideo } from '../../../typings/models'
import { AccountModel } from '@server/models/account/account'
async function processFlagActivity (options: APProcessorOptions<ActivityCreate | ActivityFlag>) { async function processFlagActivity (options: APProcessorOptions<ActivityCreate | ActivityFlag>) {
const { activity, byActor } = options const { activity, byActor } = options
@ -36,8 +37,9 @@ async function processCreateVideoAbuse (activity: ActivityCreate | ActivityFlag,
logger.debug('Reporting remote abuse for video %s.', getAPId(object)) logger.debug('Reporting remote abuse for video %s.', getAPId(object))
const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: object }) const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: object })
const reporterAccount = await sequelizeTypescript.transaction(async t => AccountModel.load(account.id, t))
const videoAbuse = await sequelizeTypescript.transaction(async t => { const videoAbuseInstance = await sequelizeTypescript.transaction(async t => {
const videoAbuseData = { const videoAbuseData = {
reporterAccountId: account.id, reporterAccountId: account.id,
reason: flag.content, reason: flag.content,
@ -45,15 +47,22 @@ async function processCreateVideoAbuse (activity: ActivityCreate | ActivityFlag,
state: VideoAbuseState.PENDING state: VideoAbuseState.PENDING
} }
const videoAbuseInstance = await VideoAbuseModel.create(videoAbuseData, { transaction: t }) as MVideoAbuseVideo const videoAbuseInstance: MVideoAbuseAccountVideo = await VideoAbuseModel.create(videoAbuseData, { transaction: t })
videoAbuseInstance.Video = video videoAbuseInstance.Video = video
videoAbuseInstance.Account = reporterAccount
logger.info('Remote abuse for video uuid %s created', flag.object) logger.info('Remote abuse for video uuid %s created', flag.object)
return videoAbuseInstance return videoAbuseInstance
}) })
Notifier.Instance.notifyOnNewVideoAbuse(videoAbuse) const videoAbuseJSON = videoAbuseInstance.toFormattedJSON()
Notifier.Instance.notifyOnNewVideoAbuse({
videoAbuse: videoAbuseJSON,
videoAbuseInstance,
reporter: reporterAccount.Actor.getIdentifier()
})
} catch (err) { } catch (err) {
logger.debug('Cannot process report of %s. (Maybe not a video abuse).', getAPId(object), { err }) logger.debug('Cannot process report of %s. (Maybe not a video abuse).', getAPId(object), { err })
} }

View File

@ -1,5 +1,5 @@
import { createTransport, Transporter } from 'nodemailer' import { createTransport, Transporter } from 'nodemailer'
import { isTestInstance } from '../helpers/core-utils' import { isTestInstance, root } from '../helpers/core-utils'
import { bunyanLogger, logger } from '../helpers/logger' import { bunyanLogger, logger } from '../helpers/logger'
import { CONFIG, isEmailEnabled } from '../initializers/config' import { CONFIG, isEmailEnabled } from '../initializers/config'
import { JobQueue } from './job-queue' import { JobQueue } from './job-queue'
@ -16,6 +16,12 @@ import {
import { MActorFollowActors, MActorFollowFull, MUser } from '../typings/models' import { MActorFollowActors, MActorFollowFull, MUser } from '../typings/models'
import { MVideoImport, MVideoImportVideo } from '@server/typings/models/video/video-import' import { MVideoImport, MVideoImportVideo } from '@server/typings/models/video/video-import'
import { EmailPayload } from '@shared/models' import { EmailPayload } from '@shared/models'
import { join } from 'path'
import { VideoAbuse } from '../../shared/models/videos'
import { SendEmailOptions } from '../../shared/models/server/emailer.model'
import { merge } from 'lodash'
import { VideoChannelModel } from '@server/models/video/video-channel'
const Email = require('email-templates')
class Emailer { class Emailer {
@ -105,37 +111,36 @@ class Emailer {
const channelName = video.VideoChannel.getDisplayName() const channelName = video.VideoChannel.getDisplayName()
const videoUrl = WEBSERVER.URL + video.getWatchStaticPath() const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
const text = 'Hi dear user,\n\n' +
`Your subscription ${channelName} just published a new video: ${video.name}` +
'\n\n' +
`You can view it on ${videoUrl} ` +
'\n\n' +
'Cheers,\n' +
`${CONFIG.EMAIL.BODY.SIGNATURE}`
const emailPayload: EmailPayload = { const emailPayload: EmailPayload = {
to, to,
subject: CONFIG.EMAIL.SUBJECT.PREFIX + channelName + ' just published a new video', subject: channelName + ' just published a new video',
text text: `Your subscription ${channelName} just published a new video: "${video.name}".`,
locals: {
title: 'New content ',
action: {
text: 'View video',
url: videoUrl
}
}
} }
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
} }
addNewFollowNotification (to: string[], actorFollow: MActorFollowFull, followType: 'account' | 'channel') { addNewFollowNotification (to: string[], actorFollow: MActorFollowFull, followType: 'account' | 'channel') {
const followerName = actorFollow.ActorFollower.Account.getDisplayName()
const followingName = (actorFollow.ActorFollowing.VideoChannel || actorFollow.ActorFollowing.Account).getDisplayName() const followingName = (actorFollow.ActorFollowing.VideoChannel || actorFollow.ActorFollowing.Account).getDisplayName()
const text = 'Hi dear user,\n\n' +
`Your ${followType} ${followingName} has a new subscriber: ${followerName}` +
'\n\n' +
'Cheers,\n' +
`${CONFIG.EMAIL.BODY.SIGNATURE}`
const emailPayload: EmailPayload = { const emailPayload: EmailPayload = {
template: 'follower-on-channel',
to, to,
subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'New follower on your channel ' + followingName, subject: `New follower on your channel ${followingName}`,
text locals: {
followerName: actorFollow.ActorFollower.Account.getDisplayName(),
followerUrl: actorFollow.ActorFollower.url,
followingName,
followingUrl: actorFollow.ActorFollowing.url,
followType
}
} }
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
@ -144,32 +149,28 @@ class Emailer {
addNewInstanceFollowerNotification (to: string[], actorFollow: MActorFollowActors) { addNewInstanceFollowerNotification (to: string[], actorFollow: MActorFollowActors) {
const awaitingApproval = actorFollow.state === 'pending' ? ' awaiting manual approval.' : '' const awaitingApproval = actorFollow.state === 'pending' ? ' awaiting manual approval.' : ''
const text = 'Hi dear admin,\n\n' +
`Your instance has a new follower: ${actorFollow.ActorFollower.url}${awaitingApproval}` +
'\n\n' +
'Cheers,\n' +
`${CONFIG.EMAIL.BODY.SIGNATURE}`
const emailPayload: EmailPayload = { const emailPayload: EmailPayload = {
to, to,
subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'New instance follower', subject: 'New instance follower',
text text: `Your instance has a new follower: ${actorFollow.ActorFollower.url}${awaitingApproval}.`,
locals: {
title: 'New instance follower',
action: {
text: 'Review followers',
url: WEBSERVER.URL + '/admin/follows/followers-list'
}
}
} }
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
} }
addAutoInstanceFollowingNotification (to: string[], actorFollow: MActorFollowActors) { addAutoInstanceFollowingNotification (to: string[], actorFollow: MActorFollowActors) {
const text = 'Hi dear admin,\n\n' + const instanceUrl = actorFollow.ActorFollowing.url
`Your instance automatically followed a new instance: ${actorFollow.ActorFollowing.url}` +
'\n\n' +
'Cheers,\n' +
`${CONFIG.EMAIL.BODY.SIGNATURE}`
const emailPayload: EmailPayload = { const emailPayload: EmailPayload = {
to, to,
subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'Auto instance following', subject: 'Auto instance following',
text text: `Your instance automatically followed a new instance: <a href="${instanceUrl}">${instanceUrl}</a>.`
} }
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
@ -178,18 +179,17 @@ class Emailer {
myVideoPublishedNotification (to: string[], video: MVideo) { myVideoPublishedNotification (to: string[], video: MVideo) {
const videoUrl = WEBSERVER.URL + video.getWatchStaticPath() const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
const text = 'Hi dear user,\n\n' +
`Your video ${video.name} has been published.` +
'\n\n' +
`You can view it on ${videoUrl} ` +
'\n\n' +
'Cheers,\n' +
`${CONFIG.EMAIL.BODY.SIGNATURE}`
const emailPayload: EmailPayload = { const emailPayload: EmailPayload = {
to, to,
subject: CONFIG.EMAIL.SUBJECT.PREFIX + `Your video ${video.name} is published`, subject: `Your video ${video.name} has been published`,
text text: `Your video "${video.name}" has been published.`,
locals: {
title: 'You video is live',
action: {
text: 'View video',
url: videoUrl
}
}
} }
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
@ -198,18 +198,17 @@ class Emailer {
myVideoImportSuccessNotification (to: string[], videoImport: MVideoImportVideo) { myVideoImportSuccessNotification (to: string[], videoImport: MVideoImportVideo) {
const videoUrl = WEBSERVER.URL + videoImport.Video.getWatchStaticPath() const videoUrl = WEBSERVER.URL + videoImport.Video.getWatchStaticPath()
const text = 'Hi dear user,\n\n' +
`Your video import ${videoImport.getTargetIdentifier()} is finished.` +
'\n\n' +
`You can view the imported video on ${videoUrl} ` +
'\n\n' +
'Cheers,\n' +
`${CONFIG.EMAIL.BODY.SIGNATURE}`
const emailPayload: EmailPayload = { const emailPayload: EmailPayload = {
to, to,
subject: CONFIG.EMAIL.SUBJECT.PREFIX + `Your video import ${videoImport.getTargetIdentifier()} is finished`, subject: `Your video import ${videoImport.getTargetIdentifier()} is complete`,
text text: `Your video "${videoImport.getTargetIdentifier()}" just finished importing.`,
locals: {
title: 'Import complete',
action: {
text: 'View video',
url: videoUrl
}
}
} }
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
@ -218,40 +217,47 @@ class Emailer {
myVideoImportErrorNotification (to: string[], videoImport: MVideoImport) { myVideoImportErrorNotification (to: string[], videoImport: MVideoImport) {
const importUrl = WEBSERVER.URL + '/my-account/video-imports' const importUrl = WEBSERVER.URL + '/my-account/video-imports'
const text = 'Hi dear user,\n\n' + const text =
`Your video import ${videoImport.getTargetIdentifier()} encountered an error.` + `Your video import "${videoImport.getTargetIdentifier()}" encountered an error.` +
'\n\n' + '\n\n' +
`See your videos import dashboard for more information: ${importUrl}` + `See your videos import dashboard for more information: <a href="${importUrl}">${importUrl}</a>.`
'\n\n' +
'Cheers,\n' +
`${CONFIG.EMAIL.BODY.SIGNATURE}`
const emailPayload: EmailPayload = { const emailPayload: EmailPayload = {
to, to,
subject: CONFIG.EMAIL.SUBJECT.PREFIX + `Your video import ${videoImport.getTargetIdentifier()} encountered an error`, subject: `Your video import "${videoImport.getTargetIdentifier()}" encountered an error`,
text text,
locals: {
title: 'Import failed',
action: {
text: 'Review imports',
url: importUrl
}
}
} }
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
} }
addNewCommentOnMyVideoNotification (to: string[], comment: MCommentOwnerVideo) { addNewCommentOnMyVideoNotification (to: string[], comment: MCommentOwnerVideo) {
const accountName = comment.Account.getDisplayName()
const video = comment.Video const video = comment.Video
const videoUrl = WEBSERVER.URL + comment.Video.getWatchStaticPath()
const commentUrl = WEBSERVER.URL + comment.getCommentStaticPath() const commentUrl = WEBSERVER.URL + comment.getCommentStaticPath()
const text = 'Hi dear user,\n\n' +
`A new comment has been posted by ${accountName} on your video ${video.name}` +
'\n\n' +
`You can view it on ${commentUrl} ` +
'\n\n' +
'Cheers,\n' +
`${CONFIG.EMAIL.BODY.SIGNATURE}`
const emailPayload: EmailPayload = { const emailPayload: EmailPayload = {
template: 'video-comment-new',
to, to,
subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'New comment on your video ' + video.name, subject: 'New comment on your video ' + video.name,
text locals: {
accountName: comment.Account.getDisplayName(),
accountUrl: comment.Account.Actor.url,
comment,
video,
videoUrl,
action: {
text: 'View comment',
url: commentUrl
}
}
} }
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
@ -260,75 +266,88 @@ class Emailer {
addNewCommentMentionNotification (to: string[], comment: MCommentOwnerVideo) { addNewCommentMentionNotification (to: string[], comment: MCommentOwnerVideo) {
const accountName = comment.Account.getDisplayName() const accountName = comment.Account.getDisplayName()
const video = comment.Video const video = comment.Video
const videoUrl = WEBSERVER.URL + comment.Video.getWatchStaticPath()
const commentUrl = WEBSERVER.URL + comment.getCommentStaticPath() const commentUrl = WEBSERVER.URL + comment.getCommentStaticPath()
const text = 'Hi dear user,\n\n' +
`${accountName} mentioned you on video ${video.name}` +
'\n\n' +
`You can view the comment on ${commentUrl} ` +
'\n\n' +
'Cheers,\n' +
`${CONFIG.EMAIL.BODY.SIGNATURE}`
const emailPayload: EmailPayload = { const emailPayload: EmailPayload = {
template: 'video-comment-mention',
to, to,
subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'Mention on video ' + video.name, subject: 'Mention on video ' + video.name,
text locals: {
comment,
video,
videoUrl,
accountName,
action: {
text: 'View comment',
url: commentUrl
}
}
} }
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
} }
addVideoAbuseModeratorsNotification (to: string[], videoAbuse: MVideoAbuseVideo) { addVideoAbuseModeratorsNotification (to: string[], parameters: {
const videoUrl = WEBSERVER.URL + videoAbuse.Video.getWatchStaticPath() videoAbuse: VideoAbuse
videoAbuseInstance: MVideoAbuseVideo
const text = 'Hi,\n\n' + reporter: string
`${WEBSERVER.HOST} received an abuse for the following video: ${videoUrl}\n\n` + }) {
'Cheers,\n' + const videoAbuseUrl = WEBSERVER.URL + '/admin/moderation/video-abuses/list?search=%23' + parameters.videoAbuse.id
`${CONFIG.EMAIL.BODY.SIGNATURE}` const videoUrl = WEBSERVER.URL + parameters.videoAbuseInstance.Video.getWatchStaticPath()
const emailPayload: EmailPayload = { const emailPayload: EmailPayload = {
template: 'video-abuse-new',
to, to,
subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'Received a video abuse', subject: `New video abuse report from ${parameters.reporter}`,
text locals: {
videoUrl,
videoAbuseUrl,
videoCreatedAt: new Date(parameters.videoAbuseInstance.Video.createdAt).toLocaleString(),
videoPublishedAt: new Date(parameters.videoAbuseInstance.Video.publishedAt).toLocaleString(),
videoAbuse: parameters.videoAbuse,
reporter: parameters.reporter,
action: {
text: 'View report #' + parameters.videoAbuse.id,
url: videoAbuseUrl
}
}
} }
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
} }
addVideoAutoBlacklistModeratorsNotification (to: string[], videoBlacklist: MVideoBlacklistLightVideo) { async addVideoAutoBlacklistModeratorsNotification (to: string[], videoBlacklist: MVideoBlacklistLightVideo) {
const VIDEO_AUTO_BLACKLIST_URL = WEBSERVER.URL + '/admin/moderation/video-auto-blacklist/list' const VIDEO_AUTO_BLACKLIST_URL = WEBSERVER.URL + '/admin/moderation/video-auto-blacklist/list'
const videoUrl = WEBSERVER.URL + videoBlacklist.Video.getWatchStaticPath() const videoUrl = WEBSERVER.URL + videoBlacklist.Video.getWatchStaticPath()
const channel = (await VideoChannelModel.loadByIdAndPopulateAccount(videoBlacklist.Video.channelId)).toFormattedSummaryJSON()
const text = 'Hi,\n\n' +
'A recently added video was auto-blacklisted and requires moderator review before publishing.' +
'\n\n' +
`You can view it and take appropriate action on ${videoUrl}` +
'\n\n' +
`A full list of auto-blacklisted videos can be reviewed here: ${VIDEO_AUTO_BLACKLIST_URL}` +
'\n\n' +
'Cheers,\n' +
`${CONFIG.EMAIL.BODY.SIGNATURE}`
const emailPayload: EmailPayload = { const emailPayload: EmailPayload = {
template: 'video-auto-blacklist-new',
to, to,
subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'An auto-blacklisted video is awaiting review', subject: 'A new video is pending moderation',
text locals: {
channel,
videoUrl,
videoName: videoBlacklist.Video.name,
action: {
text: 'Review autoblacklist',
url: VIDEO_AUTO_BLACKLIST_URL
}
}
} }
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
} }
addNewUserRegistrationNotification (to: string[], user: MUser) { addNewUserRegistrationNotification (to: string[], user: MUser) {
const text = 'Hi,\n\n' +
`User ${user.username} just registered on ${WEBSERVER.HOST} PeerTube instance.\n\n` +
'Cheers,\n' +
`${CONFIG.EMAIL.BODY.SIGNATURE}`
const emailPayload: EmailPayload = { const emailPayload: EmailPayload = {
template: 'user-registered',
to, to,
subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'New user registration on ' + WEBSERVER.HOST, subject: `a new user registered on ${WEBSERVER.HOST}: ${user.username}`,
text locals: {
user
}
} }
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
@ -341,16 +360,13 @@ class Emailer {
const reasonString = videoBlacklist.reason ? ` for the following reason: ${videoBlacklist.reason}` : '' const reasonString = videoBlacklist.reason ? ` for the following reason: ${videoBlacklist.reason}` : ''
const blockedString = `Your video ${videoName} (${videoUrl} on ${WEBSERVER.HOST} has been blacklisted${reasonString}.` const blockedString = `Your video ${videoName} (${videoUrl} on ${WEBSERVER.HOST} has been blacklisted${reasonString}.`
const text = 'Hi,\n\n' +
blockedString +
'\n\n' +
'Cheers,\n' +
`${CONFIG.EMAIL.BODY.SIGNATURE}`
const emailPayload: EmailPayload = { const emailPayload: EmailPayload = {
to, to,
subject: CONFIG.EMAIL.SUBJECT.PREFIX + `Video ${videoName} blacklisted`, subject: `Video ${videoName} blacklisted`,
text text: blockedString,
locals: {
title: 'Your video was blacklisted'
}
} }
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
@ -359,66 +375,53 @@ class Emailer {
addVideoUnblacklistNotification (to: string[], video: MVideo) { addVideoUnblacklistNotification (to: string[], video: MVideo) {
const videoUrl = WEBSERVER.URL + video.getWatchStaticPath() const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
const text = 'Hi,\n\n' +
`Your video ${video.name} (${videoUrl}) on ${WEBSERVER.HOST} has been unblacklisted.` +
'\n\n' +
'Cheers,\n' +
`${CONFIG.EMAIL.BODY.SIGNATURE}`
const emailPayload: EmailPayload = { const emailPayload: EmailPayload = {
to, to,
subject: CONFIG.EMAIL.SUBJECT.PREFIX + `Video ${video.name} unblacklisted`, subject: `Video ${video.name} unblacklisted`,
text text: `Your video "${video.name}" (${videoUrl}) on ${WEBSERVER.HOST} has been unblacklisted.`,
locals: {
title: 'Your video was unblacklisted'
}
} }
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
} }
addPasswordResetEmailJob (to: string, resetPasswordUrl: string) { addPasswordResetEmailJob (to: string, resetPasswordUrl: string) {
const text = 'Hi dear user,\n\n' +
`A reset password procedure for your account ${to} has been requested on ${WEBSERVER.HOST} ` +
`Please follow this link to reset it: ${resetPasswordUrl} (the link will expire within 1 hour)\n\n` +
'If you are not the person who initiated this request, please ignore this email.\n\n' +
'Cheers,\n' +
`${CONFIG.EMAIL.BODY.SIGNATURE}`
const emailPayload: EmailPayload = { const emailPayload: EmailPayload = {
template: 'password-reset',
to: [ to ], to: [ to ],
subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'Reset your password', subject: 'Reset your account password',
text locals: {
resetPasswordUrl
}
} }
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
} }
addPasswordCreateEmailJob (username: string, to: string, resetPasswordUrl: string) { addPasswordCreateEmailJob (username: string, to: string, createPasswordUrl: string) {
const text = 'Hi,\n\n' +
`Welcome to your ${WEBSERVER.HOST} PeerTube instance. Your username is: ${username}.\n\n` +
`Please set your password by following this link: ${resetPasswordUrl} (this link will expire within seven days).\n\n` +
'Cheers,\n' +
`${CONFIG.EMAIL.BODY.SIGNATURE}`
const emailPayload: EmailPayload = { const emailPayload: EmailPayload = {
template: 'password-create',
to: [ to ], to: [ to ],
subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'New PeerTube account password', subject: 'Create your account password',
text locals: {
username,
createPasswordUrl
}
} }
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
} }
addVerifyEmailJob (to: string, verifyEmailUrl: string) { addVerifyEmailJob (to: string, verifyEmailUrl: string) {
const text = 'Welcome to PeerTube,\n\n' +
`To start using PeerTube on ${WEBSERVER.HOST} you must verify your email! ` +
`Please follow this link to verify this email belongs to you: ${verifyEmailUrl}\n\n` +
'If you are not the person who initiated this request, please ignore this email.\n\n' +
'Cheers,\n' +
`${CONFIG.EMAIL.BODY.SIGNATURE}`
const emailPayload: EmailPayload = { const emailPayload: EmailPayload = {
template: 'verify-email',
to: [ to ], to: [ to ],
subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'Verify your email', subject: `Verify your email on ${WEBSERVER.HOST}`,
text locals: {
verifyEmailUrl
}
} }
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
@ -427,39 +430,28 @@ class Emailer {
addUserBlockJob (user: MUser, blocked: boolean, reason?: string) { addUserBlockJob (user: MUser, blocked: boolean, reason?: string) {
const reasonString = reason ? ` for the following reason: ${reason}` : '' const reasonString = reason ? ` for the following reason: ${reason}` : ''
const blockedWord = blocked ? 'blocked' : 'unblocked' const blockedWord = blocked ? 'blocked' : 'unblocked'
const blockedString = `Your account ${user.username} on ${WEBSERVER.HOST} has been ${blockedWord}${reasonString}.`
const text = 'Hi,\n\n' +
blockedString +
'\n\n' +
'Cheers,\n' +
`${CONFIG.EMAIL.BODY.SIGNATURE}`
const to = user.email const to = user.email
const emailPayload: EmailPayload = { const emailPayload: EmailPayload = {
to: [ to ], to: [ to ],
subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'Account ' + blockedWord, subject: 'Account ' + blockedWord,
text text: `Your account ${user.username} on ${WEBSERVER.HOST} has been ${blockedWord}${reasonString}.`
} }
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
} }
addContactFormJob (fromEmail: string, fromName: string, subject: string, body: string) { addContactFormJob (fromEmail: string, fromName: string, subject: string, body: string) {
const text = 'Hello dear admin,\n\n' +
fromName + ' sent you a message' +
'\n\n---------------------------------------\n\n' +
body +
'\n\n---------------------------------------\n\n' +
'Cheers,\n' +
'PeerTube.'
const emailPayload: EmailPayload = { const emailPayload: EmailPayload = {
fromDisplayName: fromEmail, template: 'contact-form',
replyTo: fromEmail,
to: [ CONFIG.ADMIN.EMAIL ], to: [ CONFIG.ADMIN.EMAIL ],
subject: CONFIG.EMAIL.SUBJECT.PREFIX + subject, replyTo: `"${fromName}" <${fromEmail}>`,
text subject: `(contact form) ${subject}`,
locals: {
fromName,
fromEmail,
body
}
} }
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
@ -470,18 +462,44 @@ class Emailer {
throw new Error('Cannot send mail because SMTP is not configured.') throw new Error('Cannot send mail because SMTP is not configured.')
} }
const fromDisplayName = options.fromDisplayName const fromDisplayName = options.from
? options.fromDisplayName ? options.from
: WEBSERVER.HOST : WEBSERVER.HOST
const email = new Email({
send: true,
message: {
from: `"${fromDisplayName}" <${CONFIG.SMTP.FROM_ADDRESS}>`
},
transport: this.transporter,
views: {
root: join(root(), 'server', 'lib', 'emails')
},
subjectPrefix: CONFIG.EMAIL.SUBJECT.PREFIX
})
for (const to of options.to) { for (const to of options.to) {
await this.transporter.sendMail({ await email
from: `"${fromDisplayName}" <${CONFIG.SMTP.FROM_ADDRESS}>`, .send(merge(
replyTo: options.replyTo, {
to, template: 'common',
subject: options.subject, message: {
text: options.text to,
}) from: options.from,
subject: options.subject,
replyTo: options.replyTo
},
locals: { // default variables available in all templates
WEBSERVER,
EMAIL: CONFIG.EMAIL,
text: options.text,
subject: options.subject
}
},
options // overriden/new variables given for a specific template in the payload
) as SendEmailOptions)
.then(logger.info)
.catch(logger.error)
} }
} }

View File

@ -0,0 +1,267 @@
//-
The email background color is defined in three places:
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
3. mso conditional: For Windows 10 Mail
- var backgroundColor = "#fff";
- var mainColor = "#f2690d";
doctype html
head
// This template is heavily adapted from the Cerberus Fluid template. Kudos to them!
meta(charset='utf-8')
//- utf-8 works for most cases
meta(name='viewport' content='width=device-width')
//- Forcing initial-scale shouldn't be necessary
meta(http-equiv='X-UA-Compatible' content='IE=edge')
//- Use the latest (edge) version of IE rendering engine
meta(name='x-apple-disable-message-reformatting')
//- Disable auto-scale in iOS 10 Mail entirely
meta(name='format-detection' content='telephone=no,address=no,email=no,date=no,url=no')
//- Tell iOS not to automatically link certain text strings.
meta(name='color-scheme' content='light')
meta(name='supported-color-schemes' content='light')
//- The title tag shows in email notifications, like Android 4.4.
title #{subject}
//- What it does: Makes background images in 72ppi Outlook render at correct size.
//if gte mso 9
xml
o:officedocumentsettings
o:allowpng
o:pixelsperinch 96
//- CSS Reset : BEGIN
style.
/* What it does: Tells the email client that only light styles are provided but the client can transform them to dark. A duplicate of meta color-scheme meta tag above. */
:root {
color-scheme: light;
supported-color-schemes: light;
}
/* What it does: Remove spaces around the email design added by some email clients. */
/* Beware: It can remove the padding / margin and add a background color to the compose a reply window. */
html,
body {
margin: 0 auto !important;
padding: 0 !important;
height: 100% !important;
width: 100% !important;
}
/* What it does: Stops email clients resizing small text. */
* {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
/* What it does: Centers email on Android 4.4 */
div[style*="margin: 16px 0"] {
margin: 0 !important;
}
/* What it does: forces Samsung Android mail clients to use the entire viewport */
#MessageViewBody, #MessageWebViewDiv{
width: 100% !important;
}
/* What it does: Stops Outlook from adding extra spacing to tables. */
table,
td {
mso-table-lspace: 0pt !important;
mso-table-rspace: 0pt !important;
}
/* What it does: Fixes webkit padding issue. */
table {
border-spacing: 0 !important;
border-collapse: collapse !important;
table-layout: fixed !important;
margin: 0 auto !important;
}
/* What it does: Uses a better rendering method when resizing images in IE. */
img {
-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 {
text-decoration: none;
}
a:not(.nocolor) {
color: #{mainColor};
}
a.nocolor {
color: inherit !important;
}
/* What it does: A work-around for email clients meddling in triggered links. */
a[x-apple-data-detectors], /* iOS */
.unstyle-auto-detected-links a,
.aBn {
border-bottom: 0 !important;
cursor: default !important;
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
/* What it does: Prevents Gmail from displaying a download button on large, non-linked images. */
.a6S {
display: none !important;
opacity: 0.01 !important;
}
/* What it does: Prevents Gmail from changing the text color in conversation threads. */
.im {
color: inherit !important;
}
/* If the above doesn't work, add a .g-img class to any image in question. */
img.g-img + div {
display: none !important;
}
/* What it does: Removes right gutter in Gmail iOS app: https://github.com/TedGoas/Cerberus/issues/89 */
/* Create one of these media queries for each additional viewport size you'd like to fix */
/* iPhone 4, 4S, 5, 5S, 5C, and 5SE */
@media only screen and (min-device-width: 320px) and (max-device-width: 374px) {
u ~ div .email-container {
min-width: 320px !important;
}
}
/* iPhone 6, 6S, 7, 8, and X */
@media only screen and (min-device-width: 375px) and (max-device-width: 413px) {
u ~ div .email-container {
min-width: 375px !important;
}
}
/* iPhone 6+, 7+, and 8+ */
@media only screen and (min-device-width: 414px) {
u ~ div .email-container {
min-width: 414px !important;
}
}
//- CSS Reset : END
//- CSS for PeerTube : START
style.
blockquote {
margin-left: 0;
padding-left: 20px;
border-left: 2px solid #f2690d;
}
//- 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};")
center(role='article' aria-roledescription='email' lang='en' style='width: 100%; background-color: #{backgroundColor};')
//if mso | IE
table(role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%' style='background-color: #fff;')
tr
td
//- Visually Hidden Preheader Text : BEGIN
div(style='max-height:0; overflow:hidden; mso-hide:all;' aria-hidden='true')
block preheader
//- Visually Hidden Preheader Text : END
//- Create white space after the desired preview text so email clients dont pull other distracting text into the inbox preview. Extend as necessary.
//- Preview Text Spacing Hack : BEGIN
div(style='display: none; font-size: 1px; line-height: 1px; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden; mso-hide: all; font-family: sans-serif;')
| &zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;
//- Preview Text Spacing Hack : END
//-
Set the email width. Defined in two places:
1. max-width for all clients except Desktop Windows Outlook, allowing the email to squish on narrow but never go wider than 600px.
2. MSO tags for Desktop Windows Outlook enforce a 600px width.
.email-container(style='max-width: 600px; margin: 0 auto;')
//if mso
table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='600')
tr
td
//- Email Body : BEGIN
table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style='margin: auto;')
//- 1 Column Text + Button : BEGIN
tr
td(style='background-color: #ffffff;')
table(role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%')
tr
td(style='padding: 20px; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #555555;')
table(role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%")
tr
td(width="40px")
img(src=`${WEBSERVER.URL}/client/assets/images/icons/icon-192x192.png` width="auto" height="30px" alt="icon" border="0" style="height: 30px; background: #ffffff; font-family: sans-serif; font-size: 15px; line-height: 15px; color: #555555;")
td
h1(style='margin: 10px 0 10px 0; font-family: sans-serif; font-size: 25px; line-height: 30px; color: #333333; font-weight: normal;')
block title
if title
| #{title}
else
| Something requires your attention
p(style='margin: 0;')
block body
if action
tr
td(style='padding: 0 20px;')
//- Button : BEGIN
table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' style='margin: auto;')
tr
td.button-td.button-td-primary(style='border-radius: 4px; background: #222222;')
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}
//- Button : END
//- 1 Column Text + Button : END
//- Clear Spacer : BEGIN
tr
td(aria-hidden='true' height='20' style='font-size: 0px; line-height: 0px;')
br
//- Clear Spacer : END
//- 1 Column Text : BEGIN
if username
tr
td(style='background-color: #cccccc;')
table(role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%')
tr
td(style='padding: 20px; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #555555;')
p(style='margin: 0;')
| You are receiving this email as part of your notification settings on #{WEBSERVER.HOST} for your account #{username}.
//- 1 Column Text : END
//- Email Body : END
//- Email Footer : BEGIN
table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style='margin: auto;')
tr
td(style='padding: 20px; padding-bottom: 0px; font-family: sans-serif; font-size: 12px; line-height: 15px; text-align: center; color: #888888;')
webversion
a.nocolor(href=`${WEBSERVER.URL}/my-account/notifications` style='color: #cccccc; font-weight: bold;') View in your notifications
br
tr
td(style='padding: 20px; padding-top: 10px; font-family: sans-serif; font-size: 12px; line-height: 15px; text-align: center; color: #888888;')
unsubscribe
a.nocolor(href=`${WEBSERVER.URL}/my-account/settings#notifications` style='color: #888888;') Manage your notification preferences in your profile
br
//- Email Footer : END
//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

View File

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

View File

@ -0,0 +1,4 @@
extends greetings
block content
p !{text}

View File

@ -0,0 +1,3 @@
mixin channel(channel)
- var handle = `${channel.name}@${channel.host}`
| #[a(href=`${WEBSERVER.URL}/video-channels/${handle}` title=handle) #{channel.displayName}]

View File

@ -0,0 +1,9 @@
extends ../common/greetings
block title
| Someone just used the contact form
block content
p #{fromName} sent you a message via the contact form on #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}]:
blockquote(style='white-space: pre-wrap') #{body}
p You can contact them at #[a(href=`mailto:${fromEmail}`) #{fromEmail}], or simply reply to this email to get in touch.

View File

@ -0,0 +1,9 @@
extends ../common/greetings
block title
| New follower on your channel
block content
p.
Your #{followType} #[a(href=followingUrl) #{followingName}] has a new subscriber:
#[a(href=followerUrl) #{followerName}].

View File

@ -0,0 +1,10 @@
extends ../common/greetings
block title
| Password creation for your account
block content
p.
Welcome to #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}], your PeerTube instance. Your username is: #{username}.
Please set your password by following #[a(href=createPasswordUrl) this link]: #[a(href=createPasswordUrl) #{createPasswordUrl}]
(this link will expire within seven days).

View File

@ -0,0 +1,12 @@
extends ../common/greetings
block title
| Password reset for your account
block content
p.
A reset password procedure for your account ${to} has been requested on #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}].
Please follow #[a(href=resetPasswordUrl) this link] to reset it: #[a(href=resetPasswordUrl) #{resetPasswordUrl}]
(the link will expire within 1 hour)
p.
If you are not the person who initiated this request, please ignore this email.

View File

@ -0,0 +1,10 @@
extends ../common/greetings
block title
| A new user registered
block content
- var mail = user.email || user.pendingEmail;
p
| User #[a(href=`${WEBSERVER.URL}/accounts/${user.username}`) #{user.username}] just registered.
| You might want to contact them at #[a(href=`mailto:${mail}`) #{mail}].

View File

@ -0,0 +1,14 @@
extends ../common/greetings
block title
| Account verification
block content
p Welcome to PeerTube!
p.
You just created an account #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}], your new PeerTube instance.
Your username there is: #{username}.
p.
To start using PeerTube on #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}] you must verify your email first!
Please follow #[a(href=verifyEmailUrl) this link] to verify this email belongs to you: #[a(href=verifyEmailUrl) #{verifyEmailUrl}]
If you are not the person who initiated this request, please ignore this email.

View File

@ -0,0 +1,18 @@
extends ../common/greetings
include ../common/mixins.pug
block title
| A video is pending moderation
block content
p
| #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}] received an abuse report for the #{videoAbuse.video.channel.isLocal ? '' : 'remote '}video "
a(href=videoUrl) #{videoAbuse.video.name}
| " by #[+channel(videoAbuse.video.channel)]
if videoPublishedAt
| , published the #{videoPublishedAt}.
else
| , uploaded the #{videoCreatedAt} but not yet published.
p The reporter, #{reporter}, cited the following reason(s):
blockquote #{videoAbuse.reason}
br(style="display: none;")

View File

@ -0,0 +1,17 @@
extends ../common/greetings
include ../common/mixins
block title
| A video is pending moderation
block content
p
| A recently added video was auto-blacklisted and requires moderator review before going public:
|
a(href=videoUrl) #{videoName}
|
| by #[+channel(channel)].
p.
Apart from the publisher and the moderation team, no one will be able to see the video until you
unblacklist it. If you trust the publisher, any admin can whitelist the user for later videos so
that they don't require approval before going public.

View File

@ -0,0 +1,11 @@
extends ../common/greetings
block title
| Someone mentioned you
block content
p.
#[a(href=accountUrl title=handle) #{accountName}] mentioned you in a comment on video
"#[a(href=videoUrl) #{video.name}]":
blockquote #{comment.text}
br(style="display: none;")

View File

@ -0,0 +1,11 @@
extends ../common/greetings
block title
| Someone commented your video
block content
p.
#[a(href=accountUrl title=handle) #{accountName}] added a comment on your video
"#[a(href=videoUrl) #{video.name}]":
blockquote #{comment.text}
br(style="display: none;")

View File

@ -5,7 +5,7 @@ import { UserNotificationModel } from '../models/account/user-notification'
import { UserModel } from '../models/account/user' import { UserModel } from '../models/account/user'
import { PeerTubeSocket } from './peertube-socket' import { PeerTubeSocket } from './peertube-socket'
import { CONFIG } from '../initializers/config' import { CONFIG } from '../initializers/config'
import { VideoPrivacy, VideoState } from '../../shared/models/videos' import { VideoPrivacy, VideoState, VideoAbuse } from '../../shared/models/videos'
import { AccountBlocklistModel } from '../models/account/account-blocklist' import { AccountBlocklistModel } from '../models/account/account-blocklist'
import { import {
MCommentOwnerVideo, MCommentOwnerVideo,
@ -77,9 +77,9 @@ class Notifier {
.catch(err => logger.error('Cannot notify mentions of comment %s.', comment.url, { err })) .catch(err => logger.error('Cannot notify mentions of comment %s.', comment.url, { err }))
} }
notifyOnNewVideoAbuse (videoAbuse: MVideoAbuseVideo): void { notifyOnNewVideoAbuse (parameters: { videoAbuse: VideoAbuse, videoAbuseInstance: MVideoAbuseVideo, reporter: string }): void {
this.notifyModeratorsOfNewVideoAbuse(videoAbuse) this.notifyModeratorsOfNewVideoAbuse(parameters)
.catch(err => logger.error('Cannot notify of new video abuse of video %s.', videoAbuse.Video.url, { err })) .catch(err => logger.error('Cannot notify of new video abuse of video %s.', parameters.videoAbuseInstance.Video.url, { err }))
} }
notifyOnVideoAutoBlacklist (videoBlacklist: MVideoBlacklistLightVideo): void { notifyOnVideoAutoBlacklist (videoBlacklist: MVideoBlacklistLightVideo): void {
@ -350,11 +350,15 @@ class Notifier {
return this.notify({ users: admins, settingGetter, notificationCreator, emailSender }) return this.notify({ users: admins, settingGetter, notificationCreator, emailSender })
} }
private async notifyModeratorsOfNewVideoAbuse (videoAbuse: MVideoAbuseVideo) { private async notifyModeratorsOfNewVideoAbuse (parameters: {
videoAbuse: VideoAbuse
videoAbuseInstance: MVideoAbuseVideo
reporter: string
}) {
const moderators = await UserModel.listWithRight(UserRight.MANAGE_VIDEO_ABUSES) const moderators = await UserModel.listWithRight(UserRight.MANAGE_VIDEO_ABUSES)
if (moderators.length === 0) return if (moderators.length === 0) return
logger.info('Notifying %s user/moderators of new video abuse %s.', moderators.length, videoAbuse.Video.url) logger.info('Notifying %s user/moderators of new video abuse %s.', moderators.length, parameters.videoAbuseInstance.Video.url)
function settingGetter (user: MUserWithNotificationSetting) { function settingGetter (user: MUserWithNotificationSetting) {
return user.NotificationSetting.videoAbuseAsModerator return user.NotificationSetting.videoAbuseAsModerator
@ -364,15 +368,15 @@ class Notifier {
const notification: UserNotificationModelForApi = await UserNotificationModel.create<UserNotificationModelForApi>({ const notification: UserNotificationModelForApi = await UserNotificationModel.create<UserNotificationModelForApi>({
type: UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS, type: UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS,
userId: user.id, userId: user.id,
videoAbuseId: videoAbuse.id videoAbuseId: parameters.videoAbuse.id
}) })
notification.VideoAbuse = videoAbuse notification.VideoAbuse = parameters.videoAbuseInstance
return notification return notification
} }
function emailSender (emails: string[]) { function emailSender (emails: string[]) {
return Emailer.Instance.addVideoAbuseModeratorsNotification(emails, videoAbuse) return Emailer.Instance.addVideoAbuseModeratorsNotification(emails, parameters)
} }
return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender }) return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender })

View File

@ -46,7 +46,7 @@ describe('Test contact form', function () {
const email = emails[0] const email = emails[0]
expect(email['from'][0]['address']).equal('test-admin@localhost') expect(email['from'][0]['address']).equal('test-admin@localhost')
expect(email['from'][0]['name']).equal('toto@example.com') expect(email['replyTo'][0]['address']).equal('toto@example.com')
expect(email['to'][0]['address']).equal('admin' + server.internalServerNumber + '@example.com') expect(email['to'][0]['address']).equal('admin' + server.internalServerNumber + '@example.com')
expect(email['subject']).contains('my subject') expect(email['subject']).contains('my subject')
expect(email['text']).contains('my super message') expect(email['text']).contains('my super message')

View File

@ -110,10 +110,10 @@ async function checkNotification (
if (checkType === 'presence') { if (checkType === 'presence') {
const obj = inspect(base.socketNotifications, { depth: 5 }) const obj = inspect(base.socketNotifications, { depth: 5 })
expect(socketNotification, 'The socket notification is absent. ' + obj).to.not.be.undefined expect(socketNotification, 'The socket notification is absent when is should be present. ' + obj).to.not.be.undefined
} else { } else {
const obj = inspect(socketNotification, { depth: 5 }) const obj = inspect(socketNotification, { depth: 5 })
expect(socketNotification, 'The socket notification is present. ' + obj).to.be.undefined expect(socketNotification, 'The socket notification is present when is should not be present. ' + obj).to.be.undefined
} }
} }
@ -125,9 +125,9 @@ async function checkNotification (
.find(e => emailNotificationFinder(e)) .find(e => emailNotificationFinder(e))
if (checkType === 'presence') { if (checkType === 'presence') {
expect(email, 'The email is absent. ' + inspect(base.emails)).to.not.be.undefined expect(email, 'The email is absent when is should be present. ' + inspect(base.emails)).to.not.be.undefined
} else { } else {
expect(email, 'The email is present. ' + inspect(email)).to.be.undefined expect(email, 'The email is present when is should not be present. ' + inspect(email)).to.be.undefined
} }
} }
} }
@ -172,12 +172,12 @@ async function checkNewVideoFromSubscription (base: CheckerBaseParams, videoName
} }
} }
function emailFinder (email: object) { function emailNotificationFinder (email: object) {
const text = email['text'] const text = email['text']
return text.indexOf(videoUUID) !== -1 && text.indexOf('Your subscription') !== -1 return text.indexOf(videoUUID) !== -1 && text.indexOf('Your subscription') !== -1
} }
await checkNotification(base, notificationChecker, emailFinder, type) await checkNotification(base, notificationChecker, emailNotificationFinder, type)
} }
async function checkVideoIsPublished (base: CheckerBaseParams, videoName: string, videoUUID: string, type: CheckerType) { async function checkVideoIsPublished (base: CheckerBaseParams, videoName: string, videoUUID: string, type: CheckerType) {
@ -195,12 +195,12 @@ async function checkVideoIsPublished (base: CheckerBaseParams, videoName: string
} }
} }
function emailFinder (email: object) { function emailNotificationFinder (email: object) {
const text: string = email['text'] const text: string = email['text']
return text.includes(videoUUID) && text.includes('Your video') return text.includes(videoUUID) && text.includes('Your video')
} }
await checkNotification(base, notificationChecker, emailFinder, type) await checkNotification(base, notificationChecker, emailNotificationFinder, type)
} }
async function checkMyVideoImportIsFinished ( async function checkMyVideoImportIsFinished (
@ -226,14 +226,14 @@ async function checkMyVideoImportIsFinished (
} }
} }
function emailFinder (email: object) { function emailNotificationFinder (email: object) {
const text: string = email['text'] const text: string = email['text']
const toFind = success ? ' finished' : ' error' const toFind = success ? ' finished' : ' error'
return text.includes(url) && text.includes(toFind) return text.includes(url) && text.includes(toFind)
} }
await checkNotification(base, notificationChecker, emailFinder, type) await checkNotification(base, notificationChecker, emailNotificationFinder, type)
} }
async function checkUserRegistered (base: CheckerBaseParams, username: string, type: CheckerType) { async function checkUserRegistered (base: CheckerBaseParams, username: string, type: CheckerType) {
@ -251,13 +251,13 @@ async function checkUserRegistered (base: CheckerBaseParams, username: string, t
} }
} }
function emailFinder (email: object) { function emailNotificationFinder (email: object) {
const text: string = email['text'] const text: string = email['text']
return text.includes(' registered ') && text.includes(username) return text.includes(' registered.') && text.includes(username)
} }
await checkNotification(base, notificationChecker, emailFinder, type) await checkNotification(base, notificationChecker, emailNotificationFinder, type)
} }
async function checkNewActorFollow ( async function checkNewActorFollow (
@ -291,13 +291,13 @@ async function checkNewActorFollow (
} }
} }
function emailFinder (email: object) { function emailNotificationFinder (email: object) {
const text: string = email['text'] const text: string = email['text']
return text.includes('Your ' + followType) && text.includes(followingDisplayName) && text.includes(followerDisplayName) return text.includes(followType) && text.includes(followingDisplayName) && text.includes(followerDisplayName)
} }
await checkNotification(base, notificationChecker, emailFinder, type) await checkNotification(base, notificationChecker, emailNotificationFinder, type)
} }
async function checkNewInstanceFollower (base: CheckerBaseParams, followerHost: string, type: CheckerType) { async function checkNewInstanceFollower (base: CheckerBaseParams, followerHost: string, type: CheckerType) {
@ -320,13 +320,13 @@ async function checkNewInstanceFollower (base: CheckerBaseParams, followerHost:
} }
} }
function emailFinder (email: object) { function emailNotificationFinder (email: object) {
const text: string = email['text'] const text: string = email['text']
return text.includes('instance has a new follower') && text.includes(followerHost) return text.includes('instance has a new follower') && text.includes(followerHost)
} }
await checkNotification(base, notificationChecker, emailFinder, type) await checkNotification(base, notificationChecker, emailNotificationFinder, type)
} }
async function checkAutoInstanceFollowing (base: CheckerBaseParams, followerHost: string, followingHost: string, type: CheckerType) { async function checkAutoInstanceFollowing (base: CheckerBaseParams, followerHost: string, followingHost: string, type: CheckerType) {
@ -351,13 +351,13 @@ async function checkAutoInstanceFollowing (base: CheckerBaseParams, followerHost
} }
} }
function emailFinder (email: object) { function emailNotificationFinder (email: object) {
const text: string = email['text'] const text: string = email['text']
return text.includes(' automatically followed a new instance') && text.includes(followingHost) return text.includes(' automatically followed a new instance') && text.includes(followingHost)
} }
await checkNotification(base, notificationChecker, emailFinder, type) await checkNotification(base, notificationChecker, emailNotificationFinder, type)
} }
async function checkCommentMention ( async function checkCommentMention (
@ -385,13 +385,13 @@ async function checkCommentMention (
} }
} }
function emailFinder (email: object) { function emailNotificationFinder (email: object) {
const text: string = email['text'] const text: string = email['text']
return text.includes(' mentioned ') && text.includes(uuid) && text.includes(byAccountDisplayName) return text.includes(' mentioned ') && text.includes(uuid) && text.includes(byAccountDisplayName)
} }
await checkNotification(base, notificationChecker, emailFinder, type) await checkNotification(base, notificationChecker, emailNotificationFinder, type)
} }
let lastEmailCount = 0 let lastEmailCount = 0
@ -416,11 +416,11 @@ async function checkNewCommentOnMyVideo (base: CheckerBaseParams, uuid: string,
const commentUrl = `http://localhost:${base.server.port}/videos/watch/${uuid};threadId=${threadId}` const commentUrl = `http://localhost:${base.server.port}/videos/watch/${uuid};threadId=${threadId}`
function emailFinder (email: object) { function emailNotificationFinder (email: object) {
return email['text'].indexOf(commentUrl) !== -1 return email['text'].indexOf(commentUrl) !== -1
} }
await checkNotification(base, notificationChecker, emailFinder, type) await checkNotification(base, notificationChecker, emailNotificationFinder, type)
if (type === 'presence') { if (type === 'presence') {
// We cannot detect email duplicates, so check we received another email // We cannot detect email duplicates, so check we received another email
@ -446,12 +446,12 @@ async function checkNewVideoAbuseForModerators (base: CheckerBaseParams, videoUU
} }
} }
function emailFinder (email: object) { function emailNotificationFinder (email: object) {
const text = email['text'] const text = email['text']
return text.indexOf(videoUUID) !== -1 && text.indexOf('abuse') !== -1 return text.indexOf(videoUUID) !== -1 && text.indexOf('abuse') !== -1
} }
await checkNotification(base, notificationChecker, emailFinder, type) await checkNotification(base, notificationChecker, emailNotificationFinder, type)
} }
async function checkVideoAutoBlacklistForModerators (base: CheckerBaseParams, videoUUID: string, videoName: string, type: CheckerType) { async function checkVideoAutoBlacklistForModerators (base: CheckerBaseParams, videoUUID: string, videoName: string, type: CheckerType) {
@ -471,12 +471,12 @@ async function checkVideoAutoBlacklistForModerators (base: CheckerBaseParams, vi
} }
} }
function emailFinder (email: object) { function emailNotificationFinder (email: object) {
const text = email['text'] const text = email['text']
return text.indexOf(videoUUID) !== -1 && email['text'].indexOf('video-auto-blacklist/list') !== -1 return text.indexOf(videoUUID) !== -1 && email['text'].indexOf('video-auto-blacklist/list') !== -1
} }
await checkNotification(base, notificationChecker, emailFinder, type) await checkNotification(base, notificationChecker, emailNotificationFinder, type)
} }
async function checkNewBlacklistOnMyVideo ( async function checkNewBlacklistOnMyVideo (
@ -498,12 +498,12 @@ async function checkNewBlacklistOnMyVideo (
checkVideo(video, videoName, videoUUID) checkVideo(video, videoName, videoUUID)
} }
function emailFinder (email: object) { function emailNotificationFinder (email: object) {
const text = email['text'] const text = email['text']
return text.indexOf(videoUUID) !== -1 && text.indexOf(' ' + blacklistType) !== -1 return text.indexOf(videoUUID) !== -1 && text.indexOf(' ' + blacklistType) !== -1
} }
await checkNotification(base, notificationChecker, emailFinder, 'presence') await checkNotification(base, notificationChecker, emailNotificationFinder, 'presence')
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -1,8 +1,12 @@
export type SendEmailOptions = { export type SendEmailOptions = {
to: string[] to: string[]
subject: string
text: string
fromDisplayName?: string template?: string
locals?: { [key: string]: any }
// override defaults
subject?: string
text?: string
from?: string | { name?: string, address: string }
replyTo?: string replyTo?: string
} }

999
yarn.lock

File diff suppressed because it is too large Load Diff