ofw-mcp 2.8.0 → 2.9.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.
@@ -2,10 +2,11 @@ import { z } from 'zod';
2
2
  import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage, getDraftsCacheStatus } from '../sync.js';
3
3
  import { buildFreshness } from './freshness.js';
4
4
  import { checkDraftFreshness, draftRevision, fetchServerDraft, staleDraftPayload, } from './draft-freshness.js';
5
+ import { FOLDER_TYPE, newDraftKey, persistFolderIds, probeIds, resolveDraftKey } from './lifecycle.js';
5
6
  import { getFolderVerifiedAt } from '../sync.js';
6
7
  import { buildInlineDelivery, tryExtract } from './delivery.js';
7
8
  import { resolveDownloadMime } from './attachments.js';
8
- import { getAllowMarkRead, getAttachmentsDir, getDefaultInlineAttachments, getFetchUnreadBodies, getSyncMaxRequests, getWriteMode, } from '../config.js';
9
+ import { getAllowMarkRead, getAttachmentsDir, getAutoRefreshStaleReads, getDefaultInlineAttachments, getFetchUnreadBodies, getSyncMaxRequests, getWriteMode, } from '../config.js';
9
10
  import { basename, join } from 'node:path';
10
11
  import { ApiRecipientSchema, deriveRead, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
11
12
  import { parseLenient } from '@chrischall/mcp-utils';
@@ -45,7 +46,10 @@ const MessageDetailSchema = z.looseObject({
45
46
  // The detail payload carries its own owning folder ({id, name}). We read the
46
47
  // id to label a live-fetched message sent-vs-inbox instead of blindly
47
48
  // defaulting to inbox — see the folder derivation in ofw_get_message.
48
- folder: z.looseObject({ id: z.number() }).optional(),
49
+ // Same union as ServerDraftSchema's, for the same reason — OFW types this id
50
+ // as a string on the folders listing and a number on message detail. Lenient
51
+ // here, so a mismatch only warns, but it would warn on EVERY live fetch.
52
+ folder: z.looseObject({ id: z.union([z.string(), z.number()]) }).optional(),
49
53
  });
50
54
  // Attachment-backfill detail fetch reads only `files`.
51
55
  const DetailFilesSchema = z.looseObject({ files: z.array(z.number()).optional() });
@@ -62,11 +66,6 @@ const FolderCountsSchema = z.looseObject({
62
66
  count: z.number().optional(),
63
67
  })).optional(),
64
68
  });
65
- const FOLDER_TYPE = {
66
- inbox: 'INBOX',
67
- sent: 'SENT_MESSAGES',
68
- drafts: 'DRAFTS',
69
- };
70
69
  /**
71
70
  * Cap on per-id probes in one ofw_check_freshness call.
72
71
  *
@@ -126,6 +125,64 @@ async function draftsFreshness(cache) {
126
125
  : 'unverified';
127
126
  return { freshness, serverConfirmed: cacheStatus === 'fresh', cacheStatus };
128
127
  }
128
+ /**
129
+ * Description shared by every read tool's `autoRefresh` argument, so the escape
130
+ * hatch from an UNVERIFIED_EMPTY refusal reads identically wherever it appears.
131
+ */
132
+ const AUTO_REFRESH_DESC = 'If the result comes back EMPTY from a cache that is not verified-fresh, sync the backing folders first and answer from the refreshed cache instead of refusing. Defaults to the OFW_AUTO_REFRESH env var (false unless set), in which case the call refuses with result:"UNVERIFIED_EMPTY" and names the remedy. Costs OFW requests when it fires.';
133
+ /**
134
+ * Run a cached read, and never let it answer "nothing there" on the strength of
135
+ * a cache that cannot vouch for itself.
136
+ *
137
+ * The failure this closes: an empty result set carrying a 207-minute-old
138
+ * `freshness` block is shaped IDENTICALLY to a verified "nothing matched". Skim
139
+ * past the warning and the natural next sentence is "no, that message was never
140
+ * sent" — a false negative stated as fact about a court-visible record. A false
141
+ * negative is more dangerous than a refusal precisely because it reads as a
142
+ * definitive answer, so the bias is: refuse, and say how to get a real one.
143
+ *
144
+ * `autoRefresh` turns the refusal into a sync-then-retry. If that still cannot
145
+ * make the read verifiable (the budget paused, OFW was unreachable), the refusal
146
+ * fires anyway — a refresh that did not work must not be treated as one that did.
147
+ *
148
+ * Non-empty results are never touched: a stale cache that DID find something is
149
+ * evidence of presence, and its `freshness` block already labels its age.
150
+ */
151
+ async function guardedCacheRead(o) {
152
+ let value = await o.read();
153
+ let refreshed = false;
154
+ const unverifiable = (v) => o.isEmpty(v) && v.freshness.staleness !== 'fresh';
155
+ if (unverifiable(value) && o.autoRefresh) {
156
+ await syncAll(o.client, {
157
+ folders: o.folders,
158
+ // Same ceiling ofw_sync_messages applies: an automatic refresh must never
159
+ // stamp unread inbox messages as a side effect of a list read.
160
+ fetchUnreadBodies: getAllowMarkRead() && getFetchUnreadBodies(),
161
+ maxRequests: getSyncMaxRequests(),
162
+ }, o.cache);
163
+ refreshed = true;
164
+ value = await o.read();
165
+ }
166
+ return { value, refreshed, unverifiedEmpty: unverifiable(value) };
167
+ }
168
+ /** The structured non-result returned instead of an unverifiable empty list. */
169
+ function unverifiedEmptyResponse(input) {
170
+ const { freshness } = input;
171
+ const age = freshness.ageSeconds === null
172
+ ? 'it has never been checked against OurFamilyWizard'
173
+ : `it was last verified ${freshness.ageSeconds < 60 ? `${freshness.ageSeconds} sec` : `${Math.round(freshness.ageSeconds / 60)} min`} ago`;
174
+ const refreshClause = input.refreshed
175
+ ? ' An automatic refresh ran on this call and did NOT make the result verifiable (the sync paused or skipped this folder), so the refusal stands.'
176
+ : '';
177
+ return jsonErrorResponse({
178
+ result: 'UNVERIFIED_EMPTY',
179
+ reason: `No ${input.what} were found, but the backing cache is "${freshness.staleness}" — ${age}. Refusing to report absence from unverified data: an empty result from a stale cache is indistinguishable from a verified "nothing there", and repeating it as one asserts a false negative about a legal record.${refreshClause}`,
180
+ remedy: input.remedy,
181
+ complete: false,
182
+ freshness,
183
+ ...input.extra,
184
+ });
185
+ }
129
186
  /**
130
187
  * Decide whether fetching this message's body from OFW would stamp the record,
131
188
  * and refuse when the caller (or the deployment) has opted out of that.
@@ -185,8 +242,8 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
185
242
  return jsonResponse({ folders: data, freshness });
186
243
  });
187
244
  server.registerTool('ofw_list_messages', {
188
- description: 'List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based but if you know what you want (a date range, a topic), prefer the filters over walking pages — the cache may have 1000+ messages. Call ofw_sync_messages first if the cache is empty or stale.',
189
- annotations: { readOnlyHint: true },
245
+ description: 'List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based but if you know what you want (a date range, a topic), prefer the filters over walking pages — the cache may have 1000+ messages. Returns an explicit `complete` boolean describing the RESULT SET: true means "this is every message on OurFamilyWizard matching these filters as of freshness.asOf" — check it before asserting a count. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as an absence; pass autoRefresh:true to sync and answer instead.',
246
+ annotations: { readOnlyHint: false },
190
247
  inputSchema: {
191
248
  folderId: z.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
192
249
  page: z.number().int().min(1).describe('Page number (default 1)').optional(),
@@ -194,6 +251,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
194
251
  since: z.string().describe('ISO date or datetime — only messages with sent_at >= since (inclusive)').optional(),
195
252
  until: z.string().describe('ISO date or datetime — only messages with sent_at < until (exclusive)').optional(),
196
253
  q: z.string().describe('Substring match on subject AND body (case-insensitive). Use to find messages on a specific topic.').optional(),
254
+ autoRefresh: z.boolean().describe(AUTO_REFRESH_DESC).optional(),
197
255
  },
198
256
  }, async (args) => {
199
257
  const page = args.page ?? 1;
@@ -207,40 +265,76 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
207
265
  else if (folderArg === 'both')
208
266
  folder = undefined;
209
267
  else {
210
- // Still carries freshness: `messages: []` with no age label is exactly
211
- // the shape this mechanism exists to eliminate, even when the emptiness
212
- // is caused by a bad argument rather than an empty cache.
213
- return jsonResponse({
214
- messages: [],
215
- freshness: await buildFreshness(cacheProvider(), {
216
- source: 'cache',
217
- folders: ['inbox', 'sent'],
218
- }),
219
- note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache. No lookup was performed — this empty result says nothing about what is in the cache.',
268
+ // A rejected argument is an ERROR, not a result. Returning `messages: []`
269
+ // here — even with a note attached — hands back the one shape this whole
270
+ // mechanism exists to eliminate: an empty list that looks like an answer.
271
+ return jsonErrorResponse({
272
+ result: 'INVALID_FOLDER',
273
+ reason: `folderId must be "inbox", "sent", or "both" (got ${JSON.stringify(folderArg)}). Numeric OFW folder IDs are not supported by the cache.`,
274
+ remedy: 'Re-call with folderId omitted (searches both) or set to one of the three accepted names.',
275
+ complete: false,
276
+ note: 'No lookup was performed. This says NOTHING about what is in the cache — do not read it as "no messages".',
220
277
  });
221
278
  }
222
279
  const cache = cacheProvider();
280
+ const folders = folder === undefined ? ['inbox', 'sent'] : [folder];
223
281
  const filter = { folder, since: args.since, until: args.until, q: args.q };
224
- const total = await cache.countMessages(filter);
225
- // Reconcile each row's read state at read time: the cached list flags can be
226
- // stale (a message read after it was first scraped), so `read` is derived
227
- // from the record's own `viewedAt`/`fetchedBodyAt` and `listData` is forced
228
- // to agree — see withReadState.
229
- const messages = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
230
- // Served from the local cache, so the result must say how old it is and
231
- // whether anything vouches for it — a caller cannot state current state
232
- // from this payload without either re-reading or surfacing the caveat.
233
- const freshness = await buildFreshness(cache, {
234
- source: 'cache',
235
- folders: folder === undefined ? ['inbox', 'sent'] : [folder],
282
+ const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
283
+ client,
284
+ cache,
285
+ folders,
286
+ autoRefresh: args.autoRefresh ?? getAutoRefreshStaleReads(),
287
+ isEmpty: (v) => v.total === 0,
288
+ read: async () => {
289
+ const total = await cache.countMessages(filter);
290
+ // Reconcile each row's read state at read time: the cached list flags
291
+ // can be stale (a message read after it was first scraped), so `read`
292
+ // is derived from the record's own `viewedAt`/`fetchedBodyAt` and
293
+ // `listData` is forced to agree — see withReadState.
294
+ const messages = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
295
+ // Served from the local cache, so the result must say how old it is and
296
+ // whether anything vouches for it — a caller cannot state current state
297
+ // from this payload without either re-reading or surfacing the caveat.
298
+ const freshness = await buildFreshness(cache, { source: 'cache', folders });
299
+ return { messages, total, freshness };
300
+ },
236
301
  });
237
- const payload = { messages, total, page, size, freshness };
302
+ if (unverifiedEmpty) {
303
+ return unverifiedEmptyResponse({
304
+ what: 'messages matching these filters',
305
+ freshness: value.freshness,
306
+ refreshed,
307
+ remedy: `Call ofw_sync_messages(folders:${JSON.stringify(folders)}) and retry, or re-call this tool with autoRefresh:true. ofw_check_freshness is the cheap live alternative when you only need to confirm a specific message.`,
308
+ extra: { page, size, filters: { folderId: folderArg, since: args.since, until: args.until, q: args.q } },
309
+ });
310
+ }
311
+ const { messages, total, freshness } = value;
312
+ // `complete` describes the RESULT SET, not the sync: true only when this
313
+ // payload holds every matching message the server has. `syncComplete` /
314
+ // `historyComplete` in `freshness` describe the walk that filled the cache
315
+ // and cannot answer "have I now seen all of them?" on their own — a caller
316
+ // needs one boolean to check before saying "you have N messages".
317
+ const fullSlice = page === 1 && messages.length === total;
318
+ const complete = fullSlice && freshness.staleness === 'fresh' && freshness.historyComplete;
319
+ const payload = { messages, total, page, size, complete, freshness };
320
+ if (!complete) {
321
+ payload.completeNote = [
322
+ !fullSlice ? `this page holds ${messages.length} of ${total} matching cached messages` : null,
323
+ freshness.staleness !== 'fresh' ? `the cache is "${freshness.staleness}", so newer messages may exist on OurFamilyWizard` : null,
324
+ !freshness.historyComplete ? 'older history is still being backfilled, so the cache does not yet hold every message' : null,
325
+ ].filter((r) => r !== null)
326
+ .join('; ')
327
+ .concat('. Do not state a total or an absence from this result without resolving that first.');
328
+ }
238
329
  if (total === 0) {
239
- payload.note = 'No messages match these filters. If you expected results, check ofw_sync_messages was run, or relax the filters.';
330
+ payload.note = 'No messages match these filters, and the cache IS verified-fresh for these folders — so this is a real "nothing matched", not a stale-cache artefact. If you expected results, relax the filters.';
240
331
  }
241
332
  else if (page * size < total) {
242
333
  payload.note = `Showing ${(page - 1) * size + 1}–${(page - 1) * size + messages.length} of ${total}. Increase 'page' to see more, or narrow with since/until/q.`;
243
334
  }
335
+ if (refreshed) {
336
+ payload.autoRefreshed = true;
337
+ }
244
338
  return jsonResponse(payload);
245
339
  });
246
340
  server.registerTool('ofw_get_message', {
@@ -281,6 +375,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
281
375
  // Concurrency token — pass as expectedRevision to ofw_save_draft /
282
376
  // ofw_delete_draft to assert you are editing THIS version.
283
377
  revision: draftRevision(draftRow),
378
+ // Stable logical identity. Survives the create-then-delete id churn of
379
+ // editing AND the transition to sent — pass it to ofw_status to ask
380
+ // "what happened to the thing I was working on?". Null when this draft
381
+ // was never written through this tool (e.g. authored in the web app).
382
+ draftKey: (await cache.getDraftLineageById(draftRow.id))?.draftKey ?? null,
284
383
  cacheStatus,
285
384
  // False = this draft's existence and unsent status are remembered from
286
385
  // a cache, not confirmed on OFW. Call ofw_check_freshness before
@@ -467,6 +566,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
467
566
  }, SentDetailSchema, 'ofw_send_message');
468
567
  let persisted = null;
469
568
  let verifyNote = null;
569
+ let sentDraftKey = null;
470
570
  if (newId !== null) {
471
571
  verifyNote = verifyWriteLanded('message', { subject, body }, detail);
472
572
  persisted = {
@@ -483,6 +583,21 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
483
583
  listData: detail,
484
584
  };
485
585
  await cache.upsertMessage(persisted);
586
+ // Extend the draft's identity chain onto the SENT message. Without this
587
+ // the chain would dead-end at the last draft id and "what happened to the
588
+ // draft I was editing?" would answer `deleted` — technically true of that
589
+ // id, and the exact wrong impression. With it, resolving the key lands on
590
+ // the sent message and reports state:"sent" with its sentAt.
591
+ if (draftRef !== undefined) {
592
+ const prior = await cache.getDraftLineageById(draftRef);
593
+ const now = new Date().toISOString();
594
+ const key = prior?.draftKey ?? newDraftKey();
595
+ if (prior === null) {
596
+ await cache.recordDraftLineage({ id: draftRef, draftKey: key, previousId: null, recordedAt: now });
597
+ }
598
+ await cache.recordDraftLineage({ id: newId, draftKey: key, previousId: draftRef, recordedAt: now });
599
+ sentDraftKey = key;
600
+ }
486
601
  // Link attached files to the new message in the attachments cache.
487
602
  // We may not have full metadata if the upload happened in a prior
488
603
  // session — fall back to what we know.
@@ -513,7 +628,9 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
513
628
  await deleteOFWMessages(client, [draftRef]);
514
629
  await cache.deleteDraft(draftRef);
515
630
  }
516
- const responseObj = persisted ?? raw;
631
+ const responseObj = persisted === null
632
+ ? raw
633
+ : { ...persisted, ...(sentDraftKey !== null ? { draftKey: sentDraftKey, previousId: draftRef } : {}) };
517
634
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Message sent successfully.';
518
635
  const notes = [rewriteNote, verifyNote, unconfirmedNote].filter((n) => n !== null).join('\n\n');
519
636
  return textResponse(notes ? `${notes}\n\n${text}` : text);
@@ -587,38 +704,72 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
587
704
  };
588
705
  }
589
706
  server.registerTool('ofw_list_drafts', {
590
- description: 'List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.',
591
- annotations: { readOnlyHint: true },
707
+ description: 'List draft messages from the local OurFamilyWizard cache. Returns an explicit `complete` boolean describing the RESULT SET: true means "these are ALL the drafts on OurFamilyWizard as of freshness.asOf" — check it before saying "you have N drafts". Each draft carries its `draftKey` (stable across the create-then-delete churn of editing) when one is known. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY"); pass autoRefresh:true to sync and answer instead. For a live, one-call answer prefer ofw_status(includeDraftInventory:true).',
708
+ annotations: { readOnlyHint: false },
592
709
  inputSchema: {
593
710
  page: z.number().int().min(1).describe('Page number (default 1)').optional(),
594
711
  size: z.number().int().min(1).describe('Drafts per page (default 50)').optional(),
712
+ autoRefresh: z.boolean().describe(AUTO_REFRESH_DESC).optional(),
595
713
  },
596
714
  }, async (args) => {
597
715
  const page = args.page ?? 1;
598
716
  const size = args.size ?? 50;
599
717
  const cache = cacheProvider();
600
- const { freshness, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
601
- const rows = await cache.listDrafts({ page, size });
602
- // Every draft carries the concurrency token to echo back on a write, plus
603
- // whether the last sync actually compared this cache against OFW and
604
- // whether its presence-on-server is confirmed or merely remembered.
605
- const drafts = rows.map((d) => ({
606
- ...d,
607
- revision: draftRevision(d),
608
- cacheStatus,
609
- serverConfirmed,
610
- asOf: freshness.asOf,
611
- }));
612
- if (drafts.length === 0) {
613
- return jsonResponse({
614
- drafts: [],
615
- freshness,
616
- note: 'No drafts in the local cache. That is NOT proof there are no drafts on OurFamilyWizard — call ofw_sync_messages to populate, or ofw_check_freshness to confirm.',
718
+ const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
719
+ client,
720
+ cache,
721
+ folders: ['drafts'],
722
+ autoRefresh: args.autoRefresh ?? getAutoRefreshStaleReads(),
723
+ isEmpty: (v) => v.total === 0,
724
+ read: async () => {
725
+ const { freshness, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
726
+ const rows = await cache.listDrafts({ page, size });
727
+ const total = await cache.countDrafts();
728
+ // One batch lookup for the whole page — on the Durable Object backend a
729
+ // per-draft lineage read would be a subrequest each.
730
+ const keyById = new Map((await cache.getDraftLineageByIds(rows.map((d) => d.id))).map((l) => [l.id, l.draftKey]));
731
+ // Every draft carries the concurrency token to echo back on a write, its
732
+ // stable identity across edits, and whether the last sync actually
733
+ // compared this cache against OFW.
734
+ const drafts = rows.map((d) => ({
735
+ ...d,
736
+ revision: draftRevision(d),
737
+ draftKey: keyById.get(d.id) ?? null,
738
+ cacheStatus,
739
+ serverConfirmed,
740
+ asOf: freshness.asOf,
741
+ }));
742
+ return { drafts, total, freshness, serverConfirmed };
743
+ },
744
+ });
745
+ if (unverifiedEmpty) {
746
+ return unverifiedEmptyResponse({
747
+ what: 'drafts',
748
+ freshness: value.freshness,
749
+ refreshed,
750
+ remedy: 'Call ofw_sync_messages(folders:["drafts"]) and retry, re-call with autoRefresh:true, or use ofw_status(includeDraftInventory:true) for a single live answer.',
751
+ extra: { page, size },
617
752
  });
618
753
  }
619
- const payload = { drafts, freshness };
754
+ const { drafts, total, freshness, serverConfirmed } = value;
755
+ // True only when this payload IS the full server-side draft set: verified
756
+ // against OFW inside the freshness window AND not a slice of a larger list.
757
+ const fullSlice = page === 1 && drafts.length === total;
758
+ const complete = serverConfirmed && fullSlice;
759
+ const payload = { drafts, total, page, size, complete, freshness };
760
+ if (!complete) {
761
+ payload.completeNote = [
762
+ !fullSlice ? `this page holds ${drafts.length} of ${total} cached drafts` : null,
763
+ !serverConfirmed ? 'the drafts cache has not been confirmed against OurFamilyWizard inside the freshness window' : null,
764
+ ].filter((r) => r !== null)
765
+ .join('; ')
766
+ .concat('. Do NOT state a draft count from this result — call ofw_status(includeDraftInventory:true) for a live, complete one.');
767
+ }
620
768
  if (!serverConfirmed) {
621
- payload.note = 'serverConfirmed:false — these drafts are remembered from the local cache, NOT confirmed to still exist unsent on OurFamilyWizard right now, and their bodies may be behind the server. Do not state that a draft "is still sitting unsent" on this basis; drafts edited or deleted in the OFW web app bump no timestamp, so the cache cannot detect it on its own. Call ofw_check_freshness (cheap, live) or ofw_sync_messages first. Writes are guarded regardless — ofw_save_draft and ofw_delete_draft re-check the server and refuse a stale overwrite.';
769
+ payload.note = 'serverConfirmed:false — these drafts are remembered from the local cache, NOT confirmed to still exist unsent on OurFamilyWizard right now, and their bodies may be behind the server. Do not state that a draft "is still sitting unsent" on this basis; drafts edited, deleted or SENT in the OFW web app bump no timestamp, so the cache cannot detect it on its own. Call ofw_status / ofw_check_freshness (cheap, live) or ofw_sync_messages first. Writes are guarded regardless — ofw_save_draft and ofw_delete_draft re-check the server and refuse a stale overwrite.';
770
+ }
771
+ if (refreshed) {
772
+ payload.autoRefreshed = true;
622
773
  }
623
774
  return jsonResponse(payload);
624
775
  });
@@ -683,6 +834,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
683
834
  let replaceNote = null;
684
835
  let verifyNote = null;
685
836
  let newRevision = null;
837
+ let draftKey = null;
686
838
  // Fields accepted on the write that the saved draft must carry — or their
687
839
  // loss must be reported. Never a silent drop (Defect 3).
688
840
  const warnings = [];
@@ -710,6 +862,34 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
710
862
  // The revision is now computed from the server-authoritative detail, so it
711
863
  // is the value a subsequent read/verify will observe (Defect 1).
712
864
  newRevision = draftRevision(persisted);
865
+ // Carry the logical identity across the id change. Replacing a draft mints
866
+ // a NEW OFW id every time (create-then-delete — see the note below), so
867
+ // ten edits produced ten unrelated ids and there was no way to ask what
868
+ // became of the one you started with. The key is minted on first sight —
869
+ // including retroactively for the draft being replaced, so a draft that
870
+ // predates this mechanism joins a chain the moment it is edited.
871
+ const now = new Date().toISOString();
872
+ if (args.messageId !== undefined) {
873
+ const prior = await cache.getDraftLineageById(args.messageId);
874
+ if (prior !== null) {
875
+ draftKey = prior.draftKey;
876
+ }
877
+ else {
878
+ draftKey = newDraftKey();
879
+ await cache.recordDraftLineage({
880
+ id: args.messageId, draftKey, previousId: null, recordedAt: now,
881
+ });
882
+ }
883
+ }
884
+ else {
885
+ draftKey = newDraftKey();
886
+ }
887
+ await cache.recordDraftLineage({
888
+ id: newId,
889
+ draftKey,
890
+ previousId: args.messageId ?? null,
891
+ recordedAt: now,
892
+ });
713
893
  // Audit every field the caller supplied against what actually landed, so a
714
894
  // silent normalization becomes a visible warning rather than a surprise.
715
895
  if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
@@ -767,6 +947,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
767
947
  ...persisted,
768
948
  inReplyTo: persisted.replyToId,
769
949
  revision: newRevision,
950
+ // The id above is volatile — it changes on every edit. `draftKey` is
951
+ // not: pass it to ofw_status to resolve the chain's CURRENT id, or to
952
+ // find out that the draft was sent and when.
953
+ draftKey,
954
+ previousId: args.messageId ?? null,
770
955
  cacheStatus: 'fresh',
771
956
  serverConfirmed: true,
772
957
  ...(warnings.length > 0 ? { warnings } : {}),
@@ -806,28 +991,49 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
806
991
  return textResponse(guard.note ? `${guard.note}\n\n${text}` : text);
807
992
  });
808
993
  server.registerTool('ofw_get_unread_sent', {
809
- description: 'List sent messages that have not been read by one or more recipients. Reads from local cache; call ofw_sync_messages first if cache is stale.',
810
- annotations: { readOnlyHint: true },
994
+ description: 'List sent messages that have not been read by one or more recipients. Reads from local cache. Returns `complete` describing whether every sent message was scanned. An empty SENT cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as "nothing sent"; pass autoRefresh:true to sync and answer instead.',
995
+ annotations: { readOnlyHint: false },
811
996
  inputSchema: {
812
997
  page: z.number().int().min(1).describe('Page (default 1)').optional(),
813
998
  size: z.number().int().min(1).describe('Per page (default 50)').optional(),
999
+ autoRefresh: z.boolean().describe(AUTO_REFRESH_DESC).optional(),
814
1000
  },
815
1001
  }, async (args) => {
816
1002
  const page = args.page ?? 1;
817
1003
  const size = args.size ?? 50;
818
1004
  const cache = cacheProvider();
819
- const sent = await cache.listMessages({ folder: 'sent', page, size });
820
- // "Nobody has read it yet" is a present-tense claim drawn entirely from
821
- // cached view timestamps, which only move when a sync refreshes them —
822
- // so it needs the same age label as any other cached read.
823
- const freshness = await buildFreshness(cache, { source: 'cache', folders: ['sent'] });
824
- if (sent.length === 0) {
825
- return jsonResponse({
826
- unread: [],
827
- freshness,
828
- note: 'Sent cache is empty. Call ofw_sync_messages to populate. An empty cache is NOT evidence that no sent messages exist.',
1005
+ const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
1006
+ client,
1007
+ cache,
1008
+ folders: ['sent'],
1009
+ autoRefresh: args.autoRefresh ?? getAutoRefreshStaleReads(),
1010
+ // The guard is about the CACHE being empty, not the verdict. "You have
1011
+ // no sent messages" is an absence claim a stale cache cannot support;
1012
+ // "all of them are read" is a verdict over messages we did see, and it is
1013
+ // labelled by `freshness` and `complete` as before.
1014
+ isEmpty: (v) => v.total === 0,
1015
+ read: async () => {
1016
+ const sent = await cache.listMessages({ folder: 'sent', page, size });
1017
+ const total = await cache.countMessages({ folder: 'sent' });
1018
+ // "Nobody has read it yet" is a present-tense claim drawn entirely from
1019
+ // cached view timestamps, which only move when a sync refreshes them —
1020
+ // so it needs the same age label as any other cached read.
1021
+ const freshness = await buildFreshness(cache, { source: 'cache', folders: ['sent'] });
1022
+ return { sent, total, freshness };
1023
+ },
1024
+ });
1025
+ if (unverifiedEmpty) {
1026
+ return unverifiedEmptyResponse({
1027
+ what: 'sent messages in the local cache',
1028
+ freshness: value.freshness,
1029
+ refreshed,
1030
+ remedy: 'Call ofw_sync_messages(folders:["sent"]) and retry, or re-call with autoRefresh:true.',
1031
+ extra: { page, size },
829
1032
  });
830
1033
  }
1034
+ const { sent, total, freshness } = value;
1035
+ const fullSlice = page === 1 && sent.length === total;
1036
+ const complete = fullSlice && freshness.staleness === 'fresh' && freshness.historyComplete;
831
1037
  const unread = [];
832
1038
  for (const msg of sent) {
833
1039
  const unreadBy = msg.recipients.filter((r) => r.viewedAt === null).map((r) => r.name);
@@ -835,14 +1041,17 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
835
1041
  unread.push({ id: msg.id, subject: msg.subject, sentAt: msg.sentAt, unreadBy });
836
1042
  }
837
1043
  }
1044
+ const payload = { unread, scanned: sent.length, total, complete, freshness };
1045
+ if (!complete) {
1046
+ payload.completeNote = `This verdict covers the ${sent.length} of ${total} cached sent messages on this page${freshness.staleness === 'fresh' ? '' : `, from a cache that is "${freshness.staleness}"`}. It is not a statement about every message you have sent.`;
1047
+ }
838
1048
  if (unread.length === 0) {
839
- return jsonResponse({
840
- unread: [],
841
- freshness,
842
- message: 'All scanned sent messages had been read as of the timestamp in `freshness.asOf`. A recipient may have read a message since without the cache hearing about it.',
843
- });
1049
+ payload.message = 'Every sent message scanned had been read as of the timestamp in `freshness.asOf`. A recipient may have read — or not read — a message since without the cache hearing about it.';
844
1050
  }
845
- return jsonResponse({ unread, freshness });
1051
+ if (refreshed) {
1052
+ payload.autoRefreshed = true;
1053
+ }
1054
+ return jsonResponse(payload);
846
1055
  });
847
1056
  if (allowDrafts)
848
1057
  server.registerTool('ofw_upload_attachment', {
@@ -1024,12 +1233,12 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1024
1233
  return jsonResponse({ ...result, freshness });
1025
1234
  });
1026
1235
  server.registerTool('ofw_check_freshness', {
1027
- description: 'Cheaply confirm whether the local cache still matches OurFamilyWizard, WITHOUT running a full sync. Use this before asserting anything about current state — especially "draft X is still sitting unsent" — when a read returned serverConfirmed:false or freshness.staleness other than "fresh". Costs one OFW request for the folder check plus one per messageId. For each folder it returns the live server count next to the cached count; for each id, whether it still exists on OFW and whether its content matches the cache (compared by content revision, because OFW draft timestamps do NOT change when a draft is edited in the web app). Does not fetch bodies into the cache, does not touch attachments, and does not depend on sync state.',
1028
- annotations: { readOnlyHint: true },
1236
+ description: 'Cheaply confirm whether the local cache still matches OurFamilyWizard, WITHOUT running a full sync. Use this before asserting anything about current state — especially "draft X is still sitting unsent". Costs one OFW request for the folder check plus one per messageId. For each folder it returns the live server count next to the cached count. For each id it returns a LIVE lifecycle `state` — "draft" | "sent" | "received" | "deleted" | "unknown" — alongside `folder`, `sentAt`, `existsOnServer` and a content comparison. `state` is the field that answers "is this still a draft?": a draft that has been SENT still exists on the server, so existsOnServer:true never distinguished the two. A cached draft whose state is no longer "draft" reports inSync:false even when its text is byte-identical. Content is compared by revision hash, because OFW draft timestamps do NOT change when a draft is edited in the web app. Does not fetch bodies into the cache, does not touch attachments, and does not depend on sync state. For draftKeys, or a full live draft inventory, use ofw_status.',
1237
+ annotations: { readOnlyHint: false },
1029
1238
  inputSchema: {
1030
1239
  folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).min(1).describe('Folders to compare cached vs live counts for. Defaults to all three when messageIds is not given. Must be non-empty if given.').optional(),
1031
- messageIds: z.array(z.number()).describe(`Specific ids to verify against OFW (max ${MAX_FRESHNESS_IDS}). By default only ids present in the drafts cache are probed — see allowMarkRead.`).optional(),
1032
- allowMarkRead: z.boolean().describe('Default false. Probing an id that is NOT a cached draft requires fetching its detail, which marks an unread inbox message as READ on OurFamilyWizard — an irreversible change to the record. Such ids are skipped unless you set this to true.').optional(),
1240
+ messageIds: z.array(z.number()).describe(`Specific ids to verify against OFW (max ${MAX_FRESHNESS_IDS}). Ids cached as drafts, as sent messages, or as already-read inbox messages are probed freely — none of those can stamp the record. Anything else is skipped — see allowMarkRead.`).optional(),
1241
+ allowMarkRead: z.boolean().describe('Default false. Probing an id whose cached state cannot rule out an unread INBOX message requires fetching its detail, which marks it READ on OurFamilyWizard and stamps a co-parent-visible "First Viewed" time — irreversible. Such ids are skipped (reason:"WOULD_MARK_READ") unless you set this to true. The server-wide OFW_ALLOW_MARK_READ=false is a ceiling this cannot raise.').optional(),
1033
1242
  },
1034
1243
  }, async (args) => {
1035
1244
  const cache = cacheProvider();
@@ -1049,6 +1258,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1049
1258
  requestsUsed++;
1050
1259
  const data = parseLenient(FolderCountsSchema, await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true'), { label: 'ofw-mcp', context: 'GET /pub/v1/messageFolders (ofw_check_freshness)' });
1051
1260
  const sys = data.systemFolders ?? [];
1261
+ // Take the folder ids while we have them. Without this a call asking
1262
+ // about BOTH folders and ids, on a cache that has never synced, fetched
1263
+ // this exact endpoint twice — once here for the counts and again inside
1264
+ // probeIds' ensureFolderIdMap for the ids it already had in hand.
1265
+ await persistFolderIds(cache, sys);
1052
1266
  for (const folder of wantFolders) {
1053
1267
  const entry = sys.find((x) => x.folderType === FOLDER_TYPE[folder]);
1054
1268
  const serverCount = entry?.totalCount ?? entry?.messageCount ?? entry?.count ?? null;
@@ -1088,64 +1302,12 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1088
1302
  });
1089
1303
  }
1090
1304
  }
1091
- const items = [];
1092
- for (const id of ids) {
1093
- const cachedDraft = await cache.getDraft(id);
1094
- // Drafts have no read state, so probing one is genuinely side-effect
1095
- // free. Any other id means GET /pub/v3/messages/{id}, which marks an
1096
- // unread inbox message read on OFW — a permanent change to a
1097
- // court-visible record. Refuse by default rather than quietly doing it.
1098
- if (cachedDraft === null && !allowMarkRead) {
1099
- items.push({
1100
- id,
1101
- skipped: true,
1102
- reason: 'NOT_A_CACHED_DRAFT',
1103
- note: 'Not in the drafts cache. Verifying it requires fetching its detail from OFW, which would mark an unread inbox message as READ on OurFamilyWizard. Pass allowMarkRead:true if that is acceptable.',
1104
- });
1105
- continue;
1106
- }
1107
- requestsUsed++;
1108
- try {
1109
- const server = await fetchServerDraft(client, id);
1110
- const cacheRevision = cachedDraft === null ? null : draftRevision(cachedDraft);
1111
- if (server === null) {
1112
- items.push({
1113
- id,
1114
- existsOnServer: false,
1115
- inSync: false,
1116
- cacheRevision,
1117
- serverRevision: null,
1118
- note: cachedDraft === null
1119
- ? 'Not found on OurFamilyWizard.'
1120
- : 'This draft is in the local cache but NO LONGER EXISTS on OurFamilyWizard — it was sent or deleted elsewhere. Do not describe it as still unsent.',
1121
- });
1122
- continue;
1123
- }
1124
- const serverRevision = draftRevision(server);
1125
- items.push({
1126
- id,
1127
- existsOnServer: true,
1128
- cacheRevision,
1129
- serverRevision,
1130
- inSync: cacheRevision !== null && cacheRevision === serverRevision,
1131
- ...(cacheRevision === null
1132
- ? { note: 'Exists on OurFamilyWizard but is not in the local cache.' }
1133
- : cacheRevision !== serverRevision
1134
- ? { note: 'Content differs from the cache — it was edited on OurFamilyWizard since the last sync. Run ofw_sync_messages before reading or writing it.' }
1135
- : {}),
1136
- });
1137
- }
1138
- catch (e) {
1139
- // A check that could not run must not read as "in sync".
1140
- items.push({
1141
- id,
1142
- error: 'FRESHNESS_CHECK_FAILED',
1143
- message: e.message,
1144
- inSync: null,
1145
- note: 'The freshness check itself failed, so nothing is confirmed either way.',
1146
- });
1147
- }
1148
- }
1305
+ // One folder-id resolve for the whole batch, and only when at least one id
1306
+ // is actually going to be probed — a call whose every id is refused for
1307
+ // mark-read reasons must cost nothing at all.
1308
+ const probed = await probeIds(client, cache, ids, { allowMarkRead });
1309
+ requestsUsed += probed.requests;
1310
+ const items = probed.items;
1149
1311
  const payload = {
1150
1312
  checkedAt: new Date().toISOString(),
1151
1313
  requestsUsed,
@@ -1157,6 +1319,156 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1157
1319
  }
1158
1320
  return jsonResponse(payload);
1159
1321
  });
1322
+ server.registerTool('ofw_status', {
1323
+ description: 'ONE live call that answers "where does everything stand?". This is the call that should back any status summary about drafts or specific messages — never session memory, and never a cached read alone. With no arguments it returns the FULL current draft inventory, verified against OurFamilyWizard. Pass ids and/or draftKeys to get each one\'s live lifecycle `state` ("draft" | "sent" | "received" | "deleted" | "unknown") with `sentAt` and `viewedAt`. A draftKey is the stable identity ofw_save_draft returns: editing a draft mints a new OFW id every time (create-then-delete), so the key is the only way to ask "what happened to the thing I was working on?" — it resolves to the chain\'s current id and keeps resolving after the draft is SENT (state:"sent" with sentMessageId). The top-level `complete` is true ONLY when every part of this snapshot was verified live; if it is false, do not state a draft count or a lifecycle claim from this payload.',
1324
+ annotations: { readOnlyHint: false },
1325
+ inputSchema: {
1326
+ ids: z.array(z.number()).describe(`Message/draft ids to resolve to a live state (combined with draftKeys, max ${MAX_FRESHNESS_IDS} probes per call).`).optional(),
1327
+ draftKeys: z.array(z.string()).describe('Stable draft keys (from ofw_save_draft / ofw_list_drafts) to resolve to their CURRENT id and state.').optional(),
1328
+ includeDraftInventory: z.boolean().describe('Return the full current draft list, verified against OurFamilyWizard first. Defaults to TRUE when neither ids nor draftKeys is given (so a bare ofw_status() is a complete status snapshot), otherwise false.').optional(),
1329
+ allowMarkRead: z.boolean().describe('Default false. An id whose cached state cannot rule out an unread INBOX message can only be probed by fetching its detail, which marks it READ on OurFamilyWizard — irreversible and co-parent-visible. Those are skipped unless this is true. Cached drafts, sent messages and already-read messages are always probed. Capped by OFW_ALLOW_MARK_READ.').optional(),
1330
+ },
1331
+ }, async (args) => {
1332
+ const cache = cacheProvider();
1333
+ const allowMarkRead = getAllowMarkRead() && (args.allowMarkRead ?? false);
1334
+ const requestedIds = args.ids ?? [];
1335
+ const requestedKeys = args.draftKeys ?? [];
1336
+ // A bare ofw_status() is meant to be the "here is where everything stands"
1337
+ // call, so it defaults to the inventory. Asking about specific ids does not
1338
+ // silently spend a drafts sync you did not ask for.
1339
+ const wantInventory = args.includeDraftInventory
1340
+ ?? (requestedIds.length === 0 && requestedKeys.length === 0);
1341
+ const allTargets = [
1342
+ ...requestedIds.map((id) => ({ kind: 'id', id })),
1343
+ ...requestedKeys.map((draftKey) => ({ kind: 'draftKey', draftKey })),
1344
+ ];
1345
+ const targets = allTargets.slice(0, MAX_FRESHNESS_IDS);
1346
+ const truncated = allTargets.length - targets.length;
1347
+ // Nothing asked for, nothing checked — so there is nothing to be complete
1348
+ // ABOUT. Returning `complete: true` here would be a reassuring-looking
1349
+ // payload that verified precisely nothing, which is the failure mode this
1350
+ // whole tool exists to remove.
1351
+ if (!wantInventory && targets.length === 0) {
1352
+ return jsonErrorResponse({
1353
+ result: 'NOTHING_REQUESTED',
1354
+ reason: 'ofw_status was called with includeDraftInventory:false and no ids or draftKeys, so nothing was checked.',
1355
+ remedy: 'Call ofw_status() with no arguments for the full draft inventory, or pass ids / draftKeys.',
1356
+ complete: false,
1357
+ });
1358
+ }
1359
+ let probeRequests = 0;
1360
+ const incomplete = [];
1361
+ // ── Draft inventory ────────────────────────────────────────────────────
1362
+ let drafts;
1363
+ let inventoryComplete = true;
1364
+ let inventoryFreshness;
1365
+ if (wantInventory) {
1366
+ const sync = await syncAll(client, {
1367
+ folders: ['drafts'],
1368
+ maxRequests: getSyncMaxRequests(),
1369
+ }, cache);
1370
+ // `refreshed` is the only honest signal that the walk actually diffed
1371
+ // against OFW. A budget-paused walk applies nothing and returns no count
1372
+ // — reporting its inventory as complete would be the same lie as
1373
+ // `drafts: 0` from a deferred sync.
1374
+ inventoryComplete = sync.refreshed.includes('drafts');
1375
+ const { freshness, cacheStatus, serverConfirmed } = await draftsFreshness(cache);
1376
+ inventoryFreshness = freshness;
1377
+ if (!serverConfirmed)
1378
+ inventoryComplete = false;
1379
+ const total = await cache.countDrafts();
1380
+ const rows = await cache.listDrafts({ page: 1, size: Math.max(total, 1) });
1381
+ const keyById = new Map((await cache.getDraftLineageByIds(rows.map((d) => d.id))).map((l) => [l.id, l.draftKey]));
1382
+ drafts = rows.map((d) => ({
1383
+ id: d.id,
1384
+ draftKey: keyById.get(d.id) ?? null,
1385
+ subject: d.subject,
1386
+ revision: draftRevision(d),
1387
+ modifiedAt: d.modifiedAt,
1388
+ recipients: d.recipients,
1389
+ replyToId: d.replyToId,
1390
+ cacheStatus,
1391
+ }));
1392
+ if (!inventoryComplete) {
1393
+ incomplete.push('the drafts folder was not fully verified against OurFamilyWizard on this call (the request budget paused the walk), so this inventory may be missing or misreporting drafts');
1394
+ }
1395
+ }
1396
+ // ── Per-id / per-key lifecycle ─────────────────────────────────────────
1397
+ const requested = [];
1398
+ if (targets.length > 0) {
1399
+ // Resolve keys to ids first so a key and a bare id naming the SAME
1400
+ // message are probed once, not twice.
1401
+ const resolved = new Map();
1402
+ for (const t of targets) {
1403
+ if (t.kind === 'draftKey' && !resolved.has(t.draftKey)) {
1404
+ resolved.set(t.draftKey, await resolveDraftKey(cache, t.draftKey));
1405
+ }
1406
+ }
1407
+ const toProbe = new Set();
1408
+ for (const t of targets) {
1409
+ if (t.kind === 'id')
1410
+ toProbe.add(t.id);
1411
+ else {
1412
+ const chain = resolved.get(t.draftKey);
1413
+ if (chain !== null && chain !== undefined)
1414
+ toProbe.add(chain.currentId);
1415
+ }
1416
+ }
1417
+ const probed = await probeIds(client, cache, [...toProbe], { allowMarkRead });
1418
+ probeRequests += probed.requests;
1419
+ const probes = new Map(probed.items.map((item) => [item.id, item]));
1420
+ for (const t of targets) {
1421
+ if (t.kind === 'id') {
1422
+ requested.push(decorate(probes.get(t.id)));
1423
+ continue;
1424
+ }
1425
+ const chain = resolved.get(t.draftKey);
1426
+ if (chain === null || chain === undefined) {
1427
+ requested.push({
1428
+ draftKey: t.draftKey,
1429
+ state: 'unknown',
1430
+ error: 'UNKNOWN_DRAFT_KEY',
1431
+ note: 'This draftKey has never been recorded in the local cache, so it cannot be resolved to a message id. Draft keys are minted by ofw_save_draft; a cache rebuilt or opened on another machine will not know an older key.',
1432
+ });
1433
+ continue;
1434
+ }
1435
+ requested.push({
1436
+ draftKey: t.draftKey,
1437
+ currentId: chain.currentId,
1438
+ previousIds: chain.ids.slice(0, -1),
1439
+ ...decorate(probes.get(chain.currentId)),
1440
+ });
1441
+ }
1442
+ for (const entry of requested) {
1443
+ if (entry.skipped === true || entry.error !== undefined || entry.state === 'unknown') {
1444
+ incomplete.push(`id/key ${String(entry.draftKey ?? entry.id)} could not be resolved to a confirmed live state`);
1445
+ }
1446
+ }
1447
+ }
1448
+ if (truncated > 0) {
1449
+ incomplete.push(`${truncated} of ${allTargets.length} requested ids/draftKeys were not probed (per-call cap of ${MAX_FRESHNESS_IDS})`);
1450
+ }
1451
+ const complete = incomplete.length === 0;
1452
+ return jsonResponse({
1453
+ checkedAt: new Date().toISOString(),
1454
+ probeRequests,
1455
+ ...(drafts !== undefined ? { drafts, draftCount: drafts.length, draftInventoryComplete: inventoryComplete } : {}),
1456
+ ...(requested.length > 0 ? { requested } : {}),
1457
+ complete,
1458
+ ...(complete
1459
+ ? {}
1460
+ : { incompleteReasons: incomplete, note: 'complete:false — this snapshot is NOT a verified statement of current state. Do not report a draft count or say whether something was sent from it; resolve the reasons above (usually by calling ofw_sync_messages, or re-calling with allowMarkRead:true) and ask again.' }),
1461
+ ...(inventoryFreshness !== undefined ? { freshness: inventoryFreshness } : {}),
1462
+ });
1463
+ });
1464
+ }
1465
+ /**
1466
+ * Add the derived fields a status entry wants on top of a raw lifecycle probe.
1467
+ * `sentMessageId` names the id the message ended up as once sent — the answer to
1468
+ * "the draft I was editing went where?" — and only exists for a `sent` verdict.
1469
+ */
1470
+ function decorate(item) {
1471
+ return item.state === 'sent' ? { ...item, sentMessageId: item.id } : { ...item };
1160
1472
  }
1161
1473
  // OFW's bulk-delete endpoint takes a multipart form with `messageIds`.
1162
1474
  // Used by both ofw_delete_draft and ofw_send_message (draft cleanup).