hypermail-mcp 0.7.13 → 0.7.15

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({
@@ -14950,6 +15127,7 @@ var ImapClient = class {
14950
15127
  imap = null;
14951
15128
  transporter = null;
14952
15129
  connecting = null;
15130
+ imapQueue = Promise.resolve();
14953
15131
  /** Get (or create) the ImapFlow instance. */
14954
15132
  async getImap() {
14955
15133
  if (this.imap) return this.imap;
@@ -14987,18 +15165,31 @@ var ImapClient = class {
14987
15165
  });
14988
15166
  return this.transporter;
14989
15167
  }
15168
+ /** Run an IMAP operation serially on this account's shared connection. */
15169
+ async run(fn) {
15170
+ const run = this.imapQueue.catch(() => void 0).then(async () => {
15171
+ const imap = await this.getImap();
15172
+ return fn(imap);
15173
+ });
15174
+ this.imapQueue = run.then(
15175
+ () => void 0,
15176
+ () => void 0
15177
+ );
15178
+ return run;
15179
+ }
14990
15180
  /**
14991
15181
  * Acquire a mailbox lock and run `fn` with the mailbox selected.
14992
15182
  * Releases the lock automatically after `fn` completes.
14993
15183
  */
14994
15184
  async withMailbox(mailbox, fn) {
14995
- const imap = await this.getImap();
14996
- const lock = await imap.getMailboxLock(mailbox);
14997
- try {
14998
- return await fn(imap);
14999
- } finally {
15000
- lock.release();
15001
- }
15185
+ return this.run(async (imap) => {
15186
+ const lock = await imap.getMailboxLock(mailbox);
15187
+ try {
15188
+ return await fn(imap);
15189
+ } finally {
15190
+ lock.release();
15191
+ }
15192
+ });
15002
15193
  }
15003
15194
  /** Disconnect IMAP and close the SMTP pool. */
15004
15195
  async disconnect() {
@@ -15296,10 +15487,11 @@ async function readAttachment2(clients, account, messageId, attachmentId) {
15296
15487
  }
15297
15488
  async function listFolders2(clients, account, opts) {
15298
15489
  const client = clients.get(account);
15299
- const imap = await client.getImap();
15300
- const mailboxes = await imap.list({
15301
- statusQuery: { messages: true, unseen: true, uidNext: true }
15302
- });
15490
+ const mailboxes = await client.run(
15491
+ (imap) => imap.list({
15492
+ statusQuery: { messages: true, unseen: true, uidNext: true }
15493
+ })
15494
+ );
15303
15495
  let results = mailboxes.map(mapMailboxToListEntry);
15304
15496
  if (opts.parentFolderId) {
15305
15497
  const parentPath = opts.parentFolderId;
@@ -15438,59 +15630,11 @@ function removeMimePart(source, targetFilename, targetContentType) {
15438
15630
  async function sendEmail(clients, account, msg) {
15439
15631
  const client = clients.get(account);
15440
15632
  const transporter = client.getTransporter();
15441
- const mailOptions = {
15442
- from: `${account.displayName ?? ""} <${account.email}>`,
15443
- to: msg.to.map((a) => a.name ? `"${a.name}" <${a.address}>` : a.address).join(", "),
15444
- subject: msg.subject
15445
- };
15446
- if (msg.isHtml) {
15447
- mailOptions.html = msg.body;
15448
- } else {
15449
- mailOptions.text = msg.body;
15450
- }
15451
- mailOptions.attachDataUrls = true;
15452
- if (msg.cc && msg.cc.length > 0) {
15453
- mailOptions.cc = msg.cc.map((a) => a.name ? `"${a.name}" <${a.address}>` : a.address).join(", ");
15454
- }
15455
- if (msg.bcc && msg.bcc.length > 0) {
15456
- mailOptions.bcc = msg.bcc.map((a) => a.name ? `"${a.name}" <${a.address}>` : a.address).join(", ");
15457
- }
15458
- if (msg.inReplyTo || msg.forwardMessageId) {
15459
- const refId = msg.inReplyTo ?? msg.forwardMessageId;
15460
- if (refId) {
15461
- try {
15462
- const { folder: refFolder, uid: refUid } = decodeId(refId);
15463
- const refMsg = await client.withMailbox(refFolder, async (imap) => {
15464
- return imap.fetchOne(
15465
- refUid,
15466
- { envelope: true, source: true },
15467
- { uid: true }
15468
- );
15469
- });
15470
- if (refMsg?.envelope) {
15471
- const env = refMsg.envelope;
15472
- if (msg.inReplyTo && env.messageId && !msg.forwardMessageId) {
15473
- mailOptions.inReplyTo = env.messageId;
15474
- mailOptions.references = env.messageId;
15475
- }
15476
- }
15477
- if (msg.forwardMessageId && refMsg?.source) {
15478
- const sourceStr = typeof refMsg.source === "string" ? refMsg.source : Buffer.from(refMsg.source).toString("utf-8");
15479
- const divider = '\n\n<div style="line-height:12px"><br></div>\n\n<div style="border-left:2px solid #ccc; padding-left:8px; margin-left:0; color:#666">\n---------- Forwarded message ---------<br>' + sourceStr + "\n</div>";
15480
- if (mailOptions.html) {
15481
- mailOptions.html += divider;
15482
- } else if (mailOptions.text) {
15483
- mailOptions.text += "\n\n---------- Forwarded message ---------\n" + sourceStr;
15484
- }
15485
- }
15486
- } catch {
15487
- }
15488
- }
15489
- }
15633
+ const mailOptions = await buildMailOptions(client, account, msg);
15490
15634
  const info = await transporter.sendMail(mailOptions);
15491
15635
  try {
15492
- const rawMsg = await buildRawMessage(account, msg, info.messageId);
15493
- await client.withMailbox("Sent", async (imap) => {
15636
+ const rawMsg = await buildRawMessage(client, account, msg, info.messageId);
15637
+ await client.run(async (imap) => {
15494
15638
  await imap.append("Sent", rawMsg, ["\\Seen"]);
15495
15639
  });
15496
15640
  } catch {
@@ -15499,48 +15643,55 @@ async function sendEmail(clients, account, msg) {
15499
15643
  }
15500
15644
  async function saveDraft(clients, account, msg) {
15501
15645
  const client = clients.get(account);
15502
- const rawMsg = await buildRawMessage(account, msg);
15503
- return client.withMailbox("Drafts", async (imap) => {
15504
- const result = await imap.append("Drafts", rawMsg, ["\\Draft"]);
15505
- return { id: encodeId("Drafts", result.uid) };
15506
- });
15646
+ const folder = "Drafts";
15647
+ const rawMsg = await buildRawMessage(client, account, msg);
15648
+ try {
15649
+ const result = await client.run((imap) => appendDraft(imap, folder, rawMsg));
15650
+ return { id: encodeId(folder, appendUid(result, folder)) };
15651
+ } catch (err) {
15652
+ throw imapOperationError(`failed to save IMAP draft to ${folder}`, err);
15653
+ }
15507
15654
  }
15508
15655
  async function updateDraft2(clients, account, id, update) {
15509
15656
  const client = clients.get(account);
15510
15657
  const { folder, uid } = decodeId(id);
15511
- return client.withMailbox(folder, async (imap) => {
15512
- const existing = await imap.fetchOne(
15513
- uid,
15514
- { source: true, envelope: true },
15515
- { uid: true }
15516
- );
15517
- if (!existing?.source) {
15518
- throw new Error(`draft not found: ${id}`);
15519
- }
15520
- const origSubject = existing.envelope ? existing.envelope.subject ?? "" : "";
15521
- const updatedMsg = {
15522
- from: `${account.displayName ?? ""} <${account.email}>`,
15523
- subject: update.subject ?? origSubject,
15524
- attachDataUrls: true
15525
- };
15526
- if (update.body !== void 0) {
15527
- if (update.isHtml) {
15528
- updatedMsg.html = update.body;
15529
- } else {
15530
- updatedMsg.text = update.body;
15658
+ try {
15659
+ return await client.withMailbox(folder, async (imap) => {
15660
+ const existing = await imap.fetchOne(
15661
+ uid,
15662
+ { source: true, envelope: true },
15663
+ { uid: true }
15664
+ );
15665
+ if (!existing?.source) {
15666
+ throw new Error(`draft not found: ${id}`);
15667
+ }
15668
+ const origSubject = existing.envelope ? existing.envelope.subject ?? "" : "";
15669
+ const updatedMsg = {
15670
+ from: `${account.displayName ?? ""} <${account.email}>`,
15671
+ subject: update.subject ?? origSubject,
15672
+ attachDataUrls: true
15673
+ };
15674
+ if (update.body !== void 0) {
15675
+ if (update.isHtml) {
15676
+ updatedMsg.html = update.body;
15677
+ } else {
15678
+ updatedMsg.text = update.body;
15679
+ }
15531
15680
  }
15532
- }
15533
- const raw = await new Promise((resolve, reject) => {
15534
- const mc = new MailComposer(updatedMsg);
15535
- mc.compile().build((err, buf) => {
15536
- if (err) reject(err);
15537
- else resolve(buf);
15681
+ const raw = await new Promise((resolve, reject) => {
15682
+ const mc = new MailComposer(updatedMsg);
15683
+ mc.compile().build((err, buf) => {
15684
+ if (err) reject(err);
15685
+ else resolve(buf);
15686
+ });
15538
15687
  });
15688
+ const result = await appendDraft(imap, folder, raw.toString("utf-8"));
15689
+ await imap.messageDelete(uid, { uid: true });
15690
+ return { id: encodeId(folder, appendUid(result, folder)) };
15539
15691
  });
15540
- await imap.messageDelete(uid, { uid: true });
15541
- const result = await imap.append(folder, raw, ["\\Draft"]);
15542
- return { id: encodeId(folder, result.uid) };
15543
- });
15692
+ } catch (err) {
15693
+ throw imapOperationError(`failed to update IMAP draft ${id}`, err);
15694
+ }
15544
15695
  }
15545
15696
  async function moveEmail2(clients, account, id, destinationId) {
15546
15697
  if (isTrashFolderAlias(destinationId)) {
@@ -15556,9 +15707,8 @@ async function moveEmail2(clients, account, id, destinationId) {
15556
15707
  async function trashEmail(clients, account, id) {
15557
15708
  const client = clients.get(account);
15558
15709
  const { folder, uid } = decodeId(id);
15559
- const imap = await client.getImap();
15560
- const dest = resolveTrashMailbox(
15561
- await imap.list()
15710
+ const dest = await client.run(
15711
+ async (imap) => resolveTrashMailbox(await imap.list())
15562
15712
  );
15563
15713
  return client.withMailbox(folder, async (lockedImap) => {
15564
15714
  await lockedImap.messageMove(uid, dest, { uid: true });
@@ -15589,43 +15739,47 @@ async function sendDraft2(clients, account, id) {
15589
15739
  async function addAttachmentToDraft2(clients, account, draftId, name, contentBytes, contentType) {
15590
15740
  const client = clients.get(account);
15591
15741
  const { folder, uid } = decodeId(draftId);
15592
- return client.withMailbox(folder, async (imap) => {
15593
- const existing = await imap.fetchOne(
15594
- uid,
15595
- { source: true },
15596
- { uid: true }
15597
- );
15598
- if (!existing?.source) {
15599
- throw new Error(`draft not found: ${draftId}`);
15600
- }
15601
- const sourceStr = typeof existing.source === "string" ? existing.source : Buffer.from(existing.source).toString("utf-8");
15602
- const built = await new Promise((resolve, reject) => {
15603
- const mc = new MailComposer({
15604
- raw: sourceStr,
15605
- attachments: [
15606
- {
15607
- filename: name,
15608
- content: Buffer.from(contentBytes, "base64"),
15609
- contentType: contentType ?? "application/octet-stream"
15610
- }
15611
- ]
15612
- });
15613
- mc.compile().build((err, buf) => {
15614
- if (err) reject(err);
15615
- else resolve(buf);
15742
+ try {
15743
+ return await client.withMailbox(folder, async (imap) => {
15744
+ const existing = await imap.fetchOne(
15745
+ uid,
15746
+ { source: true },
15747
+ { uid: true }
15748
+ );
15749
+ if (!existing?.source) {
15750
+ throw new Error(`draft not found: ${draftId}`);
15751
+ }
15752
+ const sourceStr = typeof existing.source === "string" ? existing.source : Buffer.from(existing.source).toString("utf-8");
15753
+ const built = await new Promise((resolve, reject) => {
15754
+ const mc = new MailComposer({
15755
+ raw: sourceStr,
15756
+ attachments: [
15757
+ {
15758
+ filename: name,
15759
+ content: Buffer.from(contentBytes, "base64"),
15760
+ contentType: contentType ?? "application/octet-stream"
15761
+ }
15762
+ ]
15763
+ });
15764
+ mc.compile().build((err, buf) => {
15765
+ if (err) reject(err);
15766
+ else resolve(buf);
15767
+ });
15616
15768
  });
15769
+ const result = await appendDraft(imap, folder, built.toString("utf-8"));
15770
+ await imap.messageDelete(uid, { uid: true });
15771
+ return {
15772
+ id: encodeId(folder, appendUid(result, folder)),
15773
+ attachment: {
15774
+ id: randomUUID3(),
15775
+ name,
15776
+ contentType: contentType ?? "application/octet-stream"
15777
+ }
15778
+ };
15617
15779
  });
15618
- await imap.messageDelete(uid, { uid: true });
15619
- const result = await imap.append(folder, built, ["\\Draft"]);
15620
- return {
15621
- id: encodeId(folder, result.uid),
15622
- attachment: {
15623
- id: randomUUID3(),
15624
- name,
15625
- contentType: contentType ?? "application/octet-stream"
15626
- }
15627
- };
15628
- });
15780
+ } catch (err) {
15781
+ throw imapOperationError(`failed to add attachment to IMAP draft ${draftId}`, err);
15782
+ }
15629
15783
  }
15630
15784
  async function removeAttachmentFromDraft2(clients, account, draftId, attachmentId) {
15631
15785
  const client = clients.get(account);
@@ -15660,7 +15814,7 @@ async function markRead2(clients, account, id, isRead) {
15660
15814
  }
15661
15815
  });
15662
15816
  }
15663
- async function buildRawMessage(account, msg, messageId) {
15817
+ async function buildMailOptions(client, account, msg, messageId) {
15664
15818
  const mailOptions = {
15665
15819
  from: `${account.displayName ?? ""} <${account.email}>`,
15666
15820
  to: msg.to.map((a) => a.name ? `"${a.name}" <${a.address}>` : a.address).join(", "),
@@ -15679,16 +15833,55 @@ async function buildRawMessage(account, msg, messageId) {
15679
15833
  mailOptions.bcc = msg.bcc.map((a) => a.name ? `"${a.name}" <${a.address}>` : a.address).join(", ");
15680
15834
  }
15681
15835
  if (msg.attachments && msg.attachments.length > 0) {
15682
- const fileAttachments = msg.attachments.map((att) => ({
15836
+ mailOptions.attachments = msg.attachments.map((att) => ({
15683
15837
  filename: att.name,
15684
15838
  content: Buffer.from(att.contentBytes, "base64"),
15685
15839
  contentType: att.contentType
15686
15840
  }));
15687
- mailOptions.attachments = fileAttachments;
15688
15841
  }
15689
15842
  if (messageId) {
15690
15843
  mailOptions.messageId = messageId;
15691
15844
  }
15845
+ await applyReferenceMessage(client, mailOptions, msg);
15846
+ return mailOptions;
15847
+ }
15848
+ async function applyReferenceMessage(client, mailOptions, msg) {
15849
+ if (!msg.inReplyTo && !msg.forwardMessageId) return;
15850
+ const refId = msg.inReplyTo || msg.forwardMessageId;
15851
+ if (!refId) return;
15852
+ try {
15853
+ const { folder: refFolder, uid: refUid } = decodeId(refId);
15854
+ const refMsg = await client.withMailbox(refFolder, async (imap) => {
15855
+ return imap.fetchOne(
15856
+ refUid,
15857
+ { envelope: true, source: true },
15858
+ { uid: true }
15859
+ );
15860
+ });
15861
+ if (refMsg?.envelope) {
15862
+ const env = refMsg.envelope;
15863
+ if (msg.inReplyTo && env.messageId && !msg.forwardMessageId) {
15864
+ mailOptions.inReplyTo = env.messageId;
15865
+ mailOptions.references = env.messageId;
15866
+ }
15867
+ }
15868
+ if (msg.forwardMessageId && refMsg?.source) {
15869
+ const sourceStr = typeof refMsg.source === "string" ? refMsg.source : Buffer.from(refMsg.source).toString("utf-8");
15870
+ const divider = '\n\n<div style="line-height:12px"><br></div>\n\n<div style="border-left:2px solid #ccc; padding-left:8px; margin-left:0; color:#666">\n---------- Forwarded message ---------<br>' + sourceStr + "\n</div>";
15871
+ if (mailOptions.html) {
15872
+ mailOptions.html = `${mailOptions.html}${divider}`;
15873
+ } else if (mailOptions.text) {
15874
+ mailOptions.text = `${mailOptions.text}
15875
+
15876
+ ---------- Forwarded message ---------
15877
+ ${sourceStr}`;
15878
+ }
15879
+ }
15880
+ } catch {
15881
+ }
15882
+ }
15883
+ async function buildRawMessage(client, account, msg, messageId) {
15884
+ const mailOptions = await buildMailOptions(client, account, msg, messageId);
15692
15885
  return new Promise((resolve, reject) => {
15693
15886
  const mc = new MailComposer(mailOptions);
15694
15887
  mc.compile().build((err, buf) => {
@@ -15697,6 +15890,48 @@ async function buildRawMessage(account, msg, messageId) {
15697
15890
  });
15698
15891
  });
15699
15892
  }
15893
+ async function appendDraft(imap, folder, rawMsg) {
15894
+ try {
15895
+ return await imap.append(folder, rawMsg, ["\\Draft"]);
15896
+ } catch (err) {
15897
+ if (!isImapCommandFailure(err)) throw err;
15898
+ return imap.append(folder, rawMsg);
15899
+ }
15900
+ }
15901
+ function appendUid(result, folder) {
15902
+ if (!result || typeof result !== "object") {
15903
+ throw new Error(`IMAP append to ${folder} did not return a UID`);
15904
+ }
15905
+ const uid = Number(result.uid);
15906
+ if (Number.isFinite(uid) && uid > 0) return uid;
15907
+ throw new Error(`IMAP append to ${folder} did not return a UID`);
15908
+ }
15909
+ function isImapCommandFailure(err) {
15910
+ const e = err;
15911
+ return typeof e.responseStatus === "string" || typeof e.message === "string" && e.message.includes("Command failed");
15912
+ }
15913
+ function imapOperationError(message, err) {
15914
+ const detail = formatImapError(err);
15915
+ return new Error(`${message}: ${detail}`, { cause: err });
15916
+ }
15917
+ function formatImapError(err) {
15918
+ const e = err;
15919
+ const parts = [];
15920
+ const message = err instanceof Error ? err.message : String(err);
15921
+ if (message) parts.push(message);
15922
+ for (const key of ["responseStatus", "responseText", "serverResponseCode", "response"]) {
15923
+ const value = e[key];
15924
+ if (value !== void 0 && value !== null) {
15925
+ parts.push(`${key}=${safeErrorValue(value)}`);
15926
+ }
15927
+ }
15928
+ return parts.join("; ");
15929
+ }
15930
+ function safeErrorValue(value) {
15931
+ const raw = typeof value === "string" ? value : JSON.stringify(value);
15932
+ const text = raw ?? String(value);
15933
+ return text.length > 500 ? `${text.slice(0, 500)}\u2026` : text;
15934
+ }
15700
15935
 
15701
15936
  // src/providers/imap/folders.ts
15702
15937
  async function createFolder2(clients, account, input) {
@@ -17013,11 +17248,29 @@ var accountFullOutputSchema = z2.object({
17013
17248
  email: z2.string(),
17014
17249
  provider: providerIdEnum,
17015
17250
  displayName: z2.string().optional(),
17016
- tokens: z2.record(z2.string(), z2.unknown()),
17017
17251
  addedAt: z2.string(),
17018
17252
  signature: z2.string().optional(),
17019
17253
  style: styleOutputSchema.optional()
17020
17254
  });
17255
+ function publicAccount(account) {
17256
+ return {
17257
+ email: account.email,
17258
+ provider: account.provider,
17259
+ displayName: account.displayName,
17260
+ addedAt: account.addedAt,
17261
+ signature: account.signature,
17262
+ style: account.style
17263
+ };
17264
+ }
17265
+ function publicAccountResult(result) {
17266
+ if (typeof result !== "object" || result === null) {
17267
+ return { result };
17268
+ }
17269
+ const record2 = result;
17270
+ const account = record2.account;
17271
+ if (!account) return record2;
17272
+ return { ...record2, account: publicAccount(account) };
17273
+ }
17021
17274
  var emailSummaryOutputSchema = z2.object({
17022
17275
  id: z2.string(),
17023
17276
  subject: z2.string(),
@@ -17043,8 +17296,17 @@ var folderInfoOutputSchema = z2.object({
17043
17296
  totalItemCount: z2.number(),
17044
17297
  unreadItemCount: z2.number()
17045
17298
  });
17299
+ var meaningfulHtmlTagRe = /<\/?(?:p|div|br|blockquote|table|thead|tbody|tfoot|tr|td|th|ul|ol|li|a|span|font|b|strong|em|i|u|img|hr|pre|h[1-6])\b/i;
17300
+ function isSuspiciousPlainTextHtml(body) {
17301
+ return body.trim() !== "" && /\r\n|\r|\n/.test(body) && !meaningfulHtmlTagRe.test(body);
17302
+ }
17046
17303
  function composeBody(input) {
17047
17304
  const { body, format, signature, style, includeSignature } = input;
17305
+ if (format === "html" && isSuspiciousPlainTextHtml(body)) {
17306
+ throw new Error(
17307
+ 'format: "html" requires valid HTML for multiline bodies. Use format: "markdown" for plain text with paragraphs, or add HTML tags such as <p> or <br>.'
17308
+ );
17309
+ }
17048
17310
  const htmlBody = format === "markdown" ? markdownToHtml(body) : body;
17049
17311
  const hasSignature = includeSignature && !!signature;
17050
17312
  const hasStyle = !!(style && (style.fontFamily || style.fontSize || style.fontColor));
@@ -17155,7 +17417,8 @@ function registerAccountTools(server, ctx) {
17155
17417
  email: args.email,
17156
17418
  config: args.config
17157
17419
  });
17158
- return ok(res, res);
17420
+ const data = publicAccountResult(res);
17421
+ return ok(data, data);
17159
17422
  } catch (err) {
17160
17423
  return fail(errMsg(err));
17161
17424
  }
@@ -17200,7 +17463,8 @@ function registerAccountTools(server, ctx) {
17200
17463
  code: args.code,
17201
17464
  state: args.state
17202
17465
  });
17203
- return ok(res, res);
17466
+ const data = publicAccountResult(res);
17467
+ return ok(data, data);
17204
17468
  } catch (err) {
17205
17469
  return fail(errMsg(err));
17206
17470
  }
@@ -17347,7 +17611,7 @@ var BODY_LIMIT = 2e4;
17347
17611
  var PAGE_SIZE = 100;
17348
17612
  var MISSING_RECEIVED_AT = "1970-01-01T00:00:00.000Z";
17349
17613
  function registerNewEmailTool(server, ctx) {
17350
- const { store, registry, tools } = ctx;
17614
+ const { store, registry, tools, logger = noopLogger } = ctx;
17351
17615
  if (!shouldRegister("get_new_emails", tools)) return;
17352
17616
  const newEmailOutputSchema = z4.object({
17353
17617
  account: z4.string(),
@@ -17389,20 +17653,46 @@ function registerNewEmailTool(server, ctx) {
17389
17653
  },
17390
17654
  async (args) => {
17391
17655
  const limit = args.limit ?? DEFAULT_LIMIT;
17656
+ logger.debug("get-new-emails", "start", {
17657
+ account: args.account ?? null,
17658
+ limit
17659
+ });
17392
17660
  if (args.account) {
17393
17661
  try {
17394
17662
  const { provider, account } = registry.resolveByEmail(args.account);
17395
- const result = await collectCandidatesForAccount(store, provider, account);
17663
+ const result = await collectCandidatesForAccount(store, provider, account, logger);
17396
17664
  const selected2 = oldestCandidatesFirst(result.candidates).slice(0, limit);
17397
- const emails2 = limit === 0 ? [] : await hydrateAndAdvance(store, provider, result.account, selected2);
17665
+ logger.debug("get-new-emails", "selected", {
17666
+ account: result.account.email,
17667
+ candidateCount: result.candidates.length,
17668
+ selectedCount: selected2.length,
17669
+ selectedIds: selected2.map((candidate) => candidate.summary.id),
17670
+ selectedReceivedAt: selected2.map((candidate) => candidate.timestamp),
17671
+ limit
17672
+ });
17673
+ const emails2 = limit === 0 ? [] : await hydrateAndAdvance(store, provider, result.account, selected2, logger);
17398
17674
  const data2 = { count: emails2.length, emails: emails2, errors: [] };
17675
+ logger.debug("get-new-emails", "end", {
17676
+ account: result.account.email,
17677
+ returnedCount: emails2.length,
17678
+ errorCount: 0
17679
+ });
17399
17680
  return ok(data2, data2);
17400
17681
  } catch (err) {
17682
+ logger.debug("get-new-emails", "error", {
17683
+ account: args.account,
17684
+ message: errMsg(err)
17685
+ });
17401
17686
  return fail(errMsg(err));
17402
17687
  }
17403
17688
  }
17404
17689
  const accounts = store.listAccounts();
17405
17690
  if (accounts.length === 0) {
17691
+ logger.debug("get-new-emails", "end", {
17692
+ accountCount: 0,
17693
+ returnedCount: 0,
17694
+ errorCount: 1
17695
+ });
17406
17696
  return fail("no accounts registered. Call add_account first.");
17407
17697
  }
17408
17698
  const errors = [];
@@ -17412,15 +17702,27 @@ function registerNewEmailTool(server, ctx) {
17412
17702
  for (const stored of accounts) {
17413
17703
  try {
17414
17704
  const { provider, account } = registry.resolveByEmail(stored.email);
17415
- const result = await collectCandidatesForAccount(store, provider, account);
17705
+ const result = await collectCandidatesForAccount(store, provider, account, logger);
17416
17706
  providersByEmail.set(result.account.email, provider);
17417
17707
  accountsByEmail.set(result.account.email, result.account);
17418
17708
  collected.push(...result.candidates);
17419
17709
  } catch (err) {
17710
+ logger.debug("get-new-emails", "accountError", {
17711
+ account: stored.email,
17712
+ message: errMsg(err)
17713
+ });
17420
17714
  errors.push({ account: stored.email, message: errMsg(err) });
17421
17715
  }
17422
17716
  }
17423
17717
  const selected = oldestCandidatesFirst(collected).slice(0, limit);
17718
+ logger.debug("get-new-emails", "selected", {
17719
+ accountCount: accounts.length,
17720
+ candidateCount: collected.length,
17721
+ selectedCount: selected.length,
17722
+ selectedIds: selected.map((candidate) => candidate.summary.id),
17723
+ selectedReceivedAt: selected.map((candidate) => candidate.timestamp),
17724
+ limit
17725
+ });
17424
17726
  const emails = [];
17425
17727
  if (limit > 0) {
17426
17728
  const byAccount = /* @__PURE__ */ new Map();
@@ -17439,24 +17741,39 @@ function registerNewEmailTool(server, ctx) {
17439
17741
  store,
17440
17742
  provider,
17441
17743
  account,
17442
- accountCandidates
17744
+ accountCandidates,
17745
+ logger
17443
17746
  )
17444
17747
  );
17445
17748
  } catch (err) {
17749
+ logger.debug("get-new-emails", "accountError", {
17750
+ account: email,
17751
+ message: errMsg(err)
17752
+ });
17446
17753
  errors.push({ account: email, message: errMsg(err) });
17447
17754
  }
17448
17755
  }
17449
17756
  }
17450
17757
  const orderedEmails = emails.sort(compareNewEmailOutputOldestFirst);
17451
17758
  const data = { count: orderedEmails.length, emails: orderedEmails, errors };
17759
+ logger.debug("get-new-emails", "end", {
17760
+ accountCount: accounts.length,
17761
+ returnedCount: orderedEmails.length,
17762
+ errorCount: errors.length
17763
+ });
17452
17764
  return ok(data, data);
17453
17765
  }
17454
17766
  );
17455
17767
  }
17456
- async function collectCandidatesForAccount(store, provider, account) {
17768
+ async function collectCandidatesForAccount(store, provider, account, logger) {
17457
17769
  const checkpoint = normalizeCheckpoint2(account.newEmailCheckpoint);
17458
17770
  if (!checkpoint) {
17459
- await initializeCheckpoint(store, provider, account);
17771
+ await initializeCheckpoint(store, provider, account, logger);
17772
+ logger.debug("get-new-emails", "candidatesCollected", {
17773
+ account: account.email,
17774
+ initialized: true,
17775
+ candidateCount: 0
17776
+ });
17460
17777
  return { account, candidates: [] };
17461
17778
  }
17462
17779
  const deliveredAtCheckpoint = new Set(checkpoint.deliveredIdsAtReceivedAt ?? []);
@@ -17486,9 +17803,18 @@ async function collectCandidatesForAccount(store, provider, account) {
17486
17803
  if (sawOlderThanCheckpoint || !hasMore) break;
17487
17804
  skip += items.length;
17488
17805
  }
17806
+ logger.debug("get-new-emails", "candidatesCollected", {
17807
+ account: account.email,
17808
+ initialized: false,
17809
+ checkpointReceivedAt: checkpoint.receivedAt,
17810
+ deliveredIdCount: checkpoint.deliveredIdsAtReceivedAt?.length ?? 0,
17811
+ candidateCount: candidates.length,
17812
+ candidateIds: candidates.map((candidate) => candidate.summary.id),
17813
+ candidateReceivedAt: candidates.map((candidate) => candidate.timestamp)
17814
+ });
17489
17815
  return { account, candidates };
17490
17816
  }
17491
- async function initializeCheckpoint(store, provider, account) {
17817
+ async function initializeCheckpoint(store, provider, account, logger) {
17492
17818
  const { items } = await provider.listEmails(account, {
17493
17819
  folder: "inbox",
17494
17820
  limit: PAGE_SIZE
@@ -17500,9 +17826,22 @@ async function initializeCheckpoint(store, provider, account) {
17500
17826
  receivedAt,
17501
17827
  deliveredIdsAtReceivedAt
17502
17828
  });
17829
+ logger.debug("get-new-emails", "checkpointInitialized", {
17830
+ account: account.email,
17831
+ receivedAt,
17832
+ deliveredIdCount: deliveredIdsAtReceivedAt.length
17833
+ });
17503
17834
  }
17504
- async function hydrateAndAdvance(store, provider, account, selected) {
17505
- if (selected.length === 0) return [];
17835
+ async function hydrateAndAdvance(store, provider, account, selected, logger) {
17836
+ if (selected.length === 0) {
17837
+ logger.debug("get-new-emails", "hydrated", {
17838
+ account: account.email,
17839
+ selectedCount: 0,
17840
+ hydratedCount: 0,
17841
+ claimedCount: 0
17842
+ });
17843
+ return [];
17844
+ }
17506
17845
  const hydrated = [];
17507
17846
  for (const candidate of selected) {
17508
17847
  const full = await provider.readEmail(account, candidate.summary.id);
@@ -17517,7 +17856,19 @@ async function hydrateAndAdvance(store, provider, account, selected) {
17517
17856
  receivedAt: candidate.timestamp,
17518
17857
  ids: [candidate.summary.id, fullId]
17519
17858
  }));
17859
+ logger.debug("get-new-emails", "hydrated", {
17860
+ account: account.email,
17861
+ selectedCount: selected.length,
17862
+ hydratedCount: hydrated.length
17863
+ });
17520
17864
  const claimed = new Set(await store.claimNewEmails(account.email, claims));
17865
+ logger.debug("get-new-emails", "claimed", {
17866
+ account: account.email,
17867
+ claimCount: claims.length,
17868
+ claimedCount: claimed.size,
17869
+ claimIds: claims.map((claim) => claim.summaryId),
17870
+ claimedIds: [...claimed]
17871
+ });
17521
17872
  return hydrated.filter(({ candidate }) => claimed.has(candidate.summary.id)).map(({ email }) => email);
17522
17873
  }
17523
17874
  function formatNewEmail(account, msg, summary) {
@@ -17583,7 +17934,7 @@ function compareTimestamp2(a, b) {
17583
17934
 
17584
17935
  // src/tools/browse.ts
17585
17936
  function registerBrowseTools(server, ctx) {
17586
- const { store, registry, tools } = ctx;
17937
+ const { store, registry, tools, logger } = ctx;
17587
17938
  const emailListOutputSchema = z5.object({
17588
17939
  account: z5.string(),
17589
17940
  count: z5.number(),
@@ -17633,7 +17984,7 @@ function registerBrowseTools(server, ctx) {
17633
17984
  }
17634
17985
  );
17635
17986
  }
17636
- registerNewEmailTool(server, { store, registry, tools });
17987
+ registerNewEmailTool(server, { store, registry, tools, logger });
17637
17988
  if (shouldRegister("search_emails", tools)) {
17638
17989
  server.registerTool(
17639
17990
  "search_emails",
@@ -18041,7 +18392,7 @@ function registerOrganizeTools(server, ctx) {
18041
18392
  }
18042
18393
 
18043
18394
  // src/tools/compose.ts
18044
- import { z as z8 } from "zod";
18395
+ import { z as z9 } from "zod";
18045
18396
  import { readFileSync } from "fs";
18046
18397
  import { basename, extname } from "path";
18047
18398
 
@@ -18072,42 +18423,106 @@ var MIME_TYPES = {
18072
18423
  ".mp4": "video/mp4"
18073
18424
  };
18074
18425
 
18426
+ // src/tools/edit-draft-verify.ts
18427
+ var EDIT_DRAFT_VERIFY_DELAYS_MS = [250, 1e3, 2e3];
18428
+ function normalizeDraftBody2(body) {
18429
+ return body.replace(/\r\n/g, "\n").trim();
18430
+ }
18431
+ function bodyEditPersisted(actualBody, expectation) {
18432
+ const actual = normalizeDraftBody2(actualBody);
18433
+ const expected = normalizeDraftBody2(expectation.expectedBody);
18434
+ if (actual === expected) return true;
18435
+ const oldText = normalizeDraftBody2(expectation.oldText);
18436
+ const replacementBody = normalizeDraftBody2(expectation.replacementBody);
18437
+ if (oldText === replacementBody) {
18438
+ return actual.includes(replacementBody);
18439
+ }
18440
+ return actual.includes(replacementBody) && !actual.includes(oldText);
18441
+ }
18442
+ function delay2(ms) {
18443
+ return new Promise((resolve) => setTimeout(resolve, ms));
18444
+ }
18445
+ async function readDraftWithVerifiedBody(provider, account, id, expectation) {
18446
+ let draft = await provider.readEmail(account, id);
18447
+ for (const delayMs of EDIT_DRAFT_VERIFY_DELAYS_MS) {
18448
+ const body2 = draft.bodyHtml ?? draft.bodyText ?? "";
18449
+ if (bodyEditPersisted(body2, expectation)) return draft;
18450
+ await delay2(delayMs);
18451
+ draft = await provider.readEmail(account, id);
18452
+ }
18453
+ const body = draft.bodyHtml ?? draft.bodyText ?? "";
18454
+ return bodyEditPersisted(body, expectation) ? draft : void 0;
18455
+ }
18456
+
18457
+ // src/tools/compose-schemas.ts
18458
+ import { z as z8 } from "zod";
18459
+ var sendEmailSchema = z8.object({
18460
+ account: z8.string().email(),
18461
+ to: z8.array(emailAddrSchema).min(1),
18462
+ cc: z8.array(emailAddrSchema).optional(),
18463
+ bcc: z8.array(emailAddrSchema).optional(),
18464
+ subject: z8.string(),
18465
+ body: z8.string(),
18466
+ format: z8.enum(["html", "markdown"]).describe(
18467
+ "Body format. 'html' sends the body as-is (must be valid HTML); multiline plain text with format='html' is rejected, so use 'markdown' for paragraphs or add tags like <p>/<br>. 'markdown' converts the body from Markdown to HTML for clean rendering on the recipient side."
18468
+ ),
18469
+ include_signature: z8.boolean().describe(
18470
+ "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."
18471
+ ),
18472
+ inReplyTo: z8.union([z8.string(), z8.literal(false)]).describe(
18473
+ "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)."
18474
+ ),
18475
+ replyAll: z8.boolean().default(false).optional().describe(
18476
+ "When true and `inReplyTo` is set, reply to all recipients instead of just the sender."
18477
+ ),
18478
+ forwardMessageId: z8.string().optional().describe(
18479
+ "Message ID to forward. When set, sends as a forward of the specified message, preserving the original content. Mutually exclusive with `inReplyTo`."
18480
+ ),
18481
+ attachments: z8.array(
18482
+ z8.object({
18483
+ filePath: z8.string().min(1).describe("Absolute path to a local file"),
18484
+ name: z8.string().optional().describe("Attachment filename. Defaults to the file's basename.")
18485
+ })
18486
+ ).optional().describe(
18487
+ "File attachments to include. The server reads the files from disk and base64-encodes them automatically."
18488
+ )
18489
+ });
18490
+ var editDraftSchema = z8.object({
18491
+ account: z8.string().email(),
18492
+ id: z8.string().min(1).describe("Draft message ID to edit"),
18493
+ to: z8.array(emailAddrSchema).optional(),
18494
+ cc: z8.array(emailAddrSchema).optional(),
18495
+ bcc: z8.array(emailAddrSchema).optional(),
18496
+ subject: z8.string().optional(),
18497
+ old_text: z8.string().min(1).optional().describe(
18498
+ "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."
18499
+ ),
18500
+ new_text: z8.string().optional().describe(
18501
+ "Replacement content for `old_text`. The replacement is composed using `format` and `include_signature`, then inserted exactly where `old_text` matched."
18502
+ ),
18503
+ body: z8.string().optional().describe(
18504
+ "Deprecated alias for `new_text`. Body-only full replacement is not supported; provide `old_text` with this field."
18505
+ ),
18506
+ format: z8.enum(["html", "markdown"]).optional().describe(
18507
+ "Replacement format. Only meaningful when `new_text` or deprecated `body` is also provided. 'html' inserts the replacement as-is (must be valid HTML); multiline plain text with format='html' is rejected, so use 'markdown' for paragraphs or add tags like <p>/<br>. 'markdown' converts the replacement from Markdown to HTML."
18508
+ ),
18509
+ include_signature: z8.boolean().optional().describe(
18510
+ "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."
18511
+ ),
18512
+ new_attachments: z8.array(
18513
+ z8.object({
18514
+ filePath: z8.string().min(1).describe("Absolute path to a local file"),
18515
+ name: z8.string().optional().describe("Attachment filename. Defaults to the file's basename.")
18516
+ })
18517
+ ).optional().describe(
18518
+ "New file attachments to add to the draft. The server reads the files from disk and base64-encodes them automatically."
18519
+ ),
18520
+ remove_attachments: z8.array(z8.string().min(1)).optional().describe("Attachment IDs to remove from the draft. Get attachment IDs from read_email.")
18521
+ });
18522
+
18075
18523
  // src/tools/compose.ts
18076
18524
  function registerComposeTools(server, ctx) {
18077
18525
  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
18526
  async function handleSendOrDraft(args, action, resultKey, toolName) {
18112
18527
  try {
18113
18528
  const { provider, account } = registry.resolveByEmail(args.account);
@@ -18163,8 +18578,8 @@ function registerComposeTools(server, ctx) {
18163
18578
  }
18164
18579
  }
18165
18580
  const sendEmailOutputSchema = {
18166
- sent: z8.literal(true),
18167
- id: z8.string()
18581
+ sent: z9.literal(true),
18582
+ id: z9.string()
18168
18583
  };
18169
18584
  if (shouldRegister("send_email", tools)) {
18170
18585
  server.registerTool(
@@ -18183,9 +18598,9 @@ function registerComposeTools(server, ctx) {
18183
18598
  );
18184
18599
  }
18185
18600
  const draftEmailOutputSchema = {
18186
- draft: z8.literal(true),
18187
- id: z8.string(),
18188
- draftHtml: z8.string().optional()
18601
+ draft: z9.literal(true),
18602
+ id: z9.string(),
18603
+ draftHtml: z9.string().optional()
18189
18604
  };
18190
18605
  if (shouldRegister("draft_email", tools)) {
18191
18606
  server.registerTool(
@@ -18203,46 +18618,10 @@ function registerComposeTools(server, ctx) {
18203
18618
  )
18204
18619
  );
18205
18620
  }
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
18621
  const editDraftOutputSchema = {
18243
- edited: z8.literal(true),
18244
- id: z8.string(),
18245
- draftHtml: z8.string().optional()
18622
+ edited: z9.literal(true),
18623
+ id: z9.string(),
18624
+ draftHtml: z9.string().optional()
18246
18625
  };
18247
18626
  if (shouldRegister("edit_draft", tools)) {
18248
18627
  server.registerTool(
@@ -18281,6 +18660,7 @@ function registerComposeTools(server, ctx) {
18281
18660
  }
18282
18661
  let bodyPayload;
18283
18662
  let isHtmlPayload;
18663
+ let bodyExpectation;
18284
18664
  if (replacementText !== void 0) {
18285
18665
  const existing = await provider.readEmail(account, a.id);
18286
18666
  const existingBody = existing.bodyHtml ?? existing.bodyText ?? "";
@@ -18293,6 +18673,11 @@ function registerComposeTools(server, ctx) {
18293
18673
  });
18294
18674
  bodyPayload = applyExactTextEdit(existingBody, a.old_text ?? "", composed.body);
18295
18675
  isHtmlPayload = composed.isHtml;
18676
+ bodyExpectation = {
18677
+ expectedBody: bodyPayload,
18678
+ oldText: a.old_text ?? "",
18679
+ replacementBody: composed.body
18680
+ };
18296
18681
  }
18297
18682
  const hasDraftUpdate = a.to !== void 0 || a.cc !== void 0 || a.bcc !== void 0 || a.subject !== void 0 || bodyPayload !== void 0;
18298
18683
  let currentId = a.id;
@@ -18335,7 +18720,35 @@ function registerComposeTools(server, ctx) {
18335
18720
  removedIds.push(attId);
18336
18721
  }
18337
18722
  }
18338
- const draft = await provider.readEmail(account, currentId);
18723
+ let draft;
18724
+ if (bodyExpectation) {
18725
+ draft = await readDraftWithVerifiedBody(
18726
+ provider,
18727
+ account,
18728
+ currentId,
18729
+ bodyExpectation
18730
+ );
18731
+ if (!draft && provider.id === "outlook" && bodyPayload !== void 0) {
18732
+ const res = await provider.updateDraft(account, currentId, {
18733
+ body: bodyPayload,
18734
+ isHtml: isHtmlPayload
18735
+ });
18736
+ currentId = res.id;
18737
+ draft = await readDraftWithVerifiedBody(
18738
+ provider,
18739
+ account,
18740
+ currentId,
18741
+ bodyExpectation
18742
+ );
18743
+ }
18744
+ if (!draft) {
18745
+ return fail(
18746
+ "Draft body edit was not observable after saving. Retry edit_draft, or recreate the draft with draft_email before sending."
18747
+ );
18748
+ }
18749
+ } else {
18750
+ draft = await provider.readEmail(account, currentId);
18751
+ }
18339
18752
  const result = {
18340
18753
  edited: true,
18341
18754
  id: currentId,
@@ -18349,8 +18762,8 @@ function registerComposeTools(server, ctx) {
18349
18762
  );
18350
18763
  }
18351
18764
  const sendDraftOutputSchema = {
18352
- sent: z8.literal(true),
18353
- id: z8.string()
18765
+ sent: z9.literal(true),
18766
+ id: z9.string()
18354
18767
  };
18355
18768
  if (shouldRegister("send_draft", tools)) {
18356
18769
  server.registerTool(
@@ -18358,8 +18771,8 @@ function registerComposeTools(server, ctx) {
18358
18771
  {
18359
18772
  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
18773
  inputSchema: {
18361
- account: z8.string().email(),
18362
- id: z8.string().min(1).describe("Draft message ID to send")
18774
+ account: z9.string().email(),
18775
+ id: z9.string().min(1).describe("Draft message ID to send")
18363
18776
  },
18364
18777
  outputSchema: sendDraftOutputSchema
18365
18778
  },
@@ -18379,9 +18792,9 @@ function registerComposeTools(server, ctx) {
18379
18792
 
18380
18793
  // src/tools/index.ts
18381
18794
  function registerTools(server, opts) {
18382
- const { store, registry, tools } = opts;
18795
+ const { store, registry, tools, logger } = opts;
18383
18796
  registerAccountTools(server, { store, registry, tools });
18384
- registerBrowseTools(server, { store, registry, tools });
18797
+ registerBrowseTools(server, { store, registry, tools, logger });
18385
18798
  registerFolderTools(server, { registry, tools });
18386
18799
  registerOrganizeTools(server, { registry, tools });
18387
18800
  registerComposeTools(server, { store, registry, tools });
@@ -18390,7 +18803,7 @@ function registerTools(server, opts) {
18390
18803
  // package.json
18391
18804
  var package_default = {
18392
18805
  name: "hypermail-mcp",
18393
- version: "0.7.13",
18806
+ version: "0.7.15",
18394
18807
  description: "Unified email MCP server \u2014 operate any inbox (Outlook now, IMAP/Gmail later) by passing an email address.",
18395
18808
  type: "module",
18396
18809
  bin: {
@@ -18400,7 +18813,8 @@ var package_default = {
18400
18813
  files: [
18401
18814
  "dist",
18402
18815
  "README.md",
18403
- "LICENSE"
18816
+ "LICENSE",
18817
+ "examples"
18404
18818
  ],
18405
18819
  scripts: {
18406
18820
  build: "tsup",
@@ -18470,6 +18884,7 @@ var ENV_HTTP_PORT = "HYPERMAIL_HTTP_PORT";
18470
18884
  var ENV_HTTP_HOST = "HYPERMAIL_HTTP_HOST";
18471
18885
  var ENV_TOOLS_DISABLED = "HYPERMAIL_TOOLS_DISABLED";
18472
18886
  var ENV_TOOLS_ENABLED = "HYPERMAIL_TOOLS_ENABLED";
18887
+ var ENV_DEBUG = "HYPERMAIL_DEBUG";
18473
18888
  var ENV_OUTLOOK_CLIENT_ID = "HYPERMAIL_OUTLOOK_CLIENT_ID";
18474
18889
  var ENV_OUTLOOK_TENANT_ID = "HYPERMAIL_OUTLOOK_TENANT_ID";
18475
18890
  var ENV_GMAIL_CLIENT_ID = "HYPERMAIL_GMAIL_CLIENT_ID";
@@ -18510,6 +18925,15 @@ function parseStringArray(value) {
18510
18925
  if (trimmed === "") return [];
18511
18926
  return trimmed.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
18512
18927
  }
18928
+ function resolveDebugLogging(warnings) {
18929
+ const value = envRaw(ENV_DEBUG);
18930
+ if (value === void 0 || value.trim() === "") return false;
18931
+ const lower = value.trim().toLowerCase();
18932
+ if (["1", "true", "yes", "on", "debug"].includes(lower)) return true;
18933
+ if (["0", "false", "no", "off"].includes(lower)) return false;
18934
+ warnings.push(`Invalid ${ENV_DEBUG}; debug logging disabled.`);
18935
+ return false;
18936
+ }
18513
18937
  function validateToolNames(toolNames, envName) {
18514
18938
  if (!toolNames || toolNames.length === 0) return;
18515
18939
  const known = new Set(KNOWN_TOOLS);
@@ -18591,6 +19015,7 @@ function loadConfig(cliOverrides = {}) {
18591
19015
  const tools = resolveToolsConfig();
18592
19016
  const providers = resolveProvidersConfig();
18593
19017
  const dataDir = cliOverrides.dataDir ?? optionalEnvString(ENV_DATA_DIR);
19018
+ const debugLogging = resolveDebugLogging(warnings);
18594
19019
  if (!optionalEnvString(ENV_KEY)) {
18595
19020
  warnings.push(
18596
19021
  `${ENV_KEY} is not set; a local generated key will be used. Set ${ENV_KEY} explicitly for portable hosted deployments.`
@@ -18602,7 +19027,8 @@ function loadConfig(cliOverrides = {}) {
18602
19027
  transport,
18603
19028
  http,
18604
19029
  tools,
18605
- providers
19030
+ providers,
19031
+ debugLogging
18606
19032
  },
18607
19033
  warnings
18608
19034
  };
@@ -18648,7 +19074,16 @@ function resolveTools(config) {
18648
19074
  // src/server.ts
18649
19075
  async function startServer(opts) {
18650
19076
  const { config } = opts;
18651
- const store = await AccountStore.open({ dataDir: config.dataDir });
19077
+ const logger = createLogger({ enabled: config.debugLogging });
19078
+ const dataDir = resolveDataDir(config.dataDir);
19079
+ logger.debug("server", "startup", {
19080
+ version: VERSION,
19081
+ transport: config.transport,
19082
+ dataDir,
19083
+ toolsEnabled: config.tools?.enabled ?? null,
19084
+ toolsDisabled: config.tools?.disabled ?? null
19085
+ });
19086
+ const store = await AccountStore.open({ dataDir, logger });
18652
19087
  const registry = buildRegistry({ store, providers: config.providers });
18653
19088
  const tools = resolveTools(config);
18654
19089
  const createServer = () => {
@@ -18656,7 +19091,7 @@ async function startServer(opts) {
18656
19091
  { name: "hypermail-mcp", version: VERSION },
18657
19092
  { capabilities: { tools: {}, logging: {} } }
18658
19093
  );
18659
- registerTools(s, { store, registry, tools });
19094
+ registerTools(s, { store, registry, tools, logger });
18660
19095
  return s;
18661
19096
  };
18662
19097
  if (config.transport === "http") {
@@ -18847,6 +19282,7 @@ Core environment variables:
18847
19282
  HYPERMAIL_HTTP_HOST
18848
19283
  HYPERMAIL_TOOLS_ENABLED
18849
19284
  HYPERMAIL_TOOLS_DISABLED
19285
+ HYPERMAIL_DEBUG=1|true|yes|on|debug
18850
19286
 
18851
19287
  Provider environment variables:
18852
19288
  HYPERMAIL_OUTLOOK_CLIENT_ID