Prepare for user registration system

pull/5/head
sigonasr2 4 years ago
parent ec3a90c082
commit 5d0de5240b
  1. 2
      docker-compose.yml
  2. 2
      frontend/.gitignore
  3. 12
      frontend/src/App.js
  4. 21
      frontend/src/setupProxy.js
  5. 77
      server/app.js
  6. 8
      server/node_modules/nodemailer/.prettierrc.js
  7. 615
      server/node_modules/nodemailer/CHANGELOG.md
  8. 76
      server/node_modules/nodemailer/CODE_OF_CONDUCT.md
  9. 67
      server/node_modules/nodemailer/CONTRIBUTING.md
  10. 16
      server/node_modules/nodemailer/LICENSE
  11. 44
      server/node_modules/nodemailer/README.md
  12. 313
      server/node_modules/nodemailer/lib/addressparser/index.js
  13. 142
      server/node_modules/nodemailer/lib/base64/index.js
  14. 251
      server/node_modules/nodemailer/lib/dkim/index.js
  15. 158
      server/node_modules/nodemailer/lib/dkim/message-parser.js
  16. 154
      server/node_modules/nodemailer/lib/dkim/relaxed-body.js
  17. 117
      server/node_modules/nodemailer/lib/dkim/sign.js
  18. 284
      server/node_modules/nodemailer/lib/fetch/cookies.js
  19. 277
      server/node_modules/nodemailer/lib/fetch/index.js
  20. 82
      server/node_modules/nodemailer/lib/json-transport/index.js
  21. 549
      server/node_modules/nodemailer/lib/mail-composer/index.js
  22. 423
      server/node_modules/nodemailer/lib/mailer/index.js
  23. 320
      server/node_modules/nodemailer/lib/mailer/mail-message.js
  24. 628
      server/node_modules/nodemailer/lib/mime-funcs/index.js
  25. 2109
      server/node_modules/nodemailer/lib/mime-funcs/mime-types.js
  26. 1247
      server/node_modules/nodemailer/lib/mime-node/index.js
  27. 33
      server/node_modules/nodemailer/lib/mime-node/last-newline.js
  28. 148
      server/node_modules/nodemailer/lib/nodemailer.js
  29. 219
      server/node_modules/nodemailer/lib/qp/index.js
  30. 208
      server/node_modules/nodemailer/lib/sendmail-transport/index.js
  31. 43
      server/node_modules/nodemailer/lib/sendmail-transport/le-unix.js
  32. 52
      server/node_modules/nodemailer/lib/sendmail-transport/le-windows.js
  33. 312
      server/node_modules/nodemailer/lib/ses-transport/index.js
  34. 510
      server/node_modules/nodemailer/lib/shared/index.js
  35. 108
      server/node_modules/nodemailer/lib/smtp-connection/data-stream.js
  36. 131
      server/node_modules/nodemailer/lib/smtp-connection/http-proxy-client.js
  37. 1757
      server/node_modules/nodemailer/lib/smtp-connection/index.js
  38. 641
      server/node_modules/nodemailer/lib/smtp-pool/index.js
  39. 253
      server/node_modules/nodemailer/lib/smtp-pool/pool-resource.js
  40. 408
      server/node_modules/nodemailer/lib/smtp-transport/index.js
  41. 142
      server/node_modules/nodemailer/lib/stream-transport/index.js
  42. 47
      server/node_modules/nodemailer/lib/well-known/index.js
  43. 262
      server/node_modules/nodemailer/lib/well-known/services.json
  44. 368
      server/node_modules/nodemailer/lib/xoauth2/index.js
  45. 74
      server/node_modules/nodemailer/package.json
  46. 101
      server/node_modules/nodemailer/postinstall.js
  47. 5
      server/package-lock.json
  48. 5
      server/package.json

@ -47,6 +47,8 @@ services:
- CHOKIDAR_USEPOLLING=true
stdin_open: true
working_dir: /frontend
env_file:
- ./frontend/.env
volumes:
- /app/node_modules
- ./frontend:/frontend

@ -21,3 +21,5 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env

@ -1130,6 +1130,15 @@ function SimpleUpload(p){
)
}
function SendMail() {
//process.env.REACT_APP_FRONTEND_AUTH
return (
<>
</>
)
}
function Submit(p) {
return (
<div className="row">
@ -1243,6 +1252,9 @@ function Website() {
<Route path="/submitplay">
<Submit songs={songs}/>
</Route>
<Route path="/sendmail">
<SendMail/>
</Route>
<Route path="/recentplays">
<h1 className="title">Project DivaR</h1>
<RecentPlays songs={songs}/>

@ -1,6 +1,27 @@
const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function(app) {
app.use(
"/authenticateuser",
createProxyMiddleware({
target: 'http://server:4501',
changeOrigin: true,
})
);
app.use(
"/sendemail/register",
createProxyMiddleware({
target: 'http://server:4501',
changeOrigin: true,
})
);
app.use(
"/sendemail/login",
createProxyMiddleware({
target: 'http://server:4501',
changeOrigin: true,
})
);
app.use(
"/recentplays/:username",
createProxyMiddleware({

@ -12,6 +12,7 @@ app.use(
extended: true,
})
)
const nodemailer = require('nodemailer');
const fileUpload = require('express-fileupload');
const unzipper = require('unzipper');
const fs = require('fs');
@ -702,6 +703,82 @@ app.post('/song/:songname/:difficulty',(req,res)=>{
}
})
function CheckUserExists(username,email) {
return db.query("select id,username,email from users where username=$1 or email=$2 limit 1",[username,email])
}
function SendRegistrationEmail(username,emailTo,authCode) {
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: 'admin@projectdivar.com',
pass: process.env.GMAIL // naturally, replace both with your real credentials or an application-specific password
}
});
transporter.sendMail({
from: 'admin@projectdivar.com',
to: emailTo,
subject: 'Project DivaR Registration Code',
html: `<b>${username}</b>,<br><br>Thank you for signing up for Project DivaR!<br><br>Your authentication code is <b>${authCode}</b>!`
}, (err, info) => {
if (err) {
console.log(err.message)
} else {
console.log(info.envelope);
console.log(info.messageId);
}
});
}
app.post('/sendemail/register',function(req,res) {
if (req.body&&req.body.username&&req.body.email) {
//Generate a token for the user to login with.
CheckUserExists(req.body.username,req.body.email)
.then((data)=>{
var authCode=Math.floor(Math.random()*90000)+10000
var authenticationToken=String(Math.floor(Math.random()*90000)+10000)+"-"+String(Math.floor(Math.random()*90000)+10000)+"-"+String(Math.floor(Math.random()*90000)+10000);
if (data.rows.length>0) {
db.query("update users set code=$1 where id=$2",[authCode,data.rows[0].id])
} else {
db.query("insert into users(username,email,authentication_token,code) values($1,$2,$3,$4)",
[req.body.username,req.body.email,authenticationToken,authCode])
}
return authCode
})
.then((authCode)=>{
res.status(200).json("Email sent.")
SendRegistrationEmail(req.body.username,req.body.email,authCode)
})
.catch((err)=>{
res.status(500).json(err.message)
})
} else {
res.status(400).json("Invalid credentials!")
}
})
function AuthenticateUser(username,auth) {
return db.query("select id,username,email from users where username=$1 and authentication_token=$2 limit 1",[username,auth])
}
app.post('/authenticateuser',function(req,res) {
if (req.body&&req.body.username&&req.body.authenticationToken) {
AuthenticateUser(req.body.username,req.body.authenticationToken)
.then((data)=>{
if (data.rows.length>0) {
res.status(200).json("Authentication Success!")
} else {
res.status(400).json("Authentication Failed!")
}
})
.catch((err)=>{
res.status(500).json(err.message)
})
} else {
res.status(400).json("Invalid credentials!")
}
})
/*
app.get('/twitter/mentions', function(req, res) {
if (req.query.data) {

@ -0,0 +1,8 @@
module.exports = {
printWidth: 160,
tabWidth: 4,
singleQuote: true,
endOfLine: 'lf',
trailingComma: 'none',
arrowParens: 'avoid'
};

@ -0,0 +1,615 @@
# CHANGELOG
## 6.4.11 2020-07-29
- Fixed escape sequence handling in address parsing
## 6.4.10 2020-06-17
- Fixed RFC822 output for MailComposer when using invalid content-type value. Mostly relevant if message attachments have stragne content-type values set.
## 6.4.7 2020-05-28
- Always set charset=utf-8 for Content-Type headers
- Catch error whn using invalid crypto.sign input
## 6.4.6 2020-03-20
- fix: `requeueAttempts=n` should requeue `n` times (Patrick Malouin) [a27ed2f7]
## 6.4.4 2020-03-01
- Add `options.forceAuth` for SMTP (Patrick Malouin) [a27ed2f7]
## 6.4.3 2020-02-22
- Added an option to specify max number of requeues when connection closes unexpectedly (Igor Sechyn) [8a927f5a]
## 6.4.2 2019-12-11
- Fixed bug where array item was used with a potentially empty array
## 6.4.1 2019-12-07
- Fix processing server output with unterminated responses
## 6.4.0 2019-12-04
- Do not use auth if server does not advertise AUTH support [f419b09d]
- add dns.CONNREFUSED (Hiroyuki Okada) [5c4c8ca8]
## 6.3.1 2019-10-09
- Ignore "end" events because it might be "error" after it (dex4er) [72bade9]
- Set username and password on the connection proxy object correctly (UsamaAshraf) [250b1a8]
- Support more DNS errors (madarche) [2391aa4]
## 6.3.0 2019-07-14
- Added new option to pass a set of httpHeaders to be sent when fetching attachments. See [PR #1034](https://github.com/nodemailer/nodemailer/pull/1034)
## 6.2.1 2019-05-24
- No changes. It is the same as 6.2.0 that was accidentally published as 6.2.1 to npm
## 6.2.0 2019-05-24
- Added new option for addressparser: `flatten`. If true then ignores group names and returns a single list of all addresses
## 6.1.1 2019-04-20
- Fixed regression bug with missing smtp `authMethod` property
## 6.1.0 2019-04-06
- Added new message property `amp` for providing AMP4EMAIL content
## 6.0.0 2019-03-25
- SMTPConnection: use removeListener instead of removeAllListeners (xr0master) [ddc4af15]
Using removeListener should fix memory leak with Node.js streams
## 5.1.1 2019-01-09
- Added missing option argument for custom auth
## 5.1.0 2019-01-09
- Official support for custom authentication methods and examples (examples/custom-auth-async.js and examples/custom-auth-cb.js)
## 5.0.1 2019-01-09
- Fixed regression error to support Node versions lower than 6.11
- Added expiremental custom authentication support
## 5.0.0 2018-12-28
- Start using dns.resolve() instead of dns.lookup() for resolving SMTP hostnames. Might be breaking change on some environments so upgrade with care
- Show more logs for renewing OAuth2 tokens, previously it was not possible to see what actually failed
## 4.7.0 2018-11-19
- Cleaned up List-\* header generation
- Fixed 'full' return option for DSN (klaronix) [23b93a3b]
- Support promises `for mailcomposer.build()`
## 4.6.8 2018-08-15
- Use first IP address from DNS resolution when using a proxy (Limbozz) [d4ca847c]
- Return raw email from SES transport (gabegorelick) [3aa08967]
## 4.6.7 2018-06-15
- Added option `skipEncoding` to JSONTransport
## 4.6.6 2018-06-10
- Fixes mime encoded-word compatibility issue with invalid clients like Zimbra
## 4.6.5 2018-05-23
- Fixed broken DKIM stream in Node.js v10
- Updated error messages for SMTP responses to not include a newline
## 4.6.4 2018-03-31
- Readded logo author link to README that was accidentally removed a while ago
## 4.6.3 2018-03-13
- Removed unneeded dependency
## 4.6.2 2018-03-06
- When redirecting URL calls then do not include original POST content
## 4.6.1 2018-03-06
- Fixed Smtp connection freezing, when trying to send after close / quit (twawszczak) [73d3911c]
## 4.6.0 2018-02-22
- Support socks module v2 in addition to v1 [e228bcb2]
- Fixed invalid promise return value when using createTestAccount [5524e627]
- Allow using local addresses [8f6fa35f]
## 4.5.0 2018-02-21
- Added new message transport option `normalizeHeaderKey(key)=>normalizedKey` for custom header formatting
## 4.4.2 2018-01-20
- Added sponsors section to README
- enclose encodeURIComponent in try..catch to handle invalid urls
## 4.4.1 2017-12-08
- Better handling of unexpectedly dropping connections
## 4.4.0 2017-11-10
- Changed default behavior for attachment option contentTransferEncoding. If it is unset then base64 encoding is used for the attachment. If it is set to false then previous default applies (base64 for most, 7bit for text)
## 4.3.1 2017-10-25
- Fixed a confict with Electron.js where timers do not have unref method
## 4.3.0 2017-10-23
- Added new mail object method `mail.normalize(cb)` that should make creating HTTP API based transports much easier
## 4.2.0 2017-10-13
- Expose streamed messages size and timers in info response
## v4.1.3 2017-10-06
- Allow generating preview links without calling createTestAccount first
## v4.1.2 2017-10-03
- No actual changes. Needed to push updated README to npmjs
## v4.1.1 2017-09-25
- Fixed JSONTransport attachment handling
## v4.1.0 2017-08-28
- Added new methods `createTestAccount` and `getTestMessageUrl` to use autogenerated email accounts from https://Ethereal.email
## v4.0.1 2017-04-13
- Fixed issue with LMTP and STARTTLS
## v4.0.0 2017-04-06
- License changed from EUPLv1.1 to MIT
## v3.1.8 2017-03-21
- Fixed invalid List-\* header generation
## v3.1.7 2017-03-14
- Emit an error if STARTTLS ends with connection being closed
## v3.1.6 2017-03-14
- Expose last server response for smtpConnection
## v3.1.5 2017-03-08
- Fixed SES transport, added missing `response` value
## v3.1.4 2017-02-26
- Fixed DKIM calculation for empty body
- Ensure linebreak after message content. This fixes DKIM signatures for non-multipart messages where input did not end with a newline
## v3.1.3 2017-02-17
- Fixed missing `transport.verify()` methods for SES transport
## v3.1.2 2017-02-17
- Added missing error handlers for Sendmail, SES and Stream transports. If a messages contained an invalid URL as attachment then these transports threw an uncatched error
## v3.1.1 2017-02-13
- Fixed missing `transport.on('idle')` and `transport.isIdle()` methods for SES transports
## v3.1.0 2017-02-13
- Added built-in transport for AWS SES. [Docs](http://localhost:1313/transports/ses/)
- Updated stream transport to allow building JSON strings. [Docs](http://localhost:1313/transports/stream/#json-transport)
- Added new method _mail.resolveAll_ that fetches all attachments and such to be able to more easily build API-based transports
## v3.0.2 2017-02-04
- Fixed a bug with OAuth2 login where error callback was fired twice if getToken was not available.
## v3.0.1 2017-02-03
- Fixed a bug where Nodemailer threw an exception if `disableFileAccess` option was used
- Added FLOSS [exception declaration](FLOSS_EXCEPTIONS.md)
## v3.0.0 2017-01-31
- Initial version of Nodemailer 3
This update brings a lot of breaking changes:
- License changed from MIT to **EUPL-1.1**. This was possible as the new version of Nodemailer is a major rewrite. The features I don't have ownership for, were removed or reimplemented. If there's still some snippets in the code that have vague ownership then notify <mailto:andris@kreata.ee> about the conflicting code and I'll fix it.
- Requires **Node.js v6+**
- All **templating is gone**. It was too confusing to use and to be really universal a huge list of different renderers would be required. Nodemailer is about email, not about parsing different template syntaxes
- **No NTLM authentication**. It was too difficult to re-implement. If you still need it then it would be possible to introduce a pluggable SASL interface where you could load the NTLM module in your own code and pass it to Nodemailer. Currently this is not possible.
- **OAuth2 authentication** is built in and has a different [configuration](https://nodemailer.com/smtp/oauth2/). You can use both user (3LO) and service (2LO) accounts to generate access tokens from Nodemailer. Additionally there's a new feature to authenticate differently for every message – useful if your application sends on behalf of different users instead of a single sender.
- **Improved Calendaring**. Provide an ical file to Nodemailer to send out [calendar events](https://nodemailer.com/message/calendar-events/).
And also some non-breaking changes:
- All **dependencies were dropped**. There is exactly 0 dependencies needed to use Nodemailer. This brings the installation time of Nodemailer from NPM down to less than 2 seconds
- **Delivery status notifications** added to Nodemailer
- Improved and built-in **DKIM** signing of messages. Previously you needed an external module for this and it did quite a lousy job with larger messages
- **Stream transport** to return a RFC822 formatted message as a stream. Useful if you want to use Nodemailer as a preprocessor and not for actual delivery.
- **Sendmail** transport built-in, no need for external transport plugin
See [Nodemailer.com](https://nodemailer.com/) for full documentation
## 2.7.0 2016-12-08
- Bumped mailcomposer that generates encoded-words differently which might break some tests
## 2.6.0 2016-09-05
- Added new options disableFileAccess and disableUrlAccess
- Fixed envelope handling where cc/bcc fields were ignored in the envelope object
## 2.4.2 2016-05-25
- Removed shrinkwrap file. Seemed to cause more trouble than help
## 2.4.1 2016-05-12
- Fixed outdated shrinkwrap file
## 2.4.0 2016-05-11
- Bumped mailcomposer module to allow using `false` as attachment filename (suppresses filename usage)
- Added NTLM authentication support
## 2.3.2 2016-04-11
- Bumped smtp transport modules to get newest smtp-connection that fixes SMTPUTF8 support for internationalized email addresses
## 2.3.1 2016-04-08
- Bumped mailcomposer to have better support for message/822 attachments
## 2.3.0 2016-03-03
- Fixed a bug with attachment filename that contains mixed unicode and dashes
- Added built-in support for proxies by providing a new SMTP option `proxy` that takes a proxy configuration url as its value
- Added option `transport` to dynamically load transport plugins
- Do not require globally installed grunt-cli
## 2.2.1 2016-02-20
- Fixed a bug in SMTP requireTLS option that was broken
## 2.2.0 2016-02-18
- Removed the need to use `clone` dependency
- Added new method `verify` to check SMTP configuration
- Direct transport uses STARTTLS by default, fallbacks to plaintext if STARTTLS fails
- Added new message option `list` for setting List-\* headers
- Add simple proxy support with `getSocket` method
- Added new message option `textEncoding`. If `textEncoding` is not set then detect best encoding automatically
- Added new message option `icalEvent` to embed iCalendar events. Example [here](examples/ical-event.js)
- Added new attachment option `raw` to use prepared MIME contents instead of generating a new one. This might be useful when you want to handcraft some parts of the message yourself, for example if you want to inject a PGP encrypted message as the contents of a MIME node
- Added new message option `raw` to use an existing MIME message instead of generating a new one
## 2.1.0 2016-02-01
Republishing 2.1.0-rc.1 as stable. To recap, here's the notable changes between v2.0 and v2.1:
- Implemented templating support. You can either use a simple built-in renderer or some external advanced renderer, eg. [node-email-templates](https://github.com/niftylettuce/node-email-templates). Templating [docs](http://nodemailer.com/2-0-0-beta/templating/).
- Updated smtp-pool to emit 'idle' events in order to handle message queue more effectively
- Updated custom header handling, works everywhere the same now, no differences between adding custom headers to the message or to an attachment
## 2.1.0-rc.1 2016-01-25
Sneaked in some new features even though it is already rc
- If a SMTP pool is closed while there are still messages in a queue, the message callbacks are invoked with an error
- In case of SMTP pool the transporter emits 'idle' when there is a free connection slot available
- Added method `isIdle()` that checks if a pool has still some free connection slots available
## 2.1.0-rc.0 2016-01-20
- Bumped dependency versions
## 2.1.0-beta.3 2016-01-20
- Added support for node-email-templates templating in addition to the built-in renderer
## 2.1.0-beta.2 2016-01-20
- Implemented simple templating feature
## 2.1.0-beta.1 2016-01-20
- Allow using prepared header values that are not folded or encoded by Nodemailer
## 2.1.0-beta.0 2016-01-20
- Use the same header custom structure for message root, attachments and alternatives
- Ensure that Message-Id exists when accessing message
- Allow using array values for custom headers (inserts every value in its own row)
## 2.0.0 2016-01-11
- Released rc.2 as stable
## 2.0.0-rc.2 2016-01-04
- Locked dependencies
## 2.0.0-beta.2 2016-01-04
- Updated documentation to reflect changes with SMTP handling
- Use beta versions for smtp/pool/direct transports
- Updated logging
## 2.0.0-beta.1 2016-01-03
- Use bunyan compatible logger instead of the emit('log') style
- Outsourced some reusable methods to nodemailer-shared
- Support setting direct/smtp/pool with the default configuration
## 2.0.0-beta.0 2015-12-31
- Stream errors are not silently swallowed
- Do not use format=flowed
- Use nodemailer-fetch to fetch URL streams
- jshint replaced by eslint
## v1.11.0 2015-12-28
Allow connection url based SMTP configurations
## v1.10.0 2015-11-13
Added `defaults` argument for `createTransport` to predefine commonn values (eg. `from` address)
## v1.9.0 2015-11-09
Returns a Promise for `sendMail` if callback is not defined
## v1.8.0 2015-10-08
Added priority option (high, normal, low) for setting Importance header
## v1.7.0 2015-10-06
Replaced hyperquest with needle. Fixes issues with compressed data and redirects
## v1.6.0 2015-10-05
Maintenance release. Bumped dependencies to get support for unicode filenames for QQ webmail and to support emoji in filenames
## v1.5.0 2015-09-24
Use mailcomposer instead of built in solution to generate message sources. Bumped libmime gives better quoted-printable handling.
## v1.4.0 2015-06-27
Added new message option `watchHtml` to specify Apple Watch specific HTML part of the message. See [this post](https://litmus.com/blog/how-to-send-hidden-version-email-apple-watch) for details
## v1.3.4 2015-04-25
Maintenance release, bumped buildmail version to get fixed format=flowed handling
## v1.3.3 2015-04-25
Maintenance release, bumped dependencies
## v1.3.2 2015-03-09
Maintenance release, upgraded dependencies. Replaced simplesmtp based tests with smtp-server based ones.
## v1.3.0 2014-09-12
Maintenance release, upgrades buildmail and libmime. Allows using functions as transform plugins and fixes issue with unicode filenames in Gmail.
## v1.2.2 2014-09-05
Proper handling of data uris as attachments. Attachment `path` property can also be defined as a data uri, not just regular url or file path.
## v1.2.1 2014-08-21
Bumped libmime and mailbuild versions to properly handle filenames with spaces (short ascii only filenames with spaces were left unquoted).
## v1.2.0 2014-08-18
Allow using encoded strings as attachments. Added new property `encoding` which defines the encoding used for a `content` string. If encoding is set, the content value is converted to a Buffer value using the defined encoding before usage. Useful for including binary attachemnts in JSON formatted email objects.
## v1.1.2 2014-08-18
Return deprecatin error for v0.x style configuration
## v1.1.1 2014-07-30
Bumped nodemailer-direct-transport dependency. Updated version includes a bugfix for Stream nodes handling. Important only if use direct-transport with Streams (not file paths or urls) as attachment content.
## v1.1.0 2014-07-29
Added new method `resolveContent()` to get the html/text/attachment content as a String or Buffer.
## v1.0.4 2014-07-23
Bugfix release. HTML node was instered twice if the message consisted of a HTML content (but no text content) + at least one attachment with CID + at least one attachment without CID. In this case the HTML node was inserted both to the root level multipart/mixed section and to the multipart/related sub section
## v1.0.3 2014-07-16
Fixed a bug where Nodemailer crashed if the message content type was multipart/related
## v1.0.2 2014-07-16
Upgraded nodemailer-smtp-transport to 0.1.11\. The docs state that for SSL you should use 'secure' option but the underlying smtp-connection module used 'secureConnection' for this purpose. Fixed smpt-connection to match the docs.
## v1.0.1 2014-07-15
Implemented missing #close method that is passed to the underlying transport object. Required by the smtp pool.
## v1.0.0 2014-07-15
Total rewrite. See migration guide here: <http://www.andrisreinman.com/nodemailer-v1-0/#migrationguide>
## v0.7.1 2014-07-09
- Upgraded aws-sdk to 2.0.5
## v0.7.0 2014-06-17
- Bumped version to v0.7.0
- Fix AWS-SES usage [5b6bc144]
- Replace current SES with new SES using AWS-SDK (Elanorr) [c79d797a]
- Updated README.md about Node Email Templates (niftylettuce) [e52bef81]
## v0.6.5 2014-05-15
- Bumped version to v0.6.5
- Use tildes instead of carets for dependency listing [5296ce41]
- Allow clients to set a custom identityString (venables) [5373287d]
- bugfix (adding "-i" to sendmail command line for each new mail) by copying this.args (vrodic) [05a8a9a3]
- update copyright (gdi2290) [3a6cba3a]
## v0.6.4 2014-05-13
- Bumped version to v0.6.4
- added npmignore, bumped dependencies [21bddcd9]
- Add AOL to well-known services (msouce) [da7dd3b7]
## v0.6.3 2014-04-16
- Bumped version to v0.6.3
- Upgraded simplesmtp dependency [dd367f59]
## v0.6.2 2014-04-09
- Bumped version to v0.6.2
- Added error option to Stub transport [c423acad]
- Use SVG npm badge (t3chnoboy) [677117b7]
- add SendCloud to well known services (haio) [43c358e0]
- High-res build-passing and NPM module badges (sahat) [9fdc37cd]
## v0.6.1 2014-01-26
- Bumped version to v0.6.1
- Do not throw on multiple errors from sendmail command [c6e2cd12]
- Do not require callback for pickup, fixes #238 [93eb3214]
- Added AWSSecurityToken information to README, fixes #235 [58e921d1]
- Added Nodemailer logo [06b7d1a8]
## v0.6.0 2013-12-30
- Bumped version to v0.6.0
- Allow defining custom transport methods [ec5b48ce]
- Return messageId with responseObject for all built in transport methods [74445cec]
- Bumped dependency versions for mailcomposer and readable-stream [9a034c34]
- Changed pickup argument name to 'directory' [01c3ea53]
- Added support for IIS pickup directory with PICKUP transport (philipproplesch) [36940b59..360a2878]
- Applied common styles [9e93a409]
- Updated readme [c78075e7]
## v0.5.15 2013-12-13
- bumped version to v0.5.15
- Updated README, added global options info for setting uo transports [554bb0e5]
- Resolve public hostname, if resolveHostname property for a transport object is set to `true` [9023a6e1..4c66b819]
## v0.5.14 2013-12-05
- bumped version to v0.5.14
- Expose status for direct messages [f0312df6]
- Allow to skip the X-Mailer header if xMailer value is set to 'false' [f2c20a68]
## v0.5.13 2013-12-03
- bumped version to v0.5.13
- Use the name property from the transport object to use for the domain part of message-id values (1598eee9)
## v0.5.12 2013-12-02
- bumped version to v0.5.12
- Expose transport method and transport module version if available [a495106e]
- Added 'he' module instead of using custom html entity decoding [c197d102]
- Added xMailer property for transport configuration object to override X-Mailer value [e8733a61]
- Updated README, added description for 'mail' method [e1f5f3a6]
## v0.5.11 2013-11-28
- bumped version to v0.5.11
- Updated mailcomposer version. Replaces ent with he [6a45b790e]
## v0.5.10 2013-11-26
- bumped version to v0.5.10
- added shorthand function mail() for direct transport type [88129bd7]
- minor tweaks and typo fixes [f797409e..ceac0ca4]
## v0.5.9 2013-11-25
- bumped version to v0.5.9
- Update for 'direct' handling [77b84e2f]
- do not require callback to be provided for 'direct' type [ec51c79f]
## v0.5.8 2013-11-22
- bumped version to v0.5.8
- Added support for 'direct' transport [826f226d..0dbbcbbc]
## v0.5.7 2013-11-18
- bumped version to v0.5.7
- Replace \r\n by \n in Sendmail transport (rolftimmermans) [fed2089e..616ec90c] A lot of sendmail implementations choke on \r\n newlines and require \n This commit addresses this by transforming all \r\n sequences passed to the sendmail command with \n
## v0.5.6 2013-11-15
- bumped version to v0.5.6
- Upgraded mailcomposer dependency to 0.2.4 [e5ff9c40]
- Removed noCR option [e810d1b8]
- Update wellknown.js, added FastMail (k-j-kleist) [cf930f6d]
## v0.5.5 2013-10-30
- bumped version to v0.5.5
- Updated mailcomposer dependnecy version to 0.2.3
- Remove legacy code - node v0.4 is not supported anymore anyway
- Use hostname (autodetected or from the options.name property) for Message-Id instead of "Nodemailer" (helps a bit when messages are identified as spam)
- Added maxMessages info to README
## v0.5.4 2013-10-29
- bumped version to v0.5.4
- added "use strict" statements
- Added DSN info to README
- add support for QQ enterprise email (coderhaoxin)
- Add a Bitdeli Badge to README
- DSN options Passthrought into simplesmtp. (irvinzz)
## v0.5.3 2013-10-03
- bumped version v0.5.3
- Using a stub transport to prevent sendmail from being called during a test. (jsdevel)
- closes #78: sendmail transport does not work correctly on Unix machines. (jsdevel)
- Updated PaaS Support list to include Modulus. (fiveisprime)
- Translate self closing break tags to newline (kosmasgiannis)
- fix typos (aeosynth)
## v0.5.2 2013-07-25
- bumped version v0.5.2
- Merge pull request #177 from MrSwitch/master Fixing Amazon SES, fatal error caused by bad connection

@ -0,0 +1,76 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at info@nodemailer.com. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq

@ -0,0 +1,67 @@
# Contribute
## Introduction
First, thank you for considering contributing to nodemailer! It's people like you that make the open source community such a great community! 😊
We welcome any type of contribution, not only code. You can help with
- **QA**: file bug reports, the more details you can give the better (e.g. screenshots with the console open)
- **Marketing**: writing blog posts, howto's, printing stickers, ...
- **Community**: presenting the project at meetups, organizing a dedicated meetup for the local community, ...
- **Code**: take a look at the [open issues](https://github.com/nodemailer/nodemailer/issues). Even if you can't write code, commenting on them, showing that you care about a given issue matters. It helps us triage them.
- **Money**: we welcome financial contributions in full transparency on our [open collective](https://opencollective.com/nodemailer).
## Your First Contribution
Working on your first Pull Request? You can learn how from this *free* series, [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github).
## Submitting code
Any code change should be submitted as a pull request. The description should explain what the code does and give steps to execute it. The pull request should also contain tests.
## Code review process
The bigger the pull request, the longer it will take to review and merge. Try to break down large pull requests in smaller chunks that are easier to review and merge.
It is also always helpful to have some context for your pull request. What was the purpose? Why does it matter to you?
## Financial contributions
We also welcome financial contributions in full transparency on our [open collective](https://opencollective.com/nodemailer).
Anyone can file an expense. If the expense makes sense for the development of the community, it will be "merged" in the ledger of our open collective by the core contributors and the person who filed the expense will be reimbursed.
## Questions
If you have any questions, create an [issue](https://github.com/nodemailer/nodemailer/issues) (protip: do a quick search first to see if someone else didn't ask the same question before!).
You can also reach us at hello@nodemailer.opencollective.com.
## Credits
### Contributors
Thank you to all the people who have already contributed to nodemailer!
<a href="graphs/contributors"><img src="https://opencollective.com/nodemailer/contributors.svg?width=890" /></a>
### Backers
Thank you to all our backers! [[Become a backer](https://opencollective.com/nodemailer#backer)]
<a href="https://opencollective.com/nodemailer#backers" target="_blank"><img src="https://opencollective.com/nodemailer/backers.svg?width=890"></a>
### Sponsors
Thank you to all our sponsors! (please ask your company to also support this open source project by [becoming a sponsor](https://opencollective.com/nodemailer#sponsor))
<a href="https://opencollective.com/nodemailer/sponsor/0/website" target="_blank"><img src="https://opencollective.com/nodemailer/sponsor/0/avatar.svg"></a>
<a href="https://opencollective.com/nodemailer/sponsor/1/website" target="_blank"><img src="https://opencollective.com/nodemailer/sponsor/1/avatar.svg"></a>
<a href="https://opencollective.com/nodemailer/sponsor/2/website" target="_blank"><img src="https://opencollective.com/nodemailer/sponsor/2/avatar.svg"></a>
<a href="https://opencollective.com/nodemailer/sponsor/3/website" target="_blank"><img src="https://opencollective.com/nodemailer/sponsor/3/avatar.svg"></a>
<a href="https://opencollective.com/nodemailer/sponsor/4/website" target="_blank"><img src="https://opencollective.com/nodemailer/sponsor/4/avatar.svg"></a>
<a href="https://opencollective.com/nodemailer/sponsor/5/website" target="_blank"><img src="https://opencollective.com/nodemailer/sponsor/5/avatar.svg"></a>
<a href="https://opencollective.com/nodemailer/sponsor/6/website" target="_blank"><img src="https://opencollective.com/nodemailer/sponsor/6/avatar.svg"></a>
<a href="https://opencollective.com/nodemailer/sponsor/7/website" target="_blank"><img src="https://opencollective.com/nodemailer/sponsor/7/avatar.svg"></a>
<a href="https://opencollective.com/nodemailer/sponsor/8/website" target="_blank"><img src="https://opencollective.com/nodemailer/sponsor/8/avatar.svg"></a>
<a href="https://opencollective.com/nodemailer/sponsor/9/website" target="_blank"><img src="https://opencollective.com/nodemailer/sponsor/9/avatar.svg"></a>
<!-- This `CONTRIBUTING.md` is based on @nayafia's template https://github.com/nayafia/contributing-template -->

@ -0,0 +1,16 @@
Copyright (c) 2011-2019 Andris Reinman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

@ -0,0 +1,44 @@
# Nodemailer
[![Nodemailer](https://raw.githubusercontent.com/nodemailer/nodemailer/master/assets/nm_logo_200x136.png)](https://nodemailer.com/about/)
Send e-mails from Node.js – easy as cake! 🍰✉
[![NPM](https://nodei.co/npm/nodemailer.png?downloads=true&downloadRank=true&stars=true)](https://nodemailer.com/about/)
See [nodemailer.com](https://nodemailer.com/) for documentation and terms.
## Having an issue?
#### First review the docs
Documentation for Nodemailer can be found at [nodemailer.com](https://nodemailer.com/about/).
#### Nodemailer throws a SyntaxError for "..."
You are using older Node.js version than v6.0. Upgrade Node.js to get support for the spread operator.
#### I'm having issues with Gmail
Gmail either works well or it does not work at all. It is probably easier to switch to an alternative service instead of fixing issues with Gmail. If Gmail does not work for you then don't use it. Read more about it [here](https://nodemailer.com/usage/using-gmail/).
#### I get ETIMEDOUT errors
Check your firewall settings. Timeout usually occurs when you try to open a connection to a port that is firewalled either on the server or on your machine.
#### I get TLS errors
* If you are running the code in your own machine, then check your antivirus settings. Antiviruses often mess around with email ports usage. Node.js might not recognize the MITM cert your antivirus is using.
* Latest Node versions allow only TLS versions 1.2 and higher, some servers might still use TLS 1.1 or lower. Check Node.js docs how to get correct TLS support for your app.
#### I have a different problem
If you are having issues with Nodemailer, then the best way to find help would be [Stack Overflow](https://stackoverflow.com/search?q=nodemailer) or revisit the [docs](https://nodemailer.com/about/).
### License
Nodemailer is licensed under the **MIT license**
---
The Nodemailer logo was designed by [Sven Kristjansen](https://www.behance.net/kristjansen).

@ -0,0 +1,313 @@
'use strict';
/**
* Converts tokens for a single address into an address object
*
* @param {Array} tokens Tokens object
* @return {Object} Address object
*/
function _handleAddress(tokens) {
let token;
let isGroup = false;
let state = 'text';
let address;
let addresses = [];
let data = {
address: [],
comment: [],
group: [],
text: []
};
let i;
let len;
// Filter out <addresses>, (comments) and regular text
for (i = 0, len = tokens.length; i < len; i++) {
token = tokens[i];
if (token.type === 'operator') {
switch (token.value) {
case '<':
state = 'address';
break;
case '(':
state = 'comment';
break;
case ':':
state = 'group';
isGroup = true;
break;
default:
state = 'text';
}
} else if (token.value) {
if (state === 'address') {
// handle use case where unquoted name includes a "<"
// Apple Mail truncates everything between an unexpected < and an address
// and so will we
token.value = token.value.replace(/^[^<]*<\s*/, '');
}
data[state].push(token.value);
}
}
// If there is no text but a comment, replace the two
if (!data.text.length && data.comment.length) {
data.text = data.comment;
data.comment = [];
}
if (isGroup) {
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
data.text = data.text.join(' ');
addresses.push({
name: data.text || (address && address.name),
group: data.group.length ? addressparser(data.group.join(',')) : []
});
} else {
// If no address was found, try to detect one from regular text
if (!data.address.length && data.text.length) {
for (i = data.text.length - 1; i >= 0; i--) {
if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
data.address = data.text.splice(i, 1);
break;
}
}
let _regexHandler = function (address) {
if (!data.address.length) {
data.address = [address.trim()];
return ' ';
} else {
return address;
}
};
// still no address
if (!data.address.length) {
for (i = data.text.length - 1; i >= 0; i--) {
// fixed the regex to parse email address correctly when email address has more than one @
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^\s]+\b\s*/, _regexHandler).trim();
if (data.address.length) {
break;
}
}
}
}
// If there's still is no text but a comment exixts, replace the two
if (!data.text.length && data.comment.length) {
data.text = data.comment;
data.comment = [];
}
// Keep only the first address occurence, push others to regular text
if (data.address.length > 1) {
data.text = data.text.concat(data.address.splice(1));
}
// Join values with spaces
data.text = data.text.join(' ');
data.address = data.address.join(' ');
if (!data.address && isGroup) {
return [];
} else {
address = {
address: data.address || data.text || '',
name: data.text || data.address || ''
};
if (address.address === address.name) {
if ((address.address || '').match(/@/)) {
address.name = '';
} else {
address.address = '';
}
}
addresses.push(address);
}
}
return addresses;
}
/**
* Creates a Tokenizer object for tokenizing address field strings
*
* @constructor
* @param {String} str Address field string
*/
class Tokenizer {
constructor(str) {
this.str = (str || '').toString();
this.operatorCurrent = '';
this.operatorExpecting = '';
this.node = null;
this.escaped = false;
this.list = [];
/**
* Operator tokens and which tokens are expected to end the sequence
*/
this.operators = {
'"': '"',
'(': ')',
'<': '>',
',': '',
':': ';',
// Semicolons are not a legal delimiter per the RFC2822 grammar other
// than for terminating a group, but they are also not valid for any
// other use in this context. Given that some mail clients have
// historically allowed the semicolon as a delimiter equivalent to the
// comma in their UI, it makes sense to treat them the same as a comma
// when used outside of a group.
';': ''
};
}
/**
* Tokenizes the original input string
*
* @return {Array} An array of operator|text tokens
*/
tokenize() {
let chr,
list = [];
for (let i = 0, len = this.str.length; i < len; i++) {
chr = this.str.charAt(i);
this.checkChar(chr);
}
this.list.forEach(node => {
node.value = (node.value || '').toString().trim();
if (node.value) {
list.push(node);
}
});
return list;
}
/**
* Checks if a character is an operator or text and acts accordingly
*
* @param {String} chr Character from the address field
*/
checkChar(chr) {
if (this.escaped) {
// ignore next condition blocks
} else if (chr === this.operatorExpecting) {
this.node = {
type: 'operator',
value: chr
};
this.list.push(this.node);
this.node = null;
this.operatorExpecting = '';
this.escaped = false;
return;
} else if (!this.operatorExpecting && chr in this.operators) {
this.node = {
type: 'operator',
value: chr
};
this.list.push(this.node);
this.node = null;
this.operatorExpecting = this.operators[chr];
this.escaped = false;
return;
} else if (['"', "'"].includes(this.operatorExpecting) && chr === '\\') {
this.escaped = true;
return;
}
if (!this.node) {
this.node = {
type: 'text',
value: ''
};
this.list.push(this.node);
}
if (chr === '\n') {
// Convert newlines to spaces. Carriage return is ignored as \r and \n usually
// go together anyway and there already is a WS for \n. Lone \r means something is fishy.
chr = ' ';
}
if (chr.charCodeAt(0) >= 0x21 || [' ', '\t'].includes(chr)) {
// skip command bytes
this.node.value += chr;
}
this.escaped = false;
}
}
/**
* Parses structured e-mail addresses from an address field
*
* Example:
*
* 'Name <address@domain>'
*
* will be converted to
*
* [{name: 'Name', address: 'address@domain'}]
*
* @param {String} str Address field
* @return {Array} An array of address objects
*/
function addressparser(str, options) {
options = options || {};
let tokenizer = new Tokenizer(str);
let tokens = tokenizer.tokenize();
let addresses = [];
let address = [];
let parsedAddresses = [];
tokens.forEach(token => {
if (token.type === 'operator' && (token.value === ',' || token.value === ';')) {
if (address.length) {
addresses.push(address);
}
address = [];
} else {
address.push(token);
}
});
if (address.length) {
addresses.push(address);
}
addresses.forEach(address => {
address = _handleAddress(address);
if (address.length) {
parsedAddresses = parsedAddresses.concat(address);
}
});
if (options.flatten) {
let addresses = [];
let walkAddressList = list => {
list.forEach(address => {
if (address.group) {
return walkAddressList(address.group);
} else {
addresses.push(address);
}
});
};
walkAddressList(parsedAddresses);
return addresses;
}
return parsedAddresses;
}
// expose to the world
module.exports = addressparser;

