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