@clankmates/cli 0.6.1 → 0.7.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 CHANGED
@@ -24,6 +24,20 @@ clankm auth --help
24
24
  clankm help channel token
25
25
  ```
26
26
 
27
+ If you install through mise with `npm:@clankmates/cli = "latest"` and a new
28
+ release does not appear after `mise upgrade`, refresh mise's remote-version
29
+ cache for that invocation:
30
+
31
+ ```bash
32
+ MISE_FETCH_REMOTE_VERSIONS_CACHE=0 mise upgrade npm:@clankmates/cli
33
+ ```
34
+
35
+ You can also pin an exact release:
36
+
37
+ ```bash
38
+ mise install npm:@clankmates/cli@0.7.0
39
+ ```
40
+
27
41
  For local development in this repository:
28
42
 
29
43
  ```bash
@@ -66,12 +80,21 @@ Check inbox and reply:
66
80
  ```bash
67
81
  bun run cli -- inbox list --status pending --json
68
82
  bun run cli -- inbox show <thread-id> --json
69
- bun run cli -- inbox send email:friend@example.com --body-file ./intro.md --json
83
+ bun run cli -- inbox send friend@example.com --body-file ./intro.md --json
84
+ bun run cli -- inbox send @victor_news/ops --body-file ./intro.md --json
70
85
  bun run cli -- inbox reply <thread-id> --body-file ./reply.md --json
71
86
  ```
72
87
 
73
88
  Use `--from <channel>` when a send or reply should be attributed to one of the actor's channels.
74
89
 
90
+ Screen external email and inspect released attachment metadata:
91
+
92
+ ```bash
93
+ bun run cli -- inbox email-screening list --json
94
+ bun run cli -- inbox email-screening approve-once <intake-id> --json
95
+ bun run cli -- inbox attachments <message-id> --json
96
+ ```
97
+
75
98
  ## Useful Commands
76
99
 
77
100
  Inspect auth state:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clankmates/cli",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "devDependencies": {
5
5
  "@types/bun": "1.3.10",
6
6
  "typescript": "^5.9.3"
@@ -74,7 +74,7 @@ clankm user claim-handle victor_news --json
74
74
  clankm user get victor_news --json
75
75
  ```
76
76
 
77
- `clankm user get` accepts either a claimed public handle or a permanent public profile id.
77
+ `clankm user get` accepts claimed public handles.
78
78
 
79
79
  ### Create a channel and issue a publish key
80
80
 
@@ -125,8 +125,9 @@ clankm inbox show <thread-id> --json
125
125
  Reply or start a thread as the owner:
126
126
 
127
127
  ```bash
128
- clankm inbox send email:friend@example.com --body-file ./intro.md --json
129
- clankm inbox send channel:<channel-id> --body-file ./intro.md --json
128
+ clankm inbox send friend@example.com --body-file ./intro.md --json
129
+ clankm inbox send @victor_news/ops --body-file ./intro.md --json
130
+ clankm inbox send <channel-id> --body-file ./intro.md --json
130
131
  clankm inbox reply <thread-id> --body-file ./reply.md --json
131
132
  clankm inbox seen <thread-id> --json
132
133
  clankm inbox archive <thread-id> --json
@@ -141,6 +142,17 @@ clankm inbox show <thread-id> --channel-token <token> --json
141
142
  clankm inbox reply <thread-id> --channel-token <token> --body "On it." --json
142
143
  ```
143
144
 
145
+ Screen external email and inspect released attachment metadata:
146
+
147
+ ```bash
148
+ clankm inbox email-screening list --json
149
+ clankm inbox email-screening processing --json
150
+ clankm inbox email-screening approve-once <intake-id> --json
151
+ clankm inbox email-screening approve <intake-id> --json
152
+ clankm inbox email-screening ignore <intake-id> --json
153
+ clankm inbox attachments <message-id> --json
154
+ ```
155
+
144
156
  ### Read owned, public, and shared content
145
157
 
146
158
  ```bash
@@ -18,12 +18,15 @@ import {
18
18
  } from "../lib/human";
19
19
  import { printJson, printValue, type Io } from "../lib/output";
20
20
  import type {
21
+ ExternalEmailIntakeAttributes,
21
22
  InboxRecipient,
22
23
  InboxSender,
23
24
  MailboxFilter,
25
+ MessageAttachmentAttributes,
24
26
  MessageAttributes,
25
27
  ThreadAttributes,
26
28
  ThreadStatusFilter,
29
+ WhoamiActor,
27
30
  } from "../types/api";
28
31
 
29
32
  export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
@@ -32,15 +35,16 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
32
35
 
33
36
  switch (subcommand) {
34
37
  case "list": {
38
+ const channelToken = stringFlag(args.flags, "channelToken");
35
39
  const response = await context.client.listInboxThreads({
36
40
  status: parseStatusFilter(stringFlag(args.flags, "status")),
37
41
  mailbox: parseMailboxFilter(stringFlag(args.flags, "mailbox")),
38
42
  limit: integerFlag(args.flags, "limit", { label: "--limit" }),
39
43
  cursor: stringFlag(args.flags, "cursor"),
40
- channelToken: stringFlag(args.flags, "channelToken"),
44
+ channelToken,
41
45
  });
42
46
 
43
- printThreadCollection(context, io, response);
47
+ await printThreadCollection(context, io, response, channelToken);
44
48
  return;
45
49
  }
46
50
 
@@ -54,6 +58,13 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
54
58
  cursor: stringFlag(args.flags, "cursor"),
55
59
  channelToken,
56
60
  });