@ -0,0 +1,142 @@
'use strict';
const Transform = require('stream').Transform;
/**
* Encodes a Buffer into a base64 encoded string
*
* @param {Buffer} buffer Buffer to convert
* @returns {String} base64 encoded string
*/
function encode(buffer) {
if (typeof buffer === 'string') {
buffer = Buffer.from(buffer, 'utf-8');
}
return buffer.toString('base64');
}
/**
* Adds soft line breaks to a base64 string
*
* @param {String} str base64 encoded string that might need line wrapping
* @param {Number} [lineLength=76] Maximum allowed length for a line
* @returns {String} Soft-wrapped base64 encoded string
*/
function wrap(str, lineLength) {
str = (str || '').toString();
lineLength = lineLength || 76;
if (str.length <= lineLength) {
return str;
}
let result = [];
let pos = 0;
let chunkLength = lineLength * 1024;
while (pos < str.length) {
let wrappedLines = str
.substr(pos, chunkLength)
.replace(new RegExp('.{' + lineLength + '}', 'g'), '$&\r\n')
.trim();
result.push(wrappedLines);
pos += chunkLength;
}
return result.join('\r\n').trim();
}
/**
* Creates a transform stream for encoding data to base64 encoding
*
* @constructor
* @param {Object} options Stream options
* @param {Number} [options.lineLength=76] Maximum lenght for lines, set to false to disable wrapping
*/
class Encoder extends Transform {
constructor(options) {
super();
// init Transform
this.options = options || {};
if (this.options.lineLength !== false) {
this.options.lineLength = this.options.lineLength || 76;
}
this._curLine = '';
this._remainingBytes = false;
this.inputBytes = 0;
this.outputBytes = 0;
}
_transform(chunk, encoding, done) {
if (encoding !== 'buffer') {
chunk = Buffer.from(chunk, encoding);
}
if (!chunk || !chunk.length) {
return setImmediate(done);
}
this.inputBytes += chunk.length;
if (this._remainingBytes && this._remainingBytes.length) {
chunk = Buffer.concat([this._remainingBytes, chunk], this._remainingBytes.length + chunk.length);
this._remainingBytes = false;
}
if (chunk.length % 3) {
this._remainingBytes = chunk.slice(chunk.length - (chunk.length % 3));
chunk = chunk.slice(0, chunk.length - (chunk.length % 3));
} else {
this._remainingBytes = false;
}
let b64 = this._curLine + encode(chunk);
if (this.options.lineLength) {
b64 = wrap(b64, this.options.lineLength);
// remove last line as it is still most probably incomplete
let lastLF = b64.lastIndexOf('\n');
if (lastLF < 0) {
this._curLine = b64;
b64 = '';
} else if (lastLF === b64.length - 1) {
this._curLine = '';
} else {
this._curLine = b64.substr(lastLF + 1);
b64 = b64.substr(0, lastLF + 1);
}
}
if (b64) {
this.outputBytes += b64.length;
this.push(Buffer.from(b64, 'ascii'));
}
setImmediate(done);
}
_flush(done) {
if (this._remainingBytes && this._remainingBytes.length) {
this._curLine += encode(this._remainingBytes);
}
if (this._curLine) {
this._curLine = wrap(this._curLine, this.options.lineLength);
this.outputBytes += this._curLine.length;
this.push(this._curLine, 'ascii');
this._curLine = '';
}
done();
}
}
// expose to the world
module.exports = {
encode,
wrap,
Encoder
};

@ -0,0 +1,251 @@
'use strict';
// FIXME:
// replace this Transform mess with a method that pipes input argument to output argument
const MessageParser = require('./message-parser');
const RelaxedBody = require('./relaxed-body');
const sign = require('./sign');
const PassThrough = require('stream').PassThrough;
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DKIM_ALGO = 'sha256';
const MAX_MESSAGE_SIZE = 128 * 1024; // buffer messages larger than this to disk
/*
// Usage:
let dkim = new DKIM({
domainName: 'example.com',
keySelector: 'key-selector',
privateKey,
cacheDir: '/tmp'
});
dkim.sign(input).pipe(process.stdout);
// Where inputStream is a rfc822 message (either a stream, string or Buffer)
// and outputStream is a DKIM signed rfc822 message
*/
class DKIMSigner {
constructor(options, keys, input, output) {
this.options = options || {};
this.keys = keys;
this.cacheTreshold = Number(this.options.cacheTreshold) || MAX_MESSAGE_SIZE;
this.hashAlgo = this.options.hashAlgo || DKIM_ALGO;
this.cacheDir = this.options.cacheDir || false;
this.chunks = [];
this.chunklen = 0;
this.readPos = 0;
this.cachePath = this.cacheDir ? path.join(this.cacheDir, 'message.' + Date.now() + '-' + crypto.randomBytes(14).toString('hex')) : false;
this.cache = false;
this.headers = false;
this.bodyHash = false;
this.parser = false;
this.relaxedBody = false;
this.input = input;
this.output = output;
this.output.usingCache = false;
this.errored = false;
this.input.on('error', err => {
this.errored = true;
this.cleanup();
output.emit('error', err);
});
}
cleanup() {
if (!this.cache || !this.cachePath) {
return;
}
fs.unlink(this.cachePath, () => false);
}
createReadCache() {
// pipe remainings to cache file
this.cache = fs.createReadStream(this.cachePath);
this.cache.once('error', err => {
this.cleanup();
this.output.emit('error', err);
});
this.cache.once('close', () => {
this.cleanup();
});
this.cache.pipe(this.output);
}
sendNextChunk() {
if (this.errored) {
return;
}
if (this.readPos >= this.chunks.length) {
if (!this.cache) {
return this.output.end();
}
return this.createReadCache();
}
let chunk = this.chunks[this.readPos++];
if (this.output.write(chunk) === false) {
return this.output.once('drain', () => {
this.sendNextChunk();
});
}
setImmediate(() => this.sendNextChunk());
}
sendSignedOutput() {
let keyPos = 0;
let signNextKey = () => {
if (keyPos >= this.keys.length) {
this.output.write(this.parser.rawHeaders);
return setImmediate(() => this.sendNextChunk());
}
let key = this.keys[keyPos++];
let dkimField = sign(this.headers, this.hashAlgo, this.bodyHash, {
domainName: key.domainName,
keySelector: key.keySelector,
privateKey: key.privateKey,
headerFieldNames: this.options.headerFieldNames,
skipFields: this.options.skipFields
});
if (dkimField) {
this.output.write(Buffer.from(dkimField + '\r\n'));
}
return setImmediate(signNextKey);
};
if (this.bodyHash && this.headers) {
return signNextKey();
}
this.output.write(this.parser.rawHeaders);
this.sendNextChunk();
}
createWriteCache() {
this.output.usingCache = true;
// pipe remainings to cache file
this.cache = fs.createWriteStream(this.cachePath);
this.cache.once('error', err => {
this.cleanup();
// drain input
this.relaxedBody.unpipe(this.cache);
this.relaxedBody.on('readable', () => {
while (this.relaxedBody.read() !== null) {
// do nothing
}
});
this.errored = true;
// emit error
this.output.emit('error', err);
});
this.cache.once('close', () => {
this.sendSignedOutput();
});
this.relaxedBody.removeAllListeners('readable');
this.relaxedBody.pipe(this.cache);
}
signStream() {
this.parser = new MessageParser();
this.relaxedBody = new RelaxedBody({
hashAlgo: this.hashAlgo
});
this.parser.on('headers', value => {
this.headers = value;
});
this.relaxedBody.on('hash', value => {
this.bodyHash = value;
});
this.relaxedBody.on('readable', () => {
let chunk;
if (this.cache) {
return;
}
while ((chunk = this.relaxedBody.read()) !== null) {
this.chunks.push(chunk);
this.chunklen += chunk.length;
if (this.chunklen >= this.cacheTreshold && this.cachePath) {
return this.createWriteCache();
}
}
});
this.relaxedBody.on('end', () => {
if (this.cache) {
return;
}
this.sendSignedOutput();
});
this.parser.pipe(this.relaxedBody);
setImmediate(() => this.input.pipe(this.parser));
}
}
class DKIM {
constructor(options) {
this.options = options || {};
this.keys = [].concat(
this.options.keys || {
domainName: options.domainName,
keySelector: options.keySelector,
privateKey: options.privateKey
}
);
}
sign(input, extraOptions) {
let output = new PassThrough();
let inputStream = input;
let writeValue = false;
if (Buffer.isBuffer(input)) {
writeValue = input;
inputStream = new PassThrough();
} else if (typeof input === 'string') {
writeValue = Buffer.from(input);
inputStream = new PassThrough();
}
let options = this.options;
if (extraOptions && Object.keys(extraOptions).length) {
options = {};
Object.keys(this.options || {}).forEach(key => {
options[key] = this.options[key];
});
Object.keys(extraOptions || {}).forEach(key => {
if (!(key in options)) {
options[key] = extraOptions[key];
}
});
}
let signer = new DKIMSigner(options, this.keys, inputStream, output);
setImmediate(() => {
signer.signStream();
if (writeValue) {
setImmediate(() => {
inputStream.end(writeValue);
});
}
});
return output;
}
}
module.exports = DKIM;

@ -0,0 +1,158 @@
'use strict';
const Transform = require('stream').Transform;
/**
* MessageParser instance is a transform stream that separates message headers
* from the rest of the body. Headers are emitted with the 'headers' event. Message
* body is passed on as the resulting stream.
*/
class MessageParser extends Transform {
constructor(options) {
super(options);
this.lastBytes = Buffer.alloc(4);
this.headersParsed = false;
this.headerBytes = 0;
this.headerChunks = [];
this.rawHeaders = false;
this.bodySize = 0;
}
/**
* Keeps count of the last 4 bytes in order to detect line breaks on chunk boundaries
*
* @param {Buffer} data Next data chunk from the stream
*/
updateLastBytes(data) {
let lblen = this.lastBytes.length;
let nblen = Math.min(data.length, lblen);
// shift existing bytes
for (let i = 0, len = lblen - nblen; i < len; i++) {
this.lastBytes[i] = this.lastBytes[i + nblen];
}
// add new bytes
for (let i = 1; i <= nblen; i++) {
this.lastBytes[lblen - i] = data[data.length - i];
}
}
/**
* Finds and removes message headers from the remaining body. We want to keep
* headers separated until final delivery to be able to modify these
*
* @param {Buffer} data Next chunk of data
* @return {Boolean} Returns true if headers are already found or false otherwise
*/
checkHeaders(data) {
if (this.headersParsed) {
return true;
}
let lblen = this.lastBytes.length;
let headerPos = 0;
this.curLinePos = 0;
for (let i = 0, len = this.lastBytes.length + data.length; i < len; i++) {
let chr;
if (i < lblen) {
chr = this.lastBytes[i];
} else {
chr = data[i - lblen];
}
if (chr === 0x0a && i) {
let pr1 = i - 1 < lblen ? this.lastBytes[i - 1] : data[i - 1 - lblen];
let pr2 = i > 1 ? (i - 2 < lblen ? this.lastBytes[i - 2] : data[i - 2 - lblen]) : false;
if (pr1 === 0x0a) {
this.headersParsed = true;
headerPos = i - lblen + 1;
this.headerBytes += headerPos;
break;
} else if (pr1 === 0x0d && pr2 === 0x0a) {
this.headersParsed = true;
headerPos = i - lblen + 1;
this.headerBytes += headerPos;
break;
}
}
}
if (this.headersParsed) {
this.headerChunks.push(data.slice(0, headerPos));
this.rawHeaders = Buffer.concat(this.headerChunks, this.headerBytes);
this.headerChunks = null;
this.emit('headers', this.parseHeaders());
if (data.length - 1 > headerPos) {
let chunk = data.slice(headerPos);
this.bodySize += chunk.length;
// this would be the first chunk of data sent downstream
setImmediate(() => this.push(chunk));
}
return false;
} else {
this.headerBytes += data.length;
this.headerChunks.push(data);
}
// store last 4 bytes to catch header break
this.updateLastBytes(data);
return false;
}
_transform(chunk, encoding, callback) {
if (!chunk || !chunk.length) {
return callback();
}
if (typeof chunk === 'string') {
chunk = Buffer.from(chunk, encoding);
}
let headersFound;
try {
headersFound = this.checkHeaders(chunk);
} catch (E) {
return callback(E);
}
if (headersFound) {
this.bodySize += chunk.length;
this.push(chunk);
}
setImmediate(callback);
}
_flush(callback) {
if (this.headerChunks) {
let chunk = Buffer.concat(this.headerChunks, this.headerBytes);
this.bodySize += chunk.length;
this.push(chunk);
this.headerChunks = null;
}
callback();
}
parseHeaders() {
let lines = (this.rawHeaders || '').toString().split(/\r?\n/);
for (let i = lines.length - 1; i > 0; i--) {
if (/^\s/.test(lines[i])) {
lines[i - 1] += '\n' + lines[i];
lines.splice(i, 1);
}
}
return lines
.filter(line => line.trim())
.map(line => ({
key: line
.substr(0, line.indexOf(':'))
.trim()
.toLowerCase(),
line
}));
}
}
module.exports = MessageParser;

