signalk-to-noforeignland 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.project ADDED
@@ -0,0 +1,11 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <projectDescription>
3
+ <name>nfl-signalk</name>
4
+ <comment></comment>
5
+ <projects>
6
+ </projects>
7
+ <buildSpec>
8
+ </buildSpec>
9
+ <natures>
10
+ </natures>
11
+ </projectDescription>
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # SignalK To NFL
2
+ Effortlessly log your boat's movement to **noforeignland.com**
3
+
4
+ ## Features
5
+ * Automatically log your position to NFL
6
+ * Send detailed tracks to log your entire trip and not just your final position
7
+ * Can be used in near real time or cache and upload when stopped and data-connection is available.
8
+
9
+ An internet connection is required in order to update NFL.
10
+
11
+ ## Requirements
12
+ * A **noforeignland.com** account
13
+ * Your Boat API Key from the **noforeignland.com** website:
14
+ * Account > Settings > Boat tracking > API Key
15
+
16
+ > Note your Boat API Key is not available in the app.
17
+ > You must sign in to the **noforeignland.com** website (using the same authentication method you use for the app: Google. Facebook, Email).
package/createGPX.js ADDED
@@ -0,0 +1,48 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const readline = require('readline');
4
+
5
+ async function createGPX(options) {
6
+ await writeHeader(options);
7
+ const fileStream = fs.createReadStream(options.input);
8
+
9
+ const rl = readline.createInterface({
10
+ input: fileStream,
11
+ crlfDelay: Infinity
12
+ });
13
+
14
+ for await (const line of rl) {
15
+ if (line){
16
+ const point = JSON.parse(line);
17
+ let trkpt = ' <trkpt ';
18
+ trkpt += `lat="${point.lat}" lon="${point.lon}">\n`;
19
+ trkpt += ` <time>${point.t}</time>\n`;
20
+ trkpt += ' </trkpt>\n';
21
+ fs.appendFileSync(path.join(options.outputDir, 'track.gpx'), trkpt);
22
+ }
23
+ }
24
+ await writeFooter(options);
25
+ return [path.join(options.outputDir, 'track.gpx')];
26
+ }
27
+
28
+ async function writeHeader(options) {
29
+ const header = `<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
30
+ <gpx version="1.1" creator="${options.creator}"
31
+ xmlns="http://www.topografix.com/GPX/1/1"
32
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
33
+ xmlns:gpxx="http://www8.garmin.com/xmlschemas/GpxExtensions/v3"
34
+ xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd https://www8.garmin.com/xmlschemas/GpxExtensions/v3 https://www8.garmin.com/xmlschemas/GpxExtensions/v3/GpxExtensionsv3.xsd"
35
+ >
36
+ <metadata/>
37
+ <trk>
38
+ <trkseg>
39
+ `;
40
+ fs.writeFileSync(path.join(options.outputDir, 'track.gpx'), header);
41
+ }
42
+
43
+ async function writeFooter(options) {
44
+ const footer = ' </trkseg>\n </trk>\n</gpx>';
45
+ fs.appendFileSync(path.join(options.outputDir, 'track.gpx'), footer);
46
+ }
47
+
48
+ module.exports = createGPX;
package/index.js ADDED
@@ -0,0 +1,500 @@
1
+
2
+ const { EOL } = require('os');
3
+ const internetTestAddress = 'google.com';
4
+ const internetTestTimeout = 1000;
5
+ const fs = require('fs-extra');
6
+ const path = require('path');
7
+ const CronJob = require('cron').CronJob;
8
+ const readline = require('readline');
9
+ const fetch = require('node-fetch');
10
+ const isReachable = require('is-reachable');
11
+ const sendEmail = require('./sendEmail');
12
+ const createGPX = require('./createGPX');
13
+ const apiUrl = 'https://www.noforeignland.com/home/api/v1/boat/tracking/track';
14
+ const pluginApiKey = 'eef6916b-77fa-4538-9870-034a8ab81989';
15
+ // const msToKn = 1.944;
16
+
17
+
18
+ module.exports = function (app) {
19
+ var plugin = {};
20
+ plugin.id = 'signalk-to-nfl';
21
+ plugin.name = 'SignalK To NFL';
22
+ plugin.description = 'SignalK track logger to noforeignland.com';
23
+
24
+ plugin.schema = {
25
+ "title": plugin.name,
26
+ "description": "Some parameters need for use",
27
+ "type": "object",
28
+ "required": ["emailCron", "boatApiKey"],
29
+ "properties": {
30
+ "trackFrequency": {
31
+ "type": "integer",
32
+ "title": "Position tracking frequency in seconds.",
33
+ "description": "To keep file sizes small we only log positions once in a while (unless you set this value to 0)",
34
+ "default": 60
35
+ },
36
+ "minMove": {
37
+ "type": "number",
38
+ "title": "Minimum boat move to log in meters",
39
+ "description": "To keep file sizes small we only log positions if a move larger than this size is noted (if set to 0 will log every move)",
40
+ "default": 50
41
+ },
42
+ "minSpeed": {
43
+ "type": "number",
44
+ "title": "Minimum boat speed to log in knots",
45
+ "description": "To keep file sizes small we only log positions if boat speed goes above this value to minimize recording position on anchor or mooring (if set to 0 will log every move)",
46
+ "default": 1.5
47
+ },
48
+ "emailCron": {
49
+ "type": "string",
50
+ "title": "Send attempt CRON",
51
+ "description": "We send the tracking data to NFL once in a while, you can set the schedule with this setting. CRON format: https://crontab.guru/",
52
+ "default": '*/10 * * * *',
53
+ },
54
+ 'boatApiKey': {
55
+ "type": "string",
56
+ "title": "Boat API key",
57
+ "description": "Boat API key from noforeignland.com. Can be found in Account > Settings > Boat tracking > API Key. *required only in API method is set*",
58
+ },
59
+ "internetTestTimeout": {
60
+ "type": "number",
61
+ "title": "Timeout for testing internet connection in ms",
62
+ "description": "Set this number higher for slower computers and internet connections",
63
+ "default": 2000,
64
+ },
65
+ "sendWhileMoving": {
66
+ "type": "boolean",
67
+ "title": "Attempt sending location while moving",
68
+ "description": "Should the plugin attempt to send tracking data to NFL while detecting the vessel is moving or only when stopped?",
69
+ "default": false
70
+ },
71
+ "filterSource": {
72
+ "type": "string",
73
+ "title": "Position source device",
74
+ "description": "Set this value to the name of a source if you want to only use the position given by that source.",
75
+ },
76
+ "trackDir": {
77
+ "type": "string",
78
+ "title": "Directory to cache tracks.",
79
+ "description": "Path in server filesystem, absolute or from plugin directory. optional param (only used to keep file cache).",
80
+ },
81
+ "keepFiles": {
82
+ "type": "boolean",
83
+ "title": "Should keep track files on disk?",
84
+ "description": "If you have a lot of hard drive space you can keep the track files for logging purposes.",
85
+ "default": false
86
+ },
87
+ "emailService": {
88
+ "type": "string",
89
+ "title": "*LEGACY* Email service in use to send tracking reports *OPTIONAL*",
90
+ "description": "Email service for outgoing mail from this list: https://community.nodemailer.com/2-0-0-beta/setup-smtp/well-known-services/",
91
+ "default": 'gmail',
92
+ },
93
+ "emailUser": {
94
+ "type": "string",
95
+ "title": "*LEGACY* Email user *OPTIONAL*",
96
+ "description": "Email user for outgoing mail. Normally should be set to the your email.",
97
+ },
98
+ "emailPassword": {
99
+ "type": "string",
100
+ "title": "*LEGACY* Email user password *OPTIONAL*",
101
+ "description": "Email user password for outgoing mail. check out the readme 'Requirements' section for more info.",
102
+ },
103
+ "emailFrom": {
104
+ "type": "string",
105
+ "title": "*LEGACY* Email 'From' address *OPTIONAL*",
106
+ "description": "Address must be set in NFL. Normally should be set to the your email. check out the readme 'Requirements' section for more info.",
107
+ },
108
+ "emailTo": {
109
+ "type": "string",
110
+ "title": "*LEGACY* Email 'to' address *OPTIONAL*",
111
+ "description": "Email address to send track GPX files to. defaults to: tracking@noforeignland.com. (can be set to your own email for testing purposes)",
112
+ "default": 'tracking@noforeignland.com',
113
+ },
114
+ }
115
+ };
116
+
117
+ var unsubscribes = [];
118
+ var unsubscribesControl = [];
119
+ var routeSaveName = 'track.jsonl';
120
+ let lastPosition;
121
+ let upSince;
122
+ let cron;
123
+ const creator = 'signalk-track-logger';
124
+ const defaultTracksDir = 'track';
125
+ // const maxAllowedSpeed = 100;
126
+
127
+ plugin.start = function (options, restartPlugin) {
128
+ if (!options.trackDir) options.trackDir = defaultTracksDir;
129
+ if (!path.isAbsolute(options.trackDir)) options.trackDir = path.join(__dirname, options.trackDir);
130
+ //app.debug('options.trackDir=',options.trackDir);
131
+ if (!createDir(options.trackDir)) {
132
+ plugin.stop();
133
+ return;
134
+ }
135
+
136
+ app.debug('track logger started, now logging to', options.trackDir);
137
+ app.setPluginStatus(`Started`);
138
+
139
+ doLogging();
140
+
141
+ function doLogging() {
142
+ let shouldDoLog = true
143
+ //subscribe for position
144
+ app.subscriptionmanager.subscribe({
145
+ "context": "vessels.self",
146
+ "subscribe": [
147
+ {
148
+ "path": "navigation.position",
149
+ "format": "delta",
150
+ "policy": "instant",
151
+ "minPeriod": options.trackFrequency ? options.trackFrequency * 1000 : 0,
152
+ }
153
+ ]
154
+ },
155
+ unsubscribes,
156
+ subscriptionError => {
157
+ app.debug('Error subscription to data:' + subscriptionError);
158
+ app.setPluginError('Error subscription to data:' + subscriptionError.message);
159
+ },
160
+ doOnValue
161
+ );
162
+
163
+ //subscribe for speed
164
+ if (options.minSpeed) {
165
+ app.subscriptionmanager.subscribe({
166
+ "context": "vessels.self",
167
+ "subscribe": [
168
+ {
169
+ "path": "navigation.speedOverGround",
170
+ "format": "delta",
171
+ "policy": "instant",
172
+ }
173
+ ]
174
+ },
175
+ unsubscribes,
176
+ subscriptionError => {
177
+ app.debug('Error subscription to data:' + subscriptionError);
178
+ app.setPluginError('Error subscription to data:' + subscriptionError.message);
179
+ },
180
+ delta => {
181
+ // app.debug('got speed delta', delta);
182
+ delta.updates.forEach(update => {
183
+ // app.debug(`update:`, update);
184
+ if (options.filterSource && update.$source !== options.filterSource) {
185
+ return;
186
+ }
187
+ update.values.forEach(value => {
188
+ // value.value is sog in m/s so 'sog*2' is in knots
189
+ if (!shouldDoLog && options.minSpeed < value.value * 2) {
190
+ app.debug('setting shouldDoLog to true');
191
+ shouldDoLog = true;
192
+ }
193
+ })
194
+ })
195
+ }
196
+ );
197
+ }
198
+
199
+ async function doOnValue(delta) {
200
+
201
+ for (update of delta.updates) {
202
+ // app.debug(`update:`, update);
203
+ if (options.filterSource && update.$source !== options.filterSource) {
204
+ return;
205
+ }
206
+ let timestamp = update.timestamp;
207
+ for (value of update.values) {
208
+
209
+ if (value.value.latitude === 0 && value.value.longitude === 0) {
210
+ // Skip saving point with latitude and longitude both equal to 0
211
+ return;
212
+ }
213
+ // app.debug(`value:`, value);
214
+
215
+ if (!shouldDoLog) {
216
+ return;
217
+ }
218
+ if (!isValidLatitude(value.value.latitude) || !isValidLongitude(value.value.longitude)) {
219
+ return;
220
+ }
221
+
222
+
223
+ if (lastPosition) {
224
+ if (new Date(lastPosition.timestamp).getTime() > new Date(timestamp).getTime()) {
225
+ app.debug('got error in timestamp:', timestamp, 'is earlier than previous:', lastPosition.timestamp);
226
+ // SK sometimes messes up timestamps, when that happens we throw the update
227
+ return;
228
+ }
229
+ const distance = equirectangularDistance(lastPosition.pos, value.value)
230
+ if (options.minMove && distance < options.minMove) {
231
+ return;
232
+ }
233
+ // if (calculatedSpeed(distance, (timestamp - lastPosition.timestamp) / 1000) > maxAllowedSpeed) {
234
+ // app.debug('got error position', value.value, 'ignoring...');
235
+ // return;
236
+ // }
237
+ }
238
+ lastPosition = { pos: value.value, timestamp, currentTime: new Date().getTime() };
239
+
240
+ await savePoint(lastPosition);
241
+ if (options.minSpeed) {
242
+ app.debug('setting shouldDoLog to false');
243
+ shouldDoLog = false;
244
+ }
245
+ };
246
+ };
247
+ }
248
+ }
249
+
250
+ async function savePoint(point) {
251
+ //{pos: {latitude, longitude}, timestamp}
252
+ // Date.parse(timestamp)
253
+ const obj = {
254
+ lat: point.pos.latitude,
255
+ lon: point.pos.longitude,
256
+ t: point.timestamp,
257
+ }
258
+ app.debug(`save data point:`, obj);
259
+ await fs.appendFile(path.join(options.trackDir, routeSaveName), JSON.stringify(obj) + EOL);
260
+ }
261
+
262
+ function isValidLatitude(obj) {
263
+ return isDefinedNumber(obj) && obj > -90 && obj < 90
264
+ }
265
+
266
+ function isValidLongitude(obj) {
267
+ return isDefinedNumber(obj) && obj > -180 && obj < 180
268
+ }
269
+
270
+ function isDefinedNumber(obj) {
271
+ return (obj !== undefined && obj !== null && typeof obj === 'number');
272
+ }
273
+
274
+ // function calculatedSpeed(distance, timeSecs) {
275
+ // // m/s to knots ~= speedinms * 1.944
276
+ // return (distance / timeSecs) * msToKn
277
+ // }
278
+
279
+ function equirectangularDistance(from, to) {
280
+ // https://www.movable-type.co.uk/scripts/latlong.html
281
+ // from,to: {longitude: xx, latitude: xx}
282
+ const rad = Math.PI / 180;
283
+ const φ1 = from.latitude * rad;
284
+ const φ2 = to.latitude * rad;
285
+ const Δλ = (to.longitude - from.longitude) * rad;
286
+ const R = 6371e3;
287
+ const x = Δλ * Math.cos((φ1 + φ2) / 2);
288
+ const y = (φ2 - φ1);
289
+ const d = Math.sqrt(x * x + y * y) * R;
290
+ return d;
291
+ }
292
+
293
+ function createDir(dir) {
294
+ let res = true;
295
+ if (fs.existsSync(dir)) {
296
+ try {
297
+ fs.accessSync(dir, fs.constants.R_OK | fs.constants.W_OK);
298
+ }
299
+ catch (error) {
300
+ app.debug('[createDir]', error.message);
301
+ app.setPluginError(`No rights to directory ${dir}`);
302
+ res = false;
303
+ }
304
+ }
305
+ else {
306
+ try {
307
+ fs.mkdirSync(dir, { recursive: true });
308
+ }
309
+ catch (error) {
310
+ switch (error.code) {
311
+ case 'EACCES': // Permission denied
312
+ case 'EPERM': // Operation not permitted
313
+ app.debug(`False to create ${dir} by Permission denied`);
314
+ app.setPluginError(`False to create ${dir} by Permission denied`);
315
+ res = false;
316
+ break;
317
+ case 'ETIMEDOUT': // Operation timed out
318
+ app.debug(`False to create ${dir} by Operation timed out`);
319
+ app.setPluginError(`False to create ${dir} by Operation timed out`);
320
+ res = false;
321
+ break;
322
+ }
323
+ }
324
+ }
325
+ return res;
326
+ } // end function createDir
327
+
328
+ async function interval() {
329
+ if ((checkBoatMoving()) && await checkTrack() && await testInternet()) {
330
+ await sendData();
331
+ }
332
+ }
333
+
334
+ function checkBoatMoving() {
335
+ if (options.sendWhileMoving || !options.trackFrequency) {
336
+ return true;
337
+ }
338
+ const time = lastPosition ? lastPosition.currentTime : upSince;
339
+
340
+ const secsSinceLastPoint = (new Date().getTime() - time)/1000
341
+ if (secsSinceLastPoint > (options.trackFrequency * 2)) {
342
+ app.debug('Boat stopped moving, last move at least', secsSinceLastPoint,'seconds ago');
343
+ return true;
344
+ } else {
345
+ app.debug('Boat is still moving, last move', secsSinceLastPoint,'seconds ago');
346
+ return false;
347
+ }
348
+ }
349
+
350
+ async function testInternet() {
351
+ app.debug('testing internet connection');
352
+ const check = await isReachable(internetTestAddress, { timeout: options.internetTestTimeout || internetTestTimeout });
353
+ app.debug('internet connection = ', check);
354
+ return check;
355
+ }
356
+
357
+ async function checkTrack() {
358
+ const trackFile = path.join(options.trackDir, routeSaveName);
359
+ app.debug('checking the track', trackFile, 'if should send');
360
+ const exists = await fs.pathExists(trackFile);
361
+ const size = exists ? (await fs.lstat(trackFile)).size : 0;
362
+ app.debug(`'${trackFile}'.size=${size} ${trackFile}'.exists=${exists}`);
363
+ return size > 0;
364
+ }
365
+
366
+ async function sendData() {
367
+ if (options.boatApiKey) {
368
+ sendApiData();
369
+ } else {
370
+ sendEmailData();
371
+ }
372
+ }
373
+
374
+ async function sendApiData() {
375
+ app.debug('sending the data');
376
+ const trackData = await createTrack(path.join(options.trackDir, routeSaveName));
377
+ if (!trackData) {
378
+ app.debug('Recorded track did not contain any valid track points, aborting sending.');
379
+ return;
380
+ }
381
+ app.debug('created track data with timestamp:', new Date(trackData.timestamp));
382
+
383
+ const params = new URLSearchParams();
384
+ params.append('timestamp', trackData.timestamp);
385
+ params.append('track', JSON.stringify(trackData.track));
386
+ params.append('boatApiKey', options.boatApiKey);
387
+
388
+ const headers = {
389
+ 'X-NFL-API-Key': pluginApiKey
390
+ }
391
+
392
+ app.debug('sending track to API');
393
+ try {
394
+ const response = await fetch(apiUrl, { method: 'POST', body: params, headers: new fetch.Headers(headers) });
395
+ if (response.ok) {
396
+ const responseBody = await response.json();
397
+ if (responseBody.status === 'ok') {
398
+ app.debug('Track successfully sent to API');
399
+ if (options.keepFiles) {
400
+ const filename = new Date().toJSON().slice(0, 19).replace(/:/g, '') + '-track.jsonl';
401
+ app.debug('moving and keeping track file: ', filename);
402
+ await fs.move(path.join(options.trackDir, routeSaveName), path.join(options.trackDir, filename));
403
+ } else {
404
+ app.debug('Deleting track file');
405
+ await fs.remove(path.join(options.trackDir, routeSaveName));
406
+ }
407
+ } else {
408
+ app.debug('Could not send track to API, returned response json:', responseBody);
409
+ }
410
+ } else {
411
+ app.debug('Could not send track to API, returned response code:', response.status, response.statusText);
412
+ }
413
+ } catch (err) {
414
+ app.debug('Could not send track to API due to error:', err);
415
+ }
416
+ }
417
+
418
+ async function createTrack(inputPath) {
419
+ const fileStream = fs.createReadStream(inputPath);
420
+
421
+ const rl = readline.createInterface({
422
+ input: fileStream,
423
+ crlfDelay: Infinity
424
+ });
425
+ const track = []
426
+ let lastTimestamp;
427
+ for await (const line of rl) {
428
+ if (line) {
429
+ try {
430
+ const point = JSON.parse(line);
431
+ if (isValidLatitude(point.lat) && isValidLongitude(point.lon)) {
432
+ track.push([point.lat, point.lon])
433
+ lastTimestamp = point.t
434
+ }
435
+ } catch (error) {
436
+ app.debug('could not parse line from track file:', line);
437
+ }
438
+ }
439
+ }
440
+ if (track.length > 0) {
441
+ return { timestamp: new Date(lastTimestamp).getTime(), track };
442
+ }
443
+ }
444
+
445
+ async function sendEmailData() {
446
+ app.debug('sending the data');
447
+ const gpxFiles = await createGPX({ input: path.join(options.trackDir, routeSaveName), outputDir: options.trackDir, creator });
448
+ app.debug('created GPX files', gpxFiles);
449
+ try {
450
+ for (let file of gpxFiles) {
451
+ app.debug('sending', file);
452
+ try {
453
+ !await sendEmail({
454
+ emailService: options.emailService,
455
+ user: options.emailUser,
456
+ password: options.emailPassword,
457
+ from: options.emailFrom,
458
+ to: options.emailTo,
459
+ trackFile: file
460
+ })
461
+ } catch (err) {
462
+ app.debug('Sending email failed:', err);
463
+ return;
464
+ }
465
+ }
466
+ } finally {
467
+ for (let file of gpxFiles) {
468
+ app.debug('deleting', file);
469
+ await fs.rm(file);
470
+ }
471
+ }
472
+ await fs.rm(path.join(options.trackDir, routeSaveName));
473
+ }
474
+
475
+ upSince = new Date().getTime();
476
+
477
+ app.debug('Setting CRON to ', options.emailCron);
478
+ cron = new CronJob(
479
+ options.emailCron,
480
+ interval
481
+ );
482
+ cron.start();
483
+ }; // end plugin.start
484
+
485
+ plugin.stop = function () {
486
+ app.debug('plugin stopped');
487
+ if (cron) {
488
+ cron.stop();
489
+ cron = undefined;
490
+ }
491
+ unsubscribesControl.forEach(f => f());
492
+ unsubscribesControl = [];
493
+ unsubscribes.forEach(f => f());
494
+ unsubscribes = [];
495
+ app.setPluginStatus('Plugin stopped');
496
+ }; // end plugin.stop
497
+
498
+
499
+ return plugin;
500
+ };
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "signalk-to-noforeignland",
3
+ "version": "0.1.5",
4
+ "description": "SignalK track logger to noforeignland.com",
5
+ "main": "index.js",
6
+ "keywords": [
7
+ "signalk-node-server-plugin",
8
+ "signalk-category-utility"
9
+ ],
10
+ "author": "Ian Miller",
11
+ "homepage": "https://github.com/noforeignland/nfl-signalk",
12
+ "bugs": {
13
+ "url": "https://github.com/noforeignland/nfl-signalk/issues"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/noforeignland/nfl-signalk.git"
18
+ },
19
+ "scripts": {
20
+ "test": "echo \"Error: no test specified\" && exit 1"
21
+ },
22
+ "dependencies": {
23
+ "cron": "^2.1.0",
24
+ "fs-extra": "^10.1.0",
25
+ "is-reachable": "^5.2.1",
26
+ "node-fetch": "^2.6.7",
27
+ "nodemailer": "^6.8.0"
28
+ }
29
+ }
package/sendEmail.js ADDED
@@ -0,0 +1,24 @@
1
+ const nodemailer = require('nodemailer');
2
+
3
+ async function sendEmail(options) {
4
+ const mail = nodemailer.createTransport({
5
+ service: options.emailService,
6
+ auth: {
7
+ user: options.user,
8
+ pass: options.password,
9
+ }
10
+ });
11
+ var mailOptions = {
12
+ from: options.from,
13
+ to: options.to,
14
+ subject: `GPX ${(new Date()).toISOString()}`,
15
+ text: 'Generated by SignalK Tracker',
16
+ attachments: [{ // file on disk as an attachment
17
+ filename: 'track.gpx',
18
+ path: options.trackFile // stream this file
19
+ }],
20
+ };
21
+ await mail.sendMail(mailOptions);
22
+ }
23
+
24
+ module.exports = sendEmail;