@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.
@@ -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 };