Skip to content

Commit 82b7560

Browse files
committed
[IMP] dms: .eml email preview — parsed header card + MIME body
.eml files (stored as text/plain) routed to the raw-text iframe, showing RFC822 source. Add an EmlPreview handler that parses the top-level headers into a From/To/Subject/Date card and renders the best body part (HTML preferred, else plain text) in a sandboxed iframe; handles single + multipart/{mixed,alternative,related} with base64 / quoted-printable. The pane remaps the .eml extension to message/rfc822 so the handler wins.
1 parent fec90a4 commit 82b7560

4 files changed

Lines changed: 194 additions & 0 deletions

File tree

dms/static/src/js/components/preview/file_preview_pane.esm.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const _EXTENSION_MIMETYPES = {
3939
css: "text/x-scss",
4040
sass: "text/x-scss",
4141
less: "text/x-scss",
42+
eml: "message/rfc822",
4243
rtf: "text/rtf",
4344
csv: "text/csv",
4445
html: "text/html",

dms/static/src/js/components/preview/handlers.esm.js

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,174 @@ export class MarkdownPreview extends Component {
217217
}
218218
}
219219

220+
// Email (.eml / RFC822): parse the top-level headers into a compact card and
221+
// render the best body part — HTML preferred, else plain text — inside a
222+
// sandboxed iframe. sandbox="" denies scripts, forms, same-origin and top
223+
// navigation, so untrusted message HTML can't execute or escape the frame.
224+
// Handles the common single + multipart/{mixed,alternative,related} shapes
225+
// with base64 / quoted-printable transfer encodings; anything it can't parse
226+
// degrades to the raw source, never worse than the plain text view.
227+
const _EML_HEADER_RE = /^([!-9;-~]+):[ \t]?(.*)$/;
228+
229+
function _parseEmlHeaders(block) {
230+
// Unfold RFC822 continuation lines (a line starting with whitespace
231+
// continues the previous header) before splitting on key: value.
232+
const headers = {};
233+
for (const line of block.replace(/\r?\n[ \t]+/g, " ").split(/\r?\n/)) {
234+
const m = line.match(_EML_HEADER_RE);
235+
if (m) {
236+
headers[m[1].toLowerCase()] = m[2];
237+
}
238+
}
239+
return headers;
240+
}
241+
242+
function _splitEml(raw) {
243+
const i = raw.search(/\r?\n\r?\n/);
244+
if (i === -1) {
245+
return {headers: _parseEmlHeaders(raw), body: ""};
246+
}
247+
return {
248+
headers: _parseEmlHeaders(raw.slice(0, i)),
249+
body: raw.slice(i).replace(/^\r?\n\r?\n/, ""),
250+
};
251+
}
252+
253+
function _decodeEmlPart(content, encoding) {
254+
const enc = (encoding || "").trim().toLowerCase();
255+
if (enc === "base64") {
256+
try {
257+
return decodeURIComponent(escape(atob(content.replace(/\s/g, ""))));
258+
} catch {
259+
return content;
260+
}
261+
}
262+
if (enc === "quoted-printable") {
263+
return content
264+
.replace(/=\r?\n/g, "")
265+
.replace(/=([0-9A-Fa-f]{2})/g, (_, h) =>
266+
String.fromCharCode(parseInt(h, 16))
267+
);
268+
}
269+
return content;
270+
}
271+
272+
// Walk a (possibly nested) MIME tree; return the best displayable part —
273+
// text/html beats text/plain — as {type, content}, or null.
274+
function _bestEmlBody(headers, body, depth = 0) {
275+
const ct = (headers["content-type"] || "text/plain").trim();
276+
const boundary = (ct.match(/boundary="?([^";]+)"?/i) || [])[1];
277+
if (!/^multipart\//i.test(ct) || !boundary || depth > 4) {
278+
const type = ct.split(";")[0].trim().toLowerCase();
279+
if (type === "text/html" || type === "text/plain") {
280+
return {
281+
type,
282+
content: _decodeEmlPart(body, headers["content-transfer-encoding"]),
283+
};
284+
}
285+
return null;
286+
}
287+
const delim = "--" + boundary.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
288+
const candidates = [];
289+
for (const part of body.split(new RegExp(delim))) {
290+
const {headers: ph, body: pb} = _splitEml(part.replace(/^\r?\n/, ""));
291+
if (!ph["content-type"] && !pb.trim()) {
292+
continue;
293+
}
294+
const found = _bestEmlBody(ph, pb, depth + 1);
295+
if (found) {
296+
candidates.push(found);
297+
}
298+
}
299+
candidates.sort(
300+
(a, b) => (a.type === "text/html" ? 0 : 1) - (b.type === "text/html" ? 0 : 1)
301+
);
302+
return candidates[0] || null;
303+
}
304+
305+
function _escapeHtml(s) {
306+
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
307+
}
308+
309+
export class EmlPreview extends Component {
310+
static template = "dms.preview.Eml";
311+
static props = fileProps;
312+
313+
setup() {
314+
this.state = useState({headers: {}, bodyHtml: "", error: null});
315+
onWillStart(async () => {
316+
try {
317+
const r = await fetch(this._sourceUrl);
318+
if (!r.ok) {
319+
throw new Error("HTTP " + r.status);
320+
}
321+
const {headers, body} = _splitEml(await r.text());
322+
this.state.headers = headers;
323+
const part = _bestEmlBody(headers, body);
324+
if (part && part.type === "text/html") {
325+
this.state.bodyHtml = part.content;
326+
} else {
327+
this.state.bodyHtml =
328+
"<pre class='eml-plain'>" +
329+
_escapeHtml((part && part.content) || body) +
330+
"</pre>";
331+
}
332+
} catch (e) {
333+
this.state.error = String(e.message || e);
334+
}
335+
});
336+
}
337+
338+
get _sourceUrl() {
339+
const ts = encodeURIComponent(this.props.file.write_date || "");
340+
return (
341+
`/web/content?id=${this.props.file.id}&model=dms.file` +
342+
`&field=content&filename_field=name&v=${ts}`
343+
);
344+
}
345+
346+
get srcdoc() {
347+
if (this.state.error) {
348+
return `<p style="color:#dc3545">Failed to load: ${_escapeHtml(
349+
this.state.error
350+
)}</p>`;
351+
}
352+
const h = this.state.headers;
353+
const row = (label, val) =>
354+
val
355+
? `<tr><td class='k'>${label}</td><td class='v'>${_escapeHtml(
356+
val
357+
)}</td></tr>`
358+
: "";
359+
const card =
360+
"<table class='eml-head'>" +
361+
row("From", h.from) +
362+
row("To", h.to) +
363+
row("Cc", h.cc) +
364+
row("Subject", h.subject) +
365+
row("Date", h.date) +
366+
"</table>";
367+
return (
368+
"<!doctype html><html><head><meta charset='utf-8'><style>" +
369+
"body{font-family:system-ui,sans-serif;margin:0;color:#212529}" +
370+
".eml-head{width:100%;border-collapse:collapse;font-size:.85rem;" +
371+
"background:#faf8fa;border-bottom:1px solid #e5e0e5}" +
372+
".eml-head td{padding:4px 12px;vertical-align:top}" +
373+
".eml-head .k{color:#714b67;font-weight:600;white-space:nowrap;width:1%}" +
374+
".eml-head .v{color:#333;word-break:break-word}" +
375+
".eml-body{padding:16px 20px;max-width:80ch}" +
376+
".eml-plain{white-space:pre-wrap;word-break:break-word;margin:0;" +
377+
"font-family:ui-monospace,Menlo,monospace;font-size:.85em}" +
378+
"img{max-width:100%}" +
379+
"</style></head><body>" +
380+
card +
381+
"<div class='eml-body'>" +
382+
this.state.bodyHtml +
383+
"</div></body></html>"
384+
);
385+
}
386+
}
387+
220388
// Audio: HTML5 <audio>.
221389
export class AudioPreview extends Component {
222390
static template = "dms.preview.Audio";
@@ -306,6 +474,13 @@ reg.add("text/markdown", {
306474
match: (mt) => mt === "text/markdown",
307475
score: 5,
308476
});
477+
// Email: .eml files (stored as text/plain) are remapped to message/rfc822 by
478+
// _effectiveMimetype; render the parsed message instead of the raw source.
479+
reg.add("message/rfc822", {
480+
component: EmlPreview,
481+
match: (mt) => mt === "message/rfc822",
482+
score: 5,
483+
});
309484
reg.add("audio/*", {
310485
component: AudioPreview,
311486
match: (mt) => mt.startsWith("audio/"),

dms/static/src/js/components/preview/handlers.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@
5959
/>
6060
</t>
6161

62+
<t t-name="dms.preview.Eml">
63+
<iframe
64+
class="o_dms_preview__iframe o_dms_preview__eml"
65+
sandbox=""
66+
t-att-srcdoc="srcdoc"
67+
t-att-title="props.file.name"
68+
/>
69+
</t>
70+
6271
<t t-name="dms.preview.Audio">
6372
<div class="o_dms_preview__media o_dms_preview__media--audio">
6473
<audio controls="controls" t-att-src="src" />

dms/static/tests/components/preview_handlers.test.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {describe, expect, test} from "@odoo/hoot";
1717
import {
1818
AudioPreview,
1919
DownloadFallbackPreview,
20+
EmlPreview,
2021
ImagePreview,
2122
MarkdownPreview,
2223
OfficeFallbackPreview,
@@ -148,4 +149,12 @@ describe("dispatch — built-in mimetype family routing", () => {
148149
const h = getPreviewHandler("application/json");
149150
expect(h.component).toBe(TextPreview);
150151
});
152+
153+
test("message/rfc822 → EmlPreview (beats TextPreview at score 5)", () => {
154+
// .eml files (stored text/plain) are remapped to message/rfc822 by the
155+
// pane's _effectiveMimetype so the parsed-email handler wins.
156+
const h = getPreviewHandler("message/rfc822");
157+
expect(h.component).toBe(EmlPreview);
158+
expect(h.score).toBe(5);
159+
});
151160
});

0 commit comments

Comments
 (0)