• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / blurts-server / 34cde2c5-46cb-4312-850d-fcea45fa1c76

pending completion
34cde2c5-46cb-4312-850d-fcea45fa1c76

Pull #2959

circleci

Vincent
fixup! Pretend HTMLElement.shadowRoot is always set
Pull Request #2959: Add type checking for a couple of files

282 of 1619 branches covered (17.42%)

Branch coverage included in aggregate %.

31 of 31 new or added lines in 6 files covered. (100.0%)

959 of 4333 relevant lines covered (22.13%)

3.69 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/utils/email.js
1
/* This Source Code Form is subject to the terms of the Mozilla Public
2
 * License, v. 2.0. If a copy of the MPL was not distributed with this
3
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4

5
import { createTransport } from 'nodemailer'
6
import { URL } from 'url'
7

8
import mozlog from './log.js'
9
import AppConstants from '../app-constants.js'
10
import { MethodNotAllowedError } from '../utils/error.js'
11
import { getMessage, fluentError } from '../utils/fluent.js'
12
import { updateMonthlyEmailOptout } from '../db/tables/subscribers.js'
13

14
const log = mozlog('email-utils')
×
15

16
const { SERVER_URL } = AppConstants
×
17

18
// The SMTP transport object. This is initialized to a nodemailer transport
19
// object while reading SMTP credentials, or to a dummy function in debug mode.
20
let gTransporter
21

22
const EmailTemplateType = {
×
23
  Notification: 'notification',
24
  Verification: 'verification',
25
  Monthly: 'monthly',
26
  SignupReport: 'signup-report'
27
}
28

29
async function initEmail (smtpUrl = AppConstants.SMTP_URL) {
×
30
  // Allow a debug mode that will log JSON instead of sending emails.
31
  if (!smtpUrl) {
×
32
    log.info('smtpUrl-empty', { message: 'EmailUtils will log a JSON response instead of sending emails.' })
×
33
    gTransporter = createTransport({ jsonTransport: true })
×
34
    return true
×
35
  }
36

37
  gTransporter = createTransport(smtpUrl)
×
38
  const gTransporterVerification = await gTransporter.verify()
×
39
  return gTransporterVerification
×
40
}
41

42
/**
43
 * Send Email
44
 *
45
 * @param {string} recipient
46
 * @param {string} subject
47
 * @param {string} html
48
 * @returns {Promise} <Promise>
49
 */
50
function sendEmail (recipient, subject, html) {
51
  if (!gTransporter) {
×
52
    return Promise.reject(new Error('SMTP transport not initialized'))
×
53
  }
54

55
  return new Promise((resolve, reject) => {
×
56
    const emailFrom = AppConstants.EMAIL_FROM
×
57
    const mailOptions = {
×
58
      from: emailFrom,
59
      to: recipient,
60
      subject,
61
      html,
62
      headers: {
63
        'x-ses-configuration-set': AppConstants.SES_CONFIG_SET
64
      }
65
    }
66

67
    gTransporter.sendMail(mailOptions, (error, info) => {
×
68
      if (error) {
×
69
        reject(new Error(error))
×
70
        return
×
71
      }
72
      if (gTransporter.transporter.name === 'JSONTransport') {
×
73
        log.info('JSONTransport', { message: info.message.toString() })
×
74
      }
75
      resolve(info)
×
76
    })
77
  })
78
}
79

80
function appendUrlParams (url, urlParams) {
81
  const defaultUtmParams = {
×
82
    utm_source: 'fx-monitor',
83
    utm_medium: 'email'
84
  }
85

86
  const allUtmParams = { ...defaultUtmParams, ...urlParams }
×
87

88
  for (const param in allUtmParams) {
×
89
    const paramValue = allUtmParams[param]
×
90

91
    if (typeof paramValue !== 'undefined' && paramValue) {
×
92
      url.searchParams.set(param, encodeURIComponent(paramValue))
×
93
    }
94
  }
95

96
  return url
×
97
}
98

99
function getEmailCtaHref (emailType, content, subscriberId = null) {
×
100
  const dashboardUrl = `${SERVER_URL}/user/breaches`
×
101
  const url = new URL(dashboardUrl)
×
102
  const utmParams = {
×
103
    subscriber_id: subscriberId,
104
    utm_campaign: emailType,
105
    utm_content: content
106
  }
107

108
  return appendUrlParams(url, utmParams)
×
109
}
110