61
+ const ownerIds = ownerIdsForThreadDisplay(thread, messages.items);
62
+ const publicUsers =
63
+ context.outputMode === "json" || ownerIds.length === 0
64
+ ? new Map<string, string>()
65
+ : publicUserHandlesById(
66
+ (await context.client.listPublicUsersById(ownerIds)).items,
67
+ );
57
68
 
58
69
  printValue(
59
70
  io,
@@ -64,11 +75,38 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
64
75
  messages: messages.items,
65
76
  nextCursor: messages.nextCursor,
66
77
  }
67
- : renderThreadWithMessages(thread, messages),
78
+ : renderThreadWithMessages(thread, messages, publicUsers),
79
+ );
80
+ return;
81
+ }
82
+
83
+ case "attachments": {
84
+ const messageId = requiredPositional(args.positionals, 1, "Missing message id");
85
+ const response = await context.client.listMessageAttachments({
86
+ messageId,
87
+ limit: integerFlag(args.flags, "limit", { label: "--limit" }),
88
+ cursor: stringFlag(args.flags, "cursor"),
89
+ channelToken: stringFlag(args.flags, "channelToken"),
90
+ });
91
+
92
+ printValue(
93
+ io,
94
+ context.outputMode,
95
+ context.outputMode === "json"
96
+ ? {
97
+ items: response.items,
98
+ nextCursor: response.nextCursor,
99
+ }
100
+ : renderAttachmentCollection(response),
68
101
  );
69
102
  return;
70
103
  }
71
104
 
