sendscore 0.0.0-stage → 0.3.0
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/LICENSE +21 -0
- package/README.md +37 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +34 -0
- package/dist/download.d.ts +28 -0
- package/dist/download.js +152 -0
- package/dist/error.d.ts +8 -0
- package/dist/error.js +12 -0
- package/dist/hashes.d.ts +4 -0
- package/dist/hashes.js +9 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +176 -0
- package/dist/types.d.ts +96 -0
- package/dist/types.js +2 -0
- package/package.json +37 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SendScore
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
-
#
|
|
1
|
+
# SendScore for TypeScript and JavaScript
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A typed wrapper around the [SendScore](https://github.com/sendscore/sendscore) CLI, which checks a newsletter email before it goes out. Every language package runs the same binary, so they all return the same verdict.
|
|
4
|
+
|
|
5
|
+
Not published yet.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install sendscore
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
It also gives you the `sendscore` command, which takes the same arguments and exit codes as the CLI.
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npx sendscore check issue.eml
|
|
15
|
+
npm install -g sendscore
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { check, diff } from "sendscore";
|
|
20
|
+
|
|
21
|
+
const verdict = await check("issue.eml");
|
|
22
|
+
if (!verdict.ready_to_send) {
|
|
23
|
+
for (const fix of verdict.top_fixes) console.log(fix.who, fix.fix);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
await check(rawBytes, { offline: true });
|
|
27
|
+
await diff("before.eml", "after.eml");
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
A message that fails its checks is still a report, so `check` returns it. `SendScoreError` is thrown only when no report could be made, and its `exitCode` is the CLI's. Receipt at SendScore does not prove inbox placement at Gmail or any other provider.
|
|
31
|
+
|
|
32
|
+
Pass `timeout` in milliseconds to `check` or `diff` to kill the CLI when it runs out. A killed CLI raises `SendScoreError` with `exitCode` set to `KILLED`, which is -1.
|
|
33
|
+
|
|
34
|
+
## The binary
|
|
35
|
+
|
|
36
|
+
The binary installs with the package. `npm install sendscore` also installs one `@sendscore/cli-<os>-<arch>` package for your machine, through `optionalDependencies`, on macOS, Linux, and Windows, for x64 and arm64. Nothing runs at install time, and nothing is downloaded when you use it.
|
|
37
|
+
|
|
38
|
+
The package uses `SENDSCORE_BIN`, then that bundled binary, then the CLI on `PATH` if its version matches, then your user cache. If you install with optional dependencies turned off, the first call downloads the pinned release from GitHub, and checks it against the SHA-256 values in the package before it runs anything, including a cached binary.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The sendscore command: finds the CLI the same way the library does, then
|
|
3
|
+
// runs it with this process's arguments, streams, and exit code.
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { constants } from "node:os";
|
|
6
|
+
import { binary } from "./index.js";
|
|
7
|
+
// EX_SOFTWARE, as the CLI uses for an internal error.
|
|
8
|
+
const INTERNAL = 70;
|
|
9
|
+
// The PATH search may find this launcher and run it to read its version. The
|
|
10
|
+
// mark makes that run fail at once, so the search moves on instead of looping.
|
|
11
|
+
const MARK = "SENDSCORE_LAUNCHED";
|
|
12
|
+
function fail(message) {
|
|
13
|
+
process.stderr.write(`sendscore: ${message}\n`);
|
|
14
|
+
process.exit(INTERNAL);
|
|
15
|
+
}
|
|
16
|
+
if (process.env[MARK])
|
|
17
|
+
fail("found itself instead of the CLI");
|
|
18
|
+
process.env[MARK] = "1";
|
|
19
|
+
let path;
|
|
20
|
+
try {
|
|
21
|
+
path = await binary();
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
fail(e.message);
|
|
25
|
+
}
|
|
26
|
+
const child = spawn(path, process.argv.slice(2), { stdio: "inherit", shell: false, windowsHide: false });
|
|
27
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
28
|
+
process.on(signal, () => child.kill(signal));
|
|
29
|
+
}
|
|
30
|
+
child.on("error", (e) => fail(`could not run ${path}: ${e.message}`));
|
|
31
|
+
child.on("exit", (code, signal) => {
|
|
32
|
+
// A shell reports a process killed by a signal as 128 plus its number.
|
|
33
|
+
process.exit(code ?? 128 + (signal ? constants.signals[signal] : 0));
|
|
34
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export declare const RELEASES = "https://github.com/sendscore/sendscore/releases/download";
|
|
2
|
+
export interface Platform {
|
|
3
|
+
os: string;
|
|
4
|
+
arch: string;
|
|
5
|
+
}
|
|
6
|
+
/** The SHA-256 of a release archive and of the binary inside it. */
|
|
7
|
+
export interface Pin {
|
|
8
|
+
archive: string;
|
|
9
|
+
binary: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function currentPlatform(): Platform;
|
|
12
|
+
export declare function cacheRoot(): string;
|
|
13
|
+
/** The pin for a platform. With none, nothing may be downloaded or trusted from the cache. */
|
|
14
|
+
export declare function pinFor(p: Platform, pins?: Record<string, Pin>): Pin;
|
|
15
|
+
/** Whether a file exists and is exactly the pinned binary. */
|
|
16
|
+
export declare function matchesPin(path: string, pin: Pin): Promise<boolean>;
|
|
17
|
+
/** The name of the release archive for a version and platform. */
|
|
18
|
+
export declare function archiveName(version: string, p: Platform): string;
|
|
19
|
+
/** Reads one file, by its exact name, from a tar archive. Nothing else is touched. */
|
|
20
|
+
export declare function fromTar(tar: Buffer, name: string): Buffer | undefined;
|
|
21
|
+
/** Reads one file, by its exact name, from a zip archive. Nothing else is touched. */
|
|
22
|
+
export declare function fromZip(zip: Buffer, name: string): Buffer | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Downloads a release into dir, and returns the binary's path. The archive
|
|
25
|
+
* must match its pin before anything is extracted, and the binary must match
|
|
26
|
+
* its pin before it reaches the disk.
|
|
27
|
+
*/
|
|
28
|
+
export declare function download(version: string, base: string, dir: string, pin: Pin, p?: Platform): Promise<string>;
|
package/dist/download.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { isAbsolute, join } from "node:path";
|
|
5
|
+
import { gunzipSync, inflateRawSync } from "node:zlib";
|
|
6
|
+
import { SendScoreError } from "./error.js";
|
|
7
|
+
import { HASHES } from "./hashes.js";
|
|
8
|
+
export const RELEASES = "https://github.com/sendscore/sendscore/releases/download";
|
|
9
|
+
const MAX_DOWNLOAD = 100 * 1024 * 1024;
|
|
10
|
+
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
11
|
+
const OS = { linux: "linux", darwin: "darwin", win32: "windows" };
|
|
12
|
+
const ARCH = { x64: "amd64", arm64: "arm64" };
|
|
13
|
+
export function currentPlatform() {
|
|
14
|
+
const os = OS[process.platform];
|
|
15
|
+
const arch = ARCH[process.arch];
|
|
16
|
+
if (!os || !arch) {
|
|
17
|
+
throw new SendScoreError(`no SendScore build for ${process.platform} on ${process.arch}`, 0);
|
|
18
|
+
}
|
|
19
|
+
return { os, arch };
|
|
20
|
+
}
|
|
21
|
+
export function cacheRoot() {
|
|
22
|
+
if (process.platform === "darwin")
|
|
23
|
+
return join(homedir(), "Library", "Caches");
|
|
24
|
+
if (process.platform === "win32")
|
|
25
|
+
return process.env["LOCALAPPDATA"] || join(homedir(), "AppData", "Local");
|
|
26
|
+
const xdg = process.env["XDG_CACHE_HOME"];
|
|
27
|
+
return xdg && isAbsolute(xdg) ? xdg : join(homedir(), ".cache");
|
|
28
|
+
}
|
|
29
|
+
/** The pin for a platform. With none, nothing may be downloaded or trusted from the cache. */
|
|
30
|
+
export function pinFor(p, pins = HASHES) {
|
|
31
|
+
const pin = pins[`${p.os}_${p.arch}`];
|
|
32
|
+
if (!pin) {
|
|
33
|
+
throw new SendScoreError(`this package has no pinned hash for ${p.os}_${p.arch}, so it will not download a CLI. Install the matching sendscore on PATH, or set SENDSCORE_BIN.`, 0);
|
|
34
|
+
}
|
|
35
|
+
return pin;
|
|
36
|
+
}
|
|
37
|
+
function sha256(bytes) {
|
|
38
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
39
|
+
}
|
|
40
|
+
/** Whether a file exists and is exactly the pinned binary. */
|
|
41
|
+
export async function matchesPin(path, pin) {
|
|
42
|
+
try {
|
|
43
|
+
return sha256(await readFile(path)) === pin.binary;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** The name of the release archive for a version and platform. */
|
|
50
|
+
export function archiveName(version, p) {
|
|
51
|
+
return `sendscore_${version}_${p.os}_${p.arch}.${p.os === "windows" ? "zip" : "tar.gz"}`;
|
|
52
|
+
}
|
|
53
|
+
async function fetchBytes(url) {
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
56
|
+
if (!res.ok || !res.body)
|
|
57
|
+
throw new SendScoreError(`download of ${url} answered ${res.status}`, 0);
|
|
58
|
+
const parts = [];
|
|
59
|
+
let size = 0;
|
|
60
|
+
for await (const part of res.body) {
|
|
61
|
+
size += part.length;
|
|
62
|
+
if (size > MAX_DOWNLOAD)
|
|
63
|
+
throw new SendScoreError(`download of ${url} is too large`, 0);
|
|
64
|
+
parts.push(part);
|
|
65
|
+
}
|
|
66
|
+
return Buffer.concat(parts);
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
if (e instanceof SendScoreError)
|
|
70
|
+
throw e;
|
|
71
|
+
throw new SendScoreError(`download of ${url} failed: ${e.message}`, 0);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Reads one file, by its exact name, from a tar archive. Nothing else is touched. */
|
|
75
|
+
export function fromTar(tar, name) {
|
|
76
|
+
for (let at = 0; at + 512 <= tar.length;) {
|
|
77
|
+
const header = tar.subarray(at, at + 512);
|
|
78
|
+
if (header.every((b) => b === 0))
|
|
79
|
+
return undefined;
|
|
80
|
+
const end = header.indexOf(0);
|
|
81
|
+
const entry = header.subarray(0, end < 0 || end > 100 ? 100 : end).toString();
|
|
82
|
+
const size = parseInt(header.subarray(124, 136).toString().replace(/\0.*$/, "").trim() || "0", 8);
|
|
83
|
+
const type = header[156];
|
|
84
|
+
at += 512;
|
|
85
|
+
if (entry === name && (type === 0 || type === 48))
|
|
86
|
+
return tar.subarray(at, at + size);
|
|
87
|
+
at += Math.ceil(size / 512) * 512;
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
/** Reads one file, by its exact name, from a zip archive. Nothing else is touched. */
|
|
92
|
+
export function fromZip(zip, name) {
|
|
93
|
+
let end = zip.length - 22;
|
|
94
|
+
while (end >= 0 && zip.readUInt32LE(end) !== 0x06054b50)
|
|
95
|
+
end--;
|
|
96
|
+
if (end < 0)
|
|
97
|
+
return undefined;
|
|
98
|
+
const count = zip.readUInt16LE(end + 10);
|
|
99
|
+
let at = zip.readUInt32LE(end + 16);
|
|
100
|
+
for (let i = 0; i < count; i++) {
|
|
101
|
+
const method = zip.readUInt16LE(at + 10);
|
|
102
|
+
const packed = zip.readUInt32LE(at + 20);
|
|
103
|
+
const nameLen = zip.readUInt16LE(at + 28);
|
|
104
|
+
const extraLen = zip.readUInt16LE(at + 30);
|
|
105
|
+
const commentLen = zip.readUInt16LE(at + 32);
|
|
106
|
+
const local = zip.readUInt32LE(at + 42);
|
|
107
|
+
if (zip.subarray(at + 46, at + 46 + nameLen).toString() === name) {
|
|
108
|
+
const start = local + 30 + zip.readUInt16LE(local + 26) + zip.readUInt16LE(local + 28);
|
|
109
|
+
const data = zip.subarray(start, start + packed);
|
|
110
|
+
return method === 0 ? data : inflateRawSync(data);
|
|
111
|
+
}
|
|
112
|
+
at += 46 + nameLen + extraLen + commentLen;
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Downloads a release into dir, and returns the binary's path. The archive
|
|
118
|
+
* must match its pin before anything is extracted, and the binary must match
|
|
119
|
+
* its pin before it reaches the disk.
|
|
120
|
+
*/
|
|
121
|
+
export async function download(version, base, dir, pin, p = currentPlatform()) {
|
|
122
|
+
const binName = p.os === "windows" ? "sendscore.exe" : "sendscore";
|
|
123
|
+
const target = join(dir, binName);
|
|
124
|
+
const name = archiveName(version, p);
|
|
125
|
+
const archive = await fetchBytes(`${base}/${name}`);
|
|
126
|
+
if (sha256(archive) !== pin.archive) {
|
|
127
|
+
throw new SendScoreError(`${name} does not match its pinned hash`, 0);
|
|
128
|
+
}
|
|
129
|
+
const binary = p.os === "windows" ? fromZip(archive, binName) : fromTar(gunzipSync(archive), binName);
|
|
130
|
+
if (!binary)
|
|
131
|
+
throw new SendScoreError(`${name} has no ${binName}`, 0);
|
|
132
|
+
if (sha256(binary) !== pin.binary) {
|
|
133
|
+
throw new SendScoreError(`the binary in ${name} does not match its pinned hash`, 0);
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
137
|
+
const temp = await mkdtemp(join(dir, ".download-"));
|
|
138
|
+
try {
|
|
139
|
+
const path = join(temp, binName);
|
|
140
|
+
await writeFile(path, binary, { mode: 0o755 });
|
|
141
|
+
await chmod(path, 0o755);
|
|
142
|
+
await rename(path, target);
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
await rm(temp, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
throw new SendScoreError(`could not write to ${dir}: ${e.message}`, 0);
|
|
150
|
+
}
|
|
151
|
+
return target;
|
|
152
|
+
}
|
package/dist/error.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The exitCode of a CLI that was killed, or stopped by a timeout. */
|
|
2
|
+
export declare const KILLED = -1;
|
|
3
|
+
/** The CLI could not produce a report, or could not be found or installed. */
|
|
4
|
+
export declare class SendScoreError extends Error {
|
|
5
|
+
/** The CLI's exit code: 64 and above, 0 when the CLI never ran, or KILLED. */
|
|
6
|
+
readonly exitCode: number;
|
|
7
|
+
constructor(message: string, exitCode: number);
|
|
8
|
+
}
|
package/dist/error.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The exitCode of a CLI that was killed, or stopped by a timeout. */
|
|
2
|
+
export const KILLED = -1;
|
|
3
|
+
/** The CLI could not produce a report, or could not be found or installed. */
|
|
4
|
+
export class SendScoreError extends Error {
|
|
5
|
+
/** The CLI's exit code: 64 and above, 0 when the CLI never ran, or KILLED. */
|
|
6
|
+
exitCode;
|
|
7
|
+
constructor(message, exitCode) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "SendScoreError";
|
|
10
|
+
this.exitCode = exitCode;
|
|
11
|
+
}
|
|
12
|
+
}
|
package/dist/hashes.d.ts
ADDED
package/dist/hashes.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Generated by scripts/pinhashes. Do not edit.
|
|
2
|
+
export const HASHES = {
|
|
3
|
+
darwin_amd64: { archive: "d79c0d7d6f41322caf56f2ab90b6d61f25b97109f3449d84964751e9e9a685bb", binary: "a508ff1a9bc8e63d5f79f663d5596cdd1c4cb6803d051d3c91a5a9481870dae1" },
|
|
4
|
+
darwin_arm64: { archive: "899eeac2be04f1d0619829cd0eb9fc171e358a0deb409e8dee74e3c95543bfac", binary: "4e3ea55cc7bfa9d00d4b4742720ca475e8354052008a8133b0eeea9b17e6c109" },
|
|
5
|
+
linux_amd64: { archive: "c9bee6d95e057da006c27b015697db02f8d40f5f933c2c0f8956d33adc304bc8", binary: "87a3c521bbf05516267757eb979f14bc66a2289cc5d5c86a8955186e984ff7da" },
|
|
6
|
+
linux_arm64: { archive: "69c75e7bd97f5f4e1e5f67f9eaa9efa808ef1264550385887c7222ff6370e6a1", binary: "c0d25f8d93b8b202fa1b78e4275da41fe8e637dc256b03b19eed3af38e0b2c99" },
|
|
7
|
+
windows_amd64: { archive: "68f136dfd60a370512b97080781005c7cbffc00ce318bc77f5f347ae1625265f", binary: "d2da36431144d87971a33ee9e59590f281fb84b8acf3b8814e989073e90f4866" },
|
|
8
|
+
windows_arm64: { archive: "f3f2d796add9f0690e447239931b6bd0e99ff25e6ea9553f205e88821e9b4761", binary: "b78b9923bb5a6f7f0040969162e67417292d6e3558451a5faa7de18510398862" },
|
|
9
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { pinFor } from "./download.js";
|
|
2
|
+
import type { CheckOptions, DiffOptions, DiffResult, Verdict } from "./types.js";
|
|
3
|
+
export * from "./types.js";
|
|
4
|
+
export { KILLED, SendScoreError } from "./error.js";
|
|
5
|
+
/** The CLI release this package downloads when none is installed. */
|
|
6
|
+
export declare const ENGINE_VERSION = "0.3.0";
|
|
7
|
+
/**
|
|
8
|
+
* Finds the CLI: SENDSCORE_BIN, then the binary installed with this package,
|
|
9
|
+
* then the pinned version on PATH, then the cache, then a download. A cached
|
|
10
|
+
* binary is checked against its pinned hash every time, so a file planted in
|
|
11
|
+
* the cache is replaced and never run.
|
|
12
|
+
*/
|
|
13
|
+
export declare function binary(): Promise<string>;
|
|
14
|
+
/**
|
|
15
|
+
* The binary from this platform's package, or null. npm installs only the
|
|
16
|
+
* package that matches this machine. It came with this code, so it is trusted
|
|
17
|
+
* as this code is, and needs no version check or hash.
|
|
18
|
+
*/
|
|
19
|
+
export declare function bundled(): Promise<string | null>;
|
|
20
|
+
/** The pinned version on PATH, or null. Exported for tests. */
|
|
21
|
+
export declare function onPath(): Promise<string | null>;
|
|
22
|
+
/** The cached binary, or a download, as the pins allow. Exported for tests. */
|
|
23
|
+
export declare function installed(pins?: Parameters<typeof pinFor>[1]): Promise<string>;
|
|
24
|
+
/**
|
|
25
|
+
* Checks one message, given as a path or as its bytes. A message that fails
|
|
26
|
+
* checks is still a report: this throws only when no report could be made.
|
|
27
|
+
*/
|
|
28
|
+
export declare function check(message: string | Uint8Array, options?: CheckOptions): Promise<Verdict>;
|
|
29
|
+
/** Compares two saved messages, given as paths. */
|
|
30
|
+
export declare function diff(before: string, after: string, options?: DiffOptions): Promise<DiffResult>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access, constants, stat } from "node:fs/promises";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { delimiter, dirname, isAbsolute, join } from "node:path";
|
|
5
|
+
import { RELEASES, cacheRoot, currentPlatform, download, matchesPin, pinFor } from "./download.js";
|
|
6
|
+
import { KILLED, SendScoreError } from "./error.js";
|
|
7
|
+
export * from "./types.js";
|
|
8
|
+
export { KILLED, SendScoreError } from "./error.js";
|
|
9
|
+
/** The CLI release this package downloads when none is installed. */
|
|
10
|
+
export const ENGINE_VERSION = "0.3.0";
|
|
11
|
+
const exe = process.platform === "win32" ? "sendscore.exe" : "sendscore";
|
|
12
|
+
const VERSION_TIMEOUT_MS = 10_000;
|
|
13
|
+
async function executable(path) {
|
|
14
|
+
try {
|
|
15
|
+
if (!(await stat(path)).isFile())
|
|
16
|
+
return false;
|
|
17
|
+
await access(path, constants.X_OK);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Whether a CLI reports the version this package was tested against. */
|
|
25
|
+
async function rightVersion(path) {
|
|
26
|
+
try {
|
|
27
|
+
const r = await run(path, ["--version"], undefined, VERSION_TIMEOUT_MS);
|
|
28
|
+
const version = r.stdout.trim().split(/\s+/).pop() ?? "";
|
|
29
|
+
return r.code === 0 && version.replace(/^v/, "") === ENGINE_VERSION;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Finds the CLI: SENDSCORE_BIN, then the binary installed with this package,
|
|
37
|
+
* then the pinned version on PATH, then the cache, then a download. A cached
|
|
38
|
+
* binary is checked against its pinned hash every time, so a file planted in
|
|
39
|
+
* the cache is replaced and never run.
|
|
40
|
+
*/
|
|
41
|
+
export async function binary() {
|
|
42
|
+
const fromEnv = process.env["SENDSCORE_BIN"];
|
|
43
|
+
if (fromEnv)
|
|
44
|
+
return fromEnv;
|
|
45
|
+
try {
|
|
46
|
+
return (await bundled()) ?? (await onPath()) ?? (await installed());
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
if (e instanceof SendScoreError)
|
|
50
|
+
throw e;
|
|
51
|
+
throw new SendScoreError(`could not find or install the CLI: ${e.message}`, 0);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The binary from this platform's package, or null. npm installs only the
|
|
56
|
+
* package that matches this machine. It came with this code, so it is trusted
|
|
57
|
+
* as this code is, and needs no version check or hash.
|
|
58
|
+
*/
|
|
59
|
+
export async function bundled() {
|
|
60
|
+
try {
|
|
61
|
+
const name = `@sendscore/cli-${process.platform}-${process.arch}`;
|
|
62
|
+
const path = join(dirname(createRequire(import.meta.url).resolve(`${name}/package.json`)), "bin", exe);
|
|
63
|
+
return (await executable(path)) ? path : null;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** The pinned version on PATH, or null. Exported for tests. */
|
|
70
|
+
export async function onPath() {
|
|
71
|
+
for (const dir of (process.env["PATH"] ?? "").split(delimiter)) {
|
|
72
|
+
if (!isAbsolute(dir))
|
|
73
|
+
continue;
|
|
74
|
+
const path = join(dir, exe);
|
|
75
|
+
if ((await executable(path)) && (await rightVersion(path)))
|
|
76
|
+
return path;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
/** The cached binary, or a download, as the pins allow. Exported for tests. */
|
|
81
|
+
export async function installed(pins) {
|
|
82
|
+
const platform = currentPlatform();
|
|
83
|
+
const pin = pinFor(platform, pins);
|
|
84
|
+
const dir = join(cacheRoot(), "sendscore", ENGINE_VERSION);
|
|
85
|
+
if (await matchesPin(join(dir, exe), pin))
|
|
86
|
+
return join(dir, exe);
|
|
87
|
+
return await download(ENGINE_VERSION, `${RELEASES}/v${ENGINE_VERSION}`, dir, pin, platform);
|
|
88
|
+
}
|
|
89
|
+
function run(bin, args, stdin, timeout) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const child = spawn(bin, args, { stdio: [stdin ? "pipe" : "ignore", "pipe", "pipe"] });
|
|
92
|
+
const out = [];
|
|
93
|
+
const err = [];
|
|
94
|
+
let timedOut = false;
|
|
95
|
+
const timer = timeout === undefined
|
|
96
|
+
? undefined
|
|
97
|
+
: setTimeout(() => {
|
|
98
|
+
timedOut = true;
|
|
99
|
+
child.kill("SIGKILL");
|
|
100
|
+
}, timeout);
|
|
101
|
+
child.stdout.on("data", (b) => out.push(b));
|
|
102
|
+
child.stderr.on("data", (b) => err.push(b));
|
|
103
|
+
child.on("error", (e) => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
reject(new SendScoreError(`could not run ${bin}: ${e.message}`, 0));
|
|
106
|
+
});
|
|
107
|
+
child.on("close", (code) => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
if (timedOut) {
|
|
110
|
+
reject(new SendScoreError(`sendscore did not finish within ${timeout} ms`, KILLED));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
resolve({
|
|
114
|
+
code: code === null || code < 0 || code >= 128 ? KILLED : code,
|
|
115
|
+
stdout: Buffer.concat(out).toString(),
|
|
116
|
+
stderr: Buffer.concat(err).toString(),
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
if (stdin) {
|
|
120
|
+
child.stdin.on("error", () => { });
|
|
121
|
+
child.stdin.end(stdin);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
function parse(r, verdictCodes) {
|
|
126
|
+
if (!verdictCodes.includes(r.code)) {
|
|
127
|
+
throw new SendScoreError(r.stderr.trim() || `sendscore exited ${r.code}`, r.code);
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(r.stdout);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
throw new SendScoreError("sendscore printed something that is not JSON", r.code);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** A lone `-` would mean standard input to the CLI, so a file of that name gets a path. */
|
|
137
|
+
function pathArg(path) {
|
|
138
|
+
return path === "-" ? "./-" : path;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Checks one message, given as a path or as its bytes. A message that fails
|
|
142
|
+
* checks is still a report: this throws only when no report could be made.
|
|
143
|
+
*/
|
|
144
|
+
export async function check(message, options = {}) {
|
|
145
|
+
const args = ["check", "--json"];
|
|
146
|
+
if (options.offline)
|
|
147
|
+
args.push("--offline");
|
|
148
|
+
if (options.links)
|
|
149
|
+
args.push("--links");
|
|
150
|
+
if (options.ip !== undefined)
|
|
151
|
+
args.push("--ip", options.ip);
|
|
152
|
+
if (options.mailFrom !== undefined)
|
|
153
|
+
args.push("--mail-from", options.mailFrom);
|
|
154
|
+
if (options.helo !== undefined)
|
|
155
|
+
args.push("--helo", options.helo);
|
|
156
|
+
if (options.tls !== undefined)
|
|
157
|
+
args.push("--tls", options.tls ? "yes" : "no");
|
|
158
|
+
if (options.previous !== undefined)
|
|
159
|
+
args.push("--previous", pathArg(options.previous));
|
|
160
|
+
const stdin = typeof message === "string" ? undefined : message;
|
|
161
|
+
args.push("--", typeof message === "string" ? pathArg(message) : "-");
|
|
162
|
+
const verdict = parse(await run(await binary(), args, stdin, options.timeout), [0, 1, 2]);
|
|
163
|
+
if (typeof verdict?.schema !== "string" || !verdict.schema.startsWith("sendscore.verdict/")) {
|
|
164
|
+
throw new SendScoreError("sendscore printed something that is not a verdict", 0);
|
|
165
|
+
}
|
|
166
|
+
return verdict;
|
|
167
|
+
}
|
|
168
|
+
/** Compares two saved messages, given as paths. */
|
|
169
|
+
export async function diff(before, after, options = {}) {
|
|
170
|
+
const args = ["diff", "--json", "--", pathArg(before), pathArg(after)];
|
|
171
|
+
const result = parse(await run(await binary(), args, undefined, options.timeout), [0, 1]);
|
|
172
|
+
if (result === null || typeof result !== "object" || !("changes" in result)) {
|
|
173
|
+
throw new SendScoreError("sendscore printed something that is not a comparison", 0);
|
|
174
|
+
}
|
|
175
|
+
return { ...result, changes: result.changes ?? [] };
|
|
176
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** The `sendscore.verdict/v1` report. New fields may appear in this version. */
|
|
2
|
+
export type Status = "pass" | "warn" | "fail" | "info" | "skipped";
|
|
3
|
+
export type Category = "auth" | "unsub" | "struct" | "content" | "delivery" | "changes";
|
|
4
|
+
export type Who = "you" | "provider";
|
|
5
|
+
export interface Message {
|
|
6
|
+
received_at: string;
|
|
7
|
+
from: string;
|
|
8
|
+
subject: string;
|
|
9
|
+
message_id: string;
|
|
10
|
+
size_bytes: number;
|
|
11
|
+
}
|
|
12
|
+
export interface Fix {
|
|
13
|
+
who: Who;
|
|
14
|
+
text: string;
|
|
15
|
+
}
|
|
16
|
+
export interface Check {
|
|
17
|
+
id: string;
|
|
18
|
+
category: Category;
|
|
19
|
+
status: Status;
|
|
20
|
+
title: string;
|
|
21
|
+
detail?: string;
|
|
22
|
+
evidence?: Record<string, unknown>;
|
|
23
|
+
fix?: Fix;
|
|
24
|
+
docs?: string;
|
|
25
|
+
/** Why the check was skipped. */
|
|
26
|
+
reason?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface TopFix {
|
|
29
|
+
check: string;
|
|
30
|
+
who: Who;
|
|
31
|
+
fix: string;
|
|
32
|
+
}
|
|
33
|
+
export interface Change {
|
|
34
|
+
field: string;
|
|
35
|
+
before: unknown;
|
|
36
|
+
after: unknown;
|
|
37
|
+
/** The check this change made worse, if any. */
|
|
38
|
+
worsened?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface Fingerprint {
|
|
41
|
+
dkim_domains: string[];
|
|
42
|
+
dkim_selectors: string[];
|
|
43
|
+
return_path_domain: string;
|
|
44
|
+
remote_ip?: string;
|
|
45
|
+
ptr?: string;
|
|
46
|
+
list_unsubscribe: boolean;
|
|
47
|
+
one_click: boolean;
|
|
48
|
+
link_hosts: string[];
|
|
49
|
+
html_bytes: number;
|
|
50
|
+
links: number;
|
|
51
|
+
images: number;
|
|
52
|
+
auth: Record<string, Status> | null;
|
|
53
|
+
}
|
|
54
|
+
export interface Verdict {
|
|
55
|
+
schema: string;
|
|
56
|
+
id?: string;
|
|
57
|
+
message: Message;
|
|
58
|
+
publication?: string;
|
|
59
|
+
verdict: Status;
|
|
60
|
+
ready_to_send: boolean;
|
|
61
|
+
summary: string;
|
|
62
|
+
top_fixes: TopFix[];
|
|
63
|
+
checks: Check[];
|
|
64
|
+
changes: Change[];
|
|
65
|
+
fingerprint?: Fingerprint;
|
|
66
|
+
explanation: string | null;
|
|
67
|
+
note: string;
|
|
68
|
+
}
|
|
69
|
+
export interface DiffResult {
|
|
70
|
+
before: string;
|
|
71
|
+
after: string;
|
|
72
|
+
changes: Change[];
|
|
73
|
+
}
|
|
74
|
+
export interface CheckOptions {
|
|
75
|
+
/** Make no DNS lookups. Checks that need DNS are skipped. */
|
|
76
|
+
offline?: boolean;
|
|
77
|
+
/** Fetch every link and image, never unsubscribe or confirm links. */
|
|
78
|
+
links?: boolean;
|
|
79
|
+
/** IP address of the server that delivered the message. */
|
|
80
|
+
ip?: string;
|
|
81
|
+
/** Envelope sender. Defaults to the Return-Path. */
|
|
82
|
+
mailFrom?: string;
|
|
83
|
+
/** HELO name the delivering server gave. */
|
|
84
|
+
helo?: string;
|
|
85
|
+
/** Whether delivery used TLS. */
|
|
86
|
+
tls?: boolean;
|
|
87
|
+
/** Path of the publication's previous issue, to list what changed. */
|
|
88
|
+
previous?: string;
|
|
89
|
+
/** Kill the CLI and throw after this many milliseconds. No limit by default. */
|
|
90
|
+
timeout?: number;
|
|
91
|
+
}
|
|
92
|
+
/** What a comparison should do beyond reading the messages. */
|
|
93
|
+
export interface DiffOptions {
|
|
94
|
+
/** Kill the CLI and throw after this many milliseconds. No limit by default. */
|
|
95
|
+
timeout?: number;
|
|
96
|
+
}
|
package/dist/types.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
|
+
"bin": {
|
|
3
|
+
"sendscore": "dist/cli.js"
|
|
4
|
+
},
|
|
5
|
+
"description": "Check a newsletter email before it goes out. A typed wrapper around the SendScore CLI.",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": "\u003e=20"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"default": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"main": "./dist/index.js",
|
|
2
22
|
"name": "sendscore",
|
|
3
|
-
"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
23
|
+
"optionalDependencies": {
|
|
24
|
+
"@sendscore/cli-darwin-arm64": "0.3.0",
|
|
25
|
+
"@sendscore/cli-darwin-x64": "0.3.0",
|
|
26
|
+
"@sendscore/cli-linux-arm64": "0.3.0",
|
|
27
|
+
"@sendscore/cli-linux-x64": "0.3.0",
|
|
28
|
+
"@sendscore/cli-win32-arm64": "0.3.0",
|
|
29
|
+
"@sendscore/cli-win32-x64": "0.3.0"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"directory": "packages/sdk",
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/sendscore/sendscore.git"
|
|
35
|
+
},
|
|
36
|
+
"type": "module",
|
|
37
|
+
"types": "./dist/index.d.ts",
|
|
38
|
+
"version": "0.3.0"
|
|
39
|
+
}
|