Skip to content

Commit af7b58e

Browse files
committed
feat(file-viewer): enable viewing .eml files
1 parent 1f14488 commit af7b58e

10 files changed

Lines changed: 450 additions & 10 deletions

File tree

etc/lime-elements.api.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,7 @@ export namespace Components {
406406
"value": LabelValue;
407407
}
408408
export interface LimelEmailViewer {
409+
"attachments"?: EmailAttachment[];
409410
"bodyHtml"?: string;
410411
"bodyText"?: string;
411412
"cc"?: string;
@@ -983,6 +984,13 @@ export type EditorTextLink = {
983984
// @beta (undocumented)
984985
export type EditorUiType = 'standard' | 'minimal' | 'no-toolbar';
985986

987+
// @public
988+
export interface EmailAttachment {
989+
filename?: string;
990+
mimeType?: string;
991+
size?: number;
992+
}
993+
986994
// Warning: (ae-missing-release-tag) "EventEmitter" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
987995
//
988996
// @public (undocumented)
@@ -1010,7 +1018,7 @@ export interface FileInfo {
10101018
}
10111019

10121020
// @public (undocumented)
1013-
export type FileType = 'pdf' | 'image' | 'video' | 'audio' | 'text' | 'office' | 'unknown';
1021+
export type FileType = 'pdf' | 'image' | 'video' | 'audio' | 'text' | 'email' | 'office' | 'unknown';
10141022

10151023
// @public (undocumented)
10161024
export type FlexContainerAlign = 'start' | 'end' | 'center' | 'stretch';
@@ -1648,6 +1656,7 @@ export namespace JSX {
16481656
"value"?: LabelValue;
16491657
}
16501658
export interface LimelEmailViewer {
1659+
"attachments"?: EmailAttachment[];
16511660
"bodyHtml"?: string;
16521661
"bodyText"?: string;
16531662
"cc"?: string;

src/components/email-viewer/email-viewer.scss

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,58 @@
6767
}
6868
}
6969

70+
.attachments {
71+
padding: 0.5rem 0.75rem;
72+
73+
label {
74+
font-size: var(--limel-theme-small-font-size);
75+
opacity: 0.6;
76+
}
77+
78+
ul {
79+
all: unset;
80+
display: grid;
81+
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
82+
gap: 0.5rem;
83+
84+
padding: 0.5rem 0;
85+
}
86+
87+
li {
88+
all: unset;
89+
position: relative;
90+
display: flex;
91+
flex-direction: column;
92+
gap: 0.25rem;
93+
94+
font-size: 0.6875rem;
95+
line-height: normal;
96+
97+
padding: 0.75rem 0.5rem 0.5rem 0.5rem;
98+
border-radius: 0.5rem;
99+
border: 1px solid rgba(var(--contrast-600));
100+
background-color: rgba(var(--contrast-200));
101+
}
102+
103+
.attachment-filename {
104+
font-weight: 500;
105+
}
106+
107+
.attachment-mime-type {
108+
opacity: 0.7;
109+
}
110+
111+
limel-badge {
112+
--badge-max-width: auto;
113+
--badge-background-color: rgb(var(--contrast-1000), 0.7);
114+
--badge-text-color: rgb(var(--color-white));
115+
position: absolute;
116+
top: 0.125rem;
117+
right: 0.125rem;
118+
box-shadow: var(--shadow-brighten-edges-outside);
119+
}
120+
}
121+
70122
.body {
71123
flex-grow: 1;
72124
max-width: 100%;

src/components/email-viewer/email-viewer.tsx

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Component, Prop, h } from '@stencil/core';
22
import translate from '../../global/translations';
33
import { Languages } from '../date-picker/date.types';
4+
import { EmailAttachment } from '../../util/email';
5+
import { formatBytes } from '../../util/format-bytes';
46

57
/**
68
* This is a private component, used to render `.eml` files inside
@@ -63,6 +65,12 @@ export class EmailViewer {
6365
@Prop()
6466
public bodyText?: string;
6567

68+
/**
69+
* List of non-inline attachments.
70+
*/
71+
@Prop()
72+
public attachments?: EmailAttachment[];
73+
6674
/**
6775
* Optional URL to render as a final fallback using an `<object type="text/plain">`.
6876
*/
@@ -112,6 +120,7 @@ export class EmailViewer {
112120
this.getTranslation('file-viewer.email.date'),
113121
this.date
114122
)}
123+
{this.renderAttachments()}
115124
</div>
116125
);
117126
}
@@ -162,8 +171,8 @@ export class EmailViewer {
162171
return (
163172
<dl class={`headers ${type}`}>
164173
<dt>{label}</dt>
165-
{values.map((v) => (
166-
<dd>{v}</dd>
174+
{values.map((headerValue, index) => (
175+
<dd key={`${type}-${index}`}>{headerValue}</dd>
167176
))}
168177
</dl>
169178
);
@@ -176,13 +185,52 @@ export class EmailViewer {
176185
if (type === 'to' || type === 'cc') {
177186
return value
178187
.split(',')
179-
.map((v) => v.trim())
188+
.map((valuePart) => valuePart.trim())
180189
.filter(Boolean);
181190
}
182191

183192
return [value];
184193
}
185194

195+
private renderAttachments() {
196+
if (!this.attachments || this.attachments.length === 0) {
197+
return;
198+
}
199+
200+
const label = this.getTranslation('file-viewer.email.attachments');
201+
202+
return (
203+
<div class="attachments">
204+
<label>{label}</label>
205+
<ul role="list">
206+
{this.attachments.map((attachment, index) => (
207+
<li key={`attachment-${index}`} role="listitem">
208+
<span class="attachment-filename">
209+
{attachment.filename?.trim() ||
210+
this.getTranslation(
211+
'file-viewer.email.attachment.unnamed'
212+
)}
213+
</span>
214+
<span class="attachment-mime-type">
215+
{attachment.mimeType?.trim()}
216+
</span>
217+
{this.renderSizeBadge(attachment.size)}
218+
</li>
219+
))}
220+
</ul>
221+
</div>
222+
);
223+
}
224+
225+
private renderSizeBadge(size: number) {
226+
if (typeof size !== 'number') {
227+
return null;
228+
}
229+
return (
230+
<limel-badge class="attachment-size" label={formatBytes(size)} />
231+
);
232+
}
233+
186234
private getTranslation(key: string) {
187235
return translate.get(key, this.language);
188236
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import PostalMime from 'postal-mime';
2+
import { Email } from '../../util/email';
3+
4+
/**
5+
* Email loading/parsing helpers for `limel-file-viewer`.
6+
*
7+
* Parses an RFC 5322 / MIME email message (commonly stored as a `.eml` file)
8+
* and returns a simplified `Email` view-model.
9+
*/
10+
11+
/**
12+
* Fetches and parses an email message.
13+
*
14+
* - Prefers `email.html` if present, otherwise falls back to `email.text`.
15+
* - Attempts to resolve inline images referenced via `cid:` by replacing
16+
* `<img src="cid:...">` with `data:` URLs generated from inline attachments.
17+
*
18+
* @param url - URL to an email message, usually ending in `.eml`.
19+
* @returns A simplified `Email` object for rendering.
20+
*/
21+
export async function loadEmail(url: string): Promise<Email> {
22+
const response = await fetch(url);
23+
const buffer = await response.arrayBuffer();
24+
25+
const email = await PostalMime.parse(buffer, {
26+
attachmentEncoding: 'arraybuffer',
27+
});
28+
29+
const parsedEmail: Email = {
30+
subject: email.subject || undefined,
31+
from: formatAddress(email.from),
32+
to: formatAddresses(email.to),
33+
cc: formatAddresses(email.cc),
34+
date: email.date || undefined,
35+
};
36+
37+
const attachments: Email['attachments'] = [];
38+
39+
const cidUrlById = new Map<string, string>();
40+
for (const attachment of email.attachments || []) {
41+
const contentId = normalizeContentId(attachment.contentId);
42+
const isInline =
43+
attachment.related || attachment.disposition === 'inline';
44+
45+
if (!isInline) {
46+
const size =
47+
attachment.content instanceof ArrayBuffer
48+
? attachment.content.byteLength
49+
: undefined;
50+
51+
attachments.push({
52+
filename: attachment.filename || undefined,
53+
mimeType: attachment.mimeType || undefined,
54+
size,
55+
});
56+
continue;
57+
}
58+
59+
if (!contentId) {
60+
continue;
61+
}
62+
63+
if (!(attachment.content instanceof ArrayBuffer)) {
64+
continue;
65+
}
66+
67+
const mimeType = attachment.mimeType || 'application/octet-stream';
68+
const base64 = arrayBufferToBase64(attachment.content);
69+
const dataUrl = `data:${mimeType};base64,${base64}`;
70+
cidUrlById.set(contentId, dataUrl);
71+
}
72+
73+
if (attachments.length > 0) {
74+
parsedEmail.attachments = attachments;
75+
}
76+
77+
const html = (email.html || '').trim();
78+
if (html) {
79+
parsedEmail.bodyHtml = replaceCidReferences(html, cidUrlById);
80+
} else {
81+
parsedEmail.bodyText = (email.text || '').trim() || undefined;
82+
}
83+
84+
return parsedEmail;
85+
}
86+
87+
/**
88+
* Normalizes a Content-ID by removing surrounding angle brackets.
89+
*
90+
* Example: `<image@id>` -> `image@id`
91+
*
92+
* @param contentId - The Content-ID to normalize, optionally surrounded by angle brackets.
93+
*/
94+
function normalizeContentId(contentId?: string): string {
95+
if (!contentId) {
96+
return '';
97+
}
98+
99+
return contentId.replaceAll(/(^<|>$)/g, '').trim();
100+
}
101+
102+
function replaceCidReferences(
103+
html: string,
104+
cidUrlById: Map<string, string>
105+
): string {
106+
if (cidUrlById.size === 0) {
107+
return html;
108+
}
109+
110+
return html.replaceAll(
111+
/(src\s*=\s*["']?)cid:([^"'\s>]+)(["']?)/gi,
112+
(match, prefix, cid, suffix) => {
113+
const normalized = normalizeContentId(cid);
114+
const replacement = cidUrlById.get(normalized);
115+
if (!replacement) {
116+
return match;
117+
}
118+
119+
return `${prefix}${replacement}${suffix}`;
120+
}
121+
);
122+
}
123+
124+
function arrayBufferToBase64(buffer: ArrayBuffer): string {
125+
const bytes = new Uint8Array(buffer);
126+
127+
if (typeof btoa === 'function') {
128+
let binary = '';
129+
for (const byte of bytes) {
130+
binary += String.fromCodePoint(byte);
131+
}
132+
return btoa(binary);
133+
}
134+
135+
// Jest/Node fallback
136+
return (globalThis as any).Buffer.from(bytes).toString('base64');
137+
}
138+
139+
/**
140+
* Formats one or many address objects returned by PostalMime.
141+
*
142+
* @param addresses - Address object(s) to format.
143+
* @returns Formatted address string, or undefined if no valid addresses.
144+
*/
145+
function formatAddresses(addresses: any): string {
146+
if (!addresses) {
147+
return undefined;
148+
}
149+
150+
const list = Array.isArray(addresses) ? addresses : [addresses];
151+
const parts = list.map((addr) => formatAddress(addr)).filter(Boolean);
152+
153+
return parts.length > 0 ? parts.join(', ') : undefined;
154+
}
155+
156+
function formatAddress(address: any): string {
157+
if (!address) {
158+
return undefined;
159+
}
160+
161+
if (Array.isArray(address)) {
162+
return formatAddresses(address);
163+
}
164+
165+
if (address.group && Array.isArray(address.group)) {
166+
const groupName = (address.name || '').trim();
167+
const groupMembers = address.group
168+
.map((m) => formatAddress(m))
169+
.filter(Boolean)
170+
.join(', ');
171+
return groupName ? `${groupName}: ${groupMembers}` : groupMembers;
172+
}
173+
174+
const name = (address.name || '').trim();
175+
const email = (address.address || '').trim();
176+
177+
if (name && email) {
178+
return `${name} <${email}>`;
179+
}
180+
181+
return name || email || undefined;
182+
}

0 commit comments

Comments
 (0)