@diskd-ai/email-mcp 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/LICENSE +155 -0
  2. package/README.md +820 -0
  3. package/dist/cli/account-commands.d.ts +10 -0
  4. package/dist/cli/account-commands.d.ts.map +1 -0
  5. package/dist/cli/account-commands.js +703 -0
  6. package/dist/cli/account-commands.js.map +1 -0
  7. package/dist/cli/config-commands.d.ts +9 -0
  8. package/dist/cli/config-commands.d.ts.map +1 -0
  9. package/dist/cli/config-commands.js +156 -0
  10. package/dist/cli/config-commands.js.map +1 -0
  11. package/dist/cli/guard.d.ts +11 -0
  12. package/dist/cli/guard.d.ts.map +1 -0
  13. package/dist/cli/guard.js +18 -0
  14. package/dist/cli/guard.js.map +1 -0
  15. package/dist/cli/install-commands.d.ts +12 -0
  16. package/dist/cli/install-commands.d.ts.map +1 -0
  17. package/dist/cli/install-commands.js +320 -0
  18. package/dist/cli/install-commands.js.map +1 -0
  19. package/dist/cli/notify.d.ts +8 -0
  20. package/dist/cli/notify.d.ts.map +1 -0
  21. package/dist/cli/notify.js +143 -0
  22. package/dist/cli/notify.js.map +1 -0
  23. package/dist/cli/providers.d.ts +13 -0
  24. package/dist/cli/providers.d.ts.map +1 -0
  25. package/dist/cli/providers.js +180 -0
  26. package/dist/cli/providers.js.map +1 -0
  27. package/dist/cli/scheduler.d.ts +8 -0
  28. package/dist/cli/scheduler.d.ts.map +1 -0
  29. package/dist/cli/scheduler.js +268 -0
  30. package/dist/cli/scheduler.js.map +1 -0
  31. package/dist/cli/setup.d.ts +12 -0
  32. package/dist/cli/setup.d.ts.map +1 -0
  33. package/dist/cli/setup.js +15 -0
  34. package/dist/cli/setup.js.map +1 -0
  35. package/dist/cli/test.d.ts +7 -0
  36. package/dist/cli/test.d.ts.map +1 -0
  37. package/dist/cli/test.js +67 -0
  38. package/dist/cli/test.js.map +1 -0
  39. package/dist/config/loader.d.ts +34 -0
  40. package/dist/config/loader.d.ts.map +1 -0
  41. package/dist/config/loader.js +339 -0
  42. package/dist/config/loader.js.map +1 -0
  43. package/dist/config/schema.d.ts +351 -0
  44. package/dist/config/schema.d.ts.map +1 -0
  45. package/dist/config/schema.js +165 -0
  46. package/dist/config/schema.js.map +1 -0
  47. package/dist/config/xdg.d.ts +27 -0
  48. package/dist/config/xdg.d.ts.map +1 -0
  49. package/dist/config/xdg.js +30 -0
  50. package/dist/config/xdg.js.map +1 -0
  51. package/dist/connections/manager.d.ts +42 -0
  52. package/dist/connections/manager.d.ts.map +1 -0
  53. package/dist/connections/manager.js +266 -0
  54. package/dist/connections/manager.js.map +1 -0
  55. package/dist/connections/types.d.ts +13 -0
  56. package/dist/connections/types.d.ts.map +1 -0
  57. package/dist/connections/types.js +2 -0
  58. package/dist/connections/types.js.map +1 -0
  59. package/dist/logging.d.ts +46 -0
  60. package/dist/logging.d.ts.map +1 -0
  61. package/dist/logging.js +63 -0
  62. package/dist/logging.js.map +1 -0
  63. package/dist/main.d.ts +13 -0
  64. package/dist/main.d.ts.map +1 -0
  65. package/dist/main.js +206 -0
  66. package/dist/main.js.map +1 -0
  67. package/dist/prompts/actions.prompt.d.ts +9 -0
  68. package/dist/prompts/actions.prompt.d.ts.map +1 -0
  69. package/dist/prompts/actions.prompt.js +64 -0
  70. package/dist/prompts/actions.prompt.js.map +1 -0
  71. package/dist/prompts/calendar.prompt.d.ts +9 -0
  72. package/dist/prompts/calendar.prompt.d.ts.map +1 -0
  73. package/dist/prompts/calendar.prompt.js +55 -0
  74. package/dist/prompts/calendar.prompt.js.map +1 -0
  75. package/dist/prompts/cleanup.prompt.d.ts +9 -0
  76. package/dist/prompts/cleanup.prompt.d.ts.map +1 -0
  77. package/dist/prompts/cleanup.prompt.js +78 -0
  78. package/dist/prompts/cleanup.prompt.js.map +1 -0
  79. package/dist/prompts/compose.prompt.d.ts +8 -0
  80. package/dist/prompts/compose.prompt.d.ts.map +1 -0
  81. package/dist/prompts/compose.prompt.js +116 -0
  82. package/dist/prompts/compose.prompt.js.map +1 -0
  83. package/dist/prompts/register.d.ts +8 -0
  84. package/dist/prompts/register.d.ts.map +1 -0
  85. package/dist/prompts/register.js +20 -0
  86. package/dist/prompts/register.js.map +1 -0
  87. package/dist/prompts/thread.prompt.d.ts +9 -0
  88. package/dist/prompts/thread.prompt.d.ts.map +1 -0
  89. package/dist/prompts/thread.prompt.js +58 -0
  90. package/dist/prompts/thread.prompt.js.map +1 -0
  91. package/dist/prompts/triage.prompt.d.ts +9 -0
  92. package/dist/prompts/triage.prompt.d.ts.map +1 -0
  93. package/dist/prompts/triage.prompt.js +64 -0
  94. package/dist/prompts/triage.prompt.js.map +1 -0
  95. package/dist/resources/accounts.resource.d.ts +9 -0
  96. package/dist/resources/accounts.resource.d.ts.map +1 -0
  97. package/dist/resources/accounts.resource.js +26 -0
  98. package/dist/resources/accounts.resource.js.map +1 -0
  99. package/dist/resources/mailboxes.resource.d.ts +10 -0
  100. package/dist/resources/mailboxes.resource.d.ts.map +1 -0
  101. package/dist/resources/mailboxes.resource.js +33 -0
  102. package/dist/resources/mailboxes.resource.js.map +1 -0
  103. package/dist/resources/register.d.ts +12 -0
  104. package/dist/resources/register.d.ts.map +1 -0
  105. package/dist/resources/register.js +20 -0
  106. package/dist/resources/register.js.map +1 -0
  107. package/dist/resources/scheduled.resource.d.ts +9 -0
  108. package/dist/resources/scheduled.resource.d.ts.map +1 -0
  109. package/dist/resources/scheduled.resource.js +31 -0
  110. package/dist/resources/scheduled.resource.js.map +1 -0
  111. package/dist/resources/stats.resource.d.ts +10 -0
  112. package/dist/resources/stats.resource.d.ts.map +1 -0
  113. package/dist/resources/stats.resource.js +45 -0
  114. package/dist/resources/stats.resource.js.map +1 -0
  115. package/dist/resources/templates.resource.d.ts +9 -0
  116. package/dist/resources/templates.resource.d.ts.map +1 -0
  117. package/dist/resources/templates.resource.js +34 -0
  118. package/dist/resources/templates.resource.js.map +1 -0
  119. package/dist/resources/unread.resource.d.ts +11 -0
  120. package/dist/resources/unread.resource.d.ts.map +1 -0
  121. package/dist/resources/unread.resource.js +46 -0
  122. package/dist/resources/unread.resource.js.map +1 -0
  123. package/dist/safety/audit.d.ts +17 -0
  124. package/dist/safety/audit.d.ts.map +1 -0
  125. package/dist/safety/audit.js +50 -0
  126. package/dist/safety/audit.js.map +1 -0
  127. package/dist/safety/rate-limiter.d.ts +22 -0
  128. package/dist/safety/rate-limiter.d.ts.map +1 -0
  129. package/dist/safety/rate-limiter.js +52 -0
  130. package/dist/safety/rate-limiter.js.map +1 -0
  131. package/dist/safety/validation.d.ts +44 -0
  132. package/dist/safety/validation.d.ts.map +1 -0
  133. package/dist/safety/validation.js +113 -0
  134. package/dist/safety/validation.js.map +1 -0
  135. package/dist/server.d.ts +10 -0
  136. package/dist/server.d.ts.map +1 -0
  137. package/dist/server.js +25 -0
  138. package/dist/server.js.map +1 -0
  139. package/dist/services/calendar.service.d.ts +22 -0
  140. package/dist/services/calendar.service.d.ts.map +1 -0
  141. package/dist/services/calendar.service.js +115 -0
  142. package/dist/services/calendar.service.js.map +1 -0
  143. package/dist/services/event-bus.d.ts +28 -0
  144. package/dist/services/event-bus.d.ts.map +1 -0
  145. package/dist/services/event-bus.js +16 -0
  146. package/dist/services/event-bus.js.map +1 -0
  147. package/dist/services/hooks.service.d.ts +77 -0
  148. package/dist/services/hooks.service.d.ts.map +1 -0
  149. package/dist/services/hooks.service.js +498 -0
  150. package/dist/services/hooks.service.js.map +1 -0
  151. package/dist/services/imap.service.d.ts +133 -0
  152. package/dist/services/imap.service.d.ts.map +1 -0
  153. package/dist/services/imap.service.js +1393 -0
  154. package/dist/services/imap.service.js.map +1 -0
  155. package/dist/services/label-strategy.d.ts +20 -0
  156. package/dist/services/label-strategy.d.ts.map +1 -0
  157. package/dist/services/label-strategy.js +237 -0
  158. package/dist/services/label-strategy.js.map +1 -0
  159. package/dist/services/local-calendar.service.d.ts +126 -0
  160. package/dist/services/local-calendar.service.d.ts.map +1 -0
  161. package/dist/services/local-calendar.service.js +428 -0
  162. package/dist/services/local-calendar.service.js.map +1 -0
  163. package/dist/services/notifier.service.d.ts +69 -0
  164. package/dist/services/notifier.service.d.ts.map +1 -0
  165. package/dist/services/notifier.service.js +319 -0
  166. package/dist/services/notifier.service.js.map +1 -0
  167. package/dist/services/oauth.service.d.ts +47 -0
  168. package/dist/services/oauth.service.d.ts.map +1 -0
  169. package/dist/services/oauth.service.js +140 -0
  170. package/dist/services/oauth.service.js.map +1 -0
  171. package/dist/services/presets.d.ts +36 -0
  172. package/dist/services/presets.d.ts.map +1 -0
  173. package/dist/services/presets.js +173 -0
  174. package/dist/services/presets.js.map +1 -0
  175. package/dist/services/reminders.service.d.ts +63 -0
  176. package/dist/services/reminders.service.d.ts.map +1 -0
  177. package/dist/services/reminders.service.js +281 -0
  178. package/dist/services/reminders.service.js.map +1 -0
  179. package/dist/services/scheduler.service.d.ts +42 -0
  180. package/dist/services/scheduler.service.d.ts.map +1 -0
  181. package/dist/services/scheduler.service.js +260 -0
  182. package/dist/services/scheduler.service.js.map +1 -0
  183. package/dist/services/smtp.service.d.ts +40 -0
  184. package/dist/services/smtp.service.d.ts.map +1 -0
  185. package/dist/services/smtp.service.js +151 -0
  186. package/dist/services/smtp.service.js.map +1 -0
  187. package/dist/services/template.service.d.ts +33 -0
  188. package/dist/services/template.service.d.ts.map +1 -0
  189. package/dist/services/template.service.js +123 -0
  190. package/dist/services/template.service.js.map +1 -0
  191. package/dist/services/watcher.service.d.ts +36 -0
  192. package/dist/services/watcher.service.d.ts.map +1 -0
  193. package/dist/services/watcher.service.js +241 -0
  194. package/dist/services/watcher.service.js.map +1 -0
  195. package/dist/tools/accounts.tool.d.ts +7 -0
  196. package/dist/tools/accounts.tool.d.ts.map +1 -0
  197. package/dist/tools/accounts.tool.js +29 -0
  198. package/dist/tools/accounts.tool.js.map +1 -0
  199. package/dist/tools/analytics.tool.d.ts +9 -0
  200. package/dist/tools/analytics.tool.d.ts.map +1 -0
  201. package/dist/tools/analytics.tool.js +27 -0
  202. package/dist/tools/analytics.tool.js.map +1 -0
  203. package/dist/tools/attachments.tool.d.ts +7 -0
  204. package/dist/tools/attachments.tool.d.ts.map +1 -0
  205. package/dist/tools/attachments.tool.js +45 -0
  206. package/dist/tools/attachments.tool.js.map +1 -0
  207. package/dist/tools/bulk.tool.d.ts +7 -0
  208. package/dist/tools/bulk.tool.d.ts.map +1 -0
  209. package/dist/tools/bulk.tool.js +75 -0
  210. package/dist/tools/bulk.tool.js.map +1 -0
  211. package/dist/tools/calendar.tool.d.ts +19 -0
  212. package/dist/tools/calendar.tool.d.ts.map +1 -0
  213. package/dist/tools/calendar.tool.js +538 -0
  214. package/dist/tools/calendar.tool.js.map +1 -0
  215. package/dist/tools/contacts.tool.d.ts +7 -0
  216. package/dist/tools/contacts.tool.d.ts.map +1 -0
  217. package/dist/tools/contacts.tool.js +44 -0
  218. package/dist/tools/contacts.tool.js.map +1 -0
  219. package/dist/tools/drafts.tool.d.ts +8 -0
  220. package/dist/tools/drafts.tool.d.ts.map +1 -0
  221. package/dist/tools/drafts.tool.js +92 -0
  222. package/dist/tools/drafts.tool.js.map +1 -0
  223. package/dist/tools/emails.tool.d.ts +7 -0
  224. package/dist/tools/emails.tool.d.ts.map +1 -0
  225. package/dist/tools/emails.tool.js +400 -0
  226. package/dist/tools/emails.tool.js.map +1 -0
  227. package/dist/tools/folders.tool.d.ts +7 -0
  228. package/dist/tools/folders.tool.d.ts.map +1 -0
  229. package/dist/tools/folders.tool.js +111 -0
  230. package/dist/tools/folders.tool.js.map +1 -0
  231. package/dist/tools/health.tool.d.ts +10 -0
  232. package/dist/tools/health.tool.d.ts.map +1 -0
  233. package/dist/tools/health.tool.js +78 -0
  234. package/dist/tools/health.tool.js.map +1 -0
  235. package/dist/tools/label.tool.d.ts +11 -0
  236. package/dist/tools/label.tool.d.ts.map +1 -0
  237. package/dist/tools/label.tool.js +165 -0
  238. package/dist/tools/label.tool.js.map +1 -0
  239. package/dist/tools/locate.tool.d.ts +11 -0
  240. package/dist/tools/locate.tool.d.ts.map +1 -0
  241. package/dist/tools/locate.tool.js +59 -0
  242. package/dist/tools/locate.tool.js.map +1 -0
  243. package/dist/tools/mailboxes.tool.d.ts +7 -0
  244. package/dist/tools/mailboxes.tool.d.ts.map +1 -0
  245. package/dist/tools/mailboxes.tool.js +38 -0
  246. package/dist/tools/mailboxes.tool.js.map +1 -0
  247. package/dist/tools/manage.tool.d.ts +7 -0
  248. package/dist/tools/manage.tool.d.ts.map +1 -0
  249. package/dist/tools/manage.tool.js +125 -0
  250. package/dist/tools/manage.tool.js.map +1 -0
  251. package/dist/tools/register.d.ts +20 -0
  252. package/dist/tools/register.d.ts.map +1 -0
  253. package/dist/tools/register.js +53 -0
  254. package/dist/tools/register.js.map +1 -0
  255. package/dist/tools/scheduler.tool.d.ts +9 -0
  256. package/dist/tools/scheduler.tool.d.ts.map +1 -0
  257. package/dist/tools/scheduler.tool.js +104 -0
  258. package/dist/tools/scheduler.tool.js.map +1 -0
  259. package/dist/tools/send.tool.d.ts +7 -0
  260. package/dist/tools/send.tool.d.ts.map +1 -0
  261. package/dist/tools/send.tool.js +123 -0
  262. package/dist/tools/send.tool.js.map +1 -0
  263. package/dist/tools/templates.tool.d.ts +12 -0
  264. package/dist/tools/templates.tool.d.ts.map +1 -0
  265. package/dist/tools/templates.tool.js +140 -0
  266. package/dist/tools/templates.tool.js.map +1 -0
  267. package/dist/tools/thread.tool.d.ts +10 -0
  268. package/dist/tools/thread.tool.d.ts.map +1 -0
  269. package/dist/tools/thread.tool.js +146 -0
  270. package/dist/tools/thread.tool.js.map +1 -0
  271. package/dist/tools/watcher.tool.d.ts +9 -0
  272. package/dist/tools/watcher.tool.d.ts.map +1 -0
  273. package/dist/tools/watcher.tool.js +282 -0
  274. package/dist/tools/watcher.tool.js.map +1 -0
  275. package/dist/types/index.d.ts +271 -0
  276. package/dist/types/index.d.ts.map +1 -0
  277. package/dist/types/index.js +5 -0
  278. package/dist/types/index.js.map +1 -0
  279. package/dist/utils/calendar-notes.d.ts +31 -0
  280. package/dist/utils/calendar-notes.d.ts.map +1 -0
  281. package/dist/utils/calendar-notes.js +99 -0
  282. package/dist/utils/calendar-notes.js.map +1 -0
  283. package/dist/utils/calendar-state.d.ts +27 -0
  284. package/dist/utils/calendar-state.d.ts.map +1 -0
  285. package/dist/utils/calendar-state.js +85 -0
  286. package/dist/utils/calendar-state.js.map +1 -0
  287. package/dist/utils/conference-details.d.ts +12 -0
  288. package/dist/utils/conference-details.d.ts.map +1 -0
  289. package/dist/utils/conference-details.js +71 -0
  290. package/dist/utils/conference-details.js.map +1 -0
  291. package/dist/utils/meeting-url.d.ts +10 -0
  292. package/dist/utils/meeting-url.d.ts.map +1 -0
  293. package/dist/utils/meeting-url.js +30 -0
  294. package/dist/utils/meeting-url.js.map +1 -0
  295. package/package.json +108 -0
@@ -0,0 +1,1393 @@
1
+ /**
2
+ * IMAP service — pure business logic for email read operations.
3
+ *
4
+ * No MCP dependency — fully unit-testable.
5
+ */
6
+ import { sanitizeMailboxName, sanitizeSearchQuery } from '../safety/validation.js';
7
+ import { detectLabelStrategy } from './label-strategy.js';
8
+ // ---------------------------------------------------------------------------
9
+ // Helpers (must be defined before ImapService)
10
+ // ---------------------------------------------------------------------------
11
+ function parseAddress(addr) {
12
+ return {
13
+ name: addr?.name ?? undefined,
14
+ address: addr?.address ?? 'unknown',
15
+ };
16
+ }
17
+ function parseAddresses(addrs) {
18
+ if (!addrs)
19
+ return [];
20
+ return addrs.map(parseAddress);
21
+ }
22
+ function hasAttachments(bodyStructure) {
23
+ if (!bodyStructure || typeof bodyStructure !== 'object')
24
+ return false;
25
+ const bs = bodyStructure;
26
+ if (bs.disposition === 'attachment')
27
+ return true;
28
+ if (Array.isArray(bs.childNodes)) {
29
+ return bs.childNodes.some((child) => hasAttachments(child));
30
+ }
31
+ return false;
32
+ }
33
+ function extractAttachments(bodyStructure) {
34
+ const attachments = [];
35
+ if (!bodyStructure || typeof bodyStructure !== 'object')
36
+ return attachments;
37
+ const bs = bodyStructure;
38
+ if (bs.disposition === 'attachment') {
39
+ const params = (bs.dispositionParameters ?? bs.parameters ?? {});
40
+ attachments.push({
41
+ filename: params.filename ?? params.name ?? 'unnamed',
42
+ mimeType: `${bs.type ?? 'application'}/${bs.subtype ?? 'octet-stream'}`,
43
+ size: bs.size ?? 0,
44
+ });
45
+ }
46
+ if (Array.isArray(bs.childNodes)) {
47
+ bs.childNodes.forEach((child) => {
48
+ attachments.push(...extractAttachments(child));
49
+ });
50
+ }
51
+ return attachments;
52
+ }
53
+ /** Find the MIME part number for an attachment by filename. */
54
+ function findMimePartByFilename(bodyStructure, targetFilename, partPath = '') {
55
+ if (!bodyStructure || typeof bodyStructure !== 'object')
56
+ return undefined;
57
+ const bs = bodyStructure;
58
+ const currentPart = bs.part;
59
+ const effectivePath = currentPart ?? partPath;
60
+ if (bs.disposition === 'attachment') {
61
+ const params = (bs.dispositionParameters ?? bs.parameters ?? {});
62
+ const filename = params.filename ?? params.name ?? 'unnamed';
63
+ if (filename === targetFilename)
64
+ return effectivePath;
65
+ }
66
+ if (Array.isArray(bs.childNodes)) {
67
+ // eslint-disable-next-line no-plusplus
68
+ for (let i = 0; i < bs.childNodes.length; i++) {
69
+ const childPart = effectivePath ? `${effectivePath}.${i + 1}` : String(i + 1);
70
+ const found = findMimePartByFilename(bs.childNodes[i], targetFilename, childPart);
71
+ if (found)
72
+ return found;
73
+ }
74
+ }
75
+ return undefined;
76
+ }
77
+ function messageToEmailMeta(msg) {
78
+ const envelope = (msg.envelope ?? {});
79
+ const flags = new Set((msg.flags ?? []));
80
+ // Extract non-system flags as labels (IMAP keywords)
81
+ const labels = [...flags].filter((f) => !f.startsWith('\\'));
82
+ // Extract preview from source buffer
83
+ let preview;
84
+ if (msg.source && Buffer.isBuffer(msg.source)) {
85
+ const rawText = msg.source.toString('utf-8');
86
+ // Try to extract body text after the header blank line
87
+ const bodyStart = rawText.indexOf('\r\n\r\n');
88
+ if (bodyStart >= 0) {
89
+ preview = rawText
90
+ .slice(bodyStart + 4, bodyStart + 204)
91
+ .replace(/\s+/g, ' ')
92
+ .trim();
93
+ }
94
+ }
95
+ return {
96
+ id: String(msg.uid ?? msg.seq),
97
+ subject: envelope.subject ?? '(no subject)',
98
+ from: parseAddress(envelope.from?.[0]),
99
+ to: parseAddresses(envelope.to),
100
+ date: envelope.date
101
+ ? new Date(envelope.date).toISOString()
102
+ : new Date().toISOString(),
103
+ seen: flags.has('\\Seen'),
104
+ flagged: flags.has('\\Flagged'),
105
+ answered: flags.has('\\Answered'),
106
+ hasAttachments: hasAttachments(msg.bodyStructure),
107
+ labels,
108
+ preview,
109
+ };
110
+ }
111
+ async function messageToEmail(msg, client, uid) {
112
+ const meta = messageToEmailMeta(msg);
113
+ const envelope = (msg.envelope ?? {});
114
+ // Parse full source for body content
115
+ let bodyText;
116
+ let bodyHtml;
117
+ const headers = {};
118
+ if (msg.source && Buffer.isBuffer(msg.source)) {
119
+ const raw = msg.source.toString('utf-8');
120
+ const headerEnd = raw.indexOf('\r\n\r\n');
121
+ if (headerEnd >= 0) {
122
+ // Parse headers
123
+ const headerSection = raw.slice(0, headerEnd);
124
+ headerSection.split('\r\n').forEach((line) => {
125
+ const colonIdx = line.indexOf(':');
126
+ if (colonIdx > 0 && !line.startsWith(' ') && !line.startsWith('\t')) {
127
+ const key = line.slice(0, colonIdx).trim().toLowerCase();
128
+ const value = line.slice(colonIdx + 1).trim();
129
+ headers[key] = value;
130
+ }
131
+ });
132
+ const body = raw.slice(headerEnd + 4);
133
+ // Simple content type detection
134
+ const contentType = headers['content-type'] ?? '';
135
+ if (contentType.includes('text/html')) {
136
+ bodyHtml = body;
137
+ }
138
+ else {
139
+ bodyText = body;
140
+ }
141
+ }
142
+ }
143
+ // Try to get text/html parts via download if body parsing was simple
144
+ try {
145
+ const textPart = await client.download(String(uid), '1', { uid: true });
146
+ if (textPart?.content) {
147
+ const chunks = [];
148
+ // eslint-disable-next-line no-restricted-syntax
149
+ for await (const chunk of textPart.content) {
150
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
151
+ }
152
+ bodyText = Buffer.concat(chunks).toString('utf-8');
153
+ }
154
+ }
155
+ catch {
156
+ // Part may not exist
157
+ }
158
+ return {
159
+ ...meta,
160
+ cc: parseAddresses(envelope.cc),
161
+ bcc: parseAddresses(envelope.bcc),
162
+ bodyText,
163
+ bodyHtml,
164
+ messageId: envelope.messageId ?? '',
165
+ inReplyTo: envelope.inReplyTo ?? undefined,
166
+ references: headers.references?.split(/\s+/).filter(Boolean),
167
+ attachments: extractAttachments(msg.bodyStructure),
168
+ headers,
169
+ };
170
+ }
171
+ // ---------------------------------------------------------------------------
172
+ // Service
173
+ // ---------------------------------------------------------------------------
174
+ export default class ImapService {
175
+ connections;
176
+ labelStrategies = new Map();
177
+ labelStrategyPending = new Map();
178
+ constructor(connections) {
179
+ this.connections = connections;
180
+ }
181
+ async getLabelStrategy(accountName) {
182
+ const cached = this.labelStrategies.get(accountName);
183
+ if (cached)
184
+ return cached;
185
+ // Deduplicate concurrent detection for the same account
186
+ const pending = this.labelStrategyPending.get(accountName);
187
+ if (pending)
188
+ return pending;
189
+ const promise = (async () => {
190
+ const client = await this.connections.getImapClient(accountName);
191
+ const strategy = await detectLabelStrategy(client);
192
+ this.labelStrategies.set(accountName, strategy);
193
+ this.labelStrategyPending.delete(accountName);
194
+ return strategy;
195
+ })();
196
+ this.labelStrategyPending.set(accountName, promise);
197
+ return promise;
198
+ }
199
+ // -------------------------------------------------------------------------
200
+ // Mailboxes
201
+ // -------------------------------------------------------------------------
202
+ async listMailboxes(accountName) {
203
+ const client = await this.connections.getImapClient(accountName);
204
+ const mailboxes = await client.list();
205
+ const statusResults = await Promise.allSettled(mailboxes.map(async (mb) => {
206
+ const status = await client.status(mb.path, {
207
+ messages: true,
208
+ unseen: true,
209
+ });
210
+ return {
211
+ name: mb.name,
212
+ path: mb.path,
213
+ specialUse: mb.specialUse ?? undefined,
214
+ totalMessages: status.messages ?? 0,
215
+ unseenMessages: status.unseen ?? 0,
216
+ };
217
+ }));
218
+ return statusResults.map((result, idx) => {
219
+ if (result.status === 'fulfilled') {
220
+ return result.value;
221
+ }
222
+ // Fallback for folders that don't support STATUS (e.g. \Noselect)
223
+ const mb = mailboxes[idx];
224
+ return {
225
+ name: mb.name,
226
+ path: mb.path,
227
+ specialUse: mb.specialUse ?? undefined,
228
+ totalMessages: 0,
229
+ unseenMessages: 0,
230
+ };
231
+ });
232
+ }
233
+ // -------------------------------------------------------------------------
234
+ // List emails
235
+ // -------------------------------------------------------------------------
236
+ async listEmails(accountName, options = {}) {
237
+ const client = await this.connections.getImapClient(accountName);
238
+ const mailbox = sanitizeMailboxName(options.mailbox ?? 'INBOX');
239
+ const page = options.page ?? 1;
240
+ const pageSize = options.pageSize ?? 20;
241
+ const lock = await client.getMailboxLock(mailbox);
242
+ try {
243
+ // Build search criteria
244
+ const search = {};
245
+ if (options.since)
246
+ search.since = new Date(options.since);
247
+ if (options.before)
248
+ search.before = new Date(options.before);
249
+ if (options.from)
250
+ search.from = options.from;
251
+ if (options.subject)
252
+ search.subject = options.subject;
253
+ if (options.seen !== undefined)
254
+ search.seen = options.seen;
255
+ if (options.flagged !== undefined)
256
+ search.flagged = options.flagged;
257
+ if (options.answered !== undefined)
258
+ search.answered = options.answered;
259
+ // Search for matching UIDs
260
+ const searchResult = await client.search(search, { uid: true });
261
+ let uids = Array.isArray(searchResult) ? searchResult : [];
262
+ // Post-filter for hasAttachment (IMAP has no native attachment search)
263
+ if (options.hasAttachment !== undefined && uids.length > 0) {
264
+ const filteredUids = [];
265
+ // eslint-disable-next-line no-restricted-syntax
266
+ for await (const msg of client.fetch(uids.join(','), { uid: true, bodyStructure: true }, { uid: true })) {
267
+ const raw = msg;
268
+ if (options.hasAttachment === hasAttachments(raw.bodyStructure)) {
269
+ filteredUids.push(raw.uid);
270
+ }
271
+ }
272
+ uids = filteredUids;
273
+ }
274
+ if (uids.length === 0) {
275
+ return {
276
+ items: [],
277
+ total: 0,
278
+ page,
279
+ pageSize,
280
+ hasMore: false,
281
+ };
282
+ }
283
+ // Sort descending (newest first) and paginate
284
+ uids.sort((a, b) => b - a);
285
+ const total = uids.length;
286
+ const start = (page - 1) * pageSize;
287
+ const pageUids = uids.slice(start, start + pageSize);
288
+ if (pageUids.length === 0) {
289
+ return {
290
+ items: [],
291
+ total,
292
+ page,
293
+ pageSize,
294
+ hasMore: false,
295
+ };
296
+ }
297
+ const items = [];
298
+ const range = pageUids.join(',');
299
+ // eslint-disable-next-line no-restricted-syntax
300
+ for await (const msg of client.fetch(range, {
301
+ uid: true,
302
+ envelope: true,
303
+ flags: true,
304
+ bodyStructure: true,
305
+ source: { start: 0, maxLength: 256 },
306
+ }, { uid: true })) {
307
+ items.push(messageToEmailMeta(msg));
308
+ }
309
+ // Sort by date descending
310
+ items.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
311
+ return {
312
+ items,
313
+ total,
314
+ page,
315
+ pageSize,
316
+ hasMore: start + pageSize < total,
317
+ };
318
+ }
319
+ finally {
320
+ lock.release();
321
+ }
322
+ }
323
+ // -------------------------------------------------------------------------
324
+ // Get single email
325
+ // -------------------------------------------------------------------------
326
+ async getEmail(accountName, emailId, mailbox = 'INBOX') {
327
+ const client = await this.connections.getImapClient(accountName);
328
+ const uid = parseInt(emailId, 10);
329
+ const safeMailbox = sanitizeMailboxName(mailbox);
330
+ const lock = await client.getMailboxLock(safeMailbox);
331
+ try {
332
+ const msg = await client.fetchOne(String(uid), {
333
+ uid: true,
334
+ envelope: true,
335
+ flags: true,
336
+ bodyStructure: true,
337
+ source: true,
338
+ }, { uid: true });
339
+ if (!msg) {
340
+ throw new Error(`Email ${emailId} not found in ${mailbox}`);
341
+ }
342
+ return await messageToEmail(msg, client, uid);
343
+ }
344
+ finally {
345
+ lock.release();
346
+ }
347
+ }
348
+ // -------------------------------------------------------------------------
349
+ // Get email flags (lightweight — no body fetch, no \Seen change)
350
+ // -------------------------------------------------------------------------
351
+ async getEmailFlags(accountName, emailId, mailbox = 'INBOX') {
352
+ const client = await this.connections.getImapClient(accountName);
353
+ const uid = parseInt(emailId, 10);
354
+ const lock = await client.getMailboxLock(mailbox);
355
+ try {
356
+ const msg = await client.fetchOne(String(uid), { uid: true, envelope: true, flags: true }, { uid: true });
357
+ if (!msg) {
358
+ throw new Error(`Email ${emailId} not found in ${mailbox}`);
359
+ }
360
+ const raw = msg;
361
+ const flags = new Set((raw.flags ?? []));
362
+ const labels = [...flags].filter((f) => !f.startsWith('\\'));
363
+ const envelope = (raw.envelope ?? {});
364
+ const fromEntry = envelope.from?.[0];
365
+ let from = '';
366
+ if (fromEntry) {
367
+ from = fromEntry.name
368
+ ? `${fromEntry.name} <${fromEntry.address}>`
369
+ : (fromEntry.address ?? '');
370
+ }
371
+ return {
372
+ seen: flags.has('\\Seen'),
373
+ flagged: flags.has('\\Flagged'),
374
+ answered: flags.has('\\Answered'),
375
+ labels,
376
+ subject: envelope.subject ?? '(no subject)',
377
+ from,
378
+ date: envelope.date ? new Date(envelope.date).toISOString() : '',
379
+ };
380
+ }
381
+ finally {
382
+ lock.release();
383
+ }
384
+ }
385
+ // -------------------------------------------------------------------------
386
+ // Search emails
387
+ // -------------------------------------------------------------------------
388
+ async searchEmails(accountName, query, options = {}) {
389
+ const client = await this.connections.getImapClient(accountName);
390
+ const mailbox = sanitizeMailboxName(options.mailbox ?? 'INBOX');
391
+ const page = options.page ?? 1;
392
+ const pageSize = options.pageSize ?? 20;
393
+ const sanitizedQuery = query ? sanitizeSearchQuery(query) : '';
394
+ const lock = await client.getMailboxLock(mailbox);
395
+ try {
396
+ // Build search criteria — base query OR across subject/from/body
397
+ const baseCriteria = sanitizedQuery
398
+ ? { or: [{ subject: sanitizedQuery }, { from: sanitizedQuery }, { body: sanitizedQuery }] }
399
+ : {};
400
+ // Build additional filters as AND conditions
401
+ const andConditions = [baseCriteria];
402
+ if (options.to) {
403
+ andConditions.push({ to: options.to });
404
+ }
405
+ if (options.largerThan !== undefined) {
406
+ andConditions.push({ larger: options.largerThan * 1024 });
407
+ }
408
+ if (options.smallerThan !== undefined) {
409
+ andConditions.push({ smaller: options.smallerThan * 1024 });
410
+ }
411
+ if (options.answered === true) {
412
+ andConditions.push({ answered: true });
413
+ }
414
+ else if (options.answered === false) {
415
+ andConditions.push({ answered: false });
416
+ }
417
+ // Use the combined criteria or just the base
418
+ const searchCriteria = andConditions.length === 1 ? baseCriteria : Object.assign({}, ...andConditions);
419
+ const searchResult = await client.search(searchCriteria, { uid: true });
420
+ let uids = Array.isArray(searchResult) ? searchResult : [];
421
+ // Post-filter for has_attachment if requested (IMAP doesn't have native support)
422
+ if (options.hasAttachment !== undefined && uids.length > 0) {
423
+ const filteredUids = [];
424
+ const checkRange = uids.join(',');
425
+ // eslint-disable-next-line no-restricted-syntax
426
+ for await (const msg of client.fetch(checkRange, { uid: true, bodyStructure: true }, { uid: true })) {
427
+ const raw = msg;
428
+ const msgHasAtt = hasAttachments(raw.bodyStructure);
429
+ if (options.hasAttachment === msgHasAtt) {
430
+ filteredUids.push(raw.uid);
431
+ }
432
+ }
433
+ uids = filteredUids;
434
+ }
435
+ if (uids.length === 0) {
436
+ return {
437
+ items: [],
438
+ total: 0,
439
+ page,
440
+ pageSize,
441
+ hasMore: false,
442
+ };
443
+ }
444
+ uids.sort((a, b) => b - a);
445
+ const total = uids.length;
446
+ const start = (page - 1) * pageSize;
447
+ const pageUids = uids.slice(start, start + pageSize);
448
+ if (pageUids.length === 0) {
449
+ return {
450
+ items: [],
451
+ total,
452
+ page,
453
+ pageSize,
454
+ hasMore: false,
455
+ };
456
+ }
457
+ const items = [];
458
+ const range = pageUids.join(',');
459
+ // eslint-disable-next-line no-restricted-syntax
460
+ for await (const msg of client.fetch(range, {
461
+ uid: true,
462
+ envelope: true,
463
+ flags: true,
464
+ bodyStructure: true,
465
+ source: { start: 0, maxLength: 256 },
466
+ }, { uid: true })) {
467
+ items.push(messageToEmailMeta(msg));
468
+ }
469
+ items.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
470
+ return {
471
+ items,
472
+ total,
473
+ page,
474
+ pageSize,
475
+ hasMore: start + pageSize < total,
476
+ };
477
+ }
478
+ finally {
479
+ lock.release();
480
+ }
481
+ }
482
+ // -------------------------------------------------------------------------
483
+ // Labels
484
+ // -------------------------------------------------------------------------
485
+ async listLabels(accountName) {
486
+ const strategy = await this.getLabelStrategy(accountName);
487
+ const client = await this.connections.getImapClient(accountName);
488
+ return strategy.listLabels(client);
489
+ }
490
+ async addLabel(accountName, emailId, mailbox, label) {
491
+ const strategy = await this.getLabelStrategy(accountName);
492
+ const client = await this.connections.getImapClient(accountName);
493
+ await strategy.addLabel(client, emailId, mailbox, label);
494
+ }
495
+ async removeLabel(accountName, emailId, mailbox, label) {
496
+ const strategy = await this.getLabelStrategy(accountName);
497
+ const client = await this.connections.getImapClient(accountName);
498
+ await strategy.removeLabel(client, emailId, mailbox, label);
499
+ }
500
+ async createLabel(accountName, name) {
501
+ const strategy = await this.getLabelStrategy(accountName);
502
+ const client = await this.connections.getImapClient(accountName);
503
+ await strategy.createLabel(client, name);
504
+ }
505
+ async deleteLabel(accountName, name) {
506
+ const strategy = await this.getLabelStrategy(accountName);
507
+ const client = await this.connections.getImapClient(accountName);
508
+ await strategy.deleteLabel(client, name);
509
+ }
510
+ // -------------------------------------------------------------------------
511
+ // Virtual-folder detection
512
+ // -------------------------------------------------------------------------
513
+ static VIRTUAL_SPECIAL_USE = new Set(['\\All', '\\Flagged']);
514
+ static async assertRealMailbox(client, mailboxPath) {
515
+ const mailboxes = await client.list();
516
+ const mb = mailboxes.find((m) => m.path === mailboxPath);
517
+ if (!mb)
518
+ return; // unknown — let the server reject if invalid
519
+ const virtualFlag = [...ImapService.VIRTUAL_SPECIAL_USE].find((f) => mb.specialUse === f || mb.flags?.has(f));
520
+ if (virtualFlag) {
521
+ throw new Error(`"${mailboxPath}" is a virtual folder (${virtualFlag}). ` +
522
+ 'Use find_email_folder to locate the real folder first.');
523
+ }
524
+ }
525
+ // -------------------------------------------------------------------------
526
+ // Find real folder for an email
527
+ // -------------------------------------------------------------------------
528
+ async findEmailFolder(accountName, emailId, sourceMailbox) {
529
+ const client = await this.connections.getImapClient(accountName);
530
+ // 1. Fetch Message-ID from the source mailbox
531
+ let messageId;
532
+ const srcLock = await client.getMailboxLock(sourceMailbox);
533
+ try {
534
+ const msg = await client.fetchOne(emailId, { headers: true }, { uid: true });
535
+ // biome-ignore lint/complexity/useOptionalChain: optional chain breaks TS type narrowing for union with false
536
+ if (msg && msg.headers && Buffer.isBuffer(msg.headers)) {
537
+ const headerText = msg.headers.toString('utf-8');
538
+ const match = /^message-id:\s*(.+)$/im.exec(headerText);
539
+ if (match) {
540
+ messageId = match[1].trim();
541
+ }
542
+ }
543
+ }
544
+ finally {
545
+ srcLock.release();
546
+ }
547
+ if (!messageId) {
548
+ throw new Error('Could not retrieve Message-ID for this email.');
549
+ }
550
+ // 2. List all real mailboxes (exclude virtual and non-selectable)
551
+ const allMailboxes = await client.list();
552
+ const realMailboxes = allMailboxes.filter((mb) => {
553
+ if (!mb.listed)
554
+ return false;
555
+ if (mb.flags?.has('\\Noselect'))
556
+ return false;
557
+ const isVirtual = [...ImapService.VIRTUAL_SPECIAL_USE].some((f) => mb.specialUse === f || mb.flags?.has(f));
558
+ if (isVirtual)
559
+ return false;
560
+ return true;
561
+ });
562
+ // 3. Search each real mailbox for the Message-ID (sequential — each needs its own lock)
563
+ const folders = [];
564
+ const searchMailbox = async (mbPath) => {
565
+ try {
566
+ const lock = await client.getMailboxLock(mbPath);
567
+ try {
568
+ const results = await client.search({ header: { 'message-id': messageId } }, { uid: true });
569
+ if (results && Array.isArray(results) && results.length > 0) {
570
+ folders.push(mbPath);
571
+ }
572
+ }
573
+ finally {
574
+ lock.release();
575
+ }
576
+ }
577
+ catch {
578
+ // Skip folders that can't be selected or searched (e.g. \Noselect, INBOX on some providers)
579
+ }
580
+ };
581
+ // eslint-disable-next-line no-restricted-syntax
582
+ for (const mb of realMailboxes) {
583
+ // eslint-disable-next-line no-await-in-loop
584
+ await searchMailbox(mb.path);
585
+ }
586
+ return { folders, messageId };
587
+ }
588
+ // -------------------------------------------------------------------------
589
+ // Move / Delete
590
+ // -------------------------------------------------------------------------
591
+ async moveEmail(accountName, emailId, sourceMailbox, destinationMailbox) {
592
+ const client = await this.connections.getImapClient(accountName);
593
+ const safeSource = sanitizeMailboxName(sourceMailbox);
594
+ const safeDest = sanitizeMailboxName(destinationMailbox);
595
+ await ImapService.assertRealMailbox(client, safeSource);
596
+ const lock = await client.getMailboxLock(safeSource);
597
+ try {
598
+ const ok = await client.messageMove(emailId, safeDest, { uid: true });
599
+ if (!ok) {
600
+ throw new Error(`IMAP server rejected the move from "${safeSource}" to "${safeDest}".`);
601
+ }
602
+ }
603
+ finally {
604
+ lock.release();
605
+ }
606
+ }
607
+ async deleteEmail(accountName, emailId, mailbox = 'INBOX', permanent = false) {
608
+ const client = await this.connections.getImapClient(accountName);
609
+ const safeMailbox = sanitizeMailboxName(mailbox);
610
+ if (permanent) {
611
+ const lock = await client.getMailboxLock(safeMailbox);
612
+ try {
613
+ const ok = await client.messageDelete(emailId, { uid: true });
614
+ if (!ok) {
615
+ throw new Error('IMAP server rejected the delete operation.');
616
+ }
617
+ }
618
+ finally {
619
+ lock.release();
620
+ }
621
+ }
622
+ else {
623
+ await ImapService.assertRealMailbox(client, safeMailbox);
624
+ const mailboxes = await client.list();
625
+ const trash = mailboxes.find((mb) => mb.specialUse === '\\Trash');
626
+ const trashPath = trash?.path ?? 'Trash';
627
+ const lock = await client.getMailboxLock(safeMailbox);
628
+ try {
629
+ const ok = await client.messageMove(emailId, trashPath, { uid: true });
630
+ if (!ok) {
631
+ throw new Error('IMAP server rejected the move to Trash.');
632
+ }
633
+ }
634
+ finally {
635
+ lock.release();
636
+ }
637
+ }
638
+ }
639
+ // -------------------------------------------------------------------------
640
+ // Flag management
641
+ // -------------------------------------------------------------------------
642
+ async setFlags(accountName, emailId, mailbox, action) {
643
+ const client = await this.connections.getImapClient(accountName);
644
+ const safeMailbox = sanitizeMailboxName(mailbox);
645
+ const lock = await client.getMailboxLock(safeMailbox);
646
+ try {
647
+ const flagMap = {
648
+ read: { flags: ['\\Seen'], add: true },
649
+ unread: { flags: ['\\Seen'], add: false },
650
+ flag: { flags: ['\\Flagged'], add: true },
651
+ unflag: { flags: ['\\Flagged'], add: false },
652
+ };
653
+ const { flags, add } = flagMap[action];
654
+ let ok;
655
+ if (add) {
656
+ ok = await client.messageFlagsAdd(emailId, flags, { uid: true });
657
+ }
658
+ else {
659
+ ok = await client.messageFlagsRemove(emailId, flags, { uid: true });
660
+ }
661
+ if (!ok) {
662
+ throw new Error(`IMAP server rejected the ${action} flag operation.`);
663
+ }
664
+ }
665
+ finally {
666
+ lock.release();
667
+ }
668
+ }
669
+ // -------------------------------------------------------------------------
670
+ // Bulk operations
671
+ // -------------------------------------------------------------------------
672
+ async bulkSetFlags(accountName, ids, mailbox, action) {
673
+ const client = await this.connections.getImapClient(accountName);
674
+ const lock = await client.getMailboxLock(mailbox);
675
+ const result = {
676
+ total: ids.length,
677
+ succeeded: 0,
678
+ failed: 0,
679
+ errors: [],
680
+ };
681
+ try {
682
+ const flagMap = {
683
+ mark_read: { flags: ['\\Seen'], add: true },
684
+ mark_unread: { flags: ['\\Seen'], add: false },
685
+ flag: { flags: ['\\Flagged'], add: true },
686
+ unflag: { flags: ['\\Flagged'], add: false },
687
+ };
688
+ const { flags, add } = flagMap[action];
689
+ const range = ids.join(',');
690
+ let ok;
691
+ if (add) {
692
+ ok = await client.messageFlagsAdd(range, flags, { uid: true });
693
+ }
694
+ else {
695
+ ok = await client.messageFlagsRemove(range, flags, { uid: true });
696
+ }
697
+ if (ok) {
698
+ result.succeeded = ids.length;
699
+ }
700
+ else {
701
+ result.failed = ids.length;
702
+ result.errors = ['IMAP server rejected the flag operation.'];
703
+ }
704
+ }
705
+ catch (err) {
706
+ result.failed = ids.length;
707
+ result.errors = [err instanceof Error ? err.message : String(err)];
708
+ }
709
+ finally {
710
+ lock.release();
711
+ }
712
+ if (result.errors?.length === 0)
713
+ delete result.errors;
714
+ return result;
715
+ }
716
+ async bulkMove(accountName, ids, mailbox, destination) {
717
+ const client = await this.connections.getImapClient(accountName);
718
+ await ImapService.assertRealMailbox(client, mailbox);
719
+ const lock = await client.getMailboxLock(mailbox);
720
+ const result = {
721
+ total: ids.length,
722
+ succeeded: 0,
723
+ failed: 0,
724
+ errors: [],
725
+ };
726
+ try {
727
+ const range = ids.join(',');
728
+ const ok = await client.messageMove(range, destination, { uid: true });
729
+ if (ok) {
730
+ result.succeeded = ids.length;
731
+ }
732
+ else {
733
+ result.failed = ids.length;
734
+ result.errors = ['IMAP server rejected the move operation.'];
735
+ }
736
+ }
737
+ catch (err) {
738
+ result.failed = ids.length;
739
+ result.errors = [err instanceof Error ? err.message : String(err)];
740
+ }
741
+ finally {
742
+ lock.release();
743
+ }
744
+ if (result.errors?.length === 0)
745
+ delete result.errors;
746
+ return result;
747
+ }
748
+ async bulkDelete(accountName, ids, mailbox, permanent = false) {
749
+ const client = await this.connections.getImapClient(accountName);
750
+ const result = {
751
+ total: ids.length,
752
+ succeeded: 0,
753
+ failed: 0,
754
+ errors: [],
755
+ };
756
+ if (permanent) {
757
+ const lock = await client.getMailboxLock(mailbox);
758
+ try {
759
+ const range = ids.join(',');
760
+ const ok = await client.messageDelete(range, { uid: true });
761
+ if (ok) {
762
+ result.succeeded = ids.length;
763
+ }
764
+ else {
765
+ result.failed = ids.length;
766
+ result.errors = ['IMAP server rejected the delete operation.'];
767
+ }
768
+ }
769
+ catch (err) {
770
+ result.failed = ids.length;
771
+ result.errors = [err instanceof Error ? err.message : String(err)];
772
+ }
773
+ finally {
774
+ lock.release();
775
+ }
776
+ }
777
+ else {
778
+ await ImapService.assertRealMailbox(client, mailbox);
779
+ const mailboxes = await client.list();
780
+ const trash = mailboxes.find((mb) => mb.specialUse === '\\Trash');
781
+ const trashPath = trash?.path ?? 'Trash';
782
+ const lock = await client.getMailboxLock(mailbox);
783
+ try {
784
+ const range = ids.join(',');
785
+ const ok = await client.messageMove(range, trashPath, { uid: true });
786
+ if (ok) {
787
+ result.succeeded = ids.length;
788
+ }
789
+ else {
790
+ result.failed = ids.length;
791
+ result.errors = ['IMAP server rejected the move to Trash.'];
792
+ }
793
+ }
794
+ catch (err) {
795
+ result.failed = ids.length;
796
+ result.errors = [err instanceof Error ? err.message : String(err)];
797
+ }
798
+ finally {
799
+ lock.release();
800
+ }
801
+ }
802
+ if (result.errors?.length === 0)
803
+ delete result.errors;
804
+ return result;
805
+ }
806
+ // -------------------------------------------------------------------------
807
+ // Draft management
808
+ // -------------------------------------------------------------------------
809
+ async saveDraft(accountName, options) {
810
+ const client = await this.connections.getImapClient(accountName);
811
+ const account = this.connections.getAccount(accountName);
812
+ // Find the Drafts folder
813
+ const mailboxes = await client.list();
814
+ const drafts = mailboxes.find((mb) => mb.specialUse === '\\Drafts');
815
+ const draftsPath = drafts?.path ?? 'Drafts';
816
+ // Construct RFC 822 message
817
+ const headers = [
818
+ `From: ${account.fullName ? `"${account.fullName}" <${account.email}>` : account.email}`,
819
+ `To: ${options.to.join(', ')}`,
820
+ `Subject: ${options.subject}`,
821
+ `Date: ${new Date().toUTCString()}`,
822
+ `MIME-Version: 1.0`,
823
+ ];
824
+ if (options.cc?.length)
825
+ headers.push(`Cc: ${options.cc.join(', ')}`);
826
+ if (options.bcc?.length)
827
+ headers.push(`Bcc: ${options.bcc.join(', ')}`);
828
+ if (options.inReplyTo)
829
+ headers.push(`In-Reply-To: ${options.inReplyTo}`);
830
+ const contentType = options.html ? 'text/html; charset=utf-8' : 'text/plain; charset=utf-8';
831
+ headers.push(`Content-Type: ${contentType}`);
832
+ const rawMessage = `${headers.join('\r\n')}\r\n\r\n${options.body}`;
833
+ const appendResult = await client.append(draftsPath, Buffer.from(rawMessage), [
834
+ '\\Draft',
835
+ '\\Seen',
836
+ ]);
837
+ return {
838
+ id: appendResult.uid ?? 0,
839
+ mailbox: draftsPath,
840
+ };
841
+ }
842
+ /**
843
+ * Fetch a draft message for sending.
844
+ * Returns the parsed draft with recipients and content.
845
+ */
846
+ async fetchDraft(accountName, emailId, mailbox) {
847
+ const client = await this.connections.getImapClient(accountName);
848
+ // Find drafts folder if not specified
849
+ let draftsPath = mailbox;
850
+ if (!draftsPath) {
851
+ const mailboxes = await client.list();
852
+ const draftsFolder = mailboxes.find((mb) => mb.specialUse === '\\Drafts');
853
+ draftsPath = draftsFolder?.path ?? 'Drafts';
854
+ }
855
+ const email = await this.getEmail(accountName, String(emailId), draftsPath);
856
+ return { email, mailbox: draftsPath };
857
+ }
858
+ /** Delete a draft after it has been sent. */
859
+ async deleteDraft(accountName, emailId, mailbox) {
860
+ const client = await this.connections.getImapClient(accountName);
861
+ const lock = await client.getMailboxLock(mailbox);
862
+ try {
863
+ await client.messageDelete(String(emailId), { uid: true });
864
+ }
865
+ finally {
866
+ lock.release();
867
+ }
868
+ }
869
+ // -------------------------------------------------------------------------
870
+ // Mailbox (folder) CRUD
871
+ // -------------------------------------------------------------------------
872
+ async createMailbox(accountName, folderPath) {
873
+ const client = await this.connections.getImapClient(accountName);
874
+ await client.mailboxCreate(folderPath);
875
+ }
876
+ async renameMailbox(accountName, folderPath, newPath) {
877
+ const client = await this.connections.getImapClient(accountName);
878
+ await client.mailboxRename(folderPath, newPath);
879
+ }
880
+ async deleteMailbox(accountName, folderPath) {
881
+ const client = await this.connections.getImapClient(accountName);
882
+ await client.mailboxDelete(folderPath);
883
+ }
884
+ // -------------------------------------------------------------------------
885
+ // Attachment download
886
+ // -------------------------------------------------------------------------
887
+ async downloadAttachment(accountName, emailId, mailbox, filename, maxSizeBytes = 5 * 1024 * 1024) {
888
+ const client = await this.connections.getImapClient(accountName);
889
+ const uid = parseInt(emailId, 10);
890
+ const lock = await client.getMailboxLock(mailbox);
891
+ try {
892
+ // Fetch bodyStructure to find the MIME part
893
+ const msg = await client.fetchOne(String(uid), { uid: true, bodyStructure: true }, { uid: true });
894
+ if (!msg) {
895
+ throw new Error(`Email ${emailId} not found in ${mailbox}`);
896
+ }
897
+ const attachments = extractAttachments(msg.bodyStructure);
898
+ const attachment = attachments.find((a) => a.filename === filename);
899
+ if (!attachment) {
900
+ throw new Error(`Attachment "${filename}" not found. Available: ${attachments.map((a) => a.filename).join(', ') || 'none'}`);
901
+ }
902
+ if (attachment.size > maxSizeBytes) {
903
+ throw new Error(`Attachment "${filename}" is ${Math.round(attachment.size / 1024 / 1024)}MB, exceeds ${Math.round(maxSizeBytes / 1024 / 1024)}MB limit`);
904
+ }
905
+ // Find the MIME part number
906
+ const partNumber = findMimePartByFilename(msg.bodyStructure, filename);
907
+ if (!partNumber) {
908
+ throw new Error(`Could not locate MIME part for "${filename}"`);
909
+ }
910
+ // Download the part
911
+ const downloadResult = await client.download(String(uid), partNumber, {
912
+ uid: true,
913
+ });
914
+ if (!downloadResult?.content) {
915
+ throw new Error(`Failed to download attachment "${filename}"`);
916
+ }
917
+ const chunks = [];
918
+ // eslint-disable-next-line no-restricted-syntax
919
+ for await (const chunk of downloadResult.content) {
920
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
921
+ }
922
+ const content = Buffer.concat(chunks);
923
+ return {
924
+ filename: attachment.filename,
925
+ mimeType: attachment.mimeType,
926
+ size: content.length,
927
+ contentBase64: content.toString('base64'),
928
+ };
929
+ }
930
+ finally {
931
+ lock.release();
932
+ }
933
+ }
934
+ // -------------------------------------------------------------------------
935
+ // Save all email attachments to a local directory
936
+ // -------------------------------------------------------------------------
937
+ /**
938
+ * Download and save all non-ICS attachments from an email to a local directory.
939
+ * Returns metadata including the saved file paths and file:// URLs.
940
+ *
941
+ * Attachments larger than maxSizeBytes (default 25 MB) are skipped.
942
+ */
943
+ async saveEmailAttachments(accountName, emailId, mailbox, destDir, maxSizeBytes = 25 * 1024 * 1024) {
944
+ const client = await this.connections.getImapClient(accountName);
945
+ const uid = parseInt(emailId, 10);
946
+ const lock = await client.getMailboxLock(mailbox);
947
+ let attachmentMetas = [];
948
+ try {
949
+ const msg = await client.fetchOne(String(uid), { uid: true, bodyStructure: true }, { uid: true });
950
+ if (!msg)
951
+ return [];
952
+ // biome-ignore format: line too long; eslint implicit-arrow-linebreak prevents multi-line implicit return
953
+ attachmentMetas = extractAttachments(msg.bodyStructure).filter((a) => a.size <= maxSizeBytes && !a.mimeType.includes('calendar') && !a.filename.toLowerCase().endsWith('.ics'));
954
+ }
955
+ finally {
956
+ lock.release();
957
+ }
958
+ if (attachmentMetas.length === 0)
959
+ return [];
960
+ const { mkdir } = await import('node:fs/promises');
961
+ await mkdir(destDir, { recursive: true });
962
+ const results = await Promise.allSettled(attachmentMetas.map(async (meta) => {
963
+ const downloaded = await this.downloadAttachment(accountName, emailId, mailbox, meta.filename, maxSizeBytes);
964
+ const safe = meta.filename.replace(/[/\\?%*:|"<>]/g, '_');
965
+ const localPath = `${destDir}/${safe}`;
966
+ const { writeFile } = await import('node:fs/promises');
967
+ await writeFile(localPath, Buffer.from(downloaded.contentBase64, 'base64'));
968
+ return {
969
+ filename: meta.filename,
970
+ localPath,
971
+ fileUrl: `file://${localPath}`,
972
+ mimeType: meta.mimeType,
973
+ size: downloaded.size,
974
+ };
975
+ }));
976
+ return results
977
+ .filter((r) => r.status === 'fulfilled')
978
+ .map((r) => r.value);
979
+ }
980
+ /**
981
+ * Reconstruct an email thread by following References / In-Reply-To chains.
982
+ * Searches by Message-ID header for each reference and returns messages in
983
+ * chronological order. Caps at MAX_THREAD_MESSAGES to prevent runaway chains.
984
+ */
985
+ async getThread(accountName, messageId, mailbox = 'INBOX') {
986
+ const MAX_THREAD_MESSAGES = 50;
987
+ const client = await this.connections.getImapClient(accountName);
988
+ const lock = await client.getMailboxLock(mailbox);
989
+ try {
990
+ // Collect all Message-IDs in the thread
991
+ const targetMsgIds = new Set([messageId]);
992
+ // First, find the root message to get its References chain
993
+ const rootSearch = await client.search({ header: { 'Message-ID': messageId } }, { uid: true });
994
+ const rootUids = Array.isArray(rootSearch) ? rootSearch : [];
995
+ if (rootUids.length > 0) {
996
+ const rootMsg = await client.fetchOne(String(rootUids[0]), { uid: true, envelope: true, source: true }, { uid: true });
997
+ if (rootMsg) {
998
+ const raw = rootMsg;
999
+ const envelope = (raw.envelope ?? {});
1000
+ const inReplyTo = envelope.inReplyTo;
1001
+ if (inReplyTo)
1002
+ targetMsgIds.add(inReplyTo);
1003
+ // Parse References header from source
1004
+ if (raw.source && Buffer.isBuffer(raw.source)) {
1005
+ const src = raw.source.toString('utf-8');
1006
+ const refMatch = /^References:\s*(.+?)(?:\r?\n(?!\s))/ms.exec(src);
1007
+ if (refMatch) {
1008
+ refMatch[1]
1009
+ .split(/\s+/)
1010
+ .filter(Boolean)
1011
+ .forEach((ref) => {
1012
+ targetMsgIds.add(ref);
1013
+ });
1014
+ }
1015
+ }
1016
+ }
1017
+ }
1018
+ // Search for all related messages by Message-ID
1019
+ const foundUids = new Set();
1020
+ // eslint-disable-next-line no-restricted-syntax
1021
+ for (const msgId of targetMsgIds) {
1022
+ if (foundUids.size >= MAX_THREAD_MESSAGES)
1023
+ break;
1024
+ try {
1025
+ // eslint-disable-next-line no-await-in-loop
1026
+ const searchResult = await client.search({ header: { 'Message-ID': msgId } }, { uid: true });
1027
+ if (Array.isArray(searchResult)) {
1028
+ searchResult.forEach((uid) => {
1029
+ foundUids.add(uid);
1030
+ });
1031
+ }
1032
+ }
1033
+ catch {
1034
+ // Header search may not be supported for all messages
1035
+ }
1036
+ }
1037
+ // Also search for messages that reference any of our Message-IDs
1038
+ // eslint-disable-next-line no-restricted-syntax
1039
+ for (const msgId of targetMsgIds) {
1040
+ if (foundUids.size >= MAX_THREAD_MESSAGES)
1041
+ break;
1042
+ try {
1043
+ // eslint-disable-next-line no-await-in-loop
1044
+ const refSearch = await client.search({ header: { References: msgId } }, { uid: true });
1045
+ if (Array.isArray(refSearch)) {
1046
+ refSearch.forEach((uid) => {
1047
+ foundUids.add(uid);
1048
+ });
1049
+ }
1050
+ // eslint-disable-next-line no-await-in-loop
1051
+ const replySearch = await client.search({ header: { 'In-Reply-To': msgId } }, { uid: true });
1052
+ if (Array.isArray(replySearch)) {
1053
+ replySearch.forEach((uid) => {
1054
+ foundUids.add(uid);
1055
+ });
1056
+ }
1057
+ }
1058
+ catch {
1059
+ // Header search may fail on some servers
1060
+ }
1061
+ }
1062
+ if (foundUids.size === 0) {
1063
+ return {
1064
+ threadId: messageId,
1065
+ messages: [],
1066
+ participants: [],
1067
+ messageCount: 0,
1068
+ };
1069
+ }
1070
+ // Fetch full content for all thread messages
1071
+ const uidList = Array.from(foundUids).slice(0, MAX_THREAD_MESSAGES);
1072
+ const range = uidList.join(',');
1073
+ const messages = [];
1074
+ // eslint-disable-next-line no-restricted-syntax
1075
+ for await (const msg of client.fetch(range, {
1076
+ uid: true,
1077
+ envelope: true,
1078
+ flags: true,
1079
+ bodyStructure: true,
1080
+ source: true,
1081
+ }, { uid: true })) {
1082
+ const raw = msg;
1083
+ const uid = raw.uid;
1084
+ messages.push(await messageToEmail(raw, client, uid));
1085
+ }
1086
+ // Sort chronologically
1087
+ messages.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
1088
+ // Extract unique participants
1089
+ const participantMap = new Map();
1090
+ messages.forEach((email) => {
1091
+ const addParticipant = (addr) => {
1092
+ const key = addr.address.toLowerCase();
1093
+ if (!participantMap.has(key)) {
1094
+ participantMap.set(key, addr);
1095
+ }
1096
+ };
1097
+ addParticipant(email.from);
1098
+ email.to.forEach(addParticipant);
1099
+ email.cc?.forEach(addParticipant);
1100
+ });
1101
+ return {
1102
+ threadId: messageId,
1103
+ messages,
1104
+ participants: Array.from(participantMap.values()),
1105
+ messageCount: messages.length,
1106
+ };
1107
+ }
1108
+ finally {
1109
+ lock.release();
1110
+ }
1111
+ }
1112
+ // -------------------------------------------------------------------------
1113
+ // Contact extraction
1114
+ // -------------------------------------------------------------------------
1115
+ async extractContacts(accountName, options = {}) {
1116
+ const client = await this.connections.getImapClient(accountName);
1117
+ const mailbox = options.mailbox ?? 'INBOX';
1118
+ const limit = Math.min(options.limit ?? 100, 500);
1119
+ const lock = await client.getMailboxLock(mailbox);
1120
+ try {
1121
+ // Search for all messages, take the latest N
1122
+ const searchResult = await client.search({ all: true }, { uid: true });
1123
+ const uids = Array.isArray(searchResult) ? searchResult : [];
1124
+ if (uids.length === 0)
1125
+ return [];
1126
+ uids.sort((a, b) => b - a);
1127
+ const targetUids = uids.slice(0, limit);
1128
+ const range = targetUids.join(',');
1129
+ const contactMap = new Map();
1130
+ // eslint-disable-next-line no-restricted-syntax
1131
+ for await (const msg of client.fetch(range, { uid: true, envelope: true }, { uid: true })) {
1132
+ const envelope = (msg.envelope ?? {});
1133
+ const date = envelope.date ? new Date(envelope.date) : new Date();
1134
+ const addressLists = [
1135
+ envelope.from,
1136
+ envelope.to,
1137
+ envelope.cc,
1138
+ ];
1139
+ addressLists.forEach((addrs) => {
1140
+ (addrs ?? []).forEach((addr) => {
1141
+ if (!addr.address)
1142
+ return;
1143
+ const key = addr.address.toLowerCase();
1144
+ const existing = contactMap.get(key);
1145
+ if (existing) {
1146
+ existing.frequency += 1;
1147
+ if (date > existing.lastSeen) {
1148
+ existing.lastSeen = date;
1149
+ if (addr.name)
1150
+ existing.name = addr.name;
1151
+ }
1152
+ }
1153
+ else {
1154
+ contactMap.set(key, {
1155
+ name: addr.name ?? undefined,
1156
+ email: addr.address,
1157
+ frequency: 1,
1158
+ lastSeen: date,
1159
+ });
1160
+ }
1161
+ });
1162
+ });
1163
+ }
1164
+ // Sort by frequency descending
1165
+ const contacts = Array.from(contactMap.values())
1166
+ .sort((a, b) => b.frequency - a.frequency)
1167
+ .map((c) => ({
1168
+ name: c.name,
1169
+ email: c.email,
1170
+ frequency: c.frequency,
1171
+ lastSeen: c.lastSeen.toISOString(),
1172
+ }));
1173
+ return contacts;
1174
+ }
1175
+ finally {
1176
+ lock.release();
1177
+ }
1178
+ }
1179
+ // -------------------------------------------------------------------------
1180
+ // Email analytics
1181
+ // -------------------------------------------------------------------------
1182
+ async getEmailStats(accountName, mailbox, period) {
1183
+ const client = await this.connections.getImapClient(accountName);
1184
+ const now = new Date();
1185
+ const since = new Date(now);
1186
+ if (period === 'day')
1187
+ since.setDate(since.getDate() - 1);
1188
+ else if (period === 'week')
1189
+ since.setDate(since.getDate() - 7);
1190
+ else
1191
+ since.setMonth(since.getMonth() - 1);
1192
+ const lock = await client.getMailboxLock(mailbox);
1193
+ try {
1194
+ // Date-range search
1195
+ const uids = await client
1196
+ .search({ since }, { uid: true })
1197
+ .then((r) => (Array.isArray(r) ? r : []));
1198
+ if (uids.length === 0) {
1199
+ return {
1200
+ period,
1201
+ dateRange: {
1202
+ from: since.toISOString().split('T')[0],
1203
+ to: now.toISOString().split('T')[0],
1204
+ },
1205
+ totalReceived: 0,
1206
+ unreadCount: 0,
1207
+ flaggedCount: 0,
1208
+ topSenders: [],
1209
+ dailyVolume: [],
1210
+ hasAttachmentsCount: 0,
1211
+ avgPerDay: 0,
1212
+ };
1213
+ }
1214
+ const range = uids.join(',');
1215
+ const senderMap = new Map();
1216
+ const dailyMap = new Map();
1217
+ let unread = 0;
1218
+ let flagged = 0;
1219
+ let withAttachments = 0;
1220
+ // eslint-disable-next-line no-restricted-syntax
1221
+ for await (const msg of client.fetch(range, {
1222
+ uid: true,
1223
+ envelope: true,
1224
+ flags: true,
1225
+ bodyStructure: true,
1226
+ }, { uid: true })) {
1227
+ const envelope = (msg.envelope ?? {});
1228
+ const flags = (msg.flags ??
1229
+ new Set());
1230
+ const { bodyStructure } = msg;
1231
+ // Count flags
1232
+ if (!flags.has('\\Seen'))
1233
+ unread += 1;
1234
+ if (flags.has('\\Flagged'))
1235
+ flagged += 1;
1236
+ if (hasAttachments(bodyStructure))
1237
+ withAttachments += 1;
1238
+ // Track sender
1239
+ const fromList = (envelope.from ?? []);
1240
+ if (fromList.length > 0 && fromList[0].address) {
1241
+ const key = fromList[0].address.toLowerCase();
1242
+ const existing = senderMap.get(key);
1243
+ if (existing) {
1244
+ existing.count += 1;
1245
+ }
1246
+ else {
1247
+ senderMap.set(key, {
1248
+ email: fromList[0].address,
1249
+ name: fromList[0].name,
1250
+ count: 1,
1251
+ });
1252
+ }
1253
+ }
1254
+ // Track daily volume
1255
+ const date = envelope.date ? new Date(envelope.date) : new Date();
1256
+ const dayKey = date.toISOString().split('T')[0];
1257
+ dailyMap.set(dayKey, (dailyMap.get(dayKey) ?? 0) + 1);
1258
+ }
1259
+ const topSenders = Array.from(senderMap.values())
1260
+ .sort((a, b) => b.count - a.count)
1261
+ .slice(0, 10);
1262
+ const dailyVolume = Array.from(dailyMap.entries())
1263
+ .map(([date, count]) => ({ date, count }))
1264
+ .sort((a, b) => a.date.localeCompare(b.date));
1265
+ const days = Math.max(1, dailyVolume.length);
1266
+ return {
1267
+ period,
1268
+ dateRange: {
1269
+ from: since.toISOString().split('T')[0],
1270
+ to: now.toISOString().split('T')[0],
1271
+ },
1272
+ totalReceived: uids.length,
1273
+ unreadCount: unread,
1274
+ flaggedCount: flagged,
1275
+ topSenders,
1276
+ dailyVolume,
1277
+ hasAttachmentsCount: withAttachments,
1278
+ avgPerDay: Math.round((uids.length / days) * 10) / 10,
1279
+ };
1280
+ }
1281
+ finally {
1282
+ lock.release();
1283
+ }
1284
+ }
1285
+ // -------------------------------------------------------------------------
1286
+ // Quota
1287
+ // -------------------------------------------------------------------------
1288
+ async getQuota(accountName) {
1289
+ const client = await this.connections.getImapClient(accountName);
1290
+ try {
1291
+ const quota = await client.getQuotaForMailbox('INBOX');
1292
+ if (!quota?.storage?.limit)
1293
+ return null;
1294
+ const usedMb = Math.round((quota.storage.usage ?? 0) / 1024);
1295
+ const totalMb = Math.round(quota.storage.limit / 1024);
1296
+ return {
1297
+ usedMb,
1298
+ totalMb,
1299
+ percentage: totalMb > 0 ? Math.round((usedMb / totalMb) * 100) : 0,
1300
+ };
1301
+ }
1302
+ catch {
1303
+ return null;
1304
+ }
1305
+ }
1306
+ // -------------------------------------------------------------------------
1307
+ // Capabilities
1308
+ // -------------------------------------------------------------------------
1309
+ async getCapabilities(accountName) {
1310
+ const client = await this.connections.getImapClient(accountName);
1311
+ try {
1312
+ // ImapFlow exposes capabilities as a Set on the client
1313
+ const caps = client.capabilities;
1314
+ return caps ? Array.from(caps) : [];
1315
+ }
1316
+ catch {
1317
+ return [];
1318
+ }
1319
+ }
1320
+ // -------------------------------------------------------------------------
1321
+ // Calendar part extraction
1322
+ // -------------------------------------------------------------------------
1323
+ /* eslint-disable no-await-in-loop, no-restricted-syntax -- Sequential IMAP fetch required */
1324
+ async getCalendarParts(accountName, mailbox, emailId) {
1325
+ const client = await this.connections.getImapClient(accountName);
1326
+ const lock = await client.getMailboxLock(mailbox);
1327
+ try {
1328
+ const icsContents = [];
1329
+ // Fetch body structure
1330
+ for await (const msg of client.fetch(emailId, { uid: true, bodyStructure: true }, { uid: true })) {
1331
+ const structure = msg.bodyStructure;
1332
+ const parts = this.findCalendarParts(structure);
1333
+ // Fetch each calendar part
1334
+ for (const partId of parts) {
1335
+ for await (const partMsg of client.fetch(emailId, { uid: true, bodyParts: [partId] }, { uid: true })) {
1336
+ const bodyParts = partMsg.bodyParts;
1337
+ if (bodyParts) {
1338
+ bodyParts.forEach((buf) => {
1339
+ icsContents.push(buf.toString('utf-8'));
1340
+ });
1341
+ }
1342
+ }
1343
+ }
1344
+ }
1345
+ return icsContents;
1346
+ }
1347
+ finally {
1348
+ lock.release();
1349
+ }
1350
+ }
1351
+ /* eslint-enable no-await-in-loop, no-restricted-syntax */
1352
+ /**
1353
+ * Recursively find body parts with text/calendar content type.
1354
+ */
1355
+ findCalendarParts(structure, prefix = '') {
1356
+ if (!structure || typeof structure !== 'object')
1357
+ return [];
1358
+ const s = structure;
1359
+ const parts = [];
1360
+ const type = s.type?.toLowerCase() ?? '';
1361
+ const subtype = s.subtype?.toLowerCase() ?? '';
1362
+ const disposition = s.disposition?.toLowerCase() ?? '';
1363
+ // Check for text/calendar part
1364
+ if (type === 'text' && subtype === 'calendar') {
1365
+ const partId = s.part;
1366
+ if (partId)
1367
+ parts.push(partId);
1368
+ else if (prefix)
1369
+ parts.push(prefix);
1370
+ }
1371
+ // Check for .ics attachment
1372
+ if (disposition === 'attachment' && typeof s.dispositionParameters === 'object') {
1373
+ const params = s.dispositionParameters;
1374
+ const filename = params.filename ?? '';
1375
+ if (filename.toLowerCase().endsWith('.ics')) {
1376
+ const partId = s.part;
1377
+ if (partId)
1378
+ parts.push(partId);
1379
+ else if (prefix)
1380
+ parts.push(prefix);
1381
+ }
1382
+ }
1383
+ // Recurse into child nodes
1384
+ if (Array.isArray(s.childNodes)) {
1385
+ s.childNodes.forEach((child, i) => {
1386
+ const childPrefix = prefix ? `${prefix}.${i + 1}` : `${i + 1}`;
1387
+ parts.push(...this.findCalendarParts(child, childPrefix));
1388
+ });
1389
+ }
1390
+ return parts;
1391
+ }
1392
+ }
1393
+ //# sourceMappingURL=imap.service.js.map