@ -0,0 +1,154 @@
'use strict';
// streams through a message body and calculates relaxed body hash
const Transform = require('stream').Transform;
const crypto = require('crypto');
class RelaxedBody extends Transform {
constructor(options) {
super();
options = options || {};
this.chunkBuffer = [];
this.chunkBufferLen = 0;
this.bodyHash = crypto.createHash(options.hashAlgo || 'sha1');
this.remainder = '';
this.byteLength = 0;
this.debug = options.debug;
this._debugBody = options.debug ? [] : false;
}
updateHash(chunk) {
let bodyStr;
// find next remainder
let nextRemainder = '';
// This crux finds and removes the spaces from the last line and the newline characters after the last non-empty line
// If we get another chunk that does not match this description then we can restore the previously processed data
let state = 'file';
for (let i = chunk.length - 1; i >= 0; i--) {
let c = chunk[i];
if (state === 'file' && (c === 0x0a || c === 0x0d)) {
// do nothing, found \n or \r at the end of chunk, stil end of file
} else if (state === 'file' && (c === 0x09 || c === 0x20)) {
// switch to line ending mode, this is the last non-empty line
state = 'line';
} else if (state === 'line' && (c === 0x09 || c === 0x20)) {
// do nothing, found ' ' or \t at the end of line, keep processing the last non-empty line
} else if (state === 'file' || state === 'line') {
// non line/file ending character found, switch to body mode
state = 'body';
if (i === chunk.length - 1) {
// final char is not part of line end or file end, so do nothing
break;
}
}
if (i === 0) {
// reached to the beginning of the chunk, check if it is still about the ending
// and if the remainder also matches
if (
(state === 'file' && (!this.remainder || /[\r\n]$/.test(this.remainder))) ||
(state === 'line' && (!this.remainder || /[ \t]$/.test(this.remainder)))
) {
// keep everything
this.remainder += chunk.toString('binary');
return;
} else if (state === 'line' || state === 'file') {
// process existing remainder as normal line but store the current chunk
nextRemainder = chunk.toString('binary');
chunk = false;
break;
}
}
if (state !== 'body') {
continue;
}
// reached first non ending byte
nextRemainder = chunk.slice(i + 1).toString('binary');
chunk = chunk.slice(0, i + 1);
break;
}
let needsFixing = !!this.remainder;
if (chunk && !needsFixing) {
// check if we even need to change anything
for (let i = 0, len = chunk.length; i < len; i++) {
if (i && chunk[i] === 0x0a && chunk[i - 1] !== 0x0d) {
// missing \r before \n
needsFixing = true;
break;
} else if (i && chunk[i] === 0x0d && chunk[i - 1] === 0x20) {
// trailing WSP found
needsFixing = true;
break;
} else if (i && chunk[i] === 0x20 && chunk[i - 1] === 0x20) {
// multiple spaces found, needs to be replaced with just one
needsFixing = true;
break;
} else if (chunk[i] === 0x09) {
// TAB found, needs to be replaced with a space
needsFixing = true;
break;
}
}
}
if (needsFixing) {
bodyStr = this.remainder + (chunk ? chunk.toString('binary') : '');
this.remainder = nextRemainder;
bodyStr = bodyStr
.replace(/\r?\n/g, '\n') // use js line endings
.replace(/[ \t]*$/gm, '') // remove line endings, rtrim
.replace(/[ \t]+/gm, ' ') // single spaces
.replace(/\n/g, '\r\n'); // restore rfc822 line endings
chunk = Buffer.from(bodyStr, 'binary');
} else if (nextRemainder) {
this.remainder = nextRemainder;
}
if (this.debug) {
this._debugBody.push(chunk);
}
this.bodyHash.update(chunk);
}
_transform(chunk, encoding, callback) {
if (!chunk || !chunk.length) {
return callback();
}
if (typeof chunk === 'string') {
chunk = Buffer.from(chunk, encoding);
}
this.updateHash(chunk);
this.byteLength += chunk.length;
this.push(chunk);
callback();
}
_flush(callback) {
// generate final hash and emit it
if (/[\r\n]$/.test(this.remainder) && this.byteLength > 2) {
// add terminating line end
this.bodyHash.update(Buffer.from('\r\n'));
}
if (!this.byteLength) {
// emit empty line buffer to keep the stream flowing
this.push(Buffer.from('\r\n'));
// this.bodyHash.update(Buffer.from('\r\n'));
}
this.emit('hash', this.bodyHash.digest('base64'), this.debug ? Buffer.concat(this._debugBody) : false);
callback();
}
}
module.exports = RelaxedBody;

@ -0,0 +1,117 @@
'use strict';
const punycode = require('punycode');
const mimeFuncs = require('../mime-funcs');
const crypto = require('crypto');
/**
* Returns DKIM signature header line
*
* @param {Object} headers Parsed headers object from MessageParser
* @param {String} bodyHash Base64 encoded hash of the message
* @param {Object} options DKIM options
* @param {String} options.domainName Domain name to be signed for
* @param {String} options.keySelector DKIM key selector to use
* @param {String} options.privateKey DKIM private key to use
* @return {String} Complete header line
*/
module.exports = (headers, hashAlgo, bodyHash, options) => {
options = options || {};
// all listed fields from RFC4871 #5.5
let defaultFieldNames =
'From:Sender:Reply-To:Subject:Date:Message-ID:To:' +
'Cc:MIME-Version:Content-Type:Content-Transfer-Encoding:Content-ID:' +
'Content-Description:Resent-Date:Resent-From:Resent-Sender:' +
'Resent-To:Resent-Cc:Resent-Message-ID:In-Reply-To:References:' +
'List-Id:List-Help:List-Unsubscribe:List-Subscribe:List-Post:' +
'List-Owner:List-Archive';
let fieldNames = options.headerFieldNames || defaultFieldNames;
let canonicalizedHeaderData = relaxedHeaders(headers, fieldNames, options.skipFields);
let dkimHeader = generateDKIMHeader(options.domainName, options.keySelector, canonicalizedHeaderData.fieldNames, hashAlgo, bodyHash);
let signer, signature;
canonicalizedHeaderData.headers += 'dkim-signature:' + relaxedHeaderLine(dkimHeader);
signer = crypto.createSign(('rsa-' + hashAlgo).toUpperCase());
signer.update(canonicalizedHeaderData.headers);
try {
signature = signer.sign(options.privateKey, 'base64');
} catch (E) {
return false;
}
return dkimHeader + signature.replace(/(^.{73}|.{75}(?!\r?\n|\r))/g, '$&\r\n ').trim();
};
module.exports.relaxedHeaders = relaxedHeaders;
function generateDKIMHeader(domainName, keySelector, fieldNames, hashAlgo, bodyHash) {
let dkim = [
'v=1',
'a=rsa-' + hashAlgo,
'c=relaxed/relaxed',
'd=' + punycode.toASCII(domainName),
'q=dns/txt',
's=' + keySelector,
'bh=' + bodyHash,
'h=' + fieldNames
].join('; ');
return mimeFuncs.foldLines('DKIM-Signature: ' + dkim, 76) + ';\r\n b=';
}
function relaxedHeaders(headers, fieldNames, skipFields) {
let includedFields = new Set();
let skip = new Set();
let headerFields = new Map();
(skipFields || '')
.toLowerCase()
.split(':')
.forEach(field => {
skip.add(field.trim());
});
(fieldNames || '')
.toLowerCase()
.split(':')
.filter(field => !skip.has(field.trim()))
.forEach(field => {
includedFields.add(field.trim());
});
for (let i = headers.length - 1; i >= 0; i--) {
let line = headers[i];
// only include the first value from bottom to top
if (includedFields.has(line.key) && !headerFields.has(line.key)) {
headerFields.set(line.key, relaxedHeaderLine(line.line));
}
}
let headersList = [];
let fields = [];
includedFields.forEach(field => {
if (headerFields.has(field)) {
fields.push(field);
headersList.push(field + ':' + headerFields.get(field));
}
});
return {
headers: headersList.join('\r\n') + '\r\n',
fieldNames: fields.join(':')
};
}
function relaxedHeaderLine(line) {
return line
.substr(line.indexOf(':') + 1)
.replace(/\r?\n/g, '')
.replace(/\s+/g, ' ')
.trim();
}

@ -0,0 +1,284 @@
'use strict';
// module to handle cookies
const urllib = require('url');
const SESSION_TIMEOUT = 1800; // 30 min
/**
* Creates a biskviit cookie jar for managing cookie values in memory
*
* @constructor
* @param {Object} [options] Optional options object
*/
class Cookies {
constructor(options) {
this.options = options || {};
this.cookies = [];
}
/**
* Stores a cookie string to the cookie storage
*
* @param {String} cookieStr Value from the 'Set-Cookie:' header
* @param {String} url Current URL
*/
set(cookieStr, url) {
let urlparts = urllib.parse(url || '');
let cookie = this.parse(cookieStr);
let domain;
if (cookie.domain) {
domain = cookie.domain.replace(/^\./, '');
// do not allow cross origin cookies
if (
// can't be valid if the requested domain is shorter than current hostname
urlparts.hostname.length < domain.length ||
// prefix domains with dot to be sure that partial matches are not used
('.' + urlparts.hostname).substr(-domain.length + 1) !== '.' + domain
) {
cookie.domain = urlparts.hostname;
}
} else {
cookie.domain = urlparts.hostname;
}
if (!cookie.path) {
cookie.path = this.getPath(urlparts.pathname);
}
// if no expire date, then use sessionTimeout value
if (!cookie.expires) {
cookie.expires = new Date(Date.now() + (Number(this.options.sessionTimeout || SESSION_TIMEOUT) || SESSION_TIMEOUT) * 1000);
}
return this.add(cookie);
}
/**
* Returns cookie string for the 'Cookie:' header.
*
* @param {String} url URL to check for
* @returns {String} Cookie header or empty string if no matches were found
*/
get(url) {
return this.list(url)
.map(cookie => cookie.name + '=' + cookie.value)
.join('; ');
}
/**
* Lists all valied cookie objects for the specified URL
*
* @param {String} url URL to check for
* @returns {Array} An array of cookie objects
*/
list(url) {
let result = [];
let i;
let cookie;
for (i = this.cookies.length - 1; i >= 0; i--) {
cookie = this.cookies[i];
if (this.isExpired(cookie)) {
this.cookies.splice(i, i);
continue;
}
if (this.match(cookie, url)) {
result.unshift(cookie);
}
}
return result;
}
/**
* Parses cookie string from the 'Set-Cookie:' header
*
* @param {String} cookieStr String from the 'Set-Cookie:' header
* @returns {Object} Cookie object
*/
parse(cookieStr) {
let cookie = {};
(cookieStr || '')
.toString()
.split(';')
.forEach(cookiePart => {
let valueParts = cookiePart.split('=');
let key = valueParts
.shift()
.trim()
.toLowerCase();
let value = valueParts.join('=').trim();
let domain;
if (!key) {
// skip empty parts
return;
}
switch (key) {
case 'expires':
value = new Date(value);
// ignore date if can not parse it
if (value.toString() !== 'Invalid Date') {
cookie.expires = value;
}
break;
case 'path':
cookie.path = value;
break;
case 'domain':
domain = value.toLowerCase();
if (domain.length && domain.charAt(0) !== '.') {
domain = '.' + domain; // ensure preceeding dot for user set domains
}
cookie.domain = domain;
break;
case 'max-age':
cookie.expires = new Date(Date.now() + (Number(value) || 0) * 1000);
break;
case 'secure':
cookie.secure = true;
break;
case 'httponly':
cookie.httponly = true;
break;
default:
if (!cookie.name) {
cookie.name = key;
cookie.value = value;
}
}
});
return cookie;
}
/**
* Checks if a cookie object is valid for a specified URL
*
* @param {Object} cookie Cookie object
* @param {String} url URL to check for
* @returns {Boolean} true if cookie is valid for specifiec URL
*/
match(cookie, url) {
let urlparts = urllib.parse(url || '');
// check if hostname matches
// .foo.com also matches subdomains, foo.com does not
if (
urlparts.hostname !== cookie.domain &&
(cookie.domain.charAt(0) !== '.' || ('.' + urlparts.hostname).substr(-cookie.domain.length) !== cookie.domain)
) {
return false;
}
// check if path matches
let path = this.getPath(urlparts.pathname);
if (path.substr(0, cookie.path.length) !== cookie.path) {
return false;
}
// check secure argument
if (cookie.secure && urlparts.protocol !== 'https:') {
return false;
}
return true;
}
/**
* Adds (or updates/removes if needed) a cookie object to the cookie storage
*
* @param {Object} cookie Cookie value to be stored
*/
add(cookie) {
let i;
let len;
// nothing to do here
if (!cookie || !cookie.name) {
return false;
}
// overwrite if has same params
for (i = 0, len = this.cookies.length; i < len; i++) {
if (this.compare(this.cookies[i], cookie)) {
// check if the cookie needs to be removed instead
if (this.isExpired(cookie)) {
this.cookies.splice(i, 1); // remove expired/unset cookie
return false;
}
this.cookies[i] = cookie;
return true;
}
}
// add as new if not already expired
if (!this.isExpired(cookie)) {
this.cookies.push(cookie);
}
return true;
}
/**
* Checks if two cookie objects are the same
*
* @param {Object} a Cookie to check against
* @param {Object} b Cookie to check against
* @returns {Boolean} True, if the cookies are the same
*/
compare(a, b) {
return a.name === b.name && a.path === b.path && a.domain === b.domain && a.secure === b.secure && a.httponly === a.httponly;
}
/**
* Checks if a cookie is expired
*
* @param {Object} cookie Cookie object to check against
* @returns {Boolean} True, if the cookie is expired
*/
isExpired(cookie) {
return (cookie.expires && cookie.expires < new Date()) || !cookie.value;
}
/**
* Returns normalized cookie path for an URL path argument
*
* @param {String} pathname
* @returns {String} Normalized path
*/
getPath(pathname) {
let path = (pathname || '/').split('/');
path.pop(); // remove filename part
path = path.join('/').trim();
// ensure path prefix /
if (path.charAt(0) !== '/') {
path = '/' + path;
}
// ensure path suffix /
if (path.substr(-1) !== '/') {
path += '/';
}
return path;
}
}
module.exports = Cookies;

@ -0,0 +1,277 @@
'use strict';
const http = require('http');
const https = require('https');
const urllib = require('url');
const zlib = require('zlib');
const PassThrough = require('stream').PassThrough;
const Cookies = require('./cookies');
const packageData = require('../../package.json');
const MAX_REDIRECTS = 5;
module.exports = function(url, options) {
return fetch(url, options);
};
module.exports.Cookies = Cookies;
function fetch(url, options) {
options = options || {};
options.fetchRes = options.fetchRes || new PassThrough();
options.cookies = options.cookies || new Cookies();
options.redirects = options.redirects || 0;
options.maxRedirects = isNaN(options.maxRedirects) ? MAX_REDIRECTS : options.maxRedirects;
if (options.cookie) {
[].concat(options.cookie || []).forEach(cookie => {
options.cookies.set(cookie, url);
});
options.cookie = false;
}
let fetchRes = options.fetchRes;
let parsed = urllib.parse(url);
let method =
(options.method || '')
.toString()
.trim()
.toUpperCase() || 'GET';
let finished = false;
let cookies;
let body;
let handler = parsed.protocol === 'https:' ? https : http;
let headers = {
'accept-encoding': 'gzip,deflate',
'user-agent': 'nodemailer/' + packageData.version
};
Object.keys(options.headers || {}).forEach(key => {
headers[key.toLowerCase().trim()] = options.headers[key];
});
if (options.userAgent) {
headers['user-agent'] = options.userAgent;
}
if (parsed.auth) {
headers.Authorization = 'Basic ' + Buffer.from(parsed.auth).toString('base64');
}
if ((cookies = options.cookies.get(url))) {
headers.cookie = cookies;
}
if (options.body) {
if (options.contentType !== false) {
headers['Content-Type'] = options.contentType || 'application/x-www-form-urlencoded';
}
if (typeof options.body.pipe === 'function') {
// it's a stream
headers['Transfer-Encoding'] = 'chunked';
body = options.body;
body.on('error', err => {
if (finished) {
return;
}
finished = true;
err.type = 'FETCH';
err.sourceUrl = url;
fetchRes.emit('error', err);
});
} else {
if (options.body instanceof Buffer) {
body = options.body;
} else if (typeof options.body === 'object') {
try {
// encodeURIComponent can fail on invalid input (partial emoji etc.)
body = Buffer.from(
Object.keys(options.body)
.map(key => {
let value = options.body[key].toString().trim();
return encodeURIComponent(key) + '=' + encodeURIComponent(value);
})
.join('&')
);
} catch (E) {
if (finished) {
return;
}
finished = true;
E.type = 'FETCH';
E.sourceUrl = url;
fetchRes.emit('error', E);
return;
}
} else {
body = Buffer.from(options.body.toString().trim());
}
headers['Content-Type'] = options.contentType || 'application/x-www-form-urlencoded';
headers['Content-Length'] = body.length;
}
// if method is not provided, use POST instead of GET
method =
(options.method || '')
.toString()
.trim()
.toUpperCase() || 'POST';
}
let req;
let reqOptions = {
method,
host: parsed.hostname,
path: parsed.path,
port: parsed.port ? parsed.port : parsed.protocol === 'https:' ? 443 : 80,
headers,
rejectUnauthorized: false,
agent: false
};
if (options.tls) {
Object.keys(options.tls).forEach(key => {
reqOptions[key] = options.tls[key];
});
}
try {
req = handler.request(reqOptions);
} catch (E) {
finished = true;
setImmediate(() => {
E.type = 'FETCH';
E.sourceUrl = url;
fetchRes.emit('error', E);
});
return fetchRes;
}
if (options.timeout) {
req.setTimeout(options.timeout, () => {
if (finished) {
return;
}
finished = true;
req.abort();
let err = new Error('Request Timeout');
err.type = 'FETCH';
err.sourceUrl = url;
fetchRes.emit('error', err);
});
}
req.on('error', err => {
if (finished) {
return;
}
finished = true;
err.type = 'FETCH';
err.sourceUrl = url;
fetchRes.emit('error', err);
});
req.on('response', res => {
let inflate;
if (finished) {
return;
}
switch (res.headers['content-encoding']) {
case 'gzip':
case 'deflate':
inflate = zlib.createUnzip();
break;
}
if (res.headers['set-cookie']) {
[].concat(res.headers['set-cookie'] || []).forEach(cookie => {
options.cookies.set(cookie, url);
});
}
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
// redirect
options.redirects++;
if (options.redirects > options.maxRedirects) {
finished = true;
let err = new Error('Maximum redirect count exceeded');
err.type = 'FETCH';
err.sourceUrl = url;
fetchRes.emit('error', err);
req.abort();
return;
}
// redirect does not include POST body
options.method = 'GET';
options.body = false;
return fetch(urllib.resolve(url, res.headers.location), options);
}
fetchRes.statusCode = res.statusCode;
fetchRes.headers = res.headers;
if (res.statusCode >= 300 && !options.allowErrorResponse) {
finished = true;
let err = new Error('Invalid status code ' + res.statusCode);
err.type = 'FETCH';
err.sourceUrl = url;
fetchRes.emit('error', err);
req.abort();
return;
}
res.on('error', err => {
if (finished) {
return;
}
finished = true;
err.type = 'FETCH';
err.sourceUrl = url;
fetchRes.emit('error', err);
req.abort();
});
if (inflate) {
res.pipe(inflate).pipe(fetchRes);
inflate.on('error', err => {
if (finished) {
return;
}
finished = true;
err.type = 'FETCH';
err.sourceUrl = url;
fetchRes.emit('error', err);
req.abort();
});
} else {
res.pipe(fetchRes);
}
});
setImmediate(() => {
if (body) {
try {
if (typeof body.pipe === 'function') {
return body.pipe(req);
} else {
req.write(body);
}
} catch (err) {
finished = true;
err.type = 'FETCH';
err.sourceUrl = url;
fetchRes.emit('error', err);
return;
}
}
req.end();
});
return fetchRes;
}

@ -0,0 +1,82 @@
'use strict';
const packageData = require('../../package.json');
const shared = require('../shared');
/**
* Generates a Transport object to generate JSON output
*
* @constructor
* @param {Object} optional config parameter
*/
class JSONTransport {
constructor(options) {
options = options || {};
this.options = options || {};
this.name = 'JSONTransport';
this.version = packageData.version;
this.logger = shared.getLogger(this.options, {
component: this.options.component || 'json-transport'
});
}
/**
* <p>Compiles a mailcomposer message and forwards it to handler that sends it.</p>
*
* @param {Object} emailMessage MailComposer object
* @param {Function} callback Callback function to run when the sending is completed
*/
send(mail, done) {
// Sendmail strips this header line by itself
mail.message.keepBcc = true;
let envelope = mail.data.envelope || mail.message.getEnvelope();
let messageId = mail.message.messageId();
let recipients = [].concat(envelope.to || []);
if (recipients.length > 3) {
recipients.push('...and ' + recipients.splice(2).length + ' more');
}
this.logger.info(
{
tnx: 'send',
messageId
},
'Composing JSON structure of %s to <%s>',
messageId,
recipients.join(', ')
);
setImmediate(() => {
mail.normalize((err, data) => {
if (err) {
this.logger.error(
{
err,
tnx: 'send',
messageId
},
'Failed building JSON structure for %s. %s',
messageId,
err.message
);
return done(err);
}
delete data.envelope;
delete data.normalizedHeaders;
return done(null, {
envelope,
messageId,
message: this.options.skipEncoding ? data : JSON.stringify(data)
});
});
});
}
}
module.exports = JSONTransport;

@ -0,0 +1,549 @@
/* eslint no-undefined: 0 */
'use strict';
const MimeNode = require('../mime-node');
const mimeFuncs = require('../mime-funcs');
/**
* Creates the object for composing a MimeNode instance out from the mail options
*
* @constructor
* @param {Object} mail Mail options
*/
class MailComposer {
constructor(mail) {
this.mail = mail || {};
this.message = false;
}
/**
* Builds MimeNode instance
*/
compile() {
this._alternatives = this.getAlternatives();
this._htmlNode = this._alternatives.filter(alternative => /^text\/html\b/i.test(alternative.contentType)).pop();
this._attachments = this.getAttachments(!!this._htmlNode);
this._useRelated = !!(this._htmlNode && this._attachments.related.length);
this._useAlternative = this._alternatives.length > 1;
this._useMixed = this._attachments.attached.length > 1 || (this._alternatives.length && this._attachments.attached.length === 1);
// Compose MIME tree
if (this.mail.raw) {
this.message = new MimeNode().setRaw(this.mail.raw);
} else if (this._useMixed) {
this.message = this._createMixed();
} else if (this._useAlternative) {
this.message = this._createAlternative();
} else if (this._useRelated) {
this.message = this._createRelated();
} else {
this.message = this._createContentNode(
false,
[]
.concat(this._alternatives || [])
.concat(this._attachments.attached || [])
.shift() || {
contentType: 'text/plain',
content: ''
}
);
}
// Add custom headers
if (this.mail.headers) {
this.message.addHeader(this.mail.headers);
}
// Add headers to the root node, always overrides custom headers
['from', 'sender', 'to', 'cc', 'bcc', 'reply-to', 'in-reply-to', 'references', 'subject', 'message-id', 'date'].forEach(header => {
let key = header.replace(/-(\w)/g, (o, c) => c.toUpperCase());
if (this.mail[key]) {
this.message.setHeader(header, this.mail[key]);
}
});
// Sets custom envelope
if (this.mail.envelope) {
this.message.setEnvelope(this.mail.envelope);
}
// ensure Message-Id value
this.message.messageId();
return this.message;
}
/**
* List all attachments. Resulting attachment objects can be used as input for MimeNode nodes
*
* @param {Boolean} findRelated If true separate related attachments from attached ones
* @returns {Object} An object of arrays (`related` and `attached`)
*/
getAttachments(findRelated) {
let icalEvent, eventObject;
let attachments = [].concat(this.mail.attachments || []).map((attachment, i) => {
let data;
let isMessageNode = /^message\//i.test(attachment.contentType);
if (/^data:/i.test(attachment.path || attachment.href)) {
attachment = this._processDataUrl(attachment);
}
data = {
contentType: attachment.contentType || mimeFuncs.detectMimeType(attachment.filename || attachment.path || attachment.href || 'bin'),
contentDisposition: attachment.contentDisposition || (isMessageNode ? 'inline' : 'attachment'),
contentTransferEncoding: 'contentTransferEncoding' in attachment ? attachment.contentTransferEncoding : 'base64'
};
if (attachment.filename) {
data.filename = attachment.filename;
} else if (!isMessageNode && attachment.filename !== false) {
data.filename = (attachment.path || attachment.href || '').split('/').pop().split('?').shift() || 'attachment-' + (i + 1);
if (data.filename.indexOf('.') < 0) {
data.filename += '.' + mimeFuncs.detectExtension(data.contentType);
}
}
if (/^https?:\/\//i.test(attachment.path)) {
attachment.href = attachment.path;
attachment.path = undefined;
}
if (attachment.cid) {
data.cid = attachment.cid;
}
if (attachment.raw) {
data.raw = attachment.raw;
} else if (attachment.path) {
data.content = {
path: attachment.path
};
} else if (attachment.href) {
data.content = {
href: attachment.href,
httpHeaders: attachment.httpHeaders
};
} else {
data.content = attachment.content || '';
}
if (attachment.encoding) {
data.encoding = attachment.encoding;
}
if (attachment.headers) {
data.headers = attachment.headers;
}
return data;
});
if (this.mail.icalEvent) {
if (
typeof this.mail.icalEvent === 'object' &&
(this.mail.icalEvent.content || this.mail.icalEvent.path || this.mail.icalEvent.href || this.mail.icalEvent.raw)
) {
icalEvent = this.mail.icalEvent;
} else {
icalEvent = {
content: this.mail.icalEvent
};
}
eventObject = {};
Object.keys(icalEvent).forEach(key => {
eventObject[key] = icalEvent[key];
});
eventObject.contentType = 'application/ics';
if (!eventObject.headers) {
eventObject.headers = {};
}
eventObject.filename = eventObject.filename || 'invite.ics';
eventObject.headers['Content-Disposition'] = 'attachment';
eventObject.headers['Content-Transfer-Encoding'] = 'base64';
}
if (!findRelated) {
return {
attached: attachments.concat(eventObject || []),
related: []
};
} else {
return {
attached: attachments.filter(attachment => !attachment.cid).concat(eventObject || []),
related: attachments.filter(attachment => !!attachment.cid)
};
}
}
/**
* List alternatives. Resulting objects can be used as input for MimeNode nodes
*
* @returns {Array} An array of alternative elements. Includes the `text` and `html` values as well
*/
getAlternatives() {
let alternatives = [],
text,
html,
watchHtml,
amp,
icalEvent,
eventObject;
if (this.mail.text) {
if (typeof this.mail.text === 'object' && (this.mail.text.content || this.mail.text.path || this.mail.text.href || this.mail.text.raw)) {
text = this.mail.text;
} else {
text = {
content: this.mail.text
};
}
text.contentType = 'text/plain; charset=utf-8';
}
if (this.mail.watchHtml) {
if (
typeof this.mail.watchHtml === 'object' &&
(this.mail.watchHtml.content || this.mail.watchHtml.path || this.mail.watchHtml.href || this.mail.watchHtml.raw)
) {
watchHtml = this.mail.watchHtml;
} else {
watchHtml = {
content: this.mail.watchHtml
};
}
watchHtml.contentType = 'text/watch-html; charset=utf-8';
}
if (this.mail.amp) {
if (typeof this.mail.amp === 'object' && (this.mail.amp.content || this.mail.amp.path || this.mail.amp.href || this.mail.amp.raw)) {
amp = this.mail.amp;
} else {
amp = {
content: this.mail.amp
};
}
amp.contentType = 'text/x-amp-html; charset=utf-8';
}
// only include the calendar alternative if there are no attachments
// otherwise you might end up in a blank screen on some clients
if (this.mail.icalEvent && !(this.mail.attachments && this.mail.attachments.length)) {
if (
typeof this.mail.icalEvent === 'object' &&
(this.mail.icalEvent.content || this.mail.icalEvent.path || this.mail.icalEvent.href || this.mail.icalEvent.raw)
) {
icalEvent = this.mail.icalEvent;
} else {
icalEvent = {
content: this.mail.icalEvent
};
}
eventObject = {};
Object.keys(icalEvent).forEach(key => {
eventObject[key] = icalEvent[key];
});
if (eventObject.content && typeof eventObject.content === 'object') {
// we are going to have the same attachment twice, so mark this to be
// resolved just once
eventObject.content._resolve = true;
}
eventObject.filename = false;
eventObject.contentType = 'text/calendar; charset=utf-8; method=' + (eventObject.method || 'PUBLISH').toString().trim().toUpperCase();
if (!eventObject.headers) {
eventObject.headers = {};
}
}
if (this.mail.html) {
if (typeof this.mail.html === 'object' && (this.mail.html.content || this.mail.html.path || this.mail.html.href || this.mail.html.raw)) {
html = this.mail.html;
} else {
html = {
content: this.mail.html
};
}
html.contentType = 'text/html; charset=utf-8';
}
[]
.concat(text || [])
.concat(watchHtml || [])
.concat(amp || [])
.concat(html || [])
.concat(eventObject || [])
.concat(this.mail.alternatives || [])
.forEach(alternative => {
let data;
if (/^data:/i.test(alternative.path || alternative.href)) {
alternative = this._processDataUrl(alternative);
}
data = {
contentType: alternative.contentType || mimeFuncs.detectMimeType(alternative.filename || alternative.path || alternative.href || 'txt'),
contentTransferEncoding: alternative.contentTransferEncoding
};
if (alternative.filename) {
data.filename = alternative.filename;
}
if (/^https?:\/\//i.test(alternative.path)) {
alternative.href = alternative.path;
alternative.path = undefined;
}
if (alternative.raw) {
data.raw = alternative.raw;
} else if (alternative.path) {
data.content = {
path: alternative.path
};
} else if (alternative.href) {
data.content = {
href: alternative.href
};
} else {
data.content = alternative.content || '';
}
if (alternative.encoding) {
data.encoding = alternative.encoding;
}
if (alternative.headers) {
data.headers = alternative.headers;
}
alternatives.push(data);
});
return alternatives;
}
/**
* Builds multipart/mixed node. It should always contain different type of elements on the same level
* eg. text + attachments
*
* @param {Object} parentNode Parent for this note. If it does not exist, a root node is created
* @returns {Object} MimeNode node element
*/
_createMixed(parentNode) {
let node;
if (!parentNode) {
node = new MimeNode('multipart/mixed', {
baseBoundary: this.mail.baseBoundary,
textEncoding: this.mail.textEncoding,
boundaryPrefix: this.mail.boundaryPrefix,
disableUrlAccess: this.mail.disableUrlAccess,
disableFileAccess: this.mail.disableFileAccess,
normalizeHeaderKey: this.mail.normalizeHeaderKey
});
} else {
node = parentNode.createChild('multipart/mixed', {
disableUrlAccess: this.mail.disableUrlAccess,
disableFileAccess: this.mail.disableFileAccess,
normalizeHeaderKey: this.mail.normalizeHeaderKey
});
}
if (this._useAlternative) {
this._createAlternative(node);
} else if (this._useRelated) {
this._createRelated(node);
}
[]
.concat((!this._useAlternative && this._alternatives) || [])
.concat(this._attachments.attached || [])
.forEach(element => {
// if the element is a html node from related subpart then ignore it
if (!this._useRelated || element !== this._htmlNode) {
this._createContentNode(node, element);
}
});
return node;
}
/**
* Builds multipart/alternative node. It should always contain same type of elements on the same level
* eg. text + html view of the same data
*
* @param {Object} parentNode Parent for this note. If it does not exist, a root node is created
* @returns {Object} MimeNode node element
*/
_createAlternative(parentNode) {
let node;
if (!parentNode) {
node = new MimeNode('multipart/alternative', {
baseBoundary: this.mail.baseBoundary,
textEncoding: this.mail.textEncoding,
boundaryPrefix: this.mail.boundaryPrefix,
disableUrlAccess: this.mail.disableUrlAccess,
disableFileAccess: this.mail.disableFileAccess,
normalizeHeaderKey: this.mail.normalizeHeaderKey
});
} else {
node = parentNode.createChild('multipart/alternative', {
disableUrlAccess: this.mail.disableUrlAccess,
disableFileAccess: this.mail.disableFileAccess,
normalizeHeaderKey: this.mail.normalizeHeaderKey
});
}
this._alternatives.forEach(alternative => {
if (this._useRelated && this._htmlNode === alternative) {
this._createRelated(node);
} else {
this._createContentNode(node, alternative);
}
});
return node;
}
/**
* Builds multipart/related node. It should always contain html node with related attachments
*
* @param {Object} parentNode Parent for this note. If it does not exist, a root node is created
* @returns {Object} MimeNode node element
*/
_createRelated(parentNode) {
let node;
if (!parentNode) {
node = new MimeNode('multipart/related; type="text/html"', {
baseBoundary: this.mail.baseBoundary,
textEncoding: this.mail.textEncoding,
boundaryPrefix: this.mail.boundaryPrefix,
disableUrlAccess: this.mail.disableUrlAccess,
disableFileAccess: this.mail.disableFileAccess,
normalizeHeaderKey: this.mail.normalizeHeaderKey
});
} else {
node = parentNode.createChild('multipart/related; type="text/html"', {
disableUrlAccess: this.mail.disableUrlAccess,
disableFileAccess: this.mail.disableFileAccess,
normalizeHeaderKey: this.mail.normalizeHeaderKey
});
}
this._createContentNode(node, this._htmlNode);
this._attachments.related.forEach(alternative => this._createContentNode(node, alternative));
return node;
}
/**
* Creates a regular node with contents
*
* @param {Object} parentNode Parent for this note. If it does not exist, a root node is created
* @param {Object} element Node data
* @returns {Object} MimeNode node element
*/
_createContentNode(parentNode, element) {
element = element || {};
element.content = element.content || '';
let node;
let encoding = (element.encoding || 'utf8')
.toString()
.toLowerCase()
.replace(/[-_\s]/g, '');
if (!parentNode) {
node = new MimeNode(element.contentType, {
filename: element.filename,
baseBoundary: this.mail.baseBoundary,
textEncoding: this.mail.textEncoding,
boundaryPrefix: this.mail.boundaryPrefix,
disableUrlAccess: this.mail.disableUrlAccess,
disableFileAccess: this.mail.disableFileAccess
});
} else {
node = parentNode.createChild(element.contentType, {
filename: element.filename,
disableUrlAccess: this.mail.disableUrlAccess,
disableFileAccess: this.mail.disableFileAccess,
normalizeHeaderKey: this.mail.normalizeHeaderKey
});
}
// add custom headers
if (element.headers) {
node.addHeader(element.headers);
}
if (element.cid) {
node.setHeader('Content-Id', '<' + element.cid.replace(/[<>]/g, '') + '>');
}
if (element.contentTransferEncoding) {
node.setHeader('Content-Transfer-Encoding', element.contentTransferEncoding);
} else if (this.mail.encoding && /^text\//i.test(element.contentType)) {
node.setHeader('Content-Transfer-Encoding', this.mail.encoding);
}
if (!/^text\//i.test(element.contentType) || element.contentDisposition) {
node.setHeader('Content-Disposition', element.contentDisposition || (element.cid ? 'inline' : 'attachment'));
}
if (typeof element.content === 'string' && !['utf8', 'usascii', 'ascii'].includes(encoding)) {
element.content = Buffer.from(element.content, encoding);
}
// prefer pregenerated raw content
if (element.raw) {
node.setRaw(element.raw);
} else {
node.setContent(element.content);
}
return node;
}
/**
* Parses data uri and converts it to a Buffer
*
* @param {Object} element Content element
* @return {Object} Parsed element
*/
_processDataUrl(element) {
let parts = (element.path || element.href).match(/^data:((?:[^;]*;)*(?:[^,]*)),(.*)$/i);
if (!parts) {
return element;
}
element.content = /\bbase64$/i.test(parts[1]) ? Buffer.from(parts[2], 'base64') : Buffer.from(decodeURIComponent(parts[2]));
if ('path' in element) {
element.path = false;
}
if ('href' in element) {
element.href = false;
}
parts[1].split(';').forEach(item => {
if (/^\w+\/[^/]+$/i.test(item)) {
element.contentType = element.contentType || item.toLowerCase();
}
});
return element;
}
}
module.exports = MailComposer;

