Email Rendering and Images - edmozley/freeitsm GitHub Wiki
How FreeITSM turns a raw inbound email into what you see in the ticket reading pane — specifically the two hard problems that come with rendering arbitrary third‑party HTML inside your own app:
- Images — where they live (embedded vs remote) and how each is served.
- CSS isolation — stopping the app's stylesheet and the email's stylesheet from bleeding into each other.
This is a developer‑facing deep dive. If you just want to use mailboxes, see Mailbox Authentication and Basic IMAP mailboxes.
An <img> in an email body points at its data in one of three ways. Which one it is decides everything about how FreeITSM has to handle it.
| Kind | Looks like | Where the bytes live | Who fetches them |
|---|---|---|---|
| Remote | <img src="https://cdn.sender.com/logo.png"> |
The sender's web server / CDN | The browser, directly, when the pane renders |
| Inline (embedded) |
<img src="cid:abc123"> + a matching MIME attachment part |
Inside the email as an attachment with a Content-ID
|
FreeITSM must extract, store and re‑serve it |
| Data URI | <img src="data:image/png;base64,…"> |
Inline in the HTML itself | Nobody — the browser decodes it in place |
The key insight: most marketing / newsletter / notification mail uses remote images (it's cheaper for the sender and lets them track opens). Remote images bypass FreeITSM entirely — the browser just loads them off the internet. Only inline cid: images need FreeITSM to do any work, and they're comparatively rare — which is exactly why bugs in the inline path can hide for a long time (see #777 below).
Worked example. The Zoho "welcome" email that surfaced most of this work is unusual: it embeds all 12 of its images as inline
cid:attachments and uses zero remote images. That made it the first email where the entire visual design depended on FreeITSM's inline‑image path working correctly.
Remote images need no handling. Inline images go through a four‑step pipeline on import:
-
Extract. Each provider connector pulls the attachment bytes and normalises them to one common shape (a Microsoft Graph–style
fileAttachment), regardless of source:- Microsoft →
includes/mailbox_graph.php(Graph API) - Google →
includes/gmail.php(Gmail API) - Basic IMAP →
includes/mailbox_imap.php(MIME walk of the fetched message)
IMAP and Gmail attach the decoded parts inline on the message (
attachments_inline), because they can't be re‑fetched later the way a Graph message can. - Microsoft →
-
Store.
saveAttachment()inapi/tickets/check_mailbox_email.phpwrites the bytes totickets/attachments/…on disk and a row to theemail_attachmentstable, keeping thecontent_id(thecid) and anis_inlineflag. -
Rewrite.
rewriteCidReferences()swaps everysrc="cid:abc123"in the stored body for a servable URL pointing atget_attachment.php?cid=…&email_id=…, matched bycontent_id. -
Serve.
api/tickets/get_attachment.phplooks the attachment up (bycontent_idor numericid), gates it on the ticket's company so an analyst can't fetch another tenant's file by guessing ids, and streams it withContent-Disposition: inlinefor renderable media.
rewriteCidReferences() originally emitted a root‑absolute URL:
$apiUrl = '/api/tickets/get_attachment.php?cid=' . urlencode($cid) . '&email_id=' . $dbEmailId;FreeITSM auto‑detects a BASE_URL prefix (in config.php) so it can run from a sub‑folder — e.g. http://host/freeitsm-app/. That absolute path ignores BASE_URL, so on any install not served from the web root the browser requested http://host/api/tickets/… (no /freeitsm-app/) and every inline image 404'd.
It stayed hidden for ages because:
- Most mail uses remote images, which never touch this URL.
- The few emails that did embed a
cid:image usually had one broken logo sitting next to working remote images — invisible. - The all‑embedded Zoho mail made it glaring.
Fix (render‑time normalisation). Rather than migrate stored HTML, the URL is corrected when the pane renders it, in safeEmailHtml() (assets/js/inbox.js):
doc.querySelectorAll('img[src*="get_attachment.php"]').forEach(img => {
const raw = img.getAttribute('src') || '';
const qs = raw.indexOf('?');
img.setAttribute('src', API_BASE + 'get_attachment.php' + (qs >= 0 ? raw.slice(qs) : ''));
});API_BASE (../api/tickets/) is the same relative base every other attachment link already uses, so images resolve wherever the app is mounted. Because it runs on render, it fixes all existing tickets (no migration) and all future mail on every provider, and it leaves remote https:// images untouched.
The reading pane displays an email by injecting its (sanitised) HTML into the page. The moment that HTML is part of the page's DOM, the app's own stylesheet cascades into it — and email HTML is authored for a bare browser, not for a page that already has a design system. That mismatch distorts layouts.
There are two directions of leak:
- App → email (the app's CSS reshaping the email) — the visible bug.
-
Email → app (an email's own
<style>selectors likediv {…}or* { position:absolute }bleeding into the app chrome) — historically why FreeITSM strips<style>/<script>/<link>/<base>/<meta>from email bodies insafeEmailHtml().
The single most damaging leak was one global reset in assets/css/inbox.css:
* { box-sizing: border-box; }That * matches every element on the page, including the injected email. Classic email layouts are fixed‑width <table> grids with padded cells and sized images, authored for the default content-box. Forcing border-box recomputes every width — cells shrink, images overflow their cell, and an illustration lands on top of the text.
The targeted fix restores the browser default for the email subtree only:
.email-body-content *,
.thread-message-body * {
box-sizing: content-box;
}.email-body-content * has specificity (0,1,0) which cleanly beats the global * at (0,0,0), and it's scoped to the rendered body, so no FreeITSM UI is touched. This fixed most of the distortion — but it only plugs one leak. Fonts, link styling, list styling, table rules and anything else global still cascade in. To stop playing whack‑a‑mole, we went one step further.
Each email body now renders inside a Shadow DOM instead of the page's light DOM.
Why it works: by spec, a CSS selector in the outer document does not match elements inside a shadow tree. So * { box-sizing }, table {…}, a {…}, list rules — none of them reach the email. At the same time, inherited properties (font-family, color, line-height, …) do cross the boundary from the host element. That combination is precisely what we want:
- Layout / selector rules are blocked → the email lays out exactly as authored.
- Font & colour still inherit → a plain‑text reply keeps the app's readable sans‑serif instead of falling back to Times, while a designed email overrides with its own inline styles.
Implementation (assets/js/inbox.js), a small host/hydrate pair used at both render sites (the main reading pane and each correspondence‑thread message):
function emailBodyHost(rawHtml, cls) {
const token = 'eb' + (++_emailBodySeq);
_emailBodyPending.set(token, safeEmailHtml(rawHtml)); // sanitise now
return `<div class="${cls}" data-email-body="${token}"></div>`; // empty host
}
function hydrateEmailBodies(root) {
root.querySelectorAll('[data-email-body]').forEach(host => {
const token = host.getAttribute('data-email-body');
host.removeAttribute('data-email-body');
const bodyHtml = _emailBodyPending.get(token);
_emailBodyPending.delete(token);
try {
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<style>*{box-sizing:content-box}</style>' + bodyHtml;
} catch (e) {
host.innerHTML = bodyHtml; // graceful fallback, still sanitised
}
});
}emailBodyHost() emits an empty host div and stashes the sanitised HTML by token; hydrateEmailBodies(root) is called synchronously right after the container's innerHTML is set, so there's no flash and no stranded map entries. Sanitisation (safeEmailHtml) still runs first, so scripts/styles are stripped before anything reaches the shadow tree; the box-sizing line inside the shadow is belt‑and‑braces (the app's * can't reach in anyway).
The #778 content-box rule is kept — it still applies to the rare inline fallback path if a browser lacks Shadow DOM.
A sandboxed <iframe srcdoc> is even more isolated (fresh document, app CSS can't reach it at all, and it sandboxes the email's JS). It was rejected for a pure styling problem because it drags in auto‑height management — you must measure content and resize the frame after every image load and on resize, or you get clipped bodies / nested scrollbars. On the most‑viewed surface in the app, that complexity wasn't worth it. Shadow DOM gives full CSS isolation without the sizing pain. (If the goal ever shifts to security sandboxing of untrusted email JS, the iframe becomes the right tool.)
-
Sanitisation.
safeEmailHtml()strips<style>,<script>,<link>,<base>,<meta>viaDOMParserbefore render. Shadow DOM does not sandbox scripts — the sanitiser is what does. (Inline event‑handler attributes remain a known, pre‑existing surface; a sandboxed iframe would be the way to close that if ever needed.) - Remote images = tracking. Because remote images load directly from the sender, opening a ticket fetches them, which can signal an "open" to the sender. This mirrors every webmail client. A parked idea to fetch and localise them at import (for privacy + archival) is written up — along with the SSRF danger that keeps it parked — in Remote Image Archiving.
-
Attachment access is tenant‑gated.
get_attachment.phpchecks the analyst can access the attachment's ticket (via its email → ticket → company) before serving, so inline‑image URLs can't be used to enumerate another company's files.
| Concern | File |
|---|---|
| Sanitise + normalise image URLs + Shadow DOM host/hydrate |
assets/js/inbox.js (safeEmailHtml, emailBodyHost, hydrateEmailBodies) |
box-sizing reset + reading‑pane containment |
assets/css/inbox.css (.email-body-content) |
| Inbound attachment extract / store / cid‑rewrite |
api/tickets/check_mailbox_email.php (saveAttachment, rewriteCidReferences, saveEmailToDatabase) |
| Provider attachment extraction |
includes/mailbox_graph.php, includes/gmail.php, includes/mailbox_imap.php
|
| Serve an attachment (tenant‑gated) | api/tickets/get_attachment.php |
| Deployment sub‑path prefix |
config.php (BASE_URL) |
Changelog references: #777 (inline image sub‑path URL fix), #778 (box-sizing reset), #779 (Shadow DOM isolation).
- Mailbox Authentication — Microsoft 365 & Google Workspace connection modes
- Basic IMAP mailboxes — plain username/password mailboxes
- Remote Image Archiving — parked idea to localise remote images (and why it's risky)
- Tickets — the module this all feeds
- Security — encryption of mailbox secrets and attachment access control