parent
ec3a90c082
commit
5d0de5240b
@ -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,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); |
Loading…
Reference in new issue