Skip to content

Email Rendering and Images

Ed Mozley edited this page Jul 11, 2026 · 2 revisions

Email Rendering & Images

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:

  1. Images β€” where they live (embedded vs remote) and how each is served.
  2. 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.


Part 1 β€” How images arrive in an email

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.


Part 2 β€” How FreeITSM serves inline (cid:) images

Remote images need no handling. Inline images go through a four‑step pipeline on import:

  1. 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.

  2. Store. saveAttachment() in api/tickets/check_mailbox_email.php writes the bytes to tickets/attachments/… on disk and a row to the email_attachments table, keeping the content_id (the cid) and an is_inline flag.

  3. Rewrite. rewriteCidReferences() swaps every src="cid:abc123" in the stored body for a servable URL pointing at get_attachment.php?cid=…&email_id=…, matched by content_id.

  4. Serve. api/tickets/get_attachment.php looks the attachment up (by content_id or numeric id), gates it on the ticket's company so an analyst can't fetch another tenant's file by guessing ids, and streams it with Content-Disposition: inline for renderable media.

#777 β€” the sub‑path URL bug

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.


Part 3 β€” CSS isolation (the reading pane vs the app stylesheet)

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 like div {…} or * { position:absolute } bleeding into the app chrome) β€” historically why FreeITSM strips <style>/<script>/<link>/<base>/<meta> from email bodies in safeEmailHtml().

#778 β€” the box-sizing leak (the cheap, targeted fix)

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.

#779 β€” Shadow DOM isolation (the real fix)

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.

Why not an <iframe>?

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.)


Security & privacy notes

  • Sanitisation. safeEmailHtml() strips <style>, <script>, <link>, <base>, <meta> via DOMParser before 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 future enhancement could proxy or block remote images.
  • Attachment access is tenant‑gated. get_attachment.php checks 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.

Code map

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).


Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally