v3.0.0

<bmx-mail-viewer>

Opens received email in the page, with nothing sent anywhere: an .eml, an Outlook .msg, a winmail.dat, an .mbox mailbox of thousands of messages, or a folder's worth of messages at once.

9 properties · 5 events · 14 methods · 5 parts

Example

Try it with your own files in confidence: they are opened by your browser, on your device. Nothing is uploaded, no copy reaches us or anyone else, and nothing is kept once you close this tab.

A fictional design studio's mailbox: a project conversation, a newsletter whose tracking picture is blocked until you ask (its banner travels with the message, so it shows), a meeting request, files to open and save - a picture, a spreadsheet and a forwarded message - and a message in Japanese. Drop your own .eml, .msg or .mbox files on it, or use Open.

Show markup
<div class="row" style="gap: 0.75rem; flex-wrap: wrap; align-items: center; margin-block-end: 0.75rem">
  <button type="button" id="ex-mv-box">Open the sample mailbox</button>
  <button type="button" id="ex-mv-one">Open one message</button>
  <label><input type="checkbox" id="ex-mv-threads" /> Conversations</label>
</div>

<bmx-mail-viewer id="ex-mv" locale="en-GB" label="Sample mailbox" style="--bmx-mail-viewer-height: 46rem"></bmx-mail-viewer>
<p class="note" id="ex-mv-out" role="status">A fictional design studio's mailbox: a project conversation, a newsletter whose tracking picture is blocked until you ask (its banner travels with the message, so it shows), a meeting request, files to open and save - a picture, a spreadsheet and a forwarded message - and a message in Japanese. Drop your own .eml, .msg or .mbox files on it, or use Open.</p>

