import PostalMime from 'postal-mime'; import html from './html-template'; import type { Address, Attachment, Email } from 'postal-mime'; import type { SlDialog, SlDropdown, SlIconButton } from '@shoelace-style/shoelace'; import type { TrustedString } from './html-template'; export default class EmlView extends HTMLElement { static get observedAttributes(): string[] { return [ 'src' ]; } #src: string = ''; get src(): string { return this.#src; } set src( value: string ) { if ( this.#src !== value ) { this.#src = value; this.#render(); } } /** * Keep track of all the blob URLs so they can be cleaned up later */ #blobUrls: string[] = []; // TODO: abort the previous render instead of skipping the new one #rendering = false; connectedCallback(): void { this.getSlotted(); this.#render(); } disconnectedCallback(): void { this.innerHTML = ''; this.#releaseBlobUrls(); } attributeChangedCallback( name: string, _oldValue: string, newValue: string ): void { switch ( name ) { case 'src': this.src = newValue; break; } } #slotted: Record = {}; getSlotted(): void { for ( const child of this.children ) { const { slot } = child; if ( slot ) { child.removeAttribute( 'slot' ); this.#slotted[ slot ] ??= []; this.#slotted[ slot ].push( child ); } } } injectSlotted() { for ( const [ slot, children ] of Object.entries( this.#slotted ) ) { const slotElement = this.querySelector( `slot[name="${slot}"]` ); if ( !slotElement ) { continue; } slotElement.replaceWith( ...children ); } } async #render(): Promise { if ( !this.isConnected || this.#rendering ) { return; } this.#rendering = true; try { if ( !this.src ) { this.innerHTML = ``; return; } this.#renderLoadingTemplate(); this.#releaseBlobUrls(); const response = await fetch( this.src ); if ( !response.ok ) { throw new Error( `Server responded with ${response.status} ${response.statusText}` ); } await this.#renderTemplate( await response.arrayBuffer() ); this.injectSlotted(); } catch ( error ) { console.error( error ); this.#renderErrorTemplate( error ); } finally { this.#rendering = false; } } #eml: Email | undefined; get eml(): Email | undefined { return this.#eml; } async #renderTemplate( emlBytes: ArrayBufferLike ): Promise { this.#eml = await new PostalMime().parse( emlBytes ); this.#eml.subject ??= '(No subject)'; this.#eml.from ??= { name: 'undisclosed sender' }; this.#eml.to ??= [ { name: 'undisclosed recipients' } ]; this.#eml.attachments.forEach( attachment => attachment.filename ??= 'unnamed attachment' ); this.#eml.html = this.#fixupHtml( this.#eml ); const emlDownloadUrl = this.#makeBlobUrl( [ emlBytes ], 'message/rfc822', `${this.#eml.subject}.eml` ); function renderMailto( addresses: Address | Address[] ): TrustedString[] { if ( !Array.isArray( addresses ) ) { addresses = [ addresses ]; } return addresses .map( address => { if ( address.group ) { return html`${address.name}: ${renderMailto( address.group )}`; } if ( !address.name && address.address ) { return html`${address.address}` } if ( !address.address ) { return html`${address.name}`; } return html`${address.name} ${address.address}` } ) .flatMap( ( link, i ) => i > 0 ? [ html`, `, link ] : [ link ] ); }; const details = []; const detailEmailAddresses: [ string, Address[]?][] = [ [ 'Reply-To', this.#eml.replyTo ], [ 'CC', this.#eml.cc ], [ 'BCC', this.#eml.bcc ], ] for ( const [ label, values ] of detailEmailAddresses ) { if ( values ) { details.push( html`
${label}
${renderMailto( values )}
` ); } } if ( this.#eml.date ) { details.push( html`
Date
` ); } const emlHtmlContent = this.#eml.html; const emlTextContent = this.#eml.text; const onlyHtml = emlHtmlContent !== undefined && emlTextContent === undefined; const onlyText = emlTextContent !== undefined && emlHtmlContent === undefined; const bothHtmlAndText = emlHtmlContent !== undefined && emlTextContent !== undefined; const htmlViewer = ( content: string ) => html` `; const textViewer = ( content: string ) => html`
${content}
`; const fromInitials = this.#eml.from.name.split( ' ', 2 ).map( ( word ) => word[ 0 ] ).join( '' ); const attachments = this.#eml.attachments.filter( att => att.disposition !== 'inline' ); const inlineAttachments = this.#eml.attachments.filter( att => att.disposition === 'inline' ); const output = html`
${renderMailto( this.#eml.from )}
To: ${renderMailto( this.#eml.to )}
Show details
${details}

${this.#eml.subject}

${attachments.map( a => this.#attachmentTemplate( a ) )}
${inlineAttachments.length ? html`
Show ${inlineAttachments.length.toString()} inline ${_n( inlineAttachments.length, 'attachment', 'attachments' )}
${inlineAttachments.map( a => this.#attachmentTemplate( a ) )}
` : null}
${onlyHtml ? htmlViewer( emlHtmlContent ) : null} ${onlyText ? textViewer( emlTextContent ) : null} ${bothHtmlAndText ? html` HTML ${htmlViewer( emlHtmlContent )} Text ${textViewer( emlTextContent )} ` : null}
`; this.innerHTML = output; this.querySelector( 'sl-dropdown[name="actions"]' )?.addEventListener( 'sl-select', event => { const item = event.detail.item; const eml = this.#eml; if ( !item || !eml ) { return; } switch ( item.value ) { case 'download-eml': this.#downloadUrl( emlDownloadUrl, `${eml.subject}.eml` ); break; case 'download-attachments': for ( const attachment of eml.attachments ) { this.#downloadUrl( this.#makeBlobUrl( [ attachment.content ], attachment.mimeType, attachment.filename! ), attachment.filename!, ); } break; case 'view-headers': const dialog = this.querySelector( 'sl-dialog.eml-dialog-headers' )!; dialog.innerHTML = this.#headerTableTemplate( eml ); dialog.show(); break; default: this.dispatchEvent( new CustomEvent( 'eml-action', { bubbles: true, detail: { action: item.value, eml, }, } ) ); break; } } ); this.querySelectorAll( '.eml-attachment' ) .forEach( link => link.addEventListener( 'click', e => { if ( e.ctrlKey || e.metaKey || e.shiftKey || e.altKey ) { return; } if ( ( e.target as HTMLElement ).closest( '[download]' ) ) { return; } e.preventDefault(); const filename = link.dataset.filename!; switch ( link.dataset.mime ) { case 'application/pdf': if ( navigator.pdfViewerEnabled ) { // PDFs don't need to be sandboxed return this.#preview( html``, link.href, filename ); } break; case 'message/rfc822': // It's turtles all the way down return this.#preview( html``, link.href, filename ); case 'text/plain': case 'text/html': // These *might* be able to be viewed in an iframe return this.#preview( html``, link.href, filename ); default: switch ( link.dataset.mime?.split( '/' )[ 0 ] ) { case 'image': return this.#preview( html``, link.href, filename ); case 'video': return this.#preview( html``, link.href, filename ); case 'audio': return this.#preview( html`