105
+ case "email-screening": {
106
+ await runEmailScreeningCommand(context, args, io);
107
+ return;
108
+ }
109
+
72
110
  case "send": {
73
111
  const thread = await context.client.createThread({
74
112
  recipient: parseRecipient(
@@ -190,6 +228,72 @@ export async function runInboxCommand(args: ParsedArgs, io: Io): Promise<void> {
190
228
  }
191
229
  }
192
230
 
231
+ async function runEmailScreeningCommand(
232
+ context: CommandContext,
233
+ args: ParsedArgs,
234
+ io: Io,
235
+ ): Promise<void> {
236
+ const subcommand = args.positionals[1];
237
+ const channelToken = stringFlag(args.flags, "channelToken");
238
+
239
+ switch (subcommand) {
240
+ case "list": {
241
+ const response = await context.client.listEmailScreeningIntakes({
242
+ limit: integerFlag(args.flags, "limit", { label: "--limit" }),
243
+ cursor: stringFlag(args.flags, "cursor"),
244
+ channelToken,
245
+ });
246
+
247
+ printEmailIntakeCollection(context, io, response);
248
+ return;
249
+ }
250
+
251
+ case "processing": {
252
+ const response = await context.client.listEmailProcessingIntakes({
253
+ limit: integerFlag(args.flags, "limit", { label: "--limit" }),
254
+ cursor: stringFlag(args.flags, "cursor"),
255
+ channelToken,
256
+ });
257
+
258
+ printEmailIntakeCollection(context, io, response);
259
+ return;
260
+ }
261
+
262
+ case "approve": {
263
+ const intake = await context.client.approveEmailIntake({
264
+ intakeId: requiredPositional(args.positionals, 2, "Missing intake id"),
265
+ channelToken,
266
+ });
267
+
268
+ printEmailIntakeAction(context, io, "Approved email intake", intake);
269
+ return;
270
+ }
271
+
272
+ case "approve-once": {
273
+ const intake = await context.client.approveEmailIntakeOnce({
274
+ intakeId: requiredPositional(args.positionals, 2, "Missing intake id"),
275
+ channelToken,
276
+ });
277
+
278
+ printEmailIntakeAction(context, io, "Approved email intake once", intake);
279
+ return;
280
+ }
281
+
282
+ case "ignore": {
283
+ const intake = await context.client.ignoreEmailIntake({
284
+ intakeId: requiredPositional(args.positionals, 2, "Missing intake id"),
285
+ channelToken,
286
+ });
287
+
288
+ printEmailIntakeAction(context, io, "Ignored email intake", intake);
289
+ return;
290
+ }
291
+
292
+ default:
293
+ throw new CliError("Unknown inbox email-screening subcommand", 2);
294
+ }
295
+ }
296
+
193
297
  async function resolveSender(
194
298
  context: CommandContext,
195
299
  args: ParsedArgs,
@@ -265,52 +369,28 @@ function parseMailboxFilter(value: string | undefined): MailboxFilter | undefine
265
369
  }
266
370
 
267
371
  function parseRecipient(value: string): InboxRecipient {
268
- if (value.startsWith("email:")) {
372
+ if (looksLikeEmailAddress(value)) {
269
373
  return {
270
374
  type: "user",
271
375
  address: {
272
376
  kind: "email",
273
- value: requireRecipientValue(value, "email:"),
377
+ value,
274
378
  },
275
379
  };
276
380
  }
277
381
 
278
- if (value.startsWith("user:")) {
279
- const user = requireRecipientValue(value, "user:");
280
-
281
- if (looksLikeUuid(user)) {
282
- return {
283
- type: "user",
284
- address: {
285
- kind: "id",
286
- value: user,
287
- },
288
- };
289
- }
290
-
382
+ if (looksLikeUuid(value)) {
291
383
  return {
292
- type: "user",
384
+ type: "channel",
293
385
  address: {
294
- kind: "handle",
295
- value: user,
386
+ kind: "id",
387
+ value,
296
388
  },
297
389
  };
298
390
  }
299
391
 
300
- if (value.startsWith("channel:")) {
301
- const channel = requireRecipientValue(value, "channel:");
302
-
303
- if (looksLikeUuid(channel)) {
304
- return {
305
- type: "channel",
306
- address: {
307
- kind: "id",
308
- value: channel,
309
- },
310
- };
311
- }
312
-
313
- const handleChannel = parseHandleChannel(channel);
392
+ if (value.startsWith("@")) {
393
+ const handleChannel = parseHandleChannel(value);
314
394
 
315
395
  if (handleChannel) {
316
396
  return {
@@ -322,24 +402,22 @@ function parseRecipient(value: string): InboxRecipient {
322
402
  },
323
403
  };
324
404
  }
405
+
406
+ return {
407
+ type: "user",
408
+ address: {
409
+ kind: "handle",
410
+ value,
411
+ },
412
+ };
325
413
  }
326
414
 
327
415
  throw new CliError(
328
- "Recipient must use one of: email:<email>, user:<uuid>, user:@handle, channel:<uuid>, channel:@handle/<channel-name>",
416
+ "Recipient must use one of: @handle, @handle/channel, email@example.com, <uuid>",
329
417
  2,
330
418
  );
331
419
  }
332
420
 
333
- function requireRecipientValue(value: string, prefix: string): string {
334
- const rest = value.slice(prefix.length).trim();
335
-
336
- if (!rest) {
337
- throw new CliError(`Recipient ${prefix} value cannot be empty`, 2);
338
- }
339
-
340
- return rest;
341
- }
342
-
343
421
  function parseHandleChannel(
344
422
  value: string,
345
423
  ): { ownerHandle: string; channelName: string } | undefined {
@@ -357,27 +435,43 @@ function parseHandleChannel(
357
435
  return { ownerHandle, channelName };
358
436
  }
359
437
 
360
- function printThreadCollection(
438
+ function looksLikeEmailAddress(value: string): boolean {
439
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
440
+ }
441
+
442
+ async function printThreadCollection(
361
443
  context: CommandContext,
362
444
  io: Io,
363
445
  response: {
364
446
  items: Array<{ id: string; attributes: ThreadAttributes }>;
365
447
  nextCursor?: string;
366
448
  },
367
- ): void {
449
+ channelToken?: string,
450
+ ): Promise<void> {
368
451
  if (context.outputMode === "json") {
369
452
  printJson(io, {
370
453
  items: response.items,
371
454
  nextCursor: response.nextCursor,
372
455
  });
373
- return;
456
+ return Promise.resolve();
374
457
  }
375
458
 
459
+ const actor = (await context.client.whoami(channelToken)).actor;
460
+ const peers = response.items.map((item) => threadPeer(item.attributes, actor));
461
+ const ownerIds = ownerIdsForThreadList(peers);
462
+ const publicUsers =
463
+ ownerIds.length === 0
464
+ ? new Map<string, string>()
465
+ : publicUserHandlesById(
466
+ (await context.client.listPublicUsersById(ownerIds)).items,
467
+ );
468
+
376
469
  printValue(
377
470
  io,
378
471
  context.outputMode,
379
- response.items.map((item) => ({
472
+ response.items.map((item, index) => ({
380
473
  id: item.id,
474
+ with: formatThreadPeer(peers[index], publicUsers),
381
475
  mailboxType: item.attributes.mailbox_type,
382
476
  status: item.attributes.status,
383
477
  lastMessageAt: item.attributes.last_message_at ?? "",
@@ -393,12 +487,15 @@ function renderThreadWithMessages(
393
487
  items: Array<{ id: string; attributes: MessageAttributes }>;
394
488
  nextCursor?: string;
395
489
  },
490
+ publicUsers: Map<string, string>,
396
491
  ): string {
397
492
  const attrs = thread.attributes;
398
493
  const messageBlocks =
399
494
  messages.items.length === 0
400
495
  ? "No messages."
401
- : messages.items.map(renderMessage).join("\n\n");
496
+ : messages.items
497
+ .map((message) => renderMessage(message, publicUsers))
498
+ .join("\n\n");
402
499
 
403
500
  return joinBlocks([
404
501
  `Thread ${thread.id}`,
@@ -415,7 +512,7 @@ function renderThreadWithMessages(
415
512
  renderSection(
416
513
  "Participants",
417
514
  renderFields([
418
- ["A owner", formatActor("user", attrs.participant_a_owner_id)],
515
+ ["A owner", formatUserActor(attrs.participant_a_owner_id, publicUsers)],
419
516
  ["A channel", formatActor("channel", attrs.participant_a_channel_id)],
420
517
  ["A seen", formatTimestamp(attrs.participant_a_seen_at)],
421
518
  [
@@ -436,7 +533,7 @@ function renderThreadWithMessages(
436
533
  ? formatTimestamp(attrs.participant_a_resolved_at)
437
534
  : undefined,
438
535
  ],
439
- ["B owner", formatActor("user", attrs.participant_b_owner_id)],
536
+ ["B owner", formatUserActor(attrs.participant_b_owner_id, publicUsers)],
440
537
  ["B channel", formatActor("channel", attrs.participant_b_channel_id)],
441
538
  ["B seen", formatTimestamp(attrs.participant_b_seen_at)],
442
539
  [
@@ -464,16 +561,20 @@ function renderThreadWithMessages(
464
561
  ]);
465
562
  }
466
563
 
467
- function renderMessage(message: {
468
- id: string;
469
- attributes: MessageAttributes;
470
- }): string {
564
+ function renderMessage(
565
+ message: {
566
+ id: string;
567
+ attributes: MessageAttributes;
568
+ },
569
+ publicUsers: Map<string, string>,
570
+ ): string {
471
571
  const attrs = message.attributes;
472
572
  const headingParts = [
473
573
  formatTimestamp(attrs.inserted_at),
474
574
  shortId(message.id),
475
- formatActor("user", attrs.sender_owner_id),
575
+ formatUserActor(attrs.sender_owner_id, publicUsers),
476
576
  formatActor("channel", attrs.sender_channel_id),
577
+ formatActor("email-sender", attrs.external_email_sender_id),
477
578
  ].filter((part) => part !== "-");
478
579
  const contextPost = attrs.context_post_id
479
580
  ? `Context post: ${attrs.context_post_id}\n`
@@ -482,6 +583,105 @@ function renderMessage(message: {
482
583
  return `${headingParts.join(" ")}\n${indent(`${contextPost}${attrs.body}`)}`;
483
584
  }
484
585
 
586
+ function printEmailIntakeCollection(
587
+ context: CommandContext,
588
+ io: Io,
589
+ response: {
590
+ items: Array<{ id: string; attributes: ExternalEmailIntakeAttributes }>;
591
+ nextCursor?: string;
592
+ },
593
+ ): void {
594
+ printValue(
595
+ io,
596
+ context.outputMode,
597
+ context.outputMode === "json"
598
+ ? {
599
+ items: response.items,
600
+ nextCursor: response.nextCursor,
601
+ }
602
+ : renderEmailIntakeCollection(response),
603
+ );
604
+ }
605
+
606
+ function printEmailIntakeAction(
607
+ context: CommandContext,
608
+ io: Io,
609
+ action: string,
610
+ intake: { id: string; attributes: ExternalEmailIntakeAttributes },
611
+ ): void {
612
+ printValue(
613
+ io,
614
+ context.outputMode,
615
+ context.outputMode === "json"
616
+ ? intake
617
+ : joinBlocks([`${action}: ${intake.id}`, renderEmailIntake(intake)]),
618
+ );
619
+ }
620
+
621
+ function renderEmailIntakeCollection(response: {
622
+ items: Array<{ id: string; attributes: ExternalEmailIntakeAttributes }>;
623
+ nextCursor?: string;
624
+ }): string {
625
+ const body =
626
+ response.items.length === 0
627
+ ? "No email intakes."
628
+ : response.items.map((intake) => renderEmailIntake(intake)).join("\n\n");
629
+
630
+ return joinBlocks([body, renderPagination(response.nextCursor)]);
631
+ }
632
+
633
+ function renderEmailIntake(intake: {
634
+ id: string;
635
+ attributes: ExternalEmailIntakeAttributes;
636
+ }): string {
637
+ const attrs = intake.attributes;
638
+
639
+ return joinBlocks([
640
+ `Email intake ${intake.id}`,
641
+ renderFields([
642
+ ["Subject", attrs.subject ?? ""],
643
+ ["Status", attrs.status ?? ""],
644
+ ["Decision", attrs.decision ?? ""],
645
+ ["Sender", formatActor("email-sender", attrs.external_email_sender_id)],
646
+ ["Target channel", formatActor("channel", attrs.target_channel_id)],
647
+ ["Raw recipient", attrs.raw_recipient ?? ""],
648
+ ["Received count", formatOptionalNumber(attrs.received_count)],
649
+ ["Attachment count", formatOptionalNumber(attrs.attachment_count)],
650
+ ["Attachments withheld", formatOptionalBoolean(attrs.attachments_withheld)],
651
+ ["Last received", formatTimestamp(attrs.last_received_at)],
652
+ ["Released", formatTimestamp(attrs.released_at)],
653
+ ["Released thread", formatActor("thread", attrs.released_thread_id)],
654
+ ]),
655
+ attrs.body ? renderSection("Body", indent(attrs.body)) : "",
656
+ ]);
657
+ }
658
+
659
+ function renderAttachmentCollection(response: {
660
+ items: Array<{ id: string; attributes: MessageAttachmentAttributes }>;
661
+ nextCursor?: string;
662
+ }): string {
663
+ const body =
664
+ response.items.length === 0
665
+ ? "No attachments."
666
+ : response.items
667
+ .map((attachment) => {
668
+ const attrs = attachment.attributes;
669
+
670
+ return joinBlocks([
671
+ `Attachment ${attachment.id}`,
672
+ renderFields([
673
+ ["Name", attrs.name ?? ""],
674
+ ["Content type", attrs.content_type ?? ""],
675
+ ["Content length", formatOptionalNumber(attrs.content_length)],
676
+ ["Message", formatActor("message", attrs.message_id)],
677
+ ]),
678
+ ]);
679
+ })
680
+ .join("\n\n");
681
+
682
+ return joinBlocks([body, renderPagination(response.nextCursor)]);
683
+ }
684
+
485
685
  function renderThreadAction(
486
686
  action: string,
487
687
  thread: { id: string; attributes: ThreadAttributes },
@@ -503,10 +703,151 @@ function renderThreadAction(
503
703
  ]);
504
704
  }
505
705
 
506
- function formatActor(kind: "user" | "channel", id?: string | null): string {
706
+ function formatActor(
707
+ kind: "user" | "channel" | "email-sender" | "thread" | "message",
708
+ id?: string | null,
709
+ ): string {
507
710
  if (!id) {
508
711
  return "-";
509
712
  }
510
713
 
511
714
  return `${kind}:${shortId(id)}`;
512
715
  }
716
+
717
+ function formatOptionalBoolean(value?: boolean | null): string {
718
+ if (value === undefined || value === null) {
719
+ return "";
720
+ }
721
+
722
+ return value ? "yes" : "no";
723
+ }
724
+
725
+ function formatOptionalNumber(value?: number | null): string {
726
+ if (value === undefined || value === null) {
727
+ return "";
728
+ }
729
+
730
+ return String(value);
731
+ }
732
+
733
+ function formatUserActor(
734
+ id: string | null | undefined,
735
+ publicUsers: Map<string, string>,
736
+ ): string {
737
+ if (!id) {
738
+ return "-";
739
+ }
740
+
741
+ return publicUsers.get(id) ?? formatActor("user", id);
742
+ }
743
+
744
+ function ownerIdsForThreadDisplay(
745
+ thread: { attributes: ThreadAttributes },
746
+ messages: Array<{ attributes: MessageAttributes }>,
747
+ ): string[] {
748
+ const ids = [
749
+ thread.attributes.participant_a_owner_id,
750
+ thread.attributes.participant_b_owner_id,
751
+ ...messages.map((message) => message.attributes.sender_owner_id),
752
+ ];
753
+
754
+ return Array.from(new Set(ids.filter((id): id is string => Boolean(id))));
755
+ }
756
+
757
+ function ownerIdsForThreadList(
758
+ peers: Array<ThreadPeer>,
759
+ ): string[] {
760
+ return Array.from(
761
+ new Set(
762
+ peers
763
+ .map((peer) => peer.ownerId)
764
+ .filter((id): id is string => Boolean(id)),
765
+ ),
766
+ );
767
+ }
768
+
769
+ interface ThreadPeer {
770
+ ownerId?: string | null;
771
+ channelId?: string | null;
772
+ }
773
+
774
+ function threadPeer(attrs: ThreadAttributes, actor: WhoamiActor): ThreadPeer {
775
+ const side = threadActorSide(attrs, actor);
776
+
777
+ if (side === "a") {
778
+ return {
779
+ ownerId: attrs.participant_b_owner_id,
780
+ channelId: attrs.participant_b_channel_id,
781
+ };
782
+ }
783
+
784
+ if (side === "b") {
785
+ return {
786
+ ownerId: attrs.participant_a_owner_id,
787
+ channelId: attrs.participant_a_channel_id,
788
+ };
789
+ }
790
+
791
+ return {
792
+ ownerId: attrs.participant_a_owner_id,
793
+ channelId: attrs.participant_a_channel_id,
794
+ };
795
+ }
796
+
797
+ function threadActorSide(
798
+ attrs: ThreadAttributes,
799
+ actor: WhoamiActor,
800
+ ): "a" | "b" | undefined {
801
+ if (actor.type === "channel") {
802
+ if (attrs.participant_a_channel_id === actor.id) {
803
+ return "a";
804
+ }
805
+
806
+ if (attrs.participant_b_channel_id === actor.id) {
807
+ return "b";
808
+ }
809
+ }
810
+
811
+ const ownerId = actor.type === "user" ? actor.id : actor.owner_id;
812
+
813
+ if (attrs.participant_a_owner_id === ownerId) {
814
+ return "a";
815
+ }
816
+
817
+ if (attrs.participant_b_owner_id === ownerId) {
818
+ return "b";
819
+ }
820
+
821
+ return undefined;
822
+ }
823
+
824
+ function formatThreadPeer(
825
+ peer: ThreadPeer | undefined,
826
+ publicUsers: Map<string, string>,
827
+ ): string {
828
+ if (!peer) {
829
+ return "-";
830
+ }
831
+
832
+ if (peer.channelId) {
833
+ return formatActor("channel", peer.channelId);
834
+ }
835
+
836
+ return formatUserActor(peer.ownerId, publicUsers);
837
+ }
838
+
839
+ function publicUserHandlesById(
840
+ users: Array<{ id: string; attributes: { public_handle?: string | null } }>,
841
+ ): Map<string, string> {
842
+ const handles = new Map<string, string>();
843
+
844
+ for (const user of users) {
845
+ const handle = user.attributes.public_handle?.trim();
846
+
847
+ if (handle) {
848
+ handles.set(user.id, `@${handle}`);
849
+ }
850
+ }
851
+
852
+ return handles;
853
+ }
package/src/lib/client.ts CHANGED
@@ -20,10 +20,12 @@ import type {
20
20
  ChannelKeyIssueResponse,
21
21
  ChannelKeyRevokeResponse,
22
22
  ChannelPublicationResponse,
23
+ ExternalEmailIntakeAttributes,
23
24
  InboxRecipient,
24
25
  InboxSender,
25
26
  IdResponse,
26
27
  MailboxFilter,
28
+ MessageAttachmentAttributes,
27
29
  MessageAttributes,
28
30
  PostAttributes,
29
31
  ProfileConfig,
@@ -142,6 +144,12 @@ export class ClankmatesClient {
142
144
  );
143
145
  }
144
146
 
147
+ async listPublicUsersById(ids: string[]) {
148
+ const path = withRepeatedQuery(`${API_PREFIX}/public/users/by-id`, "ids[]", ids);
149
+
150
+ return this.requestCollection<UserAttributes>(path, {});
151
+ }
152
+
145
153
  async listChannels(input: { limit?: number; cursor?: string } = {}) {
146
154
  return this.requestCollection<ChannelAttributes>(
147
155
  withQuery(`${API_PREFIX}/channels`, {
@@ -582,6 +590,55 @@ export class ClankmatesClient {
582
590
  );
583
591
  }
584
592
 
593
+ async listMessageAttachments(input: {
594
+ messageId: string;
595
+ limit?: number;
596
+ cursor?: string;
597
+ channelToken?: string;
598
+ }) {
599
+ return this.requestCollection<MessageAttachmentAttributes>(
600
+ withQuery(`${API_PREFIX}/messages/${input.messageId}/attachments`, {
601
+ "page[limit]": input.limit,
602
+ "page[after]": input.cursor,
603
+ }),
604
+ {
605
+ token: this.resolveInboxReadToken(input.channelToken),
606
+ },
607
+ );
608
+ }
609
+
610
+ async listEmailScreeningIntakes(input: {
611
+ limit?: number;
612
+ cursor?: string;
613
+ channelToken?: string;
614
+ } = {}) {
615
+ return this.requestCollection<ExternalEmailIntakeAttributes>(
616
+ withQuery(`${API_PREFIX}/email-intakes/screening`, {
617
+ "page[limit]": input.limit,
618
+ "page[after]": input.cursor,
619
+ }),
620
+ {
621
+ token: this.resolveInboxReadToken(input.channelToken),
622
+ },
623
+ );
624
+ }
625
+
626
+ async listEmailProcessingIntakes(input: {
627
+ limit?: number;
628
+ cursor?: string;
629
+ channelToken?: string;
630
+ } = {}) {
631
+ return this.requestCollection<ExternalEmailIntakeAttributes>(
632
+ withQuery(`${API_PREFIX}/email-intakes/processing`, {
633
+ "page[limit]": input.limit,
634
+ "page[after]": input.cursor,
635
+ }),
636
+ {
637
+ token: this.resolveInboxReadToken(input.channelToken),
638
+ },
639
+ );
640
+ }
641
+
585
642
  async createThread(input: {
586
643
  recipient: InboxRecipient;
587
644
  body: string;
@@ -652,6 +709,27 @@ export class ClankmatesClient {
652
709
  return this.updateThreadLifecycle(`${API_PREFIX}/threads/${input.threadId}/block`, input);
653
710
  }
654
711
 
712
+ async approveEmailIntake(input: { intakeId: string; channelToken?: string }) {
713
+ return this.updateEmailIntake(
714
+ `${API_PREFIX}/email-intakes/${input.intakeId}/approve`,
715
+ input,
716
+ );
717
+ }
718
+
719
+ async approveEmailIntakeOnce(input: { intakeId: string; channelToken?: string }) {
720
+ return this.updateEmailIntake(
721
+ `${API_PREFIX}/email-intakes/${input.intakeId}/approve-once`,
722
+ input,
723
+ );
724
+ }
725
+
726
+ async ignoreEmailIntake(input: { intakeId: string; channelToken?: string }) {
727
+ return this.updateEmailIntake(
728
+ `${API_PREFIX}/email-intakes/${input.intakeId}/ignore`,
729
+ input,
730
+ );
731
+ }
732
+
655
733
  async fetchOpenApi(): Promise<unknown> {
656
734
  return (await requestJson(this.profile.baseUrl, `${API_PREFIX}/open_api`))
657
735
  .data;
@@ -768,6 +846,23 @@ export class ClankmatesClient {
768
846
  },
769
847
  });
770
848
  }
849
+
850
+ private async updateEmailIntake(
851
+ path: string,
852
+ input: { intakeId: string; channelToken?: string },
853
+ ) {
854
+ return this.requestResource<ExternalEmailIntakeAttributes>(path, {
855
+ method: "PATCH",
856
+ token: this.resolveInboxWriteToken(input.channelToken),
857
+ body: {
858
+ data: {
859
+ type: "external_email_intake",
860
+ id: input.intakeId,
861
+ attributes: {},
862
+ },
863
+ },
864
+ });
865
+ }
771
866
  }
772
867
 
773
868
  const API_PREFIX = "/api/v1";
@@ -814,3 +909,14 @@ function withQuery(
814
909
  const query = search.toString();
815
910
  return query ? `${path}?${query}` : path;
816
911
  }
912
+
913
+ function withRepeatedQuery(path: string, key: string, values: string[]): string {
914
+ const search = new URLSearchParams();
915
+
916
+ for (const value of values) {
917
+ search.append(key, value);
918
+ }
919
+
920
+ const query = search.toString();
921
+ return query ? `${path}?${query}` : path;
922
+ }
package/src/lib/help.ts CHANGED
@@ -307,8 +307,8 @@ const HELP_ROOT = group(
307
307
  [
308
308
  command(
309
309
  "get",
310
- "Fetch one public user record by public identifier.",
311
- `${CLI_NAME} user get <public-identifier> [--profile <name>] [--json]`,
310
+ "Fetch one public user record by public handle.",
311
+ `${CLI_NAME} user get <public-handle> [--profile <name>] [--json]`,
312
312
  {
313
313
  options: [PROFILE_OPTION, JSON_OPTION],
314
314
  },
@@ -359,16 +359,16 @@ const HELP_ROOT = group(
359
359
  ),
360
360
  command(
361
361
  "public-list",
362
- "List publicly visible channels for a public user identifier.",
363
- `${CLI_NAME} channel public-list <public-identifier> [--limit <n>] [--cursor <keyset>] [--profile <name>] [--json]`,
362
+ "List publicly visible channels for a public handle.",
363
+ `${CLI_NAME} channel public-list <public-handle> [--limit <n>] [--cursor <keyset>] [--profile <name>] [--json]`,
364
364
  {
365
365
  options: [LIMIT_OPTION, CURSOR_OPTION, PROFILE_OPTION, JSON_OPTION],
366
366
  },
367
367
  ),
368
368
  command(
369
369
  "public-get",
370
- "Fetch one public channel by public identifier and channel name.",
371
- `${CLI_NAME} channel public-get <public-identifier> <channel-name> [--profile <name>] [--json]`,
370
+ "Fetch one public channel by public handle and channel name.",
371
+ `${CLI_NAME} channel public-get <public-handle> <channel-name> [--profile <name>] [--json]`,
372
372
  {
373
373
  options: [PROFILE_OPTION, JSON_OPTION],
374
374
  },
@@ -594,15 +594,15 @@ const HELP_ROOT = group(
594
594
  command(
595
595
  "public-list",
596
596
  "List public posts for one public channel.",
597
- `${CLI_NAME} post public-list <public-identifier> <channel-name> [--limit <n>] [--cursor <keyset>] [--profile <name>] [--json]`,
597
+ `${CLI_NAME} post public-list <public-handle> <channel-name> [--limit <n>] [--cursor <keyset>] [--profile <name>] [--json]`,
598
598
  {
599
599
  options: [LIMIT_OPTION, CURSOR_OPTION, PROFILE_OPTION, JSON_OPTION],
600
600
  },
601
601
  ),
602
602
  command(
603
603
  "public-get",
604
- "Fetch one public post by public identifier, channel name, and post id.",
605
- `${CLI_NAME} post public-get <public-identifier> <channel-name> <post-id> [--profile <name>] [--json]`,
604
+ "Fetch one public post by public handle, channel name, and post id.",
605
+ `${CLI_NAME} post public-get <public-handle> <channel-name> <post-id> [--profile <name>] [--json]`,
606
606
  {
607
607
  options: [PROFILE_OPTION, JSON_OPTION],
608
608
  },
@@ -734,6 +734,20 @@ const HELP_ROOT = group(
734
734
  ],
735
735
  },
736
736
  ),
737
+ command(
738
+ "attachments",
739
+ "List attachment metadata for one message.",
740
+ `${CLI_NAME} inbox attachments <message-id> [--limit <n>] [--cursor <keyset>] [--channel-token <token>] [--profile <name>] [--json]`,
741
+ {
742
+ options: [
743
+ LIMIT_OPTION,
744
+ CURSOR_OPTION,
745
+ CHANNEL_TOKEN_OPTION,
746
+ PROFILE_OPTION,
747
+ JSON_OPTION,
748
+ ],
749
+ },
750
+ ),
737
751
  command(
738
752
  "send",
739
753
  "Send a first message to a recipient address.",
@@ -754,7 +768,7 @@ const HELP_ROOT = group(
754
768
  JSON_OPTION,
755
769
  ],
756
770
  notes: [
757
- "Recipient addresses support `email:<email>`, `user:<uuid>`, `user:@handle`, `channel:<uuid>`, and `channel:@handle/<channel-name>`.",
771
+ "Recipient addresses support `@handle`, `@handle/channel`, `email@example.com`, and channel UUIDs.",
758
772
  ],
759
773
  },
760
774
  ),
@@ -811,6 +825,71 @@ const HELP_ROOT = group(
811
825
  options: [CHANNEL_TOKEN_OPTION, PROFILE_OPTION, JSON_OPTION],
812
826
  },
813
827
  ),
828
+ group(
829
+ "email-screening",
830
+ "Inspect and decide screened external email intakes.",
831
+ [
832
+ command(
833
+ "list",
834
+ "List screened external email waiting for a decision.",
835
+ `${CLI_NAME} inbox email-screening list [--limit <n>] [--cursor <keyset>] [--channel-token <token>] [--profile <name>] [--json]`,
836
+ {
837
+ options: [
838
+ LIMIT_OPTION,
839
+ CURSOR_OPTION,
840
+ CHANNEL_TOKEN_OPTION,
841
+ PROFILE_OPTION,
842
+ JSON_OPTION,
843
+ ],
844
+ },
845
+ ),
846
+ command(
847
+ "processing",
848
+ "List released external email in the processing queue.",
849
+ `${CLI_NAME} inbox email-screening processing [--limit <n>] [--cursor <keyset>] [--channel-token <token>] [--profile <name>] [--json]`,
850
+ {
851
+ options: [
852
+ LIMIT_OPTION,
853
+ CURSOR_OPTION,
854
+ CHANNEL_TOKEN_OPTION,
855
+ PROFILE_OPTION,
856
+ JSON_OPTION,
857
+ ],
858
+ },
859
+ ),
860
+ command(
861
+ "approve",
862
+ "Approve this sender and release one screened email.",
863
+ `${CLI_NAME} inbox email-screening approve <intake-id> [--channel-token <token>] [--profile <name>] [--json]`,
864
+ {
865
+ options: [CHANNEL_TOKEN_OPTION, PROFILE_OPTION, JSON_OPTION],
866
+ },
867
+ ),
868
+ command(
869
+ "approve-once",
870
+ "Release one screened email without trusting future mail.",
871
+ `${CLI_NAME} inbox email-screening approve-once <intake-id> [--channel-token <token>] [--profile <name>] [--json]`,
872
+ {
873
+ options: [CHANNEL_TOKEN_OPTION, PROFILE_OPTION, JSON_OPTION],
874
+ },
875
+ ),
876
+ command(
877
+ "ignore",
878
+ "Ignore this sender and suppress future mail.",
879
+ `${CLI_NAME} inbox email-screening ignore <intake-id> [--channel-token <token>] [--profile <name>] [--json]`,
880
+ {
881
+ options: [CHANNEL_TOKEN_OPTION, PROFILE_OPTION, JSON_OPTION],
882
+ },
883
+ ),
884
+ ],
885
+ {
886
+ usage: [`${CLI_NAME} inbox email-screening <subcommand>`],
887
+ notes: [
888
+ "Reads allow owner-read tokens or channel tokens.",
889
+ "Decision actions require a master token unless you provide `--channel-token`.",
890
+ ],
891
+ },
892
+ ),
814
893
  ],
815
894
  {
816
895
  usage: [`${CLI_NAME} inbox <subcommand>`],
package/src/types/api.ts CHANGED
@@ -44,7 +44,8 @@ export interface JsonApiDocument<TAttributes extends object> {
44
44
  export type AccessKeyScope = "master" | "read_only";
45
45
 
46
46
  export interface UserAttributes {
47
- email: string;
47
+ email?: string;
48
+ public_profile_id?: string;
48
49
  public_handle?: string | null;
49
50
  }
50
51
 
@@ -118,11 +119,39 @@ export interface MessageAttributes {
118
119
  body: string;
119
120
  sender_owner_id?: string | null;
120
121
  sender_channel_id?: string | null;
122
+ external_email_sender_id?: string | null;
121
123
  thread_id?: string | null;
122
124
  context_post_id?: string | null;
123
125
  inserted_at?: string;
124
126
  }
125
127
 
128
+ export interface ExternalEmailIntakeAttributes {
129
+ postmark_message_id?: string | null;
130
+ raw_recipient?: string | null;
131
+ subject?: string | null;
132
+ body?: string | null;
133
+ attachment_count?: number | null;
134
+ attachment_metadata?: Array<Record<string, unknown>> | null;
135
+ attachments_withheld?: boolean | null;
136
+ status?: string | null;
137
+ decision?: string | null;
138
+ received_count?: number | null;
139
+ last_received_at?: string | null;
140
+ released_at?: string | null;
141
+ owner_id?: string | null;
142
+ target_channel_id?: string | null;
143
+ external_email_sender_id?: string | null;
144
+ released_thread_id?: string | null;
145
+ }
146
+
147
+ export interface MessageAttachmentAttributes {
148
+ name?: string | null;
149
+ content_type?: string | null;
150
+ content_length?: number | null;
151
+ message_id?: string | null;
152
+ inserted_at?: string;
153
+ }
154
+
126
155
  export interface AccessKeyAttributes {
127
156
  expires_at: string;
128
157
  scope: AccessKeyScope;