@@ -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 - 9 A - F a - 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 ( / b o u n d a r y = " ? ( [ ^ " ; ] + ) " ? / i) || [ ] ) [ 1 ] ;
277+ if ( ! / ^ m u l t i p a r t \/ / 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, "&" ) . replace ( / < / g, "<" ) . replace ( / > / g, ">" ) ;
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>.
221389export 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+ } ) ;
309484reg . add ( "audio/*" , {
310485 component : AudioPreview ,
311486 match : ( mt ) => mt . startsWith ( "audio/" ) ,
0 commit comments