<script type="module">
  await customElements.whenDefined('bmx-mail-viewer');
  const el = document.getElementById('ex-mv');
  const out = document.getElementById('ex-mv-out');

  const b64 = text => btoa(String.fromCharCode(...new TextEncoder().encode(text))).replace(/.{76}/g, '$&\r\n');
  const word = text => `=?UTF-8?B?${btoa(String.fromCharCode(...new TextEncoder().encode(text)))}?=`;

  // A banner and a photo, drawn here so the sample needs no files.
  const picture = (w, h, draw) => {
    const c = document.createElement('canvas');
    c.width = w;
    c.height = h;
    draw(c.getContext('2d'), w, h);
    return c.toDataURL('image/png').split(',')[1].replace(/.{76}/g, '$&\r\n');
  };
  const banner = picture(600, 160, (g, w, h) => {
    const grad = g.createLinearGradient(0, 0, w, h);
    grad.addColorStop(0, '#0f766e');
    grad.addColorStop(1, '#1e3a8a');
    g.fillStyle = grad;
    g.fillRect(0, 0, w, h);
    g.fillStyle = '#fff';
    g.font = '600 34px system-ui, sans-serif';
    g.fillText('Lumen & Loom', 32, 74);
    g.font = '18px system-ui, sans-serif';
    g.fillText('Autumn studio notes', 34, 110);
  });
  const photo = picture(480, 320, (g, w, h) => {
    g.fillStyle = '#f5efe6';
    g.fillRect(0, 0, w, h);
    g.fillStyle = '#c2410c';
    g.fillRect(60, 120, 200, 140);
    g.fillStyle = '#334155';
    g.fillRect(290, 80, 120, 180);
    g.fillStyle = '#0f766e';
    g.beginPath();
    g.arc(170, 90, 40, 0, Math.PI * 2);
    g.fill();
  });

  const day = n => new Date(Date.UTC(2026, 9, 12 + n, 9 + (n % 5), 12 + n * 3)).toUTCString().replace('GMT', '+0000');
  const messages = [
    `From: Maya Lindqvist <maya@lumenloom.example>
To: Studio <studio@lumenloom.example>
Subject: Harbour Street café - first sketches
Date: ${day(0)}
Message-ID: <hs1@lumenloom.example>
Content-Type: multipart/mixed; boundary="b1"

--b1
Content-Type: text/html; charset=utf-8

<p>Hi all,</p><p>Here is the first pass for the <b>Harbour Street café</b>: warm timber, terracotta and a green that picks up the sea outside.</p><p>The <a href="https://lumenloom.example/projects/harbour">project page</a> has the brief. Thoughts by Thursday?</p><p>Maya</p>
--b1
Content-Type: image/png; name="harbour-sketch.png"
Content-Disposition: attachment; filename="harbour-sketch.png"
Content-Transfer-Encoding: base64

${photo}
--b1
Content-Type: text/csv; name="materials.csv"
Content-Disposition: attachment; filename="materials.csv"
Content-Transfer-Encoding: base64

${b64('Item,Finish,Supplier,Cost\nCounter top,Oiled oak,Northwood,1240\nWall tiles,Terracotta,Clay & Co,860\nPendants,Brushed brass,Lumen Works,420\n')}
--b1--
`,
    `From: Tom Achebe <tom@lumenloom.example>
To: Maya Lindqvist <maya@lumenloom.example>
Cc: Studio <studio@lumenloom.example>
Subject: Re: Harbour Street café - first sketches
Date: ${day(1)}
Message-ID: <hs2@lumenloom.example>
In-Reply-To: <hs1@lumenloom.example>
References: <hs1@lumenloom.example>
Content-Type: text/plain; charset=utf-8; format=flowed

Love the terracotta. Could we try the pendants a touch lower over the ${''}
counter? I have attached nothing - just thoughts.

> Here is the first pass for the Harbour Street café: warm timber,
> terracotta and a green that picks up the sea outside.

Tom
`,
    `From: Priya Nair <priya@lumenloom.example>
To: Maya Lindqvist <maya@lumenloom.example>
Subject: Re: Harbour Street café - first sketches
Date: ${day(2)}
Message-ID: <hs3@lumenloom.example>
In-Reply-To: <hs2@lumenloom.example>
References: <hs1@lumenloom.example> <hs2@lumenloom.example>
X-Priority: 1
Content-Type: text/plain; charset=utf-8

The client wants to see it on Monday - can we have boards ready by Friday?

Priya
`,
    `From: Lumen & Loom Notes <notes@lumenloom.example>
To: studio@lumenloom.example
Subject: ${word('Autumn studio notes 🍂')}
Date: ${day(3)}
Message-ID: <news9@lumenloom.example>
Content-Type: multipart/related; boundary="r1"

--r1
Content-Type: text/html; charset=utf-8

<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f1ea"><tr><td align="center" style="padding:24px"><table width="600" style="background:#ffffff;border-radius:8px;overflow:hidden"><tr><td><img src="cid:banner@notes" width="600" alt="Lumen & Loom autumn notes"></td></tr><tr><td style="padding:24px;font-family:Georgia,serif;font-size:16px;line-height:1.6;color:#1f2937"><h1 style="font-size:24px;margin:0 0 12px">Three colours for the season</h1><p>Rust, moss and a soft stone: what we are reaching for this autumn, and why.</p><p><a href="https://lumenloom.example/notes/autumn" style="display:inline-block;background:#0f766e;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none">Read the notes</a></p></td></tr></table><img src="https://tracking.example.net/open.gif?id=8841" width="1" height="1" alt=""></td></tr></table>
--r1
Content-Type: image/png
Content-ID: <banner@notes>
Content-Transfer-Encoding: base64

${banner}
--r1--
`,
    `From: Priya Nair <priya@lumenloom.example>
To: Studio <studio@lumenloom.example>
Subject: Client review: Harbour Street
Date: ${day(4)}
Message-ID: <inv1@lumenloom.example>
Content-Type: multipart/alternative; boundary="a1"

--a1
Content-Type: text/plain; charset=utf-8

Review of the Harbour Street boards with the client.
--a1
Content-Type: text/calendar; method=REQUEST; charset=utf-8

BEGIN:VCALENDAR
METHOD:REQUEST
BEGIN:VEVENT
UID:inv1@lumenloom.example
SUMMARY:Client review: Harbour Street
DTSTART:20261019T130000Z
DTEND:20261019T140000Z
LOCATION:Studio meeting room
ORGANIZER;CN=Priya Nair:mailto:priya@lumenloom.example
END:VEVENT
END:VCALENDAR
--a1--
`,
    `From: Sam Rivera <sam@lumenloom.example>
To: Maya Lindqvist <maya@lumenloom.example>
Subject: Fwd: Lighting quote
Date: ${day(5)}
Message-ID: <fw1@lumenloom.example>
Content-Type: multipart/mixed; boundary="m2"

--m2
Content-Type: text/plain; charset=utf-8

Forwarding the quote from Lumen Works - see the attached message.
--m2
Content-Type: message/rfc822

From: Lumen Works <sales@lumenworks.example>
To: Sam Rivera <sam@lumenloom.example>
Subject: Quote 2026-118: brushed brass pendants
Date: ${day(4)}
Content-Type: text/plain; charset=utf-8

Six pendants in brushed brass, 1.2 m drop: 420.00 each, delivery in three weeks.
--m2--
`,
    `From: ${word('佐藤 花子')} <hanako@kyoto-textiles.example>
To: Maya Lindqvist <maya@lumenloom.example>
Subject: ${word('生地サンプルの件')}
Date: ${day(6)}
Message-ID: <jp1@kyoto-textiles.example>
Content-Type: text/plain; charset=utf-8

リンドクヴィスト様

生地のサンプルを本日発送いたしました。来週の木曜日に到着予定です。

佐藤
`,
  ];
  const mbox = messages.map(m => `From sender@example ${new Date().toUTCString()}\n${m.replace(/\r?\n/g, '\r\n')}`).join('\r\n');

  document.getElementById('ex-mv-box').addEventListener('click', () => el.open(mbox, 'studio.mbox'));
  document.getElementById('ex-mv-one').addEventListener('click', () => el.open(messages[3].replace(/\r?\n/g, '\r\n'), 'notes.eml'));
  document.getElementById('ex-mv-threads').addEventListener('change', e => (el.conversations = e.target.checked));
  el.addEventListener('bmxOpen', e => (out.textContent = `Opened ${e.detail.name}: ${e.detail.count} message${e.detail.count === 1 ? '' : 's'}.`));
  el.addEventListener('bmxLinkOpen', e => {
    // The sample's links go nowhere real: a page could open them in a new tab, or handle them itself.
    e.preventDefault();
    out.textContent = `The message links to ${e.detail.href}.`;
  });
  el.open(mbox, 'studio.mbox');
