@bli-cockpit/cli 0.2.167 → 0.2.168

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.
@@ -16,7 +16,12 @@
16
16
  * stores.
17
17
  * - **The message body.** `cockpit mail send` and `cockpit mail draft` take it
18
18
  * from stdin or `--file`, the same discipline `cockpit issue comment` and
19
- * `cockpit docs update` keep.
19
+ * `cockpit docs update` keep. `--file` is the BODY and nothing else.
20
+ *
21
+ * `--attach <path>` (BLI-12661, repeatable, `send` and `draft` only) names a
22
+ * file to ride along. It is a PATH on argv, never the file's bytes: the bytes
23
+ * are read in `mail-attachments.ts`, screened there, and posted in the JSON
24
+ * body like the text is.
20
25
  *
21
26
  * `draft` (BLI-12530) takes `send`'s flags exactly — `--account`, `--to`,
22
27
  * `--cc`, `--subject`, `--reply-to`, `--file` — and is refused the same two
@@ -60,6 +65,7 @@ export function parseMailArgs(args) {
60
65
  "--subject",
61
66
  "--reply-to",
62
67
  "--file",
68
+ "--attach",
63
69
  "--body-stdin",
64
70
  "--out",
65
71
  "--json",
@@ -78,6 +84,7 @@ export function parseMailArgs(args) {
78
84
  "--subject",
79
85
  "--reply-to",
80
86
  "--file",
87
+ "--attach",
81
88
  "--out",
82
89
  ],
83
90
  });
@@ -87,7 +94,8 @@ export function parseMailArgs(args) {
87
94
  const { to, cc } = readMailRecipients(action, values);
88
95
  const outPath = readAttachmentPath(action, values);
89
96
  const limit = readMailLimit(action, values);
90
- return buildMailCommand(values, action, subject, query, address, to, cc, outPath, limit);
97
+ const attachPaths = readAttachPaths(action, values);
98
+ return buildMailCommand(values, action, subject, query, address, to, cc, outPath, limit, attachPaths);
91
99
  }
92
100
  function readMailAction(values) {
93
101
  const first = values.positionals[0];
@@ -152,6 +160,24 @@ function readAttachmentPath(action, values) {
152
160
  }
153
161
  return outPath;
154
162
  }