111
function getVerificationUrl (subscriber) {
112
  if (!subscriber.verification_token) {
×
113
    throw new Error('subscriber has no verification_token')
×
114
  }
115

116
  const url = new URL(`${SERVER_URL}/api/v1/user/verify-email`)
×
117
  const verificationUtmParams = {
×
118
    token: subscriber.verification_token,
119
    utm_campaign: 'verified-subscribers',
120
    utm_content: 'account-verification-email'
121
  }
122

123
  return appendUrlParams(url, verificationUtmParams)
×
124
}
125

126
function getUnsubscribeCtaHref ({ subscriber, isMonthlyEmail }) {
127
  const path = isMonthlyEmail
×
128
    ? `${SERVER_URL}/user/unsubscribe-monthly`
129
    : `${SERVER_URL}/user/unsubscribe`
130

131
  if (!subscriber) {
×
132
    return path
×
133
  }
134

135
  if (isMonthlyEmail && !subscriber.primary_verification_token) {
×
136
    throw new Error('subscriber has no primary verification_token')
×
137
  }
138

139
  const url = new URL(path)
×
140
  const token = Object.hasOwn(subscriber, 'verification_token')
×
141
    ? subscriber.verification_token
142
    : subscriber.primary_verification_token
143
  const hash = Object.hasOwn(subscriber, 'sha1')
×
144
    ? subscriber.sha1
145
    : subscriber.primary_sha1
146

147
  // Mandatory params for unsubscribing a user
148
  const unsubscribeUtmParams = {
×
149
    hash,
150
    token,
151
    utm_campaign: isMonthlyEmail
×
152
      ? 'monthly-unresolved'
153
      : 'unsubscribe',
154
    utm_content: 'unsubscribe-cta'
155
  }
156

157
  return appendUrlParams(url, unsubscribeUtmParams)
×
158
}
159

160
/**
161
 * Check if params contain all mandatory params.
162
 *
163
 * @param {object} params Params that we like to have checked
164
 * @param {string} mandatoryParams A comma separated list of mandatory params
165
 * @returns {boolean} True if all mandatory params are present in params
166
 */
167
function hasMandatoryParams (params, mandatoryParams) {
168
  return mandatoryParams.split(',').every(paramKey => {
×
169
    const paramKeyParsed = paramKey.trim()
×
170
    return (
×
171
      Object.hasOwn(params, paramKeyParsed) &&
×
172
      params[paramKeyParsed] !== ''
173
    )
174
  })
175
}
176

177
/**
178
 * TODO: Unsubscribing from emails is currently only implemented for the
179
 * monthly unresolved breach report.
180
 *
181
 * Unsubscribes a user from receiving emails other than the monthly reports.
182
 *
183
 * @param {object} req Request should contain a `token` and `hash` query param.
184
 */
185
async function unsubscribeFromEmails (req) {
186
  const urlQuery = req.query
×
187

188
  // For unsubscribing from emails we need a hash and token
189
  if (!hasMandatoryParams(urlQuery, 'hash,token')) {
×
190
    throw fluentError('user-unsubscribe-token-email-error')
×
191
  }
192

193
  throw new MethodNotAllowedError()
×
194
}
195

196
/**
197
 * Unsubscribe the user from receiving the monthly unresolved breach reports.
198
 *
199
 * @param {object} req Request that should contain a `token` query param
200
 */
201
async function unsubscribeFromMonthlyReport (req) {
202
  const urlQuery = req.query
×
203

204
  // For unsubscribing from the monthly emails we need a token
205
  if (!hasMandatoryParams(urlQuery, 'token')) {
×
206
    throw fluentError('user-unsubscribe-token-error')
×
207
  }
208

209
  // Unsubscribe user from the monthly unresolved breach emails
210
  await updateMonthlyEmailOptout(urlQuery.token)
×
211
}
212

213
/**
214
 * Dummy data for populating the breach notification email preview.
215
 *
216
 * @param {string} recipient
217
 * @returns {object} Breach dummy data
218
 */
