@devdogsuga/backstage 0.1.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/README.md +147 -0
- package/bin/backstage.mjs +28 -0
- package/dist/args-Cjr_Iqts.js +66 -0
- package/dist/build-info.json +3 -0
- package/dist/catalog-BOBE4ee7.js +1570 -0
- package/dist/cli-YLCe3Zrx.js +6300 -0
- package/dist/commands-Bk7IbhPD.js +220 -0
- package/dist/commands-D345F-Od.js +800 -0
- package/dist/commands-Dv5TXjvb.js +545 -0
- package/dist/dispatch-D048O65I.js +13 -0
- package/dist/launch.js +329 -0
- package/dist/options-BTjOf5KP.js +183 -0
- package/dist/peer-redirect-hooks.js +15 -0
- package/dist/telemetry-Bjoz29Hl.js +609 -0
- package/dist/telemetry.js +2 -0
- package/dist/ui-CdKo8mLw.js +66 -0
- package/env.ts +163 -0
- package/package.json +82 -0
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
import { p as isNonInteractive } from "./telemetry-Bjoz29Hl.js";
|
|
2
|
+
import { a as explainError, o as unwrap, r as errorMessage, t as UsageError } from "./ui-CdKo8mLw.js";
|
|
3
|
+
import { t as DONE } from "./dispatch-D048O65I.js";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { confirm, log, multiselect, spinner, text } from "@clack/prompts";
|
|
6
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { render } from "@devdogsuga/brand/render";
|
|
12
|
+
import { ISSUES, issueByVersion } from "@devdogsuga/newsletter";
|
|
13
|
+
import { buildEml, emailImages, previewRenderContext, renderIssueDocument } from "@devdogsuga/newsletter/export";
|
|
14
|
+
import { connect } from "node:tls";
|
|
15
|
+
import { createElement } from "react";
|
|
16
|
+
import { createServer } from "node:http";
|
|
17
|
+
import { createConnection } from "node:net";
|
|
18
|
+
//#region src/newsletter/imap.ts
|
|
19
|
+
/**
|
|
20
|
+
* One-shot IMAP APPEND: place a finished MIME message in a mailbox's Drafts
|
|
21
|
+
* folder, where every Outlook — new, web, Mac, classic — offers it as an
|
|
22
|
+
* editable draft. This is the whole reason for speaking IMAP at all: the
|
|
23
|
+
* mailbox is the one place all the clients agree on, and APPEND is the one
|
|
24
|
+
* verb this workflow needs, so a dependency-free sliver of the protocol beats
|
|
25
|
+
* a client library that models the rest of it.
|
|
26
|
+
*/
|
|
27
|
+
const DEFAULT_HOST$1 = "outlook.office365.com";
|
|
28
|
+
const TIMEOUT_MS$1 = 3e4;
|
|
29
|
+
/** RFC 7628's SASL initial response: user and bearer token, ^A-delimited. */
|
|
30
|
+
function xoauth2(user, accessToken) {
|
|
31
|
+
return Buffer.from(`user=${user}\x01auth=Bearer ${accessToken}\x01\x01`, "utf8").toString("base64");
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The Drafts folder's real name out of `LIST` output, for mailboxes whose
|
|
35
|
+
* folders are not in English. The `\Drafts` attribute is the label Exchange
|
|
36
|
+
* puts on the folder regardless of what it is called.
|
|
37
|
+
*/
|
|
38
|
+
function draftsFromList(lines) {
|
|
39
|
+
for (const line of lines) {
|
|
40
|
+
const match = /^\* LIST \(([^)]*)\) (?:"[^"]*"|NIL) (.+)$/.exec(line);
|
|
41
|
+
if (!match || !/\\Drafts\b/i.test(match[1])) continue;
|
|
42
|
+
const raw = match[2].trim();
|
|
43
|
+
return raw.startsWith("\"") && raw.endsWith("\"") ? raw.slice(1, -1).replace(/\\(["\\])/g, "$1") : raw;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
/** Folder names go back out quoted, so quote-significant bytes get escaped. */
|
|
48
|
+
function quoteMailbox(name) {
|
|
49
|
+
return `"${name.replace(/([\\"])/g, "\\$1")}"`;
|
|
50
|
+
}
|
|
51
|
+
async function appendDraft(options) {
|
|
52
|
+
const host = options.host ?? DEFAULT_HOST$1;
|
|
53
|
+
const socket = connect({
|
|
54
|
+
host,
|
|
55
|
+
port: 993,
|
|
56
|
+
servername: host
|
|
57
|
+
});
|
|
58
|
+
socket.setTimeout(TIMEOUT_MS$1);
|
|
59
|
+
let buffer = "";
|
|
60
|
+
let sequence = 0;
|
|
61
|
+
const pending = [];
|
|
62
|
+
let wake = null;
|
|
63
|
+
let failure = null;
|
|
64
|
+
socket.on("data", (chunk) => {
|
|
65
|
+
buffer += chunk.toString("utf8");
|
|
66
|
+
let index;
|
|
67
|
+
while ((index = buffer.indexOf("\r\n")) !== -1) {
|
|
68
|
+
pending.push(buffer.slice(0, index));
|
|
69
|
+
buffer = buffer.slice(index + 2);
|
|
70
|
+
}
|
|
71
|
+
wake?.();
|
|
72
|
+
});
|
|
73
|
+
const fail = (err) => {
|
|
74
|
+
failure ??= err;
|
|
75
|
+
socket.destroy();
|
|
76
|
+
wake?.();
|
|
77
|
+
};
|
|
78
|
+
socket.on("error", (err) => fail(err));
|
|
79
|
+
socket.on("timeout", () => fail(/* @__PURE__ */ new Error(`${host} stopped answering.`)));
|
|
80
|
+
socket.on("close", () => fail(/* @__PURE__ */ new Error(`${host} closed the connection.`)));
|
|
81
|
+
const nextLine = async () => {
|
|
82
|
+
for (;;) {
|
|
83
|
+
if (pending.length) return pending.shift();
|
|
84
|
+
if (failure) throw failure;
|
|
85
|
+
await new Promise((resolvePromise) => {
|
|
86
|
+
wake = resolvePromise;
|
|
87
|
+
});
|
|
88
|
+
wake = null;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Sends one command and reads to its tagged reply. A `+ ` continuation
|
|
93
|
+
* hands control back via `onContinue` — APPEND pushes the literal there,
|
|
94
|
+
* and a failed AUTHENTICATE wants a bare CRLF to surface its tagged NO.
|
|
95
|
+
*/
|
|
96
|
+
const command = async (line, onContinue) => {
|
|
97
|
+
const tag = `T${++sequence}`;
|
|
98
|
+
socket.write(`${tag} ${line}\r\n`);
|
|
99
|
+
const untagged = [];
|
|
100
|
+
for (;;) {
|
|
101
|
+
const reply = await nextLine();
|
|
102
|
+
if (reply.startsWith("+ ") || reply === "+") {
|
|
103
|
+
(onContinue ?? (() => socket.write("\r\n")))();
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (reply.startsWith(`${tag} `)) {
|
|
107
|
+
const [status = "", ...rest] = reply.slice(tag.length + 1).split(" ");
|
|
108
|
+
return {
|
|
109
|
+
status: status.toUpperCase(),
|
|
110
|
+
text: rest.join(" "),
|
|
111
|
+
untagged
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (reply.startsWith("* ")) untagged.push(reply);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
try {
|
|
118
|
+
await nextLine();
|
|
119
|
+
const auth = await command(`AUTHENTICATE XOAUTH2 ${xoauth2(options.user, options.accessToken)}`);
|
|
120
|
+
if (auth.status !== "OK") throw new Error(`${options.user} was refused: ${auth.text || auth.status}. Sign in again if the stored grant has been revoked.`);
|
|
121
|
+
const literal = Buffer.from(options.message, "utf8");
|
|
122
|
+
const append = (folder) => command(`APPEND ${quoteMailbox(folder)} (\\Draft \\Seen) {${literal.length}}`, () => {
|
|
123
|
+
socket.write(literal);
|
|
124
|
+
socket.write("\r\n");
|
|
125
|
+
});
|
|
126
|
+
let placed = await append("Drafts");
|
|
127
|
+
if (placed.status !== "OK") {
|
|
128
|
+
const drafts = draftsFromList((await command("LIST \"\" \"*\"")).untagged);
|
|
129
|
+
if (drafts && drafts !== "Drafts") placed = await append(drafts);
|
|
130
|
+
if (placed.status !== "OK") throw new Error(`APPEND was refused: ${placed.text || placed.status}`);
|
|
131
|
+
}
|
|
132
|
+
await command("LOGOUT");
|
|
133
|
+
} finally {
|
|
134
|
+
socket.destroy();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/newsletter/images.ts
|
|
139
|
+
/**
|
|
140
|
+
* The email's marks as PNG attachments.
|
|
141
|
+
*
|
|
142
|
+
* `@devdogsuga/newsletter` hands over each image as an SVG; a mail client
|
|
143
|
+
* wants a raster. They go through `@devdogsuga/brand/render`, the same Satori
|
|
144
|
+
* and resvg the rest of the club's images use, as an `<img>` of the SVG laid
|
|
145
|
+
* out at the target width. That keeps one rasteriser in the toolchain and
|
|
146
|
+
* keeps the marks vector until the last step: resvg draws the nested SVG at
|
|
147
|
+
* the final size rather than scaling a bitmap.
|
|
148
|
+
*/
|
|
149
|
+
/** The SVG's aspect ratio, from its `viewBox` or else its `width` and `height`. */
|
|
150
|
+
function svgAspect(svg) {
|
|
151
|
+
const root = /<svg\b[^>]*>/i.exec(svg)?.[0] ?? "";
|
|
152
|
+
const [, , boxWidth, boxHeight] = (/viewBox\s*=\s*"([^"]+)"/i.exec(root)?.[1] ?? "").trim().split(/[\s,]+/).map(Number);
|
|
153
|
+
if (boxWidth && boxHeight) return boxWidth / boxHeight;
|
|
154
|
+
const width = Number(/\bwidth\s*=\s*"([\d.]+)/i.exec(root)?.[1]);
|
|
155
|
+
const height = Number(/\bheight\s*=\s*"([\d.]+)/i.exec(root)?.[1]);
|
|
156
|
+
if (width && height) return width / height;
|
|
157
|
+
throw new Error("The SVG declares neither a viewBox nor a width and height.");
|
|
158
|
+
}
|
|
159
|
+
/** An SVG as PNG bytes, `width` pixels wide. */
|
|
160
|
+
async function rasterize(svg, width) {
|
|
161
|
+
const height = Math.max(1, Math.round(width / svgAspect(svg)));
|
|
162
|
+
const src = `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`;
|
|
163
|
+
const { png } = await render(createElement("img", {
|
|
164
|
+
src,
|
|
165
|
+
width,
|
|
166
|
+
height,
|
|
167
|
+
style: {
|
|
168
|
+
width,
|
|
169
|
+
height
|
|
170
|
+
}
|
|
171
|
+
}), {
|
|
172
|
+
width,
|
|
173
|
+
height
|
|
174
|
+
});
|
|
175
|
+
return png;
|
|
176
|
+
}
|
|
177
|
+
//#endregion
|
|
178
|
+
//#region src/newsletter/loopback.ts
|
|
179
|
+
/**
|
|
180
|
+
* A throwaway HTTP server that catches one OAuth redirect and dies.
|
|
181
|
+
*
|
|
182
|
+
* Entra ignores the port when matching a localhost redirect URI, so the
|
|
183
|
+
* sign-in can come home to `http://localhost:{ephemeral}/` even though
|
|
184
|
+
* Thunderbird registered no port. Bound to 127.0.0.1 only — the redirect
|
|
185
|
+
* never leaves the machine, and nothing off it should reach the listener.
|
|
186
|
+
* The `state` nonce ties the callback to this run: any other process that
|
|
187
|
+
* finds the port cannot feed the flow a code it did not mint.
|
|
188
|
+
*/
|
|
189
|
+
/**
|
|
190
|
+
* What one request to the listener means. `null` is a stray — a favicon
|
|
191
|
+
* probe, a health check — and the server keeps waiting; a wrong or missing
|
|
192
|
+
* state is also a stray, because rejecting the whole sign-in over a request
|
|
193
|
+
* an attacker could send unauthenticated would hand them a denial of
|
|
194
|
+
* service instead.
|
|
195
|
+
*/
|
|
196
|
+
function parseCallback(requestUrl, state) {
|
|
197
|
+
const url = new URL(requestUrl, "http://localhost");
|
|
198
|
+
if (url.pathname !== "/") return null;
|
|
199
|
+
if (url.searchParams.get("state") !== state) return null;
|
|
200
|
+
const error = url.searchParams.get("error");
|
|
201
|
+
if (error) return new Error(url.searchParams.get("error_description") ?? error);
|
|
202
|
+
const code = url.searchParams.get("code");
|
|
203
|
+
return code ? { code } : null;
|
|
204
|
+
}
|
|
205
|
+
const LANDING = `<!doctype html>
|
|
206
|
+
<meta charset="utf-8">
|
|
207
|
+
<title>DevDogs devtools</title>
|
|
208
|
+
<body style="font-family: system-ui; display: grid; place-items: center; min-height: 90vh; background: #13121b; color: #f3f1f6">
|
|
209
|
+
<p style="max-width: 36rem; text-align: center">%MESSAGE% You can close this tab; the rest happens in the terminal.</p>
|
|
210
|
+
`;
|
|
211
|
+
/** Throws when it cannot bind, which is the caller's cue to fall back. */
|
|
212
|
+
function startLoopback(state) {
|
|
213
|
+
return new Promise((resolve, reject) => {
|
|
214
|
+
let settle;
|
|
215
|
+
const code = new Promise((resolveCode, rejectCode) => {
|
|
216
|
+
settle = {
|
|
217
|
+
resolve: resolveCode,
|
|
218
|
+
reject: rejectCode
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
code.catch(() => void 0);
|
|
222
|
+
const server = createServer((request, response) => {
|
|
223
|
+
const result = parseCallback(request.url ?? "/", state);
|
|
224
|
+
if (result === null) {
|
|
225
|
+
response.writeHead(404).end();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const failed = result instanceof Error;
|
|
229
|
+
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(LANDING.replace("%MESSAGE%", failed ? "The sign-in did not go through." : "Signed in."));
|
|
230
|
+
if (failed) settle.reject(result);
|
|
231
|
+
else settle.resolve(result.code);
|
|
232
|
+
});
|
|
233
|
+
server.on("error", reject);
|
|
234
|
+
server.listen(0, "127.0.0.1", () => {
|
|
235
|
+
const address = server.address();
|
|
236
|
+
if (!address || typeof address === "string") {
|
|
237
|
+
server.close();
|
|
238
|
+
reject(/* @__PURE__ */ new Error("The loopback server bound without a port."));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
resolve({
|
|
242
|
+
port: address.port,
|
|
243
|
+
code,
|
|
244
|
+
close: () => server.close()
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Best effort only: the URL is always printed too, so a machine with no
|
|
251
|
+
* opener — SSH, a container, a WSL distro without wslview — costs one click
|
|
252
|
+
* on the printed line instead of a failure.
|
|
253
|
+
*/
|
|
254
|
+
function openInBrowser(url) {
|
|
255
|
+
const [command, args] = process.platform === "win32" ? ["cmd", [
|
|
256
|
+
"/c",
|
|
257
|
+
"start",
|
|
258
|
+
"",
|
|
259
|
+
url
|
|
260
|
+
]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
|
|
261
|
+
try {
|
|
262
|
+
spawn(command, args, {
|
|
263
|
+
stdio: "ignore",
|
|
264
|
+
detached: true
|
|
265
|
+
}).on("error", () => void 0).unref();
|
|
266
|
+
} catch {}
|
|
267
|
+
}
|
|
268
|
+
//#endregion
|
|
269
|
+
//#region src/newsletter/oauth.ts
|
|
270
|
+
/**
|
|
271
|
+
* Sign-in for the club mailbox, borrowing Thunderbird's app registration.
|
|
272
|
+
*
|
|
273
|
+
* UGA's tenant blocks user consent for every Microsoft Graph mail scope (the
|
|
274
|
+
* Microsoft-managed default policy), so the clean path — a Graph-created
|
|
275
|
+
* draft — needs an EITS-approved app registration. The same policy, though,
|
|
276
|
+
* allowlists a handful of mail clients by application ID for the legacy
|
|
277
|
+
* IMAP/SMTP scopes, Thunderbird among them. This module runs the standard
|
|
278
|
+
* authorization-code flow as that client: the officer signs in as the mailbox
|
|
279
|
+
* in a browser, and the refresh token is theirs — the same grant Thunderbird
|
|
280
|
+
* itself would hold. Presenting another client's ID is a documented
|
|
281
|
+
* convention in open-source mail tooling (mbsync, OfflineIMAP, DavMail), but
|
|
282
|
+
* it is Microsoft's allowlist, and this stops working the day they prune it.
|
|
283
|
+
*
|
|
284
|
+
* The redirect comes back two ways. Entra ignores the port when matching a
|
|
285
|
+
* localhost redirect URI, so the ordinary path is `loopback.ts`: a throwaway
|
|
286
|
+
* server on an ephemeral port that catches the code itself. When that server
|
|
287
|
+
* cannot bind, the flow falls back to Thunderbird's registered
|
|
288
|
+
* `https://localhost` — a dead page whose address bar holds the code, pasted
|
|
289
|
+
* back by hand.
|
|
290
|
+
*
|
|
291
|
+
* No PKCE and no client secret: Thunderbird is registered as a public client,
|
|
292
|
+
* and the code exchange happens entirely on this machine.
|
|
293
|
+
*/
|
|
294
|
+
/**
|
|
295
|
+
* Thunderbird's public client ID, BORROWED: the club has no app registration
|
|
296
|
+
* of its own. If Microsoft or UGA's tenant stops allowing it, drafting and
|
|
297
|
+
* sending need a club-owned registration (an EITS request), and this constant
|
|
298
|
+
* and the redirect below change with it.
|
|
299
|
+
*/
|
|
300
|
+
const CLIENT_ID = "9e5f94bc-e8a4-4e73-b8be-63364c29d753";
|
|
301
|
+
/** The fallback redirect when no loopback server could bind. */
|
|
302
|
+
const PASTE_REDIRECT_URI = "https://localhost";
|
|
303
|
+
const SCOPE = "https://outlook.office365.com/IMAP.AccessAsUser.All https://outlook.office365.com/SMTP.Send offline_access";
|
|
304
|
+
const AUTHORITY = "https://login.microsoftonline.com/organizations/oauth2/v2.0";
|
|
305
|
+
function authorizeUrl(mailbox, redirectUri, state) {
|
|
306
|
+
const query = new URLSearchParams({
|
|
307
|
+
client_id: CLIENT_ID,
|
|
308
|
+
response_type: "code",
|
|
309
|
+
redirect_uri: redirectUri,
|
|
310
|
+
scope: SCOPE,
|
|
311
|
+
login_hint: mailbox,
|
|
312
|
+
...state ? { state } : {}
|
|
313
|
+
});
|
|
314
|
+
return `${AUTHORITY}/authorize?${query.toString()}`;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* The code out of whatever the officer pastes: the whole `https://localhost/?
|
|
318
|
+
* code=…` address (the intended gesture — the browser shows a dead page and
|
|
319
|
+
* they copy its location) or the bare code itself.
|
|
320
|
+
*/
|
|
321
|
+
function codeFromRedirect(pasted) {
|
|
322
|
+
const input = pasted.trim();
|
|
323
|
+
const error = /[?&#]error=([^&\s]+)/.exec(input);
|
|
324
|
+
if (error) {
|
|
325
|
+
const description = /[?&#]error_description=([^&\s]+)/.exec(input);
|
|
326
|
+
return new Error(description ? decodeURIComponent(description[1].replace(/\+/g, " ")) : decodeURIComponent(error[1]));
|
|
327
|
+
}
|
|
328
|
+
const inUrl = /[?&#]code=([^&\s]+)/.exec(input);
|
|
329
|
+
if (inUrl) return decodeURIComponent(inUrl[1]);
|
|
330
|
+
if (input && !/[\s/?&=]/.test(input)) return input;
|
|
331
|
+
return /* @__PURE__ */ new Error("That has no ?code= in it. Paste the whole address bar of the localhost page.");
|
|
332
|
+
}
|
|
333
|
+
async function tokenRequest(grant) {
|
|
334
|
+
const response = await fetch(`${AUTHORITY}/token`, {
|
|
335
|
+
method: "POST",
|
|
336
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
337
|
+
body: new URLSearchParams({
|
|
338
|
+
client_id: CLIENT_ID,
|
|
339
|
+
scope: SCOPE,
|
|
340
|
+
...grant
|
|
341
|
+
})
|
|
342
|
+
});
|
|
343
|
+
const body = await response.json();
|
|
344
|
+
if (!response.ok || !body.access_token || !body.refresh_token) throw new Error(body.error_description ?? body.error ?? `Token endpoint answered ${response.status}.`);
|
|
345
|
+
return {
|
|
346
|
+
accessToken: body.access_token,
|
|
347
|
+
refreshToken: body.refresh_token
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
function redeemCode(code, redirectUri) {
|
|
351
|
+
return tokenRequest({
|
|
352
|
+
grant_type: "authorization_code",
|
|
353
|
+
code,
|
|
354
|
+
redirect_uri: redirectUri
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
function refreshTokens(refreshToken) {
|
|
358
|
+
return tokenRequest({
|
|
359
|
+
grant_type: "refresh_token",
|
|
360
|
+
refresh_token: refreshToken
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Outside the repo on purpose: the refresh token opens the club mailbox, and
|
|
365
|
+
* nothing that opens the club mailbox belongs anywhere `git add` can reach.
|
|
366
|
+
*/
|
|
367
|
+
function grantPath() {
|
|
368
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
369
|
+
const configHome = xdg === void 0 || xdg === "" ? join(homedir(), ".config") : xdg;
|
|
370
|
+
return join(configHome, "devdogsuga", "newsletter-mailbox.json");
|
|
371
|
+
}
|
|
372
|
+
async function readGrant() {
|
|
373
|
+
try {
|
|
374
|
+
const parsed = JSON.parse(await readFile(grantPath(), "utf8"));
|
|
375
|
+
return typeof parsed.mailbox === "string" && typeof parsed.refreshToken === "string" ? {
|
|
376
|
+
mailbox: parsed.mailbox,
|
|
377
|
+
refreshToken: parsed.refreshToken
|
|
378
|
+
} : null;
|
|
379
|
+
} catch {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
async function writeGrant(grant) {
|
|
384
|
+
const path = grantPath();
|
|
385
|
+
await mkdir(dirname(path), { recursive: true });
|
|
386
|
+
await writeFile(path, `${JSON.stringify(grant, null, 2)}\n`, { mode: 384 });
|
|
387
|
+
await chmod(path, 384);
|
|
388
|
+
}
|
|
389
|
+
//#endregion
|
|
390
|
+
//#region src/newsletter/smtp.ts
|
|
391
|
+
/**
|
|
392
|
+
* One-shot SMTP submission: send a finished MIME message byte-for-byte.
|
|
393
|
+
*
|
|
394
|
+
* This exists because a draft cannot survive being sent from Outlook. The
|
|
395
|
+
* web Outlook composer is a rich-text editor with a style whitelist, and
|
|
396
|
+
* sending a pushed draft re-serializes whatever that editor kept: forensics
|
|
397
|
+
* on a received copy showed the head `<style>` replaced with Outlook's own,
|
|
398
|
+
* every `background-image` and `bgcolor` gone, and most class attributes
|
|
399
|
+
* dropped — all of the newsletter's dark-mode defenses, stripped in transit.
|
|
400
|
+
* (Classic Outlook mangles differently, into Word HTML, but just as surely.)
|
|
401
|
+
* Submitting over SMTP bypasses every composer: recipients get the authored
|
|
402
|
+
* document, stylesheet and all. Like `imap.ts`, this speaks just the sliver
|
|
403
|
+
* of the protocol the workflow needs — EHLO, STARTTLS, AUTH, one message.
|
|
404
|
+
*/
|
|
405
|
+
const DEFAULT_HOST = "smtp.office365.com";
|
|
406
|
+
const TIMEOUT_MS = 3e4;
|
|
407
|
+
/**
|
|
408
|
+
* The name recipients see beside the club address. Without one, clients fall
|
|
409
|
+
* back to the bare address, and Outlook shows it as the tenant stores it:
|
|
410
|
+
* "devdogs@UGA.EDU".
|
|
411
|
+
*/
|
|
412
|
+
const SENDER_NAME = "DevDogs";
|
|
413
|
+
/**
|
|
414
|
+
* An RFC 5322 mailbox with a display name. Quoted so any punctuation is safe;
|
|
415
|
+
* a name outside ASCII travels as an RFC 2047 encoded word, which is never
|
|
416
|
+
* quoted.
|
|
417
|
+
*/
|
|
418
|
+
function namedMailbox(name, address) {
|
|
419
|
+
return `${/^[\x20-\x7e]*$/.test(name) ? `"${name.replace(/(["\\])/g, "\\$1")}"` : `=?UTF-8?B?${Buffer.from(name, "utf8").toString("base64")}?=`} <${address}>`;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* RFC 5322 origination headers for a message built as a draft. `buildEml`
|
|
423
|
+
* deliberately emits none of these — draft-ness is their absence — so the
|
|
424
|
+
* send path prepends them. The submission server stamps `Message-ID`.
|
|
425
|
+
*/
|
|
426
|
+
function originationHeaders(from, to, date = /* @__PURE__ */ new Date(), fromName = SENDER_NAME) {
|
|
427
|
+
return [
|
|
428
|
+
`From: ${namedMailbox(fromName, from)}`,
|
|
429
|
+
`To: ${to.map((address) => `<${address}>`).join(", ")}`,
|
|
430
|
+
`Date: ${date.toUTCString().replace(/GMT$/, "+0000")}`,
|
|
431
|
+
""
|
|
432
|
+
].join("\r\n");
|
|
433
|
+
}
|
|
434
|
+
/** RFC 5321 §4.5.2 transparency: a line-leading `.` doubles inside DATA. */
|
|
435
|
+
function dotStuff(message) {
|
|
436
|
+
return message.replace(/(^|\r\n)\./g, "$1..");
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* `true` when an SMTP reply line ends its reply: `250 done` rather than the
|
|
440
|
+
* `250-more coming` continuation form (RFC 5321 §4.2.1).
|
|
441
|
+
*/
|
|
442
|
+
function endsReply(line) {
|
|
443
|
+
return /^\d{3}(?: |$)/.test(line);
|
|
444
|
+
}
|
|
445
|
+
async function submitMessage(options) {
|
|
446
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
447
|
+
if (!options.recipients.length) throw new Error("No recipients.");
|
|
448
|
+
let buffer = "";
|
|
449
|
+
const pending = [];
|
|
450
|
+
let wake = null;
|
|
451
|
+
let failure = null;
|
|
452
|
+
const fail = (err) => {
|
|
453
|
+
failure ??= err;
|
|
454
|
+
socket.destroy();
|
|
455
|
+
wake?.();
|
|
456
|
+
};
|
|
457
|
+
/** (Re)binds the line reader — once to the TCP socket, again after TLS. */
|
|
458
|
+
const adopt = (next) => {
|
|
459
|
+
next.setTimeout(TIMEOUT_MS);
|
|
460
|
+
next.on("data", (chunk) => {
|
|
461
|
+
buffer += chunk.toString("utf8");
|
|
462
|
+
let index;
|
|
463
|
+
while ((index = buffer.indexOf("\r\n")) !== -1) {
|
|
464
|
+
pending.push(buffer.slice(0, index));
|
|
465
|
+
buffer = buffer.slice(index + 2);
|
|
466
|
+
}
|
|
467
|
+
wake?.();
|
|
468
|
+
});
|
|
469
|
+
next.on("error", (err) => fail(err));
|
|
470
|
+
next.on("timeout", () => fail(/* @__PURE__ */ new Error(`${host} stopped answering.`)));
|
|
471
|
+
next.on("close", () => fail(/* @__PURE__ */ new Error(`${host} closed the connection.`)));
|
|
472
|
+
return next;
|
|
473
|
+
};
|
|
474
|
+
let socket = adopt(createConnection({
|
|
475
|
+
host,
|
|
476
|
+
port: 587
|
|
477
|
+
}));
|
|
478
|
+
const nextLine = async () => {
|
|
479
|
+
for (;;) {
|
|
480
|
+
if (pending.length) return pending.shift();
|
|
481
|
+
if (failure) throw failure;
|
|
482
|
+
await new Promise((resolvePromise) => {
|
|
483
|
+
wake = resolvePromise;
|
|
484
|
+
});
|
|
485
|
+
wake = null;
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
/** Reads one full reply; throws unless its code is in `expect`. */
|
|
489
|
+
const reply = async (expect, doing) => {
|
|
490
|
+
let line;
|
|
491
|
+
do
|
|
492
|
+
line = await nextLine();
|
|
493
|
+
while (!endsReply(line));
|
|
494
|
+
const code = Number(line.slice(0, 3));
|
|
495
|
+
if (!expect.includes(code)) throw new Error(`${doing}: ${line}`);
|
|
496
|
+
return line;
|
|
497
|
+
};
|
|
498
|
+
const command = (line, expect, doing) => {
|
|
499
|
+
socket.write(`${line}\r\n`);
|
|
500
|
+
return reply(expect, doing);
|
|
501
|
+
};
|
|
502
|
+
try {
|
|
503
|
+
await reply([220], `${host} refused the connection`);
|
|
504
|
+
await command(`EHLO [127.0.0.1]`, [250], "EHLO was refused");
|
|
505
|
+
await command("STARTTLS", [220], "STARTTLS was refused");
|
|
506
|
+
for (const event of [
|
|
507
|
+
"data",
|
|
508
|
+
"timeout",
|
|
509
|
+
"close"
|
|
510
|
+
]) socket.removeAllListeners(event);
|
|
511
|
+
socket.setTimeout(0);
|
|
512
|
+
socket = adopt(connect({
|
|
513
|
+
socket,
|
|
514
|
+
servername: host
|
|
515
|
+
}));
|
|
516
|
+
await command(`EHLO [127.0.0.1]`, [250], "EHLO after STARTTLS was refused");
|
|
517
|
+
await command(`AUTH XOAUTH2 ${xoauth2(options.user, options.accessToken)}`, [235], `${options.user} was refused. Sign in again if the stored grant lacks SMTP.Send — grants from before --send existed only asked for IMAP`);
|
|
518
|
+
await command(`MAIL FROM:<${options.from}>`, [250], `The sender ${options.from} was refused`);
|
|
519
|
+
for (const recipient of options.recipients) await command(`RCPT TO:<${recipient}>`, [250, 251], `The recipient ${recipient} was refused`);
|
|
520
|
+
await command("DATA", [354], "DATA was refused");
|
|
521
|
+
const body = dotStuff(options.message);
|
|
522
|
+
socket.write(body.endsWith("\r\n") ? body : `${body}\r\n`);
|
|
523
|
+
await command(".", [250], "The message was refused");
|
|
524
|
+
socket.write("QUIT\r\n");
|
|
525
|
+
} finally {
|
|
526
|
+
socket.destroy();
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
//#endregion
|
|
530
|
+
//#region src/newsletter/commands.ts
|
|
531
|
+
/**
|
|
532
|
+
* `pnpm backstage newsletter render|draft|send <issue…>`
|
|
533
|
+
*
|
|
534
|
+
* The DevDogs Changelog, from the issues published in `@devdogsuga/newsletter`:
|
|
535
|
+
*
|
|
536
|
+
* * `render`: writes `.eml` and `.html` files and touches nothing else.
|
|
537
|
+
* * `draft`: appends each issue to the Drafts folder of the club mailbox
|
|
538
|
+
* over IMAP, for review in any Outlook.
|
|
539
|
+
* * `send`: submits each issue over SMTP, byte for byte as authored, to the
|
|
540
|
+
* recipients named with `--to`. There is no default audience, and every
|
|
541
|
+
* send asks first, naming the issue and the full recipient list.
|
|
542
|
+
*
|
|
543
|
+
* Drafts and sends always use the club mailbox. Sending goes through SMTP and
|
|
544
|
+
* not Outlook because Outlook's composers rewrite the HTML: they strip the
|
|
545
|
+
* styles, classes and `bgcolor` attributes the dark-mode defences rely on.
|
|
546
|
+
*
|
|
547
|
+
* Credentials: none stored for you to manage. The first `draft` or `send`
|
|
548
|
+
* opens a browser to sign in as the club mailbox (see `oauth.ts`); the
|
|
549
|
+
* refresh token is cached outside any repository, so later runs, including
|
|
550
|
+
* non-interactive ones, reuse it. No env file and no checkout.
|
|
551
|
+
*/
|
|
552
|
+
/** The account every draft and send goes through. There is no override. */
|
|
553
|
+
const CLUB_MAILBOX = "devdogs@uga.edu";
|
|
554
|
+
const NEWSLETTER_FORMATS = ["eml", "html"];
|
|
555
|
+
const SUBCOMMANDS = [
|
|
556
|
+
"render",
|
|
557
|
+
"draft",
|
|
558
|
+
"send"
|
|
559
|
+
];
|
|
560
|
+
function expandHome(value) {
|
|
561
|
+
return value === "~" || value.startsWith("~/") ? resolve(homedir(), value.slice(2)) : value;
|
|
562
|
+
}
|
|
563
|
+
function commaList(value) {
|
|
564
|
+
return (value ?? "").split(",").map((part) => part.trim()).filter(Boolean);
|
|
565
|
+
}
|
|
566
|
+
/** Reads one subcommand's arguments, or throws a {@link UsageError} saying what is wrong. */
|
|
567
|
+
function parseNewsletterArgs(argv, cwd) {
|
|
568
|
+
const [subcommand, ...rest] = argv;
|
|
569
|
+
if (!subcommand || !SUBCOMMANDS.includes(subcommand)) throw new UsageError(subcommand ? `Unknown newsletter command "${subcommand}". Try ${SUBCOMMANDS.join(", ")}.` : `Name what to do: ${SUBCOMMANDS.join(", ")}.`);
|
|
570
|
+
let parsed;
|
|
571
|
+
try {
|
|
572
|
+
parsed = parseArgs({
|
|
573
|
+
args: rest,
|
|
574
|
+
options: {
|
|
575
|
+
format: { type: "string" },
|
|
576
|
+
out: { type: "string" },
|
|
577
|
+
to: { type: "string" },
|
|
578
|
+
yes: { type: "boolean" },
|
|
579
|
+
"dry-run": { type: "boolean" }
|
|
580
|
+
},
|
|
581
|
+
allowPositionals: true
|
|
582
|
+
});
|
|
583
|
+
} catch (err) {
|
|
584
|
+
throw new UsageError(`${errorMessage(err)} (There is no --mailbox: the club mailbox is always used.)`);
|
|
585
|
+
}
|
|
586
|
+
const { values } = parsed;
|
|
587
|
+
const wrongSubcommand = (flag, belongsTo) => {
|
|
588
|
+
if (values[flag] !== void 0 && subcommand !== belongsTo) throw new UsageError(`--${flag} belongs to \`newsletter ${belongsTo}\`.`);
|
|
589
|
+
};
|
|
590
|
+
wrongSubcommand("format", "render");
|
|
591
|
+
wrongSubcommand("out", "render");
|
|
592
|
+
wrongSubcommand("to", "send");
|
|
593
|
+
const to = commaList(values.to);
|
|
594
|
+
if (subcommand === "send") {
|
|
595
|
+
if (!to.length) throw new UsageError("`newsletter send` needs --to, a comma-separated list of addresses.");
|
|
596
|
+
const notAddresses = to.filter((value) => !value.includes("@"));
|
|
597
|
+
if (notAddresses.length) throw new UsageError(`--to takes email addresses, not ${notAddresses.join(", ")}.`);
|
|
598
|
+
}
|
|
599
|
+
const formats = commaList(values.format ?? "eml,html");
|
|
600
|
+
const bad = formats.filter((value) => !NEWSLETTER_FORMATS.includes(value));
|
|
601
|
+
if (bad.length) throw new UsageError(`Unknown format ${bad.join(", ")}. Try eml or html.`);
|
|
602
|
+
return {
|
|
603
|
+
subcommand,
|
|
604
|
+
versions: parsed.positionals,
|
|
605
|
+
formats: [...new Set(formats)],
|
|
606
|
+
out: resolve(cwd, expandHome(values.out ?? "changelog-exports")),
|
|
607
|
+
to,
|
|
608
|
+
yes: values.yes === true
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
function destination(out, version, format) {
|
|
612
|
+
return resolve(out, `changelog-v${version}.${format}`);
|
|
613
|
+
}
|
|
614
|
+
/** The confirmation text for a send: the issues and every recipient. */
|
|
615
|
+
function sendSummary(versions, to) {
|
|
616
|
+
return `Send ${versions.map((version) => `v${version}`).join(", ")} from ${CLUB_MAILBOX} to ${to.length} recipient${to.length === 1 ? "" : "s"}: ${to.join(", ")}?`;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Asks before a send, every time. `--yes` answers it; with no terminal it is
|
|
620
|
+
* the only way through, so an unattended run cannot send by accident.
|
|
621
|
+
*/
|
|
622
|
+
async function confirmSend(options, versions) {
|
|
623
|
+
if (options.yes) return true;
|
|
624
|
+
if (isNonInteractive()) throw new UsageError(`${sendSummary(versions, options.to)} No terminal to ask. Pass --yes to send.`);
|
|
625
|
+
return unwrap(await confirm({
|
|
626
|
+
message: sendSummary(versions, options.to),
|
|
627
|
+
initialValue: false
|
|
628
|
+
}));
|
|
629
|
+
}
|
|
630
|
+
async function pickIssues() {
|
|
631
|
+
if (isNonInteractive()) throw new UsageError("No terminal to choose issues. Name one, or pass * for all.");
|
|
632
|
+
return unwrap(await multiselect({
|
|
633
|
+
message: "Which issues?",
|
|
634
|
+
options: ISSUES.map((issue) => ({
|
|
635
|
+
value: issue.version,
|
|
636
|
+
label: `v${issue.version}`,
|
|
637
|
+
hint: issue.tagline
|
|
638
|
+
})),
|
|
639
|
+
initialValues: [ISSUES[ISSUES.length - 1]?.version ?? ""].filter(Boolean),
|
|
640
|
+
required: true
|
|
641
|
+
}));
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* The browser sign-in, redirect caught by a loopback server: open the URL,
|
|
645
|
+
* wait for the code to knock, exchange it. Five minutes is the patience —
|
|
646
|
+
* authorization codes do not live much longer anyway.
|
|
647
|
+
*/
|
|
648
|
+
async function signInViaLoopback(server, state) {
|
|
649
|
+
const redirectUri = `http://localhost:${server.port}/`;
|
|
650
|
+
const url = authorizeUrl(CLUB_MAILBOX, redirectUri, state);
|
|
651
|
+
log.step(`Sign in as ${CLUB_MAILBOX} in the browser window that just opened:`);
|
|
652
|
+
log.message(url);
|
|
653
|
+
openInBrowser(url);
|
|
654
|
+
const wait = spinner();
|
|
655
|
+
wait.start("Waiting for the sign-in to come back");
|
|
656
|
+
try {
|
|
657
|
+
const timeout = new Promise((_, rejectLate) => {
|
|
658
|
+
setTimeout(() => rejectLate(/* @__PURE__ */ new Error("Five minutes passed with no sign-in. Run it again.")), 3e5).unref();
|
|
659
|
+
});
|
|
660
|
+
const code = await Promise.race([server.code, timeout]);
|
|
661
|
+
wait.stop("The sign-in came back.");
|
|
662
|
+
return await redeemCode(code, redirectUri);
|
|
663
|
+
} catch (err) {
|
|
664
|
+
wait.stop("No sign-in.");
|
|
665
|
+
throw err;
|
|
666
|
+
} finally {
|
|
667
|
+
server.close();
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/** The pasted fallback for a machine where no local port would bind. */
|
|
671
|
+
async function signInViaPaste() {
|
|
672
|
+
log.step(`Open this and sign in as ${CLUB_MAILBOX}:`);
|
|
673
|
+
log.message(authorizeUrl(CLUB_MAILBOX, PASTE_REDIRECT_URI));
|
|
674
|
+
const code = codeFromRedirect(unwrap(await text({
|
|
675
|
+
message: "The browser will land on a dead localhost page. Paste its full address:",
|
|
676
|
+
validate: (value) => {
|
|
677
|
+
const code = codeFromRedirect(value ?? "");
|
|
678
|
+
return code instanceof Error ? code.message : void 0;
|
|
679
|
+
}
|
|
680
|
+
})));
|
|
681
|
+
if (code instanceof Error) throw code;
|
|
682
|
+
return redeemCode(code, PASTE_REDIRECT_URI);
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* An access token for the club mailbox: the stored grant refreshed when there
|
|
686
|
+
* is one, a browser sign-in when there is not.
|
|
687
|
+
*/
|
|
688
|
+
async function mailboxAccessToken() {
|
|
689
|
+
const stored = await readGrant();
|
|
690
|
+
if (stored?.mailbox === "devdogs@uga.edu") try {
|
|
691
|
+
const tokens = await refreshTokens(stored.refreshToken);
|
|
692
|
+
await writeGrant({
|
|
693
|
+
mailbox: CLUB_MAILBOX,
|
|
694
|
+
refreshToken: tokens.refreshToken
|
|
695
|
+
});
|
|
696
|
+
return tokens.accessToken;
|
|
697
|
+
} catch (err) {
|
|
698
|
+
log.warn(`The stored sign-in was refused (${errorMessage(err)}).`);
|
|
699
|
+
}
|
|
700
|
+
if (isNonInteractive()) throw new UsageError(`No terminal to sign in as ${CLUB_MAILBOX}. Run \`pnpm backstage newsletter draft <issue>\` interactively once; after that this works anywhere.`);
|
|
701
|
+
const state = randomBytes(16).toString("hex");
|
|
702
|
+
const server = await startLoopback(state).catch(() => null);
|
|
703
|
+
const tokens = server ? await signInViaLoopback(server, state) : await signInViaPaste();
|
|
704
|
+
await writeGrant({
|
|
705
|
+
mailbox: CLUB_MAILBOX,
|
|
706
|
+
refreshToken: tokens.refreshToken
|
|
707
|
+
});
|
|
708
|
+
log.info(`Signed in. The grant lives in ${grantPath()} — mode 600, keep it that way.`);
|
|
709
|
+
return tokens.accessToken;
|
|
710
|
+
}
|
|
711
|
+
/** The email's marks, rasterised once: they are the same in every issue. */
|
|
712
|
+
async function attachments() {
|
|
713
|
+
return Promise.all(emailImages().map(async (image) => ({
|
|
714
|
+
cid: image.cid,
|
|
715
|
+
filename: image.filename,
|
|
716
|
+
contentType: "image/png",
|
|
717
|
+
base64: (await rasterize(image.svg, image.rasterWidth)).toString("base64")
|
|
718
|
+
})));
|
|
719
|
+
}
|
|
720
|
+
async function runNewsletter(argv) {
|
|
721
|
+
try {
|
|
722
|
+
await newsletter(parseNewsletterArgs(argv, process.cwd()));
|
|
723
|
+
} catch (err) {
|
|
724
|
+
explainError("Could not do that.", err, [
|
|
725
|
+
"pnpm backstage newsletter render '*' --out ~/changelog",
|
|
726
|
+
"pnpm backstage newsletter draft 3.0.1",
|
|
727
|
+
"pnpm backstage newsletter send 3.0.1 --to a@uga.edu,b@uga.edu"
|
|
728
|
+
]);
|
|
729
|
+
process.exitCode = 1;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
async function newsletter(parsed) {
|
|
733
|
+
const unknown = parsed.versions.filter((version) => version !== "*" && !issueByVersion(version));
|
|
734
|
+
if (unknown.length) throw new UsageError(`No issue called ${unknown.join(", ")}. Try ${ISSUES.map((issue) => issue.version).join(", ")}, or *.`);
|
|
735
|
+
const chosen = parsed.versions.length ? parsed.versions : await pickIssues();
|
|
736
|
+
const versions = chosen.includes("*") ? ISSUES.map((issue) => issue.version) : [...new Set(chosen)];
|
|
737
|
+
if (parsed.subcommand === "render") {
|
|
738
|
+
const images = parsed.formats.includes("eml") ? await attachments() : [];
|
|
739
|
+
const written = [];
|
|
740
|
+
for (const version of versions) {
|
|
741
|
+
const issue = issueByVersion(version);
|
|
742
|
+
for (const format of parsed.formats) {
|
|
743
|
+
const file = destination(parsed.out, version, format);
|
|
744
|
+
await mkdir(dirname(file), { recursive: true });
|
|
745
|
+
await writeFile(file, format === "eml" ? buildEml({
|
|
746
|
+
subject: issue.title,
|
|
747
|
+
html: renderIssueDocument(issue),
|
|
748
|
+
images
|
|
749
|
+
}) : renderIssueDocument(issue, previewRenderContext()));
|
|
750
|
+
written.push(file);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
for (const file of written) log.success(file);
|
|
754
|
+
log.info(`${written.length} file${written.length === 1 ? "" : "s"} written. An .eml opens in classic Outlook for review; send with \`newsletter send\`.`);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (parsed.subcommand === "send" && !await confirmSend(parsed, versions)) {
|
|
758
|
+
log.info("Nothing sent.");
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const images = await attachments();
|
|
762
|
+
const message = (version) => {
|
|
763
|
+
const issue = issueByVersion(version);
|
|
764
|
+
return buildEml({
|
|
765
|
+
subject: issue.title,
|
|
766
|
+
html: renderIssueDocument(issue),
|
|
767
|
+
images,
|
|
768
|
+
unsent: false
|
|
769
|
+
});
|
|
770
|
+
};
|
|
771
|
+
const accessToken = await mailboxAccessToken();
|
|
772
|
+
if (parsed.subcommand === "draft") {
|
|
773
|
+
for (const version of versions) {
|
|
774
|
+
await appendDraft({
|
|
775
|
+
user: CLUB_MAILBOX,
|
|
776
|
+
accessToken,
|
|
777
|
+
message: message(version)
|
|
778
|
+
});
|
|
779
|
+
log.success(`v${version} → Drafts of ${CLUB_MAILBOX}`);
|
|
780
|
+
}
|
|
781
|
+
log.info("Open any Outlook as the club account to review, but send with `newsletter send`, not from Outlook: its composers rewrite the HTML.");
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
for (const version of versions) {
|
|
785
|
+
await submitMessage({
|
|
786
|
+
user: CLUB_MAILBOX,
|
|
787
|
+
accessToken,
|
|
788
|
+
from: CLUB_MAILBOX,
|
|
789
|
+
recipients: parsed.to,
|
|
790
|
+
message: originationHeaders(CLUB_MAILBOX, parsed.to) + message(version)
|
|
791
|
+
});
|
|
792
|
+
log.success(`v${version} → sent to ${parsed.to.join(", ")}`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
const handleNewsletter = async (rest) => {
|
|
796
|
+
await runNewsletter(rest);
|
|
797
|
+
return process.exitCode ? null : DONE;
|
|
798
|
+
};
|
|
799
|
+
//#endregion
|
|
800
|
+
export { handleNewsletter };
|