@apocaliss92/scrypted-reolink-native 0.5.15 → 0.5.21

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.
@@ -0,0 +1,583 @@
1
+ /**
2
+ * Email Push Server — Scrypted singleton device exposing the manager-
3
+ * side SMTP intake from `@apocaliss92/nodelink-js`.
4
+ *
5
+ * - Owns the lib's `createEmailPushServer` instance for this plugin
6
+ * process.
7
+ * - Resolves incoming RCPT TO (`cam-<nativeId>@<domain>`) to a real
8
+ * Scrypted device (`ReolinkCamera`) by walking the plugin's
9
+ * `camerasMap`.
10
+ * - Stores config + per-camera credentials in Scrypted's storage:
11
+ * - port, bindHost, domain, requireAuth, authUsername, authPassword,
12
+ * tls, tlsDir, maxMessageBytes, enabled
13
+ * - Random credentials are generated on first boot.
14
+ * - Provides `Auto-configure all cameras` button: iterates every
15
+ * ReolinkCamera under the provider and pushes the manager-side SMTP
16
+ * settings via `api.setupEmailPushToManager` (lib auto-wraps the
17
+ * bare username in `<user@domain>` for the camera-side MAIL FROM).
18
+ *
19
+ * Per-camera event wiring lives in `camera.ts` — each ReolinkCamera
20
+ * calls `api.subscribeEmailPushEvents({ cameraId: this.nativeId })`
21
+ * once its api is ready, so the existing `api.onSimpleEvent` listener
22
+ * that flips `motionDetected` true automatically handles both native
23
+ * and SMTP-delivered events.
24
+ */
25
+
26
+ import os from "node:os";
27
+ import { randomBytes } from "node:crypto";
28
+ import {
29
+ ScryptedDeviceBase,
30
+ ScryptedInterface,
31
+ Setting,
32
+ Settings,
33
+ SettingValue,
34
+ } from "@scrypted/sdk";
35
+ // Type-only imports: the plugin builds against CommonJS while
36
+ // nodelink-js ships ESM. Values are loaded via dynamic import below.
37
+ import type {
38
+ EmailPushServerConfig,
39
+ EmailPushServerInstance,
40
+ } from "@apocaliss92/nodelink-js" with {
41
+ "resolution-mode": "import",
42
+ };
43
+ import type ReolinkNativePlugin from "./main";
44
+
45
+ export const EMAIL_PUSH_SERVER_NATIVE_ID = "email-push-server";
46
+
47
+ const DEFAULT_PORT = 2525;
48
+ const DEFAULT_DOMAIN = "nodelink.local";
49
+ const DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
50
+
51
+ interface RecentMailRow {
52
+ cameraId: string;
53
+ recipient: string;
54
+ inferredType: string;
55
+ receivedAtMs: number;
56
+ subject: string;
57
+ from: string;
58
+ bodyExcerpt: string;
59
+ }
60
+
61
+ export class EmailPushServerDevice
62
+ extends ScryptedDeviceBase
63
+ implements Settings
64
+ {
65
+ plugin!: ReolinkNativePlugin;
66
+ private instance: EmailPushServerInstance | undefined;
67
+
68
+ constructor(nativeId: string) {
69
+ super(nativeId);
70
+ // Bootstrap missing credentials on first construction so the user
71
+ // never sees blank/insecure defaults in Settings.
72
+ if (!this.storage.getItem("authUsername")) {
73
+ this.storage.setItem(
74
+ "authUsername",
75
+ `nodelink-${randomBytes(4).toString("hex")}`,
76
+ );
77
+ }
78
+ if (!this.storage.getItem("authPassword")) {
79
+ this.storage.setItem(
80
+ "authPassword",
81
+ randomBytes(18).toString("base64url"),
82
+ );
83
+ }
84
+ }
85
+
86
+ /** Called by the plugin on boot. Starts the SMTP intake if enabled. */
87
+ async start(): Promise<void> {
88
+ this.online = true;
89
+ if (this.getEnabled()) {
90
+ try {
91
+ const inst = await this.ensureInstance();
92
+ await inst.start();
93
+ } catch (e) {
94
+ this.console.error(
95
+ `Email push server failed to start: ${e instanceof Error ? e.message : e}`,
96
+ );
97
+ this.online = false;
98
+ }
99
+ }
100
+ }
101
+
102
+ /** Called by the plugin on disposal. */
103
+ async stop(): Promise<void> {
104
+ if (this.instance) {
105
+ await this.instance.stop().catch(() => {});
106
+ }
107
+ this.online = false;
108
+ }
109
+
110
+ private async ensureInstance(): Promise<EmailPushServerInstance> {
111
+ if (this.instance) {
112
+ this.instance.updateConfig(this.buildConfig());
113
+ return this.instance;
114
+ }
115
+ const { createEmailPushServer } = await import("@apocaliss92/nodelink-js");
116
+ this.instance = createEmailPushServer({
117
+ config: this.buildConfig(),
118
+ cameraResolver: (local) => this.resolveCameraId(local),
119
+ logger: {
120
+ debug: (m) => this.console.log(`[debug] ${m}`),
121
+ info: (m) => this.console.log(m),
122
+ warn: (m) => this.console.warn(m),
123
+ error: (m) => this.console.error(m),
124
+ },
125
+ });
126
+ return this.instance;
127
+ }
128
+
129
+ private buildConfig(): EmailPushServerConfig {
130
+ return {
131
+ port: Number(this.storage.getItem("port") || DEFAULT_PORT),
132
+ bindHost: this.storage.getItem("bindHost") || "0.0.0.0",
133
+ domain: this.storage.getItem("domain") || DEFAULT_DOMAIN,
134
+ requireAuth: this.storage.getItem("requireAuth") !== "false",
135
+ authUsername: this.storage.getItem("authUsername") || "",
136
+ authPassword: this.storage.getItem("authPassword") || "",
137
+ tls: this.storage.getItem("tls") === "true",
138
+ tlsDir: this.storage.getItem("tlsDir") || "./email-push-tls",
139
+ maxMessageBytes: Number(
140
+ this.storage.getItem("maxMessageBytes") || DEFAULT_MAX_BYTES,
141
+ ),
142
+ };
143
+ }
144
+
145
+ private getEnabled(): boolean {
146
+ return this.storage.getItem("enabled") !== "false";
147
+ }
148
+
149
+ /**
150
+ * `camerasMap` is keyed by Scrypted's internal device UUID
151
+ * (`cam.id`), NOT by `nativeId`. The e-mail pipeline however
152
+ * identifies cameras by `nativeId` (recipient local-part = the
153
+ * `cam-<nativeId>` we feed to `setupEmailPushToManager`), so any
154
+ * lookup driven by an SMTP recipient or by a bus event needs to
155
+ * walk the map's values and match on `cam.nativeId`.
156
+ */
157
+ private findCameraByNativeId(
158
+ nativeId: string,
159
+ ): ReolinkNativePlugin["camerasMap"] extends Map<string, infer C>
160
+ ? C | undefined
161
+ : never {
162
+ for (const cam of this.plugin.camerasMap.values()) {
163
+ if (cam.nativeId === nativeId) return cam as never;
164
+ }
165
+ return undefined as never;
166
+ }
167
+
168
+ /**
169
+ * Match RCPT localpart (`cam-<nativeId>`) against the plugin's known
170
+ * camera nativeIds. Returning the nativeId verbatim is what the lib
171
+ * uses as the cameraId in the event payload; the per-camera
172
+ * `subscribeEmailPushEvents({cameraId: this.nativeId})` filter in
173
+ * camera.ts matches on equality.
174
+ *
175
+ * NVR-attached cameras are skipped — they don't have an independent
176
+ * SMTP path (the NVR handles e-mail centrally), so accepting one
177
+ * here would silently mis-route alerts. The Settings UI hides them
178
+ * from the per-camera Auto-configure select for the same reason.
179
+ */
180
+ private resolveCameraId(local: string): string | undefined {
181
+ const cam = this.findCameraByNativeId(local);
182
+ if (!cam || cam.isOnNvr) return undefined;
183
+ return local;
184
+ }
185
+
186
+ /**
187
+ * Public accessor used by `ReolinkCamera` when the camera-side
188
+ * Settings panel calls `Auto-configure from Email Push Server`.
189
+ * Returns `null` when the server isn't yet configured (no LAN
190
+ * address resolvable, missing storage) — the caller should surface
191
+ * a user-friendly error instead of throwing.
192
+ */
193
+ public getManagerSetupParamsForCamera(props: {
194
+ nativeId: string;
195
+ id: string;
196
+ }): {
197
+ managerHost: string;
198
+ managerPort: number;
199
+ domain: string;
200
+ recipientLocalPart: string;
201
+ sendNickname: string;
202
+ authUsername?: string;
203
+ authPassword?: string;
204
+ } | null {
205
+ const { nativeId, id } = props;
206
+ // Lookup by Scrypted device id — that's how `camerasMap` is keyed
207
+ // (see `camera.ts:837 — camerasMap.set(this.id, this)`).
208
+ const cam = this.plugin.camerasMap.get(id);
209
+ if (!cam || cam.isOnNvr) return null;
210
+ const cfg = this.buildConfig();
211
+ const managerHost = this.listLanHosts()[0];
212
+ if (!managerHost) return null;
213
+ return {
214
+ managerHost,
215
+ managerPort: cfg.port,
216
+ domain: cfg.domain,
217
+ recipientLocalPart: nativeId,
218
+ sendNickname: cam.name ?? nativeId,
219
+ ...(cfg.requireAuth
220
+ ? {
221
+ authUsername: cfg.authUsername,
222
+ authPassword: cfg.authPassword,
223
+ }
224
+ : {}),
225
+ };
226
+ }
227
+
228
+ // ─── Settings ──────────────────────────────────────────────────
229
+
230
+ async getSettings(): Promise<Setting[]> {
231
+ const cfg = this.buildConfig();
232
+ const status = this.instance?.getStatus();
233
+ const candidates = this.listLanHosts();
234
+ const recommendedHost = candidates[0] ?? "127.0.0.1";
235
+ // Pull the latest from the in-memory ring before rendering.
236
+ await this.refreshRecentEventsCache();
237
+
238
+ return [
239
+ {
240
+ group: "Server",
241
+ key: "enabled",
242
+ title: "Enabled",
243
+ description:
244
+ "Start the SMTP intake on plugin load. When off the server is stopped and no e-mails are received.",
245
+ type: "boolean",
246
+ value: this.getEnabled(),
247
+ },
248
+ {
249
+ group: "Server",
250
+ key: "status",
251
+ title: "Status",
252
+ type: "string",
253
+ readonly: true,
254
+ value: status
255
+ ? `${status.running ? "Running" : "Stopped"} on ${status.bindHost}:${status.port} (auth=${status.requireAuth}, tls=${status.tls}) — accepted=${status.messagesAccepted} rejected=${status.messagesRejected}`
256
+ : "Not initialised",
257
+ },
258
+ {
259
+ group: "Server",
260
+ key: "managerHost",
261
+ title: "Recommended camera-facing host",
262
+ description:
263
+ "Auto-detected LAN address of this Scrypted host. Use this value as the SMTP server on the camera side (or hit Auto-configure below).",
264
+ type: "string",
265
+ readonly: true,
266
+ value: recommendedHost,
267
+ },
268
+ {
269
+ group: "Server",
270
+ key: "port",
271
+ title: "Port",
272
+ description: "Default 2525. Avoid privileged 25.",
273
+ type: "number",
274
+ value: cfg.port,
275
+ },
276
+ {
277
+ group: "Server",
278
+ key: "bindHost",
279
+ title: "Bind host",
280
+ description: "0.0.0.0 to listen on every interface.",
281
+ type: "string",
282
+ value: cfg.bindHost,
283
+ },
284
+ {
285
+ group: "Server",
286
+ key: "domain",
287
+ title: "Virtual domain",
288
+ description:
289
+ "Used to extract the camera id from the recipient (cam-<id>@<domain>).",
290
+ type: "string",
291
+ value: cfg.domain,
292
+ },
293
+ {
294
+ group: "Server",
295
+ key: "maxMessageBytes",
296
+ title: "Max message bytes",
297
+ type: "number",
298
+ value: cfg.maxMessageBytes,
299
+ },
300
+ {
301
+ group: "Auth",
302
+ key: "requireAuth",
303
+ title: "Require AUTH",
304
+ description:
305
+ "Reolink firmwares are noticeably more reliable when AUTH PLAIN is negotiated. Recommended on.",
306
+ type: "boolean",
307
+ value: cfg.requireAuth,
308
+ },
309
+ {
310
+ group: "Auth",
311
+ key: "authUsername",
312
+ title: "Auth username",
313
+ description:
314
+ "Bare value. The lib auto-wraps with @<domain> when configuring the camera so MAIL FROM stays RFC-compliant.",
315
+ type: "string",
316
+ value: cfg.authUsername,
317
+ },
318
+ {
319
+ group: "Auth",
320
+ key: "authPassword",
321
+ title: "Auth password",
322
+ type: "password",
323
+ value: cfg.authPassword,
324
+ },
325
+ {
326
+ group: "Auth",
327
+ key: "regenerateAuth",
328
+ title: "Regenerate random credentials",
329
+ description:
330
+ "Replaces the username + password with fresh random values. You'll need to re-run Auto-configure on the cameras after this.",
331
+ type: "button",
332
+ },
333
+ {
334
+ group: "TLS",
335
+ key: "tls",
336
+ title: "Enable STARTTLS",
337
+ description:
338
+ "Loads cert.pem + key.pem from the TLS directory. Disable if you don't have valid cert material.",
339
+ type: "boolean",
340
+ value: cfg.tls,
341
+ },
342
+ {
343
+ group: "TLS",
344
+ key: "tlsDir",
345
+ title: "TLS directory",
346
+ description: "Where the server looks for cert.pem and key.pem.",
347
+ type: "string",
348
+ value: cfg.tlsDir ?? "./email-push-tls",
349
+ },
350
+ {
351
+ group: "Actions",
352
+ key: "restart",
353
+ title: "Restart server",
354
+ description:
355
+ "Stops and re-starts the SMTP intake with the latest settings.",
356
+ type: "button",
357
+ },
358
+ {
359
+ group: "Actions",
360
+ key: "autoConfigureCamera",
361
+ title: "Auto-configure a camera",
362
+ description:
363
+ "Pick a camera to push these server settings to (host, port, AUTH, recipient, 24/7 trigger schedule). Applies one camera at a time — pick again to configure the next. The selector resets after each apply so it never silently re-runs.",
364
+ type: "string",
365
+ // Force the empty value on every render so the select always
366
+ // resets after an apply — Scrypted re-fetches settings on save.
367
+ value: "",
368
+ immediate: true,
369
+ choices: ["", ...this.listCameraChoices()],
370
+ },
371
+ {
372
+ group: "Recent e-mails (last 20, in-memory)",
373
+ key: "recentEmails",
374
+ title: "Last received",
375
+ readonly: true,
376
+ type: "string",
377
+ value: this.recentEventsCache,
378
+ },
379
+ ];
380
+ }
381
+
382
+ async putSetting(key: string, value: SettingValue): Promise<void> {
383
+ switch (key) {
384
+ case "restart":
385
+ try {
386
+ const inst = await this.ensureInstance();
387
+ await inst.restart();
388
+ } catch (e) {
389
+ this.console.error(
390
+ `Restart failed: ${e instanceof Error ? e.message : e}`,
391
+ );
392
+ }
393
+ break;
394
+ case "autoConfigureCamera": {
395
+ const targetNativeId = String(value ?? "").trim();
396
+ if (!targetNativeId) break;
397
+ await this.autoConfigureCamera(targetNativeId);
398
+ // The select is rendered with `value: ""` on every getSettings,
399
+ // so the next Settings refresh shows the empty placeholder again.
400
+ break;
401
+ }
402
+ case "regenerateAuth":
403
+ this.storage.setItem(
404
+ "authUsername",
405
+ `nodelink-${randomBytes(4).toString("hex")}`,
406
+ );
407
+ this.storage.setItem(
408
+ "authPassword",
409
+ randomBytes(18).toString("base64url"),
410
+ );
411
+ if (this.instance) await this.instance.restart().catch(() => {});
412
+ break;
413
+ case "enabled": {
414
+ const enabled = value === true || value === "true";
415
+ this.storage.setItem("enabled", String(enabled));
416
+ if (enabled) {
417
+ try {
418
+ const inst = await this.ensureInstance();
419
+ await inst.start();
420
+ } catch (e) {
421
+ this.console.error(
422
+ `Start failed: ${e instanceof Error ? e.message : e}`,
423
+ );
424
+ }
425
+ } else if (this.instance) {
426
+ await this.instance.stop().catch(() => {});
427
+ }
428
+ break;
429
+ }
430
+ case "port":
431
+ case "maxMessageBytes":
432
+ this.storage.setItem(key, String(Number(value) || 0));
433
+ if (this.instance) await this.instance.restart().catch(() => {});
434
+ break;
435
+ case "requireAuth":
436
+ case "tls":
437
+ this.storage.setItem(
438
+ key,
439
+ value === true || value === "true" ? "true" : "false",
440
+ );
441
+ if (this.instance) await this.instance.restart().catch(() => {});
442
+ break;
443
+ case "bindHost":
444
+ case "domain":
445
+ case "authUsername":
446
+ case "authPassword":
447
+ case "tlsDir":
448
+ this.storage.setItem(key, String(value ?? ""));
449
+ if (this.instance) await this.instance.restart().catch(() => {});
450
+ break;
451
+ }
452
+ this.onDeviceEvent(ScryptedInterface.Settings, undefined);
453
+ }
454
+
455
+ /** Cached snapshot of the bus's recent events, refreshed on each Settings GET. */
456
+ private recentEventsCache: string = "(loading…)";
457
+
458
+ private async refreshRecentEventsCache(): Promise<void> {
459
+ try {
460
+ const { getRecentEmailPushEvents } =
461
+ await import("@apocaliss92/nodelink-js");
462
+ const events = getRecentEmailPushEvents(20);
463
+ if (events.length === 0) {
464
+ this.recentEventsCache = "No e-mails received yet.";
465
+ return;
466
+ }
467
+ this.recentEventsCache = events
468
+ .map((e: RecentMailRow) => {
469
+ const ts = new Date(e.receivedAtMs).toISOString();
470
+ // `e.cameraId` is the nativeId from the recipient local-part,
471
+ // so we have to walk by nativeId to recover a friendly name.
472
+ const name =
473
+ this.findCameraByNativeId(e.cameraId)?.name ?? e.cameraId;
474
+ return `${ts} ${e.inferredType.padEnd(8)} ${name} — ${e.subject || "(no subject)"}`;
475
+ })
476
+ .join("\n");
477
+ } catch (e) {
478
+ this.recentEventsCache = `(error reading recent events: ${e instanceof Error ? e.message : e})`;
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Build the dropdown options for the per-camera Auto-configure select.
484
+ * Format: `"<nativeId> — <displayName>"` — `nativeId` is the
485
+ * camera's plugin-side identifier (NOT the Scrypted UUID) so it
486
+ * matches what the SMTP pipeline expects as the recipient local-part
487
+ * and what `setupEmailPushToManager` writes back to the camera.
488
+ */
489
+ private listCameraChoices(): string[] {
490
+ const out: string[] = [];
491
+ for (const cam of this.plugin.camerasMap.values()) {
492
+ // NVR-attached cameras don't have an independent SMTP path —
493
+ // exclude them so users can't pick something that will silently
494
+ // fail at delivery time.
495
+ if (cam.isOnNvr) continue;
496
+ if (!cam.nativeId) continue;
497
+ const label = cam.name
498
+ ? `${cam.nativeId} — ${cam.name}`
499
+ : cam.nativeId;
500
+ out.push(label);
501
+ }
502
+ return out.sort();
503
+ }
504
+
505
+ /**
506
+ * Push the manager-side SMTP config to a single camera. The selector
507
+ * value comes back as `"<nativeId> — <name>"`; we strip the label
508
+ * suffix to recover the `nativeId`, then walk `camerasMap.values()`
509
+ * to find the matching camera (the map is keyed by Scrypted UUID,
510
+ * not nativeId — see `findCameraByNativeId`). The lib auto-wraps
511
+ * `authUsername` in `<user@domain>` so the camera-side MAIL FROM is
512
+ * RFC-compliant.
513
+ */
514
+ private async autoConfigureCamera(selection: string): Promise<void> {
515
+ const nativeId = selection.split(/\s+—\s+/, 1)[0]?.trim();
516
+ if (!nativeId) {
517
+ this.console.warn(`Auto-configure: empty selection.`);
518
+ return;
519
+ }
520
+ const cam = this.findCameraByNativeId(nativeId);
521
+ if (!cam) {
522
+ this.console.warn(
523
+ `Auto-configure: camera not found for nativeId="${nativeId}".`,
524
+ );
525
+ return;
526
+ }
527
+ const cfg = this.buildConfig();
528
+ const managerHost = this.listLanHosts()[0] ?? "127.0.0.1";
529
+ this.console.log(
530
+ `Auto-configuring ${cam.name ?? nativeId} against ${managerHost}:${cfg.port}`,
531
+ );
532
+ try {
533
+ const api = await cam.ensureClient();
534
+ await api.setupEmailPushToManager(
535
+ {
536
+ managerHost,
537
+ managerPort: cfg.port,
538
+ domain: cfg.domain,
539
+ recipientLocalPart: nativeId,
540
+ sendNickname: cam.name ?? nativeId,
541
+ attachmentType: "picture",
542
+ triggerTypes: ["MD", "people", "vehicle"],
543
+ ...(cfg.requireAuth
544
+ ? {
545
+ authUsername: cfg.authUsername,
546
+ authPassword: cfg.authPassword,
547
+ }
548
+ : {}),
549
+ runTest: false,
550
+ },
551
+ cam.storageSettings?.values?.rtspChannel ?? 0,
552
+ );
553
+ this.console.log(` ✓ ${cam.name ?? nativeId}`);
554
+ } catch (e) {
555
+ this.console.warn(
556
+ ` ✗ ${cam.name ?? nativeId}: ${e instanceof Error ? e.message : e}`,
557
+ );
558
+ }
559
+ }
560
+
561
+ private listLanHosts(): string[] {
562
+ const ifaces = os.networkInterfaces();
563
+ const candidates: { addr: string; rank: number }[] = [];
564
+ for (const list of Object.values(ifaces)) {
565
+ if (!list) continue;
566
+ for (const addr of list) {
567
+ if (addr.family !== "IPv4") continue;
568
+ if (addr.internal) continue;
569
+ if (addr.address.startsWith("169.254.")) continue;
570
+ const rank = addr.address.startsWith("192.168.")
571
+ ? 0
572
+ : addr.address.startsWith("10.")
573
+ ? 1
574
+ : /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(addr.address)
575
+ ? 2
576
+ : 3;
577
+ candidates.push({ addr: addr.address, rank });
578
+ }
579
+ }
580
+ candidates.sort((a, b) => a.rank - b.rank);
581
+ return candidates.map((c) => c.addr);
582
+ }
583
+ }