-
-
Notifications
You must be signed in to change notification settings - Fork 27
Outbound Email Attachments Developer Guide
Fixes #158 Β· Changelog #2045β#2049, #2055, #2081 Β· Shipped in 2.10.0 Β· Corrected in 2.10.1
β οΈ 2.10.0 declaredINLINE_THREAD_BUDGETbelow the code that sends, so any reply quoting a picture from the ticket died withUndefined constant. Section 3 now shows where it lives and why. The write-up: Replies with a picture in the thread failed to send.
How a reply or forward becomes an email, on each of the three providers, and what changed to make attachments and pictures actually arrive. For the bug report itself and what it meant for users, see Reply attachments never reached the customer.
Thanks to An Duong (@duongtuanan), who reported it with the raw message Exchange received β the single line Content-Type: text/html that made the cause obvious β and to @mbsouth, who confirmed it on Forward.
api/tickets/send_email.php
β
ββ buildFullEmailBody() analyst's HTML + "reply above this line" + quoted thread
β
ββ provider = Microsoft ββΊ buildEmailMessage() ββΊ JSON { body, attachments[] } ββΊ Graph sendMail
β ββ processInlineImages() + uploadedFileParts()
β
ββ provider = SMTP / Gmail ββΊ processInlineImages() + uploadedFileParts()
ββ mimeBuildMessage() (includes/mime_message.php)
ββ SMTP: imapSmtpSend() writes it down the socket
ββ Gmail: gmailSendEmail() posts it, base64url, to the API
The key idea: all three providers are now fed the same list of files. Microsoft takes that list as JSON and builds the email itself; SMTP and Gmail need a finished email, and mimeBuildMessage() builds it from the same list.
π§± builder Β· π€ send Β· πΌοΈ pictures Β· π§ͺ test
| π¨ | File | What it does |
|---|---|---|
| π§± | includes/mime_message.php |
New. mimeBuildMessage() β the raw RFC 5322 / MIME message for SMTP and Gmail |
| π€ | api/tickets/send_email.php |
Chooses the provider; gathers the files; processInlineImages(), uploadedFileParts()
|
| π€ | includes/mailbox_imap.php |
imapSmtpSend() β the dependency-free SMTP client; now takes $parts
|
| π€ | includes/gmail.php |
gmailSendEmail() β now takes $cc and $parts
|
| π§ͺ | tests/outbound-email-mime.php |
29 checks, including a real SMTP round trip to a fake server |
Also benefit, untouched: includes/template_email.php, includes/self_service_email.php, api/auth/request_password_reset.php and workflow/includes/engine.php call the same two send functions without $parts, so they get the plain HTML email they always did β plus, on SMTP, the new Date and Message-ID headers.
Everything is expressed in Microsoft Graph's fileAttachment shape, because the Graph path already used it:
['name' => 'report.pdf', 'contentType' => 'application/pdf',
'contentBytes' => '<base64>', // an ordinary attachment
'contentId' => 'inline_image_1_1790805999', 'isInline' => true] // add these two for a picture in the bodyThe analyst's uploaded files are converted by one small function, now shared by all three paths:
function uploadedFileParts($attachments) {
$parts = [];
foreach ((array)$attachments as $attachment) {
if (!is_array($attachment) || !isset($attachment['name'], $attachment['content'])) {
continue;
}
$parts[] = [
'@odata.type' => '#microsoft.graph.fileAttachment',
'name' => $attachment['name'],
'contentType' => $attachment['type'] ?? 'application/octet-stream',
'contentBytes' => $attachment['content'] // Already base64 encoded
];
}
return $parts;
}And the send itself:
if ($provider === 'imap' || $provider === 'google') {
$inline = processInlineImages($bodyForSending, (int)$ticketId);
$parts = array_merge($inline['attachments'], uploadedFileParts($attachments));
if ($provider === 'imap') {
imapSmtpSend($mailbox, $to, $cc, $subject, $inline['body'], $parts);
} else {
gmailSendEmail($accessToken, $to, $subject, $inline['body'], $mailbox['target_mailbox'] ?? '', $cc, $parts);
}
} else {
$message = buildEmailMessage($to, $cc, $subject, $bodyForSending, $attachments, (int)$ticketId);
$result = sendEmailViaGraph($accessToken, $message);
}Before the fix the first branch was two separate calls that passed $bodyForSending and nothing else. The ticket then saved $attachments as "sent", so everything on FreeITSM's side said the customer had the file.
mimeBuildMessage() builds the email only as deep as it needs to be:
| The reply has | Structure |
|---|---|
| Just text |
text/html β byte-for-byte the shape sent before #158 |
| Pictures in the body | multipart/related { html, image, image⦠} |
| Attached files | multipart/mixed { html-or-related, file, file⦠} |
$body = mimeHtmlPart((string)($m['html'] ?? ''));
if ($inline) {
$body = mimeMultipart('related', array_merge([$body], array_map('mimeFilePart', $inline)), 'type="text/html"');
}
if ($files) {
$body = mimeMultipart('mixed', array_merge([$body], array_map('mimeFilePart', $files)));
}
// $body is [headers, content]; its headers become the message's own.
return implode("\r\n", array_merge($headers, $body[0])) . "\r\n\r\n" . $body[1];Each entity is a [headers, content] pair, so nesting is just wrapping: a related becomes one entity inside a mixed. Everything is base64 in 76-character lines, so no body line can ever begin with the . that would end an SMTP DATA block (the SMTP client dot-stuffs anyway).
A file's name and type come from the analyst's browser, so they are treated as hostile:
// Only a plain type/subtype reaches the header
$type = strtolower((string)($p['contentType'] ?? ''));
if (!preg_match('#^[a-z0-9][a-z0-9!\#$&^_.+-]*/[a-z0-9][a-z0-9!\#$&^_.+-]*$#', $type)) {
$type = 'application/octet-stream';
}
// An ASCII fallback name, plus RFC 2231 for the real UTF-8 name
$name = str_replace(["\r", "\n", '"', '\\'], '', $name);
$ascii = preg_replace('/[^\x20-\x7E]/', '_', $name);
$fileParam = 'filename="' . $ascii . '"';
if ($ascii !== $name) {
$fileParam .= "; filename*=UTF-8''" . rawurlencode($name);
}The test sends a type of application/pdf\r\nBcc: evil@example.test and checks no Bcc: header appears. Recipients go through FILTER_VALIDATE_EMAIL on both paths, and the subject and sender name are RFC 2047-encoded with line breaks removed.
RFC 5322 requires both, and spam filters score a message without them. The Gmail API stamps its own (the raw sent copies show Date: and Message-Id: <β¦@mail.gmail.com> that FreeITSM never wrote), but nothing on the SMTP path did. So they're added on request, and only imapSmtpSend() asks:
if (!empty($m['envelope'])) {
$headers[] = 'Date: ' . gmdate('D, d M Y H:i:s') . ' +0000';
$headers[] = 'Message-ID: ' . mimeMessageId($from); // <random.time@sender-domain>
}A picture in an email body only displays if it travels inside the email, referenced as src="cid:β¦". Two kinds of picture needed converting.
When an email with pictures arrives, FreeITSM saves each picture and rewrites its tag to point back at itself:
<img src="/api/tickets/get_attachment.php?cid=image001.png@01DC...&email_id=326">Every reply quotes the thread, so that link travels to the customer, whose mail client can't reach it. The converter existed, but looked for api/get_attachment.php β a form no stored email contains. It had converted nothing, on any provider. The pattern now takes any relative link, in any of the forms that have been stored:
$pattern = '/src=(["\'])(?![a-z][a-z0-9+.\-]*:)[^"\']*?api\/(?:tickets\/)?get_attachment\.php\?([^"\']+)\1/i';The (?!β¦:) refuses anything with a scheme, so https://someone-else/api/get_attachment.php is left alone.
Only this ticket's files. The old lookup took any attachment id. An analyst can edit a reply's HTML source, so a typed-in link could have mailed out another ticket's β on a multi-company install, another company's β file. Both lookups now join to the email's ticket:
$sql = "SELECT a.id, a.filename, a.content_type, a.file_path
FROM email_attachments a
JOIN emails e ON e.id = a.email_id
WHERE a.id = ? AND e.ticket_id = ?";At most 2 MB per email. Every reply re-sends the whole thread's pictures, and Graph refuses a request over 4 MB. Uncapped, a picture-heavy thread would have gone from "sends with broken pictures" to "doesn't send" β on Microsoft, which was never reported broken. Past the cap a picture keeps its link, as before:
// At the TOP of send_email.php, under the require_once lines:
const INLINE_THREAD_BUDGET = 2 * 1024 * 1024;
// ...and inside processInlineImages():
$size = (int)filesize($filePath);
if ($threadBytes + $size > INLINE_THREAD_BUDGET) {
return $matches[0]; // leave this one as a link
}
$threadBytes += $size;π΄ The constant must stay at the top. A function anywhere in a PHP file exists as soon as the file starts; a top-level const exists only once execution reaches its line. send_email.php does its work at the top and keeps its functions below, so a constant declared down beside processInlineImages() β where 2.10.0 put it β is never defined when a reply is sent. It was read only when a thread picture from the ticket was found, so 2.10.0 failed exactly those replies and sent the rest (#2081).
The picture callback catches Throwable, not just Exception, so a fault embedding one picture leaves that link as it was rather than failing the whole email:
} catch (Throwable $e) {
error_log('Inline image processing error: ' . $e->getMessage());
return $matches[0];
}The same picture quoted twice goes in once ($byAttachmentId).
The reply editor stores a pasted screenshot as a data: image, and Gmail and Outlook won't show a data: image in a received email. Each one becomes an inline part:
$body = preg_replace_callback(
'/src=(["\'])data:(image\/[a-z0-9.+\-]+);base64,([A-Za-z0-9+\/=\s]+)\1/i',
function ($m) use (&$inlineAttachments, &$cidCounter, $stamp) {
$bytes = preg_replace('/\s+/', '', $m[3]);
if (base64_decode($bytes, true) === false) {
return $m[0];
}
// ... add ['contentType' => $m[2], 'contentBytes' => $bytes, 'contentId' => $newCid, 'isInline' => true]
return 'src="cid:' . $newCid . '"';
},
$body
);These aren't counted against the 2 MB thread budget: they are the analyst's own content, like an attachment.
After the fix, a live reply with a pasted screenshot arrived in Gmail showing an empty box where the picture should be, with the picture listed as an attachment underneath. It looked exactly like a broken cid: reference. It wasn't one, and it's worth recording how that was proved, because the same symptom will turn up again.
-
The sent copy was pulled back through the Gmail API: valid MIME, one
<img src="cid:inline_image_1_β¦">, a matchingContent-IDon aninlineimage part, and a complete PNG (IENDpresent,getimagesizefromstring()happy). -
The received copy (Gmail β Show original β Download) was identical in every way that matters. Gmail had re-encoded it and added a text part, but the
cidstill matched. - Isolation tests, each changing one thing, all displayed correctly: the real screenshot, the real reply's exact HTML, the real subject (so Gmail threaded it into the same conversation), and no width/height.
-
Outlook opened the downloaded
.emlperfectly. -
The answer was on screen all along: the broken messages were in Gmail's Spam folder. Gmail strips images from spam β it keeps the
<img>with its width and height, which is the empty box, removes thesrc, and lists the picture as an attachment. Report as not spam and it appears.
Why spam? The sending domain's SPF record didn't list Google's servers (spf=fail in the received headers, though DKIM passed). That's DNS, not FreeITSM β but it will do the same to your customers. If you send through a Google Workspace mailbox, make sure your domain's SPF includes include:_spf.google.com.
π Before hunting an email rendering bug, check which folder it landed in. And compare the received message, not only the sent one β the difference between them is the receiver's doing, not yours.
php tests/outbound-email-mime.php β 29 checks, against the real code and real data:
| # | Section | What it proves |
|---|---|---|
| 0 | Declaration order | Every top-level const in send_email.php is declared above the code that sends (added in 2.10.1 β run against 2.10.0 it fails and names INLINE_THREAD_BUDGET) |
| 1 | The builder | Right structure for each case; bytes identical; the injected Bcc refused; UTF-8 names and subjects encoded; Date/Message-ID only with envelope, well-formed and unique |
| 2 | A real quoted thread | Every stored picture becomes a cid: part, byte-for-byte the file on disk |
| 3 | Another ticket's file | Left as a link β with a positive control: the same link to this ticket's file is embedded |
| 4 | The real SMTP client | Sends to a fake server the test starts on 127.0.0.1: multipart/mixed, CC delivered, PDF identical, every picture inside, Date and Message-ID present |
| 5 | The 2 MB cap | A ticket over the cap: one picture embedded, the rest left as links |
| 6 | A pasted screenshot | A data: image becomes an inline part |
Run against the broken code, the test fails where it should: 0 of the thread pictures are converted, and the SMTP section cannot send at all. The Date/Message-ID check was confirmed to fail with the option switched off.
Beyond the test:
-
The inbound side is untouched. The reading pane's responses for six inbound emails (22 pictures and files, all three providers) were recorded before and after: byte-for-byte identical. The recording has to save and restore
is_read, because opening an email marks it read β the first "before" snapshot differed from itself for that reason. - Live, from a Gmail mailbox: the attached PDF arrived, and the pasted screenshot displayed inline once the message was moved out of the Spam folder (see section 4).
- Live, 2.10.1, all three providers, through the real endpoint: nine numbered emails β per provider, a reply quoting a thread picture, a plain reply, and a forward with a thread picture, a pasted screenshot and an attached file. All nine arrived. The same tickets on 2.10.0 failed every picture case with the reporter's exact error, and sent nothing.
send_email.php sends the moment it is loaded, so the test evals only the functions (from getMailboxForTicket() down) and then the file's top-level constants. That means the test never runs the file in the server's order β which is how 2.10.0 passed every check while failing in production. Section 0 reads the order from the source to make up for it. If you add a constant, put it at the top; if you add a send path, drive it through the real endpoint as well.
-
"Code that looks like it handles a case" is where bugs hide.
buildEmailMessage()calledprocessInlineImages(), so everyone assumed Microsoft embedded thread pictures. The pattern inside matched nothing. Count real matches before believing a converter works. -
A file saved to the ticket is not a file sent. If you add another send path, feed it the same
$partslist; don't let the ticket record something the email didn't carry. - The Graph 4 MB limit applies to the whole request. Anything that adds bytes to every reply needs a budget.
-
Only SMTP gets
envelope. Gmail stamps its ownDateandMessage-ID. - An empty box in Gmail may be Spam, not you. See section 4.
-
A top-level
constis not hoisted. In an endpoint, declare it above the working code, never beside the function that uses it. A test that loads only the functions cannot tell the difference.
- Replies with a picture in the thread failed to send β the 2.10.0 fault
- Reply attachments never reached the customer β the bug write-up
- Basic IMAP mailboxes
- Bugs resolved
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
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
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- 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: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- 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
- β³ π’ One board across every company
- β³ 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)
- What this is
-
π Bugs resolved
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96