</script>

WHAT IT DOES

  • Shows a message as its sender formatted it, in a sandboxed frame that runs no script, with pictures from the internet blocked until the reader asks for them (they tell the sender the message was opened); the pictures the message carries are shown at once. Or as plain text, or its source.
  • The people, date, importance, signed or encrypted, a meeting request's what, when and where; every header on request.
  • Attachments with their real names in any language: open (pictures, text, messages attached to messages, files inside winmail.dat), save one or all as a .zip.
  • A mailbox: a list read a window at a time, search with from:, to:, subject:, has:attachment, before: and after:, sorted, or gathered into conversations.
  • Save any message as .eml (an Outlook .msg included), copy it as text, print it or save it as PDF.

Properties

PropertyAttributeTypeDefaultDescription
allowOpen allow-open boolean true Show Open and accept dropped files.
compactBelow compact-below number 760 Below this width (pixels) the list and the message take turns.
conversations conversations boolean false Gather a mailbox's messages into conversations.
label label string — The element's accessible name.
locale locale string — The locale for dates and sizes. Unset: the page's lang.
remoteImages remote-images BmxMailViewerRemote 'ask' Pictures from the internet: ask, block or allow.
src src string — A file to open by address (fetched from the page's own server).
strings strings Partial<BmxMailViewerStrings> | string — Replacements for any of the wording (an object, or its JSON).
view view BmxMailViewerView 'html' How the body is shown.

Events

EventDetailDescription
bmxAttachmentOpen BmxMailViewerAttachmentDetail Fired before an attachment is opened or saved. Cancelable: cancel to handle the file yourself.
bmxError { message: string; name?: string | undefined; } Fired when a file cannot be opened.
bmxLinkOpen BmxMailViewerLinkDetail Fired when a link in a message is followed. Cancelable: cancel to handle it yourself.
bmxMessageChange BmxMailViewerMessageDetail Fired when another message is shown.
bmxOpen BmxMailViewerOpenDetail Fired when files are opened.

Methods

MethodSignatureDescription
allowRemote allowRemote() => Promise<void> Load the pictures from the internet in the message being shown.
clear clear() => Promise<void> Close what is open.
getMailbox getMailbox() => Promise<BmxMailViewerEntry[]> The open mailbox's messages (headers only).
getMessage getMessage() => Promise<BmxMailMessage | null> The message being shown (an attached one, when one is open).
open open(data: Blob | ArrayBuffer | Uint8Array | string, name?: string) => Promise<number> Open a message or mailbox: a File or Blob, bytes, or the text of an .eml.
openFiles openFiles(files: FileList | readonly File[]) => Promise<number> Open several files as one mailbox (a folder of .eml and .msg files, mailboxes too).
print print() => Promise<void> Print the message being shown (the browser's print offers Save as PDF).
saveAllAttachments saveAllAttachments() => Promise<void> Save every attachment of the message being shown in one .zip.
saveAttachment saveAttachment(id: string) => Promise<boolean> Save one attachment, by its id.
saveEml saveEml() => Promise<void> Save the message being shown as an .eml file.
search search(query: string) => Promise<number> Search the mailbox (as typed in its search box). Returns how many messages match.
select select(index: number) => Promise<boolean> Show a message of the mailbox.
setFocus setFocus() => Promise<void> Focus the list (a mailbox) or the message.
toEml toEml() => Promise<string> The message being shown, as MIME text (an .eml).

CSS shadow parts

PartDescription
attachments The attachments.
body The message body.
header The message's subject, people and date.
list The mailbox's message list.
toolbar The bar with Open and the message actions.

CSS custom properties

PropertyDescription
--bmx-mail-viewer-accent Selected messages and the main buttons.
--bmx-mail-viewer-height Height of a mailbox (the list scrolls within it).
--bmx-mail-viewer-list-width Width of the message list.
--bmx-mail-viewer-radius Corner radius of the frame.