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.
- package/README.md +1 -1
- package/bin/zele +27 -0
- package/dist/api-utils.d.ts +51 -2
- package/dist/api-utils.js +89 -3
- package/dist/api-utils.js.map +1 -1
- package/dist/auth.d.ts +27 -6
- package/dist/auth.js +185 -129
- package/dist/auth.js.map +1 -1
- package/dist/calendar-client.d.ts +16 -9
- package/dist/calendar-client.js +163 -59
- package/dist/calendar-client.js.map +1 -1
- package/dist/cli.js +34 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/attachment.js +17 -15
- package/dist/commands/attachment.js.map +1 -1
- package/dist/commands/auth-cmd.js +20 -9
- package/dist/commands/auth-cmd.js.map +1 -1
- package/dist/commands/calendar.js +67 -78
- package/dist/commands/calendar.js.map +1 -1
- package/dist/commands/draft.js +25 -18
- package/dist/commands/draft.js.map +1 -1
- package/dist/commands/label.js +33 -45
- package/dist/commands/label.js.map +1 -1
- package/dist/commands/mail-actions.js +11 -13
- package/dist/commands/mail-actions.js.map +1 -1
- package/dist/commands/mail.js +119 -127
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/profile.js +18 -21
- package/dist/commands/profile.js.map +1 -1
- package/dist/commands/watch.js +33 -261
- package/dist/commands/watch.js.map +1 -1
- package/dist/db.js +12 -13
- package/dist/db.js.map +1 -1
- package/dist/generated/browser.d.ts +12 -27
- package/dist/generated/client.d.ts +13 -28
- package/dist/generated/client.js +1 -1
- package/dist/generated/commonInputTypes.d.ts +90 -26
- package/dist/generated/enums.d.ts +0 -4
- package/dist/generated/enums.js +0 -3
- package/dist/generated/enums.js.map +1 -1
- package/dist/generated/internal/class.d.ts +22 -55
- package/dist/generated/internal/class.js +12 -4
- package/dist/generated/internal/class.js.map +1 -1
- package/dist/generated/internal/prismaNamespace.d.ts +272 -511
- package/dist/generated/internal/prismaNamespace.js +54 -66
- package/dist/generated/internal/prismaNamespace.js.map +1 -1
- package/dist/generated/internal/prismaNamespaceBrowser.d.ts +60 -74
- package/dist/generated/internal/prismaNamespaceBrowser.js +50 -62
- package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
- package/dist/generated/models/Account.d.ts +1637 -0
- package/dist/generated/models/Account.js +2 -0
- package/dist/generated/models/Account.js.map +1 -0
- package/dist/generated/models/CalendarList.d.ts +1161 -0
- package/dist/generated/models/CalendarList.js +2 -0
- package/dist/generated/models/CalendarList.js.map +1 -0
- package/dist/generated/models/Label.d.ts +1161 -0
- package/dist/generated/models/Label.js +2 -0
- package/dist/generated/models/Label.js.map +1 -0
- package/dist/generated/models/Profile.d.ts +1269 -0
- package/dist/generated/models/Profile.js +2 -0
- package/dist/generated/models/Profile.js.map +1 -0
- package/dist/generated/models/SyncState.d.ts +1130 -0
- package/dist/generated/models/SyncState.js +2 -0
- package/dist/generated/models/SyncState.js.map +1 -0
- package/dist/generated/models/Thread.d.ts +1608 -0
- package/dist/generated/models/Thread.js +2 -0
- package/dist/generated/models/Thread.js.map +1 -0
- package/dist/generated/models.d.ts +6 -9
- package/dist/gmail-client.d.ts +119 -94
- package/dist/gmail-client.js +862 -322
- package/dist/gmail-client.js.map +1 -1
- package/dist/mail-tui.d.ts +1 -0
- package/dist/mail-tui.js +517 -0
- package/dist/mail-tui.js.map +1 -0
- package/dist/output.d.ts +6 -0
- package/dist/output.js +124 -11
- package/dist/output.js.map +1 -1
- package/package.json +39 -11
- package/schema.prisma +81 -113
- package/src/api-utils.ts +103 -5
- package/src/auth.ts +224 -143
- package/src/calendar-client.ts +196 -89
- package/src/cli.ts +39 -1
- package/src/commands/attachment.ts +18 -19
- package/src/commands/auth-cmd.ts +19 -9
- package/src/commands/calendar.ts +42 -85
- package/src/commands/draft.ts +19 -22
- package/src/commands/label.ts +21 -57
- package/src/commands/mail-actions.ts +11 -19
- package/src/commands/mail.ts +109 -148
- package/src/commands/profile.ts +12 -28
- package/src/commands/watch.ts +37 -304
- package/src/db.ts +13 -16
- package/src/generated/browser.ts +49 -0
- package/src/generated/client.ts +71 -0
- package/src/generated/commonInputTypes.ts +332 -0
- package/src/generated/enums.ts +17 -0
- package/src/generated/internal/class.ts +250 -0
- package/src/generated/internal/prismaNamespace.ts +1198 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +169 -0
- package/src/generated/models/Account.ts +1848 -0
- package/src/generated/models/CalendarList.ts +1331 -0
- package/src/generated/models/Label.ts +1331 -0
- package/src/generated/models/Profile.ts +1439 -0
- package/src/generated/models/SyncState.ts +1300 -0
- package/src/generated/models/Thread.ts +1787 -0
- package/src/generated/models.ts +17 -0
- package/src/gmail-client.test.ts +59 -0
- package/src/gmail-client.ts +1034 -429
- package/src/mail-tui.tsx +1061 -0
- package/src/output.test.ts +1093 -0
- package/src/output.ts +128 -13
- package/src/schema.sql +58 -68
- package/src/test-fixtures/email-html/safe-claude-event.html +28 -0
- package/src/test-fixtures/email-html/safe-product-announcement.html +25 -0
- package/src/test-fixtures/email-html/safe-tracked-links.html +27 -0
- package/src/test-fixtures/email-html-snapshots/safe-claude-event.html.md +9 -0
- package/src/test-fixtures/email-html-snapshots/safe-product-announcement.html.md +13 -0
- package/src/test-fixtures/email-html-snapshots/safe-tracked-links.html.md +7 -0
- package/AGENTS.md +0 -26
- package/CHANGELOG.md +0 -43
- package/dist/generated/models/accounts.d.ts +0 -2000
- package/dist/generated/models/accounts.js +0 -2
- package/dist/generated/models/accounts.js.map +0 -1
- package/dist/generated/models/calendar_events.d.ts +0 -1433
- package/dist/generated/models/calendar_events.js +0 -2
- package/dist/generated/models/calendar_events.js.map +0 -1
- package/dist/generated/models/calendar_lists.d.ts +0 -1131
- package/dist/generated/models/calendar_lists.js +0 -2
- package/dist/generated/models/calendar_lists.js.map +0 -1
- package/dist/generated/models/label_counts.d.ts +0 -1131
- package/dist/generated/models/label_counts.js +0 -2
- package/dist/generated/models/label_counts.js.map +0 -1
- package/dist/generated/models/labels.d.ts +0 -1131
- package/dist/generated/models/labels.js +0 -2
- package/dist/generated/models/labels.js.map +0 -1
- package/dist/generated/models/profiles.d.ts +0 -1131
- package/dist/generated/models/profiles.js +0 -2
- package/dist/generated/models/profiles.js.map +0 -1
- package/dist/generated/models/sync_states.d.ts +0 -1107
- package/dist/generated/models/sync_states.js +0 -2
- package/dist/generated/models/sync_states.js.map +0 -1
- package/dist/generated/models/thread_lists.d.ts +0 -1404
- package/dist/generated/models/thread_lists.js +0 -2
- package/dist/generated/models/thread_lists.js.map +0 -1
- package/dist/generated/models/threads.d.ts +0 -1247
- package/dist/generated/models/threads.js +0 -2
- package/dist/generated/models/threads.js.map +0 -1
- package/dist/gmail-cache.d.ts +0 -60
- package/dist/gmail-cache.js +0 -264
- package/dist/gmail-cache.js.map +0 -1
- package/docs/gogcli-gmail-implementation.md +0 -599
- package/scripts/test-device-code-clients.ts +0 -186
- package/scripts/test-micropython-scopes.ts +0 -72
- package/scripts/test-oauth-clients.ts +0 -257
- package/src/gmail-cache.ts +0 -339
- 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 ' (apostrophe)', () => {
|
|
379
|
+
expect(htmlToMarkdown('<p>It's a beautiful day</p>')).toMatchInlineSnapshot(`"It's a beautiful day"`)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
test('numeric entity " (double quote)', () => {
|
|
383
|
+
expect(htmlToMarkdown('<p>She said "hello" to me</p>')).toMatchInlineSnapshot(`"She said "hello" to me"`)
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
test('named entity &', () => {
|
|
387
|
+
expect(htmlToMarkdown('<p>Tom & Jerry</p>')).toMatchInlineSnapshot(`"Tom & Jerry"`)
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
test('named entity < and >', () => {
|
|
391
|
+
expect(htmlToMarkdown('<p>Use <div> for layout</p>')).toMatchInlineSnapshot(`"Use <div> for layout"`)
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
test('named entity ', () => {
|
|
395
|
+
expect(htmlToMarkdown('<p>Hello World</p>')).toMatchInlineSnapshot(`"Hello World"`)
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
test('hex entity ' (apostrophe)', () => {
|
|
399
|
+
expect(htmlToMarkdown('<p>It's working</p>')).toMatchInlineSnapshot(`"It's working"`)
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
test('mixed entities in real email subject', () => {
|
|
403
|
+
expect(htmlToMarkdown('<p>Your order #1234 — "Premium Plan" isn'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&bar=2">Click & 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've been selected for a "special" 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 <john@example.com> 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 <sarah@skynet.com><br>
|
|
601
|
+
<b>Sent:</b> Monday, February 10, 2026 9:00 AM<br>
|
|
602
|
+
<b>To:</b> Kyle Reese <kyle@resistance.org><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 <boss@corp.com><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 <dave@example.com> 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 <boss@work.com> 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 <john@example.com> 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
|
+
})
|