hypermail-mcp 0.7.13 → 0.7.14

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/cli.js CHANGED
@@ -13954,21 +13954,79 @@ async function tryKeytarSet(key) {
13954
13954
  }
13955
13955
  }
13956
13956
 
13957
+ // src/logger.ts
13958
+ var noopLogger = {
13959
+ debug: () => void 0
13960
+ };
13961
+ var REDACTED = "[redacted]";
13962
+ var SENSITIVE_FIELD = /token|secret|password|credential|key|body|content/i;
13963
+ function createLogger(opts) {
13964
+ if (!opts.enabled) return noopLogger;
13965
+ const write = opts.write ?? ((line) => process.stderr.write(line));
13966
+ return {
13967
+ debug(component, event, fields = {}) {
13968
+ try {
13969
+ const payload = {
13970
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
13971
+ pid: process.pid,
13972
+ component,
13973
+ event,
13974
+ ...sanitizeFields(fields)
13975
+ };
13976
+ write(`[hypermail-mcp] debug ${JSON.stringify(payload)}
13977
+ `);
13978
+ } catch {
13979
+ }
13980
+ }
13981
+ };
13982
+ }
13983
+ function sanitizeFields(fields) {
13984
+ const sanitized = {};
13985
+ for (const [key, value] of Object.entries(fields)) {
13986
+ sanitized[key] = sanitizeValue(key, value, 0);
13987
+ }
13988
+ return sanitized;
13989
+ }
13990
+ function sanitizeValue(key, value, depth) {
13991
+ if (SENSITIVE_FIELD.test(key)) return REDACTED;
13992
+ if (value === null || value === void 0) return value;
13993
+ const valueType = typeof value;
13994
+ if (valueType === "string" || valueType === "number" || valueType === "boolean") {
13995
+ return value;
13996
+ }
13997
+ if (value instanceof Date) return value.toISOString();
13998
+ if (Array.isArray(value)) {
13999
+ if (depth >= 2) return `[array:${value.length}]`;
14000
+ return value.map((item) => sanitizeValue(key, item, depth + 1));
14001
+ }
14002
+ if (valueType === "object") {
14003
+ if (depth >= 2) return "[object]";
14004
+ const out = {};
14005
+ for (const [childKey, childValue] of Object.entries(value)) {
14006
+ out[childKey] = sanitizeValue(childKey, childValue, depth + 1);
14007
+ }
14008
+ return out;
14009
+ }
14010
+ return String(value);
14011
+ }
14012
+
13957
14013
  // src/store/account-store.ts
13958
14014
  var FILE_NAME = "accounts.json.enc";
13959
14015
  var LOCK_STALE_MS = 3e4;
13960
14016
  var LOCK_TIMEOUT_MS = 1e4;
13961
14017
  var LOCK_RETRY_MS = 25;
13962
14018
  var AccountStore = class _AccountStore {
13963
- constructor(filePath, key, data) {
14019
+ constructor(filePath, key, data, logger) {
13964
14020
  this.filePath = filePath;
13965
14021
  this.key = key;
13966
14022
  this.data = data;
14023
+ this.logger = logger;
13967
14024
  this.lockPath = `${filePath}.lock`;
13968
14025
  }
13969
14026
  filePath;
13970
14027
  key;
13971
14028
  data;
14029
+ logger;
13972
14030
  writeLocks = /* @__PURE__ */ new Map();
13973
14031
  lockPath;
13974
14032
  static async open(opts = {}) {
@@ -13987,7 +14045,12 @@ var AccountStore = class _AccountStore {
13987
14045
  throw err;
13988
14046
  }
13989
14047
  }
13990
- return new _AccountStore(filePath, key, data);
14048
+ const logger = opts.logger ?? noopLogger;
14049
+ logger.debug("account-store", "open", {
14050
+ filePath,
14051
+ accountCount: data.accounts.length
14052
+ });
14053
+ return new _AccountStore(filePath, key, data, logger);
13991
14054
  }
13992
14055
  listAccounts() {
13993
14056
  return this.data.accounts.map((a) => ({ ...a }));
@@ -14011,6 +14074,13 @@ var AccountStore = class _AccountStore {
14011
14074
  else delete next.newEmailCheckpoint;
14012
14075
  if (idx >= 0) data.accounts[idx] = next;
14013
14076
  else data.accounts.push(next);
14077
+ this.logger.debug("account-store", "upsertAccount", {
14078
+ email: norm,
14079
+ existed: idx >= 0,
14080
+ checkpointReceivedAt: mergedCheckpoint?.receivedAt ?? null,
14081
+ deliveredIdCount: mergedCheckpoint?.deliveredIdsAtReceivedAt?.length ?? 0,
14082
+ changed: true
14083
+ });
14014
14084
  return { result: { ...next }, changed: true };
14015
14085
  }));
14016
14086
  }
@@ -14018,10 +14088,25 @@ var AccountStore = class _AccountStore {
14018
14088
  return this.runSerial(email, async () => this.updateLocked((data) => {
14019
14089
  const norm = email.trim().toLowerCase();
14020
14090
  const idx = data.accounts.findIndex((a) => a.email.toLowerCase() === norm);
14021
- if (idx < 0) return { result: void 0, changed: false };
14091
+ if (idx < 0) {
14092
+ this.logger.debug("account-store", "updateTokens", {
14093
+ email: norm,
14094
+ found: false,
14095
+ changed: false
14096
+ });
14097
+ return { result: void 0, changed: false };
14098
+ }
14022
14099
  const current = data.accounts[idx];
14023
14100
  const next = { ...current, tokens };
14024
14101
  data.accounts[idx] = next;
14102
+ this.logger.debug("account-store", "updateTokens", {
14103
+ email: norm,
14104
+ found: true,
14105
+ fieldCount: Object.keys(tokens).length,
14106
+ storedCheckpointReceivedAt: current.newEmailCheckpoint?.receivedAt ?? null,
14107
+ storedDeliveredIdCount: current.newEmailCheckpoint?.deliveredIdsAtReceivedAt?.length ?? 0,
14108
+ changed: true
14109
+ });
14025
14110
  return { result: { ...next }, changed: true };
14026
14111
  }));
14027
14112
  }
