Skip to content

Commit 1eb68ee

Browse files
authored
🐛(frontend) fix outlook web handler in unquote logic (#754)
About the unquote-message logic, we encount a bug with a thread implying Outlook Desktop quotes. Actually, for Outlook web we were looking for a hr tag as quote separtor element. But sometimes this one can be wrap into a div and we missed it.
1 parent 39f2d9c commit 1eb68ee

2 files changed

Lines changed: 111 additions & 9 deletions

File tree

src/frontend/src/features/utils/unquote-message/handlers.ts

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -143,15 +143,31 @@ export const HANDLERS: CustomHandler[] = [
143143
const normalizedStyle = style.replaceAll(/(cm|pt|mm)/g, "in");
144144

145145
if (normalizedStyle.endsWith(" 1.0in;padding:3.0in 0in 0in 0in")) {
146-
// Check if parent has only one element child
147-
if (msoRoot.parentElement) {
148-
const elementChildren = Array.from(
149-
msoRoot.parentElement.childNodes
150-
).filter((node) => node.nodeType === Node.ELEMENT_NODE);
151-
152-
if (elementChildren.length === 1) {
153-
msoRoot = msoRoot.parentElement as HTMLElement;
146+
// The quoted content may live at an upper level: climb out of wrapper
147+
// divs while the header has no following sibling, as long as nothing
148+
// meaningful precedes it inside the wrapper (to avoid swallowing the
149+
// new message content)
150+
while (
151+
msoRoot.parentElement &&
152+
msoRoot.parentElement.tagName.toLowerCase() !== "body" &&
153+
!msoRoot.nextElementSibling
154+
) {
155+
// Use childNodes to also catch plain-text nodes, but only those
156+
// preceding the header: text nodes following it belong to the quote
157+
// and must not halt the climb
158+
const siblings = Array.from(msoRoot.parentElement.childNodes);
159+
const previousSiblings = siblings.slice(0, siblings.indexOf(msoRoot));
160+
if (
161+
previousSiblings.some(
162+
(node) =>
163+
(node.nodeType === Node.ELEMENT_NODE ||
164+
node.nodeType === Node.TEXT_NODE) &&
165+
node.textContent?.trim()
166+
)
167+
) {
168+
break;
154169
}
170+
msoRoot = msoRoot.parentElement;
155171
}
156172

157173
// Collect all next siblings
@@ -175,7 +191,14 @@ export const HANDLERS: CustomHandler[] = [
175191
const detectedElements: HTMLElement[] = [];
176192
let prev = element.previousElementSibling;
177193
while (prev) {
178-
if (prev.tagName.toLowerCase() === "hr") {
194+
// Outlook renders the reply separator either as a bare <hr>
195+
// or as an <hr> alone inside a centering div
196+
const isSeparator =
197+
prev.tagName.toLowerCase() === "hr" ||
198+
(prev.children.length === 1 &&
199+
prev.children[0].tagName.toLowerCase() === "hr" &&
200+
!prev.textContent?.trim());
201+
if (isSeparator) {
179202
// It's a reply from Outlook!
180203
detectedElements.push(element, prev as HTMLElement);
181204
let sibling = element.nextElementSibling;

src/frontend/src/features/utils/unquote-message/index.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,85 @@ describe("UnquoteMessage", () => {
375375
);
376376
});
377377

378+
it("should remove Microsoft Outlook Desktop quotes when the hr separator is wrapped in a div", () => {
379+
const html = `
380+
<div>My reply to the message</div>
381+
<div class="MsoNormal" align="center" style="text-align:center"><hr size="3" width="98%" align="center"></div>
382+
<div id="divRplyFwdMsg">
383+
<b>De :</b> sender@example.com<br>
384+
<b>Envoyé :</b> lundi 6 juillet 2026 15:45<br>
385+
<b>À :</b> recipient@example.com<br>
386+
</div>
387+
<div>Original message body</div>
388+
`;
389+
390+
const result = new UnquoteMessage(html).getHtml();
391+
392+
expect(result.hadQuotes).toBe(true);
393+
expect(result.detectionMethod).toBe("handlers");
394+
expect(result.content).toMatchInlineSnapshot(
395+
`"<div>My reply to the message</div>"`
396+
);
397+
});
398+
399+
it("should remove Microsoft Outlook Web quotes when the header is nested in a wrapper and the body is a sibling of the wrapper", () => {
400+
const html = `
401+
<div>My reply</div>
402+
<div>
403+
<div id="content_out_sender_example.com"></div>
404+
<div style="border:none;border-top:solid #E1E1E1 1.0pt;padding:3.0pt 0cm 0cm 0cm">
405+
<p class="MsoNormal"><b>De :</b> sender@example.com</p>
406+
</div>
407+
</div>
408+
<div>Original message body</div>
409+
`;
410+
411+
const result = new UnquoteMessage(html).getHtml();
412+
413+
expect(result.hadQuotes).toBe(true);
414+
expect(result.detectionMethod).toBe("handlers");
415+
expect(result.content).toMatchInlineSnapshot(
416+
`"<div>My reply</div>"`
417+
);
418+
});
419+
420+
it("should not swallow a plain-text reply preceding the Microsoft Outlook Web header inside a wrapper", () => {
421+
const html = `
422+
<div>
423+
My plain-text reply
424+
<div style="border:none;border-top:solid #E1E1E1 1.0pt;padding:3.0pt 0cm 0cm 0cm">
425+
<p class="MsoNormal"><b>De :</b> sender@example.com</p>
426+
</div>
427+
</div>
428+
`;
429+
430+
const result = new UnquoteMessage(html).getHtml();
431+
432+
expect(result.hadQuotes).toBe(true);
433+
expect(result.content).toContain("My plain-text reply");
434+
expect(result.content).not.toContain("sender@example.com");
435+
});
436+
437+
it("should keep climbing when only quoted text nodes follow the Microsoft Outlook Web header", () => {
438+
const html = `
439+
<div>My reply</div>
440+
<div>
441+
<div style="border:none;border-top:solid #E1E1E1 1.0pt;padding:3.0pt 0cm 0cm 0cm">
442+
<p class="MsoNormal"><b>De :</b> sender@example.com</p>
443+
</div>
444+
Quoted text node
445+
</div>
446+
<div>Original message body</div>
447+
`;
448+
449+
const result = new UnquoteMessage(html).getHtml();
450+
451+
expect(result.hadQuotes).toBe(true);
452+
expect(result.content).toMatchInlineSnapshot(
453+
`"<div>My reply</div>"`
454+
);
455+
});
456+
378457
it("should remove ZMail quotes with zmail_extra", () => {
379458
const html = `
380459
<div>New message</div>

0 commit comments

Comments
 (0)