163
+ /**
164
+ * `--attach <path>`, repeatable (BLI-12661): files that ride along with a
165
+ * `send` or a `draft`. Collected here and read, screened and refused by name in
166
+ * `mail-attachments.ts`, so a missing file or a fifth too many answers as a
167
+ * door refusal with a reason label (and under --json as one object) rather than
168
+ * as a usage error. `--file` is untouched and still means the BODY.
169
+ */
170
+ function readAttachPaths(action, values) {
171
+ const named = values.flagValues.get("--attach") ?? [];
172
+ if (named.length > 0 && !MAIL_ACTIONS_COMPOSING.has(action)) {
173
+ throw new Error("--attach belongs to `cockpit mail send` and `cockpit mail draft`: a file to attach to the message.");
174
+ }
175
+ // A blank path is a mistake to name, never a file to drop without a word.
176
+ const paths = named.map((value) => optionalNonEmpty(value));
177
+ if (paths.some((value) => value === undefined))
178
+ throw new Error("--attach needs a file path, not a blank value.");
179
+ return paths;
180
+ }
155
181
  function readMailLimit(action, values) {
156
182
  const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
157
183
  if (limit !== undefined && action !== "inbox" && action !== "search") {
@@ -159,7 +185,7 @@ function readMailLimit(action, values) {
159
185
  }
160
186
  return limit;
161
187
  }
162
- function buildMailCommand(values, action, subject, query, address, to, cc, outPath, limit) {
188
+ function buildMailCommand(values, action, subject, query, address, to, cc, outPath, limit, attachPaths) {
163
189
  return {
164
190
  kind: "mail",
165
191
  action,
@@ -179,6 +205,7 @@ function buildMailCommand(values, action, subject, query, address, to, cc, outPa
179
205
  subjectLine: optionalNonEmpty(values.flags.get("--subject")),
180
206
  replyTo: optionalNonEmpty(values.flags.get("--reply-to")),
181
207
  filePath: optionalNonEmpty(values.flags.get("--file")),
208
+ ...(attachPaths.length > 0 ? { attachPaths } : {}),
182
209
  bodyStdin: values.booleans.has("--body-stdin"),
183
210
  outPath,
184
211
  json: values.booleans.has("--json"),
@@ -133,8 +133,9 @@ export const TOWER_COMMAND_HELP = [
133
133
  "inbox [--account <id>] [--unread] [--label <l>] [--limit <n>] [--before <iso>] — everything across every mailbox, newest first. * is unread, @ has an attachment. Every row (text or --json) carries the thread id — `[thread <id>]` in text — so `mail read <thread>` never costs a second --json call to learn it (BLI-10450).",
134
134
  "read <thread> — one conversation with its bodies. Thread ids come from `cockpit mail inbox` or `cockpit mail search`, in either --json or plain text.",
135
135
  "search \"<words>\" [--account <id>] [--limit <n>] — full text over subject and body. Quotes and OR work the way they do in a search box. Every row (text or --json) carries the thread id.",
136
- "send --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] — the BODY comes from --file or stdin. --account is required: which address this goes out from is yours to say.",
137
- "draft --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] — saves the message as a DRAFT in that mailbox for a person to read, edit and send themselves. NOTHING IS SENT and nobody is notified, so there is no confirmation step. Same flags and same body rule as send (--file or stdin). Only the mailbox's owner can draft into it. --reply-to threads the draft into that conversation the way send does; Gmail files it there only when the subject matches the original's, and the answer says whether it did. Gmail mailboxes only: an IMAP mailbox is refused as imap_drafts_unsupported. --json answers { ok, draft: { account_id, address, provider_draft_id, provider_thread_id, threaded } } (BLI-12530).",
136
+ "send --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] [--attach <path>]... — the BODY comes from --file or stdin. --account is required: which address this goes out from is yours to say.",
137
+ "draft --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] [--attach <path>]... — saves the message as a DRAFT in that mailbox for a person to read, edit and send themselves. NOTHING IS SENT and nobody is notified, so there is no confirmation step. Same flags and same body rule as send (--file or stdin). Only the mailbox's owner can draft into it. --reply-to threads the draft into that conversation the way send does; Gmail files it there only when the subject matches the original's, and the answer says whether it did. Gmail mailboxes only: an IMAP mailbox is refused as imap_drafts_unsupported. --json answers { ok, draft: { account_id, address, provider_draft_id, provider_thread_id, threaded } } (BLI-12530).",
138
+ "--attach <path> (send and draft; repeat it for each file) — a file that rides along with the message: up to 10, and 3,000,000 bytes together (about 3 MB: a request to Tower is capped at 4.5 MB and a file travels as base64, a third bigger than itself). The type comes from the extension (.docx .pdf .png .jpg .jpeg .txt .csv .md .zip and more; anything else goes as application/octet-stream), the name is the file's own. --file is still the BODY and only the body. Refused BEFORE anything is sent, by name and with the numbers: too_many_attachments, attachment_unreadable (a path that is missing, a folder or empty), attachment_too_large. A reply (--reply-to) keeps its thread with files in it. Works on Gmail mailboxes and on IMAP mailboxes for send; a draft is Gmail only. --json adds { attachments: { count, bytes, content_types } } to the answer. The terminal reports from Tower's own answer, not from what it posted: if Tower does not confirm the files (an older Tower), it exits 1 with attachments_not_confirmed and says whether the message went. Names, paths and contents are never logged; a refusal says \"Attachment 2\", not which file (BLI-12661).",
138
139
  "attachment <id> --out <path> — downloads one attachment to a file. Attachments are pointers until you ask; nothing is stored in Tower.",
139
140
  "sync <account> — reads that mailbox now instead of waiting for the cron. Prints what landed, or the reason it did not.",
140
141
  "sync-status [<account>] — every mailbox's health, or one's: last successful sync, the last pass's own reason, how many messages it carried, failures in the last 24h, and the lag between a message's own clock and when Tower stored it (ticket 10730).",
@@ -124,7 +124,7 @@ export function localCommandHelp(command) {
124
124
  " cockpit docs [list|tree|read <id|slug>|create --title <t>|update <id>|delete <id|slug>] [--parent <id>|root|--clear-parent] [--query <text> [--word]] [--limit <n>] [--visibility org|private] [--file <path>|--body-stdin] [--allow-empty] [--dashboard-url <url>] [--json]",
125
125
  " cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>] [--private] [--members a@x,b@y] [--description <text>] [--thread <id>] [--limit <n>] [--dashboard-url <url>] [--json]",
126
126
  " cockpit issue [list|show <BLI-id>|create --title <t>|update <BLI-id>|move <BLI-id> <state>|comment <BLI-id>|history <BLI-id>] [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] [--priority 0-4] [--size S|M|L] [--parent <BLI-id>] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
127
- " cockpit mail [accounts|add-imap --address <a>|inbox|read <thread>|search \"<words>\"|send --account <id> --to <a> --subject <s>|draft --account <id> --to <a> --subject <s> [--reply-to <message id>]|attachment <id> --out <path>|sync <account>|detach <account>] [--account <id>] [--limit <n>] [--unread] [--label <l>] [--file <path>] [--dashboard-url <url>] [--json]",
127
+ " cockpit mail [accounts|add-imap --address <a>|inbox|read <thread>|search \"<words>\"|send --account <id> --to <a> --subject <s>|draft --account <id> --to <a> --subject <s> [--reply-to <message id>]|attachment <id> --out <path>|sync <account>|detach <account>] [--account <id>] [--limit <n>] [--unread] [--label <l>] [--file <path>] [--attach <path>]... [--dashboard-url <url>] [--json]",
128
128
  " cockpit cal [today|week|next|find \"<words>\"|calendars|add-ical|create --calendar <id> --title <t> --at <iso> --until <iso>|share <id> --org-visible|--private|sync <id> [--full]|detach <id>] [--tz <zone>] [--offset <n>] [--hours <n>] [--from <d> --to <d>] [--calendar <id>] [--limit <n>] [--all] [--dashboard-url <url>] [--json]",
129
129
  " cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
130
130
  " cockpit github <pr|merge|retitle|rerun> <owner/repo> <n> [--claim <state>] [--method squash|merge|rebase] [--title \"<text>\"] [--run <id>] [--confirm <code>] [--dashboard-url <url>] [--json]",
@@ -0,0 +1,207 @@
1
+ /**
2
+ * `--attach <path>` for `cockpit mail send` and `cockpit mail draft` (BLI-12661):
3
+ * read the files on THIS machine, screen them, and turn them into the
4
+ * `attachments` the two doors take. Nothing here talks to Tower, and nothing is
5
+ * read from a disk that is not the person's own: the server is never handed a
6
+ * path.
7
+ *
8
+ * **Everything that can be refused is refused before a byte leaves the
9
+ * machine**, each with a named reason and its numbers (the server holds the same
10
+ * caps and says the same words, `lib/mail/outbound-attachments.ts`):
11
+ *
12
+ * too_many_attachments more than 10 `--attach`
13
+ * attachment_unreadable a path that is missing, is not a regular file, cannot
14
+ * be read, or is empty
15
+ * attachment_too_large more than 3,000,000 bytes together, or a whole
16
+ * request that would not fit the platform's body cap
17
+ *
18
+ * **Why 3,000,000 bytes.** Vercel caps a function REQUEST body at 4.5 MB (413
19
+ * `FUNCTION_PAYLOAD_TOO_LARGE`, before Tower's code runs), and a file travels as
20
+ * base64, a third bigger than itself. 3,000,000 bytes is 4,000,000 characters of
21
+ * base64, which leaves about half a megabyte for the JSON around it. Gmail's own
22
+ * 25 MB is not the binding limit. `MAIL_REQUEST_MAX_BYTES` is the second check:
23
+ * a 3 MB file next to a very long body is still too big for one request, and a
24
+ * platform 413 says nothing about why.
25
+ *
26
+ * **Sizes are read off `stat` first.** Every file is stat'ed, the total is
27
+ * checked, and only then is a single byte read, so a 5 GB file named by mistake
28
+ * costs one system call and no memory.
29
+ *
30
+ * **The type comes from the extension and nothing else.** Unknown is
31
+ * `application/octet-stream`. The table is copied, on purpose, into
32
+ * `packages/bli-cockpit-mcp/src/mail-attachments.ts` (that package depends on
33
+ * neither this one nor `cockpit-cli`), and `mail-attachment-parity.test.ts` in
34
+ * the MCP package fails when the two drift.
35
+ *
36
+ * **Names are never logged, and nor are paths.** The one success line says how
37
+ * many files, how big and of what type (`describeMailAttachments`). A refusal
38
+ * names the file by POSITION ("Attachment 2") and the file system's own error
39
+ * CODE, never its path or its name (the message standard: no local path in an
40
+ * operator-facing string): the person who typed the command is looking at it.
41
+ *
42
+ * No secret-name refusal lives here (`notes upload` has one): a person who names
43
+ * a file here has decided to send it, and the outbound guard masks text, not
44
+ * files. See the doc.
45
+ */
46
+ import { readFile, stat } from "node:fs/promises";
47
+ import path from "node:path";
48
+ import { isSecretLikePathSegment } from "@bli-cockpit/telemetry-core";
49
+ export const MAIL_ATTACHMENT_MAX_COUNT = 10;
50
+ export const MAIL_ATTACHMENTS_MAX_TOTAL_BYTES = 3_000_000;
51
+ /** The whole JSON request, under Vercel's 4.5 MB function-body cap with room for the headers. */
52
+ export const MAIL_REQUEST_MAX_BYTES = 4_400_000;
53
+ // BEGIN content types: identical in packages/bli-cockpit-mcp/src/mail-attachments.ts
54
+ export const MAIL_ATTACHMENT_CONTENT_TYPES = {
55
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
56
+ doc: "application/msword",
57
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
58
+ xls: "application/vnd.ms-excel",
59
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
60
+ ppt: "application/vnd.ms-powerpoint",
61
+ pdf: "application/pdf",
62
+ png: "image/png",
63
+ jpg: "image/jpeg",
64
+ jpeg: "image/jpeg",
65
+ gif: "image/gif",
66
+ webp: "image/webp",
67
+ txt: "text/plain",
68
+ csv: "text/csv",
69
+ md: "text/markdown",
70
+ json: "application/json",
71
+ html: "text/html",
72
+ rtf: "application/rtf",
73
+ zip: "application/zip",
74
+ };
75
+ // END content types
76
+ // BEGIN secret-like names: identical in the CLI and MCP mail-attachments.ts
77
+ /**
78
+ * A name a person almost never means to email: the repo's own secret-name rule
79
+ * (`isSecretLikePathSegment`, the one `notes upload` uses: `.env*`, anything with
80
+ * secret, credential, private_key or service_role in it), with `.env.example`
81
+ * let through (a template, no values), plus the private-key names that rule does
82
+ * not know: id_rsa and its siblings, `*.pem`, `*.key`.
83
+ */
84
+ export function attachmentLooksSecret(fileName) {
85
+ if (/^\.env\.example$/i.test(fileName))
86
+ return false;
87
+ return isSecretLikePathSegment(fileName) || /^id_(rsa|dsa|ecdsa|ed25519)$/i.test(fileName) || /\.(pem|key)$/i.test(fileName);
88
+ }
89
+ // END secret-like names
90
+ const FALLBACK_CONTENT_TYPE = "application/octet-stream";
91
+ /** The type a file name's extension says it is. Case-insensitive; anything unknown is octet-stream. */
92
+ export function mailAttachmentContentType(fileName) {
93
+ const extension = path.extname(fileName).slice(1).toLowerCase();
94
+ return MAIL_ATTACHMENT_CONTENT_TYPES[extension] ?? FALLBACK_CONTENT_TYPE;
95
+ }
96
+ const NOTHING_SENT = "Nothing was sent.";
97
+ function refuse(reason, detail) {
98
+ return { ok: false, reason, detail };
99
+ }
100
+ function bytesLabel(count) {
101
+ return `${count.toLocaleString("en-US")} byte${count === 1 ? "" : "s"}`;
102
+ }
103
+ /** The verdict on a set of sizes, or null when it may go. Shared by the stat pass and the read pass. */
104
+ function totalTooLarge(total) {
105
+ if (total <= MAIL_ATTACHMENTS_MAX_TOTAL_BYTES)
106
+ return null;
107
+ return refuse("attachment_too_large", `The attachments come to ${bytesLabel(total)} and one message carries at most ${bytesLabel(MAIL_ATTACHMENTS_MAX_TOTAL_BYTES)} `
108
+ + "(about 3 MB: the most one request to Tower holds once the files are encoded). "
109
+ + `Share a link for anything bigger. ${NOTHING_SENT}`);
110
+ }
111
+ /**
112
+ * Reads every `--attach` path, or says which one cannot go and why. Never throws.
113
+ * Order: the count, then each file's `stat` (missing, not a file, empty), then the
114
+ * total, and only then the bytes.
115
+ */
116
+ export async function readMailAttachments(paths) {
117
+ if (paths.length === 0)
118
+ return { ok: true, attachments: [], summary: { count: 0, bytes: 0, sizes: [], content_types: [] } };
119
+ if (paths.length > MAIL_ATTACHMENT_MAX_COUNT) {
120
+ return refuse("too_many_attachments", `${paths.length} files were named with --attach and one message carries at most ${MAIL_ATTACHMENT_MAX_COUNT}. ${NOTHING_SENT}`);
121
+ }
122
+ // Names first, before a single stat or read: a secret-looking file is never opened.
123
+ const secret = paths.findIndex((filePath) => attachmentLooksSecret(path.basename(filePath)));
124
+ if (secret !== -1) {
125
+ return refuse("attachment_secret_like", `Attachment ${secret + 1} has a name that looks like a key or credential file, so it was not opened. ${NOTHING_SENT}`);
126
+ }
127
+ const sizes = [];
128
+ for (const [index, filePath] of paths.entries()) {
129
+ const label = `Attachment ${index + 1}`;
130
+ let size;
131
+ try {
132
+ const info = await stat(filePath);
133
+ if (!info.isFile())
134
+ return refuse("attachment_unreadable", `${label} is not a regular file: a folder or a device cannot be attached. ${NOTHING_SENT}`);
135
+ size = info.size;
136
+ }
137
+ catch (error) {
138
+ const code = error.code;
139
+ return refuse("attachment_unreadable", code === "ENOENT"
140
+ ? `${label} does not exist. ${NOTHING_SENT}`
141
+ : `${label} could not be read (${code ?? "unknown error"}). ${NOTHING_SENT}`);
142
+ }
143
+ if (size === 0)
144
+ return refuse("attachment_unreadable", `${label} is empty, and an empty file is not worth sending. ${NOTHING_SENT}`);
145
+ sizes.push(size);
146
+ }
147
+ const tooLarge = totalTooLarge(sizes.reduce((sum, size) => sum + size, 0));
148
+ if (tooLarge)
149
+ return tooLarge;
150
+ const attachments = [];
151
+ const actual = [];
152
+ for (const [index, filePath] of paths.entries()) {
153
+ let bytes;
154
+ try {
155
+ bytes = await readFile(filePath);
156
+ }
157
+ catch (error) {
158
+ const code = error.code;
159
+ return refuse("attachment_unreadable", `Attachment ${index + 1} could not be read (${code ?? "unknown error"}). ${NOTHING_SENT}`);
160
+ }
161
+ actual.push(bytes.length);
162
+ const filename = path.basename(filePath);
163
+ attachments.push({
164
+ filename,
165
+ content_type: mailAttachmentContentType(filename),
166
+ content_base64: bytes.toString("base64"),
167
+ });
168
+ }
169
+ // A file that grew between the stat and the read is the file as it is now.
170
+ const total = actual.reduce((sum, size) => sum + size, 0);
171
+ const grew = totalTooLarge(total);
172
+ if (grew)
173
+ return grew;
174
+ if (actual.includes(0)) {
175
+ return refuse("attachment_unreadable", `Attachment ${actual.indexOf(0) + 1} is empty, and an empty file is not worth sending. ${NOTHING_SENT}`);
176
+ }
177
+ return {
178
+ ok: true,
179
+ attachments,
180
+ summary: {
181
+ count: attachments.length,
182
+ bytes: total,
183
+ sizes: actual,
184
+ content_types: attachments.map((attachment) => attachment.content_type),
185
+ },
186
+ };
187
+ }
188
+ /**
189
+ * The second check: the whole request, not just the files. Called with the JSON
190
+ * body about to be posted, and only when it carries files.
191
+ */
192
+ export function mailRequestFits(body) {
193
+ const bytes = Buffer.byteLength(JSON.stringify(body), "utf8");
194
+ if (bytes <= MAIL_REQUEST_MAX_BYTES)
195
+ return { ok: true, bytes };
196
+ return refuse("attachment_too_large", `The whole request would be ${bytesLabel(bytes)} (the message, the files encoded as base64, and the JSON around them) `
197
+ + `and Tower takes at most ${bytesLabel(MAIL_REQUEST_MAX_BYTES)}: a request over 4.5 MB is refused by the platform before Tower sees it. `
198
+ + `Shorten the message or send fewer or smaller files. ${NOTHING_SENT}`);
199
+ }
200
+ /** The single stderr line for a send or a draft that carries files: count, bytes, sizes, types. */
201
+ export function describeMailAttachments(summary) {
202
+ return `[mail cli] attaching ${JSON.stringify(summary)}`;
203
+ }
204
+ /** The sentence a person reads after a send or a draft that carried files. */
205
+ export function attachmentSentence(summary) {
206
+ return `${summary.count} attachment${summary.count === 1 ? "" : "s"} went with it (${bytesLabel(summary.bytes)}).`;
207
+ }
@@ -18,9 +18,16 @@
18
18
  * `send`, `issue comment` and `docs update` already keep — argv is world-
19
19
  * readable on a shared machine and is captured by this product's own session
20
20
  * harvester.
21
+ *
22
+ * FILES RIDE ALONG WITH `--attach <path>` (BLI-12661), repeatable, on `send` and
23
+ * `draft` alike. `composeMessage` reads them through `mail-attachments.ts` and
24
+ * puts them in the one request body both verbs post, so a field added for one
25
+ * cannot be forgotten by the other. `--file` still means the body and only the
26
+ * body; a path on argv is a path, not the file's contents.
21
27
  */
22
28
  import { askAgentDoor, emitAgentDoor, failAgentDoor, } from "./agent-door.js";
23
29
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
30
+ import { attachmentSentence, describeMailAttachments, mailRequestFits, readMailAttachments, } from "./mail-attachments.js";
24
31
  import { readNoteFile } from "./notes-file.js";
25
32
  // One spelling per surface: the same tag `mail.ts` uses.
26
33
  const TAG = "[mail cli]";
@@ -51,18 +58,86 @@ export async function readMailBody(command, door) {
51
58
  /**
52
59
  * The JSON that `send` and `draft` post. One shape on purpose: the two doors
53
60
  * take the same fields, and a message that can be sent can be saved unsent.
61
+ * `attachments` is present only when there are files (BLI-12661), so a message
62
+ * without any is the same bytes it always was.
54
63
  */
55
- export function composedMessageBody(command, text) {
64
+ export function composedMessageBody(command, text, attachments = []) {
56
65
  return {
57
66
  account_id: command.accountId,
58
67
  to: command.to.map((address) => ({ address })),
59
68
  ...(command.cc.length > 0 ? { cc: command.cc.map((address) => ({ address })) } : {}),
60
69
  subject: command.subjectLine ?? "",
61
70
  text,
71
+ ...(attachments.length > 0 ? { attachments } : {}),
62
72
  ...(command.replyTo ? { in_reply_to_message_id: command.replyTo } : {}),
63
73
  };
64
74
  }
65
- /** `draft --account <id> --to <a[,b]> [--cc <c>] --subject "<s>" [--reply-to <message id>] [--file <path>]` */
75
+ const NO_ATTACHMENTS = { json: {}, sentence: null, posted: null };
76
+ /**
77
+ * Did the door SAY it carried the files that were posted? Claim only what the
78
+ * receipt shows: a Tower that predates attachments answers an ordinary 201,
79
+ * because its schema strips the key it does not know, and the message goes out
80
+ * or is saved WITHOUT its files while a terminal that reported from what it
81
+ * posted would say they went. Only the door's own `attachments` receipt (count
82
+ * and bytes, both) says they did.
83
+ *
84
+ * Returns the sentence to print when the answer does not say so, else null.
85
+ */
86
+ export function unconfirmedAttachments(receipt, carried, verb) {
87
+ if (!receipt.posted)
88
+ return null;
89
+ const said = carried !== null && typeof carried === "object" ? carried : {};
90
+ if (said.count === receipt.posted.count && said.bytes === receipt.posted.bytes)
91
+ return null;
92
+ const files = `${receipt.posted.count} attachment${receipt.posted.count === 1 ? "" : "s"}`;
93
+ return verb === "send"
94
+ ? `Tower accepted the message but did not confirm that its ${files} ${receipt.posted.count === 1 ? "was" : "were"} in it. `
95
+ + "That usually means this Tower deployment predates attachments, in which case the message went out WITHOUT them. "
96
+ + "It was sent once: look in that mailbox's Sent folder before doing anything else."
97
+ : `Tower saved the draft but did not confirm that its ${files} ${receipt.posted.count === 1 ? "was" : "were"} in it. `
98
+ + "That usually means this Tower deployment predates attachments, in which case the draft has NO file. "
99
+ + "Open that mailbox's Drafts, delete it, and try again once Tower is updated.";
100
+ }
101
+ /** The failure for a send or a draft whose files the door did not confirm. The message DID go, so the answer rides along. */
102
+ export function failAttachmentsUnconfirmed(door, verb, detail, answer) {
103
+ writeLine(door.io.stderr, `${TAG} not confirmed ${JSON.stringify({ reason: "attachments_not_confirmed" })}`);
104
+ if (door.json) {
105
+ writeLine(door.io.stdout, JSON.stringify({ ok: false, error: "attachments_not_confirmed", detail, [verb === "send" ? "sent" : "draft"]: answer }));
106
+ }
107
+ else {
108
+ writeLine(door.io.stderr, detail);
109
+ }
110
+ return 1;
111
+ }
112
+ /**
113
+ * The request `send` and `draft` post: the body, plus the files named with
114
+ * `--attach`, read and screened HERE, before any request is made (BLI-12661).
115
+ * A refusal is the named reason and its numbers (`mail-attachments.ts`).
116
+ *
117
+ * The one stderr line it writes says how many files, how big and of what type;
118
+ * never a name, never a byte. Under `--json` stdout still carries one object.
119
+ */
120
+ export async function composeMessage(command, door, text) {
121
+ const read = await readMailAttachments(command.attachPaths ?? []);
122
+ if (!read.ok)
123
+ return read;
124
+ if (read.attachments.length === 0)
125
+ return { ok: true, body: composedMessageBody(command, text), receipt: NO_ATTACHMENTS };
126
+ const body = composedMessageBody(command, text, read.attachments);
127
+ const fits = mailRequestFits(body);
128
+ if (!fits.ok)
129
+ return fits;
130
+ writeLine(door.io.stderr, describeMailAttachments(read.summary));
131
+ return { ok: true, body, receipt: attachmentReceipt(read.summary) };
132
+ }
133
+ function attachmentReceipt(summary) {
134
+ return {
135
+ json: { attachments: { count: summary.count, bytes: summary.bytes, content_types: summary.content_types } },
136
+ sentence: attachmentSentence(summary),
137
+ posted: { count: summary.count, bytes: summary.bytes },
138
+ };
139
+ }
140
+ /** `draft --account <id> --to <a[,b]> [--cc <c>] --subject "<s>" [--reply-to <message id>] [--file <path>] [--attach <path>]...` */
66
141
  export async function runMailDraft(command, door) {
67
142
  const body = await readMailBody(command, door);
68
143
  if (!body.ok)
@@ -70,20 +145,29 @@ export async function runMailDraft(command, door) {
70
145
  if (body.text.trim() === "") {
71
146
  return failAgentDoor(door, TAG, "body_required", 'A draft needs a body: `echo "Tuesday works for me." | cockpit mail draft --account <id> --to someone@example.com --subject "Re: lunch"`.');
72
147
  }
148
+ const message = await composeMessage(command, door, body.text);
149
+ if (!message.ok)
150
+ return failAgentDoor(door, TAG, message.reason, message.detail);
73
151
  const answer = await askAgentDoor(door, {
74
152
  path: "/api/mail/drafts",
75
153
  method: "POST",
76
154
  label: "mail draft",
77
155
  timeoutMs: WRITE_DEADLINE_MS,
78
- body: composedMessageBody(command, body.text),
156
+ body: message.body,
79
157
  });
80
158
  if (!answer.ok)
81
159
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
82
160
  const draft = answer.body.draft;
161
+ // Report only what the door's receipt shows (BLI-12661), never what was posted.
162
+ const unconfirmed = unconfirmedAttachments(message.receipt, draft?.attachments, "draft");
163
+ if (unconfirmed)
164
+ return failAttachmentsUnconfirmed(door, "draft", unconfirmed, draft);
83
165
  if (door.json)
84
- return emitAgentDoor(door, { ok: true, draft });
166
+ return emitAgentDoor(door, { ok: true, draft, ...message.receipt.json });
85
167
  writeLine(door.io.stdout, `Saved a draft in ${draft?.address ?? "that mailbox"} for ${command.to.join(", ")}. `
86
168
  + "Nothing was sent: it is in Drafts for a person to read, edit and send.");
169
+ if (message.receipt.sentence)
170
+ writeLine(door.io.stdout, message.receipt.sentence);
87
171
  if (command.replyTo) {
88
172
  // `threaded` is what Gmail answered, not what was asked: a reply is only
89
173
  // filed in its conversation when the subject still matches the original's.
@@ -25,7 +25,7 @@
25
25
  import { writeFile } from "node:fs/promises";
26
26
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
27
27
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
28
- import { composedMessageBody, readMailBody, runMailDraft } from "./mail-compose.js";
28
+ import { composeMessage, failAttachmentsUnconfirmed, readMailBody, runMailDraft, unconfirmedAttachments, } from "./mail-compose.js";
29
29
  import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
30
30
  import { towerRequest } from "../tower-client.js";
31
31
  const TAG = "[mail cli]";
@@ -259,19 +259,29 @@ async function send(command, door) {
259
259
  if (body.text.trim() === "") {
260
260
  return failAgentDoor(door, TAG, "body_required", 'A message needs a body: `echo "on my way" | cockpit mail send --account <id> --to someone@example.com --subject "Re: lunch"`.');
261
261
  }
262
+ // The files named with --attach are read and refused HERE, by name, before any request.
263
+ const message = await composeMessage(command, door, body.text);
264
+ if (!message.ok)
265
+ return failAgentDoor(door, TAG, message.reason, message.detail);
262
266
  const answer = await askAgentDoor(door, {
263
267
  path: "/api/mail/send",
264
268
  method: "POST",
265
269
  label: "mail send",
266
270
  timeoutMs: WRITE_DEADLINE_MS,
267
- body: composedMessageBody(command, body.text),
271
+ body: message.body,
268
272
  });
269
273
  if (!answer.ok)
270
274
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
271
275
  const sent = answer.body.sent;
276
+ // Report only what the door's receipt shows (BLI-12661), never what was posted.
277
+ const unconfirmed = unconfirmedAttachments(message.receipt, sent?.attachments, "send");
278
+ if (unconfirmed)
279
+ return failAttachmentsUnconfirmed(door, "send", unconfirmed, sent);
272
280
  if (door.json)
273
- return emitAgentDoor(door, { ok: true, sent });
281
+ return emitAgentDoor(door, { ok: true, sent, ...message.receipt.json });
274
282
  writeLine(door.io.stdout, `Sent from ${sent?.address ?? "that mailbox"} to ${command.to.join(", ")}${sent?.threaded ? " (as a reply)" : ""}.`);
283
+ if (message.receipt.sentence)
284
+ writeLine(door.io.stdout, message.receipt.sentence);
275
285
  return 0;
276
286
  }
277
287
  async function attachment(command, door) {
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.167");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.168");
25
25
  return 0;
26
26
  }
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.167",
3
+ "version": "0.2.168",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.45",
31
- "@bli-cockpit/mcp": "0.1.85",
31
+ "@bli-cockpit/mcp": "0.1.86",
32
32
  "@bli-cockpit/telemetry-core": "0.1.57"
33
33
  },
34
34
  "repository": {