ofw-mcp 2.9.2 → 2.10.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.
@@ -71,6 +71,31 @@ export function mapRecipients(items) {
71
71
  export function hasRealView(recipients) {
72
72
  return recipients.some((r) => r.viewedAt !== null && !r.viewedAt.startsWith('1970-01-01'));
73
73
  }
74
+ /** The reply target OFW actually reports, whichever field it chose to put it in. */
75
+ export function threadedReplyTo(detail) {
76
+ return detail.replyToId ?? detail.inReplyTo ?? null;
77
+ }
78
+ /** True when the payload positively reports the message as threaded. */
79
+ export function reportsThreaded(detail) {
80
+ return threadedReplyTo(detail) !== null || detail.showContext === true;
81
+ }
82
+ /**
83
+ * True when the payload POSITIVELY reports the message as unthreaded — the
84
+ * evidence bar a "reply linkage was dropped" warning must clear.
85
+ *
86
+ * Only `inReplyTo` and `showContext` count as evidence, because those are the
87
+ * fields OFW actually signals threading with. A present-but-null `replyToId`
88
+ * is NOT evidence: OFW routinely emits `replyToId: null` on items that ARE
89
+ * threaded (their linkage lives in `inReplyTo`), so treating it as a
90
+ * disconfirmation manufactures a false UNTHREADED warning on exactly OFW's
91
+ * normal shape. Total absence of all three fields is "not echoed", never
92
+ * "dropped".
93
+ */
94
+ export function reportsUnthreaded(detail) {
95
+ if (reportsThreaded(detail))
96
+ return false;
97
+ return detail.inReplyTo !== undefined || detail.showContext !== undefined;
98
+ }
74
99
  // True when the once-scraped list flags themselves say the message is read.
75
100
  // `showNeverViewed === false` is OFW's reliable "has been viewed" signal (per
76
101
  // CLAUDE.md); `read === true` is the inbox list's own flag. Both are only ever
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { parseLenient } from '@chrischall/mcp-utils';
3
- import { ApiRecipientSchema, mapRecipients } from './_shared.js';
3
+ import { ApiRecipientSchema, mapRecipients, threadedReplyTo } from './_shared.js';
4
4
  /** Thrown when the freshness check itself could not be completed. */
5
5
  export class DraftFreshnessError extends Error {
6
6
  }
@@ -37,8 +37,18 @@ export function draftRevision(d) {
37
37
  const ServerDraftSchema = z.looseObject({
38
38
  subject: z.string().optional(),
39
39
  body: z.string().optional(),
40
+ // BOTH spellings of the threading echo (see ThreadingEcho in _shared.ts):
41
+ // OFW reports the reply target as `replyToId` on some payloads and as
42
+ // `inReplyTo` on others. The snapshot derives one value from whichever is
43
+ // present, so the revision hashed here matches the one ofw_save_draft
44
+ // computed from the same server state — a one-sided read produced revisions
45
+ // that disagreed about the same draft.
40
46
  replyToId: z.number().nullable().optional(),
47
+ inReplyTo: z.number().nullable().optional(),
41
48
  recipients: z.array(ApiRecipientSchema).optional(),
49
+ // Attachment fileIds — read so send-by-draft carries the draft's
50
+ // attachments onto the sent message (see DraftContent.files).
51
+ files: z.array(z.number()).optional(),
42
52
  // Read for the LIFECYCLE answer (see tools/lifecycle.ts): which folder OFW
43
53
  // itself says this id lives in right now. `existsOnServer` alone cannot
44
54
  // distinguish "still a draft" from "was sent" — a sent draft still exists.
@@ -96,8 +106,9 @@ export async function fetchMessageSnapshot(client, id) {
96
106
  content: {
97
107
  subject: detail.subject ?? '',
98
108
  body: detail.body ?? '',
99
- replyToId: detail.replyToId ?? null,
109
+ replyToId: threadedReplyTo(detail),
100
110
  recipients: mapRecipients(detail.recipients),
111
+ ...(detail.files !== undefined ? { files: detail.files } : {}),
101
112
  },
102
113
  folderId: detail.folder?.id === undefined ? null : String(detail.folder.id),
103
114
  folderName: detail.folder?.name ?? null,
@@ -61,25 +61,51 @@ export async function ensureFolderIdMap(client, store) {
61
61
  return { map: cached, requests: 1 };
62
62
  }
63
63
  }
64
+ /**
65
+ * OFW's own display names for the three system folders, as observed on live
66
+ * detail payloads (`folder.name`). Keys are lower-cased for the comparison —
67
+ * this is a fixed vocabulary OFW controls, not user content, so a
68
+ * case-insensitive match is a normalization, not a guess. A Map, not a plain
69
+ * object: an object lookup reaches `Object.prototype`, so a folder named
70
+ * "constructor" would come back as a function and silently vanish from the
71
+ * JSON payload instead of classifying as `unknown`.
72
+ */
73
+ const STATE_BY_FOLDER_NAME = new Map([
74
+ ['drafts', 'draft'],
75
+ ['sent', 'sent'],
76
+ ['sent messages', 'sent'],
77
+ ['inbox', 'received'],
78
+ ]);
64
79
  /**
65
80
  * Map a live snapshot to a lifecycle state. Pure — the request already happened.
66
81
  *
67
- * A null snapshot means OFW returned 404 / an empty body, i.e. `deleted`. An
68
- * unmappable folder is `unknown` rather than being guessed at: guessing here is
69
- * exactly how "still a draft" gets asserted about something that was sent.
82
+ * A null snapshot means OFW returned 404 / an empty body, i.e. `deleted`. The
83
+ * folder ID (matched against the persisted system-folder map) is the primary
84
+ * signal; when the id is unreported or unmapped, the folder NAME OFW itself put
85
+ * on the payload is a fallback with exactly the same authority — a snapshot
86
+ * reporting `folder.name: "Drafts"` answered the question, and declaring it
87
+ * `unknown` was a refusal to read the answer (observed live: every draft probe
88
+ * came back "unknown" while echoing the name "Drafts"). Only a folder that is
89
+ * unmappable by BOTH id and name is `unknown`: guessing beyond that is exactly
90
+ * how "still a draft" gets asserted about something that was sent.
70
91
  */
71
92
  export function classifyState(snapshot, map) {
72
93
  if (snapshot === null)
73
94
  return 'deleted';
74
- const { folderId } = snapshot;
75
- if (folderId === null)
76
- return 'unknown';
77
- if (map.drafts !== null && folderId === map.drafts)
78
- return 'draft';
79
- if (map.sent !== null && folderId === map.sent)
80
- return 'sent';
81
- if (map.inbox !== null && folderId === map.inbox)
82
- return 'received';
95
+ const { folderId, folderName } = snapshot;
96
+ if (folderId !== null) {
97
+ if (map.drafts !== null && folderId === map.drafts)
98
+ return 'draft';
99
+ if (map.sent !== null && folderId === map.sent)
100
+ return 'sent';
101
+ if (map.inbox !== null && folderId === map.inbox)
102
+ return 'received';
103
+ }
104
+ if (folderName !== null) {
105
+ const byName = STATE_BY_FOLDER_NAME.get(folderName.trim().toLowerCase());
106
+ if (byName !== undefined)
107
+ return byName;
108
+ }
83
109
  return 'unknown';
84
110
  }
85
111
  /**