04aade83a2a882dc480243cd0219facfe6838627
[oweals/peertube.git] / server / lib / emailer.ts
1 import { createTransport, Transporter } from 'nodemailer'
2 import { isTestInstance, root } from '../helpers/core-utils'
3 import { bunyanLogger, logger } from '../helpers/logger'
4 import { CONFIG, isEmailEnabled } from '../initializers/config'
5 import { JobQueue } from './job-queue'
6 import { readFileSync } from 'fs-extra'
7 import { WEBSERVER } from '../initializers/constants'
8 import {
9   MCommentOwnerVideo,
10   MVideo,
11   MVideoAbuseVideo,
12   MVideoAccountLight,
13   MVideoBlacklistLightVideo,
14   MVideoBlacklistVideo
15 } from '../typings/models/video'
16 import { MActorFollowActors, MActorFollowFull, MUser } from '../typings/models'
17 import { MVideoImport, MVideoImportVideo } from '@server/typings/models/video/video-import'
18 import { EmailPayload } from '@shared/models'
19 import { join } from 'path'
20 import { VideoAbuse } from '../../shared/models/videos'
21 import { SendEmailOptions } from '../../shared/models/server/emailer.model'
22 import { merge } from 'lodash'
23 import { VideoChannelModel } from '@server/models/video/video-channel'
24 const Email = require('email-templates')
25
26 class Emailer {
27
28   private static instance: Emailer
29   private initialized = false
30   private transporter: Transporter
31
32   private constructor () {
33   }
34
35   init () {
36     // Already initialized
37     if (this.initialized === true) return
38     this.initialized = true
39
40     if (isEmailEnabled()) {
41       if (CONFIG.SMTP.TRANSPORT === 'smtp') {
42         logger.info('Using %s:%s as SMTP server.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT)
43
44         let tls
45         if (CONFIG.SMTP.CA_FILE) {
46           tls = {
47             ca: [ readFileSync(CONFIG.SMTP.CA_FILE) ]
48           }
49         }
50
51         let auth
52         if (CONFIG.SMTP.USERNAME && CONFIG.SMTP.PASSWORD) {
53           auth = {
54             user: CONFIG.SMTP.USERNAME,
55             pass: CONFIG.SMTP.PASSWORD
56           }
57         }
58
59         this.transporter = createTransport({
60           host: CONFIG.SMTP.HOSTNAME,
61           port: CONFIG.SMTP.PORT,
62           secure: CONFIG.SMTP.TLS,
63           debug: CONFIG.LOG.LEVEL === 'debug',
64           logger: bunyanLogger as any,
65           ignoreTLS: CONFIG.SMTP.DISABLE_STARTTLS,
66           tls,
67           auth
68         })
69       } else { // sendmail
70         logger.info('Using sendmail to send emails')
71
72         this.transporter = createTransport({
73           sendmail: true,
74           newline: 'unix',
75           path: CONFIG.SMTP.SENDMAIL
76         })
77       }
78     } else {
79       if (!isTestInstance()) {
80         logger.error('Cannot use SMTP server because of lack of configuration. PeerTube will not be able to send mails!')
81       }
82     }
83   }
84
85   static isEnabled () {
86     if (CONFIG.SMTP.TRANSPORT === 'sendmail') {
87       return !!CONFIG.SMTP.SENDMAIL
88     } else if (CONFIG.SMTP.TRANSPORT === 'smtp') {
89       return !!CONFIG.SMTP.HOSTNAME && !!CONFIG.SMTP.PORT
90     } else {
91       return false
92     }
93   }
94
95   async checkConnectionOrDie () {
96     if (!this.transporter || CONFIG.SMTP.TRANSPORT !== 'smtp') return
97
98     logger.info('Testing SMTP server...')
99
100     try {
101       const success = await this.transporter.verify()
102       if (success !== true) this.dieOnConnectionFailure()
103
104       logger.info('Successfully connected to SMTP server.')
105     } catch (err) {
106       this.dieOnConnectionFailure(err)
107     }
108   }
109
110   addNewVideoFromSubscriberNotification (to: string[], video: MVideoAccountLight) {
111     const channelName = video.VideoChannel.getDisplayName()
112     const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
113
114     const emailPayload: EmailPayload = {
115       to,
116       subject: channelName + ' just published a new video',
117       text: `Your subscription ${channelName} just published a new video: "${video.name}".`,
118       locals: {
119         title: 'New content ',
120         action: {
121           text: 'View video',
122           url: videoUrl
123         }
124       }
125     }
126
127     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
128   }
129
130   addNewFollowNotification (to: string[], actorFollow: MActorFollowFull, followType: 'account' | 'channel') {
131     const followingName = (actorFollow.ActorFollowing.VideoChannel || actorFollow.ActorFollowing.Account).getDisplayName()
132
133     const emailPayload: EmailPayload = {
134       template: 'follower-on-channel',
135       to,
136       subject: `New follower on your channel ${followingName}`,
137       locals: {
138         followerName: actorFollow.ActorFollower.Account.getDisplayName(),
139         followerUrl: actorFollow.ActorFollower.url,
140         followingName,
141         followingUrl: actorFollow.ActorFollowing.url,
142         followType
143       }
144     }
145
146     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
147   }
148
149   addNewInstanceFollowerNotification (to: string[], actorFollow: MActorFollowActors) {
150     const awaitingApproval = actorFollow.state === 'pending' ? ' awaiting manual approval.' : ''
151
152     const emailPayload: EmailPayload = {
153       to,
154       subject: 'New instance follower',
155       text: `Your instance has a new follower: ${actorFollow.ActorFollower.url}${awaitingApproval}.`,
156       locals: {
157         title: 'New instance follower',
158         action: {
159           text: 'Review followers',
160           url: WEBSERVER.URL + '/admin/follows/followers-list'
161         }
162       }
163     }
164
165     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
166   }
167
168   addAutoInstanceFollowingNotification (to: string[], actorFollow: MActorFollowActors) {
169     const instanceUrl = actorFollow.ActorFollowing.url
170     const emailPayload: EmailPayload = {
171       to,
172       subject: 'Auto instance following',
173       text: `Your instance automatically followed a new instance: <a href="${instanceUrl}">${instanceUrl}</a>.`
174     }
175
176     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
177   }
178
179   myVideoPublishedNotification (to: string[], video: MVideo) {
180     const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
181
182     const emailPayload: EmailPayload = {
183       to,
184       subject: `Your video ${video.name} has been published`,
185       text: `Your video "${video.name}" has been published.`,
186       locals: {
187         title: 'You video is live',
188         action: {
189           text: 'View video',
190           url: videoUrl
191         }
192       }
193     }
194
195     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
196   }
197
198   myVideoImportSuccessNotification (to: string[], videoImport: MVideoImportVideo) {
199     const videoUrl = WEBSERVER.URL + videoImport.Video.getWatchStaticPath()
200
201     const emailPayload: EmailPayload = {
202       to,
203       subject: `Your video import ${videoImport.getTargetIdentifier()} is complete`,
204       text: `Your video "${videoImport.getTargetIdentifier()}" just finished importing.`,
205       locals: {
206         title: 'Import complete',
207         action: {
208           text: 'View video',
209           url: videoUrl
210         }
211       }
212     }
213
214     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
215   }
216
217   myVideoImportErrorNotification (to: string[], videoImport: MVideoImport) {
218     const importUrl = WEBSERVER.URL + '/my-account/video-imports'
219
220     const text =
221       `Your video import "${videoImport.getTargetIdentifier()}" encountered an error.` +
222       '\n\n' +
223       `See your videos import dashboard for more information: <a href="${importUrl}">${importUrl}</a>.`
224
225     const emailPayload: EmailPayload = {
226       to,
227       subject: `Your video import "${videoImport.getTargetIdentifier()}" encountered an error`,
228       text,
229       locals: {
230         title: 'Import failed',
231         action: {
232           text: 'Review imports',
233           url: importUrl
234         }
235       }
236     }
237
238     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
239   }
240
241   addNewCommentOnMyVideoNotification (to: string[], comment: MCommentOwnerVideo) {
242     const video = comment.Video
243     const videoUrl = WEBSERVER.URL + comment.Video.getWatchStaticPath()
244     const commentUrl = WEBSERVER.URL + comment.getCommentStaticPath()
245
246     const emailPayload: EmailPayload = {
247       template: 'video-comment-new',
248       to,
249       subject: 'New comment on your video ' + video.name,
250       locals: {
251         accountName: comment.Account.getDisplayName(),
252         accountUrl: comment.Account.Actor.url,
253         comment,
254         video,
255         videoUrl,
256         action: {
257           text: 'View comment',
258           url: commentUrl
259         }
260       }
261     }
262
263     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
264   }
265
266   addNewCommentMentionNotification (to: string[], comment: MCommentOwnerVideo) {
267     const accountName = comment.Account.getDisplayName()
268     const video = comment.Video
269     const videoUrl = WEBSERVER.URL + comment.Video.getWatchStaticPath()
270     const commentUrl = WEBSERVER.URL + comment.getCommentStaticPath()
271
272     const emailPayload: EmailPayload = {
273       template: 'video-comment-mention',
274       to,
275       subject: 'Mention on video ' + video.name,
276       locals: {
277         comment,
278         video,
279         videoUrl,
280         accountName,
281         action: {
282           text: 'View comment',
283           url: commentUrl
284         }
285       }
286     }
287
288     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
289   }
290
291   addVideoAbuseModeratorsNotification (to: string[], parameters: {
292     videoAbuse: VideoAbuse
293     videoAbuseInstance: MVideoAbuseVideo
294     reporter: string
295   }) {
296     const videoAbuseUrl = WEBSERVER.URL + '/admin/moderation/video-abuses/list?search=%23' + parameters.videoAbuse.id
297     const videoUrl = WEBSERVER.URL + parameters.videoAbuseInstance.Video.getWatchStaticPath()
298
299     const emailPayload: EmailPayload = {
300       template: 'video-abuse-new',
301       to,
302       subject: `New video abuse report from ${parameters.reporter}`,
303       locals: {
304         videoUrl,
305         videoAbuseUrl,
306         videoCreatedAt: new Date(parameters.videoAbuseInstance.Video.createdAt).toLocaleString(),
307         videoPublishedAt: new Date(parameters.videoAbuseInstance.Video.publishedAt).toLocaleString(),
308         videoAbuse: parameters.videoAbuse,
309         reporter: parameters.reporter,
310         action: {
311           text: 'View report #' + parameters.videoAbuse.id,
312           url: videoAbuseUrl
313         }
314       }
315     }
316
317     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
318   }
319
320   async addVideoAutoBlacklistModeratorsNotification (to: string[], videoBlacklist: MVideoBlacklistLightVideo) {
321     const VIDEO_AUTO_BLACKLIST_URL = WEBSERVER.URL + '/admin/moderation/video-auto-blacklist/list'
322     const videoUrl = WEBSERVER.URL + videoBlacklist.Video.getWatchStaticPath()
323     const channel = (await VideoChannelModel.loadByIdAndPopulateAccount(videoBlacklist.Video.channelId)).toFormattedSummaryJSON()
324
325     const emailPayload: EmailPayload = {
326       template: 'video-auto-blacklist-new',
327       to,
328       subject: 'A new video is pending moderation',
329       locals: {
330         channel,
331         videoUrl,
332         videoName: videoBlacklist.Video.name,
333         action: {
334           text: 'Review autoblacklist',
335           url: VIDEO_AUTO_BLACKLIST_URL
336         }
337       }
338     }
339
340     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
341   }
342
343   addNewUserRegistrationNotification (to: string[], user: MUser) {
344     const emailPayload: EmailPayload = {
345       template: 'user-registered',
346       to,
347       subject: `a new user registered on ${WEBSERVER.HOST}: ${user.username}`,
348       locals: {
349         user
350       }
351     }
352
353     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
354   }
355
356   addVideoBlacklistNotification (to: string[], videoBlacklist: MVideoBlacklistVideo) {
357     const videoName = videoBlacklist.Video.name
358     const videoUrl = WEBSERVER.URL + videoBlacklist.Video.getWatchStaticPath()
359
360     const reasonString = videoBlacklist.reason ? ` for the following reason: ${videoBlacklist.reason}` : ''
361     const blockedString = `Your video ${videoName} (${videoUrl} on ${WEBSERVER.HOST} has been blacklisted${reasonString}.`
362
363     const emailPayload: EmailPayload = {
364       to,
365       subject: `Video ${videoName} blacklisted`,
366       text: blockedString,
367       locals: {
368         title: 'Your video was blacklisted'
369       }
370     }
371
372     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
373   }
374
375   addVideoUnblacklistNotification (to: string[], video: MVideo) {
376     const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
377
378     const emailPayload: EmailPayload = {
379       to,
380       subject: `Video ${video.name} unblacklisted`,
381       text: `Your video "${video.name}" (${videoUrl}) on ${WEBSERVER.HOST} has been unblacklisted.`,
382       locals: {
383         title: 'Your video was unblacklisted'
384       }
385     }
386
387     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
388   }
389
390   addPasswordResetEmailJob (to: string, resetPasswordUrl: string) {
391     const emailPayload: EmailPayload = {
392       template: 'password-reset',
393       to: [ to ],
394       subject: 'Reset your account password',
395       locals: {
396         resetPasswordUrl
397       }
398     }
399
400     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
401   }
402
403   addPasswordCreateEmailJob (username: string, to: string, createPasswordUrl: string) {
404     const emailPayload: EmailPayload = {
405       template: 'password-create',
406       to: [ to ],
407       subject: 'Create your account password',
408       locals: {
409         username,
410         createPasswordUrl
411       }
412     }
413
414     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
415   }
416
417   addVerifyEmailJob (to: string, verifyEmailUrl: string) {
418     const emailPayload: EmailPayload = {
419       template: 'verify-email',
420       to: [ to ],
421       subject: `Verify your email on ${WEBSERVER.HOST}`,
422       locals: {
423         verifyEmailUrl
424       }
425     }
426
427     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
428   }
429
430   addUserBlockJob (user: MUser, blocked: boolean, reason?: string) {
431     const reasonString = reason ? ` for the following reason: ${reason}` : ''
432     const blockedWord = blocked ? 'blocked' : 'unblocked'
433
434     const to = user.email
435     const emailPayload: EmailPayload = {
436       to: [ to ],
437       subject: 'Account ' + blockedWord,
438       text: `Your account ${user.username} on ${WEBSERVER.HOST} has been ${blockedWord}${reasonString}.`
439     }
440
441     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
442   }
443
444   addContactFormJob (fromEmail: string, fromName: string, subject: string, body: string) {
445     const emailPayload: EmailPayload = {
446       template: 'contact-form',
447       to: [ CONFIG.ADMIN.EMAIL ],
448       replyTo: `"${fromName}" <${fromEmail}>`,
449       subject: `(contact form) ${subject}`,
450       locals: {
451         fromName,
452         fromEmail,
453         body
454       }
455     }
456
457     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
458   }
459
460   async sendMail (options: EmailPayload) {
461     if (!isEmailEnabled()) {
462       throw new Error('Cannot send mail because SMTP is not configured.')
463     }
464
465     const fromDisplayName = options.from
466       ? options.from
467       : WEBSERVER.HOST
468
469     const email = new Email({
470       send: true,
471       message: {
472         from: `"${fromDisplayName}" <${CONFIG.SMTP.FROM_ADDRESS}>`
473       },
474       transport: this.transporter,
475       views: {
476         root: join(root(), 'dist', 'server', 'lib', 'emails')
477       },
478       subjectPrefix: CONFIG.EMAIL.SUBJECT.PREFIX
479     })
480
481     for (const to of options.to) {
482       await email
483         .send(merge(
484           {
485             template: 'common',
486             message: {
487               to,
488               from: options.from,
489               subject: options.subject,
490               replyTo: options.replyTo
491             },
492             locals: { // default variables available in all templates
493               WEBSERVER,
494               EMAIL: CONFIG.EMAIL,
495               text: options.text,
496               subject: options.subject
497             }
498           },
499           options // overriden/new variables given for a specific template in the payload
500         ) as SendEmailOptions)
501         .then(res => logger.debug('Sent email.', { res }))
502         .catch(err => logger.error('Error in email sender.', { err }))
503     }
504   }
505
506   private dieOnConnectionFailure (err?: Error) {
507     logger.error('Failed to connect to SMTP %s:%d.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT, { err })
508     process.exit(-1)
509   }
510
511   static get Instance () {
512     return this.instance || (this.instance = new this())
513   }
514 }
515
516 // ---------------------------------------------------------------------------
517
518 export {
519   Emailer
520 }