219
const getNotifictionDummyData = (recipient) => ({
×
220
  breachData: {
221
    Id: 1,
222
    Name: 'Adobe',
223
    Title: 'Adobe',
224
    Domain: 'adobe.com',
225
    BreachDate: '2013-01-01T22:00:00.000Z',
226
    AddedDate: '2013-01-02T00:00:00.000Z',
227
    ModifiedDate: '2023-01-01T00:00:00.000Z',
228
    PwnCount: 123,
229
    Description: 'Example description',
230
    LogoPath: '/images/favicon-144.webp',
231
    DataClasses: [
232
      'email-addresses',
233
      'password-hints',
234
      'passwords',
235
      'usernames'
236
    ],
237
    IsVerified: true,
238
    IsFabricated: false,
239
    IsSensitive: false,
240
    IsRetired: false,
241
    IsSpamList: false,
242
    IsMalware: false
243
  },
244
  breachedEmail: recipient,
245
  ctaHref: getEmailCtaHref('email-test-notification', 'dashboard-cta'),
246
  heading: getMessage('email-spotted-new-breach'),
247
  recipientEmail: recipient,
248
  subscriberId: 123,
249
  supportedLocales: ['en']
250
})
251

252
/**
253
 * Dummy data for populating the email verification preview
254
 *
255
 * @param {string} recipient
256
 * @returns {object} Email verification dummy data
257
 */
258
const getVerificationDummyData = (recipient) => ({
×
259
  ctaHref: SERVER_URL,
260
  heading: getMessage('email-verify-heading'),
261
  recipientEmail: recipient,
262
  subheading: getMessage('email-verify-subhead')
263
})
264

265
/**
266
 * Dummy data for populating the monthly unresolved breaches email
267
 *
268
 * @param {string} recipient
269
 * @returns {object} Monthly unresolved breaches dummy data
270
 */
271
const getMonthlyDummyData = (recipient) => ({
×
272
  breachedEmail: 'breached@email.com',
273
  ctaHref: `${SERVER_URL}/user/breaches`,
274
  heading: getMessage('email-unresolved-heading'),
275
  monitoredEmails: {
276
    count: 2
277
  },
278
  numBreaches: {
279
    count: 3,
280
    numResolved: 2,
281
    numUnresolved: 1
282
  },
283
  recipientEmail: recipient,
284
  subheading: getMessage('email-unresolved-subhead'),
285
  unsubscribeUrl: `${SERVER_URL}/user/unsubscribe-monthly?token=token_123`
286
})
287

288
/**
289
 * Dummy data for populating the signup report email
290
 *
291
 * @param {string} recipient
292
 * @returns {object} Signup report email dummy data
293
 */
294

295
const getSignupReportDummyData = (recipient) => {
×
296
  const unsafeBreachesForEmail = [
×
297
    getNotifictionDummyData(recipient).breachData
298
  ]
299
  const breachesCount = unsafeBreachesForEmail.length
×
300
  const numPasswordsExposed = 1
×
301

302
  const emailBreachStats = [
×
303
    {
304
      statNumber: breachesCount,
305
      statTitle: getMessage('known-data-breaches-exposed', {
306
        breaches: breachesCount
307
      })
308
    },
309
    {
310
      statNumber: numPasswordsExposed,
311
      statTitle: getMessage('passwords-exposed', {
312
        passwords: numPasswordsExposed
313
      })
314
    }
315
  ]
316

317
  return {
×
318
    breachedEmail: recipient,
319
    ctaHref: getEmailCtaHref('email-test-notification', 'dashboard-cta'),
320
    heading: unsafeBreachesForEmail.length
×
321
      ? getMessage('email-subject-found-breaches')
322
      : getMessage('email-subject-no-breaches'),
323
    emailBreachStats,
324
    recipientEmail: recipient,
325
    subscriberId: 123,
326
    unsafeBreachesForEmail
327
  }
328
}
329

330
export {
331
  EmailTemplateType,
332
  getEmailCtaHref,
333
  getMonthlyDummyData,
334
  getNotifictionDummyData,
335
  getSignupReportDummyData,
336
  getUnsubscribeCtaHref,
337
  getVerificationDummyData,
338
  getVerificationUrl,
339
  initEmail,
340
  unsubscribeFromEmails,
341
  unsubscribeFromMonthlyReport,
342
  sendEmail
343
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc