zele 0.3.0 → 0.3.6

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 (157) hide show
  1. package/README.md +1 -1
  2. package/bin/zele +27 -0
  3. package/dist/api-utils.d.ts +51 -2
  4. package/dist/api-utils.js +89 -3
  5. package/dist/api-utils.js.map +1 -1
  6. package/dist/auth.d.ts +27 -6
  7. package/dist/auth.js +185 -129
  8. package/dist/auth.js.map +1 -1
  9. package/dist/calendar-client.d.ts +16 -9
  10. package/dist/calendar-client.js +163 -59
  11. package/dist/calendar-client.js.map +1 -1
  12. package/dist/cli.js +34 -1
  13. package/dist/cli.js.map +1 -1
  14. package/dist/commands/attachment.js +17 -15
  15. package/dist/commands/attachment.js.map +1 -1
  16. package/dist/commands/auth-cmd.js +20 -9
  17. package/dist/commands/auth-cmd.js.map +1 -1
  18. package/dist/commands/calendar.js +67 -78
  19. package/dist/commands/calendar.js.map +1 -1
  20. package/dist/commands/draft.js +25 -18
  21. package/dist/commands/draft.js.map +1 -1
  22. package/dist/commands/label.js +33 -45
  23. package/dist/commands/label.js.map +1 -1
  24. package/dist/commands/mail-actions.js +11 -13
  25. package/dist/commands/mail-actions.js.map +1 -1
  26. package/dist/commands/mail.js +119 -127
  27. package/dist/commands/mail.js.map +1 -1
  28. package/dist/commands/profile.js +18 -21
  29. package/dist/commands/profile.js.map +1 -1
  30. package/dist/commands/watch.js +33 -261
  31. package/dist/commands/watch.js.map +1 -1
  32. package/dist/db.js +12 -13
  33. package/dist/db.js.map +1 -1
  34. package/dist/generated/browser.d.ts +12 -27
  35. package/dist/generated/client.d.ts +13 -28
  36. package/dist/generated/client.js +1 -1
  37. package/dist/generated/commonInputTypes.d.ts +90 -26
  38. package/dist/generated/enums.d.ts +0 -4
  39. package/dist/generated/enums.js +0 -3
  40. package/dist/generated/enums.js.map +1 -1
  41. package/dist/generated/internal/class.d.ts +22 -55
  42. package/dist/generated/internal/class.js +12 -4
  43. package/dist/generated/internal/class.js.map +1 -1
  44. package/dist/generated/internal/prismaNamespace.d.ts +272 -511
  45. package/dist/generated/internal/prismaNamespace.js +54 -66
  46. package/dist/generated/internal/prismaNamespace.js.map +1 -1
  47. package/dist/generated/internal/prismaNamespaceBrowser.d.ts +60 -74
  48. package/dist/generated/internal/prismaNamespaceBrowser.js +50 -62
  49. package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
  50. package/dist/generated/models/Account.d.ts +1637 -0
  51. package/dist/generated/models/Account.js +2 -0
  52. package/dist/generated/models/Account.js.map +1 -0
  53. package/dist/generated/models/CalendarList.d.ts +1161 -0
  54. package/dist/generated/models/CalendarList.js +2 -0
  55. package/dist/generated/models/CalendarList.js.map +1 -0
  56. package/dist/generated/models/Label.d.ts +1161 -0
  57. package/dist/generated/models/Label.js +2 -0
  58. package/dist/generated/models/Label.js.map +1 -0
  59. package/dist/generated/models/Profile.d.ts +1269 -0
  60. package/dist/generated/models/Profile.js +2 -0
  61. package/dist/generated/models/Profile.js.map +1 -0
  62. package/dist/generated/models/SyncState.d.ts +1130 -0
  63. package/dist/generated/models/SyncState.js +2 -0
  64. package/dist/generated/models/SyncState.js.map +1 -0
  65. package/dist/generated/models/Thread.d.ts +1608 -0
  66. package/dist/generated/models/Thread.js +2 -0
  67. package/dist/generated/models/Thread.js.map +1 -0
  68. package/dist/generated/models.d.ts +6 -9
  69. package/dist/gmail-client.d.ts +119 -94
  70. package/dist/gmail-client.js +862 -322
  71. package/dist/gmail-client.js.map +1 -1
  72. package/dist/mail-tui.d.ts +1 -0
  73. package/dist/mail-tui.js +517 -0
  74. package/dist/mail-tui.js.map +1 -0
  75. package/dist/output.d.ts +6 -0
  76. package/dist/output.js +124 -11
  77. package/dist/output.js.map +1 -1
  78. package/package.json +39 -11
  79. package/schema.prisma +81 -113
  80. package/src/api-utils.ts +103 -5
  81. package/src/auth.ts +224 -143
  82. package/src/calendar-client.ts +196 -89
  83. package/src/cli.ts +39 -1
  84. package/src/commands/attachment.ts +18 -19
  85. package/src/commands/auth-cmd.ts +19 -9
  86. package/src/commands/calendar.ts +42 -85
  87. package/src/commands/draft.ts +19 -22
  88. package/src/commands/label.ts +21 -57
  89. package/src/commands/mail-actions.ts +11 -19
  90. package/src/commands/mail.ts +109 -148
  91. package/src/commands/profile.ts +12 -28
  92. package/src/commands/watch.ts +37 -304
  93. package/src/db.ts +13 -16
  94. package/src/generated/browser.ts +49 -0
  95. package/src/generated/client.ts +71 -0
  96. package/src/generated/commonInputTypes.ts +332 -0
  97. package/src/generated/enums.ts +17 -0
  98. package/src/generated/internal/class.ts +250 -0
  99. package/src/generated/internal/prismaNamespace.ts +1198 -0
  100. package/src/generated/internal/prismaNamespaceBrowser.ts +169 -0
  101. package/src/generated/models/Account.ts +1848 -0
  102. package/src/generated/models/CalendarList.ts +1331 -0
  103. package/src/generated/models/Label.ts +1331 -0
  104. package/src/generated/models/Profile.ts +1439 -0
  105. package/src/generated/models/SyncState.ts +1300 -0
  106. package/src/generated/models/Thread.ts +1787 -0
  107. package/src/generated/models.ts +17 -0
  108. package/src/gmail-client.test.ts +59 -0
  109. package/src/gmail-client.ts +1034 -429
  110. package/src/mail-tui.tsx +1061 -0
  111. package/src/output.test.ts +1093 -0
  112. package/src/output.ts +128 -13
  113. package/src/schema.sql +58 -68
  114. package/src/test-fixtures/email-html/safe-claude-event.html +28 -0
  115. package/src/test-fixtures/email-html/safe-product-announcement.html +25 -0
  116. package/src/test-fixtures/email-html/safe-tracked-links.html +27 -0
  117. package/src/test-fixtures/email-html-snapshots/safe-claude-event.html.md +9 -0
  118. package/src/test-fixtures/email-html-snapshots/safe-product-announcement.html.md +13 -0
  119. package/src/test-fixtures/email-html-snapshots/safe-tracked-links.html.md +7 -0
  120. package/AGENTS.md +0 -26
  121. package/CHANGELOG.md +0 -43
  122. package/dist/generated/models/accounts.d.ts +0 -2000
  123. package/dist/generated/models/accounts.js +0 -2
  124. package/dist/generated/models/accounts.js.map +0 -1
  125. package/dist/generated/models/calendar_events.d.ts +0 -1433
  126. package/dist/generated/models/calendar_events.js +0 -2
  127. package/dist/generated/models/calendar_events.js.map +0 -1
  128. package/dist/generated/models/calendar_lists.d.ts +0 -1131
  129. package/dist/generated/models/calendar_lists.js +0 -2
  130. package/dist/generated/models/calendar_lists.js.map +0 -1
  131. package/dist/generated/models/label_counts.d.ts +0 -1131
  132. package/dist/generated/models/label_counts.js +0 -2
  133. package/dist/generated/models/label_counts.js.map +0 -1
  134. package/dist/generated/models/labels.d.ts +0 -1131
  135. package/dist/generated/models/labels.js +0 -2
  136. package/dist/generated/models/labels.js.map +0 -1
  137. package/dist/generated/models/profiles.d.ts +0 -1131
  138. package/dist/generated/models/profiles.js +0 -2
  139. package/dist/generated/models/profiles.js.map +0 -1
  140. package/dist/generated/models/sync_states.d.ts +0 -1107
  141. package/dist/generated/models/sync_states.js +0 -2
  142. package/dist/generated/models/sync_states.js.map +0 -1
  143. package/dist/generated/models/thread_lists.d.ts +0 -1404
  144. package/dist/generated/models/thread_lists.js +0 -2
  145. package/dist/generated/models/thread_lists.js.map +0 -1
  146. package/dist/generated/models/threads.d.ts +0 -1247
  147. package/dist/generated/models/threads.js +0 -2
  148. package/dist/generated/models/threads.js.map +0 -1
  149. package/dist/gmail-cache.d.ts +0 -60
  150. package/dist/gmail-cache.js +0 -264
  151. package/dist/gmail-cache.js.map +0 -1
  152. package/docs/gogcli-gmail-implementation.md +0 -599
  153. package/scripts/test-device-code-clients.ts +0 -186
  154. package/scripts/test-micropython-scopes.ts +0 -72
  155. package/scripts/test-oauth-clients.ts +0 -257
  156. package/src/gmail-cache.ts +0 -339
  157. package/tsconfig.json +0 -16
@@ -0,0 +1,1093 @@
1
+ // Tests for htmlToMarkdown email rendering.
2
+ // Uses inline snapshots to capture how real-world email HTML is converted.
3
+
4
+ import { expect, test } from 'vitest'
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { htmlToMarkdown, renderEmailBody, replyParser } from './output.js'
9
+
10
+ const htmlFixtureDir = fileURLToPath(new URL('./test-fixtures/email-html', import.meta.url))
11
+ const htmlSnapshotDir = fileURLToPath(new URL('./test-fixtures/email-html-snapshots', import.meta.url))
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Simple HTML
15
+ // ---------------------------------------------------------------------------
16
+
17
+ test('simple inline tags', () => {
18
+ expect(htmlToMarkdown('<p>Hello <b>bold</b> and <em>italic</em> world</p>')).toMatchInlineSnapshot(`"Hello **bold** and *italic* world"`)
19
+ })
20
+
21
+ test('headings and paragraphs', () => {
22
+ expect(htmlToMarkdown('<h1>Title</h1><p>Paragraph one.</p><h2>Subtitle</h2><p>Paragraph two.</p>')).toMatchInlineSnapshot(`
23
+ "# Title
24
+
25
+ Paragraph one.
26
+
27
+ ## Subtitle
28
+
29
+ Paragraph two."
30
+ `)
31
+ })
32
+
33
+ test('links', () => {
34
+ expect(htmlToMarkdown('<p>Visit <a href="https://example.com">our site</a> today.</p>')).toMatchInlineSnapshot(`"Visit [our site](https://example.com) today."`)
35
+ })
36
+
37
+ test('unordered list', () => {
38
+ expect(htmlToMarkdown('<ul><li>One</li><li>Two</li><li>Three</li></ul>')).toMatchInlineSnapshot(`
39
+ "* One
40
+ * Two
41
+ * Three"
42
+ `)
43
+ })
44
+
45
+ test('ordered list', () => {
46
+ expect(htmlToMarkdown('<ol><li>First</li><li>Second</li><li>Third</li></ol>')).toMatchInlineSnapshot(`
47
+ "1. First
48
+ 2. Second
49
+ 3. Third"
50
+ `)
51
+ })
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Email-specific: tracking pixels
55
+ // ---------------------------------------------------------------------------
56
+
57
+ test('strips 1x1 tracking pixels', () => {
58
+ expect(htmlToMarkdown('<p>Hello</p><img src="https://track.example.com/pixel.gif" width="1" height="1"><p>World</p>')).toMatchInlineSnapshot(`
59
+ "Hello
60
+
61
+ World"
62
+ `)
63
+ })
64
+
65
+ test('strips beacon/tracker images by URL', () => {
66
+ expect(htmlToMarkdown('<p>Content</p><img src="https://analytics.example.com/beacon?id=123">')).toMatchInlineSnapshot(`"Content"`)
67
+ })
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Email-specific: image alt text
71
+ // ---------------------------------------------------------------------------
72
+
73
+ test('replaces images with alt text placeholder', () => {
74
+ expect(htmlToMarkdown('<img src="https://example.com/logo.png" alt="Company Logo">')).toMatchInlineSnapshot(`"[image: Company Logo]"`)
75
+ })
76
+
77
+ test('strips images without alt text', () => {
78
+ expect(htmlToMarkdown('<p>Before</p><img src="https://example.com/spacer.png"><p>After</p>')).toMatchInlineSnapshot(`
79
+ "Before
80
+
81
+ After"
82
+ `)
83
+ })
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Email-specific: layout tables
87
+ // ---------------------------------------------------------------------------
88
+
89
+ test('unwraps layout table with width attribute', () => {
90
+ expect(htmlToMarkdown(`
91
+ <table width="600" cellpadding="0" cellspacing="0">
92
+ <tr><td>
93
+ <h1>Welcome</h1>
94
+ <p>This is inside a layout table.</p>
95
+ </td></tr>
96
+ </table>
97
+ `)).toMatchInlineSnapshot(`
98
+ "# Welcome
99
+
100
+ This is inside a layout table."
101
+ `)
102
+ })
103
+
104
+ test('unwraps nested layout tables', () => {
105
+ expect(htmlToMarkdown(`
106
+ <table width="600" align="center">
107
+ <tr><td>
108
+ <table width="100%">
109
+ <tr><td>Column 1</td></tr>
110
+ </table>
111
+ <table width="100%">
112
+ <tr><td>Column 2</td></tr>
113
+ </table>
114
+ </td></tr>
115
+ </table>
116
+ `)).toMatchInlineSnapshot(`
117
+ "Column 1
118
+
119
+ Column 2"
120
+ `)
121
+ })
122
+
123
+ test('unwraps table with role=presentation', () => {
124
+ expect(htmlToMarkdown(`
125
+ <table role="presentation">
126
+ <tr><td><p>Presented content</p></td></tr>
127
+ </table>
128
+ `)).toMatchInlineSnapshot(`"Presented content"`)
129
+ })
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Email-specific: hidden elements
133
+ // ---------------------------------------------------------------------------
134
+
135
+ test('strips display:none elements', () => {
136
+ expect(htmlToMarkdown('<div style="display:none">Hidden</div><p>Visible</p>')).toMatchInlineSnapshot(`"Visible"`)
137
+ })
138
+
139
+ test('strips mso-hide:all elements', () => {
140
+ expect(htmlToMarkdown('<span style="mso-hide:all">MSO only</span><p>Regular</p>')).toMatchInlineSnapshot(`"Regular"`)
141
+ })
142
+
143
+ test('strips preheader spans', () => {
144
+ expect(htmlToMarkdown('<span class="preheader">Preview text here</span><p>Email body</p>')).toMatchInlineSnapshot(`"Email body"`)
145
+ })
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Email-specific: quoted replies
149
+ // ---------------------------------------------------------------------------
150
+
151
+ test('strips Gmail quoted reply blocks', () => {
152
+ expect(htmlToMarkdown(`
153
+ <p>This is my reply.</p>
154
+ <div class="gmail_quote">
155
+ <p>On Mon, Jan 1 2026, someone wrote:</p>
156
+ <blockquote><p>Original message here</p></blockquote>
157
+ </div>
158
+ `)).toMatchInlineSnapshot(`"This is my reply."`)
159
+ })
160
+
161
+ test('strips Gmail extra blocks', () => {
162
+ expect(htmlToMarkdown(`
163
+ <p>Reply text.</p>
164
+ <div class="gmail_extra">
165
+ <div class="gmail_quote">
166
+ <p>Quoted content</p>
167
+ </div>
168
+ </div>
169
+ `)).toMatchInlineSnapshot(`"Reply text."`)
170
+ })
171
+
172
+ test('strips Outlook blockquote type=cite', () => {
173
+ expect(htmlToMarkdown(`
174
+ <p>My response.</p>
175
+ <blockquote type="cite">
176
+ <p>Original text being quoted</p>
177
+ </blockquote>
178
+ `)).toMatchInlineSnapshot(`"My response."`)
179
+ })
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Email-specific: Outlook conditional comments
183
+ // ---------------------------------------------------------------------------
184
+
185
+ test('strips Outlook conditional comments', () => {
186
+ expect(htmlToMarkdown(`
187
+ <p>Normal content</p>
188
+ <![if mso]><table><tr><td>MSO only</td></tr></table><![endif]>
189
+ <p>More content</p>
190
+ `)).toMatchInlineSnapshot(`
191
+ "Normal content
192
+
193
+ More content"
194
+ `)
195
+ })
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Email-specific: style/script/head tags
199
+ // ---------------------------------------------------------------------------
200
+
201
+ test('strips style tags', () => {
202
+ expect(htmlToMarkdown('<style>.foo { color: red; }</style><p>Content</p>')).toMatchInlineSnapshot(`"Content"`)
203
+ })
204
+
205
+ test('strips script tags', () => {
206
+ expect(htmlToMarkdown('<script>alert("xss")</script><p>Safe content</p>')).toMatchInlineSnapshot(`"Safe content"`)
207
+ })
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Real-world: Google security alert (simplified)
211
+ // ---------------------------------------------------------------------------
212
+
213
+ test('Google security alert email', () => {
214
+ expect(htmlToMarkdown(`
215
+ <table width="100%" style="min-width:348px" border="0" cellspacing="0" cellpadding="0">
216
+ <tr><td>
217
+ <table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
218
+ <tr><td>
219
+ <img src="https://accounts.google.com/logo.png" alt="Google" width="75" height="24">
220
+ </td></tr>
221
+ <tr><td>
222
+ <h2>You allowed Thunderbird access to your Google Account</h2>
223
+ <p>user@gmail.com</p>
224
+ <p>If you didn't allow Thunderbird, someone else may be trying to access your account.</p>
225
+ <p><a href="https://myaccount.google.com/alert">Check activity</a></p>
226
+ </td></tr>
227
+ <tr><td>
228
+ <p style="font-size:11px;color:#777">
229
+ © 2026 Google Ireland Ltd., Gordon House, Barrow Street, Dublin 4, Ireland
230
+ </p>
231
+ </td></tr>
232
+ </table>
233
+ </td></tr>
234
+ </table>
235
+ `)).toMatchInlineSnapshot(`
236
+ "[image: Google]
237
+
238
+ ## You allowed Thunderbird access to your Google Account
239
+
240
+ user@gmail.com
241
+
242
+ If you didn't allow Thunderbird, someone else may be trying to access your account.
243
+
244
+ [Check activity](https://myaccount.google.com/alert)
245
+
246
+ © 2026 Google Ireland Ltd., Gordon House, Barrow Street, Dublin 4, Ireland"
247
+ `)
248
+ })
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Real-world: Stripe receipt (simplified)
252
+ // ---------------------------------------------------------------------------
253
+
254
+ test('Stripe receipt email', () => {
255
+ expect(htmlToMarkdown(`
256
+ <table width="600" align="center" cellpadding="0" cellspacing="0" border="0">
257
+ <tr><td>
258
+ <table width="100%" border="0" cellpadding="0">
259
+ <tr><td><h2>Receipt from X</h2></td></tr>
260
+ <tr><td><p><strong>$16.00</strong></p></td></tr>
261
+ <tr><td><p>Paid February 9, 2026</p></td></tr>
262
+ </table>
263
+ <table width="100%" border="0" cellpadding="0">
264
+ <tr><td>Receipt number</td><td>2383-9009-8737</td></tr>
265
+ <tr><td>Payment method</td><td>Mastercard - 8441</td></tr>
266
+ </table>
267
+ <table width="100%" border="0" cellpadding="0">
268
+ <tr><td>X Premium Plus</td><td>$40.00</td></tr>
269
+ <tr><td>Discount (60% off)</td><td>-$24.00</td></tr>
270
+ <tr><td><strong>Total</strong></td><td><strong>$16.00</strong></td></tr>
271
+ </table>
272
+ <p>Questions? <a href="https://help.x.com">Visit support</a></p>
273
+ </td></tr>
274
+ </table>
275
+ `)).toMatchInlineSnapshot(`
276
+ "## Receipt from X
277
+
278
+ **$16.00**
279
+
280
+ Paid February 9, 2026
281
+
282
+ Receipt number
283
+
284
+ 2383-9009-8737
285
+
286
+ Payment method
287
+
288
+ Mastercard - 8441
289
+
290
+ X Premium Plus
291
+
292
+ $40.00
293
+
294
+ Discount (60% off)
295
+
296
+ -$24.00
297
+
298
+ **Total**
299
+
300
+ **$16.00**
301
+
302
+ Questions? [Visit support](https://help.x.com)"
303
+ `)
304
+ })
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Real-world: newsletter with CTA buttons
308
+ // ---------------------------------------------------------------------------
309
+
310
+ test('newsletter with headings and CTAs', () => {
311
+ expect(htmlToMarkdown(`
312
+ <table width="600" align="center" cellpadding="0" cellspacing="0">
313
+ <tr><td>
314
+ <p>Hi there,</p>
315
+ <p>We've launched a new <strong>AI Assistant</strong>.</p>
316
+ <table width="100%" cellpadding="0"><tr><td>
317
+ <a href="https://app.example.com/try" style="background:#007bff;color:#fff;padding:12px 24px;text-decoration:none;border-radius:4px">Try it now</a>
318
+ </td></tr></table>
319
+ <h3>Getting started</h3>
320
+ <p>Click the button above to begin.</p>
321
+ <ul>
322
+ <li>Search by meaning</li>
323
+ <li>Summarize articles</li>
324
+ <li>Organize bookmarks</li>
325
+ </ul>
326
+ <p><a href="https://example.com/unsubscribe">Unsubscribe</a></p>
327
+ </td></tr>
328
+ </table>
329
+ `)).toMatchInlineSnapshot(`
330
+ "Hi there,
331
+
332
+ We've launched a new **AI Assistant**.
333
+
334
+ [Try it now](https://app.example.com/try)
335
+
336
+ ### Getting started
337
+
338
+ Click the button above to begin.
339
+
340
+ * Search by meaning
341
+ * Summarize articles
342
+ * Organize bookmarks
343
+
344
+ [Unsubscribe](https://example.com/unsubscribe)"
345
+ `)
346
+ })
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Combined: hidden + tracking + layout in one email
350
+ // ---------------------------------------------------------------------------
351
+
352
+ test('combined email noise removal', () => {
353
+ expect(htmlToMarkdown(`
354
+ <span class="preheader" style="display:none">Preview: Check out our deals!</span>
355
+ <img src="https://track.example.com/open?id=abc" width="1" height="1">
356
+ <table width="600" align="center" cellpadding="0" cellspacing="0">
357
+ <tr><td>
358
+ <div style="display:none">Hidden duplicate content</div>
359
+ <h1>Big Sale!</h1>
360
+ <p>Everything is <b>50% off</b> today.</p>
361
+ <p><a href="https://shop.example.com">Shop now</a></p>
362
+ </td></tr>
363
+ </table>
364
+ <img src="https://pixel.example.com/beacon" width="0" height="0">
365
+ `)).toMatchInlineSnapshot(`
366
+ "# Big Sale!
367
+
368
+ Everything is **50% off** today.
369
+
370
+ [Shop now](https://shop.example.com)"
371
+ `)
372
+ })
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // HTML encoded entities
376
+ // ---------------------------------------------------------------------------
377
+
378
+ test('numeric entity &#39; (apostrophe)', () => {
379
+ expect(htmlToMarkdown('<p>It&#39;s a beautiful day</p>')).toMatchInlineSnapshot(`"It's a beautiful day"`)
380
+ })
381
+
382
+ test('numeric entity &#34; (double quote)', () => {
383
+ expect(htmlToMarkdown('<p>She said &#34;hello&#34; to me</p>')).toMatchInlineSnapshot(`"She said "hello" to me"`)
384
+ })
385
+
386
+ test('named entity &amp;', () => {
387
+ expect(htmlToMarkdown('<p>Tom &amp; Jerry</p>')).toMatchInlineSnapshot(`"Tom & Jerry"`)
388
+ })
389
+
390
+ test('named entity &lt; and &gt;', () => {
391
+ expect(htmlToMarkdown('<p>Use &lt;div&gt; for layout</p>')).toMatchInlineSnapshot(`"Use <div> for layout"`)
392
+ })
393
+
394
+ test('named entity &nbsp;', () => {
395
+ expect(htmlToMarkdown('<p>Hello&nbsp;World</p>')).toMatchInlineSnapshot(`"Hello World"`)
396
+ })
397
+
398
+ test('hex entity &#x27; (apostrophe)', () => {
399
+ expect(htmlToMarkdown('<p>It&#x27;s working</p>')).toMatchInlineSnapshot(`"It's working"`)
400
+ })
401
+
402
+ test('mixed entities in real email subject', () => {
403
+ expect(htmlToMarkdown('<p>Your order #1234 &mdash; &#34;Premium Plan&#34; isn&#39;t ready</p>')).toMatchInlineSnapshot(`"Your order #1234 — "Premium Plan" isn't ready"`)
404
+ })
405
+
406
+ test('entities inside links', () => {
407
+ expect(htmlToMarkdown('<a href="https://example.com?foo=1&amp;bar=2">Click &amp; Go</a>')).toMatchInlineSnapshot(`"[Click & Go](https://example.com?foo=1&bar=2)"`)
408
+ })
409
+
410
+ test('entities inside layout tables', () => {
411
+ expect(htmlToMarkdown(`
412
+ <table width="600">
413
+ <tr><td><p>You&#39;ve been selected for a &quot;special&quot; offer!</p></td></tr>
414
+ </table>
415
+ `)).toMatchInlineSnapshot(`"You've been selected for a "special" offer!"`)
416
+ })
417
+
418
+ // ---------------------------------------------------------------------------
419
+ // renderEmailBody: plain text pass-through
420
+ // ---------------------------------------------------------------------------
421
+
422
+ test('renderEmailBody passes through plain text', () => {
423
+ expect(renderEmailBody('Hello, this is plain text.\n\nSecond paragraph.', 'text/plain')).toMatchInlineSnapshot(`
424
+ "Hello, this is plain text.
425
+
426
+ Second paragraph."
427
+ `)
428
+ })
429
+
430
+ test('renderEmailBody converts HTML', () => {
431
+ expect(renderEmailBody('<p>Hello <b>world</b></p>', 'text/html')).toMatchInlineSnapshot(`"Hello **world**"`)
432
+ })
433
+
434
+ // ---------------------------------------------------------------------------
435
+ // Quoted reply stripping via replyParser (plain text only)
436
+ // ---------------------------------------------------------------------------
437
+
438
+ test('strips plain text reply with > quotes and On...wrote header', () => {
439
+ expect(replyParser.parseReply(`Thanks for the update! I'll review the PR today.
440
+
441
+ Let me know if you need anything else.
442
+
443
+ On Mon, Feb 10, 2026 at 10:30 AM John Smith <john@example.com> wrote:
444
+ > Hey team,
445
+ >
446
+ > I just pushed the fix for the login bug. The PR is ready for review.
447
+ >
448
+ > Best,
449
+ > John`)).toMatchInlineSnapshot(`
450
+ "Thanks for the update! I'll review the PR today.
451
+
452
+ Let me know if you need anything else.
453
+ "
454
+ `)
455
+ })
456
+
457
+ test('strips plain text reply with German locale (Am...schrieb)', () => {
458
+ expect(replyParser.parseReply(`Danke für die Information, ich schaue es mir an.
459
+
460
+ Am 10. Februar 2026 um 14:00 schrieb Max Müller <max@example.de>:
461
+ > Hallo,
462
+ >
463
+ > Hier ist der aktuelle Stand des Projekts.
464
+ >
465
+ > Grüße,
466
+ > Max`)).toMatchInlineSnapshot(`
467
+ "Danke für die Information, ich schaue es mir an.
468
+ "
469
+ `)
470
+ })
471
+
472
+ test('strips plain text reply with French locale (Le...écrit)', () => {
473
+ expect(replyParser.parseReply(`Merci pour la mise à jour, je vais vérifier.
474
+
475
+ Le 10 février 2026 à 15:00, Pierre Dupont <pierre@example.fr> a écrit :
476
+ > Bonjour,
477
+ >
478
+ > Voici les dernières modifications.
479
+ >
480
+ > Cordialement,
481
+ > Pierre`)).toMatchInlineSnapshot(`
482
+ "Merci pour la mise à jour, je vais vérifier.
483
+ "
484
+ `)
485
+ })
486
+
487
+ test('strips plain text forwarded message block', () => {
488
+ expect(replyParser.parseReply(`FYI see below.
489
+
490
+ ---------- Forwarded message ----------
491
+ From: Alice <alice@example.com>
492
+ Date: Mon, Feb 10, 2026
493
+ Subject: Budget update
494
+ To: Bob <bob@example.com>
495
+
496
+ > The budget has been approved for Q2.
497
+ > Please proceed with the hiring plan.`)).toMatchInlineSnapshot(`
498
+ "FYI see below.
499
+
500
+ ---------- Forwarded message ----------
501
+ From: Alice <alice@example.com>
502
+ Date: Mon, Feb 10, 2026
503
+ Subject: Budget update
504
+ To: Bob <bob@example.com>"
505
+ `)
506
+ })
507
+
508
+ test('strips plain text "Sent from my iPhone" signature', () => {
509
+ expect(replyParser.parseReply(`Sure, I'll be there at 3pm.
510
+
511
+ Sent from my iPhone`)).toMatchInlineSnapshot(`
512
+ "Sure, I'll be there at 3pm.
513
+ "
514
+ `)
515
+ })
516
+
517
+ test('strips plain text -- signature separator', () => {
518
+ expect(replyParser.parseReply(`Let me know when you're free to discuss.
519
+
520
+ --
521
+ John Smith
522
+ Senior Engineer
523
+ Acme Corp`)).toMatchInlineSnapshot(`
524
+ "Let me know when you're free to discuss.
525
+ "
526
+ `)
527
+ })
528
+
529
+ test('preserves plain text email with no quotes or signatures', () => {
530
+ expect(replyParser.parseReply(`Hey team,
531
+
532
+ Just a reminder that the sprint review is tomorrow at 2pm.
533
+ Please prepare your demo.
534
+
535
+ Thanks,
536
+ Alice`)).toMatchInlineSnapshot(`
537
+ "Hey team,
538
+
539
+ Just a reminder that the sprint review is tomorrow at 2pm.
540
+ Please prepare your demo.
541
+
542
+ Thanks,
543
+ Alice"
544
+ `)
545
+ })
546
+
547
+ test('strips multi-level nested > quotes', () => {
548
+ expect(replyParser.parseReply(`Got it, thanks!
549
+
550
+ On Tue, Feb 11, 2026 at 9:00 AM Bob <bob@example.com> wrote:
551
+ > Sounds good.
552
+ >
553
+ > On Mon, Feb 10, 2026 at 5:00 PM Alice <alice@example.com> wrote:
554
+ >> Can we push the deadline to Friday?
555
+ >>
556
+ >> Thanks,
557
+ >> Alice`)).toMatchInlineSnapshot(`
558
+ "Got it, thanks!
559
+ "
560
+ `)
561
+ })
562
+
563
+ // -- HTML --
564
+
565
+ test('strips Gmail HTML quoted reply (gmail_quote class)', () => {
566
+ expect(renderEmailBody(`
567
+ <div dir="ltr">
568
+ <p>Thanks for the update! I'll review the PR today.</p>
569
+ <p>Let me know if you need anything else.</p>
570
+ </div>
571
+ <div class="gmail_quote">
572
+ <div dir="ltr" class="gmail_attr">
573
+ On Mon, Feb 10, 2026 at 10:30 AM John Smith &lt;john@example.com&gt; wrote:<br>
574
+ </div>
575
+ <blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex">
576
+ <div dir="ltr">
577
+ <p>Hey team,</p>
578
+ <p>I just pushed the fix for the login bug. The PR is ready for review.</p>
579
+ <p>Best,<br>John</p>
580
+ </div>
581
+ </blockquote>
582
+ </div>
583
+ `, 'text/html')).toMatchInlineSnapshot(`
584
+ "Thanks for the update! I'll review the PR today.
585
+
586
+ Let me know if you need anything else."
587
+ `)
588
+ })
589
+
590
+ test('Outlook HTML reply: divRplyFwdMsg stripped, plain blockquote preserved', () => {
591
+ // Turndown strips divRplyFwdMsg but plain <blockquote> (no type=cite) is preserved
592
+ // as > prefixed text. This is intentional to avoid false positives.
593
+ expect(renderEmailBody(`
594
+ <html><body>
595
+ <div>
596
+ <p>Sounds good, I'll join the call at 3pm.</p>
597
+ </div>
598
+ <hr>
599
+ <div id="divRplyFwdMsg">
600
+ <p><b>From:</b> Sarah Connor &lt;sarah@skynet.com&gt;<br>
601
+ <b>Sent:</b> Monday, February 10, 2026 9:00 AM<br>
602
+ <b>To:</b> Kyle Reese &lt;kyle@resistance.org&gt;<br>
603
+ <b>Subject:</b> Meeting today</p>
604
+ </div>
605
+ <blockquote>
606
+ <p>Hi Kyle,</p>
607
+ <p>Can we meet at 3pm to discuss the timeline?</p>
608
+ <p>Thanks,<br>Sarah</p>
609
+ </blockquote>
610
+ </body></html>
611
+ `, 'text/html')).toMatchInlineSnapshot(`
612
+ "Sounds good, I'll join the call at 3pm.
613
+
614
+ ***
615
+
616
+ > Hi Kyle,
617
+ >
618
+ > Can we meet at 3pm to discuss the timeline?
619
+ >
620
+ > Thanks,\\
621
+ > Sarah"
622
+ `)
623
+ })
624
+
625
+ test('strips Outlook HTML reply with appendonsend div', () => {
626
+ expect(renderEmailBody(`
627
+ <div>
628
+ <p>I agree with the proposal.</p>
629
+ </div>
630
+ <div id="appendonsend"></div>
631
+ <hr style="display:inline-block;width:98%">
632
+ <div id="divRplyFwdMsg">
633
+ <p><b>From:</b> Boss &lt;boss@corp.com&gt;<br>
634
+ <b>Sent:</b> Tuesday, Feb 11, 2026 8:00 AM</p>
635
+ </div>
636
+ <blockquote type="cite">
637
+ <p>Team, please review the attached proposal and share your thoughts.</p>
638
+ </blockquote>
639
+ `, 'text/html')).toMatchInlineSnapshot(`
640
+ "I agree with the proposal.
641
+
642
+ ***"
643
+ `)
644
+ })
645
+
646
+ test('plain blockquote without attributes becomes > in markdown (turndown only)', () => {
647
+ // Turndown converts <blockquote> to > prefixed text. Without the reply parser,
648
+ // the quoted content is preserved — this is correct for HTML emails because
649
+ // intentional blockquotes (article quotes, GitHub notifications) should survive.
650
+ expect(renderEmailBody(`
651
+ <div>
652
+ <p>Looks good to me, ship it!</p>
653
+ </div>
654
+ <blockquote>
655
+ <p>Here's the updated design mockup for the landing page.</p>
656
+ <p>Let me know what you think.</p>
657
+ </blockquote>
658
+ `, 'text/html')).toMatchInlineSnapshot(`
659
+ "Looks good to me, ship it!
660
+
661
+ > Here's the updated design mockup for the landing page.
662
+ >
663
+ > Let me know what you think."
664
+ `)
665
+ })
666
+
667
+ test('strips Apple Mail HTML reply', () => {
668
+ expect(renderEmailBody(`
669
+ <div>I'll take a look this afternoon.</div>
670
+ <div><br></div>
671
+ <div>
672
+ <blockquote type="cite">
673
+ <div>On Feb 10, 2026, at 11:00 AM, Dave &lt;dave@example.com&gt; wrote:</div>
674
+ <div><br></div>
675
+ <div>Can you review the latest commit? I fixed the memory leak.</div>
676
+ </blockquote>
677
+ </div>
678
+ `, 'text/html')).toMatchInlineSnapshot(`"I'll take a look this afternoon."`)
679
+ })
680
+
681
+ test('preserves HTML email with no quoted content', () => {
682
+ expect(renderEmailBody(`
683
+ <div dir="ltr">
684
+ <p>Hey everyone,</p>
685
+ <p>Just a reminder that the sprint review is tomorrow at 2pm.</p>
686
+ <p>Please prepare your demo.</p>
687
+ </div>
688
+ `, 'text/html')).toMatchInlineSnapshot(`
689
+ "Hey everyone,
690
+
691
+ Just a reminder that the sprint review is tomorrow at 2pm.
692
+
693
+ Please prepare your demo."
694
+ `)
695
+ })
696
+
697
+ test('preserves newsletter HTML (no quotes to strip)', () => {
698
+ expect(renderEmailBody(`
699
+ <table width="600" align="center" cellpadding="0" cellspacing="0" border="0">
700
+ <tr><td>
701
+ <h2>Weekly Update</h2>
702
+ <p>Here are this week's highlights:</p>
703
+ <ul>
704
+ <li>Feature A launched</li>
705
+ <li>Bug fix for issue #123</li>
706
+ <li>New team member onboarded</li>
707
+ </ul>
708
+ <p>Read more at <a href="https://example.com/blog">our blog</a>.</p>
709
+ </td></tr>
710
+ </table>
711
+ `, 'text/html')).toMatchInlineSnapshot(`
712
+ "## Weekly Update
713
+
714
+ Here are this week's highlights:
715
+
716
+ * Feature A launched
717
+ * Bug fix for issue #123
718
+ * New team member onboarded
719
+
720
+ Read more at [our blog](https://example.com/blog)."
721
+ `)
722
+ })
723
+
724
+ test('Gmail HTML with Sent from signature in reply (turndown strips quote)', () => {
725
+ // Turndown strips gmail_quote div. The gmail_signature div is not stripped by
726
+ // turndown (it's not a quote), so "Sent from my iPhone" survives in the markdown.
727
+ expect(renderEmailBody(`
728
+ <div dir="ltr">
729
+ <p>OK I'll handle it.</p>
730
+ <br>
731
+ <div class="gmail_signature">Sent from my iPhone</div>
732
+ </div>
733
+ <div class="gmail_quote">
734
+ <div class="gmail_attr">On Feb 10, 2026 at 3pm, Boss &lt;boss@work.com&gt; wrote:</div>
735
+ <blockquote class="gmail_quote">
736
+ <p>Please send the report by EOD.</p>
737
+ </blockquote>
738
+ </div>
739
+ `, 'text/html')).toMatchInlineSnapshot(`
740
+ "OK I'll handle it.
741
+
742
+ Sent from my iPhone"
743
+ `)
744
+ })
745
+
746
+ test('HTML reply with Original Message separator', () => {
747
+ expect(renderEmailBody(`
748
+ <div>
749
+ <p>Thanks, received.</p>
750
+ </div>
751
+ <div>
752
+ <p>----- Original Message -----</p>
753
+ <p><b>From:</b> noreply@service.com<br>
754
+ <b>To:</b> user@example.com<br>
755
+ <b>Subject:</b> Your order has shipped</p>
756
+ <p>Your order #12345 has been shipped and will arrive by Friday.</p>
757
+ </div>
758
+ `, 'text/html')).toMatchInlineSnapshot(`
759
+ "Thanks, received.
760
+
761
+ \\----- Original Message -----
762
+
763
+ **From:** noreply@service.com\\
764
+ **To:** user@example.com\\
765
+ **Subject:** Your order has shipped
766
+
767
+ Your order #12345 has been shipped and will arrive by Friday."
768
+ `)
769
+ })
770
+
771
+ // ---------------------------------------------------------------------------
772
+ // renderEmailBody preserves everything (forwarding path uses this)
773
+ // replyParser.parseReply strips quotes (TUI display uses this)
774
+ // ---------------------------------------------------------------------------
775
+
776
+ test('renderEmailBody preserves quotes, replyParser strips them', () => {
777
+ const body = `Thanks!
778
+
779
+ On Mon, Feb 10, 2026 at 10:30 AM John <john@example.com> wrote:
780
+ > Original message here.`
781
+
782
+ const rendered = renderEmailBody(body, 'text/plain')
783
+ const stripped = replyParser.parseReply(rendered)
784
+
785
+ // renderEmailBody: full content preserved (for forwarding path)
786
+ expect(rendered).toContain('Original message here.')
787
+ // replyParser: only the reply (for TUI display)
788
+ expect(stripped).not.toContain('Original message here.')
789
+ })
790
+
791
+ // ---------------------------------------------------------------------------
792
+ // Thread simulation: the core doubling bug
793
+ // ---------------------------------------------------------------------------
794
+
795
+ test('thread detail: two messages do not double content', () => {
796
+ const msg1Body = `<div dir="ltr"><p>Hey team, I just pushed the fix. PR is ready for review.</p></div>`
797
+ const msg2Body = `
798
+ <div dir="ltr">
799
+ <p>Looks good, approved!</p>
800
+ </div>
801
+ <div class="gmail_quote">
802
+ <div class="gmail_attr">On Mon, Feb 10 at 10:30 AM John &lt;john@example.com&gt; wrote:</div>
803
+ <blockquote class="gmail_quote">
804
+ <div dir="ltr"><p>Hey team, I just pushed the fix. PR is ready for review.</p></div>
805
+ </blockquote>
806
+ </div>
807
+ `
808
+ const rendered1 = renderEmailBody(msg1Body, 'text/html')
809
+ const rendered2 = renderEmailBody(msg2Body, 'text/html')
810
+
811
+ expect(rendered1).toMatchInlineSnapshot(`"Hey team, I just pushed the fix. PR is ready for review."`)
812
+ expect(rendered2).toMatchInlineSnapshot(`"Looks good, approved!"`)
813
+
814
+ const combined = `${rendered1}\n\n---\n\n${rendered2}`
815
+ const occurrences = combined.split('I just pushed the fix').length - 1
816
+ expect(occurrences).toBe(1)
817
+ })
818
+
819
+ test('thread detail: 3-message thread with plain text', () => {
820
+ // Simulates TUI pipeline: renderEmailBody + replyParser for plain text
821
+ const render = (body: string) => replyParser.parseReply(renderEmailBody(body, 'text/plain'))
822
+
823
+ const msg1 = render('Can we meet tomorrow at 2pm?')
824
+ const msg2 = render(`Yes, 2pm works for me.
825
+
826
+ On Mon, Feb 10, 2026 at 9:00 AM Alice <alice@example.com> wrote:
827
+ > Can we meet tomorrow at 2pm?`)
828
+ const msg3 = render(`Great, I'll book the room.
829
+
830
+ On Mon, Feb 10, 2026 at 9:15 AM Bob <bob@example.com> wrote:
831
+ > Yes, 2pm works for me.
832
+ >
833
+ > On Mon, Feb 10, 2026 at 9:00 AM Alice <alice@example.com> wrote:
834
+ >> Can we meet tomorrow at 2pm?`)
835
+
836
+ expect(msg1).toMatchInlineSnapshot(`"Can we meet tomorrow at 2pm?"`)
837
+ expect(msg2).toMatchInlineSnapshot(`
838
+ "Yes, 2pm works for me.
839
+ "
840
+ `)
841
+ expect(msg3).toMatchInlineSnapshot(`
842
+ "Great, I'll book the room.
843
+ "
844
+ `)
845
+
846
+ const combined = [msg1, msg2, msg3].join('\n---\n')
847
+ const meetOccurrences = combined.split('Can we meet tomorrow').length - 1
848
+ expect(meetOccurrences).toBe(1)
849
+ const worksOccurrences = combined.split('2pm works for me').length - 1
850
+ expect(worksOccurrences).toBe(1)
851
+ })
852
+
853
+ // ---------------------------------------------------------------------------
854
+ // Edge cases: false positives (content that looks like quotes but isn't)
855
+ // ---------------------------------------------------------------------------
856
+
857
+ test('preserves plain text with > in shell commands', () => {
858
+ const body = `Here's how to redirect output:
859
+
860
+ echo "hello" > output.txt
861
+ cat file.txt | grep error > errors.log
862
+ ls -la 2>&1 > /dev/null`
863
+ expect(replyParser.parseReply(body)).toMatchInlineSnapshot(`
864
+ "Here's how to redirect output:
865
+
866
+ echo "hello" > output.txt
867
+ cat file.txt | grep error > errors.log
868
+ ls -la 2>&1 > /dev/null"
869
+ `)
870
+ })
871
+
872
+ test('preserves plain text with > in code snippets', () => {
873
+ const body = `The comparison operators in Python:
874
+
875
+ if x > 10:
876
+ print("large")
877
+ elif x > 5:
878
+ print("medium")
879
+
880
+ Also note that >> is the right shift operator.`
881
+ expect(replyParser.parseReply(body)).toMatchInlineSnapshot(`
882
+ "The comparison operators in Python:
883
+
884
+ if x > 10:
885
+ print("large")
886
+ elif x > 5:
887
+ print("medium")
888
+
889
+ Also note that >> is the right shift operator."
890
+ `)
891
+ })
892
+
893
+ test('preserves plain text with markdown blockquotes', () => {
894
+ const body = `Here's a good quote from the docs:
895
+
896
+ > Note: This API is experimental and may change.
897
+
898
+ Make sure to pin the version.`
899
+ expect(replyParser.parseReply(body)).toMatchInlineSnapshot(`
900
+ "Here's a good quote from the docs:
901
+ Make sure to pin the version."
902
+ `)
903
+ })
904
+
905
+ test('preserves HTML with intentional blockquote (article content)', () => {
906
+ // Without reply parser on HTML, intentional blockquotes are preserved
907
+ expect(renderEmailBody(`
908
+ <div>
909
+ <p>Great article! Here's the key takeaway:</p>
910
+ <blockquote>
911
+ <p>The best code is the code you don't have to write.</p>
912
+ </blockquote>
913
+ <p>I totally agree with this.</p>
914
+ </div>
915
+ `, 'text/html')).toMatchInlineSnapshot(`
916
+ "Great article! Here's the key takeaway:
917
+
918
+ > The best code is the code you don't have to write.
919
+
920
+ I totally agree with this."
921
+ `)
922
+ })
923
+
924
+ // ---------------------------------------------------------------------------
925
+ // Edge cases: auto-generated emails
926
+ // ---------------------------------------------------------------------------
927
+
928
+ test('preserves GitHub notification email (blockquote content survives)', () => {
929
+ // Without reply parser on HTML, the blockquoted PR comment is preserved
930
+ expect(renderEmailBody(`
931
+ <div>
932
+ <p><strong>@alice</strong> commented on pull request <a href="https://github.com/org/repo/pull/42">#42</a>:</p>
933
+ <blockquote>
934
+ <p>LGTM! One minor nit on line 15.</p>
935
+ </blockquote>
936
+ <p>You are receiving this because you were mentioned.</p>
937
+ <p><a href="https://github.com/org/repo/pull/42#issuecomment-123">View it on GitHub</a></p>
938
+ </div>
939
+ `, 'text/html')).toMatchInlineSnapshot(`
940
+ "**@alice** commented on pull request [#42](https://github.com/org/repo/pull/42):
941
+
942
+ > LGTM! One minor nit on line 15.
943
+
944
+ You are receiving this because you were mentioned.
945
+
946
+ [View it on GitHub](https://github.com/org/repo/pull/42#issuecomment-123)"
947
+ `)
948
+ })
949
+
950
+ test('preserves CI/build notification email', () => {
951
+ expect(replyParser.parseReply(`Build #1234 PASSED for branch main.
952
+
953
+ Changes:
954
+ - fix: resolve memory leak in worker pool
955
+ - chore: update dependencies
956
+
957
+ View build: https://ci.example.com/builds/1234`)).toMatchInlineSnapshot(`
958
+ "Build #1234 PASSED for branch main.
959
+
960
+ Changes:
961
+ - fix: resolve memory leak in worker pool
962
+ - chore: update dependencies
963
+
964
+ View build: https://ci.example.com/builds/1234"
965
+ `)
966
+ })
967
+
968
+ // ---------------------------------------------------------------------------
969
+ // Edge cases: mailing list and legal footers
970
+ // ---------------------------------------------------------------------------
971
+
972
+ test('handles mailing list footer with -- separator', () => {
973
+ expect(replyParser.parseReply(`The next meeting is on Thursday at 3pm.
974
+
975
+ Please RSVP by Tuesday.
976
+
977
+ --
978
+ community-list mailing list
979
+ community-list@example.org
980
+ https://lists.example.org/listinfo/community-list`)).toMatchInlineSnapshot(`
981
+ "The next meeting is on Thursday at 3pm.
982
+
983
+ Please RSVP by Tuesday.
984
+ "
985
+ `)
986
+ })
987
+
988
+ test('preserves email with legal disclaimer (not a signature pattern)', () => {
989
+ expect(replyParser.parseReply(`Please find the contract attached.
990
+
991
+ CONFIDENTIALITY NOTICE: This email and any attachments are for the exclusive and
992
+ confidential use of the intended recipient. If you are not the intended recipient,
993
+ please do not read, distribute, or take action based on this message.`)).toMatchInlineSnapshot(`
994
+ "Please find the contract attached.
995
+
996
+ CONFIDENTIALITY NOTICE: This email and any attachments are for the exclusive and
997
+ confidential use of the intended recipient. If you are not the intended recipient,
998
+ please do not read, distribute, or take action based on this message."
999
+ `)
1000
+ })
1001
+
1002
+ // ---------------------------------------------------------------------------
1003
+ // Edge cases: short emails and degenerate inputs
1004
+ // ---------------------------------------------------------------------------
1005
+
1006
+ test('preserves one-liner email', () => {
1007
+ expect(replyParser.parseReply('OK')).toMatchInlineSnapshot(`"OK"`)
1008
+ })
1009
+
1010
+ test('preserves one-word reply with signature', () => {
1011
+ expect(replyParser.parseReply(`Thanks!
1012
+
1013
+ Sent from my iPhone`)).toMatchInlineSnapshot(`
1014
+ "Thanks!
1015
+ "
1016
+ `)
1017
+ })
1018
+
1019
+ test('handles empty string', () => {
1020
+ expect(replyParser.parseReply('')).toMatchInlineSnapshot(`""`)
1021
+ })
1022
+
1023
+ test('handles email that is only a signature', () => {
1024
+ expect(replyParser.parseReply(`--
1025
+ John Smith
1026
+ CEO, Acme Corp
1027
+ john@acme.com`)).toMatchInlineSnapshot(`
1028
+ "--
1029
+ John Smith
1030
+ CEO, Acme Corp
1031
+ john@acme.com"
1032
+ `)
1033
+ })
1034
+
1035
+ test('handles email that is only quoted text', () => {
1036
+ expect(replyParser.parseReply(`> This was the original message.
1037
+ > It had multiple lines.
1038
+ > But no new content was added.`)).toMatchInlineSnapshot(`""`)
1039
+ })
1040
+
1041
+ // ---------------------------------------------------------------------------
1042
+ // Edge cases: CJK reply headers
1043
+ // ---------------------------------------------------------------------------
1044
+
1045
+ test('strips Chinese reply header (在...写道)', () => {
1046
+ expect(replyParser.parseReply(`收到,我会处理。
1047
+
1048
+ 在 2026年2月10日 下午3:00, 张三 <zhang@example.com> 写道:
1049
+ > 请查看附件中的报告。`)).toMatchInlineSnapshot(`
1050
+ "收到,我会处理。
1051
+ "
1052
+ `)
1053
+ })
1054
+
1055
+ test('strips Japanese reply header (のメッセージ)', () => {
1056
+ expect(replyParser.parseReply(`了解しました。
1057
+
1058
+ 2026/02/10 15:00、田中太郎 <tanaka@example.jp> のメッセージ:
1059
+ > 来週の会議について確認です。`)).toMatchInlineSnapshot(`
1060
+ "了解しました。
1061
+ "
1062
+ `)
1063
+ })
1064
+
1065
+ test('strips Korean reply header (작성)', () => {
1066
+ expect(replyParser.parseReply(`네, 알겠습니다.
1067
+
1068
+ 2026.02.10 오후 3:00 김철수 <kim@example.kr> 작성:
1069
+ > 내일 미팅 시간을 확인해 주세요.`)).toMatchInlineSnapshot(`
1070
+ "네, 알겠습니다.
1071
+ "
1072
+ `)
1073
+ })
1074
+
1075
+ test('safe real-world HTML fixtures produce stable markdown file snapshots', async () => {
1076
+ fs.mkdirSync(htmlSnapshotDir, { recursive: true })
1077
+
1078
+ const fixtureFiles = fs
1079
+ .readdirSync(htmlFixtureDir)
1080
+ .filter((file) => file.endsWith('.html'))
1081
+ .sort()
1082
+
1083
+ for (const fixtureFile of fixtureFiles) {
1084
+ const html = fs.readFileSync(path.join(htmlFixtureDir, fixtureFile), 'utf-8')
1085
+ const markdown = htmlToMarkdown(html)
1086
+ const snapshotFile = path.join(htmlSnapshotDir, `${fixtureFile}.md`)
1087
+
1088
+ expect(markdown, `no raw entity refs: ${fixtureFile}`).not.toMatch(/&#\d+;|&#x[0-9a-f]+;|&(nbsp|amp|quot|lt|gt);/i)
1089
+ expect(markdown, `no zero-width chars: ${fixtureFile}`).not.toMatch(/[\u200B\u200C\u200D\uFEFF]/)
1090
+
1091
+ await expect(markdown, `fixture: ${fixtureFile}`).toMatchFileSnapshot(snapshotFile)
1092
+ }
1093
+ })