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