@devdogsuga/devtools 0.1.0 → 0.1.1
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/dist/cli.js +50 -23
- package/dist/commands.js +63 -23
- package/dist/db/new-migration.d.ts +8 -1
- package/dist/db/new-migration.js +60 -2
- package/dist/db/pooler.d.ts +23 -0
- package/dist/db/pooler.js +55 -0
- package/dist/environment-doctor.d.ts +36 -0
- package/dist/environment-doctor.js +395 -0
- package/dist/oauth/wizard.d.ts +8 -1
- package/dist/oauth/wizard.js +20 -4
- package/dist/setup.js +178 -50
- package/package.json +7 -7
- package/dist/newsletter/commands.d.ts +0 -16
- package/dist/newsletter/commands.js +0 -311
- package/dist/newsletter/imap.d.ts +0 -17
- package/dist/newsletter/imap.js +0 -127
- package/dist/newsletter/loopback.d.ts +0 -24
- package/dist/newsletter/loopback.js +0 -100
- package/dist/newsletter/oauth.d.ts +0 -26
- package/dist/newsletter/oauth.js +0 -130
- package/dist/newsletter/smtp.d.ts +0 -23
- package/dist/newsletter/smtp.js +0 -133
- package/dist/tsconfig.typecheck.tsbuildinfo +0 -1
- /package/dist/{doctor.d.ts → moderation.d.ts} +0 -0
- /package/dist/{doctor.js → moderation.js} +0 -0
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/** RFC 7628's SASL initial response: user and bearer token, ^A-delimited. */
|
|
2
|
-
export declare function xoauth2(user: string, accessToken: string): string;
|
|
3
|
-
/**
|
|
4
|
-
* The Drafts folder's real name out of `LIST` output, for mailboxes whose
|
|
5
|
-
* folders are not in English. The `\Drafts` attribute is the label Exchange
|
|
6
|
-
* puts on the folder regardless of what it is called.
|
|
7
|
-
*/
|
|
8
|
-
export declare function draftsFromList(lines: readonly string[]): string | null;
|
|
9
|
-
/** Folder names go back out quoted, so quote-significant bytes get escaped. */
|
|
10
|
-
export declare function quoteMailbox(name: string): string;
|
|
11
|
-
export declare function appendDraft(options: {
|
|
12
|
-
user: string;
|
|
13
|
-
accessToken: string;
|
|
14
|
-
/** The full MIME message, CRLF line endings. */
|
|
15
|
-
message: string;
|
|
16
|
-
host?: string;
|
|
17
|
-
}): Promise<void>;
|
package/dist/newsletter/imap.js
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One-shot IMAP APPEND: place a finished MIME message in a mailbox's Drafts
|
|
3
|
-
* folder, where every Outlook — new, web, Mac, classic — offers it as an
|
|
4
|
-
* editable draft. This is the whole reason for speaking IMAP at all: the
|
|
5
|
-
* mailbox is the one place all the clients agree on, and APPEND is the one
|
|
6
|
-
* verb this workflow needs, so a dependency-free sliver of the protocol beats
|
|
7
|
-
* a client library that models the rest of it.
|
|
8
|
-
*/
|
|
9
|
-
import { connect } from "node:tls";
|
|
10
|
-
const DEFAULT_HOST = "outlook.office365.com";
|
|
11
|
-
const TIMEOUT_MS = 30_000;
|
|
12
|
-
/** RFC 7628's SASL initial response: user and bearer token, ^A-delimited. */
|
|
13
|
-
export function xoauth2(user, accessToken) {
|
|
14
|
-
return Buffer.from(`user=${user}\x01auth=Bearer ${accessToken}\x01\x01`, "utf8").toString("base64");
|
|
15
|
-
}
|
|
16
|
-
/**
|
|
17
|
-
* The Drafts folder's real name out of `LIST` output, for mailboxes whose
|
|
18
|
-
* folders are not in English. The `\Drafts` attribute is the label Exchange
|
|
19
|
-
* puts on the folder regardless of what it is called.
|
|
20
|
-
*/
|
|
21
|
-
export function draftsFromList(lines) {
|
|
22
|
-
for (const line of lines) {
|
|
23
|
-
const match = /^\* LIST \(([^)]*)\) (?:"[^"]*"|NIL) (.+)$/.exec(line);
|
|
24
|
-
if (!match || !/\\Drafts\b/i.test(match[1]))
|
|
25
|
-
continue;
|
|
26
|
-
const raw = match[2].trim();
|
|
27
|
-
return raw.startsWith('"') && raw.endsWith('"')
|
|
28
|
-
? raw.slice(1, -1).replace(/\\(["\\])/g, "$1")
|
|
29
|
-
: raw;
|
|
30
|
-
}
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
/** Folder names go back out quoted, so quote-significant bytes get escaped. */
|
|
34
|
-
export function quoteMailbox(name) {
|
|
35
|
-
return `"${name.replace(/([\\"])/g, "\\$1")}"`;
|
|
36
|
-
}
|
|
37
|
-
export async function appendDraft(options) {
|
|
38
|
-
const host = options.host ?? DEFAULT_HOST;
|
|
39
|
-
const socket = connect({ host, port: 993, servername: host });
|
|
40
|
-
socket.setTimeout(TIMEOUT_MS);
|
|
41
|
-
let buffer = "";
|
|
42
|
-
let sequence = 0;
|
|
43
|
-
const pending = [];
|
|
44
|
-
let wake = null;
|
|
45
|
-
let failure = null;
|
|
46
|
-
socket.on("data", (chunk) => {
|
|
47
|
-
buffer += chunk.toString("utf8");
|
|
48
|
-
let index;
|
|
49
|
-
while ((index = buffer.indexOf("\r\n")) !== -1) {
|
|
50
|
-
pending.push(buffer.slice(0, index));
|
|
51
|
-
buffer = buffer.slice(index + 2);
|
|
52
|
-
}
|
|
53
|
-
wake?.();
|
|
54
|
-
});
|
|
55
|
-
const fail = (err) => {
|
|
56
|
-
failure ??= err;
|
|
57
|
-
socket.destroy();
|
|
58
|
-
wake?.();
|
|
59
|
-
};
|
|
60
|
-
socket.on("error", (err) => fail(err));
|
|
61
|
-
socket.on("timeout", () => fail(new Error(`${host} stopped answering.`)));
|
|
62
|
-
socket.on("close", () => fail(new Error(`${host} closed the connection.`)));
|
|
63
|
-
const nextLine = async () => {
|
|
64
|
-
for (;;) {
|
|
65
|
-
if (pending.length)
|
|
66
|
-
return pending.shift();
|
|
67
|
-
if (failure)
|
|
68
|
-
throw failure;
|
|
69
|
-
await new Promise((resolvePromise) => {
|
|
70
|
-
wake = resolvePromise;
|
|
71
|
-
});
|
|
72
|
-
wake = null;
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
/**
|
|
76
|
-
* Sends one command and reads to its tagged reply. A `+ ` continuation
|
|
77
|
-
* hands control back via `onContinue` — APPEND pushes the literal there,
|
|
78
|
-
* and a failed AUTHENTICATE wants a bare CRLF to surface its tagged NO.
|
|
79
|
-
*/
|
|
80
|
-
const command = async (line, onContinue) => {
|
|
81
|
-
const tag = `T${++sequence}`;
|
|
82
|
-
socket.write(`${tag} ${line}\r\n`);
|
|
83
|
-
const untagged = [];
|
|
84
|
-
for (;;) {
|
|
85
|
-
const reply = await nextLine();
|
|
86
|
-
if (reply.startsWith("+ ") || reply === "+") {
|
|
87
|
-
(onContinue ?? (() => socket.write("\r\n")))();
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
if (reply.startsWith(`${tag} `)) {
|
|
91
|
-
const [status = "", ...rest] = reply.slice(tag.length + 1).split(" ");
|
|
92
|
-
return { status: status.toUpperCase(), text: rest.join(" "), untagged };
|
|
93
|
-
}
|
|
94
|
-
if (reply.startsWith("* "))
|
|
95
|
-
untagged.push(reply);
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
try {
|
|
99
|
-
await nextLine(); // The server greets first; nothing in it matters here.
|
|
100
|
-
const auth = await command(`AUTHENTICATE XOAUTH2 ${xoauth2(options.user, options.accessToken)}`);
|
|
101
|
-
if (auth.status !== "OK") {
|
|
102
|
-
throw new Error(`${options.user} was refused: ${auth.text || auth.status}. ` +
|
|
103
|
-
"Sign in again if the stored grant has been revoked.");
|
|
104
|
-
}
|
|
105
|
-
const literal = Buffer.from(options.message, "utf8");
|
|
106
|
-
const append = (folder) => command(`APPEND ${quoteMailbox(folder)} (\\Draft \\Seen) {${literal.length}}`, () => {
|
|
107
|
-
socket.write(literal);
|
|
108
|
-
socket.write("\r\n");
|
|
109
|
-
});
|
|
110
|
-
let placed = await append("Drafts");
|
|
111
|
-
if (placed.status !== "OK") {
|
|
112
|
-
// Exchange names the folder in the mailbox's own language; the
|
|
113
|
-
// attribute in LIST is the stable way to find it.
|
|
114
|
-
const list = await command('LIST "" "*"');
|
|
115
|
-
const drafts = draftsFromList(list.untagged);
|
|
116
|
-
if (drafts && drafts !== "Drafts")
|
|
117
|
-
placed = await append(drafts);
|
|
118
|
-
if (placed.status !== "OK") {
|
|
119
|
-
throw new Error(`APPEND was refused: ${placed.text || placed.status}`);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
await command("LOGOUT");
|
|
123
|
-
}
|
|
124
|
-
finally {
|
|
125
|
-
socket.destroy();
|
|
126
|
-
}
|
|
127
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* What one request to the listener means. `null` is a stray — a favicon
|
|
3
|
-
* probe, a health check — and the server keeps waiting; a wrong or missing
|
|
4
|
-
* state is also a stray, because rejecting the whole sign-in over a request
|
|
5
|
-
* an attacker could send unauthenticated would hand them a denial of
|
|
6
|
-
* service instead.
|
|
7
|
-
*/
|
|
8
|
-
export declare function parseCallback(requestUrl: string, state: string): {
|
|
9
|
-
code: string;
|
|
10
|
-
} | Error | null;
|
|
11
|
-
export interface Loopback {
|
|
12
|
-
port: number;
|
|
13
|
-
/** Resolves with the code, or rejects with what the identity page said. */
|
|
14
|
-
code: Promise<string>;
|
|
15
|
-
close(): void;
|
|
16
|
-
}
|
|
17
|
-
/** Throws when it cannot bind, which is the caller's cue to fall back. */
|
|
18
|
-
export declare function startLoopback(state: string): Promise<Loopback>;
|
|
19
|
-
/**
|
|
20
|
-
* Best effort only: the URL is always printed too, so a machine with no
|
|
21
|
-
* opener — SSH, a container, a WSL distro without wslview — costs one click
|
|
22
|
-
* on the printed line instead of a failure.
|
|
23
|
-
*/
|
|
24
|
-
export declare function openInBrowser(url: string): void;
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A throwaway HTTP server that catches one OAuth redirect and dies.
|
|
3
|
-
*
|
|
4
|
-
* Entra ignores the port when matching a localhost redirect URI, so the
|
|
5
|
-
* sign-in can come home to `http://localhost:{ephemeral}/` even though
|
|
6
|
-
* Thunderbird registered no port. Bound to 127.0.0.1 only — the redirect
|
|
7
|
-
* never leaves the machine, and nothing off it should reach the listener.
|
|
8
|
-
* The `state` nonce ties the callback to this run: any other process that
|
|
9
|
-
* finds the port cannot feed the flow a code it did not mint.
|
|
10
|
-
*/
|
|
11
|
-
import { createServer } from "node:http";
|
|
12
|
-
import { spawn } from "node:child_process";
|
|
13
|
-
/**
|
|
14
|
-
* What one request to the listener means. `null` is a stray — a favicon
|
|
15
|
-
* probe, a health check — and the server keeps waiting; a wrong or missing
|
|
16
|
-
* state is also a stray, because rejecting the whole sign-in over a request
|
|
17
|
-
* an attacker could send unauthenticated would hand them a denial of
|
|
18
|
-
* service instead.
|
|
19
|
-
*/
|
|
20
|
-
export function parseCallback(requestUrl, state) {
|
|
21
|
-
const url = new URL(requestUrl, "http://localhost");
|
|
22
|
-
if (url.pathname !== "/")
|
|
23
|
-
return null;
|
|
24
|
-
if (url.searchParams.get("state") !== state)
|
|
25
|
-
return null;
|
|
26
|
-
const error = url.searchParams.get("error");
|
|
27
|
-
if (error) {
|
|
28
|
-
return new Error(url.searchParams.get("error_description") ?? error);
|
|
29
|
-
}
|
|
30
|
-
const code = url.searchParams.get("code");
|
|
31
|
-
return code ? { code } : null;
|
|
32
|
-
}
|
|
33
|
-
const LANDING = `<!doctype html>
|
|
34
|
-
<meta charset="utf-8">
|
|
35
|
-
<title>DevDogs devtools</title>
|
|
36
|
-
<body style="font-family: system-ui; display: grid; place-items: center; min-height: 90vh; background: #13121b; color: #f3f1f6">
|
|
37
|
-
<p style="max-width: 36rem; text-align: center">%MESSAGE% You can close this tab; the rest happens in the terminal.</p>
|
|
38
|
-
`;
|
|
39
|
-
/** Throws when it cannot bind, which is the caller's cue to fall back. */
|
|
40
|
-
export function startLoopback(state) {
|
|
41
|
-
return new Promise((resolve, reject) => {
|
|
42
|
-
// Assigned synchronously by the executor below, which TS cannot see.
|
|
43
|
-
let settle;
|
|
44
|
-
const code = new Promise((resolveCode, rejectCode) => {
|
|
45
|
-
settle = { resolve: resolveCode, reject: rejectCode };
|
|
46
|
-
});
|
|
47
|
-
// A rejection can land in the gap before the caller awaits; this extra
|
|
48
|
-
// no-op handler marks it handled without eating it for the real awaiter.
|
|
49
|
-
code.catch(() => { });
|
|
50
|
-
const server = createServer((request, response) => {
|
|
51
|
-
const result = parseCallback(request.url ?? "/", state);
|
|
52
|
-
if (result === null) {
|
|
53
|
-
response.writeHead(404).end();
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
const failed = result instanceof Error;
|
|
57
|
-
response
|
|
58
|
-
.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
|
59
|
-
.end(LANDING.replace("%MESSAGE%", failed ? "The sign-in did not go through." : "Signed in."));
|
|
60
|
-
if (failed)
|
|
61
|
-
settle.reject(result);
|
|
62
|
-
else
|
|
63
|
-
settle.resolve(result.code);
|
|
64
|
-
});
|
|
65
|
-
server.on("error", reject);
|
|
66
|
-
server.listen(0, "127.0.0.1", () => {
|
|
67
|
-
const address = server.address();
|
|
68
|
-
if (!address || typeof address === "string") {
|
|
69
|
-
server.close();
|
|
70
|
-
reject(new Error("The loopback server bound without a port."));
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
resolve({
|
|
74
|
-
port: address.port,
|
|
75
|
-
code,
|
|
76
|
-
close: () => server.close(),
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* Best effort only: the URL is always printed too, so a machine with no
|
|
83
|
-
* opener — SSH, a container, a WSL distro without wslview — costs one click
|
|
84
|
-
* on the printed line instead of a failure.
|
|
85
|
-
*/
|
|
86
|
-
export function openInBrowser(url) {
|
|
87
|
-
const [command, args] = process.platform === "win32"
|
|
88
|
-
? ["cmd", ["/c", "start", "", url]]
|
|
89
|
-
: process.platform === "darwin"
|
|
90
|
-
? ["open", [url]]
|
|
91
|
-
: ["xdg-open", [url]];
|
|
92
|
-
try {
|
|
93
|
-
spawn(command, args, { stdio: "ignore", detached: true })
|
|
94
|
-
.on("error", () => { })
|
|
95
|
-
.unref();
|
|
96
|
-
}
|
|
97
|
-
catch {
|
|
98
|
-
// The printed URL is the real interface; this was only a convenience.
|
|
99
|
-
}
|
|
100
|
-
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
/** The fallback redirect when no loopback server could bind. */
|
|
2
|
-
export declare const PASTE_REDIRECT_URI = "https://localhost";
|
|
3
|
-
export declare function authorizeUrl(mailbox: string, redirectUri: string, state?: string): string;
|
|
4
|
-
/**
|
|
5
|
-
* The code out of whatever the officer pastes: the whole `https://localhost/?
|
|
6
|
-
* code=…` address (the intended gesture — the browser shows a dead page and
|
|
7
|
-
* they copy its location) or the bare code itself.
|
|
8
|
-
*/
|
|
9
|
-
export declare function codeFromRedirect(pasted: string): string | Error;
|
|
10
|
-
export interface MailboxTokens {
|
|
11
|
-
accessToken: string;
|
|
12
|
-
refreshToken: string;
|
|
13
|
-
}
|
|
14
|
-
export declare function redeemCode(code: string, redirectUri: string): Promise<MailboxTokens>;
|
|
15
|
-
export declare function refreshTokens(refreshToken: string): Promise<MailboxTokens>;
|
|
16
|
-
export interface StoredGrant {
|
|
17
|
-
mailbox: string;
|
|
18
|
-
refreshToken: string;
|
|
19
|
-
}
|
|
20
|
-
/**
|
|
21
|
-
* Outside the repo on purpose: the refresh token opens the club mailbox, and
|
|
22
|
-
* nothing that opens the club mailbox belongs anywhere `git add` can reach.
|
|
23
|
-
*/
|
|
24
|
-
export declare function grantPath(): string;
|
|
25
|
-
export declare function readGrant(): Promise<StoredGrant | null>;
|
|
26
|
-
export declare function writeGrant(grant: StoredGrant): Promise<void>;
|
package/dist/newsletter/oauth.js
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sign-in for the club mailbox, borrowing Thunderbird's app registration.
|
|
3
|
-
*
|
|
4
|
-
* UGA's tenant blocks user consent for every Microsoft Graph mail scope (the
|
|
5
|
-
* Microsoft-managed default policy), so the clean path — a Graph-created
|
|
6
|
-
* draft — needs an EITS-approved app registration. The same policy, though,
|
|
7
|
-
* allowlists a handful of mail clients by application ID for the legacy
|
|
8
|
-
* IMAP/SMTP scopes, Thunderbird among them. This module runs the standard
|
|
9
|
-
* authorization-code flow as that client: the officer signs in as the mailbox
|
|
10
|
-
* in a browser, and the refresh token is theirs — the same grant Thunderbird
|
|
11
|
-
* itself would hold. Presenting another client's ID is a documented
|
|
12
|
-
* convention in open-source mail tooling (mbsync, OfflineIMAP, DavMail), but
|
|
13
|
-
* it is Microsoft's allowlist, and this stops working the day they prune it.
|
|
14
|
-
*
|
|
15
|
-
* The redirect comes back two ways. Entra ignores the port when matching a
|
|
16
|
-
* localhost redirect URI, so the ordinary path is `loopback.ts`: a throwaway
|
|
17
|
-
* server on an ephemeral port that catches the code itself. When that server
|
|
18
|
-
* cannot bind, the flow falls back to Thunderbird's registered
|
|
19
|
-
* `https://localhost` — a dead page whose address bar holds the code, pasted
|
|
20
|
-
* back by hand.
|
|
21
|
-
*
|
|
22
|
-
* No PKCE and no client secret: Thunderbird is registered as a public client,
|
|
23
|
-
* and the code exchange happens entirely on this machine.
|
|
24
|
-
*/
|
|
25
|
-
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
26
|
-
import { homedir } from "node:os";
|
|
27
|
-
import { dirname, join } from "node:path";
|
|
28
|
-
const CLIENT_ID = "9e5f94bc-e8a4-4e73-b8be-63364c29d753";
|
|
29
|
-
/** The fallback redirect when no loopback server could bind. */
|
|
30
|
-
export const PASTE_REDIRECT_URI = "https://localhost";
|
|
31
|
-
// IMAP places drafts; SMTP.Send is `--send`. Both are on the same consent
|
|
32
|
-
// allowlist for the borrowed client, so one grant covers both, and a stored
|
|
33
|
-
// IMAP-only grant upgrades silently on its next refresh.
|
|
34
|
-
const SCOPE = "https://outlook.office365.com/IMAP.AccessAsUser.All https://outlook.office365.com/SMTP.Send offline_access";
|
|
35
|
-
const AUTHORITY = "https://login.microsoftonline.com/organizations/oauth2/v2.0";
|
|
36
|
-
export function authorizeUrl(mailbox, redirectUri, state) {
|
|
37
|
-
const query = new URLSearchParams({
|
|
38
|
-
client_id: CLIENT_ID,
|
|
39
|
-
response_type: "code",
|
|
40
|
-
redirect_uri: redirectUri,
|
|
41
|
-
scope: SCOPE,
|
|
42
|
-
login_hint: mailbox,
|
|
43
|
-
...(state ? { state } : {}),
|
|
44
|
-
});
|
|
45
|
-
return `${AUTHORITY}/authorize?${query}`;
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* The code out of whatever the officer pastes: the whole `https://localhost/?
|
|
49
|
-
* code=…` address (the intended gesture — the browser shows a dead page and
|
|
50
|
-
* they copy its location) or the bare code itself.
|
|
51
|
-
*/
|
|
52
|
-
export function codeFromRedirect(pasted) {
|
|
53
|
-
const input = pasted.trim();
|
|
54
|
-
const error = /[?&#]error=([^&\s]+)/.exec(input);
|
|
55
|
-
if (error) {
|
|
56
|
-
const description = /[?&#]error_description=([^&\s]+)/.exec(input);
|
|
57
|
-
return new Error(description
|
|
58
|
-
? decodeURIComponent(description[1].replace(/\+/g, " "))
|
|
59
|
-
: decodeURIComponent(error[1]));
|
|
60
|
-
}
|
|
61
|
-
const inUrl = /[?&#]code=([^&\s]+)/.exec(input);
|
|
62
|
-
if (inUrl)
|
|
63
|
-
return decodeURIComponent(inUrl[1]);
|
|
64
|
-
// Bare codes are opaque but never contain URL structure or whitespace.
|
|
65
|
-
if (input && !/[\s/?&=]/.test(input))
|
|
66
|
-
return input;
|
|
67
|
-
return new Error("That has no ?code= in it. Paste the whole address bar of the localhost page.");
|
|
68
|
-
}
|
|
69
|
-
async function tokenRequest(grant) {
|
|
70
|
-
const response = await fetch(`${AUTHORITY}/token`, {
|
|
71
|
-
method: "POST",
|
|
72
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
73
|
-
body: new URLSearchParams({
|
|
74
|
-
client_id: CLIENT_ID,
|
|
75
|
-
scope: SCOPE,
|
|
76
|
-
...grant,
|
|
77
|
-
}),
|
|
78
|
-
});
|
|
79
|
-
const body = (await response.json());
|
|
80
|
-
if (!response.ok || !body.access_token || !body.refresh_token) {
|
|
81
|
-
throw new Error(body.error_description ??
|
|
82
|
-
body.error ??
|
|
83
|
-
`Token endpoint answered ${response.status}.`);
|
|
84
|
-
}
|
|
85
|
-
return { accessToken: body.access_token, refreshToken: body.refresh_token };
|
|
86
|
-
}
|
|
87
|
-
export function redeemCode(code, redirectUri) {
|
|
88
|
-
return tokenRequest({
|
|
89
|
-
grant_type: "authorization_code",
|
|
90
|
-
code,
|
|
91
|
-
// Must be byte-for-byte the redirect the code was issued against.
|
|
92
|
-
redirect_uri: redirectUri,
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
export function refreshTokens(refreshToken) {
|
|
96
|
-
return tokenRequest({
|
|
97
|
-
grant_type: "refresh_token",
|
|
98
|
-
refresh_token: refreshToken,
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* Outside the repo on purpose: the refresh token opens the club mailbox, and
|
|
103
|
-
* nothing that opens the club mailbox belongs anywhere `git add` can reach.
|
|
104
|
-
*/
|
|
105
|
-
export function grantPath() {
|
|
106
|
-
const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
107
|
-
return join(configHome, "devdogsuga", "newsletter-mailbox.json");
|
|
108
|
-
}
|
|
109
|
-
export async function readGrant() {
|
|
110
|
-
try {
|
|
111
|
-
const parsed = JSON.parse(await readFile(grantPath(), "utf8"));
|
|
112
|
-
return typeof parsed.mailbox === "string" &&
|
|
113
|
-
typeof parsed.refreshToken === "string"
|
|
114
|
-
? { mailbox: parsed.mailbox, refreshToken: parsed.refreshToken }
|
|
115
|
-
: null;
|
|
116
|
-
}
|
|
117
|
-
catch {
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
export async function writeGrant(grant) {
|
|
122
|
-
const path = grantPath();
|
|
123
|
-
await mkdir(dirname(path), { recursive: true });
|
|
124
|
-
await writeFile(path, `${JSON.stringify(grant, null, 2)}\n`, {
|
|
125
|
-
mode: 0o600,
|
|
126
|
-
});
|
|
127
|
-
// writeFile's mode only applies on create; an existing file keeps its old
|
|
128
|
-
// bits, so tighten explicitly on the rotation path too.
|
|
129
|
-
await chmod(path, 0o600);
|
|
130
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* RFC 5322 origination headers for a message built as a draft. `buildEml`
|
|
3
|
-
* deliberately emits none of these — draft-ness is their absence — so the
|
|
4
|
-
* send path prepends them. The submission server stamps `Message-ID`.
|
|
5
|
-
*/
|
|
6
|
-
export declare function originationHeaders(from: string, to: readonly string[], date?: Date): string;
|
|
7
|
-
/** RFC 5321 §4.5.2 transparency: a line-leading `.` doubles inside DATA. */
|
|
8
|
-
export declare function dotStuff(message: string): string;
|
|
9
|
-
/**
|
|
10
|
-
* `true` when an SMTP reply line ends its reply: `250 done` rather than the
|
|
11
|
-
* `250-more coming` continuation form (RFC 5321 §4.2.1).
|
|
12
|
-
*/
|
|
13
|
-
export declare function endsReply(line: string): boolean;
|
|
14
|
-
export declare function submitMessage(options: {
|
|
15
|
-
user: string;
|
|
16
|
-
accessToken: string;
|
|
17
|
-
/** The envelope sender — must be the authenticated mailbox on Exchange. */
|
|
18
|
-
from: string;
|
|
19
|
-
recipients: readonly string[];
|
|
20
|
-
/** The full MIME message, origination headers included, CRLF endings. */
|
|
21
|
-
message: string;
|
|
22
|
-
host?: string;
|
|
23
|
-
}): Promise<void>;
|
package/dist/newsletter/smtp.js
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One-shot SMTP submission: send a finished MIME message byte-for-byte.
|
|
3
|
-
*
|
|
4
|
-
* This exists because a draft cannot survive being sent from Outlook. The
|
|
5
|
-
* web Outlook composer is a rich-text editor with a style whitelist, and
|
|
6
|
-
* sending a pushed draft re-serializes whatever that editor kept: forensics
|
|
7
|
-
* on a received copy showed the head `<style>` replaced with Outlook's own,
|
|
8
|
-
* every `background-image` and `bgcolor` gone, and most class attributes
|
|
9
|
-
* dropped — all of the newsletter's dark-mode defenses, stripped in transit.
|
|
10
|
-
* (Classic Outlook mangles differently, into Word HTML, but just as surely.)
|
|
11
|
-
* Submitting over SMTP bypasses every composer: recipients get the authored
|
|
12
|
-
* document, stylesheet and all. Like `imap.ts`, this speaks just the sliver
|
|
13
|
-
* of the protocol the workflow needs — EHLO, STARTTLS, AUTH, one message.
|
|
14
|
-
*/
|
|
15
|
-
import { createConnection } from "node:net";
|
|
16
|
-
import { connect as connectTls } from "node:tls";
|
|
17
|
-
import { xoauth2 } from "./imap.js";
|
|
18
|
-
const DEFAULT_HOST = "smtp.office365.com";
|
|
19
|
-
const TIMEOUT_MS = 30_000;
|
|
20
|
-
/**
|
|
21
|
-
* RFC 5322 origination headers for a message built as a draft. `buildEml`
|
|
22
|
-
* deliberately emits none of these — draft-ness is their absence — so the
|
|
23
|
-
* send path prepends them. The submission server stamps `Message-ID`.
|
|
24
|
-
*/
|
|
25
|
-
export function originationHeaders(from, to, date = new Date()) {
|
|
26
|
-
return [
|
|
27
|
-
`From: <${from}>`,
|
|
28
|
-
`To: ${to.map((address) => `<${address}>`).join(", ")}`,
|
|
29
|
-
// toUTCString is RFC 5322's fixed-length date form, save the zone name.
|
|
30
|
-
`Date: ${date.toUTCString().replace(/GMT$/, "+0000")}`,
|
|
31
|
-
"",
|
|
32
|
-
].join("\r\n");
|
|
33
|
-
}
|
|
34
|
-
/** RFC 5321 §4.5.2 transparency: a line-leading `.` doubles inside DATA. */
|
|
35
|
-
export function dotStuff(message) {
|
|
36
|
-
return message.replace(/(^|\r\n)\./g, "$1..");
|
|
37
|
-
}
|
|
38
|
-
/**
|
|
39
|
-
* `true` when an SMTP reply line ends its reply: `250 done` rather than the
|
|
40
|
-
* `250-more coming` continuation form (RFC 5321 §4.2.1).
|
|
41
|
-
*/
|
|
42
|
-
export function endsReply(line) {
|
|
43
|
-
return /^\d{3}(?: |$)/.test(line);
|
|
44
|
-
}
|
|
45
|
-
export async function submitMessage(options) {
|
|
46
|
-
const host = options.host ?? DEFAULT_HOST;
|
|
47
|
-
if (!options.recipients.length)
|
|
48
|
-
throw new Error("No recipients.");
|
|
49
|
-
let buffer = "";
|
|
50
|
-
const pending = [];
|
|
51
|
-
let wake = null;
|
|
52
|
-
let failure = null;
|
|
53
|
-
const fail = (err) => {
|
|
54
|
-
failure ??= err;
|
|
55
|
-
socket.destroy();
|
|
56
|
-
wake?.();
|
|
57
|
-
};
|
|
58
|
-
/** (Re)binds the line reader — once to the TCP socket, again after TLS. */
|
|
59
|
-
const adopt = (next) => {
|
|
60
|
-
next.setTimeout(TIMEOUT_MS);
|
|
61
|
-
next.on("data", (chunk) => {
|
|
62
|
-
buffer += chunk.toString("utf8");
|
|
63
|
-
let index;
|
|
64
|
-
while ((index = buffer.indexOf("\r\n")) !== -1) {
|
|
65
|
-
pending.push(buffer.slice(0, index));
|
|
66
|
-
buffer = buffer.slice(index + 2);
|
|
67
|
-
}
|
|
68
|
-
wake?.();
|
|
69
|
-
});
|
|
70
|
-
next.on("error", (err) => fail(err));
|
|
71
|
-
next.on("timeout", () => fail(new Error(`${host} stopped answering.`)));
|
|
72
|
-
next.on("close", () => fail(new Error(`${host} closed the connection.`)));
|
|
73
|
-
return next;
|
|
74
|
-
};
|
|
75
|
-
let socket = adopt(createConnection({ host, port: 587 }));
|
|
76
|
-
const nextLine = async () => {
|
|
77
|
-
for (;;) {
|
|
78
|
-
if (pending.length)
|
|
79
|
-
return pending.shift();
|
|
80
|
-
if (failure)
|
|
81
|
-
throw failure;
|
|
82
|
-
await new Promise((resolvePromise) => {
|
|
83
|
-
wake = resolvePromise;
|
|
84
|
-
});
|
|
85
|
-
wake = null;
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
/** Reads one full reply; throws unless its code is in `expect`. */
|
|
89
|
-
const reply = async (expect, doing) => {
|
|
90
|
-
let line;
|
|
91
|
-
do {
|
|
92
|
-
line = await nextLine();
|
|
93
|
-
} while (!endsReply(line));
|
|
94
|
-
const code = Number(line.slice(0, 3));
|
|
95
|
-
if (!expect.includes(code)) {
|
|
96
|
-
throw new Error(`${doing}: ${line}`);
|
|
97
|
-
}
|
|
98
|
-
return line;
|
|
99
|
-
};
|
|
100
|
-
const command = (line, expect, doing) => {
|
|
101
|
-
socket.write(`${line}\r\n`);
|
|
102
|
-
return reply(expect, doing);
|
|
103
|
-
};
|
|
104
|
-
try {
|
|
105
|
-
await reply([220], `${host} refused the connection`);
|
|
106
|
-
await command(`EHLO [127.0.0.1]`, [250], "EHLO was refused");
|
|
107
|
-
await command("STARTTLS", [220], "STARTTLS was refused");
|
|
108
|
-
// The server speaks next only after the handshake, so nothing is in
|
|
109
|
-
// flight — but the plaintext socket's reader has to come off before the
|
|
110
|
-
// wrap, or it would keep pushing what is now ciphertext into the line
|
|
111
|
-
// buffer (and its close fires only when the whole connection does).
|
|
112
|
-
for (const event of ["data", "timeout", "close"]) {
|
|
113
|
-
socket.removeAllListeners(event);
|
|
114
|
-
}
|
|
115
|
-
socket.setTimeout(0);
|
|
116
|
-
socket = adopt(connectTls({ socket, servername: host }));
|
|
117
|
-
await command(`EHLO [127.0.0.1]`, [250], "EHLO after STARTTLS was refused");
|
|
118
|
-
await command(`AUTH XOAUTH2 ${xoauth2(options.user, options.accessToken)}`, [235], `${options.user} was refused. Sign in again if the stored grant lacks ` +
|
|
119
|
-
"SMTP.Send — grants from before --send existed only asked for IMAP");
|
|
120
|
-
await command(`MAIL FROM:<${options.from}>`, [250], `The sender ${options.from} was refused`);
|
|
121
|
-
for (const recipient of options.recipients) {
|
|
122
|
-
await command(`RCPT TO:<${recipient}>`, [250, 251], `The recipient ${recipient} was refused`);
|
|
123
|
-
}
|
|
124
|
-
await command("DATA", [354], "DATA was refused");
|
|
125
|
-
const body = dotStuff(options.message);
|
|
126
|
-
socket.write(body.endsWith("\r\n") ? body : `${body}\r\n`);
|
|
127
|
-
await command(".", [250], "The message was refused");
|
|
128
|
-
socket.write("QUIT\r\n");
|
|
129
|
-
}
|
|
130
|
-
finally {
|
|
131
|
-
socket.destroy();
|
|
132
|
-
}
|
|
133
|
-
}
|