hypermail-mcp 0.7.12 → 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,18 +13954,81 @@ 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";
14015
+ var LOCK_STALE_MS = 3e4;
14016
+ var LOCK_TIMEOUT_MS = 1e4;
14017
+ var LOCK_RETRY_MS = 25;
13959
14018
  var AccountStore = class _AccountStore {
13960
- constructor(filePath, key, data) {
14019
+ constructor(filePath, key, data, logger) {
13961
14020
  this.filePath = filePath;
13962
14021
  this.key = key;
13963
14022
  this.data = data;
14023
+ this.logger = logger;
14024
+ this.lockPath = `${filePath}.lock`;
13964
14025
  }
13965
14026
  filePath;
13966
14027
  key;
13967
14028
  data;
14029
+ logger;
13968
14030
  writeLocks = /* @__PURE__ */ new Map();
14031
+ lockPath;
13969
14032
  static async open(opts = {}) {
13970
14033
  const dataDir = resolveDataDir(opts.dataDir);
13971
14034
  await fs2.mkdir(dataDir, { recursive: true, mode: 448 });
@@ -13982,7 +14045,12 @@ var AccountStore = class _AccountStore {
13982
14045
  throw err;
13983
14046
  }
13984
14047
  }
13985
- 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);
13986
14054
  }
13987
14055
  listAccounts() {
13988
14056
  return this.data.accounts.map((a) => ({ ...a }));
@@ -13993,62 +14061,181 @@ var AccountStore = class _AccountStore {
13993
14061
  return rec ? { ...rec } : void 0;
13994
14062
  }
13995
14063
  async upsertAccount(rec) {
13996
- return this.runSerial(rec.email, async () => {
14064
+ return this.runSerial(rec.email, async () => this.updateLocked((data) => {
13997
14065
  const norm = rec.email.trim().toLowerCase();
14066
+ const idx = data.accounts.findIndex((a) => a.email.toLowerCase() === norm);
14067
+ const current = idx >= 0 ? data.accounts[idx] : void 0;
13998
14068
  const next = { ...rec, email: norm };
13999
- const idx = this.data.accounts.findIndex((a) => a.email.toLowerCase() === norm);
14000
- if (idx >= 0) this.data.accounts[idx] = next;
14001
- else this.data.accounts.push(next);
14002
- await this.flush();
14003
- return { ...next };
14004
- });
14069
+ const mergedCheckpoint = mergeNewEmailCheckpoints(
14070
+ current?.newEmailCheckpoint,
14071
+ rec.newEmailCheckpoint
14072
+ );
14073
+ if (mergedCheckpoint) next.newEmailCheckpoint = mergedCheckpoint;
14074
+ else delete next.newEmailCheckpoint;
14075
+ if (idx >= 0) data.accounts[idx] = next;
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
+ });
14084
+ return { result: { ...next }, changed: true };
14085
+ }));
14005
14086
  }
14006
14087
  async updateTokens(email, tokens) {
14007
- return this.runSerial(email, async () => {
14088
+ return this.runSerial(email, async () => this.updateLocked((data) => {
14008
14089
  const norm = email.trim().toLowerCase();
14009
- const idx = this.data.accounts.findIndex((a) => a.email.toLowerCase() === norm);
14010
- if (idx < 0) return void 0;
14011
- const current = this.data.accounts[idx];
14090
+ const idx = data.accounts.findIndex((a) => a.email.toLowerCase() === norm);
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
+ }
14099
+ const current = data.accounts[idx];
14012
14100
  const next = { ...current, tokens };
14013
- this.data.accounts[idx] = next;
14014
- await this.flush();
14015
- return { ...next };
14016
- });
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
+ });
14110
+ return { result: { ...next }, changed: true };
14111
+ }));
14017
14112
  }
14018
14113
  async updateNewEmailCheckpoint(email, checkpoint) {
14019
- return this.runSerial(email, async () => {
14114
+ return this.runSerial(email, async () => this.updateLocked((data) => {
14020
14115
  const norm = email.trim().toLowerCase();
14021
- const idx = this.data.accounts.findIndex((a) => a.email.toLowerCase() === norm);
14022
- if (idx < 0) return void 0;
14023
- const current = this.data.accounts[idx];
14024
- const currentCheckpoint = current.newEmailCheckpoint;
14025
- const mergedCheckpoint = currentCheckpoint?.receivedAt === checkpoint.receivedAt ? {
14026
- receivedAt: checkpoint.receivedAt,
14027
- deliveredIdsAtReceivedAt: [
14028
- .../* @__PURE__ */ new Set([
14029
- ...currentCheckpoint.deliveredIdsAtReceivedAt ?? [],
14030
- ...checkpoint.deliveredIdsAtReceivedAt ?? []
14031
- ])
14032
- ]
14033
- } : checkpoint;
14116
+ const idx = data.accounts.findIndex((a) => a.email.toLowerCase() === norm);
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
+ }
14127
+ const current = data.accounts[idx];
14128
+ const mergedCheckpoint = mergeNewEmailCheckpoints(
14129
+ current.newEmailCheckpoint,
14130
+ checkpoint
14131
+ );
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
+ }
14034
14142
  const next = {
14035
14143
  ...current,
14036
14144
  newEmailCheckpoint: mergedCheckpoint
14037
14145
  };
14038
- this.data.accounts[idx] = next;
14039
- await this.flush();
14040
- return { ...next };
14041
- });
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
+ });
14156
+ return { result: { ...next }, changed: true };
14157
+ }));
14158
+ }
14159
+ async claimNewEmails(email, candidates) {
14160
+ return this.runSerial(email, async () => this.updateLocked((data) => {
14161
+ const norm = email.trim().toLowerCase();
14162
+ const idx = data.accounts.findIndex((a) => a.email.toLowerCase() === norm);
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
+ }
14174
+ const account = data.accounts[idx];
14175
+ let checkpoint = normalizeCheckpoint(account.newEmailCheckpoint);
14176
+ const claimed = [];
14177
+ const ordered = [...candidates].sort((a, b) => {
14178
+ const byTime = compareTimestamp(normalizeTimestamp(a.receivedAt) ?? a.receivedAt, normalizeTimestamp(b.receivedAt) ?? b.receivedAt);
14179
+ if (byTime !== 0) return byTime;
14180
+ return a.summaryId.localeCompare(b.summaryId);
14181
+ });
14182
+ for (const candidate of ordered) {
14183
+ const receivedAt = normalizeTimestamp(candidate.receivedAt);
14184
+ if (!receivedAt) continue;
14185
+ const ids = uniqueIds([candidate.summaryId, ...candidate.ids]);
14186
+ if (ids.length === 0) continue;
14187
+ if (isAlreadyDelivered(checkpoint, receivedAt, ids)) continue;
14188
+ claimed.push(candidate.summaryId);
14189
+ checkpoint = mergeNewEmailCheckpoints(checkpoint, {
14190
+ receivedAt,
14191
+ deliveredIdsAtReceivedAt: ids
14192
+ });
14193
+ }
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
+ }
14208
+ const next = {
14209
+ ...account,
14210
+ newEmailCheckpoint: checkpoint
14211
+ };
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
+ });
14224
+ return { result: claimed, changed: true };
14225
+ }));
14042
14226
  }
14043
14227
  async removeAccount(email) {
14044
- return this.runSerial(email, async () => {
14228
+ return this.runSerial(email, async () => this.updateLocked((data) => {
14045
14229
  const norm = email.trim().toLowerCase();
14046
- const before = this.data.accounts.length;
14047
- this.data.accounts = this.data.accounts.filter((a) => a.email.toLowerCase() !== norm);
14048
- if (this.data.accounts.length === before) return false;
14049
- await this.flush();
14050
- return true;
14051
- });
14230
+ const before = data.accounts.length;
14231
+ data.accounts = data.accounts.filter((a) => a.email.toLowerCase() !== norm);
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 };
14238
+ }));
14052
14239
  }
14053
14240
  async runSerial(email, task) {
14054
14241
  const norm = email.trim().toLowerCase();
@@ -14067,11 +14254,146 @@ var AccountStore = class _AccountStore {
14067
14254
  }
14068
14255
  }
14069
14256
  }
14257
+ async updateLocked(task) {
14258
+ return this.withFileLock(async () => {
14259
+ this.data = await this.readLatest();
14260
+ const { result, changed } = await task(this.data);
14261
+ if (changed) await this.flush();
14262
+ return result;
14263
+ });
14264
+ }
14265
+ async readLatest() {
14266
+ try {
14267
+ const buf = await fs2.readFile(this.filePath);
14268
+ return decrypt(buf, this.key);
14269
+ } catch (err) {
14270
+ if (err.code === "ENOENT") {
14271
+ return { version: 1, accounts: [] };
14272
+ }
14273
+ throw err;
14274
+ }
14275
+ }
14070
14276
  async flush() {
14071
14277
  const buf = encrypt(this.data, this.key);
14072
14278
  await writeAtomic(this.filePath, buf);
14279
+ this.logger.debug("account-store", "flush", {
14280
+ filePath: this.filePath,
14281
+ accountCount: this.data.accounts.length
14282
+ });
14283
+ }
14284
+ async withFileLock(task) {
14285
+ await this.acquireFileLock();
14286
+ try {
14287
+ return await task();
14288
+ } finally {
14289
+ await fs2.rm(this.lockPath, { recursive: true, force: true });
14290
+ this.logger.debug("account-store", "lockReleased", {
14291
+ lockPath: this.lockPath
14292
+ });
14293
+ }
14294
+ }
14295
+ async acquireFileLock() {
14296
+ const startedAt = Date.now();
14297
+ let loggedWait = false;
14298
+ while (true) {
14299
+ try {
14300
+ await fs2.mkdir(this.lockPath, { mode: 448 });
14301
+ await fs2.writeFile(
14302
+ path2.join(this.lockPath, "owner"),
14303
+ `${process.pid}
14304
+ ${(/* @__PURE__ */ new Date()).toISOString()}
14305
+ `,
14306
+ { mode: 384 }
14307
+ );
14308
+ this.logger.debug("account-store", "lockAcquired", {
14309
+ lockPath: this.lockPath,
14310
+ waitedMs: Date.now() - startedAt
14311
+ });
14312
+ return;
14313
+ } catch (err) {
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
+ }
14321
+ await this.removeStaleLock();
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
+ });
14327
+ throw new Error(`timed out waiting for account store lock: ${this.lockPath}`);
14328
+ }
14329
+ await delay(LOCK_RETRY_MS);
14330
+ }
14331
+ }
14332
+ }
14333
+ async removeStaleLock() {
14334
+ try {
14335
+ const stat = await fs2.stat(this.lockPath);
14336
+ if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
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
+ });
14342
+ }
14343
+ } catch (err) {
14344
+ if (err.code !== "ENOENT") throw err;
14345
+ }
14073
14346
  }
14074
14347
  };
14348
+ function normalizeCheckpoint(checkpoint) {
14349
+ const receivedAt = normalizeTimestamp(checkpoint?.receivedAt);
14350
+ if (!receivedAt) return void 0;
14351
+ return {
14352
+ receivedAt,
14353
+ deliveredIdsAtReceivedAt: uniqueIds(checkpoint?.deliveredIdsAtReceivedAt ?? [])
14354
+ };
14355
+ }
14356
+ function mergeNewEmailCheckpoints(current, incoming) {
14357
+ const normalizedCurrent = normalizeCheckpoint(current);
14358
+ const normalizedIncoming = normalizeCheckpoint(incoming);
14359
+ if (!normalizedIncoming) return normalizedCurrent;
14360
+ if (!normalizedCurrent) return normalizedIncoming;
14361
+ const comparison = compareTimestamp(normalizedIncoming.receivedAt, normalizedCurrent.receivedAt);
14362
+ if (comparison > 0) return normalizedIncoming;
14363
+ if (comparison < 0) return normalizedCurrent;
14364
+ return {
14365
+ receivedAt: normalizedCurrent.receivedAt,
14366
+ deliveredIdsAtReceivedAt: uniqueIds([
14367
+ ...normalizedCurrent.deliveredIdsAtReceivedAt ?? [],
14368
+ ...normalizedIncoming.deliveredIdsAtReceivedAt ?? []
14369
+ ])
14370
+ };
14371
+ }
14372
+ function isAlreadyDelivered(checkpoint, receivedAt, ids) {
14373
+ if (!checkpoint) return false;
14374
+ const comparison = compareTimestamp(receivedAt, checkpoint.receivedAt);
14375
+ if (comparison < 0) return true;
14376
+ if (comparison > 0) return false;
14377
+ const delivered = new Set(checkpoint.deliveredIdsAtReceivedAt ?? []);
14378
+ return ids.some((id) => delivered.has(id));
14379
+ }
14380
+ function normalizeTimestamp(value) {
14381
+ if (!value) return void 0;
14382
+ const ms = Date.parse(value);
14383
+ if (!Number.isFinite(ms)) return void 0;
14384
+ return new Date(ms).toISOString();
14385
+ }
14386
+ function compareTimestamp(a, b) {
14387
+ if (a < b) return -1;
14388
+ if (a > b) return 1;
14389
+ return 0;
14390
+ }
14391
+ function uniqueIds(ids) {
14392
+ return [...new Set(ids.filter((id) => id.length > 0))];
14393
+ }
14394
+ async function delay(ms) {
14395
+ await new Promise((resolve) => setTimeout(resolve, ms));
14396
+ }
14075
14397
 
14076
14398
  // src/providers/outlook/index.ts
14077
14399
  import { randomUUID as randomUUID2 } from "crypto";
@@ -14490,8 +14812,8 @@ async function updateDraft(client, account, id, update) {
14490
14812
  payload.attachments = converted.attachments;
14491
14813
  }
14492
14814
  }
14493
- await client.api(`/me/messages/${encodeURIComponent(id)}`).patch(payload);
14494
- return { id };
14815
+ const updated = await client.api(`/me/messages/${encodeURIComponent(id)}`).header("Prefer", "return=representation").patch(payload);
14816
+ return { id: updated?.id ?? id };
14495
14817
  }
14496
14818
  async function addAttachmentToDraft(client, account, draftId, name, contentBytes, contentType) {
14497
14819
  const att = await client.api(`/me/messages/${encodeURIComponent(draftId)}/attachments`).post({
@@ -14744,6 +15066,9 @@ var OutlookProvider = class {
14744
15066
  async moveEmail(account, id, destinationId) {
14745
15067
  return moveEmail(this.clients.get(account), account, id, destinationId);
14746
15068
  }
15069
+ async trashEmail(account, id) {
15070
+ return moveEmail(this.clients.get(account), account, id, "deleteditems");
15071
+ }
14747
15072
  async sendDraft(account, id) {
14748
15073
  return sendDraft(this.clients.get(account), account, id);
14749
15074
  }
@@ -14910,6 +15235,21 @@ var WELL_KNOWN_TO_IMAP = {
14910
15235
  function resolveFolder(wellKnownOrPath) {
14911
15236
  return WELL_KNOWN_TO_IMAP[wellKnownOrPath.toLowerCase()] ?? wellKnownOrPath;
14912
15237
  }
15238
+ function isTrashFolderAlias(wellKnownOrPath) {
15239
+ const lower = wellKnownOrPath.toLowerCase();
15240
+ return lower === "deleteditems" || lower === "trash";
15241
+ }
15242
+ function resolveTrashMailbox(mailboxes) {
15243
+ for (const mailbox of mailboxes) {
15244
+ const specialUse = mailbox.specialUse?.toLowerCase();
15245
+ if (specialUse === "\\trash") return mailbox.path;
15246
+ const flags = mailbox.flags ? Array.from(mailbox.flags) : [];
15247
+ if (flags.some((flag) => flag.toLowerCase() === "\\trash")) {
15248
+ return mailbox.path;
15249
+ }
15250
+ }
15251
+ return "Trash";
15252
+ }
14913
15253
  function clampLimit2(v, dflt, max) {
14914
15254
  if (!v || v <= 0) return dflt;
14915
15255
  return Math.min(v, max);
@@ -15380,6 +15720,9 @@ async function updateDraft2(clients, account, id, update) {
15380
15720
  });
15381
15721
  }
15382
15722
  async function moveEmail2(clients, account, id, destinationId) {
15723
+ if (isTrashFolderAlias(destinationId)) {
15724
+ return trashEmail(clients, account, id);
15725
+ }
15383
15726
  const client = clients.get(account);
15384
15727
  const { folder, uid } = decodeId(id);
15385
15728
  const dest = resolveFolder(destinationId);
@@ -15387,6 +15730,17 @@ async function moveEmail2(clients, account, id, destinationId) {
15387
15730
  await imap.messageMove(uid, dest, { uid: true });
15388
15731
  });
15389
15732
  }
15733
+ async function trashEmail(clients, account, id) {
15734
+ const client = clients.get(account);
15735
+ const { folder, uid } = decodeId(id);
15736
+ const imap = await client.getImap();
15737
+ const dest = resolveTrashMailbox(
15738
+ await imap.list()
15739
+ );
15740
+ return client.withMailbox(folder, async (lockedImap) => {
15741
+ await lockedImap.messageMove(uid, dest, { uid: true });
15742
+ });
15743
+ }
15390
15744
  async function sendDraft2(clients, account, id) {
15391
15745
  const client = clients.get(account);
15392
15746
  const { folder, uid } = decodeId(id);
@@ -15599,6 +15953,9 @@ var ImapProvider = class {
15599
15953
  async moveEmail(account, id, destinationId) {
15600
15954
  return moveEmail2(this.clients, account, id, destinationId);
15601
15955
  }
15956
+ async trashEmail(account, id) {
15957
+ return trashEmail(this.clients, account, id);
15958
+ }
15602
15959
  async sendDraft(account, id) {
15603
15960
  return sendDraft2(this.clients, account, id);
15604
15961
  }
@@ -16355,7 +16712,14 @@ async function updateDraft3(clients, account, id, update) {
16355
16712
  });
16356
16713
  return { id: updated.data.message?.id ?? updated.data.id ?? id };
16357
16714
  }
16715
+ function isTrashDestination(destinationId) {
16716
+ const lower = destinationId.toLowerCase();
16717
+ return lower === "deleteditems" || lower === "trash";
16718
+ }
16358
16719
  async function moveEmail3(clients, account, id, destinationId) {
16720
+ if (isTrashDestination(destinationId)) {
16721
+ return trashEmail2(clients, account, id);
16722
+ }
16359
16723
  const { gmail } = clients.get(account);
16360
16724
  const { addLabelIds, removeLabelIds } = resolveLabelsForMove(destinationId);
16361
16725
  await gmail.users.messages.modify({
@@ -16364,6 +16728,13 @@ async function moveEmail3(clients, account, id, destinationId) {
16364
16728
  requestBody: { addLabelIds, removeLabelIds }
16365
16729
  });
16366
16730
  }
16731
+ async function trashEmail2(clients, account, id) {
16732
+ const { gmail } = clients.get(account);
16733
+ await gmail.users.messages.trash({
16734
+ userId: "me",
16735
+ id
16736
+ });
16737
+ }
16367
16738
  async function sendDraft3(clients, account, id) {
16368
16739
  const { gmail } = clients.get(account);
16369
16740
  const res = await gmail.users.drafts.send({
@@ -16685,6 +17056,9 @@ var GmailProvider = class {
16685
17056
  async moveEmail(account, id, destinationId) {
16686
17057
  return moveEmail3(this.clients, account, id, destinationId);
16687
17058
  }
17059
+ async trashEmail(account, id) {
17060
+ return trashEmail2(this.clients, account, id);
17061
+ }
16688
17062
  async sendDraft(account, id) {
16689
17063
  return sendDraft3(this.clients, account, id);
16690
17064
  }
@@ -17150,7 +17524,7 @@ var BODY_LIMIT = 2e4;
17150
17524
  var PAGE_SIZE = 100;
17151
17525
  var MISSING_RECEIVED_AT = "1970-01-01T00:00:00.000Z";
17152
17526
  function registerNewEmailTool(server, ctx) {
17153
- const { store, registry, tools } = ctx;
17527
+ const { store, registry, tools, logger = noopLogger } = ctx;
17154
17528
  if (!shouldRegister("get_new_emails", tools)) return;
17155
17529
  const newEmailOutputSchema = z4.object({
17156
17530
  account: z4.string(),
@@ -17192,20 +17566,46 @@ function registerNewEmailTool(server, ctx) {
17192
17566
  },
17193
17567
  async (args) => {
17194
17568
  const limit = args.limit ?? DEFAULT_LIMIT;
17569
+ logger.debug("get-new-emails", "start", {
17570
+ account: args.account ?? null,
17571
+ limit
17572
+ });
17195
17573
  if (args.account) {
17196
17574
  try {
17197
17575
  const { provider, account } = registry.resolveByEmail(args.account);
17198
- const result = await collectCandidatesForAccount(store, provider, account);
17576
+ const result = await collectCandidatesForAccount(store, provider, account, logger);
17199
17577
  const selected2 = oldestCandidatesFirst(result.candidates).slice(0, limit);
17200
- 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);
17201
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
+ });
17202
17593
  return ok(data2, data2);
17203
17594
  } catch (err) {
17595
+ logger.debug("get-new-emails", "error", {
17596
+ account: args.account,
17597
+ message: errMsg(err)
17598
+ });
17204
17599
  return fail(errMsg(err));
17205
17600
  }
17206
17601
  }
17207
17602
  const accounts = store.listAccounts();
17208
17603
  if (accounts.length === 0) {
17604
+ logger.debug("get-new-emails", "end", {
17605
+ accountCount: 0,
17606
+ returnedCount: 0,
17607
+ errorCount: 1
17608
+ });
17209
17609
  return fail("no accounts registered. Call add_account first.");
17210
17610
  }
17211
17611
  const errors = [];
@@ -17215,15 +17615,27 @@ function registerNewEmailTool(server, ctx) {
17215
17615
  for (const stored of accounts) {
17216
17616
  try {
17217
17617
  const { provider, account } = registry.resolveByEmail(stored.email);
17218
- const result = await collectCandidatesForAccount(store, provider, account);
17618
+ const result = await collectCandidatesForAccount(store, provider, account, logger);
17219
17619
  providersByEmail.set(result.account.email, provider);
17220
17620
  accountsByEmail.set(result.account.email, result.account);
17221
17621
  collected.push(...result.candidates);
17222
17622
  } catch (err) {
17623
+ logger.debug("get-new-emails", "accountError", {
17624
+ account: stored.email,
17625
+ message: errMsg(err)
17626
+ });
17223
17627
  errors.push({ account: stored.email, message: errMsg(err) });
17224
17628
  }
17225
17629
  }
17226
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
+ });
17227
17639
  const emails = [];
17228
17640
  if (limit > 0) {
17229
17641
  const byAccount = /* @__PURE__ */ new Map();
@@ -17242,24 +17654,39 @@ function registerNewEmailTool(server, ctx) {
17242
17654
  store,
17243
17655
  provider,
17244
17656
  account,
17245
- accountCandidates
17657
+ accountCandidates,
17658
+ logger
17246
17659
  )
17247
17660
  );
17248
17661
  } catch (err) {
17662
+ logger.debug("get-new-emails", "accountError", {
17663
+ account: email,
17664
+ message: errMsg(err)
17665
+ });
17249
17666
  errors.push({ account: email, message: errMsg(err) });
17250
17667
  }
17251
17668
  }
17252
17669
  }
17253
17670
  const orderedEmails = emails.sort(compareNewEmailOutputOldestFirst);
17254
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
+ });
17255
17677
  return ok(data, data);
17256
17678
  }
17257
17679
  );
17258
17680
  }
17259
- async function collectCandidatesForAccount(store, provider, account) {
17260
- const checkpoint = normalizeCheckpoint(account.newEmailCheckpoint);
17681
+ async function collectCandidatesForAccount(store, provider, account, logger) {
17682
+ const checkpoint = normalizeCheckpoint2(account.newEmailCheckpoint);
17261
17683
  if (!checkpoint) {
17262
- 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
+ });
17263
17690
  return { account, candidates: [] };
17264
17691
  }
17265
17692
  const deliveredAtCheckpoint = new Set(checkpoint.deliveredIdsAtReceivedAt ?? []);
@@ -17275,7 +17702,7 @@ async function collectCandidatesForAccount(store, provider, account) {
17275
17702
  let sawOlderThanCheckpoint = false;
17276
17703
  for (const item of items) {
17277
17704
  const timestamp = effectiveReceivedAt(item.receivedAt);
17278
- const comparison = compareTimestamp(timestamp, checkpoint.receivedAt);
17705
+ const comparison = compareTimestamp2(timestamp, checkpoint.receivedAt);
17279
17706
  if (comparison > 0) {
17280
17707
  candidates.push({ account, summary: item, timestamp });
17281
17708
  } else if (comparison === 0) {
@@ -17289,9 +17716,18 @@ async function collectCandidatesForAccount(store, provider, account) {
17289
17716
  if (sawOlderThanCheckpoint || !hasMore) break;
17290
17717
  skip += items.length;
17291
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
+ });
17292
17728
  return { account, candidates };
17293
17729
  }
17294
- async function initializeCheckpoint(store, provider, account) {
17730
+ async function initializeCheckpoint(store, provider, account, logger) {
17295
17731
  const { items } = await provider.listEmails(account, {
17296
17732
  folder: "inbox",
17297
17733
  limit: PAGE_SIZE
@@ -17303,16 +17739,50 @@ async function initializeCheckpoint(store, provider, account) {
17303
17739
  receivedAt,
17304
17740
  deliveredIdsAtReceivedAt
17305
17741
  });
17742
+ logger.debug("get-new-emails", "checkpointInitialized", {
17743
+ account: account.email,
17744
+ receivedAt,
17745
+ deliveredIdCount: deliveredIdsAtReceivedAt.length
17746
+ });
17306
17747
  }
17307
- async function hydrateAndAdvance(store, provider, account, selected) {
17308
- if (selected.length === 0) return [];
17309
- const emails = [];
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
+ }
17758
+ const hydrated = [];
17310
17759
  for (const candidate of selected) {
17311
17760
  const full = await provider.readEmail(account, candidate.summary.id);
17312
- emails.push(formatNewEmail(account.email, full, candidate.summary));
17761
+ hydrated.push({
17762
+ candidate,
17763
+ email: formatNewEmail(account.email, full, candidate.summary),
17764
+ fullId: full.id
17765
+ });
17313
17766
  }
17314
- await advanceCheckpoint(store, account, selected);
17315
- return emails;
17767
+ const claims = hydrated.map(({ candidate, fullId }) => ({
17768
+ summaryId: candidate.summary.id,
17769
+ receivedAt: candidate.timestamp,
17770
+ ids: [candidate.summary.id, fullId]
17771
+ }));
17772
+ logger.debug("get-new-emails", "hydrated", {
17773
+ account: account.email,
17774
+ selectedCount: selected.length,
17775
+ hydratedCount: hydrated.length
17776
+ });
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
+ });
17785
+ return hydrated.filter(({ candidate }) => claimed.has(candidate.summary.id)).map(({ email }) => email);
17316
17786
  }
17317
17787
  function formatNewEmail(account, msg, summary) {
17318
17788
  const body = selectBody(msg, "markdown");
@@ -17337,19 +17807,8 @@ function formatNewEmail(account, msg, summary) {
17337
17807
  bodyOriginalLength: body.length
17338
17808
  };
17339
17809
  }
17340
- async function advanceCheckpoint(store, account, selected) {
17341
- const ordered = oldestCandidatesFirst(selected);
17342
- const newest = ordered[ordered.length - 1];
17343
- if (!newest) return;
17344
- const newestTimestamp = newest.timestamp;
17345
- const idsAtNewest = ordered.filter((candidate) => candidate.timestamp === newestTimestamp).map((candidate) => candidate.summary.id);
17346
- await store.updateNewEmailCheckpoint(account.email, {
17347
- receivedAt: newestTimestamp,
17348
- deliveredIdsAtReceivedAt: idsAtNewest
17349
- });
17350
- }
17351
- function normalizeCheckpoint(checkpoint) {
17352
- const receivedAt = normalizeTimestamp(checkpoint?.receivedAt);
17810
+ function normalizeCheckpoint2(checkpoint) {
17811
+ const receivedAt = normalizeTimestamp2(checkpoint?.receivedAt);
17353
17812
  if (!receivedAt) return null;
17354
17813
  return {
17355
17814
  receivedAt,
@@ -17357,9 +17816,9 @@ function normalizeCheckpoint(checkpoint) {
17357
17816
  };
17358
17817
  }
17359
17818
  function effectiveReceivedAt(receivedAt) {
17360
- return normalizeTimestamp(receivedAt) ?? MISSING_RECEIVED_AT;
17819
+ return normalizeTimestamp2(receivedAt) ?? MISSING_RECEIVED_AT;
17361
17820
  }
17362
- function normalizeTimestamp(value) {
17821
+ function normalizeTimestamp2(value) {
17363
17822
  if (!value) return null;
17364
17823
  const ms = Date.parse(value);
17365
17824
  if (!Number.isFinite(ms)) return null;
@@ -17367,20 +17826,20 @@ function normalizeTimestamp(value) {
17367
17826
  }
17368
17827
  function oldestCandidatesFirst(items) {
17369
17828
  return [...items].sort((a, b) => {
17370
- const byTimestamp = compareTimestamp(a.timestamp, b.timestamp);
17829
+ const byTimestamp = compareTimestamp2(a.timestamp, b.timestamp);
17371
17830
  if (byTimestamp !== 0) return byTimestamp;
17372
17831
  return a.summary.id.localeCompare(b.summary.id);
17373
17832
  });
17374
17833
  }
17375
17834
  function compareNewEmailOutputOldestFirst(a, b) {
17376
- const byTimestamp = compareTimestamp(
17835
+ const byTimestamp = compareTimestamp2(
17377
17836
  effectiveReceivedAt(a.receivedAt),
17378
17837
  effectiveReceivedAt(b.receivedAt)
17379
17838
  );
17380
17839
  if (byTimestamp !== 0) return byTimestamp;
17381
17840
  return a.id.localeCompare(b.id);
17382
17841
  }
17383
- function compareTimestamp(a, b) {
17842
+ function compareTimestamp2(a, b) {
17384
17843
  if (a < b) return -1;
17385
17844
  if (a > b) return 1;
17386
17845
  return 0;
@@ -17388,7 +17847,7 @@ function compareTimestamp(a, b) {
17388
17847
 
17389
17848
  // src/tools/browse.ts
17390
17849
  function registerBrowseTools(server, ctx) {
17391
- const { store, registry, tools } = ctx;
17850
+ const { store, registry, tools, logger } = ctx;
17392
17851
  const emailListOutputSchema = z5.object({
17393
17852
  account: z5.string(),
17394
17853
  count: z5.number(),
@@ -17438,7 +17897,7 @@ function registerBrowseTools(server, ctx) {
17438
17897
  }
17439
17898
  );
17440
17899
  }
17441
- registerNewEmailTool(server, { store, registry, tools });
17900
+ registerNewEmailTool(server, { store, registry, tools, logger });
17442
17901
  if (shouldRegister("search_emails", tools)) {
17443
17902
  server.registerTool(
17444
17903
  "search_emails",
@@ -17713,6 +18172,12 @@ function registerOrganizeTools(server, ctx) {
17713
18172
  const data = { marked: true, id: args.id, isRead };
17714
18173
  return ok(data, data);
17715
18174
  }
18175
+ async function trashMessage(args) {
18176
+ const { provider, account } = registry.resolveByEmail(args.account);
18177
+ await provider.trashEmail(account, args.id);
18178
+ const data = { trashed: true, id: args.id };
18179
+ return ok(data, data);
18180
+ }
17716
18181
  const archiveMoveSchema = z7.object({
17717
18182
  account: z7.string().email(),
17718
18183
  id: z7.string().min(1).describe("Message ID to move")
@@ -17752,7 +18217,7 @@ function registerOrganizeTools(server, ctx) {
17752
18217
  },
17753
18218
  async (args) => {
17754
18219
  try {
17755
- return await moveToWellKnown(args, "deleteditems", "trashed");
18220
+ return await trashMessage(args);
17756
18221
  } catch (err) {
17757
18222
  return fail(errMsg(err));
17758
18223
  }
@@ -17840,7 +18305,7 @@ function registerOrganizeTools(server, ctx) {
17840
18305
  }
17841
18306
 
17842
18307
  // src/tools/compose.ts
17843
- import { z as z8 } from "zod";
18308
+ import { z as z9 } from "zod";
17844
18309
  import { readFileSync } from "fs";
17845
18310
  import { basename, extname } from "path";
17846
18311
 
@@ -17871,42 +18336,106 @@ var MIME_TYPES = {
17871
18336
  ".mp4": "video/mp4"
17872
18337
  };
17873
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
+
17874
18436
  // src/tools/compose.ts
17875
18437
  function registerComposeTools(server, ctx) {
17876
18438
  const { store, registry, tools } = ctx;
17877
- const sendEmailSchema = z8.object({
17878
- account: z8.string().email(),
17879
- to: z8.array(emailAddrSchema).min(1),
17880
- cc: z8.array(emailAddrSchema).optional(),
17881
- bcc: z8.array(emailAddrSchema).optional(),
17882
- subject: z8.string(),
17883
- body: z8.string(),
17884
- format: z8.enum(["html", "markdown"]).describe(
17885
- "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."
17886
- ),
17887
- include_signature: z8.boolean().describe(
17888
- "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."
17889
- ),
17890
- inReplyTo: z8.union([z8.string(), z8.literal(false)]).describe(
17891
- "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)."
17892
- ),
17893
- replyAll: z8.boolean().default(false).optional().describe(
17894
- "When true and `inReplyTo` is set, reply to all recipients instead of just the sender."
17895
- ),
17896
- forwardMessageId: z8.string().optional().describe(
17897
- "Message ID to forward. When set, sends as a forward of the specified message, preserving the original content. Mutually exclusive with `inReplyTo`."
17898
- ),
17899
- attachments: z8.array(
17900
- z8.object({
17901
- filePath: z8.string().min(1).describe("Absolute path to a local file"),
17902
- name: z8.string().optional().describe(
17903
- "Attachment filename. Defaults to the file's basename."
17904
- )
17905
- })
17906
- ).optional().describe(
17907
- "File attachments to include. The server reads the files from disk and base64-encodes them automatically."
17908
- )
17909
- });
17910
18439
  async function handleSendOrDraft(args, action, resultKey, toolName) {
17911
18440
  try {
17912
18441
  const { provider, account } = registry.resolveByEmail(args.account);
@@ -17962,8 +18491,8 @@ function registerComposeTools(server, ctx) {
17962
18491
  }
17963
18492
  }
17964
18493
  const sendEmailOutputSchema = {
17965
- sent: z8.literal(true),
17966
- id: z8.string()
18494
+ sent: z9.literal(true),
18495
+ id: z9.string()
17967
18496
  };
17968
18497
  if (shouldRegister("send_email", tools)) {
17969
18498
  server.registerTool(
@@ -17982,9 +18511,9 @@ function registerComposeTools(server, ctx) {
17982
18511
  );
17983
18512
  }
17984
18513
  const draftEmailOutputSchema = {
17985
- draft: z8.literal(true),
17986
- id: z8.string(),
17987
- draftHtml: z8.string().optional()
18514
+ draft: z9.literal(true),
18515
+ id: z9.string(),
18516
+ draftHtml: z9.string().optional()
17988
18517
  };
17989
18518
  if (shouldRegister("draft_email", tools)) {
17990
18519
  server.registerTool(
@@ -18002,46 +18531,10 @@ function registerComposeTools(server, ctx) {
18002
18531
  )
18003
18532
  );
18004
18533
  }
18005
- const editDraftSchema = z8.object({
18006
- account: z8.string().email(),
18007
- id: z8.string().min(1).describe("Draft message ID to edit"),
18008
- to: z8.array(emailAddrSchema).optional(),
18009
- cc: z8.array(emailAddrSchema).optional(),
18010
- bcc: z8.array(emailAddrSchema).optional(),
18011
- subject: z8.string().optional(),
18012
- old_text: z8.string().min(1).optional().describe(
18013
- "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."
18014
- ),
18015
- new_text: z8.string().optional().describe(
18016
- "Replacement content for `old_text`. The replacement is composed using `format` and `include_signature`, then inserted exactly where `old_text` matched."
18017
- ),
18018
- body: z8.string().optional().describe(
18019
- "Deprecated alias for `new_text`. Body-only full replacement is not supported; provide `old_text` with this field."
18020
- ),
18021
- format: z8.enum(["html", "markdown"]).optional().describe(
18022
- "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."
18023
- ),
18024
- include_signature: z8.boolean().optional().describe(
18025
- "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."
18026
- ),
18027
- new_attachments: z8.array(
18028
- z8.object({
18029
- filePath: z8.string().min(1).describe("Absolute path to a local file"),
18030
- name: z8.string().optional().describe(
18031
- "Attachment filename. Defaults to the file's basename."
18032
- )
18033
- })
18034
- ).optional().describe(
18035
- "New file attachments to add to the draft. The server reads the files from disk and base64-encodes them automatically."
18036
- ),
18037
- remove_attachments: z8.array(z8.string().min(1)).optional().describe(
18038
- "Attachment IDs to remove from the draft. Get attachment IDs from read_email."
18039
- )
18040
- });
18041
18534
  const editDraftOutputSchema = {
18042
- edited: z8.literal(true),
18043
- id: z8.string(),
18044
- draftHtml: z8.string().optional()
18535
+ edited: z9.literal(true),
18536
+ id: z9.string(),
18537
+ draftHtml: z9.string().optional()
18045
18538
  };
18046
18539
  if (shouldRegister("edit_draft", tools)) {
18047
18540
  server.registerTool(
@@ -18080,6 +18573,7 @@ function registerComposeTools(server, ctx) {
18080
18573
  }
18081
18574
  let bodyPayload;
18082
18575
  let isHtmlPayload;
18576
+ let bodyExpectation;
18083
18577
  if (replacementText !== void 0) {
18084
18578
  const existing = await provider.readEmail(account, a.id);
18085
18579
  const existingBody = existing.bodyHtml ?? existing.bodyText ?? "";
@@ -18092,6 +18586,11 @@ function registerComposeTools(server, ctx) {
18092
18586
  });
18093
18587
  bodyPayload = applyExactTextEdit(existingBody, a.old_text ?? "", composed.body);
18094
18588
  isHtmlPayload = composed.isHtml;
18589
+ bodyExpectation = {
18590
+ expectedBody: bodyPayload,
18591
+ oldText: a.old_text ?? "",
18592
+ replacementBody: composed.body
18593
+ };
18095
18594
  }
18096
18595
  const hasDraftUpdate = a.to !== void 0 || a.cc !== void 0 || a.bcc !== void 0 || a.subject !== void 0 || bodyPayload !== void 0;
18097
18596
  let currentId = a.id;
@@ -18134,7 +18633,35 @@ function registerComposeTools(server, ctx) {
18134
18633
  removedIds.push(attId);
18135
18634
  }
18136
18635
  }
18137
- 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
+ }
18138
18665
  const result = {
18139
18666
  edited: true,
18140
18667
  id: currentId,
@@ -18148,8 +18675,8 @@ function registerComposeTools(server, ctx) {
18148
18675
  );
18149
18676
  }
18150
18677
  const sendDraftOutputSchema = {
18151
- sent: z8.literal(true),
18152
- id: z8.string()
18678
+ sent: z9.literal(true),
18679
+ id: z9.string()
18153
18680
  };
18154
18681
  if (shouldRegister("send_draft", tools)) {
18155
18682
  server.registerTool(
@@ -18157,8 +18684,8 @@ function registerComposeTools(server, ctx) {
18157
18684
  {
18158
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.",
18159
18686
  inputSchema: {
18160
- account: z8.string().email(),
18161
- 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")
18162
18689
  },
18163
18690
  outputSchema: sendDraftOutputSchema
18164
18691
  },
@@ -18178,9 +18705,9 @@ function registerComposeTools(server, ctx) {
18178
18705
 
18179
18706
  // src/tools/index.ts
18180
18707
  function registerTools(server, opts) {
18181
- const { store, registry, tools } = opts;
18708
+ const { store, registry, tools, logger } = opts;
18182
18709
  registerAccountTools(server, { store, registry, tools });
18183
- registerBrowseTools(server, { store, registry, tools });
18710
+ registerBrowseTools(server, { store, registry, tools, logger });
18184
18711
  registerFolderTools(server, { registry, tools });
18185
18712
  registerOrganizeTools(server, { registry, tools });
18186
18713
  registerComposeTools(server, { store, registry, tools });
@@ -18189,7 +18716,7 @@ function registerTools(server, opts) {
18189
18716
  // package.json
18190
18717
  var package_default = {
18191
18718
  name: "hypermail-mcp",
18192
- version: "0.7.12",
18719
+ version: "0.7.14",
18193
18720
  description: "Unified email MCP server \u2014 operate any inbox (Outlook now, IMAP/Gmail later) by passing an email address.",
18194
18721
  type: "module",
18195
18722
  bin: {
@@ -18269,6 +18796,7 @@ var ENV_HTTP_PORT = "HYPERMAIL_HTTP_PORT";
18269
18796
  var ENV_HTTP_HOST = "HYPERMAIL_HTTP_HOST";
18270
18797
  var ENV_TOOLS_DISABLED = "HYPERMAIL_TOOLS_DISABLED";
18271
18798
  var ENV_TOOLS_ENABLED = "HYPERMAIL_TOOLS_ENABLED";
18799
+ var ENV_DEBUG = "HYPERMAIL_DEBUG";
18272
18800
  var ENV_OUTLOOK_CLIENT_ID = "HYPERMAIL_OUTLOOK_CLIENT_ID";
18273
18801
  var ENV_OUTLOOK_TENANT_ID = "HYPERMAIL_OUTLOOK_TENANT_ID";
18274
18802
  var ENV_GMAIL_CLIENT_ID = "HYPERMAIL_GMAIL_CLIENT_ID";
@@ -18309,6 +18837,15 @@ function parseStringArray(value) {
18309
18837
  if (trimmed === "") return [];
18310
18838
  return trimmed.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
18311
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
+ }
18312
18849
  function validateToolNames(toolNames, envName) {
18313
18850
  if (!toolNames || toolNames.length === 0) return;
18314
18851
  const known = new Set(KNOWN_TOOLS);
@@ -18390,6 +18927,7 @@ function loadConfig(cliOverrides = {}) {
18390
18927
  const tools = resolveToolsConfig();
18391
18928
  const providers = resolveProvidersConfig();
18392
18929
  const dataDir = cliOverrides.dataDir ?? optionalEnvString(ENV_DATA_DIR);
18930
+ const debugLogging = resolveDebugLogging(warnings);
18393
18931
  if (!optionalEnvString(ENV_KEY)) {
18394
18932
  warnings.push(
18395
18933
  `${ENV_KEY} is not set; a local generated key will be used. Set ${ENV_KEY} explicitly for portable hosted deployments.`
@@ -18401,7 +18939,8 @@ function loadConfig(cliOverrides = {}) {
18401
18939
  transport,
18402
18940
  http,
18403
18941
  tools,
18404
- providers
18942
+ providers,
18943
+ debugLogging
18405
18944
  },
18406
18945
  warnings
18407
18946
  };
@@ -18447,7 +18986,16 @@ function resolveTools(config) {
18447
18986
  // src/server.ts
18448
18987
  async function startServer(opts) {
18449
18988
  const { config } = opts;
18450
- 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 });
18451
18999
  const registry = buildRegistry({ store, providers: config.providers });
18452
19000
  const tools = resolveTools(config);
18453
19001
  const createServer = () => {
@@ -18455,7 +19003,7 @@ async function startServer(opts) {
18455
19003
  { name: "hypermail-mcp", version: VERSION },
18456
19004
  { capabilities: { tools: {}, logging: {} } }
18457
19005
  );
18458
- registerTools(s, { store, registry, tools });
19006
+ registerTools(s, { store, registry, tools, logger });
18459
19007
  return s;
18460
19008
  };
18461
19009
  if (config.transport === "http") {
@@ -18646,6 +19194,7 @@ Core environment variables:
18646
19194
  HYPERMAIL_HTTP_HOST
18647
19195
  HYPERMAIL_TOOLS_ENABLED
18648
19196
  HYPERMAIL_TOOLS_DISABLED
19197
+ HYPERMAIL_DEBUG=1|true|yes|on|debug
18649
19198
 
18650
19199
  Provider environment variables:
18651
19200
  HYPERMAIL_OUTLOOK_CLIENT_ID