@ -0,0 +1,423 @@
'use strict';
const EventEmitter = require('events');
const shared = require('../shared');
const mimeTypes = require('../mime-funcs/mime-types');
const MailComposer = require('../mail-composer');
const DKIM = require('../dkim');
const httpProxyClient = require('../smtp-connection/http-proxy-client');
const util = require('util');
const urllib = require('url');
const packageData = require('../../package.json');
const MailMessage = require('./mail-message');
const net = require('net');
const dns = require('dns');
const crypto = require('crypto');
/**
* Creates an object for exposing the Mail API
*
* @constructor
* @param {Object} transporter Transport object instance to pass the mails to
*/
class Mail extends EventEmitter {
constructor(transporter, options, defaults) {
super();
this.options = options || {};
this._defaults = defaults || {};
this._defaultPlugins = {
compile: [(...args) => this._convertDataImages(...args)],
stream: []
};
this._userPlugins = {
compile: [],
stream: []
};
this.meta = new Map();
this.dkim = this.options.dkim ? new DKIM(this.options.dkim) : false;
this.transporter = transporter;
this.transporter.mailer = this;
this.logger = shared.getLogger(this.options, {
component: this.options.component || 'mail'
});
this.logger.debug(
{
tnx: 'create'
},
'Creating transport: %s',
this.getVersionString()
);
// setup emit handlers for the transporter
if (typeof this.transporter.on === 'function') {
// deprecated log interface
this.transporter.on('log', log => {
this.logger.debug(
{
tnx: 'transport'
},
'%s: %s',
log.type,
log.message
);
});
// transporter errors
this.transporter.on('error', err => {
this.logger.error(
{
err,
tnx: 'transport'
},
'Transport Error: %s',
err.message
);
this.emit('error', err);
});
// indicates if the sender has became idle
this.transporter.on('idle', (...args) => {
this.emit('idle', ...args);
});
}
/**
* Optional methods passed to the underlying transport object
*/
['close', 'isIdle', 'verify'].forEach(method => {
this[method] = (...args) => {
if (typeof this.transporter[method] === 'function') {
return this.transporter[method](...args);
} else {
this.logger.warn(
{
tnx: 'transport',
methodName: method
},
'Non existing method %s called for transport',
method
);
return false;
}
};
});
// setup proxy handling
if (this.options.proxy && typeof this.options.proxy === 'string') {
this.setupProxy(this.options.proxy);
}
}
use(step, plugin) {
step = (step || '').toString();
if (!this._userPlugins.hasOwnProperty(step)) {
this._userPlugins[step] = [plugin];
} else {
this._userPlugins[step].push(plugin);
}
return this;
}
/**
* Sends an email using the preselected transport object
*
* @param {Object} data E-data description
* @param {Function?} callback Callback to run once the sending succeeded or failed
*/
sendMail(data, callback) {
let promise;
if (!callback) {
promise = new Promise((resolve, reject) => {
callback = shared.callbackPromise(resolve, reject);
});
}
if (typeof this.getSocket === 'function') {
this.transporter.getSocket = this.getSocket;
this.getSocket = false;
}
let mail = new MailMessage(this, data);
this.logger.debug(
{
tnx: 'transport',
name: this.transporter.name,
version: this.transporter.version,
action: 'send'
},
'Sending mail using %s/%s',
this.transporter.name,
this.transporter.version
);
this._processPlugins('compile', mail, err => {
if (err) {
this.logger.error(
{
err,
tnx: 'plugin',
action: 'compile'
},
'PluginCompile Error: %s',
err.message
);
return callback(err);
}
mail.message = new MailComposer(mail.data).compile();
mail.setMailerHeader();
mail.setPriorityHeaders();
mail.setListHeaders();
this._processPlugins('stream', mail, err => {
if (err) {
this.logger.error(
{
err,
tnx: 'plugin',
action: 'stream'
},
'PluginStream Error: %s',
err.message
);
return callback(err);
}
if (mail.data.dkim || this.dkim) {
mail.message.processFunc(input => {
let dkim = mail.data.dkim ? new DKIM(mail.data.dkim) : this.dkim;
this.logger.debug(
{
tnx: 'DKIM',
messageId: mail.message.messageId(),
dkimDomains: dkim.keys.map(key => key.keySelector + '.' + key.domainName).join(', ')
},
'Signing outgoing message with %s keys',
dkim.keys.length
);
return dkim.sign(input, mail.data._dkim);
});
}
this.transporter.send(mail, (...args) => {
if (args[0]) {
this.logger.error(
{
err: args[0],
tnx: 'transport',
action: 'send'
},
'Send Error: %s',
args[0].message
);
}
callback(...args);
});
});
});
return promise;
}
getVersionString() {
return util.format('%s (%s; +%s; %s/%s)', packageData.name, packageData.version, packageData.homepage, this.transporter.name, this.transporter.version);
}
_processPlugins(step, mail, callback) {
step = (step || '').toString();
if (!this._userPlugins.hasOwnProperty(step)) {
return callback();
}
let userPlugins = this._userPlugins[step] || [];
let defaultPlugins = this._defaultPlugins[step] || [];
if (userPlugins.length) {
this.logger.debug(
{
tnx: 'transaction',
pluginCount: userPlugins.length,
step
},
'Using %s plugins for %s',
userPlugins.length,
step
);
}
if (userPlugins.length + defaultPlugins.length === 0) {
return callback();
}
let pos = 0;
let block = 'default';
let processPlugins = () => {
let curplugins = block === 'default' ? defaultPlugins : userPlugins;
if (pos >= curplugins.length) {
if (block === 'default' && userPlugins.length) {
block = 'user';
pos = 0;
curplugins = userPlugins;
} else {
return callback();
}
}
let plugin = curplugins[pos++];
plugin(mail, err => {
if (err) {
return callback(err);
}
processPlugins();
});
};
processPlugins();
}
/**
* Sets up proxy handler for a Nodemailer object
*
* @param {String} proxyUrl Proxy configuration url
*/
setupProxy(proxyUrl) {
let proxy = urllib.parse(proxyUrl);
// setup socket handler for the mailer object
this.getSocket = (options, callback) => {
let protocol = proxy.protocol.replace(/:$/, '').toLowerCase();
if (this.meta.has('proxy_handler_' + protocol)) {
return this.meta.get('proxy_handler_' + protocol)(proxy, options, callback);
}
switch (protocol) {
// Connect using a HTTP CONNECT method
case 'http':
case 'https':
httpProxyClient(proxy.href, options.port, options.host, (err, socket) => {
if (err) {
return callback(err);
}
return callback(null, {
connection: socket
});
});
return;
case 'socks':
case 'socks5':
case 'socks4':
case 'socks4a': {
if (!this.meta.has('proxy_socks_module')) {
return callback(new Error('Socks module not loaded'));
}
let connect = ipaddress => {
let proxyV2 = !!this.meta.get('proxy_socks_module').SocksClient;
let socksClient = proxyV2 ? this.meta.get('proxy_socks_module').SocksClient : this.meta.get('proxy_socks_module');
let proxyType = Number(proxy.protocol.replace(/\D/g, '')) || 5;
let connectionOpts = {
proxy: {
ipaddress,
port: Number(proxy.port),
type: proxyType
},
[proxyV2 ? 'destination' : 'target']: {
host: options.host,
port: options.port
},
command: 'connect'
};
if (proxy.auth) {
let username = decodeURIComponent(proxy.auth.split(':').shift());
let password = decodeURIComponent(proxy.auth.split(':').pop());
if (proxyV2) {
connectionOpts.proxy.userId = username;
connectionOpts.proxy.password = password;
} else if (proxyType === 4) {
connectionOpts.userid = username;
} else {
connectionOpts.authentication = {
username,
password
};
}
}
socksClient.createConnection(connectionOpts, (err, info) => {
if (err) {
return callback(err);
}
return callback(null, {
connection: info.socket || info
});
});
};
if (net.isIP(proxy.hostname)) {
return connect(proxy.hostname);
}
return dns.resolve(proxy.hostname, (err, address) => {
if (err) {
return callback(err);
}
connect(Array.isArray(address) ? address[0] : address);
});
}
}
callback(new Error('Unknown proxy configuration'));
};
}
_convertDataImages(mail, callback) {
if ((!this.options.attachDataUrls && !mail.data.attachDataUrls) || !mail.data.html) {
return callback();
}
mail.resolveContent(mail.data, 'html', (err, html) => {
if (err) {
return callback(err);
}
let cidCounter = 0;
html = (html || '').toString().replace(/(<img\b[^>]* src\s*=[\s"']*)(data:([^;]+);[^"'>\s]+)/gi, (match, prefix, dataUri, mimeType) => {
let cid = crypto.randomBytes(10).toString('hex') + '@localhost';
if (!mail.data.attachments) {
mail.data.attachments = [];
}
if (!Array.isArray(mail.data.attachments)) {
mail.data.attachments = [].concat(mail.data.attachments || []);
}
mail.data.attachments.push({
path: dataUri,
cid,
filename: 'image-' + ++cidCounter + '.' + mimeTypes.detectExtension(mimeType)
});
return prefix + 'cid:' + cid;
});
mail.data.html = html;
callback();
});
}
set(key, value) {
return this.meta.set(key, value);
}
get(key) {
return this.meta.get(key);
}
}
module.exports = Mail;

@ -0,0 +1,320 @@
'use strict';
const shared = require('../shared');
const MimeNode = require('../mime-node');
const mimeFuncs = require('../mime-funcs');
class MailMessage {
constructor(mailer, data) {
this.mailer = mailer;
this.data = {};
this.message = null;
data = data || {};
let options = mailer.options || {};
let defaults = mailer._defaults || {};
Object.keys(data).forEach(key => {
this.data[key] = data[key];
});
this.data.headers = this.data.headers || {};
// apply defaults
Object.keys(defaults).forEach(key => {
if (!(key in this.data)) {
this.data[key] = defaults[key];
} else if (key === 'headers') {
// headers is a special case. Allow setting individual default headers
Object.keys(defaults.headers).forEach(key => {
if (!(key in this.data.headers)) {
this.data.headers[key] = defaults.headers[key];
}
});
}
});
// force specific keys from transporter options
['disableFileAccess', 'disableUrlAccess', 'normalizeHeaderKey'].forEach(key => {
if (key in options) {
this.data[key] = options[key];
}
});
}
resolveContent(...args) {
return shared.resolveContent(...args);
}
resolveAll(callback) {
let keys = [
[this.data, 'html'],
[this.data, 'text'],
[this.data, 'watchHtml'],
[this.data, 'amp'],
[this.data, 'icalEvent']
];
if (this.data.alternatives && this.data.alternatives.length) {
this.data.alternatives.forEach((alternative, i) => {
keys.push([this.data.alternatives, i]);
});
}
if (this.data.attachments && this.data.attachments.length) {
this.data.attachments.forEach((attachment, i) => {
if (!attachment.filename) {
attachment.filename =
(attachment.path || attachment.href || '')
.split('/')
.pop()
.split('?')
.shift() || 'attachment-' + (i + 1);
if (attachment.filename.indexOf('.') < 0) {
attachment.filename += '.' + mimeFuncs.detectExtension(attachment.contentType);
}
}
if (!attachment.contentType) {
attachment.contentType = mimeFuncs.detectMimeType(attachment.filename || attachment.path || attachment.href || 'bin');
}
keys.push([this.data.attachments, i]);
});
}
let mimeNode = new MimeNode();
let addressKeys = ['from', 'to', 'cc', 'bcc', 'sender', 'replyTo'];
addressKeys.forEach(address => {
let value;
if (this.message) {
value = [].concat(mimeNode._parseAddresses(this.message.getHeader(address === 'replyTo' ? 'reply-to' : address)) || []);
} else if (this.data[address]) {
value = [].concat(mimeNode._parseAddresses(this.data[address]) || []);
}
if (value && value.length) {
this.data[address] = value;
} else if (address in this.data) {
this.data[address] = null;
}
});
let singleKeys = ['from', 'sender', 'replyTo'];
singleKeys.forEach(address => {
if (this.data[address]) {
this.data[address] = this.data[address].shift();
}
});
let pos = 0;
let resolveNext = () => {
if (pos >= keys.length) {
return callback(null, this.data);
}
let args = keys[pos++];
if (!args[0] || !args[0][args[1]]) {
return resolveNext();
}
shared.resolveContent(...args, (err, value) => {
if (err) {
return callback(err);
}
let node = {
content: value
};
if (args[0][args[1]] && typeof args[0][args[1]] === 'object' && !Buffer.isBuffer(args[0][args[1]])) {
Object.keys(args[0][args[1]]).forEach(key => {
if (!(key in node) && !['content', 'path', 'href', 'raw'].includes(key)) {
node[key] = args[0][args[1]][key];
}
});
}
args[0][args[1]] = node;
resolveNext();
});
};
setImmediate(() => resolveNext());
}
normalize(callback) {
let envelope = this.data.envelope || this.message.getEnvelope();
let messageId = this.message.messageId();
this.resolveAll((err, data) => {
if (err) {
return callback(err);
}
data.envelope = envelope;
data.messageId = messageId;
['html', 'text', 'watchHtml', 'amp'].forEach(key => {
if (data[key] && data[key].content) {
if (typeof data[key].content === 'string') {
data[key] = data[key].content;
} else if (Buffer.isBuffer(data[key].content)) {
data[key] = data[key].content.toString();
}
}
});
if (data.icalEvent && Buffer.isBuffer(data.icalEvent.content)) {
data.icalEvent.content = data.icalEvent.content.toString('base64');
data.icalEvent.encoding = 'base64';
}
if (data.alternatives && data.alternatives.length) {
data.alternatives.forEach(alternative => {
if (alternative && alternative.content && Buffer.isBuffer(alternative.content)) {
alternative.content = alternative.content.toString('base64');
alternative.encoding = 'base64';
}
});
}
if (data.attachments && data.attachments.length) {
data.attachments.forEach(attachment => {
if (attachment && attachment.content && Buffer.isBuffer(attachment.content)) {
attachment.content = attachment.content.toString('base64');
attachment.encoding = 'base64';
}
});
}
data.normalizedHeaders = {};
Object.keys(data.headers || {}).forEach(key => {
let value = [].concat(data.headers[key] || []).shift();
value = (value && value.value) || value;
if (value) {
if (['references', 'in-reply-to', 'message-id', 'content-id'].includes(key)) {
value = this.message._encodeHeaderValue(key, value);
}
data.normalizedHeaders[key] = value;
}
});
if (data.list && typeof data.list === 'object') {
let listHeaders = this._getListHeaders(data.list);
listHeaders.forEach(entry => {
data.normalizedHeaders[entry.key] = entry.value.map(val => (val && val.value) || val).join(', ');
});
}
if (data.references) {
data.normalizedHeaders.references = this.message._encodeHeaderValue('references', data.references);
}
if (data.inReplyTo) {
data.normalizedHeaders['in-reply-to'] = this.message._encodeHeaderValue('in-reply-to', data.inReplyTo);
}
return callback(null, data);
});
}
setMailerHeader() {
if (!this.message || !this.data.xMailer) {
return;
}
this.message.setHeader('X-Mailer', this.data.xMailer);
}
setPriorityHeaders() {
if (!this.message || !this.data.priority) {
return;
}
switch ((this.data.priority || '').toString().toLowerCase()) {
case 'high':
this.message.setHeader('X-Priority', '1 (Highest)');
this.message.setHeader('X-MSMail-Priority', 'High');
this.message.setHeader('Importance', 'High');
break;
case 'low':
this.message.setHeader('X-Priority', '5 (Lowest)');
this.message.setHeader('X-MSMail-Priority', 'Low');
this.message.setHeader('Importance', 'Low');
break;
default:
// do not add anything, since all messages are 'Normal' by default
}
}
setListHeaders() {
if (!this.message || !this.data.list || typeof this.data.list !== 'object') {
return;
}
// add optional List-* headers
if (this.data.list && typeof this.data.list === 'object') {
this._getListHeaders(this.data.list).forEach(listHeader => {
listHeader.value.forEach(value => {
this.message.addHeader(listHeader.key, value);
});
});
}
}
_getListHeaders(listData) {
// make sure an url looks like <protocol:url>
return Object.keys(listData).map(key => ({
key: 'list-' + key.toLowerCase().trim(),
value: [].concat(listData[key] || []).map(value => ({
prepared: true,
foldLines: true,
value: []
.concat(value || [])
.map(value => {
if (typeof value === 'string') {
value = {
url: value
};
}
if (value && value.url) {
if (key.toLowerCase().trim() === 'id') {
// List-ID: "comment" <domain>
let comment = value.comment || '';
if (mimeFuncs.isPlainText(comment)) {
comment = '"' + comment + '"';
} else {
comment = mimeFuncs.encodeWord(comment);
}
return (value.comment ? comment + ' ' : '') + this._formatListUrl(value.url).replace(/^<[^:]+\/{,2}/, '');
}
// List-*: <http://domain> (comment)
let comment = value.comment || '';
if (!mimeFuncs.isPlainText(comment)) {
comment = mimeFuncs.encodeWord(comment);
}
return this._formatListUrl(value.url) + (value.comment ? ' (' + comment + ')' : '');
}
return '';
})
.filter(value => value)
.join(', ')
}))
}));
}
_formatListUrl(url) {
url = url.replace(/[\s<]+|[\s>]+/g, '');
if (/^(https?|mailto|ftp):/.test(url)) {
return '<' + url + '>';
}
if (/^[^@]+@[^@]+$/.test(url)) {
return '<mailto:' + url + '>';
}
return '<http://' + url + '>';
}
}
module.exports = MailMessage;

@ -0,0 +1,628 @@
/* eslint no-control-regex:0 */
'use strict';
const base64 = require('../base64');
const qp = require('../qp');
const mimeTypes = require('./mime-types');
module.exports = {
/**
* Checks if a value is plaintext string (uses only printable 7bit chars)
*
* @param {String} value String to be tested
* @returns {Boolean} true if it is a plaintext string
*/
isPlainText(value) {
if (typeof value !== 'string' || /[\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]/.test(value)) {
return false;
} else {
return true;
}
},
/**
* Checks if a multi line string containes lines longer than the selected value.
*
* Useful when detecting if a mail message needs any processing at all
* if only plaintext characters are used and lines are short, then there is
* no need to encode the values in any way. If the value is plaintext but has
* longer lines then allowed, then use format=flowed
*
* @param {Number} lineLength Max line length to check for
* @returns {Boolean} Returns true if there is at least one line longer than lineLength chars
*/
hasLongerLines(str, lineLength) {
if (str.length > 128 * 1024) {
// do not test strings longer than 128kB
return true;
}
return new RegExp('^.{' + (lineLength + 1) + ',}', 'm').test(str);
},
/**
* Encodes a string or an Buffer to an UTF-8 MIME Word (rfc2047)
*
* @param {String|Buffer} data String to be encoded
* @param {String} mimeWordEncoding='Q' Encoding for the mime word, either Q or B
* @param {Number} [maxLength=0] If set, split mime words into several chunks if needed
* @return {String} Single or several mime words joined together
*/
encodeWord(data, mimeWordEncoding, maxLength) {
mimeWordEncoding = (mimeWordEncoding || 'Q')
.toString()
.toUpperCase()
.trim()
.charAt(0);
maxLength = maxLength || 0;
let encodedStr;
let toCharset = 'UTF-8';
if (maxLength && maxLength > 7 + toCharset.length) {
maxLength -= 7 + toCharset.length;
}
if (mimeWordEncoding === 'Q') {
// https://tools.ietf.org/html/rfc2047#section-5 rule (3)
encodedStr = qp.encode(data).replace(/[^a-z0-9!*+\-/=]/gi, chr => {
let ord = chr
.charCodeAt(0)
.toString(16)
.toUpperCase();
if (chr === ' ') {
return '_';
} else {
return '=' + (ord.length === 1 ? '0' + ord : ord);
}
});
} else if (mimeWordEncoding === 'B') {
encodedStr = typeof data === 'string' ? data : base64.encode(data);
maxLength = maxLength ? Math.max(3, ((maxLength - (maxLength % 4)) / 4) * 3) : 0;
}
if (maxLength && (mimeWordEncoding !== 'B' ? encodedStr : base64.encode(data)).length > maxLength) {
if (mimeWordEncoding === 'Q') {
encodedStr = this.splitMimeEncodedString(encodedStr, maxLength).join('?= =?' + toCharset + '?' + mimeWordEncoding + '?');
} else {
// RFC2047 6.3 (2) states that encoded-word must include an integral number of characters, so no chopping unicode sequences
let parts = [];
let lpart = '';
for (let i = 0, len = encodedStr.length; i < len; i++) {
let chr = encodedStr.charAt(i);
// check if we can add this character to the existing string
// without breaking byte length limit
if (Buffer.byteLength(lpart + chr) <= maxLength || i === 0) {
lpart += chr;
} else {
// we hit the length limit, so push the existing string and start over
parts.push(base64.encode(lpart));
lpart = chr;
}
}
if (lpart) {
parts.push(base64.encode(lpart));
}
if (parts.length > 1) {
encodedStr = parts.join('?= =?' + toCharset + '?' + mimeWordEncoding + '?');
} else {
encodedStr = parts.join('');
}
}
} else if (mimeWordEncoding === 'B') {
encodedStr = base64.encode(data);
}
return '=?' + toCharset + '?' + mimeWordEncoding + '?' + encodedStr + (encodedStr.substr(-2) === '?=' ? '' : '?=');
},
/**
* Finds word sequences with non ascii text and converts these to mime words
*
* @param {String} value String to be encoded
* @param {String} mimeWordEncoding='Q' Encoding for the mime word, either Q or B
* @param {Number} [maxLength=0] If set, split mime words into several chunks if needed
* @param {Boolean} [encodeAll=false] If true and the value needs encoding then encodes entire string, not just the smallest match
* @return {String} String with possible mime words
*/
encodeWords(value, mimeWordEncoding, maxLength, encodeAll) {
maxLength = maxLength || 0;
let encodedValue;
// find first word with a non-printable ascii in it
let firstMatch = value.match(/(?:^|\s)([^\s]*[\u0080-\uFFFF])/);
if (!firstMatch) {
return value;
}
if (encodeAll) {
// if it is requested to encode everything or the string contains something that resebles encoded word, then encode everything
return this.encodeWord(value, mimeWordEncoding, maxLength);
}
// find the last word with a non-printable ascii in it
let lastMatch = value.match(/([\u0080-\uFFFF][^\s]*)[^\u0080-\uFFFF]*$/);
if (!lastMatch) {
// should not happen
return value;
}
let startIndex =
firstMatch.index +
(
firstMatch[0].match(/[^\s]/) || {
index: 0
}
).index;
let endIndex = lastMatch.index + (lastMatch[1] || '').length;
encodedValue =
(startIndex ? value.substr(0, startIndex) : '') +
this.encodeWord(value.substring(startIndex, endIndex), mimeWordEncoding || 'Q', maxLength) +
(endIndex < value.length ? value.substr(endIndex) : '');
return encodedValue;
},
/**
* Joins parsed header value together as 'value; param1=value1; param2=value2'
* PS: We are following RFC 822 for the list of special characters that we need to keep in quotes.
* Refer: https://www.w3.org/Protocols/rfc1341/4_Content-Type.html
* @param {Object} structured Parsed header value
* @return {String} joined header value
*/
buildHeaderValue(structured) {
let paramsArray = [];
Object.keys(structured.params || {}).forEach(param => {
// filename might include unicode characters so it is a special case
// other values probably do not
let value = structured.params[param];
if (!this.isPlainText(value) || value.length >= 75) {
this.buildHeaderParam(param, value, 50).forEach(encodedParam => {
if (!/[\s"\\;:/=(),<>@[\]?]|^[-']|'$/.test(encodedParam.value) || encodedParam.key.substr(-1) === '*') {
paramsArray.push(encodedParam.key + '=' + encodedParam.value);
} else {
paramsArray.push(encodedParam.key + '=' + JSON.stringify(encodedParam.value));
}
});
} else if (/[\s'"\\;:/=(),<>@[\]?]|^-/.test(value)) {
paramsArray.push(param + '=' + JSON.stringify(value));
} else {
paramsArray.push(param + '=' + value);
}
});
return structured.value + (paramsArray.length ? '; ' + paramsArray.join('; ') : '');
},
/**
* Encodes a string or an Buffer to an UTF-8 Parameter Value Continuation encoding (rfc2231)
* Useful for splitting long parameter values.
*
* For example
* title="unicode string"
* becomes
* title*0*=utf-8''unicode
* title*1*=%20string
*
* @param {String|Buffer} data String to be encoded
* @param {Number} [maxLength=50] Max length for generated chunks
* @param {String} [fromCharset='UTF-8'] Source sharacter set
* @return {Array} A list of encoded keys and headers
*/
buildHeaderParam(key, data, maxLength) {
let list = [];
let encodedStr = typeof data === 'string' ? data : (data || '').toString();
let encodedStrArr;
let chr, ord;
let line;
let startPos = 0;
let i, len;
maxLength = maxLength || 50;
// process ascii only text
if (this.isPlainText(data)) {
// check if conversion is even needed
if (encodedStr.length <= maxLength) {
return [
{
key,
value: encodedStr
}
];
}
encodedStr = encodedStr.replace(new RegExp('.{' + maxLength + '}', 'g'), str => {
list.push({
line: str
});
return '';
});
if (encodedStr) {
list.push({
line: encodedStr
});
}
} else {
if (/[\uD800-\uDBFF]/.test(encodedStr)) {
// string containts surrogate pairs, so normalize it to an array of bytes
encodedStrArr = [];
for (i = 0, len = encodedStr.length; i < len; i++) {
chr = encodedStr.charAt(i);
ord = chr.charCodeAt(0);
if (ord >= 0xd800 && ord <= 0xdbff && i < len - 1) {
chr += encodedStr.charAt(i + 1);
encodedStrArr.push(chr);
i++;
} else {
encodedStrArr.push(chr);
}
}
encodedStr = encodedStrArr;
}
// first line includes the charset and language info and needs to be encoded
// even if it does not contain any unicode characters
line = 'utf-8\x27\x27';
let encoded = true;
startPos = 0;
// process text with unicode or special chars
for (i = 0, len = encodedStr.length; i < len; i++) {
chr = encodedStr[i];
if (encoded) {
chr = this.safeEncodeURIComponent(chr);
} else {
// try to urlencode current char
chr = chr === ' ' ? chr : this.safeEncodeURIComponent(chr);
// By default it is not required to encode a line, the need
// only appears when the string contains unicode or special chars
// in this case we start processing the line over and encode all chars
if (chr !== encodedStr[i]) {
// Check if it is even possible to add the encoded char to the line
// If not, there is no reason to use this line, just push it to the list
// and start a new line with the char that needs encoding
if ((this.safeEncodeURIComponent(line) + chr).length >= maxLength) {
list.push({
line,
encoded
});
line = '';
startPos = i - 1;
} else {
encoded = true;
i = startPos;
line = '';
continue;
}
}
}
// if the line is already too long, push it to the list and start a new one
if ((line + chr).length >= maxLength) {
list.push({
line,
encoded
});
line = chr = encodedStr[i] === ' ' ? ' ' : this.safeEncodeURIComponent(encodedStr[i]);
if (chr === encodedStr[i]) {
encoded = false;
startPos = i - 1;
} else {
encoded = true;
}
} else {
line += chr;
}
}
if (line) {
list.push({
line,
encoded
});
}
}
return list.map((item, i) => ({
// encoded lines: {name}*{part}*
// unencoded lines: {name}*{part}
// if any line needs to be encoded then the first line (part==0) is always encoded
key: key + '*' + i + (item.encoded ? '*' : ''),
value: item.line
}));
},
/**
* Parses a header value with key=value arguments into a structured
* object.
*
* parseHeaderValue('content-type: text/plain; CHARSET='UTF-8'') ->
* {
* 'value': 'text/plain',
* 'params': {
* 'charset': 'UTF-8'
* }
* }
*
* @param {String} str Header value
* @return {Object} Header value as a parsed structure
*/
parseHeaderValue(str) {
let response = {
value: false,
params: {}
};
let key = false;
let value = '';
let type = 'value';
let quote = false;
let escaped = false;
let chr;
for (let i = 0, len = str.length; i < len; i++) {
chr = str.charAt(i);
if (type === 'key') {
if (chr === '=') {
key = value.trim().toLowerCase();
type = 'value';
value = '';
continue;
}
value += chr;
} else {
if (escaped) {
value += chr;
} else if (chr === '\\') {
escaped = true;
continue;
} else if (quote && chr === quote) {
quote = false;
} else if (!quote && chr === '"') {
quote = chr;
} else if (!quote && chr === ';') {
if (key === false) {
response.value = value.trim();
} else {
response.params[key] = value.trim();
}
type = 'key';
value = '';
} else {
value += chr;
}
escaped = false;
}
}
if (type === 'value') {
if (key === false) {
response.value = value.trim();
} else {
response.params[key] = value.trim();
}
} else if (value.trim()) {
response.params[value.trim().toLowerCase()] = '';
}
// handle parameter value continuations
// https://tools.ietf.org/html/rfc2231#section-3
// preprocess values
Object.keys(response.params).forEach(key => {
let actualKey, nr, match, value;
if ((match = key.match(/(\*(\d+)|\*(\d+)\*|\*)$/))) {
actualKey = key.substr(0, match.index);
nr = Number(match[2] || match[3]) || 0;
if (!response.params[actualKey] || typeof response.params[actualKey] !== 'object') {
response.params[actualKey] = {
charset: false,
values: []
};
}
value = response.params[key];
if (nr === 0 && match[0].substr(-1) === '*' && (match = value.match(/^([^']*)'[^']*'(.*)$/))) {
response.params[actualKey].charset = match[1] || 'iso-8859-1';
value = match[2];
}
response.params[actualKey].values[nr] = value;
// remove the old reference
delete response.params[key];
}
});
// concatenate split rfc2231 strings and convert encoded strings to mime encoded words
Object.keys(response.params).forEach(key => {
let value;
if (response.params[key] && Array.isArray(response.params[key].values)) {
value = response.params[key].values.map(val => val || '').join('');
if (response.params[key].charset) {
// convert "%AB" to "=?charset?Q?=AB?="
response.params[key] =
'=?' +
response.params[key].charset +
'?Q?' +
value
// fix invalidly encoded chars
.replace(/[=?_\s]/g, s => {
let c = s.charCodeAt(0).toString(16);
if (s === ' ') {
return '_';
} else {
return '%' + (c.length < 2 ? '0' : '') + c;
}
})
// change from urlencoding to percent encoding
.replace(/%/g, '=') +
'?=';
} else {
response.params[key] = value;
}
}
});
return response;
},
/**
* Returns file extension for a content type string. If no suitable extensions
* are found, 'bin' is used as the default extension
*
* @param {String} mimeType Content type to be checked for
* @return {String} File extension
*/
detectExtension: mimeType => mimeTypes.detectExtension(mimeType),
/**
* Returns content type for a file extension. If no suitable content types
* are found, 'application/octet-stream' is used as the default content type
*
* @param {String} extension Extension to be checked for
* @return {String} File extension
*/
detectMimeType: extension => mimeTypes.detectMimeType(extension),
/**
* Folds long lines, useful for folding header lines (afterSpace=false) and
* flowed text (afterSpace=true)
*
* @param {String} str String to be folded
* @param {Number} [lineLength=76] Maximum length of a line
* @param {Boolean} afterSpace If true, leave a space in th end of a line
* @return {String} String with folded lines
*/
foldLines(str, lineLength, afterSpace) {
str = (str || '').toString();
lineLength = lineLength || 76;
let pos = 0,
len = str.length,
result = '',
line,
match;
while (pos < len) {
line = str.substr(pos, lineLength);
if (line.length < lineLength) {
result += line;
break;
}
if ((match = line.match(/^[^\n\r]*(\r?\n|\r)/))) {
line = match[0];
result += line;
pos += line.length;
continue;
} else if ((match = line.match(/(\s+)[^\s]*$/)) && match[0].length - (afterSpace ? (match[1] || '').length : 0) < line.length) {
line = line.substr(0, line.length - (match[0].length - (afterSpace ? (match[1] || '').length : 0)));
} else if ((match = str.substr(pos + line.length).match(/^[^\s]+(\s*)/))) {
line = line + match[0].substr(0, match[0].length - (!afterSpace ? (match[1] || '').length : 0));
}
result += line;
pos += line.length;
if (pos < len) {
result += '\r\n';
}
}
return result;
},
/**
* Splits a mime encoded string. Needed for dividing mime words into smaller chunks
*
* @param {String} str Mime encoded string to be split up
* @param {Number} maxlen Maximum length of characters for one part (minimum 12)
* @return {Array} Split string
*/
splitMimeEncodedString: (str, maxlen) => {
let curLine,
match,
chr,
done,
lines = [];
// require at least 12 symbols to fit possible 4 octet UTF-8 sequences
maxlen = Math.max(maxlen || 0, 12);
while (str.length) {
curLine = str.substr(0, maxlen);
// move incomplete escaped char back to main
if ((match = curLine.match(/[=][0-9A-F]?$/i))) {
curLine = curLine.substr(0, match.index);
}
done = false;
while (!done) {
done = true;
// check if not middle of a unicode char sequence
if ((match = str.substr(curLine.length).match(/^[=]([0-9A-F]{2})/i))) {
chr = parseInt(match[1], 16);
// invalid sequence, move one char back anc recheck
if (chr < 0xc2 && chr > 0x7f) {
curLine = curLine.substr(0, curLine.length - 3);
done = false;
}
}
}
if (curLine.length) {
lines.push(curLine);
}
str = str.substr(curLine.length);
}
return lines;
},
encodeURICharComponent: chr => {
let res = '';
let ord = chr
.charCodeAt(0)
.toString(16)
.toUpperCase();
if (ord.length % 2) {
ord = '0' + ord;
}
if (ord.length > 2) {
for (let i = 0, len = ord.length / 2; i < len; i++) {
res += '%' + ord.substr(i, 2);
}
} else {
res += '%' + ord;
}
return res;
},
safeEncodeURIComponent(str) {
str = (str || '').toString();
try {
// might throw if we try to encode invalid sequences, eg. partial emoji
str = encodeURIComponent(str);
} catch (E) {
// should never run
return str.replace(/[^\x00-\x1F *'()<>@,;:\\"[\]?=\u007F-\uFFFF]+/g, '');
}
// ensure chars that are not handled by encodeURICompent are converted as well
return str.replace(/[\x00-\x1F *'()<>@,;:\\"[\]?=\u007F-\uFFFF]/g, chr => this.encodeURICharComponent(chr));
}
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -0,0 +1,33 @@
'use strict';
const Transform = require('stream').Transform;
class LastNewline extends Transform {
constructor() {
super();
this.lastByte = false;
}
_transform(chunk, encoding, done) {
if (chunk.length) {
this.lastByte = chunk[chunk.length - 1];
}
this.push(chunk);
done();
}
_flush(done) {
if (this.lastByte === 0x0a) {
return done();
}
if (this.lastByte === 0x0d) {
this.push(Buffer.from('\n'));
return done();
}
this.push(Buffer.from('\r\n'));
return done();
}
}
module.exports = LastNewline;

@ -0,0 +1,148 @@
'use strict';
const Mailer = require('./mailer');
const shared = require('./shared');
const SMTPPool = require('./smtp-pool');
const SMTPTransport = require('./smtp-transport');
const SendmailTransport = require('./sendmail-transport');
const StreamTransport = require('./stream-transport');
const JSONTransport = require('./json-transport');
const SESTransport = require('./ses-transport');
const fetch = require('./fetch');
const packageData = require('../package.json');
const ETHEREAL_API = (process.env.ETHEREAL_API || 'https://api.nodemailer.com').replace(/\/+$/, '');
const ETHEREAL_WEB = (process.env.ETHEREAL_WEB || 'https://ethereal.email').replace(/\/+$/, '');
const ETHEREAL_CACHE = ['true', 'yes', 'y', '1'].includes(
(process.env.ETHEREAL_CACHE || 'yes')
.toString()
.trim()
.toLowerCase()
);
let testAccount = false;
module.exports.createTransport = function(transporter, defaults) {
let urlConfig;
let options;
let mailer;
if (
// provided transporter is a configuration object, not transporter plugin
(typeof transporter === 'object' && typeof transporter.send !== 'function') ||
// provided transporter looks like a connection url
(typeof transporter === 'string' && /^(smtps?|direct):/i.test(transporter))
) {
if ((urlConfig = typeof transporter === 'string' ? transporter : transporter.url)) {
// parse a configuration URL into configuration options
options = shared.parseConnectionUrl(urlConfig);
} else {
options = transporter;
}
if (options.pool) {
transporter = new SMTPPool(options);
} else if (options.sendmail) {
transporter = new SendmailTransport(options);
} else if (options.streamTransport) {
transporter = new StreamTransport(options);
} else if (options.jsonTransport) {
transporter = new JSONTransport(options);
} else if (options.SES) {
transporter = new SESTransport(options);
} else {
transporter = new SMTPTransport(options);
}
}
mailer = new Mailer(transporter, options, defaults);
return mailer;
};
module.exports.createTestAccount = function(apiUrl, callback) {
let promise;
if (!callback && typeof apiUrl === 'function') {
callback = apiUrl;
apiUrl = false;
}
if (!callback) {
promise = new Promise((resolve, reject) => {
callback = shared.callbackPromise(resolve, reject);
});
}
if (ETHEREAL_CACHE && testAccount) {
setImmediate(() => callback(null, testAccount));
return promise;
}
apiUrl = apiUrl || ETHEREAL_API;
let chunks = [];
let chunklen = 0;
let req = fetch(apiUrl + '/user', {
contentType: 'application/json',
method: 'POST',
body: Buffer.from(
JSON.stringify({
requestor: packageData.name,
version: packageData.version
})
)
});
req.on('readable', () => {
let chunk;
while ((chunk = req.read()) !== null) {
chunks.push(chunk);
chunklen += chunk.length;
}
});
req.once('error', err => callback(err));
req.once('end', () => {
let res = Buffer.concat(chunks, chunklen);
let data;
let err;
try {
data = JSON.parse(res.toString());
} catch (E) {
err = E;
}
if (err) {
return callback(err);
}
if (data.status !== 'success' || data.error) {
return callback(new Error(data.error || 'Request failed'));
}
delete data.status;
testAccount = data;
callback(null, testAccount);
});
return promise;
};
module.exports.getTestMessageUrl = function(info) {
if (!info || !info.response) {
return false;
}
let infoProps = new Map();
info.response.replace(/\[([^\]]+)\]$/, (m, props) => {
props.replace(/\b([A-Z0-9]+)=([^\s]+)/g, (m, key, value) => {
infoProps.set(key, value);
});
});
if (infoProps.has('STATUS') && infoProps.has('MSGID')) {
return (testAccount.web || ETHEREAL_WEB) + '/message/' + infoProps.get('MSGID');
}
return false;
};

@ -0,0 +1,219 @@
'use strict';
const Transform = require('stream').Transform;
/**
* Encodes a Buffer into a Quoted-Printable encoded string
*
* @param {Buffer} buffer Buffer to convert
* @returns {String} Quoted-Printable encoded string
*/
function encode(buffer) {
if (typeof buffer === 'string') {
buffer = Buffer.from(buffer, 'utf-8');
}
// usable characters that do not need encoding
let ranges = [
// https://tools.ietf.org/html/rfc2045#section-6.7
[0x09], // <TAB>
[0x0a], // <LF>
[0x0d], // <CR>
[0x20, 0x3c], // <SP>!"#$%&'()*+,-./0123456789:;
[0x3e, 0x7e] // >?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}
];
let result = '';
let ord;
for (let i = 0, len = buffer.length; i < len; i++) {
ord = buffer[i];
// if the char is in allowed range, then keep as is, unless it is a WS in the end of a line
if (checkRanges(ord, ranges) && !((ord === 0x20 || ord === 0x09) && (i === len - 1 || buffer[i + 1] === 0x0a || buffer[i + 1] === 0x0d))) {
result += String.fromCharCode(ord);
continue;
}
result += '=' + (ord < 0x10 ? '0' : '') + ord.toString(16).toUpperCase();
}
return result;
}
/**
* Adds soft line breaks to a Quoted-Printable string
*
* @param {String} str Quoted-Printable encoded string that might need line wrapping
* @param {Number} [lineLength=76] Maximum allowed length for a line
* @returns {String} Soft-wrapped Quoted-Printable encoded string
*/
function wrap(str, lineLength) {
str = (str || '').toString();
lineLength = lineLength || 76;
if (str.length <= lineLength) {
return str;
}
let pos = 0;
let len = str.length;
let match, code, line;
let lineMargin = Math.floor(lineLength / 3);
let result = '';
// insert soft linebreaks where needed
while (pos < len) {
line = str.substr(pos, lineLength);
if ((match = line.match(/\r\n/))) {
line = line.substr(0, match.index + match[0].length);
result += line;
pos += line.length;
continue;
}
if (line.substr(-1) === '\n') {
// nothing to change here
result += line;
pos += line.length;
continue;
} else if ((match = line.substr(-lineMargin).match(/\n.*?$/))) {
// truncate to nearest line break
line = line.substr(0, line.length - (match[0].length - 1));
result += line;
pos += line.length;
continue;
} else if (line.length > lineLength - lineMargin && (match = line.substr(-lineMargin).match(/[ \t.,!?][^ \t.,!?]*$/))) {
// truncate to nearest space
line = line.substr(0, line.length - (match[0].length - 1));
} else if (line.match(/[=][\da-f]{0,2}$/i)) {
// push incomplete encoding sequences to the next line
if ((match = line.match(/[=][\da-f]{0,1}$/i))) {
line = line.substr(0, line.length - match[0].length);
}
// ensure that utf-8 sequences are not split
while (line.length > 3 && line.length < len - pos && !line.match(/^(?:=[\da-f]{2}){1,4}$/i) && (match = line.match(/[=][\da-f]{2}$/gi))) {
code = parseInt(match[0].substr(1, 2), 16);
if (code < 128) {
break;
}
line = line.substr(0, line.length - 3);
if (code >= 0xc0) {
break;
}
}
}
if (pos + line.length < len && line.substr(-1) !== '\n') {
if (line.length === lineLength && line.match(/[=][\da-f]{2}$/i)) {
line = line.substr(0, line.length - 3);
} else if (line.length === lineLength) {
line = line.substr(0, line.length - 1);
}
pos += line.length;
line += '=\r\n';
} else {
pos += line.length;
}
result += line;
}
return result;
}
/**
* Helper function to check if a number is inside provided ranges
*
* @param {Number} nr Number to check for
* @param {Array} ranges An Array of allowed values
* @returns {Boolean} True if the value was found inside allowed ranges, false otherwise
*/
function checkRanges(nr, ranges) {
for (let i = ranges.length - 1; i >= 0; i--) {
if (!ranges[i].length) {
continue;
}
if (ranges[i].length === 1 && nr === ranges[i][0]) {
return true;
}
if (ranges[i].length === 2 && nr >= ranges[i][0] && nr <= ranges[i][1]) {
return true;
}
}
return false;
}
/**
* Creates a transform stream for encoding data to Quoted-Printable encoding
*
* @constructor
* @param {Object} options Stream options
* @param {Number} [options.lineLength=76] Maximum lenght for lines, set to false to disable wrapping
*/
class Encoder extends Transform {
constructor(options) {
super();
// init Transform
this.options = options || {};
if (this.options.lineLength !== false) {
this.options.lineLength = this.options.lineLength || 76;
}
this._curLine = '';
this.inputBytes = 0;
this.outputBytes = 0;
}
_transform(chunk, encoding, done) {
let qp;
if (encoding !== 'buffer') {
chunk = Buffer.from(chunk, encoding);
}
if (!chunk || !chunk.length) {
return done();
}
this.inputBytes += chunk.length;
if (this.options.lineLength) {
qp = this._curLine + encode(chunk);
qp = wrap(qp, this.options.lineLength);
qp = qp.replace(/(^|\n)([^\n]*)$/, (match, lineBreak, lastLine) => {
this._curLine = lastLine;
return lineBreak;
});
if (qp) {
this.outputBytes += qp.length;
this.push(qp);
}
} else {
qp = encode(chunk);
this.outputBytes += qp.length;
this.push(qp, 'ascii');
}
done();
}
_flush(done) {
if (this._curLine) {
this.outputBytes += this._curLine.length;
this.push(this._curLine, 'ascii');
}
done();
}
}
// expose to the world
module.exports = {
encode,
wrap,
Encoder
};

@ -0,0 +1,208 @@
'use strict';
const spawn = require('child_process').spawn;
const packageData = require('../../package.json');
const LeWindows = require('./le-windows');
const LeUnix = require('./le-unix');
const shared = require('../shared');
/**
* Generates a Transport object for Sendmail
*
* Possible options can be the following:
*
* * **path** optional path to sendmail binary
* * **newline** either 'windows' or 'unix'
* * **args** an array of arguments for the sendmail binary
*
* @constructor
* @param {Object} optional config parameter for Sendmail
*/
class SendmailTransport {
constructor(options) {
options = options || {};
// use a reference to spawn for mocking purposes
this._spawn = spawn;
this.options = options || {};
this.name = 'Sendmail';
this.version = packageData.version;
this.path = 'sendmail';
this.args = false;
this.winbreak = false;
this.logger = shared.getLogger(this.options, {
component: this.options.component || 'sendmail'
});
if (options) {
if (typeof options === 'string') {
this.path = options;
} else if (typeof options === 'object') {
if (options.path) {
this.path = options.path;
}
if (Array.isArray(options.args)) {
this.args = options.args;
}
this.winbreak = ['win', 'windows', 'dos', '\r\n'].includes((options.newline || '').toString().toLowerCase());
}
}
}
/**
* <p>Compiles a mailcomposer message and forwards it to handler that sends it.</p>
*
* @param {Object} emailMessage MailComposer object
* @param {Function} callback Callback function to run when the sending is completed
*/
send(mail, done) {
// Sendmail strips this header line by itself
mail.message.keepBcc = true;
let envelope = mail.data.envelope || mail.message.getEnvelope();
let messageId = mail.message.messageId();
let args;
let sendmail;
let returned;
let transform;
if (this.args) {
// force -i to keep single dots
args = ['-i'].concat(this.args).concat(envelope.to);
} else {
args = ['-i'].concat(envelope.from ? ['-f', envelope.from] : []).concat(envelope.to);
}
let callback = err => {
if (returned) {
// ignore any additional responses, already done
return;
}
returned = true;
if (typeof done === 'function') {
if (err) {
return done(err);
} else {
return done(null, {
envelope: mail.data.envelope || mail.message.getEnvelope(),
messageId,
response: 'Messages queued for delivery'
});
}
}
};
try {
sendmail = this._spawn(this.path, args);
} catch (E) {
this.logger.error(
{
err: E,
tnx: 'spawn',
messageId
},
'Error occurred while spawning sendmail. %s',
E.message
);
return callback(E);
}
if (sendmail) {
sendmail.on('error', err => {
this.logger.error(
{
err,
tnx: 'spawn',
messageId
},
'Error occurred when sending message %s. %s',
messageId,
err.message
);
callback(err);
});
sendmail.once('exit', code => {
if (!code) {
return callback();
}
let err;
if (code === 127) {
err = new Error('Sendmail command not found, process exited with code ' + code);
} else {
err = new Error('Sendmail exited with code ' + code);
}
this.logger.error(
{
err,
tnx: 'stdin',
messageId
},
'Error sending message %s to sendmail. %s',
messageId,
err.message
);
callback(err);
});
sendmail.once('close', callback);
sendmail.stdin.on('error', err => {
this.logger.error(
{
err,
tnx: 'stdin',
messageId
},
'Error occurred when piping message %s to sendmail. %s',
messageId,
err.message
);
callback(err);
});
let recipients = [].concat(envelope.to || []);
if (recipients.length > 3) {
recipients.push('...and ' + recipients.splice(2).length + ' more');
}
this.logger.info(
{
tnx: 'send',
messageId
},
'Sending message %s to <%s>',
messageId,
recipients.join(', ')
);
transform = this.winbreak ? new LeWindows() : new LeUnix();
let sourceStream = mail.message.createReadStream();
transform.once('error', err => {
this.logger.error(
{
err,
tnx: 'stdin',
messageId
},
'Error occurred when generating message %s. %s',
messageId,
err.message
);
sendmail.kill('SIGINT'); // do not deliver the message
callback(err);
});
sourceStream.once('error', err => transform.emit('error', err));
sourceStream.pipe(transform).pipe(sendmail.stdin);
} else {
return callback(new Error('sendmail was not found'));
}
}
}
module.exports = SendmailTransport;

@ -0,0 +1,43 @@
'use strict';
const stream = require('stream');
const Transform = stream.Transform;
/**
* Ensures that only <LF> is used for linebreaks
*
* @param {Object} options Stream options
*/
class LeWindows extends Transform {
constructor(options) {
super(options);
// init Transform
this.options = options || {};
}
/**
* Escapes dots
*/
_transform(chunk, encoding, done) {
let buf;
let lastPos = 0;
for (let i = 0, len = chunk.length; i < len; i++) {
if (chunk[i] === 0x0d) {
// \n
buf = chunk.slice(lastPos, i);
lastPos = i + 1;
this.push(buf);
}
}
if (lastPos && lastPos < chunk.length) {
buf = chunk.slice(lastPos);
this.push(buf);
} else if (!lastPos) {
this.push(chunk);
}
done();
}
}
module.exports = LeWindows;

@ -0,0 +1,52 @@
'use strict';
const stream = require('stream');
const Transform = stream.Transform;
/**
* Ensures that only <CR><LF> sequences are used for linebreaks
*
* @param {Object} options Stream options
*/
class LeWindows extends Transform {
constructor(options) {
super(options);
// init Transform
this.options = options || {};
this.lastByte = false;
}
/**
* Escapes dots
*/
_transform(chunk, encoding, done) {
let buf;
let lastPos = 0;
for (let i = 0, len = chunk.length; i < len; i++) {
if (chunk[i] === 0x0a) {
// \n
if ((i && chunk[i - 1] !== 0x0d) || (!i && this.lastByte !== 0x0d)) {
if (i > lastPos) {
buf = chunk.slice(lastPos, i);
this.push(buf);
}
this.push(Buffer.from('\r\n'));
lastPos = i + 1;
}
}
}
if (lastPos && lastPos < chunk.length) {
buf = chunk.slice(lastPos);
this.push(buf);
} else if (!lastPos) {
this.push(chunk);
}
this.lastByte = chunk[chunk.length - 1];
done();
}
}
module.exports = LeWindows;

@ -0,0 +1,312 @@
'use strict';
const EventEmitter = require('events');
const packageData = require('../../package.json');
const shared = require('../shared');
const LeWindows = require('../sendmail-transport/le-windows');
/**
* Generates a Transport object for AWS SES
*
* Possible options can be the following:
*
* * **sendingRate** optional Number specifying how many messages per second should be delivered to SES
* * **maxConnections** optional Number specifying max number of parallel connections to SES
*
* @constructor
* @param {Object} optional config parameter
*/
class SESTransport extends EventEmitter {
constructor(options) {
super();
options = options || {};
this.options = options || {};
this.ses = this.options.SES;
this.name = 'SESTransport';
this.version = packageData.version;
this.logger = shared.getLogger(this.options, {
component: this.options.component || 'ses-transport'
});
// parallel sending connections
this.maxConnections = Number(this.options.maxConnections) || Infinity;
this.connections = 0;
// max messages per second
this.sendingRate = Number(this.options.sendingRate) || Infinity;
this.sendingRateTTL = null;
this.rateInterval = 1000;
this.rateMessages = [];
this.pending = [];
this.idling = true;
setImmediate(() => {
if (this.idling) {
this.emit('idle');
}
});
}
/**
* Schedules a sending of a message
*
* @param {Object} emailMessage MailComposer object
* @param {Function} callback Callback function to run when the sending is completed
*/
send(mail, callback) {
if (this.connections >= this.maxConnections) {
this.idling = false;
return this.pending.push({
mail,
callback
});
}
if (!this._checkSendingRate()) {
this.idling = false;
return this.pending.push({
mail,
callback
});
}
this._send(mail, (...args) => {
setImmediate(() => callback(...args));
this._sent();
});
}
_checkRatedQueue() {
if (this.connections >= this.maxConnections || !this._checkSendingRate()) {
return;
}
if (!this.pending.length) {
if (!this.idling) {
this.idling = true;
this.emit('idle');
}
return;
}
let next = this.pending.shift();
this._send(next.mail, (...args) => {
setImmediate(() => next.callback(...args));
this._sent();
});
}
_checkSendingRate() {
clearTimeout(this.sendingRateTTL);
let now = Date.now();
let oldest = false;
// delete older messages
for (let i = this.rateMessages.length - 1; i >= 0; i--) {
if (this.rateMessages[i].ts >= now - this.rateInterval && (!oldest || this.rateMessages[i].ts < oldest)) {
oldest = this.rateMessages[i].ts;
}
if (this.rateMessages[i].ts < now - this.rateInterval && !this.rateMessages[i].pending) {
this.rateMessages.splice(i, 1);
}
}
if (this.rateMessages.length < this.sendingRate) {
return true;
}
let delay = Math.max(oldest + 1001, now + 20);
this.sendingRateTTL = setTimeout(() => this._checkRatedQueue(), now - delay);
try {
this.sendingRateTTL.unref();
} catch (E) {
// Ignore. Happens on envs with non-node timer implementation
}
return false;
}
_sent() {
this.connections--;
this._checkRatedQueue();
}
/**
* Returns true if there are free slots in the queue
*/
isIdle() {
return this.idling;
}
/**
* Compiles a mailcomposer message and forwards it to SES
*
* @param {Object} emailMessage MailComposer object
* @param {Function} callback Callback function to run when the sending is completed
*/
_send(mail, callback) {
let statObject = {
ts: Date.now(),
pending: true
};
this.connections++;
this.rateMessages.push(statObject);
let envelope = mail.data.envelope || mail.message.getEnvelope();
let messageId = mail.message.messageId();
let recipients = [].concat(envelope.to || []);
if (recipients.length > 3) {
recipients.push('...and ' + recipients.splice(2).length + ' more');
}
this.logger.info(
{
tnx: 'send',
messageId
},
'Sending message %s to <%s>',
messageId,
recipients.join(', ')
);
let getRawMessage = next => {
// do not use Message-ID and Date in DKIM signature
if (!mail.data._dkim) {
mail.data._dkim = {};
}
if (mail.data._dkim.skipFields && typeof mail.data._dkim.skipFields === 'string') {
mail.data._dkim.skipFields += ':date:message-id';
} else {
mail.data._dkim.skipFields = 'date:message-id';
}
let sourceStream = mail.message.createReadStream();
let stream = sourceStream.pipe(new LeWindows());
let chunks = [];
let chunklen = 0;
stream.on('readable', () => {
let chunk;
while ((chunk = stream.read()) !== null) {
chunks.push(chunk);
chunklen += chunk.length;
}
});
sourceStream.once('error', err => stream.emit('error', err));
stream.once('error', err => {
next(err);
});
stream.once('end', () => next(null, Buffer.concat(chunks, chunklen)));
};
setImmediate(() =>
getRawMessage((err, raw) => {
if (err) {
this.logger.error(
{
err,
tnx: 'send',
messageId
},
'Failed creating message for %s. %s',
messageId,
err.message
);
statObject.pending = false;
return callback(err);
}
let sesMessage = {
RawMessage: {
// required
Data: raw // required
},
Source: envelope.from,
Destinations: envelope.to
};
Object.keys(mail.data.ses || {}).forEach(key => {
sesMessage[key] = mail.data.ses[key];
});
this.ses.sendRawEmail(sesMessage, (err, data) => {
if (err) {
this.logger.error(
{
err,
tnx: 'send'
},
'Send error for %s: %s',
messageId,
err.message
);
statObject.pending = false;
return callback(err);
}
let region = (this.ses.config && this.ses.config.region) || 'us-east-1';
if (region === 'us-east-1') {
region = 'email';
}
statObject.pending = false;
callback(null, {
envelope: {
from: envelope.from,
to: envelope.to
},
messageId: '<' + data.MessageId + (!/@/.test(data.MessageId) ? '@' + region + '.amazonses.com' : '') + '>',
response: data.MessageId,
raw
});
});
})
);
}
/**
* Verifies SES configuration
*
* @param {Function} callback Callback function
*/
verify(callback) {
let promise;
if (!callback) {
promise = new Promise((resolve, reject) => {
callback = shared.callbackPromise(resolve, reject);
});
}
this.ses.sendRawEmail(
{
RawMessage: {
// required
Data: 'From: invalid@invalid\r\nTo: invalid@invalid\r\n Subject: Invalid\r\n\r\nInvalid'
},
Source: 'invalid@invalid',
Destinations: ['invalid@invalid']
},
err => {
if (err && err.code !== 'InvalidParameterValue') {
return callback(err);
}
return callback(null, true);
}
);
return promise;
}
}
module.exports = SESTransport;

@ -0,0 +1,510 @@
/* eslint no-console: 0 */
'use strict';
const urllib = require('url');
const util = require('util');
const fs = require('fs');
const fetch = require('../fetch');
const dns = require('dns');
const net = require('net');
const DNS_TTL = 5 * 60 * 1000;
const resolver = (family, hostname, callback) => {
dns['resolve' + family](hostname, (err, addresses) => {
if (err) {
switch (err.code) {
case dns.NODATA:
case dns.NOTFOUND:
case dns.NOTIMP:
case dns.SERVFAIL:
case dns.CONNREFUSED:
case 'EAI_AGAIN':
return callback(null, []);
}
return callback(err);
}
return callback(null, Array.isArray(addresses) ? addresses : [].concat(addresses || []));
});
};
const dnsCache = (module.exports.dnsCache = new Map());
module.exports.resolveHostname = (options, callback) => {
options = options || {};
if (!options.host || net.isIP(options.host)) {
// nothing to do here
let value = {
host: options.host,
servername: options.servername || false
};
return callback(null, value);
}
let cached;
if (dnsCache.has(options.host)) {
cached = dnsCache.get(options.host);
if (!cached.expires || cached.expires >= Date.now()) {
return callback(null, {
host: cached.value.host,
servername: cached.value.servername,
_cached: true
});
}
}
resolver(4, options.host, (err, addresses) => {
if (err) {
if (cached) {
// ignore error, use expired value
return callback(null, cached.value);
}
return callback(err);
}
if (addresses && addresses.length) {
let value = {
host: addresses[0] || options.host,
servername: options.servername || options.host
};
dnsCache.set(options.host, {
value,
expires: Date.now() + DNS_TTL
});
return callback(null, value);
}
resolver(6, options.host, (err, addresses) => {
if (err) {
if (cached) {
// ignore error, use expired value
return callback(null, cached.value);
}
return callback(err);
}
if (addresses && addresses.length) {
let value = {
host: addresses[0] || options.host,
servername: options.servername || options.host
};
dnsCache.set(options.host, {
value,
expires: Date.now() + DNS_TTL
});
return callback(null, value);
}
try {
dns.lookup(options.host, {}, (err, address) => {
if (err) {
if (cached) {
// ignore error, use expired value
return callback(null, cached.value);
}
return callback(err);
}
if (!address && cached) {
// nothing was found, fallback to cached value
return callback(null, cached.value);
}
let value = {
host: address || options.host,
servername: options.servername || options.host
};
dnsCache.set(options.host, {
value,
expires: Date.now() + DNS_TTL
});
return callback(null, value);
});
} catch (err) {
if (cached) {
// ignore error, use expired value
return callback(null, cached.value);
}
return callback(err);
}
});
});
};
/**
* Parses connection url to a structured configuration object
*
* @param {String} str Connection url
* @return {Object} Configuration object
*/
module.exports.parseConnectionUrl = str => {
str = str || '';
let options = {};
[urllib.parse(str, true)].forEach(url => {
let auth;
switch (url.protocol) {
case 'smtp:':
options.secure = false;
break;
case 'smtps:':
options.secure = true;
break;
case 'direct:':
options.direct = true;
break;
}
if (!isNaN(url.port) && Number(url.port)) {
options.port = Number(url.port);
}
if (url.hostname) {
options.host = url.hostname;
}
if (url.auth) {
auth = url.auth.split(':');
if (!options.auth) {
options.auth = {};
}
options.auth.user = auth.shift();
options.auth.pass = auth.join(':');
}
Object.keys(url.query || {}).forEach(key => {
let obj = options;
let lKey = key;
let value = url.query[key];
if (!isNaN(value)) {
value = Number(value);
}
switch (value) {
case 'true':
value = true;
break;
case 'false':
value = false;
break;
}
// tls is nested object
if (key.indexOf('tls.') === 0) {
lKey = key.substr(4);
if (!options.tls) {
options.tls = {};
}
obj = options.tls;
} else if (key.indexOf('.') >= 0) {
// ignore nested properties besides tls
return;
}
if (!(lKey in obj)) {
obj[lKey] = value;
}
});
});
return options;
};
module.exports._logFunc = (logger, level, defaults, data, message, ...args) => {
let entry = {};
Object.keys(defaults || {}).forEach(key => {
if (key !== 'level') {
entry[key] = defaults[key];
}
});
Object.keys(data || {}).forEach(key => {
if (key !== 'level') {
entry[key] = data[key];
}
});
logger[level](entry, message, ...args);
};
/**
* Returns a bunyan-compatible logger interface. Uses either provided logger or
* creates a default console logger
*
* @param {Object} [options] Options object that might include 'logger' value
* @return {Object} bunyan compatible logger
*/
module.exports.getLogger = (options, defaults) => {
options = options || {};
let response = {};
let levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'];
if (!options.logger) {
// use vanity logger
levels.forEach(level => {
response[level] = () => false;
});
return response;
}
let logger = options.logger;
if (options.logger === true) {
// create console logger
logger = createDefaultLogger(levels);
}
levels.forEach(level => {
response[level] = (data, message, ...args) => {
module.exports._logFunc(logger, level, defaults, data, message, ...args);
};
});
return response;
};
/**
* Wrapper for creating a callback that either resolves or rejects a promise
* based on input
*
* @param {Function} resolve Function to run if callback is called
* @param {Function} reject Function to run if callback ends with an error
*/
module.exports.callbackPromise = (resolve, reject) =>
function() {
let args = Array.from(arguments);
let err = args.shift();
if (err) {
reject(err);
} else {
resolve(...args);
}
};
/**
* Resolves a String or a Buffer value for content value. Useful if the value
* is a Stream or a file or an URL. If the value is a Stream, overwrites
* the stream object with the resolved value (you can't stream a value twice).
*
* This is useful when you want to create a plugin that needs a content value,
* for example the `html` or `text` value as a String or a Buffer but not as
* a file path or an URL.
*
* @param {Object} data An object or an Array you want to resolve an element for
* @param {String|Number} key Property name or an Array index
* @param {Function} callback Callback function with (err, value)
*/
module.exports.resolveContent = (data, key, callback) => {
let promise;
if (!callback) {
promise = new Promise((resolve, reject) => {
callback = module.exports.callbackPromise(resolve, reject);
});
}
let content = (data && data[key] && data[key].content) || data[key];
let contentStream;
let encoding = ((typeof data[key] === 'object' && data[key].encoding) || 'utf8')
.toString()
.toLowerCase()
.replace(/[-_\s]/g, '');
if (!content) {
return callback(null, content);
}
if (typeof content === 'object') {
if (typeof content.pipe === 'function') {
return resolveStream(content, (err, value) => {
if (err) {
return callback(err);
}
// we can't stream twice the same content, so we need
// to replace the stream object with the streaming result
data[key] = value;
callback(null, value);
});
} else if (/^https?:\/\//i.test(content.path || content.href)) {
contentStream = fetch(content.path || content.href);
return resolveStream(contentStream, callback);
} else if (/^data:/i.test(content.path || content.href)) {
let parts = (content.path || content.href).match(/^data:((?:[^;]*;)*(?:[^,]*)),(.*)$/i);
if (!parts) {
return callback(null, Buffer.from(0));
}
return callback(null, /\bbase64$/i.test(parts[1]) ? Buffer.from(parts[2], 'base64') : Buffer.from(decodeURIComponent(parts[2])));
} else if (content.path) {
return resolveStream(fs.createReadStream(content.path), callback);
}
}
if (typeof data[key].content === 'string' && !['utf8', 'usascii', 'ascii'].includes(encoding)) {
content = Buffer.from(data[key].content, encoding);
}
// default action, return as is
setImmediate(() => callback(null, content));
return promise;
};
/**
* Copies properties from source objects to target objects
*/
module.exports.assign = function(/* target, ... sources */) {
let args = Array.from(arguments);
let target = args.shift() || {};
args.forEach(source => {
Object.keys(source || {}).forEach(key => {
if (['tls', 'auth'].includes(key) && source[key] && typeof source[key] === 'object') {
// tls and auth are special keys that need to be enumerated separately
// other objects are passed as is
if (!target[key]) {
// ensure that target has this key
target[key] = {};
}
Object.keys(source[key]).forEach(subKey => {
target[key][subKey] = source[key][subKey];
});
} else {
target[key] = source[key];
}
});
});
return target;
};
module.exports.encodeXText = str => {
// ! 0x21
// + 0x2B
// = 0x3D
// ~ 0x7E
if (!/[^\x21-\x2A\x2C-\x3C\x3E-\x7E]/.test(str)) {
return str;
}
let buf = Buffer.from(str);
let result = '';
for (let i = 0, len = buf.length; i < len; i++) {
let c = buf[i];
if (c < 0x21 || c > 0x7e || c === 0x2b || c === 0x3d) {
result += '+' + (c < 0x10 ? '0' : '') + c.toString(16).toUpperCase();
} else {
result += String.fromCharCode(c);
}
}
return result;
};
/**
* Streams a stream value into a Buffer
*
* @param {Object} stream Readable stream
* @param {Function} callback Callback function with (err, value)
*/
function resolveStream(stream, callback) {
let responded = false;
let chunks = [];
let chunklen = 0;
stream.on('error', err => {
if (responded) {
return;
}
responded = true;
callback(err);
});
stream.on('readable', () => {
let chunk;
while ((chunk = stream.read()) !== null) {
chunks.push(chunk);
chunklen += chunk.length;
}
});
stream.on('end', () => {
if (responded) {
return;
}
responded = true;
let value;
try {
value = Buffer.concat(chunks, chunklen);
} catch (E) {
return callback(E);
}
callback(null, value);
});
}
/**
* Generates a bunyan-like logger that prints to console
*
* @returns {Object} Bunyan logger instance
*/
function createDefaultLogger(levels) {
let levelMaxLen = 0;
let levelNames = new Map();
levels.forEach(level => {
if (level.length > levelMaxLen) {
levelMaxLen = level.length;
}
});
levels.forEach(level => {
let levelName = level.toUpperCase();
if (levelName.length < levelMaxLen) {
levelName += ' '.repeat(levelMaxLen - levelName.length);
}
levelNames.set(level, levelName);
});
let print = (level, entry, message, ...args) => {
let prefix = '';
if (entry) {
if (entry.tnx === 'server') {
prefix = 'S: ';
} else if (entry.tnx === 'client') {
prefix = 'C: ';
}
if (entry.sid) {
prefix = '[' + entry.sid + '] ' + prefix;
}
if (entry.cid) {
prefix = '[#' + entry.cid + '] ' + prefix;
}
}
message = util.format(message, ...args);
message.split(/\r?\n/).forEach(line => {
console.log(
'[%s] %s %s',
new Date()
.toISOString()
.substr(0, 19)
.replace(/T/, ' '),
levelNames.get(level),
prefix + line
);
});
};
let logger = {};
levels.forEach(level => {
logger[level] = print.bind(null, level);
});
return logger;
}

@ -0,0 +1,108 @@
'use strict';
const stream = require('stream');
const Transform = stream.Transform;
/**
* Escapes dots in the beginning of lines. Ends the stream with <CR><LF>.<CR><LF>
* Also makes sure that only <CR><LF> sequences are used for linebreaks
*
* @param {Object} options Stream options
*/
class DataStream extends Transform {
constructor(options) {
super(options);
// init Transform
this.options = options || {};
this._curLine = '';
this.inByteCount = 0;
this.outByteCount = 0;
this.lastByte = false;
}
/**
* Escapes dots
*/
_transform(chunk, encoding, done) {
let chunks = [];
let chunklen = 0;
let i,
len,
lastPos = 0;
let buf;
if (!chunk || !chunk.length) {
return done();
}
if (typeof chunk === 'string') {
chunk = Buffer.from(chunk);
}
this.inByteCount += chunk.length;
for (i = 0, len = chunk.length; i < len; i++) {
if (chunk[i] === 0x2e) {
// .
if ((i && chunk[i - 1] === 0x0a) || (!i && (!this.lastByte || this.lastByte === 0x0a))) {
buf = chunk.slice(lastPos, i + 1);
chunks.push(buf);
chunks.push(Buffer.from('.'));
chunklen += buf.length + 1;
lastPos = i + 1;
}
} else if (chunk[i] === 0x0a) {
// .
if ((i && chunk[i - 1] !== 0x0d) || (!i && this.lastByte !== 0x0d)) {
if (i > lastPos) {
buf = chunk.slice(lastPos, i);
chunks.push(buf);
chunklen += buf.length + 2;
} else {
chunklen += 2;
}
chunks.push(Buffer.from('\r\n'));
lastPos = i + 1;
}
}
}
if (chunklen) {
// add last piece
if (lastPos < chunk.length) {
buf = chunk.slice(lastPos);
chunks.push(buf);
chunklen += buf.length;
}
this.outByteCount += chunklen;
this.push(Buffer.concat(chunks, chunklen));
} else {
this.outByteCount += chunk.length;
this.push(chunk);
}
this.lastByte = chunk[chunk.length - 1];
done();
}
/**
* Finalizes the stream with a dot on a single line
*/
_flush(done) {
let buf;
if (this.lastByte === 0x0a) {
buf = Buffer.from('.\r\n');
} else if (this.lastByte === 0x0d) {
buf = Buffer.from('\n.\r\n');
} else {
buf = Buffer.from('\r\n.\r\n');
}
this.outByteCount += buf.length;
this.push(buf);
done();
}
}
module.exports = DataStream;

@ -0,0 +1,131 @@
'use strict';
/**
* Minimal HTTP/S proxy client
*/
const net = require('net');
const tls = require('tls');
const urllib = require('url');
/**
* Establishes proxied connection to destinationPort
*
* httpProxyClient("http://localhost:3128/", 80, "google.com", function(err, socket){
* socket.write("GET / HTTP/1.0\r\n\r\n");
* });
*
* @param {String} proxyUrl proxy configuration, etg "http://proxy.host:3128/"
* @param {Number} destinationPort Port to open in destination host
* @param {String} destinationHost Destination hostname
* @param {Function} callback Callback to run with the rocket object once connection is established
*/
function httpProxyClient(proxyUrl, destinationPort, destinationHost, callback) {
let proxy = urllib.parse(proxyUrl);
// create a socket connection to the proxy server
let options;
let connect;
let socket;
options = {
host: proxy.hostname,
port: Number(proxy.port) ? Number(proxy.port) : proxy.protocol === 'https:' ? 443 : 80
};
if (proxy.protocol === 'https:') {
// we can use untrusted proxies as long as we verify actual SMTP certificates
options.rejectUnauthorized = false;
connect = tls.connect.bind(tls);
} else {
connect = net.connect.bind(net);
}
// Error harness for initial connection. Once connection is established, the responsibility
// to handle errors is passed to whoever uses this socket
let finished = false;
let tempSocketErr = function(err) {
if (finished) {
return;
}
finished = true;
try {
socket.destroy();
} catch (E) {
// ignore
}
callback(err);
};
socket = connect(options, () => {
if (finished) {
return;
}
let reqHeaders = {
Host: destinationHost + ':' + destinationPort,
Connection: 'close'
};
if (proxy.auth) {
reqHeaders['Proxy-Authorization'] = 'Basic ' + Buffer.from(proxy.auth).toString('base64');
}
socket.write(
// HTTP method
'CONNECT ' +
destinationHost +
':' +
destinationPort +
' HTTP/1.1\r\n' +
// HTTP request headers
Object.keys(reqHeaders)
.map(key => key + ': ' + reqHeaders[key])
.join('\r\n') +
// End request
'\r\n\r\n'
);
let headers = '';
let onSocketData = chunk => {
let match;
let remainder;
if (finished) {
return;
}
headers += chunk.toString('binary');
if ((match = headers.match(/\r\n\r\n/))) {
socket.removeListener('data', onSocketData);
remainder = headers.substr(match.index + match[0].length);
headers = headers.substr(0, match.index);
if (remainder) {
socket.unshift(Buffer.from(remainder, 'binary'));
}
// proxy connection is now established
finished = true;
// check response code
match = headers.match(/^HTTP\/\d+\.\d+ (\d+)/i);
if (!match || (match[1] || '').charAt(0) !== '2') {
try {
socket.destroy();
} catch (E) {
// ignore
}
return callback(new Error('Invalid response from proxy' + ((match && ': ' + match[1]) || '')));
}
socket.removeListener('error', tempSocketErr);
return callback(null, socket);
}
};
socket.on('data', onSocketData);
});
socket.once('error', tempSocketErr);
}
module.exports = httpProxyClient;

File diff suppressed because it is too large Load Diff

@ -0,0 +1,641 @@
'use strict';
const EventEmitter = require('events');
const PoolResource = require('./pool-resource');
const SMTPConnection = require('../smtp-connection');
const wellKnown = require('../well-known');
const shared = require('../shared');
const packageData = require('../../package.json');
/**
* Creates a SMTP pool transport object for Nodemailer
*
* @constructor
* @param {Object} options SMTP Connection options
*/
class SMTPPool extends EventEmitter {
constructor(options) {
super();
options = options || {};
if (typeof options === 'string') {
options = {
url: options
};
}
let urlData;
let service = options.service;
if (typeof options.getSocket === 'function') {
this.getSocket = options.getSocket;
}
if (options.url) {
urlData = shared.parseConnectionUrl(options.url);
service = service || urlData.service;
}
this.options = shared.assign(
false, // create new object
options, // regular options
urlData, // url options
service && wellKnown(service) // wellknown options
);
this.options.maxConnections = this.options.maxConnections || 5;
this.options.maxMessages = this.options.maxMessages || 100;
this.logger = shared.getLogger(this.options, {
component: this.options.component || 'smtp-pool'
});
// temporary object
let connection = new SMTPConnection(this.options);
this.name = 'SMTP (pool)';
this.version = packageData.version + '[client:' + connection.version + ']';
this._rateLimit = {
counter: 0,
timeout: null,
waiting: [],
checkpoint: false,
delta: Number(this.options.rateDelta) || 1000,
limit: Number(this.options.rateLimit) || 0
};
this._closed = false;
this._queue = [];
this._connections = [];
this._connectionCounter = 0;
this.idling = true;
setImmediate(() => {
if (this.idling) {
this.emit('idle');
}
});
}
/**
* Placeholder function for creating proxy sockets. This method immediatelly returns
* without a socket
*
* @param {Object} options Connection options
* @param {Function} callback Callback function to run with the socket keys
*/
getSocket(options, callback) {
// return immediatelly
return setImmediate(() => callback(null, false));
}
/**
* Queues an e-mail to be sent using the selected settings
*
* @param {Object} mail Mail object
* @param {Function} callback Callback function
*/
send(mail, callback) {
if (this._closed) {
return false;
}
this._queue.push({
mail,
requeueAttempts: 0,
callback
});
if (this.idling && this._queue.length >= this.options.maxConnections) {
this.idling = false;
}
setImmediate(() => this._processMessages());
return true;
}
/**
* Closes all connections in the pool. If there is a message being sent, the connection
* is closed later
*/
close() {
let connection;
let len = this._connections.length;
this._closed = true;
// clear rate limit timer if it exists
clearTimeout(this._rateLimit.timeout);
if (!len && !this._queue.length) {
return;
}
// remove all available connections
for (let i = len - 1; i >= 0; i--) {
if (this._connections[i] && this._connections[i].available) {
connection = this._connections[i];
connection.close();
this.logger.info(
{
tnx: 'connection',
cid: connection.id,
action: 'removed'
},
'Connection #%s removed',
connection.id
);
}
}
if (len && !this._connections.length) {
this.logger.debug(
{
tnx: 'connection'
},
'All connections removed'
);
}
if (!this._queue.length) {
return;
}
// make sure that entire queue would be cleaned
let invokeCallbacks = () => {
if (!this._queue.length) {
this.logger.debug(
{
tnx: 'connection'
},
'Pending queue entries cleared'
);
return;
}
let entry = this._queue.shift();
if (entry && typeof entry.callback === 'function') {
try {
entry.callback(new Error('Connection pool was closed'));
} catch (E) {
this.logger.error(
{
err: E,
tnx: 'callback',
cid: connection.id
},
'Callback error for #%s: %s',
connection.id,
E.message
);
}
}
setImmediate(invokeCallbacks);
};
setImmediate(invokeCallbacks);
}
/**
* Check the queue and available connections. If there is a message to be sent and there is
* an available connection, then use this connection to send the mail
*/
_processMessages() {
let connection;
let i, len;
// do nothing if already closed
if (this._closed) {
return;
}
// do nothing if queue is empty
if (!this._queue.length) {
if (!this.idling) {
// no pending jobs
this.idling = true;
this.emit('idle');
}
return;
}
// find first available connection
for (i = 0, len = this._connections.length; i < len; i++) {
if (this._connections[i].available) {
connection = this._connections[i];
break;
}
}
if (!connection && this._connections.length < this.options.maxConnections) {
connection = this._createConnection();
}
if (!connection) {
// no more free connection slots available
this.idling = false;
return;
}
// check if there is free space in the processing queue
if (!this.idling && this._queue.length < this.options.maxConnections) {
this.idling = true;
this.emit('idle');
}
let entry = (connection.queueEntry = this._queue.shift());
entry.messageId = (connection.queueEntry.mail.message.getHeader('message-id') || '').replace(/[<>\s]/g, '');
connection.available = false;
this.logger.debug(
{
tnx: 'pool',
cid: connection.id,
messageId: entry.messageId,
action: 'assign'
},
'Assigned message <%s> to #%s (%s)',
entry.messageId,
connection.id,
connection.messages + 1
);
if (this._rateLimit.limit) {
this._rateLimit.counter++;
if (!this._rateLimit.checkpoint) {
this._rateLimit.checkpoint = Date.now();
}
}
connection.send(entry.mail, (err, info) => {
// only process callback if current handler is not changed
if (entry === connection.queueEntry) {
try {
entry.callback(err, info);
} catch (E) {
this.logger.error(
{
err: E,
tnx: 'callback',
cid: connection.id
},
'Callback error for #%s: %s',
connection.id,
E.message
);
}
connection.queueEntry = false;
}
});
}
/**
* Creates a new pool resource
*/
_createConnection() {
let connection = new PoolResource(this);
connection.id = ++this._connectionCounter;
this.logger.info(
{
tnx: 'pool',
cid: connection.id,
action: 'conection'
},
'Created new pool resource #%s',
connection.id
);
// resource comes available
connection.on('available', () => {
this.logger.debug(
{
tnx: 'connection',
cid: connection.id,
action: 'available'
},
'Connection #%s became available',
connection.id
);
if (this._closed) {
// if already closed run close() that will remove this connections from connections list
this.close();
} else {
// check if there's anything else to send
this._processMessages();
}
});
// resource is terminated with an error
connection.once('error', err => {
if (err.code !== 'EMAXLIMIT') {
this.logger.error(
{
err,
tnx: 'pool',
cid: connection.id
},
'Pool Error for #%s: %s',
connection.id,
err.message
);
} else {
this.logger.debug(
{
tnx: 'pool',
cid: connection.id,
action: 'maxlimit'
},
'Max messages limit exchausted for #%s',
connection.id
);
}
if (connection.queueEntry) {
try {
connection.queueEntry.callback(err);
} catch (E) {
this.logger.error(
{
err: E,
tnx: 'callback',
cid: connection.id
},
'Callback error for #%s: %s',
connection.id,
E.message
);
}
connection.queueEntry = false;
}
// remove the erroneus connection from connections list
this._removeConnection(connection);
this._continueProcessing();
});
connection.once('close', () => {
this.logger.info(
{
tnx: 'connection',
cid: connection.id,
action: 'closed'
},
'Connection #%s was closed',
connection.id
);
this._removeConnection(connection);
if (connection.queueEntry) {
// If the connection closed when sending, add the message to the queue again
// if max number of requeues is not reached yet
// Note that we must wait a bit.. because the callback of the 'error' handler might be called
// in the next event loop
setTimeout(() => {
if (connection.queueEntry) {
if (this._shouldRequeuOnConnectionClose(connection.queueEntry)) {
this._requeueEntryOnConnectionClose(connection);
} else {
this._failDeliveryOnConnectionClose(connection);
}
}
this._continueProcessing();
}, 50);
} else {
this._continueProcessing();
}
});
this._connections.push(connection);
return connection;
}
_shouldRequeuOnConnectionClose(queueEntry) {
if (this.options.maxRequeues === undefined || this.options.maxRequeues < 0) {
return true;
}
return queueEntry.requeueAttempts < this.options.maxRequeues;
}
_failDeliveryOnConnectionClose(connection) {
if (connection.queueEntry && connection.queueEntry.callback) {
try {
connection.queueEntry.callback(new Error('Reached maximum number of retries after connection was closed'));
} catch (E) {
this.logger.error(
{
err: E,
tnx: 'callback',
messageId: connection.queueEntry.messageId,
cid: connection.id
},
'Callback error for #%s: %s',
connection.id,
E.message
);
}
connection.queueEntry = false;
}
}
_requeueEntryOnConnectionClose(connection) {
connection.queueEntry.requeueAttempts = connection.queueEntry.requeueAttempts + 1;
this.logger.debug(
{
tnx: 'pool',
cid: connection.id,
messageId: connection.queueEntry.messageId,
action: 'requeue'
},
'Re-queued message <%s> for #%s. Attempt: #%s',
connection.queueEntry.messageId,
connection.id,
connection.queueEntry.requeueAttempts
);
this._queue.unshift(connection.queueEntry);
connection.queueEntry = false;
}
/**
* Continue to process message if the pool hasn't closed
*/
_continueProcessing() {
if (this._closed) {
this.close();
} else {
setTimeout(() => this._processMessages(), 100);
}
}
/**
* Remove resource from pool
*
* @param {Object} connection The PoolResource to remove
*/
_removeConnection(connection) {
let index = this._connections.indexOf(connection);
if (index !== -1) {
this._connections.splice(index, 1);
}
}
/**
* Checks if connections have hit current rate limit and if so, queues the availability callback
*
* @param {Function} callback Callback function to run once rate limiter has been cleared
*/
_checkRateLimit(callback) {
if (!this._rateLimit.limit) {
return callback();
}
let now = Date.now();
if (this._rateLimit.counter < this._rateLimit.limit) {
return callback();
}
this._rateLimit.waiting.push(callback);
if (this._rateLimit.checkpoint <= now - this._rateLimit.delta) {
return this._clearRateLimit();
} else if (!this._rateLimit.timeout) {
this._rateLimit.timeout = setTimeout(() => this._clearRateLimit(), this._rateLimit.delta - (now - this._rateLimit.checkpoint));
this._rateLimit.checkpoint = now;
}
}
/**
* Clears current rate limit limitation and runs paused callback
*/
_clearRateLimit() {
clearTimeout(this._rateLimit.timeout);
this._rateLimit.timeout = null;
this._rateLimit.counter = 0;
this._rateLimit.checkpoint = false;
// resume all paused connections
while (this._rateLimit.waiting.length) {
let cb = this._rateLimit.waiting.shift();
setImmediate(cb);
}
}
/**
* Returns true if there are free slots in the queue
*/
isIdle() {
return this.idling;
}
/**
* Verifies SMTP configuration
*
* @param {Function} callback Callback function
*/
verify(callback) {
let promise;
if (!callback) {
promise = new Promise((resolve, reject) => {
callback = shared.callbackPromise(resolve, reject);
});
}
let auth = new PoolResource(this).auth;
this.getSocket(this.options, (err, socketOptions) => {
if (err) {
return callback(err);
}
let options = this.options;
if (socketOptions && socketOptions.connection) {
this.logger.info(
{
tnx: 'proxy',
remoteAddress: socketOptions.connection.remoteAddress,
remotePort: socketOptions.connection.remotePort,
destHost: options.host || '',
destPort: options.port || '',
action: 'connected'
},
'Using proxied socket from %s:%s to %s:%s',
socketOptions.connection.remoteAddress,
socketOptions.connection.remotePort,
options.host || '',
options.port || ''
);
options = shared.assign(false, options);
Object.keys(socketOptions).forEach(key => {
options[key] = socketOptions[key];
});
}
let connection = new SMTPConnection(options);
let returned = false;
connection.once('error', err => {
if (returned) {
return;
}
returned = true;
connection.close();
return callback(err);
});
connection.once('end', () => {
if (returned) {
return;
}
returned = true;
return callback(new Error('Connection closed'));
});
let finalize = () => {
if (returned) {
return;
}
returned = true;
connection.quit();
return callback(null, true);
};
connection.connect(() => {
if (returned) {
return;
}
if (auth && (connection.allowsAuth || options.forceAuth)) {
connection.login(auth, err => {
if (returned) {
return;
}
if (err) {
returned = true;
connection.close();
return callback(err);
}
finalize();
});
} else {
finalize();
}
});
});
return promise;
}
}
// expose to the world
module.exports = SMTPPool;

@ -0,0 +1,253 @@
'use strict';
const SMTPConnection = require('../smtp-connection');
const assign = require('../shared').assign;
const XOAuth2 = require('../xoauth2');
const EventEmitter = require('events');
/**
* Creates an element for the pool
*
* @constructor
* @param {Object} options SMTPPool instance
*/
class PoolResource extends EventEmitter {
constructor(pool) {
super();
this.pool = pool;
this.options = pool.options;
this.logger = this.pool.logger;
if (this.options.auth) {
switch ((this.options.auth.type || '').toString().toUpperCase()) {
case 'OAUTH2': {
let oauth2 = new XOAuth2(this.options.auth, this.logger);
oauth2.provisionCallback = (this.pool.mailer && this.pool.mailer.get('oauth2_provision_cb')) || oauth2.provisionCallback;
this.auth = {
type: 'OAUTH2',
user: this.options.auth.user,
oauth2,
method: 'XOAUTH2'
};
oauth2.on('token', token => this.pool.mailer.emit('token', token));
oauth2.on('error', err => this.emit('error', err));
break;
}
default:
if (!this.options.auth.user && !this.options.auth.pass) {
break;
}
this.auth = {
type: (this.options.auth.type || '').toString().toUpperCase() || 'LOGIN',
user: this.options.auth.user,
credentials: {
user: this.options.auth.user || '',
pass: this.options.auth.pass,
options: this.options.auth.options
},
method: (this.options.auth.method || '').trim().toUpperCase() || this.options.authMethod || false
};
}
}
this._connection = false;
this._connected = false;
this.messages = 0;
this.available = true;
}
/**
* Initiates a connection to the SMTP server
*
* @param {Function} callback Callback function to run once the connection is established or failed
*/
connect(callback) {
this.pool.getSocket(this.options, (err, socketOptions) => {
if (err) {
return callback(err);
}
let returned = false;
let options = this.options;
if (socketOptions && socketOptions.connection) {
this.logger.info(
{
tnx: 'proxy',
remoteAddress: socketOptions.connection.remoteAddress,
remotePort: socketOptions.connection.remotePort,
destHost: options.host || '',
destPort: options.port || '',
action: 'connected'
},
'Using proxied socket from %s:%s to %s:%s',
socketOptions.connection.remoteAddress,
socketOptions.connection.remotePort,
options.host || '',
options.port || ''
);
options = assign(false, options);
Object.keys(socketOptions).forEach(key => {
options[key] = socketOptions[key];
});
}
this.connection = new SMTPConnection(options);
this.connection.once('error', err => {
this.emit('error', err);
if (returned) {
return;
}
returned = true;
return callback(err);
});
this.connection.once('end', () => {
this.close();
if (returned) {
return;
}
returned = true;
let timer = setTimeout(() => {
if (returned) {
return;
}
// still have not returned, this means we have an unexpected connection close
let err = new Error('Unexpected socket close');
if (this.connection && this.connection._socket && this.connection._socket.upgrading) {
// starttls connection errors
err.code = 'ETLS';
}
callback(err);
}, 1000);
try {
timer.unref();
} catch (E) {
// Ignore. Happens on envs with non-node timer implementation
}
});
this.connection.connect(() => {
if (returned) {
return;
}
if (this.auth && (this.connection.allowsAuth || options.forceAuth)) {
this.connection.login(this.auth, err => {
if (returned) {
return;
}
returned = true;
if (err) {
this.connection.close();
this.emit('error', err);
return callback(err);
}
this._connected = true;
callback(null, true);
});
} else {
returned = true;
this._connected = true;
return callback(null, true);
}
});
});
}
/**
* Sends an e-mail to be sent using the selected settings
*
* @param {Object} mail Mail object
* @param {Function} callback Callback function
*/
send(mail, callback) {
if (!this._connected) {
return this.connect(err => {
if (err) {
return callback(err);
}
return this.send(mail, callback);
});
}
let envelope = mail.message.getEnvelope();
let messageId = mail.message.messageId();
let recipients = [].concat(envelope.to || []);
if (recipients.length > 3) {
recipients.push('...and ' + recipients.splice(2).length + ' more');
}
this.logger.info(
{
tnx: 'send',
messageId,
cid: this.id
},
'Sending message %s using #%s to <%s>',
messageId,
this.id,
recipients.join(', ')
);
if (mail.data.dsn) {
envelope.dsn = mail.data.dsn;
}
this.connection.send(envelope, mail.message.createReadStream(), (err, info) => {
this.messages++;
if (err) {
this.connection.close();
this.emit('error', err);
return callback(err);
}
info.envelope = {
from: envelope.from,
to: envelope.to
};
info.messageId = messageId;
setImmediate(() => {
let err;
if (this.messages >= this.options.maxMessages) {
err = new Error('Resource exhausted');
err.code = 'EMAXLIMIT';
this.connection.close();
this.emit('error', err);
} else {
this.pool._checkRateLimit(() => {
this.available = true;
this.emit('available');
});
}
});
callback(null, info);
});
}
/**
* Closes the connection
*/
close() {
this._connected = false;
if (this.auth && this.auth.oauth2) {
this.auth.oauth2.removeAllListeners();
}
if (this.connection) {
this.connection.close();
}
this.emit('close');
}
}
module.exports = PoolResource;

@ -0,0 +1,408 @@
'use strict';
const EventEmitter = require('events');
const SMTPConnection = require('../smtp-connection');
const wellKnown = require('../well-known');
const shared = require('../shared');
const XOAuth2 = require('../xoauth2');
const packageData = require('../../package.json');
/**
* Creates a SMTP transport object for Nodemailer
*
* @constructor
* @param {Object} options Connection options
*/
class SMTPTransport extends EventEmitter {
constructor(options) {
super();
options = options || {};
if (typeof options === 'string') {
options = {
url: options
};
}
let urlData;
let service = options.service;
if (typeof options.getSocket === 'function') {
this.getSocket = options.getSocket;
}
if (options.url) {
urlData = shared.parseConnectionUrl(options.url);
service = service || urlData.service;
}
this.options = shared.assign(
false, // create new object
options, // regular options
urlData, // url options
service && wellKnown(service) // wellknown options
);
this.logger = shared.getLogger(this.options, {
component: this.options.component || 'smtp-transport'
});
// temporary object
let connection = new SMTPConnection(this.options);
this.name = 'SMTP';
this.version = packageData.version + '[client:' + connection.version + ']';
if (this.options.auth) {
this.auth = this.getAuth({});
}
}
/**
* Placeholder function for creating proxy sockets. This method immediatelly returns
* without a socket
*
* @param {Object} options Connection options
* @param {Function} callback Callback function to run with the socket keys
*/
getSocket(options, callback) {
// return immediatelly
return setImmediate(() => callback(null, false));
}
getAuth(authOpts) {
if (!authOpts) {
return this.auth;
}
let hasAuth = false;
let authData = {};
if (this.options.auth && typeof this.options.auth === 'object') {
Object.keys(this.options.auth).forEach(key => {
hasAuth = true;
authData[key] = this.options.auth[key];
});
}
if (authOpts && typeof authOpts === 'object') {
Object.keys(authOpts).forEach(key => {
hasAuth = true;
authData[key] = authOpts[key];
});
}
if (!hasAuth) {
return false;
}
switch ((authData.type || '').toString().toUpperCase()) {
case 'OAUTH2': {
if (!authData.service && !authData.user) {
return false;
}
let oauth2 = new XOAuth2(authData, this.logger);
oauth2.provisionCallback = (this.mailer && this.mailer.get('oauth2_provision_cb')) || oauth2.provisionCallback;
oauth2.on('token', token => this.mailer.emit('token', token));
oauth2.on('error', err => this.emit('error', err));
return {
type: 'OAUTH2',
user: authData.user,
oauth2,
method: 'XOAUTH2'
};
}
default:
return {
type: (authData.type || '').toString().toUpperCase() || 'LOGIN',
user: authData.user,
credentials: {
user: authData.user || '',
pass: authData.pass,
options: authData.options
},
method: (authData.method || '').trim().toUpperCase() || this.options.authMethod || false
};
}
}
/**
* Sends an e-mail using the selected settings
*
* @param {Object} mail Mail object
* @param {Function} callback Callback function
*/
send(mail, callback) {
this.getSocket(this.options, (err, socketOptions) => {
if (err) {
return callback(err);
}
let returned = false;
let options = this.options;
if (socketOptions && socketOptions.connection) {
this.logger.info(
{
tnx: 'proxy',
remoteAddress: socketOptions.connection.remoteAddress,
remotePort: socketOptions.connection.remotePort,
destHost: options.host || '',
destPort: options.port || '',
action: 'connected'
},
'Using proxied socket from %s:%s to %s:%s',
socketOptions.connection.remoteAddress,
socketOptions.connection.remotePort,
options.host || '',
options.port || ''
);
// only copy options if we need to modify it
options = shared.assign(false, options);
Object.keys(socketOptions).forEach(key => {
options[key] = socketOptions[key];
});
}
let connection = new SMTPConnection(options);
connection.once('error', err => {
if (returned) {
return;
}
returned = true;
connection.close();
return callback(err);
});
connection.once('end', () => {
if (returned) {
return;
}
let timer = setTimeout(() => {
if (returned) {
return;
}
returned = true;
// still have not returned, this means we have an unexpected connection close
let err = new Error('Unexpected socket close');
if (connection && connection._socket && connection._socket.upgrading) {
// starttls connection errors
err.code = 'ETLS';
}
callback(err);
}, 1000);
try {
timer.unref();
} catch (E) {
// Ignore. Happens on envs with non-node timer implementation
}
});
let sendMessage = () => {
let envelope = mail.message.getEnvelope();
let messageId = mail.message.messageId();
let recipients = [].concat(envelope.to || []);
if (recipients.length > 3) {
recipients.push('...and ' + recipients.splice(2).length + ' more');
}
if (mail.data.dsn) {
envelope.dsn = mail.data.dsn;
}
this.logger.info(
{
tnx: 'send',
messageId
},
'Sending message %s to <%s>',
messageId,
recipients.join(', ')
);
connection.send(envelope, mail.message.createReadStream(), (err, info) => {
returned = true;
connection.close();
if (err) {
this.logger.error(
{
err,
tnx: 'send'
},
'Send error for %s: %s',
messageId,
err.message
);
return callback(err);
}
info.envelope = {
from: envelope.from,
to: envelope.to
};
info.messageId = messageId;
try {
return callback(null, info);
} catch (E) {
this.logger.error(
{
err: E,
tnx: 'callback'
},
'Callback error for %s: %s',
messageId,
E.message
);
}
});
};
connection.connect(() => {
if (returned) {
return;
}
let auth = this.getAuth(mail.data.auth);
if (auth && (connection.allowsAuth || options.forceAuth)) {
connection.login(auth, err => {
if (auth && auth !== this.auth && auth.oauth2) {
auth.oauth2.removeAllListeners();
}
if (returned) {
return;
}
if (err) {
returned = true;
connection.close();
return callback(err);
}
sendMessage();
});
} else {
sendMessage();
}
});
});
}
/**
* Verifies SMTP configuration
*
* @param {Function} callback Callback function
*/
verify(callback) {
let promise;
if (!callback) {
promise = new Promise((resolve, reject) => {
callback = shared.callbackPromise(resolve, reject);
});
}
this.getSocket(this.options, (err, socketOptions) => {
if (err) {
return callback(err);
}
let options = this.options;
if (socketOptions && socketOptions.connection) {
this.logger.info(
{
tnx: 'proxy',
remoteAddress: socketOptions.connection.remoteAddress,
remotePort: socketOptions.connection.remotePort,
destHost: options.host || '',
destPort: options.port || '',
action: 'connected'
},
'Using proxied socket from %s:%s to %s:%s',
socketOptions.connection.remoteAddress,
socketOptions.connection.remotePort,
options.host || '',
options.port || ''
);
options = shared.assign(false, options);
Object.keys(socketOptions).forEach(key => {
options[key] = socketOptions[key];
});
}
let connection = new SMTPConnection(options);
let returned = false;
connection.once('error', err => {
if (returned) {
return;
}
returned = true;
connection.close();
return callback(err);
});
connection.once('end', () => {
if (returned) {
return;
}
returned = true;
return callback(new Error('Connection closed'));
});
let finalize = () => {
if (returned) {
return;
}
returned = true;
connection.quit();
return callback(null, true);
};
connection.connect(() => {
if (returned) {
return;
}
let authData = this.getAuth({});
if (authData && (connection.allowsAuth || options.forceAuth)) {
connection.login(authData, err => {
if (returned) {
return;
}
if (err) {
returned = true;
connection.close();
return callback(err);
}
finalize();
});
} else {
finalize();
}
});
});
return promise;
}
/**
* Releases resources
*/
close() {
if (this.auth && this.auth.oauth2) {
this.auth.oauth2.removeAllListeners();
}
this.emit('close');
}
}
// expose to the world
module.exports = SMTPTransport;

@ -0,0 +1,142 @@
'use strict';
const packageData = require('../../package.json');
const shared = require('../shared');
const LeWindows = require('../sendmail-transport/le-windows');
const LeUnix = require('../sendmail-transport/le-unix');
/**
* Generates a Transport object for streaming
*
* Possible options can be the following:
*
* * **buffer** if true, then returns the message as a Buffer object instead of a stream
* * **newline** either 'windows' or 'unix'
*
* @constructor
* @param {Object} optional config parameter
*/
class StreamTransport {
constructor(options) {
options = options || {};
this.options = options || {};
this.name = 'StreamTransport';
this.version = packageData.version;
this.logger = shared.getLogger(this.options, {
component: this.options.component || 'stream-transport'
});
this.winbreak = ['win', 'windows', 'dos', '\r\n'].includes((options.newline || '').toString().toLowerCase());
}
/**
* Compiles a mailcomposer message and forwards it to handler that sends it
*
* @param {Object} emailMessage MailComposer object
* @param {Function} callback Callback function to run when the sending is completed
*/
send(mail, done) {
// We probably need this in the output
mail.message.keepBcc = true;
let envelope = mail.data.envelope || mail.message.getEnvelope();
let messageId = mail.message.messageId();
let recipients = [].concat(envelope.to || []);
if (recipients.length > 3) {
recipients.push('...and ' + recipients.splice(2).length + ' more');
}
this.logger.info(
{
tnx: 'send',
messageId
},
'Sending message %s to <%s> using %s line breaks',
messageId,
recipients.join(', '),
this.winbreak ? '<CR><LF>' : '<LF>'
);
setImmediate(() => {
let sourceStream;
let stream;
let transform;
try {
transform = this.winbreak ? new LeWindows() : new LeUnix();
sourceStream = mail.message.createReadStream();
stream = sourceStream.pipe(transform);
sourceStream.on('error', err => stream.emit('error', err));
} catch (E) {
this.logger.error(
{
err: E,
tnx: 'send',
messageId
},
'Creating send stream failed for %s. %s',
messageId,
E.message
);
return done(E);
}
if (!this.options.buffer) {
stream.once('error', err => {
this.logger.error(
{
err,
tnx: 'send',
messageId
},
'Failed creating message for %s. %s',
messageId,
err.message
);
});
return done(null, {
envelope: mail.data.envelope || mail.message.getEnvelope(),
messageId,
message: stream
});
}
let chunks = [];
let chunklen = 0;
stream.on('readable', () => {
let chunk;
while ((chunk = stream.read()) !== null) {
chunks.push(chunk);
chunklen += chunk.length;
}
});
stream.once('error', err => {
this.logger.error(
{
err,
tnx: 'send',
messageId
},
'Failed creating message for %s. %s',
messageId,
err.message
);
return done(err);
});
stream.on('end', () =>
done(null, {
envelope: mail.data.envelope || mail.message.getEnvelope(),
messageId,
message: Buffer.concat(chunks, chunklen)
})
);
});
}
}
module.exports = StreamTransport;

@ -0,0 +1,47 @@
'use strict';
const services = require('./services.json');
const normalized = {};
Object.keys(services).forEach(key => {
let service = services[key];
normalized[normalizeKey(key)] = normalizeService(service);
[].concat(service.aliases || []).forEach(alias => {
normalized[normalizeKey(alias)] = normalizeService(service);
});
[].concat(service.domains || []).forEach(domain => {
normalized[normalizeKey(domain)] = normalizeService(service);
});
});
function normalizeKey(key) {
return key.replace(/[^a-zA-Z0-9.-]/g, '').toLowerCase();
}
function normalizeService(service) {
let filter = ['domains', 'aliases'];
let response = {};
Object.keys(service).forEach(key => {
if (filter.indexOf(key) < 0) {
response[key] = service[key];
}
});
return response;
}
/**
* Resolves SMTP config for given key. Key can be a name (like 'Gmail'), alias (like 'Google Mail') or
* an email address (like 'test@googlemail.com').
*
* @param {String} key [description]
* @returns {Object} SMTP config or false if not found
*/
module.exports = function(key) {
key = normalizeKey(key.split('@').pop());
return normalized[key] || false;
};

@ -0,0 +1,262 @@
{
"1und1": {
"host": "smtp.1und1.de",
"port": 465,
"secure": true,
"authMethod": "LOGIN"
},
"AOL": {
"domains": ["aol.com"],
"host": "smtp.aol.com",
"port": 587
},
"DebugMail": {
"host": "debugmail.io",
"port": 25
},
"DynectEmail": {
"aliases": ["Dynect"],
"host": "smtp.dynect.net",
"port": 25
},
"FastMail": {
"domains": ["fastmail.fm"],
"host": "smtp.fastmail.com",
"port": 465,
"secure": true
},
"GandiMail": {
"aliases": ["Gandi", "Gandi Mail"],
"host": "mail.gandi.net",
"port": 587
},
"Gmail": {
"aliases": ["Google Mail"],
"domains": ["gmail.com", "googlemail.com"],
"host": "smtp.gmail.com",
"port": 465,
"secure": true
},
"Godaddy": {
"host": "smtpout.secureserver.net",
"port": 25
},
"GodaddyAsia": {
"host": "smtp.asia.secureserver.net",
"port": 25
},
"GodaddyEurope": {
"host": "smtp.europe.secureserver.net",
"port": 25
},
"hot.ee": {
"host": "mail.hot.ee"
},
"Hotmail": {
"aliases": ["Outlook", "Outlook.com", "Hotmail.com"],
"domains": ["hotmail.com", "outlook.com"],
"host": "smtp.live.com",
"port": 587
},
"iCloud": {
"aliases": ["Me", "Mac"],
"domains": ["me.com", "mac.com"],
"host": "smtp.mail.me.com",
"port": 587
},
"mail.ee": {
"host": "smtp.mail.ee"
},
"Mail.ru": {
"host": "smtp.mail.ru",
"port": 465,
"secure": true
},
"Maildev": {
"port": 1025,
"ignoreTLS": true
},
"Mailgun": {
"host": "smtp.mailgun.org",
"port": 465,
"secure": true
},
"Mailjet": {
"host": "in.mailjet.com",
"port": 587
},
"Mailosaur": {
"host": "mailosaur.io",
"port": 25
},
"Mailtrap": {
"host": "smtp.mailtrap.io",
"port": 2525
},
"Mandrill": {
"host": "smtp.mandrillapp.com",
"port": 587
},
"Naver": {
"host": "smtp.naver.com",
"port": 587
},
"One": {
"host": "send.one.com",
"port": 465,
"secure": true
},
"OpenMailBox": {
"aliases": ["OMB", "openmailbox.org"],
"host": "smtp.openmailbox.org",
"port": 465,
"secure": true
},
"Outlook365": {
"host": "smtp.office365.com",
"port": 587,
"secure": false
},
"Postmark": {
"aliases": ["PostmarkApp"],
"host": "smtp.postmarkapp.com",
"port": 2525
},
"qiye.aliyun": {
"host": "smtp.mxhichina.com",
"port": "465",
"secure": true
},
"QQ": {
"domains": ["qq.com"],
"host": "smtp.qq.com",
"port": 465,
"secure": true
},
"QQex": {
"aliases": ["QQ Enterprise"],
"domains": ["exmail.qq.com"],
"host": "smtp.exmail.qq.com",
"port": 465,
"secure": true
},
"SendCloud": {
"host": "smtpcloud.sohu.com",
"port": 25
},
"SendGrid": {
"host": "smtp.sendgrid.net",
"port": 587
},
"SendinBlue": {
"host": "smtp-relay.sendinblue.com",
"port": 587
},
"SendPulse": {
"host": "smtp-pulse.com",
"port": 465,
"secure": true
},
"SES": {
"host": "email-smtp.us-east-1.amazonaws.com",
"port": 465,
"secure": true
},
"SES-US-EAST-1": {
"host": "email-smtp.us-east-1.amazonaws.com",
"port": 465,
"secure": true
},
"SES-US-WEST-2": {
"host": "email-smtp.us-west-2.amazonaws.com",
"port": 465,
"secure": true
},
"SES-EU-WEST-1": {
"host": "email-smtp.eu-west-1.amazonaws.com",
"port": 465,
"secure": true
},
"Sparkpost": {
"aliases": ["SparkPost", "SparkPost Mail"],
"domains": ["sparkpost.com"],
"host": "smtp.sparkpostmail.com",
"port": 587,
"secure": false
},
"Tipimail": {
"host": "smtp.tipimail.com",
"port": 587
},
"Yahoo": {
"domains": ["yahoo.com"],
"host": "smtp.mail.yahoo.com",
"port": 465,
"secure": true
},
"Yandex": {
"domains": ["yandex.ru"],
"host": "smtp.yandex.ru",
"port": 465,
"secure": true
},
"Zoho": {
"host": "smtp.zoho.com",
"port": 465,
"secure": true,
"authMethod": "LOGIN"
},
"126": {
"host": "smtp.126.com",
"port": 465,
"secure": true
},
"163": {
"host": "smtp.163.com",
"port": 465,
"secure": true
}
}

@ -0,0 +1,368 @@
'use strict';
const Stream = require('stream').Stream;
const fetch = require('../fetch');
const crypto = require('crypto');
const shared = require('../shared');
/**
* XOAUTH2 access_token generator for Gmail.
* Create client ID for web applications in Google API console to use it.
* See Offline Access for receiving the needed refreshToken for an user
* https://developers.google.com/accounts/docs/OAuth2WebServer#offline
*
* Usage for generating access tokens with a custom method using provisionCallback:
* provisionCallback(user, renew, callback)
* * user is the username to get the token for
* * renew is a boolean that if true indicates that existing token failed and needs to be renewed
* * callback is the callback to run with (error, accessToken [, expires])
* * accessToken is a string
* * expires is an optional expire time in milliseconds
* If provisionCallback is used, then Nodemailer does not try to attempt generating the token by itself
*
* @constructor
* @param {Object} options Client information for token generation
* @param {String} options.user User e-mail address
* @param {String} options.clientId Client ID value
* @param {String} options.clientSecret Client secret value
* @param {String} options.refreshToken Refresh token for an user
* @param {String} options.accessUrl Endpoint for token generation, defaults to 'https://accounts.google.com/o/oauth2/token'
* @param {String} options.accessToken An existing valid accessToken
* @param {String} options.privateKey Private key for JSW
* @param {Number} options.expires Optional Access Token expire time in ms
* @param {Number} options.timeout Optional TTL for Access Token in seconds
* @param {Function} options.provisionCallback Function to run when a new access token is required
*/
class XOAuth2 extends Stream {
constructor(options, logger) {
super();
this.options = options || {};
if (options && options.serviceClient) {
if (!options.privateKey || !options.user) {
setImmediate(() => this.emit('error', new Error('Options "privateKey" and "user" are required for service account!')));
return;
}
let serviceRequestTimeout = Math.min(Math.max(Number(this.options.serviceRequestTimeout) || 0, 0), 3600);
this.options.serviceRequestTimeout = serviceRequestTimeout || 5 * 60;
}
this.logger = shared.getLogger(
{
logger
},
{
component: this.options.component || 'OAuth2'
}
);
this.provisionCallback = typeof this.options.provisionCallback === 'function' ? this.options.provisionCallback : false;
this.options.accessUrl = this.options.accessUrl || 'https://accounts.google.com/o/oauth2/token';
this.options.customHeaders = this.options.customHeaders || {};
this.options.customParams = this.options.customParams || {};
this.accessToken = this.options.accessToken || false;
if (this.options.expires && Number(this.options.expires)) {
this.expires = this.options.expires;
} else {
let timeout = Math.max(Number(this.options.timeout) || 0, 0);
this.expires = (timeout && Date.now() + timeout * 1000) || 0;
}
}
/**
* Returns or generates (if previous has expired) a XOAuth2 token
*
* @param {Boolean} renew If false then use cached access token (if available)
* @param {Function} callback Callback function with error object and token string
*/
getToken(renew, callback) {
if (!renew && this.accessToken && (!this.expires || this.expires > Date.now())) {
return callback(null, this.accessToken);
}
let generateCallback = (...args) => {
if (args[0]) {
this.logger.error(
{
err: args[0],
tnx: 'OAUTH2',
user: this.options.user,
action: 'renew'
},
'Failed generating new Access Token for %s',
this.options.user
);
} else {
this.logger.info(
{
tnx: 'OAUTH2',
user: this.options.user,
action: 'renew'
},
'Generated new Access Token for %s',
this.options.user
);
}
callback(...args);
};
if (this.provisionCallback) {
this.provisionCallback(this.options.user, !!renew, (err, accessToken, expires) => {
if (!err && accessToken) {
this.accessToken = accessToken;
this.expires = expires || 0;
}
generateCallback(err, accessToken);
});
} else {
this.generateToken(generateCallback);
}
}
/**
* Updates token values
*
* @param {String} accessToken New access token
* @param {Number} timeout Access token lifetime in seconds
*
* Emits 'token': { user: User email-address, accessToken: the new accessToken, timeout: TTL in seconds}
*/
updateToken(accessToken, timeout) {
this.accessToken = accessToken;
timeout = Math.max(Number(timeout) || 0, 0);
this.expires = (timeout && Date.now() + timeout * 1000) || 0;
this.emit('token', {
user: this.options.user,
accessToken: accessToken || '',
expires: this.expires
});
}
/**
* Generates a new XOAuth2 token with the credentials provided at initialization
*
* @param {Function} callback Callback function with error object and token string
*/
generateToken(callback) {
let urlOptions;
let loggedUrlOptions;
if (this.options.serviceClient) {
// service account - https://developers.google.com/identity/protocols/OAuth2ServiceAccount
let iat = Math.floor(Date.now() / 1000); // unix time
let tokenData = {
iss: this.options.serviceClient,
scope: this.options.scope || 'https://mail.google.com/',
sub: this.options.user,
aud: this.options.accessUrl,
iat,
exp: iat + this.options.serviceRequestTimeout
};
let token;
try {
token = this.jwtSignRS256(tokenData);
} catch (err) {
return callback(new Error('Can\x27t generate token. Check your auth options'));
}
urlOptions = {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: token
};
loggedUrlOptions = {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: tokenData
};
} else {
if (!this.options.refreshToken) {
return callback(new Error('Can\x27t create new access token for user'));
}
// web app - https://developers.google.com/identity/protocols/OAuth2WebServer
urlOptions = {
client_id: this.options.clientId || '',
client_secret: this.options.clientSecret || '',
refresh_token: this.options.refreshToken,
grant_type: 'refresh_token'
};
loggedUrlOptions = {
client_id: this.options.clientId || '',
client_secret: (this.options.clientSecret || '').substr(0, 6) + '...',
refresh_token: (this.options.refreshToken || '').substr(0, 6) + '...',
grant_type: 'refresh_token'
};
}
Object.keys(this.options.customParams).forEach(key => {
urlOptions[key] = this.options.customParams[key];
loggedUrlOptions[key] = this.options.customParams[key];
});
this.logger.debug(
{
tnx: 'OAUTH2',
user: this.options.user,
action: 'generate'
},
'Requesting token using: %s',
JSON.stringify(loggedUrlOptions)
);
this.postRequest(this.options.accessUrl, urlOptions, this.options, (error, body) => {
let data;
if (error) {
return callback(error);
}
try {
data = JSON.parse(body.toString());
} catch (E) {
return callback(E);
}
if (!data || typeof data !== 'object') {
this.logger.debug(
{
tnx: 'OAUTH2',
user: this.options.user,
action: 'post'
},
'Response: %s',
(body || '').toString()
);
return callback(new Error('Invalid authentication response'));
}
let logData = {};
Object.keys(data).forEach(key => {
if (key !== 'access_token') {
logData[key] = data[key];
} else {
logData[key] = (data[key] || '').toString().substr(0, 6) + '...';
}
});
this.logger.debug(
{
tnx: 'OAUTH2',
user: this.options.user,
action: 'post'
},
'Response: %s',
JSON.stringify(logData)
);
if (data.error) {
return callback(new Error(data.error));
}
if (data.access_token) {
this.updateToken(data.access_token, data.expires_in);
return callback(null, this.accessToken);
}
return callback(new Error('No access token'));
});
}
/**
* Converts an access_token and user id into a base64 encoded XOAuth2 token
*
* @param {String} [accessToken] Access token string
* @return {String} Base64 encoded token for IMAP or SMTP login
*/
buildXOAuth2Token(accessToken) {
let authData = ['user=' + (this.options.user || ''), 'auth=Bearer ' + (accessToken || this.accessToken), '', ''];
return Buffer.from(authData.join('\x01'), 'utf-8').toString('base64');
}
/**
* Custom POST request handler.
* This is only needed to keep paths short in Windows usually this module
* is a dependency of a dependency and if it tries to require something
* like the request module the paths get way too long to handle for Windows.
* As we do only a simple POST request we do not actually require complicated
* logic support (no redirects, no nothing) anyway.
*
* @param {String} url Url to POST to
* @param {String|Buffer} payload Payload to POST
* @param {Function} callback Callback function with (err, buff)
*/
postRequest(url, payload, params, callback) {
let returned = false;
let chunks = [];
let chunklen = 0;
let req = fetch(url, {
method: 'post',
headers: params.customHeaders,
body: payload,
allowErrorResponse: true
});
req.on('readable', () => {
let chunk;
while ((chunk = req.read()) !== null) {
chunks.push(chunk);
chunklen += chunk.length;
}
});
req.once('error', err => {
if (returned) {
return;
}
returned = true;
return callback(err);
});
req.once('end', () => {
if (returned) {
return;
}
returned = true;
return callback(null, Buffer.concat(chunks, chunklen));
});
}
/**
* Encodes a buffer or a string into Base64url format
*
* @param {Buffer|String} data The data to convert
* @return {String} The encoded string
*/
toBase64URL(data) {
if (typeof data === 'string') {
data = Buffer.from(data);
}
return data
.toString('base64')
.replace(/[=]+/g, '') // remove '='s
.replace(/\+/g, '-') // '+' → '-'
.replace(/\//g, '_'); // '/' → '_'
}
/**
* Creates a JSON Web Token signed with RS256 (SHA256 + RSA)
*
* @param {Object} payload The payload to include in the generated token
* @return {String} The generated and signed token
*/
jwtSignRS256(payload) {
payload = ['{"alg":"RS256","typ":"JWT"}', JSON.stringify(payload)].map(val => this.toBase64URL(val)).join('.');
let signature = crypto.createSign('RSA-SHA256').update(payload).sign(this.options.privateKey);
return payload + '.' + this.toBase64URL(signature);
}
}
module.exports = XOAuth2;

@ -0,0 +1,74 @@
{
"_from": "nodemailer",
"_id": "nodemailer@6.4.11",
"_inBundle": false,
"_integrity": "sha512-BVZBDi+aJV4O38rxsUh164Dk1NCqgh6Cm0rQSb9SK/DHGll/DrCMnycVDD7msJgZCnmVa8ASo8EZzR7jsgTukQ==",
"_location": "/nodemailer",
"_phantomChildren": {},
"_requested": {
"type": "tag",
"registry": true,
"raw": "nodemailer",
"name": "nodemailer",
"escapedName": "nodemailer",
"rawSpec": "",
"saveSpec": null,
"fetchSpec": "latest"
},
"_requiredBy": [
"#USER",
"/"
],
"_resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.4.11.tgz",
"_shasum": "1f00b4ffd106403f17c03f3d43d5945b2677046c",
"_spec": "nodemailer",
"_where": "/home/sigonasr2/divar/server",
"author": {
"name": "Andris Reinman"
},
"bugs": {
"url": "https://github.com/nodemailer/nodemailer/issues"
},
"bundleDependencies": false,
"dependencies": {},
"deprecated": false,
"description": "Easy as cake e-mail sending from your Node.js applications",
"devDependencies": {
"bunyan": "1.8.14",
"chai": "4.2.0",
"eslint-config-nodemailer": "1.2.0",
"eslint-config-prettier": "6.11.0",
"grunt": "1.2.1",
"grunt-cli": "1.3.2",
"grunt-eslint": "23.0.0",
"grunt-mocha-test": "0.13.3",
"libbase64": "1.2.1",
"libmime": "5.0.0",
"libqp": "1.1.0",
"mocha": "8.0.1",
"nodemailer-ntlm-auth": "1.0.1",
"proxy": "1.0.2",
"proxy-test-server": "1.0.0",
"sinon": "9.0.2",
"smtp-server": "3.7.0"
},
"engines": {
"node": ">=6.0.0"
},
"homepage": "https://nodemailer.com/",
"keywords": [
"Nodemailer"
],
"license": "MIT",
"main": "lib/nodemailer.js",
"name": "nodemailer",
"repository": {
"type": "git",
"url": "git+https://github.com/nodemailer/nodemailer.git"
},
"scripts": {
"postinstall": "node -e \"try{require('./postinstall')}catch(e){}\"",
"test": "grunt"
},
"version": "6.4.11"
}

@ -0,0 +1,101 @@
/* eslint no-control-regex:0 */
'use strict';
const packageData = require('./package.json');
const isEnabled = value => !!value && value !== '0' && value !== 'false';
const canUseColor = isEnabled(process.env.npm_config_color);
const title = `=== Nodemailer ${packageData.version} ===`;
const text = `
Thank you for using Nodemailer for your email sending needs! While Nodemailer itself is mostly meant to be a SMTP client there are other related projects in the Nodemailer project as well.
> IMAP API ( https://imapapi.com ) is a server application to easily access IMAP accounts via REST API
> ImapFlow ( https://imapflow.com/ ) is an async IMAP client library for Node.js
> NodemailerApp ( https://nodemailer.com/app/ ) is a cross platform GUI app to debug emails
> Project Pending ( https://projectpending.com/ ) allows you to host DNS of your project domains
> Pending DNS ( https://pendingdns.com/ ) is the DNS server used that powers Project Pending
> Ethereal Email ( https://ethereal.email/ ) is an email testing service that accepts all your test emails
`;
const footer = `Don't like this message?
There's a Github Sponsors goal to remove it
https://github.com/sponsors/andris9
`;
const secs = 4;
const formatCentered = (row, columns) => {
return row
.split(/\r?\n/)
.map(row => {
if (columns <= row.length) {
return row;
}
return ' '.repeat(Math.round(columns / 2 - row.length / 2)) + row;
})
.join('\n');
};
const formatRow = (row, columns) => {
if (row.length <= columns) {
return [row];
}
// wrap!
let lines = [];
while (row.length) {
if (row.length <= columns) {
lines.push(row);
break;
}
let slice = row.substr(0, columns);
let prefix = slice.charAt(0) === '>' ? ' ' : '';
let match = slice.match(/(\s+)[^\s]*$/);
if (match && match.index) {
let line = row.substr(0, match.index);
row = prefix + row.substr(line.length + match[1].length);
lines.push(line);
} else {
lines.push(row);
break;
}
}
return lines;
};
const wrapText = text => {
let columns = Number(process.stdout.columns) || 80;
columns = Math.min(columns, 80) - 1;
return (
(formatCentered(title, columns) + '\n' + text)
.split('\n')
.flatMap(row => formatRow(row, columns))
.join('\n') +
'\n' +
formatCentered(footer, columns)
);
};
const banner = wrapText(text)
.replace(/^/gm, '\u001B[96m')
.replace(/$/gm, '\u001B[0m')
.replace(/(https:[^\s)]+)/g, '\u001B[94m $1 \u001B[96m');
console.log(canUseColor ? banner : banner.replace(/\u001B\[\d+m/g, ''));
if (canUseColor) {
process.stdout.write('\u001B[96m');
}
setInterval(() => {
process.stdout.write('.');
}, 500);
setTimeout(() => {
if (canUseColor) {
process.stdout.write('\u001B[0m\n');
}
process.exit(0);
}, secs * 1000 + 100);

@ -1085,6 +1085,11 @@
"resolved": "https://registry.npmjs.org/node-bitmap/-/node-bitmap-0.0.1.tgz",
"integrity": "sha1-GA6scAPgxwdhjvMTaPYvhLKmkJE="
},
"nodemailer": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.4.11.tgz",
"integrity": "sha512-BVZBDi+aJV4O38rxsUh164Dk1NCqgh6Cm0rQSb9SK/DHGll/DrCMnycVDD7msJgZCnmVa8ASo8EZzR7jsgTukQ=="
},
"nopt": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz",

@ -11,13 +11,10 @@
"dependencies": {
"axios": "^0.19.2",
"connect-timeout": "^1.9.0",
"crypto": "^1.0.1",
"express": "^4.17.1",
"express-fileupload": "^1.1.9",
"get-pixels": "^3.3.2",
"image-pixels": "^2.2.2",
"nodemailer": "^6.4.11",
"pg": "^8.2.1",
"readimage": "^1.1.1",
"request": "^2.88.2",
"twitter-autohook": "^1.7.1",
"unzipper": "^0.10.11"

Loading…
Cancel
Save