-
Notifications
You must be signed in to change notification settings - Fork 15
Email Rendering and 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:
- 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
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)