ofw-mcp 2.5.0 → 2.6.4

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/dist/bundle.js CHANGED
@@ -38392,8 +38392,13 @@ async function loginWithPassword(username, password) {
38392
38392
  }
38393
38393
  const contentType = response.headers.get("content-type") ?? "";
38394
38394
  if (!contentType.includes("application/json")) {
38395
+ if (contentType.includes("text/html")) {
38396
+ throw new Error(
38397
+ "OFW login failed \u2014 your OurFamilyWizard email or password was not accepted. Check them and try again."
38398
+ );
38399
+ }
38395
38400
  const body = await response.text();
38396
- throw new Error(`OFW login returned unexpected response (${contentType}): ${body.substring(0, 200)}`);
38401
+ throw new Error(`OFW login returned unexpected response (${contentType || "no content-type"}): ${body.substring(0, 200)}`);
38397
38402
  }
38398
38403
  const data = await response.json();
38399
38404
  return {
@@ -38405,7 +38410,7 @@ async function loginWithPassword(username, password) {
38405
38410
  // package.json
38406
38411
  var package_default = {
38407
38412
  name: "ofw-mcp",
38408
- version: "2.5.0",
38413
+ version: "2.6.4",
38409
38414
  license: "MIT",
38410
38415
  mcpName: "io.github.chrischall/ofw-mcp",
38411
38416
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
@@ -38434,7 +38439,10 @@ var package_default = {
38434
38439
  dev: "node --env-file=.env dist/index.js",
38435
38440
  test: "vitest run",
38436
38441
  "test:coverage": "vitest run --coverage",
38437
- "test:watch": "vitest"
38442
+ "test:watch": "vitest",
38443
+ "worker:dev": "wrangler dev",
38444
+ "worker:deploy": "wrangler deploy",
38445
+ "worker:test": "vitest run --config vitest.workers.config.ts"
38438
38446
  },
38439
38447
  dependencies: {
38440
38448
  "@chrischall/mcp-utils": "^0.13.0",
@@ -38444,11 +38452,17 @@ var package_default = {
38444
38452
  zod: "^4.4.3"
38445
38453
  },
38446
38454
  devDependencies: {
38455
+ "@chrischall/mcp-connector": "^0.1.0",
38456
+ "@cloudflare/vitest-pool-workers": "^0.18.4",
38457
+ "@cloudflare/workers-oauth-provider": "^0.0.11",
38458
+ "@cloudflare/workers-types": "^5.20260708.1",
38447
38459
  "@types/node": "^26.0.0",
38448
38460
  "@vitest/coverage-v8": "^4.1.7",
38461
+ agents: "^0.17.3",
38449
38462
  esbuild: "^0.28.0",
38450
38463
  typescript: "^7.0.2",
38451
- vitest: "^4.1.7"
38464
+ vitest: "^4.1.7",
38465
+ wrangler: "^4.110.0"
38452
38466
  }
38453
38467
  };
38454
38468
 
@@ -38514,8 +38528,11 @@ async function resolveAuth() {
38514
38528
  }
38515
38529
 
38516
38530
  // src/client.ts
38517
- var __dirname = dirname(fileURLToPath(import.meta.url));
38518
- await loadDotenvSafely({ path: join3(__dirname, "..", ".env") });
38531
+ try {
38532
+ const dir = dirname(fileURLToPath(import.meta.url));
38533
+ await loadDotenvSafely({ path: join3(dir, "..", ".env") });
38534
+ } catch {
38535
+ }
38519
38536
  function parseContentDispositionFilename(cd) {
38520
38537
  const extMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(cd);
38521
38538
  if (extMatch) {
@@ -38548,6 +38565,16 @@ var OFWClient = class {
38548
38565
  // already-expired placeholder token so the first request drives the refresh
38549
38566
  // callback — i.e. the original "log in on first request" behavior.
38550
38567
  tokenManager;
38568
+ // Optional injected auth resolver. When set, the refresh callback uses it
38569
+ // instead of the module-level global `resolveAuth` (env-var → fetchproxy
38570
+ // priority). A hosted per-user deployment injects its own resolver so each
38571
+ // request carries that user's credentials — see the Cloudflare Worker
38572
+ // deployment. Left undefined by the stdio path, which falls back to the
38573
+ // global resolver, keeping that behaviour byte-for-byte identical.
38574
+ authResolver;
38575
+ constructor(opts) {
38576
+ this.authResolver = opts?.resolveAuth;
38577
+ }
38551
38578
  getTokenManager() {
38552
38579
  if (!this.tokenManager) {
38553
38580
  this.tokenManager = new TokenManager({
@@ -38559,7 +38586,7 @@ var OFWClient = class {
38559
38586
  // path uses (the 401-replay covers a wrong guess). We re-arm the
38560
38587
  // sentinel so the manager can refresh again later.
38561
38588
  refresh: async () => {
38562
- const { token, expiresAt } = await resolveAuth();
38589
+ const { token, expiresAt } = await (this.authResolver ?? resolveAuth)();
38563
38590
  return {
38564
38591
  accessToken: token,
38565
38592
  refreshToken: OFW_REFRESH_SENTINEL,
@@ -38669,19 +38696,46 @@ var client = new OFWClient();
38669
38696
  var jsonResponse = textResult;
38670
38697
  var textResponse = rawTextResult;
38671
38698
  var ApiRecipientSchema = external_exports.looseObject({
38672
- user: external_exports.looseObject({ id: external_exports.number().optional(), name: external_exports.string().optional() }).optional(),
38699
+ // Live OFW payloads key the recipient's id as `userId` (verified against a
38700
+ // real /pub/v3/messages record: `recipients[].user.userId === 3039201`). An
38701
+ // earlier guess read `id`, which is absent — so every normalized recipient
38702
+ // came out with `userId: 0`, breaking any "find my own recipient" match. Both
38703
+ // are accepted (userId first, id fallback) so a backend that ever returns `id`
38704
+ // still resolves.
38705
+ user: external_exports.looseObject({
38706
+ userId: external_exports.number().optional(),
38707
+ id: external_exports.number().optional(),
38708
+ name: external_exports.string().optional()
38709
+ }).optional(),
38673
38710
  viewed: external_exports.looseObject({ dateTime: external_exports.string() }).nullable().optional()
38674
38711
  });
38675
38712
  function mapRecipients(items) {
38676
38713
  return (items ?? []).map((r) => {
38677
38714
  const dt = r.viewed?.dateTime;
38678
38715
  const viewedAt = typeof dt === "string" && !dt.startsWith("1970-01-01") ? dt : null;
38679
- return { userId: r.user?.id ?? 0, name: r.user?.name ?? "", viewedAt };
38716
+ return { userId: r.user?.userId ?? r.user?.id ?? 0, name: r.user?.name ?? "", viewedAt };
38680
38717
  });
38681
38718
  }
38682
38719
  function hasRealView(recipients) {
38683
38720
  return recipients.some((r) => r.viewedAt !== null && !r.viewedAt.startsWith("1970-01-01"));
38684
38721
  }
38722
+ function scrapeSaysRead(listData) {
38723
+ if (typeof listData !== "object" || listData === null) return false;
38724
+ const ld = listData;
38725
+ return ld.read === true || ld.showNeverViewed === false;
38726
+ }
38727
+ function deriveRead(row, selfUserId) {
38728
+ if (row.folder === "inbox") {
38729
+ const viewed = selfUserId !== void 0 ? row.recipients.some((r) => r.userId === selfUserId && r.viewedAt !== null) : row.recipients.some((r) => r.viewedAt !== null);
38730
+ return viewed || row.fetchedBodyAt !== null || scrapeSaysRead(row.listData);
38731
+ }
38732
+ return row.recipients.some((r) => r.viewedAt !== null) || scrapeSaysRead(row.listData);
38733
+ }
38734
+ function withReadState(row, selfUserId) {
38735
+ const read = deriveRead(row, selfUserId);
38736
+ const listData = typeof row.listData === "object" && row.listData !== null ? { ...row.listData, read, showNeverViewed: !read } : row.listData;
38737
+ return { ...row, read, listData };
38738
+ }
38685
38739
  var expandPath2 = expandPath;
38686
38740
  function verifyWriteLanded(kind, sent, persisted) {
38687
38741
  const mismatches = [];
@@ -38732,395 +38786,6 @@ function registerUserTools(server, client2) {
38732
38786
  });
38733
38787
  }
38734
38788
 
38735
- // src/cache.ts
38736
- import { DatabaseSync } from "node:sqlite";
38737
- import { mkdirSync, chmodSync, existsSync } from "node:fs";
38738
- import { dirname as dirname2 } from "node:path";
38739
-
38740
- // src/config.ts
38741
- import { createHash } from "node:crypto";
38742
- import { homedir as homedir3 } from "node:os";
38743
- import { join as join4 } from "node:path";
38744
- function readCacheIdentity() {
38745
- return readEnvVar("OFW_CACHE_IDENTITY") ?? readEnvVar("OFW_USERNAME") ?? "_default";
38746
- }
38747
- function getCacheDir() {
38748
- const override = process.env.OFW_CACHE_DIR;
38749
- if (override && override.trim().length > 0) return override.trim();
38750
- return join4(homedir3(), ".cache", "ofw-mcp");
38751
- }
38752
- function getCacheDbPath() {
38753
- const identity = readCacheIdentity();
38754
- const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
38755
- return join4(getCacheDir(), `${hash2}.db`);
38756
- }
38757
- function getAttachmentsDir() {
38758
- const override = process.env.OFW_ATTACHMENTS_DIR;
38759
- if (override && override.trim().length > 0) return override.trim();
38760
- return join4(homedir3(), "Downloads", "ofw-mcp");
38761
- }
38762
- function getWriteMode() {
38763
- const raw = process.env.OFW_WRITE_MODE;
38764
- if (typeof raw !== "string" || raw.trim().length === 0) return "all";
38765
- const mode = raw.trim().toLowerCase();
38766
- if (mode === "none" || mode === "drafts" || mode === "all") return mode;
38767
- console.error(
38768
- `[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" \u2014 failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`
38769
- );
38770
- return "none";
38771
- }
38772
- function getCalendarWritesAllowed() {
38773
- const mode = getWriteMode();
38774
- if (mode === "all") return true;
38775
- return mode === "drafts" && parseBoolEnv("OFW_CALENDAR_WRITES");
38776
- }
38777
- function getDefaultInlineAttachments() {
38778
- return parseBoolEnv("OFW_INLINE_ATTACHMENTS");
38779
- }
38780
-
38781
- // src/cache.ts
38782
- var instance = null;
38783
- var SCHEMA_V1 = `
38784
- CREATE TABLE IF NOT EXISTS messages (
38785
- id INTEGER PRIMARY KEY,
38786
- folder TEXT NOT NULL,
38787
- subject TEXT NOT NULL,
38788
- from_user TEXT NOT NULL,
38789
- sent_at TEXT NOT NULL,
38790
- recipients_json TEXT NOT NULL,
38791
- body TEXT,
38792
- fetched_body_at TEXT,
38793
- reply_to_id INTEGER,
38794
- chain_root_id INTEGER,
38795
- list_data_json TEXT NOT NULL,
38796
- last_seen_at TEXT NOT NULL
38797
- );
38798
- CREATE INDEX IF NOT EXISTS idx_messages_folder_sent_at ON messages(folder, sent_at DESC);
38799
- CREATE INDEX IF NOT EXISTS idx_messages_chain_root ON messages(chain_root_id);
38800
-
38801
- CREATE TABLE IF NOT EXISTS drafts (
38802
- id INTEGER PRIMARY KEY,
38803
- subject TEXT NOT NULL,
38804
- body TEXT NOT NULL,
38805
- recipients_json TEXT NOT NULL,
38806
- reply_to_id INTEGER,
38807
- modified_at TEXT NOT NULL,
38808
- list_data_json TEXT NOT NULL
38809
- );
38810
-
38811
- CREATE TABLE IF NOT EXISTS sync_state (
38812
- folder TEXT PRIMARY KEY,
38813
- last_sync_at TEXT NOT NULL,
38814
- newest_id INTEGER
38815
- );
38816
-
38817
- CREATE TABLE IF NOT EXISTS meta (
38818
- key TEXT PRIMARY KEY,
38819
- value TEXT NOT NULL
38820
- );
38821
- `;
38822
- var SCHEMA_V2 = `
38823
- CREATE TABLE IF NOT EXISTS attachments (
38824
- file_id INTEGER PRIMARY KEY,
38825
- file_name TEXT NOT NULL,
38826
- label TEXT NOT NULL,
38827
- mime_type TEXT NOT NULL,
38828
- size_bytes INTEGER,
38829
- metadata_json TEXT NOT NULL,
38830
- message_ids_json TEXT NOT NULL, -- JSON array of message ids that reference this file
38831
- downloaded_path TEXT, -- absolute path on disk if/when downloaded
38832
- downloaded_at TEXT,
38833
- fetched_metadata_at TEXT NOT NULL
38834
- );
38835
- `;
38836
- function migrate(db) {
38837
- db.exec(SCHEMA_V1);
38838
- db.exec(SCHEMA_V2);
38839
- db.prepare(
38840
- "INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
38841
- ).run("schema_version", "2");
38842
- }
38843
- function enforceCachePermissions(dbPath) {
38844
- chmodSync(dirname2(dbPath), 448);
38845
- chmodSync(dbPath, 384);
38846
- for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
38847
- if (existsSync(sibling)) chmodSync(sibling, 384);
38848
- }
38849
- }
38850
- function openCache() {
38851
- if (instance) return instance;
38852
- const path = getCacheDbPath();
38853
- mkdirSync(dirname2(path), { recursive: true });
38854
- const db = new DatabaseSync(path);
38855
- enforceCachePermissions(path);
38856
- db.exec("PRAGMA journal_mode = WAL");
38857
- db.exec("PRAGMA foreign_keys = ON");
38858
- migrate(db);
38859
- enforceCachePermissions(path);
38860
- instance = { db };
38861
- return instance;
38862
- }
38863
- function rowFromDb(r) {
38864
- return {
38865
- id: r.id,
38866
- folder: r.folder,
38867
- subject: r.subject,
38868
- fromUser: r.from_user,
38869
- sentAt: r.sent_at,
38870
- recipients: JSON.parse(r.recipients_json),
38871
- body: r.body,
38872
- fetchedBodyAt: r.fetched_body_at,
38873
- replyToId: r.reply_to_id,
38874
- chainRootId: r.chain_root_id,
38875
- listData: JSON.parse(r.list_data_json)
38876
- };
38877
- }
38878
- function nullish3(v) {
38879
- return v === void 0 ? null : v;
38880
- }
38881
- function requireString(field, v) {
38882
- if (typeof v === "string") return v;
38883
- throw new Error(`cache: ${field} is required (got ${v === void 0 ? "undefined" : "null"})`);
38884
- }
38885
- function upsertMessage(row) {
38886
- const { db } = openCache();
38887
- db.prepare(
38888
- `INSERT INTO messages (
38889
- id, folder, subject, from_user, sent_at, recipients_json,
38890
- body, fetched_body_at, reply_to_id, chain_root_id, list_data_json, last_seen_at
38891
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
38892
- ON CONFLICT(id) DO UPDATE SET
38893
- folder=excluded.folder,
38894
- subject=excluded.subject,
38895
- from_user=excluded.from_user,
38896
- sent_at=excluded.sent_at,
38897
- recipients_json=excluded.recipients_json,
38898
- body=excluded.body,
38899
- fetched_body_at=excluded.fetched_body_at,
38900
- reply_to_id=excluded.reply_to_id,
38901
- chain_root_id=excluded.chain_root_id,
38902
- list_data_json=excluded.list_data_json,
38903
- last_seen_at=excluded.last_seen_at`
38904
- ).run(
38905
- row.id,
38906
- requireString("messages.folder", row.folder),
38907
- requireString("messages.subject", row.subject),
38908
- requireString("messages.fromUser", row.fromUser),
38909
- requireString("messages.sentAt", row.sentAt),
38910
- JSON.stringify(row.recipients ?? []),
38911
- nullish3(row.body),
38912
- nullish3(row.fetchedBodyAt),
38913
- nullish3(row.replyToId),
38914
- nullish3(row.chainRootId),
38915
- JSON.stringify(row.listData ?? null),
38916
- (/* @__PURE__ */ new Date()).toISOString()
38917
- );
38918
- }
38919
- function getMessage(id) {
38920
- const { db } = openCache();
38921
- const r = db.prepare("SELECT * FROM messages WHERE id = ?").get(id);
38922
- return r ? rowFromDb(r) : null;
38923
- }
38924
- function deleteMessage(id) {
38925
- const { db } = openCache();
38926
- db.prepare("DELETE FROM messages WHERE id = ?").run(id);
38927
- }
38928
- function buildMessageFilter(opts) {
38929
- const wheres = [];
38930
- const params = [];
38931
- if (opts.folder !== void 0) {
38932
- wheres.push("folder = ?");
38933
- params.push(opts.folder);
38934
- }
38935
- if (opts.since !== void 0) {
38936
- wheres.push("sent_at >= ?");
38937
- params.push(opts.since);
38938
- }
38939
- if (opts.until !== void 0) {
38940
- wheres.push("sent_at < ?");
38941
- params.push(opts.until);
38942
- }
38943
- if (opts.q !== void 0 && opts.q.length > 0) {
38944
- const pattern = `%${opts.q}%`;
38945
- wheres.push("(subject LIKE ? OR body LIKE ?)");
38946
- params.push(pattern, pattern);
38947
- }
38948
- return {
38949
- where: wheres.length > 0 ? `WHERE ${wheres.join(" AND ")}` : "",
38950
- params
38951
- };
38952
- }
38953
- function listMessages(opts) {
38954
- const { db } = openCache();
38955
- const { where, params } = buildMessageFilter(opts);
38956
- const offset = (opts.page - 1) * opts.size;
38957
- const rows = db.prepare(
38958
- `SELECT * FROM messages ${where}
38959
- ORDER BY sent_at DESC, id DESC
38960
- LIMIT ? OFFSET ?`
38961
- ).all(...params, opts.size, offset);
38962
- return rows.map(rowFromDb);
38963
- }
38964
- function countMessages(opts) {
38965
- const { db } = openCache();
38966
- const { where, params } = buildMessageFilter(opts);
38967
- const r = db.prepare(`SELECT COUNT(*) as n FROM messages ${where}`).get(...params);
38968
- return r?.n ?? 0;
38969
- }
38970
- function draftFromDb(r) {
38971
- return {
38972
- id: r.id,
38973
- subject: r.subject,
38974
- body: r.body,
38975
- recipients: JSON.parse(r.recipients_json),
38976
- replyToId: r.reply_to_id,
38977
- modifiedAt: r.modified_at,
38978
- listData: JSON.parse(r.list_data_json)
38979
- };
38980
- }
38981
- function upsertDraft(row) {
38982
- const { db } = openCache();
38983
- db.prepare(
38984
- `INSERT INTO drafts (id, subject, body, recipients_json, reply_to_id, modified_at, list_data_json)
38985
- VALUES (?, ?, ?, ?, ?, ?, ?)
38986
- ON CONFLICT(id) DO UPDATE SET
38987
- subject=excluded.subject,
38988
- body=excluded.body,
38989
- recipients_json=excluded.recipients_json,
38990
- reply_to_id=excluded.reply_to_id,
38991
- modified_at=excluded.modified_at,
38992
- list_data_json=excluded.list_data_json`
38993
- ).run(
38994
- row.id,
38995
- requireString("drafts.subject", row.subject),
38996
- requireString("drafts.body", row.body),
38997
- JSON.stringify(row.recipients ?? []),
38998
- nullish3(row.replyToId),
38999
- requireString("drafts.modifiedAt", row.modifiedAt),
39000
- JSON.stringify(row.listData ?? null)
39001
- );
39002
- }
39003
- function getDraft(id) {
39004
- const { db } = openCache();
39005
- const r = db.prepare("SELECT * FROM drafts WHERE id = ?").get(id);
39006
- return r ? draftFromDb(r) : null;
39007
- }
39008
- function listDrafts(opts) {
39009
- const { db } = openCache();
39010
- const offset = (opts.page - 1) * opts.size;
39011
- const rows = db.prepare(
39012
- "SELECT * FROM drafts ORDER BY modified_at DESC, id DESC LIMIT ? OFFSET ?"
39013
- ).all(opts.size, offset);
39014
- return rows.map(draftFromDb);
39015
- }
39016
- function deleteDraft(id) {
39017
- const { db } = openCache();
39018
- db.prepare("DELETE FROM drafts WHERE id = ?").run(id);
39019
- }
39020
- function listDraftIds() {
39021
- const { db } = openCache();
39022
- const rows = db.prepare("SELECT id FROM drafts").all();
39023
- return rows.map((r) => r.id);
39024
- }
39025
- function setSyncState(folder, state) {
39026
- const { db } = openCache();
39027
- db.prepare(
39028
- `INSERT INTO sync_state (folder, last_sync_at, newest_id) VALUES (?, ?, ?)
39029
- ON CONFLICT(folder) DO UPDATE SET
39030
- last_sync_at = excluded.last_sync_at,
39031
- newest_id = excluded.newest_id`
39032
- ).run(folder, state.lastSyncAt, state.newestId);
39033
- }
39034
- function setMeta(key, value) {
39035
- const { db } = openCache();
39036
- db.prepare(
39037
- "INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
39038
- ).run(key, value);
39039
- }
39040
- function findLatestReplyTip(replyToId) {
39041
- const { db } = openCache();
39042
- const parent = db.prepare(
39043
- "SELECT id, folder, chain_root_id FROM messages WHERE id = ?"
39044
- ).get(replyToId);
39045
- if (!parent) return replyToId;
39046
- const chainRoot = parent.chain_root_id ?? parent.id;
39047
- const tip = db.prepare(
39048
- `SELECT id FROM messages
39049
- WHERE folder = 'sent' AND chain_root_id = ?
39050
- ORDER BY id DESC LIMIT 1`
39051
- ).get(chainRoot);
39052
- return tip ? tip.id : replyToId;
39053
- }
39054
- function attachmentFromDb(r) {
39055
- return {
39056
- fileId: r.file_id,
39057
- fileName: r.file_name,
39058
- label: r.label,
39059
- mimeType: r.mime_type,
39060
- sizeBytes: r.size_bytes,
39061
- metadata: JSON.parse(r.metadata_json),
39062
- messageIds: JSON.parse(r.message_ids_json),
39063
- downloadedPath: r.downloaded_path,
39064
- downloadedAt: r.downloaded_at
39065
- };
39066
- }
39067
- function getAttachment(fileId) {
39068
- const { db } = openCache();
39069
- const r = db.prepare("SELECT * FROM attachments WHERE file_id = ?").get(fileId);
39070
- return r ? attachmentFromDb(r) : null;
39071
- }
39072
- function listAttachmentsForMessage(messageId) {
39073
- const { db } = openCache();
39074
- const rows = db.prepare(
39075
- `SELECT * FROM attachments
39076
- WHERE EXISTS (SELECT 1 FROM json_each(message_ids_json) WHERE value = ?)
39077
- ORDER BY file_id`
39078
- ).all(messageId);
39079
- return rows.map(attachmentFromDb);
39080
- }
39081
- function upsertAttachmentForMessage(input) {
39082
- const { db } = openCache();
39083
- const existing = db.prepare("SELECT message_ids_json FROM attachments WHERE file_id = ?").get(input.fileId);
39084
- const prior = existing ? JSON.parse(existing.message_ids_json) : [];
39085
- let messageIds;
39086
- if (input.messageId === 0) {
39087
- messageIds = prior;
39088
- } else if (prior.includes(input.messageId)) {
39089
- messageIds = prior;
39090
- } else {
39091
- messageIds = [...prior, input.messageId];
39092
- }
39093
- db.prepare(
39094
- `INSERT INTO attachments (
39095
- file_id, file_name, label, mime_type, size_bytes,
39096
- metadata_json, message_ids_json, fetched_metadata_at
39097
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
39098
- ON CONFLICT(file_id) DO UPDATE SET
39099
- file_name=excluded.file_name,
39100
- label=excluded.label,
39101
- mime_type=excluded.mime_type,
39102
- size_bytes=excluded.size_bytes,
39103
- metadata_json=excluded.metadata_json,
39104
- message_ids_json=excluded.message_ids_json,
39105
- fetched_metadata_at=excluded.fetched_metadata_at`
39106
- ).run(
39107
- input.fileId,
39108
- requireString("attachments.fileName", input.fileName),
39109
- requireString("attachments.label", input.label),
39110
- requireString("attachments.mimeType", input.mimeType),
39111
- nullish3(input.sizeBytes),
39112
- JSON.stringify(input.metadata ?? null),
39113
- JSON.stringify(messageIds),
39114
- (/* @__PURE__ */ new Date()).toISOString()
39115
- );
39116
- }
39117
- function markAttachmentDownloaded(fileId, path) {
39118
- const { db } = openCache();
39119
- db.prepare(
39120
- "UPDATE attachments SET downloaded_path = ?, downloaded_at = ? WHERE file_id = ?"
39121
- ).run(path, (/* @__PURE__ */ new Date()).toISOString(), fileId);
39122
- }
39123
-
39124
38789
  // src/sync.ts
39125
38790
  var FileMetaSchema = external_exports.looseObject({
39126
38791
  fileId: external_exports.number(),
@@ -39130,13 +38795,13 @@ var FileMetaSchema = external_exports.looseObject({
39130
38795
  // MIME
39131
38796
  fileSize: external_exports.number().optional()
39132
38797
  });
39133
- async function fetchAttachmentMeta(client2, fileId, messageId) {
38798
+ async function fetchAttachmentMeta(client2, fileId, messageId, store) {
39134
38799
  const meta3 = parseLenient(
39135
38800
  FileMetaSchema,
39136
38801
  await client2.request("GET", `/pub/v1/myfiles/${fileId}`),
39137
38802
  { label: "ofw-mcp", context: "GET /pub/v1/myfiles/{fileId}" }
39138
38803
  );
39139
- upsertAttachmentForMessage({
38804
+ await store.upsertAttachmentForMessage({
39140
38805
  fileId: meta3.fileId ?? fileId,
39141
38806
  fileName: meta3.fileName ?? `file-${fileId}`,
39142
38807
  label: meta3.label ?? meta3.fileName ?? `file-${fileId}`,
@@ -39146,13 +38811,33 @@ async function fetchAttachmentMeta(client2, fileId, messageId) {
39146
38811
  messageId
39147
38812
  });
39148
38813
  }
39149
- async function fetchAttachmentMetaForMessage(client2, messageId, fileIds) {
39150
- await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client2, fid, messageId)));
38814
+ async function fetchAttachmentMetaForMessage(client2, messageId, fileIds, store) {
38815
+ await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client2, fid, messageId, store)));
38816
+ }
38817
+ function makeBudget(max) {
38818
+ let remaining = max;
38819
+ return {
38820
+ take() {
38821
+ if (remaining <= 0) return false;
38822
+ remaining -= 1;
38823
+ return true;
38824
+ }
38825
+ };
38826
+ }
38827
+ async function fetchAttachmentMetaBudgeted(client2, messageId, fileIds, store, budget) {
38828
+ const affordable = [];
38829
+ for (const fid of fileIds) {
38830
+ if (!budget.take()) break;
38831
+ affordable.push(fid);
38832
+ }
38833
+ if (affordable.length > 0) {
38834
+ await fetchAttachmentMetaForMessage(client2, messageId, affordable, store);
38835
+ }
39151
38836
  }
39152
38837
  var FoldersSchema = external_exports.looseObject({
39153
38838
  systemFolders: external_exports.array(external_exports.looseObject({ id: external_exports.string(), folderType: external_exports.string() })).optional()
39154
38839
  });
39155
- async function resolveFolderIds(client2) {
38840
+ async function resolveFolderIds(client2, store) {
39156
38841
  const data = parseLenient(
39157
38842
  FoldersSchema,
39158
38843
  await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true"),
@@ -39169,7 +38854,8 @@ async function resolveFolderIds(client2) {
39169
38854
  sent: find("SENT_MESSAGES"),
39170
38855
  drafts: find("DRAFTS")
39171
38856
  };
39172
- setMeta("drafts_folder_id", ids.drafts);
38857
+ await store.setMeta("drafts_folder_id", ids.drafts);
38858
+ await store.setMeta("sent_folder_id", ids.sent);
39173
38859
  return ids;
39174
38860
  }
39175
38861
  var ListItemSchema = external_exports.looseObject({
@@ -39188,12 +38874,17 @@ var DetailResponseSchema = external_exports.looseObject({
39188
38874
  // endpoint only has an epoch placeholder) — used by the view-status refresh.
39189
38875
  recipients: external_exports.array(ApiRecipientSchema).optional()
39190
38876
  });
39191
- async function syncMessageFolder(client2, folder, folderId, opts) {
39192
- let page = 1;
39193
- let synced = 0;
38877
+ var maxId = (a, b) => a === null ? b : b === null ? a : Math.max(a, b);
38878
+ async function walkPages(client2, folder, folderId, opts, store) {
38879
+ const budget = opts.budget;
38880
+ let page = opts.startPage;
39194
38881
  let newestId = null;
38882
+ let synced = 0;
39195
38883
  const unread = [];
39196
38884
  while (true) {
38885
+ if (!budget.take()) {
38886
+ return { synced, unread, newestId, done: false, nextPage: page };
38887
+ }
39197
38888
  const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
39198
38889
  const list = parseLenient(
39199
38890
  ListResponseSchema,
@@ -39201,19 +38892,30 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
39201
38892
  { label: "ofw-mcp", context: `GET /pub/v3/messages?folders={${folder}}` }
39202
38893
  );
39203
38894
  const items = list.data ?? [];
39204
- if (items.length === 0) break;
38895
+ if (items.length === 0) {
38896
+ return { synced, unread, newestId, done: true, nextPage: null };
38897
+ }
38898
+ const existingById = new Map(
38899
+ (await store.getMessages(items.map((it) => it.id))).map((row) => [row.id, row])
38900
+ );
38901
+ const toUpsert = [];
39205
38902
  let pageHadNewItem = false;
38903
+ let pageBudgetHit = false;
39206
38904
  for (const item of items) {
39207
38905
  if (newestId === null || item.id > newestId) newestId = item.id;
39208
- const existing = getMessage(item.id);
38906
+ const existing = existingById.get(item.id);
39209
38907
  if (existing) {
39210
38908
  if (folder === "sent" && item.showNeverViewed === false && !hasRealView(existing.recipients)) {
38909
+ if (!budget.take()) {
38910
+ pageBudgetHit = true;
38911
+ break;
38912
+ }
39211
38913
  const detail = parseLenient(
39212
38914
  DetailResponseSchema,
39213
38915
  await client2.request("GET", `/pub/v3/messages/${item.id}`),
39214
38916
  { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (view-status refresh)" }
39215
38917
  );
39216
- upsertMessage({ ...existing, recipients: mapRecipients(detail.recipients), listData: item });
38918
+ toUpsert.push({ ...existing, recipients: mapRecipients(detail.recipients), listData: item });
39217
38919
  synced++;
39218
38920
  }
39219
38921
  continue;
@@ -39224,7 +38926,12 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
39224
38926
  let body = null;
39225
38927
  let fetchedBodyAt = null;
39226
38928
  let detailFileIds = [];
38929
+ let detailRecipients;
39227
38930
  if (shouldFetchBody) {
38931
+ if (!budget.take()) {
38932
+ pageBudgetHit = true;
38933
+ break;
38934
+ }
39228
38935
  const detail = parseLenient(
39229
38936
  DetailResponseSchema,
39230
38937
  await client2.request("GET", `/pub/v3/messages/${item.id}`),
@@ -39232,6 +38939,7 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
39232
38939
  );
39233
38940
  body = detail.body ?? "";
39234
38941
  fetchedBodyAt = (/* @__PURE__ */ new Date()).toISOString();
38942
+ detailRecipients = detail.recipients;
39235
38943
  if (Array.isArray(detail.files) && detail.files.length > 0) {
39236
38944
  detailFileIds = detail.files;
39237
38945
  }
@@ -39249,27 +38957,72 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
39249
38957
  subject: item.subject ?? "(no subject)",
39250
38958
  fromUser: item.from?.name ?? "",
39251
38959
  sentAt: item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
39252
- recipients: mapRecipients(item.recipients),
38960
+ recipients: mapRecipients(detailRecipients ?? item.recipients),
39253
38961
  body,
39254
38962
  fetchedBodyAt,
39255
38963
  replyToId: null,
39256
38964
  chainRootId: null,
39257
38965
  listData: item
39258
38966
  };
39259
- upsertMessage(row);
38967
+ toUpsert.push(row);
39260
38968
  synced++;
39261
38969
  if (detailFileIds.length > 0) {
39262
- await fetchAttachmentMetaForMessage(client2, item.id, detailFileIds);
38970
+ await fetchAttachmentMetaBudgeted(client2, item.id, detailFileIds, store, budget);
39263
38971
  }
39264
38972
  }
39265
- if (!opts.deep && !pageHadNewItem) break;
38973
+ await store.upsertMessages(toUpsert);
38974
+ if (pageBudgetHit) {
38975
+ return { synced, unread, newestId, done: false, nextPage: page };
38976
+ }
38977
+ if (opts.stopAtCachedPage && !pageHadNewItem) {
38978
+ return { synced, unread, newestId, done: true, nextPage: page };
38979
+ }
39266
38980
  page++;
39267
38981
  }
39268
- setSyncState(folder, {
38982
+ }
38983
+ async function syncMessageFolder(client2, folder, folderId, opts, store) {
38984
+ const budget = opts.budget ?? makeBudget(Number.POSITIVE_INFINITY);
38985
+ const saved = await store.getSyncState(folder);
38986
+ const savedResume = saved?.resumePage ?? null;
38987
+ const fwd = await walkPages(client2, folder, folderId, {
38988
+ startPage: 1,
38989
+ stopAtCachedPage: true,
38990
+ fetchUnreadBodies: opts.fetchUnreadBodies,
38991
+ budget
38992
+ }, store);
38993
+ let synced = fwd.synced;
38994
+ const unread = [...fwd.unread];
38995
+ let newestId = maxId(saved?.newestId ?? null, fwd.newestId);
38996
+ let done;
38997
+ let resumePage;
38998
+ if (!fwd.done) {
38999
+ done = false;
39000
+ resumePage = savedResume === null ? fwd.nextPage : Math.min(fwd.nextPage, savedResume);
39001
+ } else if (fwd.nextPage === null) {
39002
+ done = true;
39003
+ resumePage = null;
39004
+ } else if (savedResume === null && !opts.deep) {
39005
+ done = true;
39006
+ resumePage = null;
39007
+ } else {
39008
+ const bf = await walkPages(client2, folder, folderId, {
39009
+ startPage: savedResume ?? fwd.nextPage,
39010
+ stopAtCachedPage: false,
39011
+ fetchUnreadBodies: opts.fetchUnreadBodies,
39012
+ budget
39013
+ }, store);
39014
+ synced += bf.synced;
39015
+ unread.push(...bf.unread);
39016
+ newestId = maxId(newestId, bf.newestId);
39017
+ done = bf.done;
39018
+ resumePage = bf.done ? null : bf.nextPage;
39019
+ }
39020
+ await store.setSyncState(folder, {
39269
39021
  lastSyncAt: (/* @__PURE__ */ new Date()).toISOString(),
39270
- newestId
39022
+ newestId,
39023
+ resumePage
39271
39024
  });
39272
- return { synced, unread };
39025
+ return { synced, unread, done };
39273
39026
  }
39274
39027
  var DraftListItemSchema = external_exports.looseObject({
39275
39028
  id: external_exports.number(),
@@ -39283,10 +39036,12 @@ var DraftDetailSchema = external_exports.looseObject({
39283
39036
  body: external_exports.string().optional(),
39284
39037
  subject: external_exports.string().optional()
39285
39038
  });
39286
- async function syncDrafts(client2, draftsFolderId) {
39039
+ async function syncDrafts(client2, draftsFolderId, store, budget) {
39040
+ const b = budget ?? makeBudget(Number.POSITIVE_INFINITY);
39287
39041
  const items = [];
39288
39042
  let page = 1;
39289
39043
  while (true) {
39044
+ if (!b.take()) return { synced: 0, done: false };
39290
39045
  const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
39291
39046
  const list = parseLenient(
39292
39047
  DraftListResponseSchema,
@@ -39298,68 +39053,136 @@ async function syncDrafts(client2, draftsFolderId) {
39298
39053
  if (pageItems.length < 50) break;
39299
39054
  page++;
39300
39055
  }
39301
- const seenIds = /* @__PURE__ */ new Set();
39302
- let synced = 0;
39056
+ const rows = [];
39303
39057
  for (const item of items) {
39304
- seenIds.add(item.id);
39305
- const modifiedAt = item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString();
39306
- const existing = getDraft(item.id);
39058
+ if (!b.take()) return { synced: 0, done: false };
39307
39059
  const detail = parseLenient(
39308
39060
  DraftDetailSchema,
39309
39061
  await client2.request("GET", `/pub/v3/messages/${item.id}`),
39310
39062
  { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (drafts sync)" }
39311
39063
  );
39312
- const row = {
39064
+ rows.push({
39313
39065
  id: item.id,
39314
39066
  subject: detail.subject ?? item.subject ?? "(no subject)",
39315
39067
  body: detail.body ?? "",
39316
39068
  recipients: mapRecipients(item.recipients),
39317
39069
  replyToId: item.replyToId ?? null,
39318
- modifiedAt,
39070
+ modifiedAt: item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
39319
39071
  listData: item
39320
- };
39321
- upsertDraft(row);
39322
- if (getMessage(item.id)) deleteMessage(item.id);
39072
+ });
39073
+ }
39074
+ const ids = items.map((it) => it.id);
39075
+ const existingById = new Map((await store.getDrafts(ids)).map((d) => [d.id, d]));
39076
+ await store.upsertDrafts(rows);
39077
+ for (const stale of await store.getMessages(ids)) {
39078
+ await store.deleteMessage(stale.id);
39079
+ }
39080
+ let synced = 0;
39081
+ for (const row of rows) {
39082
+ const existing = existingById.get(row.id);
39323
39083
  if (!existing || existing.body !== row.body || existing.subject !== row.subject || existing.replyToId !== row.replyToId) {
39324
39084
  synced++;
39325
39085
  }
39326
39086
  }
39327
- for (const id of listDraftIds()) {
39328
- if (!seenIds.has(id)) deleteDraft(id);
39087
+ const seenIds = new Set(ids);
39088
+ for (const id of await store.listDraftIds()) {
39089
+ if (!seenIds.has(id)) await store.deleteDraft(id);
39329
39090
  }
39330
- return { synced };
39091
+ return { synced, done: true };
39331
39092
  }
39332
- async function syncAll(client2, opts) {
39093
+ async function syncAll(client2, opts, store) {
39333
39094
  const folders = opts.folders ?? ["inbox", "sent", "drafts"];
39334
- const ids = await resolveFolderIds(client2);
39095
+ const budget = makeBudget(opts.maxRequests ?? Number.POSITIVE_INFINITY);
39096
+ budget.take();
39097
+ const ids = await resolveFolderIds(client2, store);
39335
39098
  const synced = {};
39336
39099
  let unreadInbox = [];
39100
+ let done = true;
39337
39101
  for (const folder of folders) {
39338
39102
  if (folder === "inbox") {
39339
39103
  const r = await syncMessageFolder(client2, "inbox", ids.inbox, {
39340
39104
  fetchUnreadBodies: opts.fetchUnreadBodies ?? false,
39341
- deep: opts.deep ?? false
39342
- });
39105
+ deep: opts.deep ?? false,
39106
+ budget
39107
+ }, store);
39343
39108
  synced.inbox = r.synced;
39344
39109
  unreadInbox = r.unread;
39110
+ if (!r.done) done = false;
39345
39111
  } else if (folder === "sent") {
39346
39112
  const r = await syncMessageFolder(client2, "sent", ids.sent, {
39347
39113
  fetchUnreadBodies: false,
39348
- deep: opts.deep ?? false
39349
- });
39114
+ deep: opts.deep ?? false,
39115
+ budget
39116
+ }, store);
39350
39117
  synced.sent = r.synced;
39118
+ if (!r.done) done = false;
39351
39119
  } else if (folder === "drafts") {
39352
- const r = await syncDrafts(client2, ids.drafts);
39120
+ const r = await syncDrafts(client2, ids.drafts, store, budget);
39353
39121
  synced.drafts = r.synced;
39122
+ if (!r.done) done = false;
39354
39123
  }
39355
39124
  }
39356
- const note = unreadInbox.length > 0 ? `${unreadInbox.length} unread inbox messages cached without bodies. Call ofw_get_message(id) to read them \u2014 this will mark them as read on OFW.` : void 0;
39357
- return { synced, unreadInbox, ...note ? { note } : {} };
39125
+ const notes = [];
39126
+ if (unreadInbox.length > 0) {
39127
+ notes.push(`${unreadInbox.length} unread inbox messages cached without bodies. Call ofw_get_message(id) to read them \u2014 this will mark them as read on OFW.`);
39128
+ }
39129
+ if (!done) {
39130
+ notes.push("Paused after the request budget to stay within the hosting limit; more pages remain \u2014 call ofw_sync_messages again with the same arguments to resume where it left off and continue the backfill.");
39131
+ }
39132
+ const note = notes.length > 0 ? notes.join("\n\n") : void 0;
39133
+ return { synced, unreadInbox, done, ...note ? { note } : {} };
39134
+ }
39135
+
39136
+ // src/config.ts
39137
+ import { createHash } from "node:crypto";
39138
+ import { homedir as homedir3 } from "node:os";
39139
+ import { join as join4 } from "node:path";
39140
+ function readCacheIdentity() {
39141
+ return readEnvVar("OFW_CACHE_IDENTITY") ?? readEnvVar("OFW_USERNAME") ?? "_default";
39142
+ }
39143
+ function getCacheDir() {
39144
+ const override = process.env.OFW_CACHE_DIR;
39145
+ if (override && override.trim().length > 0) return override.trim();
39146
+ return join4(homedir3(), ".cache", "ofw-mcp");
39147
+ }
39148
+ function getCacheDbPath() {
39149
+ const identity = readCacheIdentity();
39150
+ const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
39151
+ return join4(getCacheDir(), `${hash2}.db`);
39152
+ }
39153
+ function getAttachmentsDir() {
39154
+ const override = process.env.OFW_ATTACHMENTS_DIR;
39155
+ if (override && override.trim().length > 0) return override.trim();
39156
+ return join4(homedir3(), "Downloads", "ofw-mcp");
39157
+ }
39158
+ function getWriteMode() {
39159
+ const raw = process.env.OFW_WRITE_MODE;
39160
+ if (typeof raw !== "string" || raw.trim().length === 0) return "all";
39161
+ const mode = raw.trim().toLowerCase();
39162
+ if (mode === "none" || mode === "drafts" || mode === "all") return mode;
39163
+ console.error(
39164
+ `[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" \u2014 failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`
39165
+ );
39166
+ return "none";
39167
+ }
39168
+ function getCalendarWritesAllowed() {
39169
+ const mode = getWriteMode();
39170
+ if (mode === "all") return true;
39171
+ return mode === "drafts" && parseBoolEnv("OFW_CALENDAR_WRITES");
39172
+ }
39173
+ function getDefaultInlineAttachments() {
39174
+ return parseBoolEnv("OFW_INLINE_ATTACHMENTS");
39175
+ }
39176
+ function getSyncMaxRequests() {
39177
+ const raw = readEnvVar("OFW_SYNC_MAX_REQUESTS");
39178
+ if (raw === void 0) return Number.POSITIVE_INFINITY;
39179
+ const n = Number(raw);
39180
+ if (!Number.isInteger(n) || n <= 0) return Number.POSITIVE_INFINITY;
39181
+ return n;
39358
39182
  }
39359
39183
 
39360
39184
  // src/tools/messages.ts
39361
- import { mkdirSync as mkdirSync2, readFileSync, statSync, writeFileSync } from "node:fs";
39362
- import { basename, dirname as dirname3, extname, join as join5 } from "node:path";
39185
+ import { basename, join as join5 } from "node:path";
39363
39186
  var DateSchema = external_exports.looseObject({ dateTime: external_exports.string() });
39364
39187
  var SentDetailSchema = external_exports.looseObject({
39365
39188
  subject: external_exports.string().optional(),
@@ -39382,7 +39205,11 @@ var MessageDetailSchema = external_exports.looseObject({
39382
39205
  date: DateSchema,
39383
39206
  from: external_exports.looseObject({ name: external_exports.string().optional() }).optional(),
39384
39207
  files: external_exports.array(external_exports.number()).optional(),
39385
- recipients: external_exports.array(ApiRecipientSchema).optional()
39208
+ recipients: external_exports.array(ApiRecipientSchema).optional(),
39209
+ // The detail payload carries its own owning folder ({id, name}). We read the
39210
+ // id to label a live-fetched message sent-vs-inbox instead of blindly
39211
+ // defaulting to inbox — see the folder derivation in ofw_get_message.
39212
+ folder: external_exports.looseObject({ id: external_exports.number() }).optional()
39386
39213
  });
39387
39214
  var DetailFilesSchema = external_exports.looseObject({ files: external_exports.array(external_exports.number()).optional() });
39388
39215
  var UploadedFileSchema = external_exports.looseObject({
@@ -39393,33 +39220,6 @@ var UploadedFileSchema = external_exports.looseObject({
39393
39220
  sizeInBytes: external_exports.number().optional(),
39394
39221
  shareClass: external_exports.string().optional()
39395
39222
  });
39396
- var MIME_BY_EXT = {
39397
- ".pdf": "application/pdf",
39398
- ".png": "image/png",
39399
- ".jpg": "image/jpeg",
39400
- ".jpeg": "image/jpeg",
39401
- ".gif": "image/gif",
39402
- ".webp": "image/webp",
39403
- ".heic": "image/heic",
39404
- ".txt": "text/plain",
39405
- ".md": "text/markdown",
39406
- ".csv": "text/csv",
39407
- ".html": "text/html",
39408
- ".htm": "text/html",
39409
- ".json": "application/json",
39410
- ".xml": "application/xml",
39411
- ".doc": "application/msword",
39412
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
39413
- ".xls": "application/vnd.ms-excel",
39414
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
39415
- ".ppt": "application/vnd.ms-powerpoint",
39416
- ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
39417
- ".zip": "application/zip",
39418
- ".ics": "text/calendar"
39419
- };
39420
- function mimeFromName(name) {
39421
- return MIME_BY_EXT[extname(name).toLowerCase()] ?? "application/octet-stream";
39422
- }
39423
39223
  function listDataHintsAtFiles(listData) {
39424
39224
  if (typeof listData !== "object" || listData === null) return false;
39425
39225
  const ld = listData;
@@ -39427,7 +39227,7 @@ function listDataHintsAtFiles(listData) {
39427
39227
  if (Array.isArray(ld.files)) return ld.files.length > 0;
39428
39228
  return false;
39429
39229
  }
39430
- function registerMessageTools(server, client2) {
39230
+ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
39431
39231
  const writeMode = getWriteMode();
39432
39232
  const allowSend = writeMode === "all";
39433
39233
  const allowDrafts = writeMode !== "none";
@@ -39463,9 +39263,10 @@ function registerMessageTools(server, client2) {
39463
39263
  note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache.'
39464
39264
  });
39465
39265
  }
39266
+ const cache = cacheProvider();
39466
39267
  const filter = { folder, since: args.since, until: args.until, q: args.q };
39467
- const total = countMessages(filter);
39468
- const messages = listMessages({ ...filter, page, size });
39268
+ const total = await cache.countMessages(filter);
39269
+ const messages = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
39469
39270
  const payload = { messages, total, page, size };
39470
39271
  if (total === 0) {
39471
39272
  payload.note = "No messages match these filters. If you expected results, check ofw_sync_messages was run, or relax the filters.";
@@ -39482,7 +39283,8 @@ function registerMessageTools(server, client2) {
39482
39283
  }
39483
39284
  }, async (args) => {
39484
39285
  const id = Number(args.messageId);
39485
- const draftRow = getDraft(id);
39286
+ const cache = cacheProvider();
39287
+ const draftRow = await cache.getDraft(id);
39486
39288
  if (draftRow !== null) {
39487
39289
  return jsonResponse({
39488
39290
  id: draftRow.id,
@@ -39502,7 +39304,7 @@ function registerMessageTools(server, client2) {
39502
39304
  attachments: []
39503
39305
  });
39504
39306
  }
39505
- const cached2 = getMessage(id);
39307
+ const cached2 = await cache.getMessage(id);
39506
39308
  if (cached2 && cached2.body !== null) {
39507
39309
  let row2 = cached2;
39508
39310
  if (cached2.folder === "sent" && !hasRealView(cached2.recipients)) {
@@ -39518,11 +39320,11 @@ function registerMessageTools(server, client2) {
39518
39320
  recipients,
39519
39321
  listData: { ...cached2.listData, showNeverViewed: !hasRealView(recipients) }
39520
39322
  };
39521
- upsertMessage(row2);
39323
+ await cache.upsertMessage(row2);
39522
39324
  } catch {
39523
39325
  }
39524
39326
  }
39525
- let attachments2 = listAttachmentsForMessage(id);
39327
+ let attachments2 = await cache.listAttachmentsForMessage(id);
39526
39328
  if (attachments2.length === 0 && listDataHintsAtFiles(row2.listData)) {
39527
39329
  try {
39528
39330
  const detail2 = parseLenient(
@@ -39531,20 +39333,26 @@ function registerMessageTools(server, client2) {
39531
39333
  { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (attachment backfill)" }
39532
39334
  );
39533
39335
  if (Array.isArray(detail2.files) && detail2.files.length > 0) {
39534
- await fetchAttachmentMetaForMessage(client2, id, detail2.files);
39535
- attachments2 = listAttachmentsForMessage(id);
39336
+ await fetchAttachmentMetaForMessage(client2, id, detail2.files, cache);
39337
+ attachments2 = await cache.listAttachmentsForMessage(id);
39536
39338
  }
39537
39339
  } catch {
39538
39340
  }
39539
39341
  }
39540
- return jsonResponse({ ...row2, attachments: attachments2 });
39342
+ return jsonResponse({ ...withReadState(row2), attachments: attachments2 });
39541
39343
  }
39542
39344
  const detail = parseLenient(
39543
39345
  MessageDetailSchema,
39544
39346
  await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`),
39545
39347
  { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (ofw_get_message)" }
39546
39348
  );
39547
- const folder = cached2?.folder ?? "inbox";
39349
+ let folder = cached2?.folder ?? "inbox";
39350
+ if (!cached2) {
39351
+ const sentFolderId = await cache.getMeta("sent_folder_id");
39352
+ if (sentFolderId !== null && detail.folder?.id != null && String(detail.folder.id) === sentFolderId) {
39353
+ folder = "sent";
39354
+ }
39355
+ }
39548
39356
  const row = {
39549
39357
  id: detail.id,
39550
39358
  folder,
@@ -39558,12 +39366,12 @@ function registerMessageTools(server, client2) {
39558
39366
  chainRootId: cached2?.chainRootId ?? null,
39559
39367
  listData: cached2?.listData ?? detail
39560
39368
  };
39561
- upsertMessage(row);
39369
+ await cache.upsertMessage(row);
39562
39370
  if (Array.isArray(detail.files) && detail.files.length > 0) {
39563
- await fetchAttachmentMetaForMessage(client2, detail.id, detail.files);
39371
+ await fetchAttachmentMetaForMessage(client2, detail.id, detail.files, cache);
39564
39372
  }
39565
- const attachments = listAttachmentsForMessage(detail.id);
39566
- return jsonResponse({ ...row, attachments });
39373
+ const attachments = await cache.listAttachmentsForMessage(detail.id);
39374
+ return jsonResponse({ ...withReadState(row), attachments });
39567
39375
  });
39568
39376
  if (allowSend) server.registerTool("ofw_send_message", {
39569
39377
  description: "Send a message via OurFamilyWizard. To send an existing draft, pass messageId \u2014 subject/body/recipientIds become optional overrides (missing fields default to the draft's cached values) and the draft is deleted after sending. To send a fresh message, supply subject/body/recipientIds directly. draftId is the legacy spelling of messageId and works the same way. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After sending, the tool re-fetches the message from OFW to populate the local cache and link attachments to the new message id.",
@@ -39582,6 +39390,7 @@ function registerMessageTools(server, client2) {
39582
39390
  throw new Error(`messageId (${args.messageId}) and draftId (${args.draftId}) refer to different drafts; pass only one.`);
39583
39391
  }
39584
39392
  const draftRef = args.messageId ?? args.draftId;
39393
+ const cache = cacheProvider();
39585
39394
  let subject = args.subject;
39586
39395
  let body = args.body;
39587
39396
  let recipientIds = args.recipientIds;
@@ -39590,7 +39399,7 @@ function registerMessageTools(server, client2) {
39590
39399
  let draftFound = false;
39591
39400
  if (draftRef !== void 0) {
39592
39401
  draftLookupAttempted = true;
39593
- const draft = getDraft(draftRef);
39402
+ const draft = await cache.getDraft(draftRef);
39594
39403
  if (draft !== null) {
39595
39404
  draftFound = true;
39596
39405
  subject = subject ?? draft.subject;
@@ -39619,11 +39428,11 @@ function registerMessageTools(server, client2) {
39619
39428
  let chainRootId = null;
39620
39429
  let rewriteNote = null;
39621
39430
  if (requestedReplyTo !== null) {
39622
- resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
39431
+ resolvedReplyTo = await cache.findLatestReplyTip(requestedReplyTo);
39623
39432
  if (resolvedReplyTo !== requestedReplyTo) {
39624
39433
  rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
39625
39434
  }
39626
- const parent = getMessage(resolvedReplyTo);
39435
+ const parent = await cache.getMessage(resolvedReplyTo);
39627
39436
  chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
39628
39437
  }
39629
39438
  const myFileIDs = args.myFileIDs ?? [];
@@ -39653,10 +39462,10 @@ function registerMessageTools(server, client2) {
39653
39462
  chainRootId,
39654
39463
  listData: detail
39655
39464
  };
39656
- upsertMessage(persisted);
39465
+ await cache.upsertMessage(persisted);
39657
39466
  for (const fileId of myFileIDs) {
39658
- const existing = getAttachment(fileId);
39659
- upsertAttachmentForMessage({
39467
+ const existing = await cache.getAttachment(fileId);
39468
+ await cache.upsertAttachmentForMessage({
39660
39469
  fileId,
39661
39470
  fileName: existing?.fileName ?? `file-${fileId}`,
39662
39471
  label: existing?.label ?? existing?.fileName ?? `file-${fileId}`,
@@ -39673,7 +39482,7 @@ function registerMessageTools(server, client2) {
39673
39482
  unconfirmedNote = `WARNING: OFW's send response did not include a message id, so the send could not be confirmed. ${draftClause} ourfamilywizard.com to see whether the message went out before retrying.`;
39674
39483
  } else if (draftRef !== void 0) {
39675
39484
  await deleteOFWMessages(client2, [draftRef]);
39676
- deleteDraft(draftRef);
39485
+ await cache.deleteDraft(draftRef);
39677
39486
  }
39678
39487
  const responseObj = persisted ?? raw;
39679
39488
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Message sent successfully.";
@@ -39692,7 +39501,7 @@ ${text}` : text);
39692
39501
  }, async (args) => {
39693
39502
  const page = args.page ?? 1;
39694
39503
  const size = args.size ?? 50;
39695
- const drafts = listDrafts({ page, size });
39504
+ const drafts = await cacheProvider().listDrafts({ page, size });
39696
39505
  const payload = drafts.length === 0 ? { drafts: [], note: "Cache empty. Call ofw_sync_messages to populate." } : { drafts };
39697
39506
  return jsonResponse(payload);
39698
39507
  });
@@ -39708,11 +39517,12 @@ ${text}` : text);
39708
39517
  myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment)").optional()
39709
39518
  }
39710
39519
  }, async (args) => {
39520
+ const cache = cacheProvider();
39711
39521
  const requestedReplyTo = args.replyToId ?? null;
39712
39522
  let resolvedReplyTo = requestedReplyTo;
39713
39523
  let rewriteNote = null;
39714
39524
  if (requestedReplyTo !== null) {
39715
- resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
39525
+ resolvedReplyTo = await cache.findLatestReplyTip(requestedReplyTo);
39716
39526
  if (resolvedReplyTo !== requestedReplyTo) {
39717
39527
  rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
39718
39528
  }
@@ -39747,11 +39557,11 @@ ${text}` : text);
39747
39557
  modifiedAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
39748
39558
  listData: detail
39749
39559
  };
39750
- upsertDraft(persisted);
39560
+ await cache.upsertDraft(persisted);
39751
39561
  if (args.messageId !== void 0 && args.messageId !== newId) {
39752
39562
  try {
39753
39563
  await deleteOFWMessages(client2, [args.messageId]);
39754
- deleteDraft(args.messageId);
39564
+ await cache.deleteDraft(args.messageId);
39755
39565
  replaceNote = `NOTE: ofw_save_draft replaced draft ${args.messageId} via create-then-delete. The new draft id is ${newId}; the old draft has been deleted. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it. If you cached the old id anywhere, replace it with the new one.)`;
39756
39566
  } catch (e) {
39757
39567
  replaceNote = `WARNING: New draft ${newId} created successfully, but failed to delete the old draft (${args.messageId}): ${e.message}. You may want to clean it up manually with ofw_delete_draft.`;
@@ -39773,7 +39583,7 @@ ${text}` : text);
39773
39583
  }
39774
39584
  }, async (args) => {
39775
39585
  const data = await deleteOFWMessages(client2, [args.messageId]);
39776
- deleteDraft(args.messageId);
39586
+ await cacheProvider().deleteDraft(args.messageId);
39777
39587
  return data ? jsonResponse(data) : textResponse("Draft deleted.");
39778
39588
  });
39779
39589
  server.registerTool("ofw_get_unread_sent", {
@@ -39786,7 +39596,7 @@ ${text}` : text);
39786
39596
  }, async (args) => {
39787
39597
  const page = args.page ?? 1;
39788
39598
  const size = args.size ?? 50;
39789
- const sent = listMessages({ folder: "sent", page, size });
39599
+ const sent = await cacheProvider().listMessages({ folder: "sent", page, size });
39790
39600
  if (sent.length === 0) {
39791
39601
  return jsonResponse({ note: "Sent cache is empty. Call ofw_sync_messages to populate." });
39792
39602
  }
@@ -39812,13 +39622,9 @@ ${text}` : text);
39812
39622
  description: external_exports.string().describe("Description shown in OFW My Files (default: filename)").optional()
39813
39623
  }
39814
39624
  }, async (args) => {
39815
- const abs = expandPath2(args.path);
39816
- const stat = statSync(abs);
39817
- if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
39818
- const fileName = basename(abs);
39819
- const mime = mimeFromName(fileName);
39625
+ const { blob, fileName, mimeType: mime, sizeBytes } = await attachmentIO.resolveUpload(args.path);
39820
39626
  const form = new FormData();
39821
- form.append("file", await fileBlob(abs, { type: mime }), fileName);
39627
+ form.append("file", blob, fileName);
39822
39628
  form.append("source", "message");
39823
39629
  form.append("description", args.description ?? fileName);
39824
39630
  form.append("label", args.label ?? fileName);
@@ -39829,12 +39635,12 @@ ${text}` : text);
39829
39635
  await client2.request("POST", "/pub/v3/myfiles/multipart", form),
39830
39636
  { label: "ofw-mcp", context: "POST /pub/v3/myfiles/multipart (ofw_upload_attachment)", mode: "strict" }
39831
39637
  );
39832
- upsertAttachmentForMessage({
39638
+ await cacheProvider().upsertAttachmentForMessage({
39833
39639
  fileId: meta3.fileId,
39834
39640
  fileName: meta3.fileName ?? fileName,
39835
39641
  label: meta3.label ?? args.label ?? fileName,
39836
39642
  mimeType: meta3.fileType ?? mime,
39837
- sizeBytes: typeof meta3.sizeInBytes === "number" ? meta3.sizeInBytes : stat.size,
39643
+ sizeBytes: typeof meta3.sizeInBytes === "number" ? meta3.sizeInBytes : sizeBytes,
39838
39644
  metadata: meta3,
39839
39645
  messageId: 0
39840
39646
  });
@@ -39842,7 +39648,7 @@ ${text}` : text);
39842
39648
  fileId: meta3.fileId,
39843
39649
  fileName: meta3.fileName ?? fileName,
39844
39650
  mimeType: meta3.fileType ?? mime,
39845
- sizeBytes: meta3.sizeInBytes ?? stat.size,
39651
+ sizeBytes: meta3.sizeInBytes ?? sizeBytes,
39846
39652
  shareClass: meta3.shareClass ?? args.shareClass ?? "PRIVATE",
39847
39653
  note: "Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it."
39848
39654
  });
@@ -39858,11 +39664,12 @@ ${text}` : text);
39858
39664
  }
39859
39665
  }, async (args) => {
39860
39666
  const fileId = args.fileId;
39667
+ const cache = cacheProvider();
39861
39668
  const inline = args.inline ?? getDefaultInlineAttachments();
39862
- let cached2 = getAttachment(fileId);
39669
+ let cached2 = await cache.getAttachment(fileId);
39863
39670
  if (!cached2) {
39864
- await fetchAttachmentMeta(client2, fileId, 0);
39865
- cached2 = getAttachment(fileId);
39671
+ await fetchAttachmentMeta(client2, fileId, 0, cache);
39672
+ cached2 = await cache.getAttachment(fileId);
39866
39673
  if (!cached2) throw new Error(`failed to fetch metadata for fileId ${fileId}`);
39867
39674
  }
39868
39675
  if (inline) {
@@ -39870,10 +39677,7 @@ ${text}` : text);
39870
39677
  let mimeType = cached2.mimeType;
39871
39678
  let fileName = cached2.fileName;
39872
39679
  if (cached2.downloadedPath) {
39873
- try {
39874
- bytes = readFileSync(cached2.downloadedPath);
39875
- } catch {
39876
- }
39680
+ bytes = attachmentIO.readDownloaded(cached2.downloadedPath);
39877
39681
  }
39878
39682
  if (bytes === null) {
39879
39683
  const response2 = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
@@ -39918,9 +39722,8 @@ ${text}` : text);
39918
39722
  });
39919
39723
  }
39920
39724
  const response = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
39921
- mkdirSync2(dirname3(dest), { recursive: true });
39922
- writeFileSync(dest, response.body);
39923
- markAttachmentDownloaded(fileId, dest);
39725
+ attachmentIO.writeDownload(dest, response.body);
39726
+ await cache.markAttachmentDownloaded(fileId, dest);
39924
39727
  return jsonResponse({
39925
39728
  fileId,
39926
39729
  path: dest,
@@ -39930,19 +39733,21 @@ ${text}` : text);
39930
39733
  });
39931
39734
  });
39932
39735
  server.registerTool("ofw_sync_messages", {
39933
- description: "Sync messages from OurFamilyWizard into the local cache. Returns counts per folder and a list of unread inbox messages whose bodies were NOT fetched (to avoid mark-as-read on OFW). Call ofw_get_message(id) on those to read them. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps).",
39736
+ description: "Sync messages from OurFamilyWizard into the local cache. Returns counts per folder and a list of unread inbox messages whose bodies were NOT fetched (to avoid mark-as-read on OFW). Call ofw_get_message(id) on those to read them. EVERY call re-checks the newest page first, so new messages are picked up promptly even while an old-history backfill is still running; only then does it spend what is left of its budget advancing that backfill. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps). Sync is BOUNDED and RESUMABLE: on hosted deployments a per-call OFW-request budget (env OFW_SYNC_MAX_REQUESTS, or the maxRequests argument) caps how far one call walks; when the budget is hit the response reports done:false with a note \u2014 call again with the SAME arguments to resume. done:false means older history is still being backfilled; it does NOT mean recent messages are missing. Local installs are unbounded by default (done is always true).",
39934
39737
  annotations: { readOnlyHint: false },
39935
39738
  inputSchema: {
39936
39739
  folders: external_exports.array(external_exports.enum(["inbox", "sent", "drafts"])).describe("Folders to sync (default: all three)").optional(),
39937
39740
  fetchUnreadBodies: external_exports.boolean().describe("If true, also fetch bodies for unread inbox messages (will mark them as read on OFW). Default false.").optional(),
39938
- deep: external_exports.boolean().describe("If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.").optional()
39741
+ deep: external_exports.boolean().describe("If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.").optional(),
39742
+ maxRequests: external_exports.number().int().min(1).describe("Maximum OFW requests this single call may make before pausing. When hit, the response reports done:false \u2014 call again with the same arguments to continue. Omit to use the server default (OFW_SYNC_MAX_REQUESTS, or unbounded on local installs).").optional()
39939
39743
  }
39940
39744
  }, async (args) => {
39941
39745
  const result = await syncAll(client2, {
39942
39746
  folders: args.folders,
39943
39747
  fetchUnreadBodies: args.fetchUnreadBodies,
39944
- deep: args.deep
39945
- });
39748
+ deep: args.deep,
39749
+ maxRequests: args.maxRequests ?? getSyncMaxRequests()
39750
+ }, cacheProvider());
39946
39751
  return jsonResponse(result);
39947
39752
  });
39948
39753
  }
@@ -40176,6 +39981,584 @@ function registerJournalTools(server, client2) {
40176
39981
  });
40177
39982
  }
40178
39983
 
39984
+ // src/cache/node.ts
39985
+ import { DatabaseSync } from "node:sqlite";
39986
+ import { mkdirSync, chmodSync, existsSync } from "node:fs";
39987
+ import { dirname as dirname2 } from "node:path";
39988
+
39989
+ // src/cache/store.ts
39990
+ function rowFromDb(r) {
39991
+ return {
39992
+ id: r.id,
39993
+ folder: r.folder,
39994
+ subject: r.subject,
39995
+ fromUser: r.from_user,
39996
+ sentAt: r.sent_at,
39997
+ recipients: JSON.parse(r.recipients_json),
39998
+ body: r.body,
39999
+ fetchedBodyAt: r.fetched_body_at,
40000
+ replyToId: r.reply_to_id,
40001
+ chainRootId: r.chain_root_id,
40002
+ listData: JSON.parse(r.list_data_json)
40003
+ };
40004
+ }
40005
+ function draftFromDb(r) {
40006
+ return {
40007
+ id: r.id,
40008
+ subject: r.subject,
40009
+ body: r.body,
40010
+ recipients: JSON.parse(r.recipients_json),
40011
+ replyToId: r.reply_to_id,
40012
+ modifiedAt: r.modified_at,
40013
+ listData: JSON.parse(r.list_data_json)
40014
+ };
40015
+ }
40016
+ function attachmentFromDb(r) {
40017
+ return {
40018
+ fileId: r.file_id,
40019
+ fileName: r.file_name,
40020
+ label: r.label,
40021
+ mimeType: r.mime_type,
40022
+ sizeBytes: r.size_bytes,
40023
+ metadata: JSON.parse(r.metadata_json),
40024
+ messageIds: JSON.parse(r.message_ids_json),
40025
+ downloadedPath: r.downloaded_path,
40026
+ downloadedAt: r.downloaded_at
40027
+ };
40028
+ }
40029
+ function nullish3(v) {
40030
+ return v === void 0 ? null : v;
40031
+ }
40032
+ function requireString(field, v) {
40033
+ if (typeof v === "string") return v;
40034
+ throw new Error(`cache: ${field} is required (got ${v === void 0 ? "undefined" : "null"})`);
40035
+ }
40036
+ var SCHEMA_STATEMENTS = [
40037
+ `CREATE TABLE IF NOT EXISTS messages (
40038
+ id INTEGER PRIMARY KEY,
40039
+ folder TEXT NOT NULL,
40040
+ subject TEXT NOT NULL,
40041
+ from_user TEXT NOT NULL,
40042
+ sent_at TEXT NOT NULL,
40043
+ recipients_json TEXT NOT NULL,
40044
+ body TEXT,
40045
+ fetched_body_at TEXT,
40046
+ reply_to_id INTEGER,
40047
+ chain_root_id INTEGER,
40048
+ list_data_json TEXT NOT NULL,
40049
+ last_seen_at TEXT NOT NULL
40050
+ )`,
40051
+ `CREATE INDEX IF NOT EXISTS idx_messages_folder_sent_at ON messages(folder, sent_at DESC)`,
40052
+ `CREATE INDEX IF NOT EXISTS idx_messages_chain_root ON messages(chain_root_id)`,
40053
+ `CREATE TABLE IF NOT EXISTS drafts (
40054
+ id INTEGER PRIMARY KEY,
40055
+ subject TEXT NOT NULL,
40056
+ body TEXT NOT NULL,
40057
+ recipients_json TEXT NOT NULL,
40058
+ reply_to_id INTEGER,
40059
+ modified_at TEXT NOT NULL,
40060
+ list_data_json TEXT NOT NULL
40061
+ )`,
40062
+ `CREATE TABLE IF NOT EXISTS sync_state (
40063
+ folder TEXT PRIMARY KEY,
40064
+ last_sync_at TEXT NOT NULL,
40065
+ newest_id INTEGER
40066
+ )`,
40067
+ `CREATE TABLE IF NOT EXISTS meta (
40068
+ key TEXT PRIMARY KEY,
40069
+ value TEXT NOT NULL
40070
+ )`,
40071
+ // v2: attachments table. Idempotent — IF NOT EXISTS.
40072
+ `CREATE TABLE IF NOT EXISTS attachments (
40073
+ file_id INTEGER PRIMARY KEY,
40074
+ file_name TEXT NOT NULL,
40075
+ label TEXT NOT NULL,
40076
+ mime_type TEXT NOT NULL,
40077
+ size_bytes INTEGER,
40078
+ metadata_json TEXT NOT NULL,
40079
+ message_ids_json TEXT NOT NULL,
40080
+ downloaded_path TEXT,
40081
+ downloaded_at TEXT,
40082
+ fetched_metadata_at TEXT NOT NULL
40083
+ )`
40084
+ ];
40085
+ var MIGRATIONS = [
40086
+ // Resumable deep-sync cursor. Absent/NULL → SyncState.resumePage null.
40087
+ "ALTER TABLE sync_state ADD COLUMN resume_page INTEGER"
40088
+ ];
40089
+ var SCHEMA_VERSION = "2";
40090
+ function buildMessageFilter(opts) {
40091
+ const wheres = [];
40092
+ const params = [];
40093
+ if (opts.folder !== void 0) {
40094
+ wheres.push("folder = ?");
40095
+ params.push(opts.folder);
40096
+ }
40097
+ if (opts.since !== void 0) {
40098
+ wheres.push("sent_at >= ?");
40099
+ params.push(opts.since);
40100
+ }
40101
+ if (opts.until !== void 0) {
40102
+ wheres.push("sent_at < ?");
40103
+ params.push(opts.until);
40104
+ }
40105
+ if (opts.q !== void 0 && opts.q.length > 0) {
40106
+ const pattern = `%${opts.q}%`;
40107
+ wheres.push("(subject LIKE ? OR body LIKE ?)");
40108
+ params.push(pattern, pattern);
40109
+ }
40110
+ return {
40111
+ where: wheres.length > 0 ? `WHERE ${wheres.join(" AND ")}` : "",
40112
+ params
40113
+ };
40114
+ }
40115
+ var OFWCacheCore = class {
40116
+ constructor(db) {
40117
+ this.db = db;
40118
+ for (const stmt of SCHEMA_STATEMENTS) this.db.execScript(stmt);
40119
+ for (const stmt of MIGRATIONS) {
40120
+ try {
40121
+ this.db.execScript(stmt);
40122
+ } catch {
40123
+ }
40124
+ }
40125
+ this.db.run(
40126
+ "INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
40127
+ ["schema_version", SCHEMA_VERSION]
40128
+ );
40129
+ }
40130
+ db;
40131
+ upsertMessage(row) {
40132
+ this.db.run(
40133
+ `INSERT INTO messages (
40134
+ id, folder, subject, from_user, sent_at, recipients_json,
40135
+ body, fetched_body_at, reply_to_id, chain_root_id, list_data_json, last_seen_at
40136
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
40137
+ ON CONFLICT(id) DO UPDATE SET
40138
+ folder=excluded.folder,
40139
+ subject=excluded.subject,
40140
+ from_user=excluded.from_user,
40141
+ sent_at=excluded.sent_at,
40142
+ recipients_json=excluded.recipients_json,
40143
+ body=excluded.body,
40144
+ fetched_body_at=excluded.fetched_body_at,
40145
+ reply_to_id=excluded.reply_to_id,
40146
+ chain_root_id=excluded.chain_root_id,
40147
+ list_data_json=excluded.list_data_json,
40148
+ last_seen_at=excluded.last_seen_at`,
40149
+ [
40150
+ row.id,
40151
+ requireString("messages.folder", row.folder),
40152
+ requireString("messages.subject", row.subject),
40153
+ requireString("messages.fromUser", row.fromUser),
40154
+ requireString("messages.sentAt", row.sentAt),
40155
+ JSON.stringify(row.recipients ?? []),
40156
+ nullish3(row.body),
40157
+ nullish3(row.fetchedBodyAt),
40158
+ nullish3(row.replyToId),
40159
+ nullish3(row.chainRootId),
40160
+ JSON.stringify(row.listData ?? null),
40161
+ (/* @__PURE__ */ new Date()).toISOString()
40162
+ ]
40163
+ );
40164
+ }
40165
+ /**
40166
+ * Batch upsert every row in a single transaction — one round-trip's worth of
40167
+ * work (crucial on the Durable Object backend, where each RPC is a subrequest).
40168
+ * Empty array is a no-op (no transaction opened).
40169
+ */
40170
+ upsertMessages(rows) {
40171
+ if (rows.length === 0) return;
40172
+ this.db.transaction(() => {
40173
+ for (const row of rows) this.upsertMessage(row);
40174
+ });
40175
+ }
40176
+ getMessage(id) {
40177
+ const r = this.db.get("SELECT * FROM messages WHERE id = ?", [id]);
40178
+ return r ? rowFromDb(r) : null;
40179
+ }
40180
+ /**
40181
+ * Batch read: one `SELECT ... WHERE id IN (...)` returning the present rows
40182
+ * (absent ids are simply omitted — order is not guaranteed). Empty ids returns
40183
+ * `[]` without querying.
40184
+ */
40185
+ getMessages(ids) {
40186
+ if (ids.length === 0) return [];
40187
+ const placeholders = ids.map(() => "?").join(", ");
40188
+ const rows = this.db.all(
40189
+ `SELECT * FROM messages WHERE id IN (${placeholders})`,
40190
+ ids
40191
+ );
40192
+ return rows.map(rowFromDb);
40193
+ }
40194
+ /**
40195
+ * Remove a row from the `messages` table. Used by syncDrafts to evict
40196
+ * stale rows that were cached when a draft was previously read through
40197
+ * `ofw_get_message` (which would have wrongly classified it as `inbox`)
40198
+ * — the drafts table is the authoritative source for that id now.
40199
+ */
40200
+ deleteMessage(id) {
40201
+ this.db.run("DELETE FROM messages WHERE id = ?", [id]);
40202
+ }
40203
+ listMessages(opts) {
40204
+ const { where, params } = buildMessageFilter(opts);
40205
+ const offset = (opts.page - 1) * opts.size;
40206
+ const rows = this.db.all(
40207
+ `SELECT * FROM messages ${where}
40208
+ ORDER BY sent_at DESC, id DESC
40209
+ LIMIT ? OFFSET ?`,
40210
+ [...params, opts.size, offset]
40211
+ );
40212
+ return rows.map(rowFromDb);
40213
+ }
40214
+ countMessages(opts) {
40215
+ const { where, params } = buildMessageFilter(opts);
40216
+ const r = this.db.get(`SELECT COUNT(*) as n FROM messages ${where}`, params);
40217
+ return r?.n ?? 0;
40218
+ }
40219
+ upsertDraft(row) {
40220
+ this.db.run(
40221
+ `INSERT INTO drafts (id, subject, body, recipients_json, reply_to_id, modified_at, list_data_json)
40222
+ VALUES (?, ?, ?, ?, ?, ?, ?)
40223
+ ON CONFLICT(id) DO UPDATE SET
40224
+ subject=excluded.subject,
40225
+ body=excluded.body,
40226
+ recipients_json=excluded.recipients_json,
40227
+ reply_to_id=excluded.reply_to_id,
40228
+ modified_at=excluded.modified_at,
40229
+ list_data_json=excluded.list_data_json`,
40230
+ [
40231
+ row.id,
40232
+ requireString("drafts.subject", row.subject),
40233
+ requireString("drafts.body", row.body),
40234
+ JSON.stringify(row.recipients ?? []),
40235
+ nullish3(row.replyToId),
40236
+ requireString("drafts.modifiedAt", row.modifiedAt),
40237
+ JSON.stringify(row.listData ?? null)
40238
+ ]
40239
+ );
40240
+ }
40241
+ /** Batch upsert every draft in a single transaction. Empty array is a no-op. */
40242
+ upsertDrafts(rows) {
40243
+ if (rows.length === 0) return;
40244
+ this.db.transaction(() => {
40245
+ for (const row of rows) this.upsertDraft(row);
40246
+ });
40247
+ }
40248
+ getDraft(id) {
40249
+ const r = this.db.get("SELECT * FROM drafts WHERE id = ?", [id]);
40250
+ return r ? draftFromDb(r) : null;
40251
+ }
40252
+ /**
40253
+ * Batch read: one `SELECT ... WHERE id IN (...)` returning the present drafts
40254
+ * (absent ids omitted — order not guaranteed). Empty ids returns `[]` without
40255
+ * querying.
40256
+ */
40257
+ getDrafts(ids) {
40258
+ if (ids.length === 0) return [];
40259
+ const placeholders = ids.map(() => "?").join(", ");
40260
+ const rows = this.db.all(
40261
+ `SELECT * FROM drafts WHERE id IN (${placeholders})`,
40262
+ ids
40263
+ );
40264
+ return rows.map(draftFromDb);
40265
+ }
40266
+ listDrafts(opts) {
40267
+ const offset = (opts.page - 1) * opts.size;
40268
+ const rows = this.db.all(
40269
+ "SELECT * FROM drafts ORDER BY modified_at DESC, id DESC LIMIT ? OFFSET ?",
40270
+ [opts.size, offset]
40271
+ );
40272
+ return rows.map(draftFromDb);
40273
+ }
40274
+ deleteDraft(id) {
40275
+ this.db.run("DELETE FROM drafts WHERE id = ?", [id]);
40276
+ }
40277
+ listDraftIds() {
40278
+ const rows = this.db.all("SELECT id FROM drafts", []);
40279
+ return rows.map((r) => r.id);
40280
+ }
40281
+ getSyncState(folder) {
40282
+ const r = this.db.get("SELECT last_sync_at, newest_id, resume_page FROM sync_state WHERE folder = ?", [folder]);
40283
+ if (!r) return null;
40284
+ return { lastSyncAt: r.last_sync_at, newestId: r.newest_id, resumePage: r.resume_page ?? null };
40285
+ }
40286
+ setSyncState(folder, state) {
40287
+ this.db.run(
40288
+ `INSERT INTO sync_state (folder, last_sync_at, newest_id, resume_page) VALUES (?, ?, ?, ?)
40289
+ ON CONFLICT(folder) DO UPDATE SET
40290
+ last_sync_at = excluded.last_sync_at,
40291
+ newest_id = excluded.newest_id,
40292
+ resume_page = excluded.resume_page`,
40293
+ [folder, state.lastSyncAt, nullish3(state.newestId), nullish3(state.resumePage)]
40294
+ );
40295
+ }
40296
+ getMeta(key) {
40297
+ const r = this.db.get("SELECT value FROM meta WHERE key = ?", [key]);
40298
+ return r ? r.value : null;
40299
+ }
40300
+ setMeta(key, value) {
40301
+ this.db.run(
40302
+ "INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
40303
+ [key, value]
40304
+ );
40305
+ }
40306
+ findLatestReplyTip(replyToId) {
40307
+ const parent = this.db.get("SELECT id, folder, chain_root_id FROM messages WHERE id = ?", [replyToId]);
40308
+ if (!parent) return replyToId;
40309
+ const chainRoot = parent.chain_root_id ?? parent.id;
40310
+ const tip = this.db.get(
40311
+ `SELECT id FROM messages
40312
+ WHERE folder = 'sent' AND chain_root_id = ?
40313
+ ORDER BY id DESC LIMIT 1`,
40314
+ [chainRoot]
40315
+ );
40316
+ return tip ? tip.id : replyToId;
40317
+ }
40318
+ getAttachment(fileId) {
40319
+ const r = this.db.get("SELECT * FROM attachments WHERE file_id = ?", [fileId]);
40320
+ return r ? attachmentFromDb(r) : null;
40321
+ }
40322
+ listAttachmentsForMessage(messageId) {
40323
+ const rows = this.db.all(
40324
+ `SELECT * FROM attachments
40325
+ WHERE EXISTS (SELECT 1 FROM json_each(message_ids_json) WHERE value = ?)
40326
+ ORDER BY file_id`,
40327
+ [messageId]
40328
+ );
40329
+ return rows.map(attachmentFromDb);
40330
+ }
40331
+ upsertAttachmentForMessage(input) {
40332
+ const existing = this.db.get("SELECT message_ids_json FROM attachments WHERE file_id = ?", [input.fileId]);
40333
+ const prior = existing ? JSON.parse(existing.message_ids_json) : [];
40334
+ let messageIds;
40335
+ if (input.messageId === 0) {
40336
+ messageIds = prior;
40337
+ } else if (prior.includes(input.messageId)) {
40338
+ messageIds = prior;
40339
+ } else {
40340
+ messageIds = [...prior, input.messageId];
40341
+ }
40342
+ this.db.run(
40343
+ `INSERT INTO attachments (
40344
+ file_id, file_name, label, mime_type, size_bytes,
40345
+ metadata_json, message_ids_json, fetched_metadata_at
40346
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
40347
+ ON CONFLICT(file_id) DO UPDATE SET
40348
+ file_name=excluded.file_name,
40349
+ label=excluded.label,
40350
+ mime_type=excluded.mime_type,
40351
+ size_bytes=excluded.size_bytes,
40352
+ metadata_json=excluded.metadata_json,
40353
+ message_ids_json=excluded.message_ids_json,
40354
+ fetched_metadata_at=excluded.fetched_metadata_at`,
40355
+ [
40356
+ input.fileId,
40357
+ requireString("attachments.fileName", input.fileName),
40358
+ requireString("attachments.label", input.label),
40359
+ requireString("attachments.mimeType", input.mimeType),
40360
+ nullish3(input.sizeBytes),
40361
+ JSON.stringify(input.metadata ?? null),
40362
+ JSON.stringify(messageIds),
40363
+ (/* @__PURE__ */ new Date()).toISOString()
40364
+ ]
40365
+ );
40366
+ }
40367
+ markAttachmentDownloaded(fileId, path) {
40368
+ this.db.run("UPDATE attachments SET downloaded_path = ?, downloaded_at = ? WHERE file_id = ?", [
40369
+ path,
40370
+ (/* @__PURE__ */ new Date()).toISOString(),
40371
+ fileId
40372
+ ]);
40373
+ }
40374
+ };
40375
+ var LocalCacheStore = class {
40376
+ constructor(core) {
40377
+ this.core = core;
40378
+ }
40379
+ core;
40380
+ async upsertMessage(row) {
40381
+ this.core.upsertMessage(row);
40382
+ }
40383
+ async upsertMessages(rows) {
40384
+ this.core.upsertMessages(rows);
40385
+ }
40386
+ async getMessage(id) {
40387
+ return this.core.getMessage(id);
40388
+ }
40389
+ async getMessages(ids) {
40390
+ return this.core.getMessages(ids);
40391
+ }
40392
+ async deleteMessage(id) {
40393
+ this.core.deleteMessage(id);
40394
+ }
40395
+ async listMessages(opts) {
40396
+ return this.core.listMessages(opts);
40397
+ }
40398
+ async countMessages(opts) {
40399
+ return this.core.countMessages(opts);
40400
+ }
40401
+ async upsertDraft(row) {
40402
+ this.core.upsertDraft(row);
40403
+ }
40404
+ async upsertDrafts(rows) {
40405
+ this.core.upsertDrafts(rows);
40406
+ }
40407
+ async getDraft(id) {
40408
+ return this.core.getDraft(id);
40409
+ }
40410
+ async getDrafts(ids) {
40411
+ return this.core.getDrafts(ids);
40412
+ }
40413
+ async listDrafts(opts) {
40414
+ return this.core.listDrafts(opts);
40415
+ }
40416
+ async deleteDraft(id) {
40417
+ this.core.deleteDraft(id);
40418
+ }
40419
+ async listDraftIds() {
40420
+ return this.core.listDraftIds();
40421
+ }
40422
+ async getSyncState(folder) {
40423
+ return this.core.getSyncState(folder);
40424
+ }
40425
+ async setSyncState(folder, state) {
40426
+ this.core.setSyncState(folder, state);
40427
+ }
40428
+ async getMeta(key) {
40429
+ return this.core.getMeta(key);
40430
+ }
40431
+ async setMeta(key, value) {
40432
+ this.core.setMeta(key, value);
40433
+ }
40434
+ async findLatestReplyTip(replyToId) {
40435
+ return this.core.findLatestReplyTip(replyToId);
40436
+ }
40437
+ async getAttachment(fileId) {
40438
+ return this.core.getAttachment(fileId);
40439
+ }
40440
+ async listAttachmentsForMessage(messageId) {
40441
+ return this.core.listAttachmentsForMessage(messageId);
40442
+ }
40443
+ async upsertAttachmentForMessage(input) {
40444
+ this.core.upsertAttachmentForMessage(input);
40445
+ }
40446
+ async markAttachmentDownloaded(fileId, path) {
40447
+ this.core.markAttachmentDownloaded(fileId, path);
40448
+ }
40449
+ };
40450
+
40451
+ // src/cache/node.ts
40452
+ var NodeSqlDriver = class {
40453
+ constructor(db) {
40454
+ this.db = db;
40455
+ }
40456
+ db;
40457
+ execScript(sql) {
40458
+ this.db.exec(sql);
40459
+ }
40460
+ run(sql, params) {
40461
+ this.db.prepare(sql).run(...params);
40462
+ }
40463
+ get(sql, params) {
40464
+ return this.db.prepare(sql).get(...params);
40465
+ }
40466
+ all(sql, params) {
40467
+ return this.db.prepare(sql).all(...params);
40468
+ }
40469
+ transaction(fn) {
40470
+ this.db.exec("BEGIN");
40471
+ try {
40472
+ fn();
40473
+ this.db.exec("COMMIT");
40474
+ } catch (e) {
40475
+ this.db.exec("ROLLBACK");
40476
+ throw e;
40477
+ }
40478
+ }
40479
+ };
40480
+ function enforceCachePermissions(dbPath) {
40481
+ chmodSync(dirname2(dbPath), 448);
40482
+ chmodSync(dbPath, 384);
40483
+ for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
40484
+ if (existsSync(sibling)) chmodSync(sibling, 384);
40485
+ }
40486
+ }
40487
+ var OFWCache = class _OFWCache extends LocalCacheStore {
40488
+ constructor(db, core) {
40489
+ super(core);
40490
+ this.db = db;
40491
+ }
40492
+ db;
40493
+ static open(path) {
40494
+ const memory = path === ":memory:";
40495
+ if (!memory) mkdirSync(dirname2(path), { recursive: true });
40496
+ const db = new DatabaseSync(path);
40497
+ if (!memory) enforceCachePermissions(path);
40498
+ db.exec("PRAGMA journal_mode = WAL");
40499
+ db.exec("PRAGMA foreign_keys = ON");
40500
+ const core = new OFWCacheCore(new NodeSqlDriver(db));
40501
+ if (!memory) enforceCachePermissions(path);
40502
+ return new _OFWCache(db, core);
40503
+ }
40504
+ close() {
40505
+ this.db.close();
40506
+ }
40507
+ };
40508
+
40509
+ // src/tools/attachments.ts
40510
+ import { readFileSync, statSync, mkdirSync as mkdirSync2, writeFileSync } from "node:fs";
40511
+ import { basename as basename2, dirname as dirname3, extname } from "node:path";
40512
+ var MIME_BY_EXT = {
40513
+ ".pdf": "application/pdf",
40514
+ ".png": "image/png",
40515
+ ".jpg": "image/jpeg",
40516
+ ".jpeg": "image/jpeg",
40517
+ ".gif": "image/gif",
40518
+ ".webp": "image/webp",
40519
+ ".heic": "image/heic",
40520
+ ".txt": "text/plain",
40521
+ ".md": "text/markdown",
40522
+ ".csv": "text/csv",
40523
+ ".html": "text/html",
40524
+ ".htm": "text/html",
40525
+ ".json": "application/json",
40526
+ ".xml": "application/xml",
40527
+ ".doc": "application/msword",
40528
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
40529
+ ".xls": "application/vnd.ms-excel",
40530
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
40531
+ ".ppt": "application/vnd.ms-powerpoint",
40532
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
40533
+ ".zip": "application/zip",
40534
+ ".ics": "text/calendar"
40535
+ };
40536
+ function mimeFromName(name) {
40537
+ return MIME_BY_EXT[extname(name).toLowerCase()] ?? "application/octet-stream";
40538
+ }
40539
+ var NodeAttachmentIO = class {
40540
+ async resolveUpload(path) {
40541
+ const abs = expandPath(path);
40542
+ const stat = statSync(abs);
40543
+ if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
40544
+ const fileName = basename2(abs);
40545
+ const mimeType = mimeFromName(fileName);
40546
+ const blob = await fileBlob(abs, { type: mimeType });
40547
+ return { blob, fileName, mimeType, sizeBytes: stat.size };
40548
+ }
40549
+ readDownloaded(path) {
40550
+ try {
40551
+ return readFileSync(path);
40552
+ } catch {
40553
+ return null;
40554
+ }
40555
+ }
40556
+ writeDownload(dest, bytes) {
40557
+ mkdirSync2(dirname3(dest), { recursive: true });
40558
+ writeFileSync(dest, bytes);
40559
+ }
40560
+ };
40561
+
40179
40562
  // src/index.ts
40180
40563
  var originalEmit = process.emit.bind(process);
40181
40564
  process.emit = function(event, ...args) {
@@ -40187,14 +40570,17 @@ process.emit = function(event, ...args) {
40187
40570
  }
40188
40571
  return originalEmit(event, ...args);
40189
40572
  };
40573
+ var nodeCache;
40574
+ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
40575
+ var nodeAttachmentIO = new NodeAttachmentIO();
40190
40576
  await runMcp({
40191
40577
  name: "ofw",
40192
- version: "2.5.0",
40578
+ version: "2.6.4",
40193
40579
  // x-release-please-version
40194
40580
  deps: client,
40195
40581
  tools: [
40196
40582
  registerUserTools,
40197
- registerMessageTools,
40583
+ (server, deps) => registerMessageTools(server, deps, nodeCacheProvider, nodeAttachmentIO),
40198
40584
  registerCalendarTools,
40199
40585
  registerExpenseTools,
40200
40586
  registerJournalTools