@@ -14029,18 +14114,45 @@ var AccountStore = class _AccountStore {
14029
14114
  return this.runSerial(email, async () => this.updateLocked((data) => {
14030
14115
  const norm = email.trim().toLowerCase();
14031
14116
  const idx = data.accounts.findIndex((a) => a.email.toLowerCase() === norm);
14032
- if (idx < 0) return { result: void 0, changed: false };
14117
+ if (idx < 0) {
14118
+ this.logger.debug("account-store", "updateNewEmailCheckpoint", {
14119
+ email: norm,
14120
+ found: false,
14121
+ incomingReceivedAt: checkpoint.receivedAt,
14122
+ incomingDeliveredIdCount: checkpoint.deliveredIdsAtReceivedAt?.length ?? 0,
14123
+ changed: false
14124
+ });
14125
+ return { result: void 0, changed: false };
14126
+ }
14033
14127
  const current = data.accounts[idx];
14034
14128
  const mergedCheckpoint = mergeNewEmailCheckpoints(
14035
14129
  current.newEmailCheckpoint,
14036
14130
  checkpoint
14037
14131
  );
14038
- if (!mergedCheckpoint) return { result: { ...current }, changed: false };
14132
+ if (!mergedCheckpoint) {
14133
+ this.logger.debug("account-store", "updateNewEmailCheckpoint", {
14134
+ email: norm,
14135
+ found: true,
14136
+ incomingReceivedAt: checkpoint.receivedAt,
14137
+ incomingDeliveredIdCount: checkpoint.deliveredIdsAtReceivedAt?.length ?? 0,
14138
+ changed: false
14139
+ });
14140
+ return { result: { ...current }, changed: false };
14141
+ }
14039
14142
  const next = {
14040
14143
  ...current,
14041
14144
  newEmailCheckpoint: mergedCheckpoint
14042
14145
  };
14043
14146
  data.accounts[idx] = next;
14147
+ this.logger.debug("account-store", "updateNewEmailCheckpoint", {
14148
+ email: norm,
14149
+ found: true,
14150
+ currentReceivedAt: current.newEmailCheckpoint?.receivedAt ?? null,
14151
+ incomingReceivedAt: checkpoint.receivedAt,
14152
+ mergedReceivedAt: mergedCheckpoint.receivedAt,
14153
+ mergedDeliveredIdCount: mergedCheckpoint.deliveredIdsAtReceivedAt?.length ?? 0,
14154
+ changed: true
14155
+ });
14044
14156
  return { result: { ...next }, changed: true };
14045
14157
  }));
14046
14158
  }
@@ -14048,7 +14160,17 @@ var AccountStore = class _AccountStore {
14048
14160
  return this.runSerial(email, async () => this.updateLocked((data) => {
14049
14161
  const norm = email.trim().toLowerCase();
14050
14162
  const idx = data.accounts.findIndex((a) => a.email.toLowerCase() === norm);
14051
- if (idx < 0 || candidates.length === 0) return { result: [], changed: false };
14163
+ if (idx < 0 || candidates.length === 0) {
14164
+ this.logger.debug("account-store", "claimNewEmails", {
14165
+ email: norm,
14166
+ found: idx >= 0,
14167
+ candidateCount: candidates.length,
14168
+ candidateIds: candidates.map((candidate) => candidate.summaryId),
14169
+ claimedCount: 0,
14170
+ changed: false
14171
+ });
14172
+ return { result: [], changed: false };
14173
+ }
14052
14174
  const account = data.accounts[idx];
14053
14175
  let checkpoint = normalizeCheckpoint(account.newEmailCheckpoint);
14054
14176
  const claimed = [];
@@ -14069,12 +14191,36 @@ var AccountStore = class _AccountStore {
14069
14191
  deliveredIdsAtReceivedAt: ids
14070
14192
  });
14071
14193
  }
14072
- if (claimed.length === 0 || !checkpoint) return { result: claimed, changed: false };
14194
+ if (claimed.length === 0 || !checkpoint) {
14195
+ this.logger.debug("account-store", "claimNewEmails", {
14196
+ email: norm,
14197
+ found: true,
14198
+ candidateCount: candidates.length,
14199
+ candidateIds: candidates.map((candidate) => candidate.summaryId),
14200
+ claimedCount: claimed.length,
14201
+ claimedIds: claimed,
14202
+ checkpointReceivedAt: checkpoint?.receivedAt ?? null,
14203
+ deliveredIdCount: checkpoint?.deliveredIdsAtReceivedAt?.length ?? 0,
14204
+ changed: false
14205
+ });
14206
+ return { result: claimed, changed: false };
14207
+ }
14073
14208
  const next = {
14074
14209
  ...account,
14075
14210
  newEmailCheckpoint: checkpoint
14076
14211
  };
14077
14212
  data.accounts[idx] = next;
14213
+ this.logger.debug("account-store", "claimNewEmails", {
14214
+ email: norm,
14215
+ found: true,
14216
+ candidateCount: candidates.length,
14217
+ candidateIds: candidates.map((candidate) => candidate.summaryId),
14218
+ claimedCount: claimed.length,
14219
+ claimedIds: claimed,
14220
+ checkpointReceivedAt: checkpoint.receivedAt,
14221
+ deliveredIdCount: checkpoint.deliveredIdsAtReceivedAt?.length ?? 0,
14222
+ changed: true
14223
+ });
14078
14224
  return { result: claimed, changed: true };
14079
14225
  }));
14080
14226
  }
@@ -14083,7 +14229,12 @@ var AccountStore = class _AccountStore {
14083
14229
  const norm = email.trim().toLowerCase();
14084
14230
  const before = data.accounts.length;
14085
14231
  data.accounts = data.accounts.filter((a) => a.email.toLowerCase() !== norm);
14086
- return { result: data.accounts.length !== before, changed: data.accounts.length !== before };
14232
+ const changed = data.accounts.length !== before;
14233
+ this.logger.debug("account-store", "removeAccount", {
14234
+ email: norm,
14235
+ changed
14236
+ });
14237
+ return { result: changed, changed };
14087
14238
  }));
