emailer.ts 20 KB

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