14088
14239
  }
14089
14240
  async runSerial(email, task) {
@@ -14125,6 +14276,10 @@ var AccountStore = class _AccountStore {
14125
14276
  async flush() {
14126
14277
  const buf = encrypt(this.data, this.key);
14127
14278
  await writeAtomic(this.filePath, buf);
14279
+ this.logger.debug("account-store", "flush", {
14280
+ filePath: this.filePath,
14281
+ accountCount: this.data.accounts.length
14282
+ });
14128
14283
  }
14129
14284
  async withFileLock(task) {
14130
14285
  await this.acquireFileLock();
@@ -14132,10 +14287,14 @@ var AccountStore = class _AccountStore {
14132
14287
  return await task();
14133
14288
  } finally {
14134
14289
  await fs2.rm(this.lockPath, { recursive: true, force: true });
14290
+ this.logger.debug("account-store", "lockReleased", {
14291
+ lockPath: this.lockPath
14292
+ });
14135
14293
  }
14136
14294
  }
14137
14295
  async acquireFileLock() {
14138
14296
  const startedAt = Date.now();
14297
+ let loggedWait = false;
14139
14298
  while (true) {
14140
14299
  try {
14141
14300
  await fs2.mkdir(this.lockPath, { mode: 448 });
@@ -14146,11 +14305,25 @@ ${(/* @__PURE__ */ new Date()).toISOString()}
14146
14305
  `,
14147
14306
  { mode: 384 }
14148
14307
  );
14308
+ this.logger.debug("account-store", "lockAcquired", {
14309
+ lockPath: this.lockPath,
14310
+ waitedMs: Date.now() - startedAt
14311
+ });
14149
14312
  return;
14150
14313
  } catch (err) {
14151
14314
  if (err.code !== "EEXIST") throw err;
14315
+ if (!loggedWait) {
14316
+ this.logger.debug("account-store", "lockWait", {
14317
+ lockPath: this.lockPath
14318
+ });
14319
+ loggedWait = true;
14320
+ }
14152
14321
  await this.removeStaleLock();
14153
14322
  if (Date.now() - startedAt > LOCK_TIMEOUT_MS) {
14323
+ this.logger.debug("account-store", "lockTimeout", {
14324
+ lockPath: this.lockPath,
14325
+ waitedMs: Date.now() - startedAt
14326
+ });
14154
14327
  throw new Error(`timed out waiting for account store lock: ${this.lockPath}`);
14155
14328
  }
14156
14329
  await delay(LOCK_RETRY_MS);
@@ -14162,6 +14335,10 @@ ${(/* @__PURE__ */ new Date()).toISOString()}
14162
14335
  const stat = await fs2.stat(this.lockPath);
14163
14336
  if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
14164
14337
  await fs2.rm(this.lockPath, { recursive: true, force: true });
14338
+ this.logger.debug("account-store", "staleLockRemoved", {
14339
+ lockPath: this.lockPath,
14340
+ staleMs: Date.now() - stat.mtimeMs
14341
+ });
14165
14342
  }
14166
14343
  } catch (err) {
14167
14344
  if (err.code !== "ENOENT") throw err;
@@ -14635,8 +14812,8 @@ async function updateDraft(client, account, id, update) {
14635
14812
  payload.attachments = converted.attachments;
14636
14813
  }
14637
14814
  }
14638
- await client.api(`/me/messages/${encodeURIComponent(id)}`).patch(payload);
14639
- return { id };
14815
+ const updated = await client.api(`/me/messages/${encodeURIComponent(id)}`).header("Prefer", "return=representation").patch(payload);
14816
+ return { id: updated?.id ?? id };
14640
14817
  }
14641
14818
  async function addAttachmentToDraft(client, account, draftId, name, contentBytes, contentType) {
14642
14819
  const att = await client.api(`/me/messages/${encodeURIComponent(draftId)}/attachments`).post({
@@ -17347,7 +17524,7 @@ var BODY_LIMIT = 2e4;
17347
17524
  var PAGE_SIZE = 100;
17348
17525
  var MISSING_RECEIVED_AT = "1970-01-01T00:00:00.000Z";
17349
17526
  function registerNewEmailTool(server, ctx) {
17350
- const { store, registry, tools } = ctx;
17527
+ const { store, registry, tools, logger = noopLogger } = ctx;
17351
17528
  if (!shouldRegister("get_new_emails", tools)) return;
17352
17529
  const newEmailOutputSchema = z4.object({
17353
17530
  account: z4.string(),
@@ -17389,20 +17566,46 @@ function registerNewEmailTool(server, ctx) {
17389
17566
  },
17390
17567
  async (args) => {
17391
17568
  const limit = args.limit ?? DEFAULT_LIMIT;
17569
+ logger.debug("get-new-emails", "start", {
17570
+ account: args.account ?? null,
17571
+ limit
17572
+ });
17392
17573
  if (args.account) {
17393
17574
  try {
17394
17575
  const { provider, account } = registry.resolveByEmail(args.account);
17395
- const result = await collectCandidatesForAccount(store, provider, account);
17576
+ const result = await collectCandidatesForAccount(store, provider, account, logger);
17396
17577
  const selected2 = oldestCandidatesFirst(result.candidates).slice(0, limit);
17397
- const emails2 = limit === 0 ? [] : await hydrateAndAdvance(store, provider, result.account, selected2);
17578
+ logger.debug("get-new-emails", "selected", {
17579
+ account: result.account.email,
17580
+ candidateCount: result.candidates.length,
17581
+ selectedCount: selected2.length,
17582
+ selectedIds: selected2.map((candidate) => candidate.summary.id),
17583
+ selectedReceivedAt: selected2.map((candidate) => candidate.timestamp),
17584
+ limit
17585
+ });
17586
+ const emails2 = limit === 0 ? [] : await hydrateAndAdvance(store, provider, result.account, selected2, logger);
17398
17587
  const data2 = { count: emails2.length, emails: emails2, errors: [] };
17588
+ logger.debug("get-new-emails", "end", {
17589
+ account: result.account.email,
17590
+ returnedCount: emails2.length,
17591
+ errorCount: 0
17592
+ });
17399
17593
  return ok(data2, data2);
17400
17594
  } catch (err) {
17595
+ logger.debug("get-new-emails", "error", {
17596
+ account: args.account,
17597
+ message: errMsg(err)
17598
+ });
17401
17599
  return fail(errMsg(err));
17402
17600
  }
17403
17601
  }
17404
17602
  const accounts = store.listAccounts();
17405
17603
  if (accounts.length === 0) {
17604
+ logger.debug("get-new-emails", "end", {
17605
+ accountCount: 0,
17606
+ returnedCount: 0,
17607
+ errorCount: 1
17608
+ });
17406
17609
  return fail("no accounts registered. Call add_account first.");
17407
17610
  }
17408
17611
  const errors = [];
@@ -17412,15 +17615,27 @@ function registerNewEmailTool(server, ctx) {
17412
17615
  for (const stored of accounts) {
17413
17616
  try {
17414
17617
  const { provider, account } = registry.resolveByEmail(stored.email);
17415
- const result = await collectCandidatesForAccount(store, provider, account);
17618
+ const result = await collectCandidatesForAccount(store, provider, account, logger);
17416
17619
  providersByEmail.set(result.account.email, provider);
17417
17620
  accountsByEmail.set(result.account.email, result.account);
17418
17621
  collected.push(...result.candidates);
17419
17622
  } catch (err) {
17623
+ logger.debug("get-new-emails", "accountError", {
17624
+ account: stored.email,
17625
+ message: errMsg(err)
17626
+ });
17420
17627
  errors.push({ account: stored.email, message: errMsg(err) });
17421
17628
  }
17422
17629
  }
17423
17630
  const selected = oldestCandidatesFirst(collected).slice(0, limit);
17631
+ logger.debug("get-new-emails", "selected", {
17632
+ accountCount: accounts.length,
17633
+ candidateCount: collected.length,
17634
+ selectedCount: selected.length,
17635
+ selectedIds: selected.map((candidate) => candidate.summary.id),
17636
+ selectedReceivedAt: selected.map((candidate) => candidate.timestamp),
17637
+ limit
17638
+ });
17424
17639
  const emails = [];
17425
17640
  if (limit > 0) {
17426
17641
  const byAccount = /* @__PURE__ */ new Map();
@@ -17439,24 +17654,39 @@ function registerNewEmailTool(server, ctx) {
17439
17654
  store,
17440
17655
  provider,
17441
17656
  account,
17442
- accountCandidates
17657
+ accountCandidates,
17658
+ logger
17443
17659
  )
17444
17660
  );
17445
17661
  } catch (err) {
17662
+ logger.debug("get-new-emails", "accountError", {
17663
+ account: email,
17664
+ message: errMsg(err)
17665
+ });
17446
17666
  errors.push({ account: email, message: errMsg(err) });
17447
17667
  }
17448
17668
  }
17449
17669
  }
17450
17670
  const orderedEmails = emails.sort(compareNewEmailOutputOldestFirst);
17451
17671
  const data = { count: orderedEmails.length, emails: orderedEmails, errors };
17672
+ logger.debug("get-new-emails", "end", {
17673
+ accountCount: accounts.length,
17674
+ returnedCount: orderedEmails.length,
17675
+ errorCount: errors.length
17676
+ });
17452
17677
  return ok(data, data);
17453
17678
  }
17454
17679
  );
17455
17680
  }
17456
- async function collectCandidatesForAccount(store, provider, account) {
17681
+ async function collectCandidatesForAccount(store, provider, account, logger) {
17457
17682
  const checkpoint = normalizeCheckpoint2(account.newEmailCheckpoint);
17458
17683
  if (!checkpoint) {
17459
- await initializeCheckpoint(store, provider, account);
17684
+ await initializeCheckpoint(store, provider, account, logger);
17685
+ logger.debug("get-new-emails", "candidatesCollected", {
17686
+ account: account.email,
17687
+ initialized: true,
17688
+ candidateCount: 0
17689
+ });
17460
17690
  return { account, candidates: [] };
17461
17691
  }
17462
17692
  const deliveredAtCheckpoint = new Set(checkpoint.deliveredIdsAtReceivedAt ?? []);
@@ -17486,9 +17716,18 @@ async function collectCandidatesForAccount(store, provider, account) {
17486
17716
  if (sawOlderThanCheckpoint || !hasMore) break;
17487
17717
  skip += items.length;
17488
17718
  }
17719
+ logger.debug("get-new-emails", "candidatesCollected", {
17720
+ account: account.email,
17721
+ initialized: false,
17722
+ checkpointReceivedAt: checkpoint.receivedAt,
17723
+ deliveredIdCount: checkpoint.deliveredIdsAtReceivedAt?.length ?? 0,
17724
+ candidateCount: candidates.length,
17725
+ candidateIds: candidates.map((candidate) => candidate.summary.id),
17726
+ candidateReceivedAt: candidates.map((candidate) => candidate.timestamp)
17727
+ });
17489
17728
  return { account, candidates };
17490
17729
  }
17491
- async function initializeCheckpoint(store, provider, account) {
17730
+ async function initializeCheckpoint(store, provider, account, logger) {
17492
17731
  const { items } = await provider.listEmails(account, {
17493
17732
  folder: "inbox",
17494
17733
  limit: PAGE_SIZE
@@ -17500,9 +17739,22 @@ async function initializeCheckpoint(store, provider, account) {
17500
17739
  receivedAt,
17501
17740
  deliveredIdsAtReceivedAt
17502
17741
  });
17742
+ logger.debug("get-new-emails", "checkpointInitialized", {
17743
+ account: account.email,
17744
+ receivedAt,
17745
+ deliveredIdCount: deliveredIdsAtReceivedAt.length
17746
+ });
17503
17747
  }
17504
- async function hydrateAndAdvance(store, provider, account, selected) {
17505
- if (selected.length === 0) return [];
17748
+ async function hydrateAndAdvance(store, provider, account, selected, logger) {
17749
+ if (selected.length === 0) {
17750
+ logger.debug("get-new-emails", "hydrated", {
17751
+ account: account.email,
17752
+ selectedCount: 0,
17753
+ hydratedCount: 0,
17754
+ claimedCount: 0
17755
+ });
17756
+ return [];
17757
+ }
17506
17758
  const hydrated = [];
17507
17759
  for (const candidate of selected) {
17508
17760
  const full = await provider.readEmail(account, candidate.summary.id);
@@ -17517,7 +17769,19 @@ async function hydrateAndAdvance(store, provider, account, selected) {
17517
17769
  receivedAt: candidate.timestamp,
17518
17770
  ids: [candidate.summary.id, fullId]
17519
17771
  }));
17772
+ logger.debug("get-new-emails", "hydrated", {
17773
+ account: account.email,
17774
+ selectedCount: selected.length,
17775
+ hydratedCount: hydrated.length
17776
+ });
17520
17777
  const claimed = new Set(await store.claimNewEmails(account.email, claims));
17778
+ logger.debug("get-new-emails", "claimed", {
17779
+ account: account.email,
17780
+ claimCount: claims.length,
17781
+ claimedCount: claimed.size,
17782
+ claimIds: claims.map((claim) => claim.summaryId),
17783
+ claimedIds: [...claimed]
17784
+ });
17521
17785
  return hydrated.filter(({ candidate }) => claimed.has(candidate.summary.id)).map(({ email }) => email);
17522
17786
  }
17523
17787
  function formatNewEmail(account, msg, summary) {
@@ -17583,7 +17847,7 @@ function compareTimestamp2(a, b) {
17583
17847
 
17584
17848
  // src/tools/browse.ts
17585
17849
  function registerBrowseTools(server, ctx) {
17586
- const { store, registry, tools } = ctx;
17850
+ const { store, registry, tools, logger } = ctx;
17587
17851
  const emailListOutputSchema = z5.object({
17588
17852
  account: z5.string(),
17589
17853
  count: z5.number(),
@@ -17633,7 +17897,7 @@ function registerBrowseTools(server, ctx) {
17633
17897
  }
17634
17898
  );
17635
17899
  }
17636
- registerNewEmailTool(server, { store, registry, tools });
17900
+ registerNewEmailTool(server, { store, registry, tools, logger });
17637
17901
  if (shouldRegister("search_emails", tools)) {
17638
17902
  server.registerTool(
17639
17903
  "search_emails",
@@ -18041,7 +18305,7 @@ function registerOrganizeTools(server, ctx) {
18041
18305
  }
18042
18306
 
18043
18307
  // src/tools/compose.ts
18044
- import { z as z8 } from "zod";
18308
+ import { z as z9 } from "zod";
18045
18309
  import { readFileSync } from "fs";
18046
18310
  import { basename, extname } from "path";
18047
18311
 
@@ -18072,42 +18336,106 @@ var MIME_TYPES = {
18072
18336
  ".mp4": "video/mp4"
18073
18337
  };
18074
18338
 
18339
+ // src/tools/edit-draft-verify.ts
18340
+ var EDIT_DRAFT_VERIFY_DELAYS_MS = [250, 1e3, 2e3];
18341
+ function normalizeDraftBody2(body) {
18342
+ return body.replace(/\r\n/g, "\n").trim();
18343
+ }
18344
+ function bodyEditPersisted(actualBody, expectation) {
18345
+ const actual = normalizeDraftBody2(actualBody);
18346
+ const expected = normalizeDraftBody2(expectation.expectedBody);
18347
+ if (actual === expected) return true;
18348
+ const oldText = normalizeDraftBody2(expectation.oldText);
18349
+ const replacementBody = normalizeDraftBody2(expectation.replacementBody);
18350
+ if (oldText === replacementBody) {
18351
+ return actual.includes(replacementBody);
18352
+ }
18353
+ return actual.includes(replacementBody) && !actual.includes(oldText);
18354
+ }
18355
+ function delay2(ms) {
18356
+ return new Promise((resolve) => setTimeout(resolve, ms));
18357
+ }
18358
+ async function readDraftWithVerifiedBody(provider, account, id, expectation) {
18359
+ let draft = await provider.readEmail(account, id);
18360
+ for (const delayMs of EDIT_DRAFT_VERIFY_DELAYS_MS) {
18361
+ const body2 = draft.bodyHtml ?? draft.bodyText ?? "";
18362
+ if (bodyEditPersisted(body2, expectation)) return draft;
18363
+ await delay2(delayMs);
18364
+ draft = await provider.readEmail(account, id);
18365
+ }
18366
+ const body = draft.bodyHtml ?? draft.bodyText ?? "";
18367
+ return bodyEditPersisted(body, expectation) ? draft : void 0;
18368
+ }
18369
+
18370
+ // src/tools/compose-schemas.ts
18371
+ import { z as z8 } from "zod";
18372
+ var sendEmailSchema = z8.object({
18373
+ account: z8.string().email(),
18374
+ to: z8.array(emailAddrSchema).min(1),
18375
+ cc: z8.array(emailAddrSchema).optional(),
18376
+ bcc: z8.array(emailAddrSchema).optional(),
18377
+ subject: z8.string(),
18378
+ body: z8.string(),
18379
+ format: z8.enum(["html", "markdown"]).describe(
18380
+ "Body format. 'html' sends the body as-is (must be valid HTML). 'markdown' converts the body from Markdown to HTML for clean rendering on the recipient side."
18381
+ ),
18382
+ include_signature: z8.boolean().describe(
18383
+ "Whether to append the account's saved HTML signature to the email. If true, don't include a signature in the body param to avoid double signature. Returns an error if true but no signature is configured for this account."
18384
+ ),
18385
+ inReplyTo: z8.union([z8.string(), z8.literal(false)]).describe(
18386
+ "Message ID to reply to. When set, sends as a threaded reply which includes the quoted thread history automatically. Set to `false` for a new email (not a reply)."
18387
+ ),
18388
+ replyAll: z8.boolean().default(false).optional().describe(
18389
+ "When true and `inReplyTo` is set, reply to all recipients instead of just the sender."
18390
+ ),
18391
+ forwardMessageId: z8.string().optional().describe(
18392
+ "Message ID to forward. When set, sends as a forward of the specified message, preserving the original content. Mutually exclusive with `inReplyTo`."
18393
+ ),
18394
+ attachments: z8.array(
18395
+ z8.object({
18396
+ filePath: z8.string().min(1).describe("Absolute path to a local file"),
18397
+ name: z8.string().optional().describe("Attachment filename. Defaults to the file's basename.")
18398
+ })
18399
+ ).optional().describe(
18400
+ "File attachments to include. The server reads the files from disk and base64-encodes them automatically."
18401
+ )
18402
+ });
18403
+ var editDraftSchema = z8.object({
18404
+ account: z8.string().email(),
18405
+ id: z8.string().min(1).describe("Draft message ID to edit"),
18406
+ to: z8.array(emailAddrSchema).optional(),
18407
+ cc: z8.array(emailAddrSchema).optional(),
18408
+ bcc: z8.array(emailAddrSchema).optional(),
18409
+ subject: z8.string().optional(),
18410
+ old_text: z8.string().min(1).optional().describe(
18411
+ "Exact current HTML section to replace in the draft body. Copy this from `draftHtml` or from `read_email` with format='html'. Must match exactly once; unselected content is preserved."
18412
+ ),
18413
+ new_text: z8.string().optional().describe(
18414
+ "Replacement content for `old_text`. The replacement is composed using `format` and `include_signature`, then inserted exactly where `old_text` matched."
18415
+ ),
18416
+ body: z8.string().optional().describe(
18417
+ "Deprecated alias for `new_text`. Body-only full replacement is not supported; provide `old_text` with this field."
18418
+ ),
18419
+ format: z8.enum(["html", "markdown"]).optional().describe(
18420
+ "Replacement format. Only meaningful when `new_text` or deprecated `body` is also provided. 'html' inserts the replacement as-is (must be valid HTML). 'markdown' converts the replacement from Markdown to HTML."
18421
+ ),
18422
+ include_signature: z8.boolean().optional().describe(
18423
+ "Whether to append the account's saved HTML signature to the replacement section. If true, don't include a signature in `new_text`/`body`. Only meaningful when replacement content is provided. Returns an error if true but no signature is configured for this account."
18424
+ ),
18425
+ new_attachments: z8.array(
18426
+ z8.object({
18427
+ filePath: z8.string().min(1).describe("Absolute path to a local file"),
18428
+ name: z8.string().optional().describe("Attachment filename. Defaults to the file's basename.")
18429
+ })
18430
+ ).optional().describe(
18431
+ "New file attachments to add to the draft. The server reads the files from disk and base64-encodes them automatically."
18432
+ ),
18433
+ remove_attachments: z8.array(z8.string().min(1)).optional().describe("Attachment IDs to remove from the draft. Get attachment IDs from read_email.")
18434
+ });
18435
+
18075
18436
  // src/tools/compose.ts
18076
18437
  function registerComposeTools(server, ctx) {
18077
18438
  const { store, registry, tools } = ctx;
18078
- const sendEmailSchema = z8.object({
18079
- account: z8.string().email(),
18080
- to: z8.array(emailAddrSchema).min(1),
18081
- cc: z8.array(emailAddrSchema).optional(),
18082
- bcc: z8.array(emailAddrSchema).optional(),
18083
- subject: z8.string(),
18084
- body: z8.string(),
18085
- format: z8.enum(["html", "markdown"]).describe(
18086
- "Body format. 'html' sends the body as-is (must be valid HTML). 'markdown' converts the body from Markdown to HTML for clean rendering on the recipient side."
18087
- ),
18088
- include_signature: z8.boolean().describe(
18089
- "Whether to append the account's saved HTML signature to the email. If true, don't include a signature in the body param to avoid double signature. Returns an error if true but no signature is configured for this account."
18090
- ),
18091
- inReplyTo: z8.union([z8.string(), z8.literal(false)]).describe(
18092
- "Message ID to reply to. When set, sends as a threaded reply which includes the quoted thread history automatically. Set to `false` for a new email (not a reply)."
18093
- ),
18094
- replyAll: z8.boolean().default(false).optional().describe(
18095
- "When true and `inReplyTo` is set, reply to all recipients instead of just the sender."
18096
- ),
18097
- forwardMessageId: z8.string().optional().describe(
18098
- "Message ID to forward. When set, sends as a forward of the specified message, preserving the original content. Mutually exclusive with `inReplyTo`."
18099
- ),
18100
- attachments: z8.array(
18101
- z8.object({
18102
- filePath: z8.string().min(1).describe("Absolute path to a local file"),
18103
- name: z8.string().optional().describe(
18104
- "Attachment filename. Defaults to the file's basename."
18105
- )
18106
- })
18107
- ).optional().describe(
18108
- "File attachments to include. The server reads the files from disk and base64-encodes them automatically."
18109
- )
18110
- });
18111
18439
  async function handleSendOrDraft(args, action, resultKey, toolName) {
18112
18440
  try {
18113
18441
  const { provider, account } = registry.resolveByEmail(args.account);
@@ -18163,8 +18491,8 @@ function registerComposeTools(server, ctx) {
18163
18491
  }
18164
18492
  }
18165
18493
  const sendEmailOutputSchema = {
18166
- sent: z8.literal(true),
18167
- id: z8.string()
18494
+ sent: z9.literal(true),
18495
+ id: z9.string()
18168
18496
  };
18169
18497
  if (shouldRegister("send_email", tools)) {
18170
18498
  server.registerTool(
@@ -18183,9 +18511,9 @@ function registerComposeTools(server, ctx) {
18183
18511
  );
18184
18512
  }
18185
18513
  const draftEmailOutputSchema = {
18186
- draft: z8.literal(true),
18187
- id: z8.string(),
18188
- draftHtml: z8.string().optional()
18514
+ draft: z9.literal(true),
18515
+ id: z9.string(),
18516
+ draftHtml: z9.string().optional()
18189
18517
  };
18190
18518
  if (shouldRegister("draft_email", tools)) {
18191
18519
  server.registerTool(
@@ -18203,46 +18531,10 @@ function registerComposeTools(server, ctx) {
18203
18531
  )
18204
18532
  );
18205
18533
  }
18206
- const editDraftSchema = z8.object({
18207
- account: z8.string().email(),
18208
- id: z8.string().min(1).describe("Draft message ID to edit"),
18209
- to: z8.array(emailAddrSchema).optional(),
18210
- cc: z8.array(emailAddrSchema).optional(),
18211
- bcc: z8.array(emailAddrSchema).optional(),
18212
- subject: z8.string().optional(),
18213
- old_text: z8.string().min(1).optional().describe(
18214
- "Exact current HTML section to replace in the draft body. Copy this from `draftHtml` or from `read_email` with format='html'. Must match exactly once; unselected content is preserved."
18215
- ),
18216
- new_text: z8.string().optional().describe(
18217
- "Replacement content for `old_text`. The replacement is composed using `format` and `include_signature`, then inserted exactly where `old_text` matched."
18218
- ),
18219
- body: z8.string().optional().describe(
18220
- "Deprecated alias for `new_text`. Body-only full replacement is not supported; provide `old_text` with this field."
18221
- ),
18222
- format: z8.enum(["html", "markdown"]).optional().describe(
18223
- "Replacement format. Only meaningful when `new_text` or deprecated `body` is also provided. 'html' inserts the replacement as-is (must be valid HTML). 'markdown' converts the replacement from Markdown to HTML."
18224
- ),
18225
- include_signature: z8.boolean().optional().describe(
18226
- "Whether to append the account's saved HTML signature to the replacement section. If true, don't include a signature in `new_text`/`body`. Only meaningful when replacement content is provided. Returns an error if true but no signature is configured for this account."
18227
- ),
18228
- new_attachments: z8.array(
18229
- z8.object({
18230
- filePath: z8.string().min(1).describe("Absolute path to a local file"),
18231
- name: z8.string().optional().describe(
18232
- "Attachment filename. Defaults to the file's basename."
18233
- )
18234
- })
18235
- ).optional().describe(
18236
- "New file attachments to add to the draft. The server reads the files from disk and base64-encodes them automatically."
18237
- ),
18238
- remove_attachments: z8.array(z8.string().min(1)).optional().describe(
18239
- "Attachment IDs to remove from the draft. Get attachment IDs from read_email."
18240
- )
18241
- });
18242
18534
  const editDraftOutputSchema = {
18243
- edited: z8.literal(true),
18244
- id: z8.string(),
18245
- draftHtml: z8.string().optional()
18535
+ edited: z9.literal(true),
18536
+ id: z9.string(),
18537
+ draftHtml: z9.string().optional()
18246
18538
  };
18247
18539
  if (shouldRegister("edit_draft", tools)) {
18248
18540
  server.registerTool(
@@ -18281,6 +18573,7 @@ function registerComposeTools(server, ctx) {
18281
18573
  }
18282
18574
  let bodyPayload;
18283
18575
  let isHtmlPayload;
18576
+ let bodyExpectation;
18284
18577
  if (replacementText !== void 0) {
18285
18578
  const existing = await provider.readEmail(account, a.id);
18286
18579
  const existingBody = existing.bodyHtml ?? existing.bodyText ?? "";
@@ -18293,6 +18586,11 @@ function registerComposeTools(server, ctx) {
18293
18586
  });
18294
18587
  bodyPayload = applyExactTextEdit(existingBody, a.old_text ?? "", composed.body);
18295
18588
  isHtmlPayload = composed.isHtml;
18589
+ bodyExpectation = {
18590
+ expectedBody: bodyPayload,
18591
+ oldText: a.old_text ?? "",
18592
+ replacementBody: composed.body
18593
+ };
18296
18594
  }
18297
18595
  const hasDraftUpdate = a.to !== void 0 || a.cc !== void 0 || a.bcc !== void 0 || a.subject !== void 0 || bodyPayload !== void 0;
18298
18596
  let currentId = a.id;
@@ -18335,7 +18633,35 @@ function registerComposeTools(server, ctx) {
18335
18633
  removedIds.push(attId);
18336
18634
  }
18337
18635
  }
18338
- const draft = await provider.readEmail(account, currentId);
18636
+ let draft;
18637
+ if (bodyExpectation) {
18638
+ draft = await readDraftWithVerifiedBody(
18639
+ provider,
18640
+ account,
18641
+ currentId,
18642
+ bodyExpectation
18643
+ );
18644
+ if (!draft && provider.id === "outlook" && bodyPayload !== void 0) {
18645
+ const res = await provider.updateDraft(account, currentId, {
18646
+ body: bodyPayload,
18647
+ isHtml: isHtmlPayload
18648
+ });
18649
+ currentId = res.id;
18650
+ draft = await readDraftWithVerifiedBody(
18651
+ provider,
18652
+ account,
18653
+ currentId,
18654
+ bodyExpectation
18655
+ );
18656
+ }
18657
+ if (!draft) {
18658
+ return fail(
18659
+ "Draft body edit was not observable after saving. Retry edit_draft, or recreate the draft with draft_email before sending."
18660
+ );
18661
+ }
18662
+ } else {
18663
+ draft = await provider.readEmail(account, currentId);
18664
+ }
18339
18665
  const result = {
18340
18666
  edited: true,
18341
18667
  id: currentId,
@@ -18349,8 +18675,8 @@ function registerComposeTools(server, ctx) {
18349
18675
  );
18350
18676
  }
18351
18677
  const sendDraftOutputSchema = {
18352
- sent: z8.literal(true),
18353
- id: z8.string()
18678
+ sent: z9.literal(true),
18679
+ id: z9.string()
18354
18680
  };
18355
18681
  if (shouldRegister("send_draft", tools)) {
18356
18682
  server.registerTool(
@@ -18358,8 +18684,8 @@ function registerComposeTools(server, ctx) {
18358
18684
  {
18359
18685
  description: "Send an existing draft email by ID. Use this with draft IDs returned by `draft_email` or `edit_draft`. Disabled in --read-only mode.",
18360
18686
  inputSchema: {
18361
- account: z8.string().email(),
18362
- id: z8.string().min(1).describe("Draft message ID to send")
18687
+ account: z9.string().email(),
18688
+ id: z9.string().min(1).describe("Draft message ID to send")
18363
18689
  },
18364
18690
  outputSchema: sendDraftOutputSchema
18365
18691
  },
@@ -18379,9 +18705,9 @@ function registerComposeTools(server, ctx) {
18379
18705
 
18380
18706
  // src/tools/index.ts
18381
18707
  function registerTools(server, opts) {
18382
- const { store, registry, tools } = opts;
18708
+ const { store, registry, tools, logger } = opts;
18383
18709
  registerAccountTools(server, { store, registry, tools });
18384
- registerBrowseTools(server, { store, registry, tools });
18710
+ registerBrowseTools(server, { store, registry, tools, logger });
18385
18711
  registerFolderTools(server, { registry, tools });
18386
18712
  registerOrganizeTools(server, { registry, tools });
18387
18713
  registerComposeTools(server, { store, registry, tools });
@@ -18390,7 +18716,7 @@ function registerTools(server, opts) {
18390
18716
  // package.json
18391
18717
  var package_default = {
18392
18718
  name: "hypermail-mcp",
18393
- version: "0.7.13",
18719
+ version: "0.7.14",
18394
18720
  description: "Unified email MCP server \u2014 operate any inbox (Outlook now, IMAP/Gmail later) by passing an email address.",
18395
18721
  type: "module",
18396
18722
  bin: {
@@ -18470,6 +18796,7 @@ var ENV_HTTP_PORT = "HYPERMAIL_HTTP_PORT";
18470
18796
  var ENV_HTTP_HOST = "HYPERMAIL_HTTP_HOST";
18471
18797
  var ENV_TOOLS_DISABLED = "HYPERMAIL_TOOLS_DISABLED";
18472
18798
  var ENV_TOOLS_ENABLED = "HYPERMAIL_TOOLS_ENABLED";
18799
+ var ENV_DEBUG = "HYPERMAIL_DEBUG";
18473
18800
  var ENV_OUTLOOK_CLIENT_ID = "HYPERMAIL_OUTLOOK_CLIENT_ID";
18474
18801
  var ENV_OUTLOOK_TENANT_ID = "HYPERMAIL_OUTLOOK_TENANT_ID";
18475
18802
  var ENV_GMAIL_CLIENT_ID = "HYPERMAIL_GMAIL_CLIENT_ID";
@@ -18510,6 +18837,15 @@ function parseStringArray(value) {
18510
18837
  if (trimmed === "") return [];
18511
18838
  return trimmed.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
18512
18839
  }
18840
+ function resolveDebugLogging(warnings) {
18841
+ const value = envRaw(ENV_DEBUG);
18842
+ if (value === void 0 || value.trim() === "") return false;
18843
+ const lower = value.trim().toLowerCase();
18844
+ if (["1", "true", "yes", "on", "debug"].includes(lower)) return true;
18845
+ if (["0", "false", "no", "off"].includes(lower)) return false;
18846
+ warnings.push(`Invalid ${ENV_DEBUG}; debug logging disabled.`);
18847
+ return false;
18848
+ }
18513
18849
  function validateToolNames(toolNames, envName) {
18514
18850
  if (!toolNames || toolNames.length === 0) return;
18515
18851
  const known = new Set(KNOWN_TOOLS);
@@ -18591,6 +18927,7 @@ function loadConfig(cliOverrides = {}) {
18591
18927
  const tools = resolveToolsConfig();
18592
18928
  const providers = resolveProvidersConfig();
18593
18929
  const dataDir = cliOverrides.dataDir ?? optionalEnvString(ENV_DATA_DIR);
18930
+ const debugLogging = resolveDebugLogging(warnings);
18594
18931
  if (!optionalEnvString(ENV_KEY)) {
18595
18932
  warnings.push(
18596
18933
  `${ENV_KEY} is not set; a local generated key will be used. Set ${ENV_KEY} explicitly for portable hosted deployments.`
@@ -18602,7 +18939,8 @@ function loadConfig(cliOverrides = {}) {
18602
18939
  transport,
18603
18940
  http,
18604
18941
  tools,
18605
- providers
18942
+ providers,
18943
+ debugLogging
18606
18944
  },
18607
18945
  warnings
18608
18946
  };
@@ -18648,7 +18986,16 @@ function resolveTools(config) {
18648
18986
  // src/server.ts
18649
18987
  async function startServer(opts) {
18650
18988
  const { config } = opts;
18651
- const store = await AccountStore.open({ dataDir: config.dataDir });
18989
+ const logger = createLogger({ enabled: config.debugLogging });
18990
+ const dataDir = resolveDataDir(config.dataDir);
18991
+ logger.debug("server", "startup", {
18992
+ version: VERSION,
18993
+ transport: config.transport,
18994
+ dataDir,
18995
+ toolsEnabled: config.tools?.enabled ?? null,
18996
+ toolsDisabled: config.tools?.disabled ?? null
18997
+ });
18998
+ const store = await AccountStore.open({ dataDir, logger });
18652
18999
  const registry = buildRegistry({ store, providers: config.providers });
18653
19000
  const tools = resolveTools(config);
18654
19001
  const createServer = () => {
@@ -18656,7 +19003,7 @@ async function startServer(opts) {
18656
19003
  { name: "hypermail-mcp", version: VERSION },
18657
19004
  { capabilities: { tools: {}, logging: {} } }
18658
19005
  );
18659
- registerTools(s, { store, registry, tools });
19006
+ registerTools(s, { store, registry, tools, logger });
18660
19007
  return s;
18661
19008
  };
18662
19009
  if (config.transport === "http") {
@@ -18847,6 +19194,7 @@ Core environment variables:
18847
19194
  HYPERMAIL_HTTP_HOST
18848
19195
  HYPERMAIL_TOOLS_ENABLED
18849
19196
  HYPERMAIL_TOOLS_DISABLED
19197
+ HYPERMAIL_DEBUG=1|true|yes|on|debug
18850
19198
 
18851
19199
  Provider environment variables:
18852
19200
  HYPERMAIL_OUTLOOK_CLIENT_ID