Repository navigation
Attachment Size Limits and Large Emails Developer Guide
How FreeITSM decides how big an attachment may be, and what happens to an inbound email that is too large (issue #170). This page is for someone changing the code. The settings themselves are explained in the in-app help: System β Security β Attachments and the mailbox admin guide's Large emails section.
Related: Mailbox Authentication Β· Basic IMAP mailboxes Β· Email send log Β· Outbound email attachments - Developer Guide
- There are two limits: the largest single attachment (
attachment_max_mb, default 10) and the largest total of one inbound email's attachments (attachment_max_total_mb, default 0 = no limit). Both live insystem_settingsand are set on System β Security. - The per-attachment limit replaced a hard-coded 10 MB. Every upload path that does not pass a limit of its own now gets the configured one.
- An inbound email over either limit is either kept with the oversized attachments stripped (a note on the ticket lists them) or rejected (no ticket). When the attachments are each fine but too big together, a setting decides which go: largest first, all, or in order until the next would not fit.
- Optionally an alert goes to chosen addresses and a reply goes to the sender. The reply cannot loop.
- All of that has install-wide defaults (Tickets β Settings β Mailboxes) and a per-mailbox override, limits included, stored as JSON on the mailbox.
- Everything is recorded in the mailbox activity log, and the alert and reply in the email send log.
The whole decision lives in one file, includes/attachment_limits.php. The mailbox checker only measures, asks, and acts on the answer.
ποΈ schema Β· π read Β· βοΈ write Β· π cross-module Β· π₯οΈ UI / help Β· π docs
| π¨ | File | What it does here |
|---|---|---|
| π | includes/attachment_limits.php |
The rules. Reads the settings, merges a mailbox's override, decides what to strip or reject (oversizePlan()), sends the alert and reply (oversizeNotify()), and the loop guard. Also phpUploadLimits() for the settings screen. |
| βοΈ | api/tickets/check_mailbox_email.php |
The mailbox checker. Measures each email's attachments before importing it, then rejects, strips or carries on. oversizeMeasure(), oversizeLogStep() and oversizeSummary() live at the bottom. |
| π | includes/uploads.php |
uploadStoreFile() and uploadStoreBytes() - a null limit now means "the configured one" (attachmentMaxBytes()), not the old constant. |
| π | includes/mailbox_send.php |
mailboxSendSimple() - sends one plain email from a ticket mailbox through Microsoft Graph, Gmail or SMTP, and writes the send log. Used for the alert and the reply. |
| π | includes/email_log.php |
Two send-log routes: oversize_alert and oversize_reply. |
| βοΈ | api/tickets/oversize_settings.php |
GET / POST the install-wide defaults (Cap::TICKETS_MAILBOXES). |
| βοΈ | api/tickets/save_mailbox.php |
Saves a mailbox's own rules into target_mailboxes.oversize_policy. |
| π | api/tickets/get_mailboxes.php |
Returns oversize_policy with each mailbox. |
| βοΈ |
api/settings/save_system_settings.php + includes/settings_keys.php
|
Saves the two limits (registered keys, System β Security). |
| π₯οΈ | system/security/index.php |
The Attachments card: the two limits, PHP's own limits, the warning, the link to Mailboxes. |
| π₯οΈ | tickets/settings/index.php |
When an email is too large under the mailbox list, and Large emails in each mailbox's dialog. Both are drawn by one PHP function, renderOversizeFields($prefix, $withLimits), and read by one JS pair, setOversizeFields() / oversizeFields(). |
| π₯οΈ |
system/help/security.php, tickets/help-mailbox-auth.php
|
The two help sections. |
| ποΈ |
includes/db_verify_schema.php, database/freeitsm.sql
|
target_mailboxes.oversize_policy TEXT NULL. |
| π | includes/feature_bingo/cards/tickets-channels.php |
The card for "somebody hears about an oversized email". |
What you do not touch: the ingest notice (attachmentIngestNotice() in includes/uploads.php) is reused unchanged - a stripped attachment is reported exactly like any other attachment FreeITSM would not keep.
The owner asked for this split, with each screen pointing to the other:
| Setting | Key | Screen | Why there |
|---|---|---|---|
| Largest attachment | attachment_max_mb |
System β Security | It applies to every upload path, not just email. |
| Largest email | attachment_max_total_mb |
System β Security | Sits with its sibling. Only inbound email uses it. |
| Keep or reject |
oversize_mode (strip / reject) |
Tickets β Settings β Mailboxes | Only email can be "too big" after the fact. A browser upload is simply refused on the spot. |
| What goes when over the total |
oversize_strip_order (largest / all / order) |
Mailboxes | Admins disagree on the best answer, so it is a choice. |
| Alert |
oversize_alert (0/1), oversize_alert_to
|
Mailboxes | |
| Reply to the sender |
oversize_autoreply (0/1), oversize_autoreply_text
|
Mailboxes |
A mailbox's own rules are one JSON value in target_mailboxes.oversize_policy, in MB:
{"max_mb":50,"max_total_mb":200,"mode":"strip","strip_order":"largest",
"alert":true,"alert_to":"it@example.com","autoreply":false,"autoreply_text":"..."}NULL means "use the defaults". When it is set, all of it applies, the limits included - so a supplier mailbox can accept 50 MB drawings while everything else stays at 10.
One function turns settings into the rules for one mailbox. Limits are in bytes from here on.
$rules = [
'max_bytes' => attachmentMaxBytes($conn),
'max_total_bytes' => attachmentMb(attachmentSetting($conn, 'attachment_max_total_mb', '0'), 0, true) * 1024 * 1024,
'mode' => attachmentSetting($conn, 'oversize_mode', 'strip') === 'reject' ? 'reject' : 'strip',
...
'source' => 'defaults',
];
$own = $mailbox && !empty($mailbox['oversize_policy']) ? json_decode((string)$mailbox['oversize_policy'], true) : null;
if (is_array($own)) {
$rules = array_merge($rules, oversizeNormalisePolicy($own, $rules));
$rules['source'] = 'mailbox';
}oversizeNormalisePolicy() is used both when a mailbox is saved and when it is read, so a hand-edited row ("max_mb": -5, "mode": "explode") can never break a mailbox check - it falls back to something sane. attachmentMb() clamps every number (1 to 100 GB; 0 allowed only where it means "no limit").
source is carried through so the alert can say whether the install's or the mailbox's limits applied.
The checker loops over the emails a mailbox returned. The size check sits after the sender allow-list check and before the email is imported:
fetch emails
for each email:
allow-list check ββ fail ββΆ rejected (as before)
oversizeMeasure() fetch + measure attachments, once
oversizePlan() what breaks a limit?
plan says reject ββββββββββΆ handle like rejected mail, notify, log 'rejected', next
saveEmailToDatabase(..., $prefetched, $strip, $maxBytes)
if anything was stripped: notify, put the reason on the log row
log 'imported'
Before #170, Microsoft 365 attachments were fetched after the ticket and the email row had been written, inside saveEmailToDatabase(). "Reject the whole email" has to be decided before a ticket exists, so the fetch moved forward:
[$prefetched, $sizeAtts] = oversizeMeasure($conn, $email, $accessToken);
if ($sizeAtts) $sizePlan = oversizePlan($sizeAtts, $oversizeRules);The same fetched list is handed on to saveEmailToDatabase() as $prefetched, so nothing is downloaded twice. For IMAP and Gmail the attachments already arrive with the message (attachments_inline), so nothing extra is fetched at all.
oversizeMeasure() checks the de-dup table first. An email already imported is skipped by saveEmailToDatabase() anyway, and measuring it would send the alert and the reply about the same email twice.
Sizes are the decoded bytes when the content is in hand, otherwise the size the provider reports:
$b64 = (string)($a['contentBytes'] ?? '');
$size = $b64 !== '' ? (int)floor(strlen($b64) * 3 / 4) - substr_count(substr($b64, -2), '=')
: (int)($a['size'] ?? 0);Graph's own size includes MIME overhead, so it reads a little high - fine as a fallback, not as the measure.
Only file attachments are counted (#microsoft.graph.fileAttachment, which the IMAP and Gmail connectors also produce). Inline images count, because they are files on disk like any other.
If measuring throws, the email is imported exactly as before and the failure goes into the check's error list. A size check must never cost somebody their ticket.
Pure: a list of ['name', 'size'] in message order plus the rules in, a plan out. No database, no I/O, so it is easy to test.
- Any attachment over
max_bytesis always removed. - If what is left is still over
max_total_bytes(and that is not 0),strip_orderdecides what else goes:
strip_order |
What happens |
|---|---|
largest |
Sort what is left by size, remove from the top until the rest fit. Keeps the most files. |
all |
Remove everything that is left. |
order |
Walk in message order, keeping each one while the running total fits; anything that would overflow goes. |
return ['over' => $over, 'reject' => $over && $rules['mode'] === 'reject',
'strip' => $rules['mode'] === 'reject' ? [] : $strip,
'total' => $total, 'reasons' => $strip];strip is what to leave out, keyed by file-attachment index. reasons keeps the same list even in reject mode, so the alert can say which attachments broke the rules.
The attachment loop counts file attachments in the same order oversizeMeasure() did, and skips the ones in $strip:
$fileIndex++;
if (isset($strip[$fileIndex])) {
$rejectedAttachments[] = ['name' => $attachment['name'] ?? 'attachment', 'reason' => $strip[$fileIndex]];
continue;
}
$savedAttachment = saveAttachment($conn, $dbEmailId, $attachment, $maxBytes);A stripped file goes into the same $rejectedAttachments list as a file whose type is not accepted, so the existing note at the foot of the message lists it, with the reason, where the attachment would have been. That note is the "stub".
saveAttachment() passes $maxBytes (the mailbox's limit when it has its own) down to uploadStoreBytes(). Without that, a mailbox allowed 50 MB would still have its files refused at the global 10 MB on the way to disk.
A rejected email goes through handleEmailAfterProcessing() with the mailbox's Rejected emails action (delete, move to Deleted Items, or mark as read) - the same path as a sender who is not on the allow-list. No ticket, no email row, no attachments written.
Called once per oversized email with 'rejected' or 'stripped'. It returns processing-log steps, so the mailbox log shows whether each alert and reply was sent, skipped or failed.
-
Alert: one email to each address in
alert_to, listing every attachment with its size and what happened to it, the limits and where they came from, and the ticket number when one was made. -
Reply:
autoreply_textwith{subject},{limit}and{total_limit}filled in, HTML-escaped and turned into paragraphs.
Both go through mailboxSendSimple(), so they leave from the mailbox the email arrived at, by the same route that mailbox uses for everything else, and land in the Email send log under their own routes.
Two systems that each answer an oversized email with an email can bounce messages back and forth forever. oversizeReplySkipReason() refuses to reply:
- to an address that is not a valid email address;
- to the mailbox itself;
- to an automated local part -
no-reply,noreply,do-not-reply,mailer-daemon,postmaster,bounce,notifications,auto-replyand their variants; - to anyone it has already replied to from the same mailbox in the last hour.
The last check reads the mailbox activity log, which already records every reply in its processing log - no new table:
SELECT COUNT(*) FROM mailbox_activity_log
WHERE mailbox_id = ? AND from_address = ? AND created_datetime > UTC_TIMESTAMP() - INTERVAL 1 HOUR
AND processing_log LIKE '%"step":"oversize_reply"%"result":"sent"%'A skipped reply is logged as skipped with the reason, so "why didn't they get a reply?" has an answer.
| Where | What |
|---|---|
Mailbox activity log, reason
|
Too large: big.zip (40 MB), video.mp4 (30 MB) for a rejection, Attachments removed (too large): ... for a strip. Visible on the row without opening it. |
Mailbox activity log, processing_log
|
A size_check step (the limits, where they came from, the mode and order, every attachment and its fate), the post-action, and one step per alert and reply. |
| The ticket | The note at the foot of the message, for a strip. |
| Email send log | Each alert (oversize_alert) and reply (oversize_reply), sent or failed. |
uploadStoreFile() and uploadStoreBytes() used to default to the constant UPLOAD_MAX_BYTES (10 MB). They now take ?int $maxBytes = null, and null resolves to attachmentMaxBytes():
function uploadStoreFile(array $file, string $destDir, array $allowed = UPLOAD_TYPES_ATTACHMENT, ?int $maxBytes = null): array
{
$maxBytes = $maxBytes ?? attachmentMaxBytes();So every caller that never passed a limit - the portal, chat channels, analyst replies, attached documents, change attachments - follows the setting with no change to the caller. Callers that pass their own limit (logos at 2 MB, RFP documents at 20 MB) are unaffected. UPLOAD_MAX_BYTES stays as a documented fallback only.
The total limit is not applied to browser uploads. A person uploading is told at once that a file is too big; the total exists for email, which arrives as one thing nobody can trim first.
FreeITSM's limit can only be lower than what PHP accepts. A file uploaded through a browser is refused by PHP before any FreeITSM code runs if it is over upload_max_filesize or post_max_size, and a web server or proxy in front (nginx client_max_body_size, IIS maxAllowedContentLength) can be lower again.
phpUploadLimits() reads all three relevant ini_get() values, turning 2M / 1G into bytes (-1 means unlimited), and the Security card shows them and warns when the configured limit is higher than PHP's.
Email does not pass through those two limits, because it is fetched, not uploaded. But the whole message is held in memory while it is decoded and measured, so memory_limit is shown too.
-
Measure before import, and only once. Fetching in the main loop and again in
saveEmailToDatabase()would double the Graph traffic for every email with attachments. Pass$prefetchedon. - De-dup before measuring, or a duplicate in reject mode alerts and replies twice.
- The two loops count file attachments the same way (5.4). The strip list is keyed by that count.
-
Pass the mailbox's limit down to
uploadStoreBytes(), or an override above the global limit is silently undone on the way to disk. - The reply must never loop (6.1). Do not add an "always reply" option.
-
oversizeNormalisePolicy()on read as well as save. The JSON is stored text, and a bad value must not break a mailbox check. - A failed measurement imports the email as before. Never let the size check lose mail.
oversizePlan() and oversizeRules() are pure enough to test from the command line against a fixture list. The cases worth keeping:
- within the limits: nothing happens;
- one attachment over the per-attachment limit: always removed, whatever the order setting;
- total over with each of
largest,allandorder, on a list where they give different answers; - total limit 0: only the per-attachment limit applies;
- reject mode:
stripempty,reasonsstill filled; - a mailbox override wins, limits included, and junk addresses and nonsense values are cleaned;
- the reply guard refuses no-reply, mailer-daemon, postmaster, bounce and the mailbox itself, and allows a person.
The end-to-end path needs a real mailbox. Set a small limit (1 MB), switch on the alert and the reply, send the mailbox an email with a 2 MB attachment, then check the note on the ticket, the mailbox Logs, the send log and the two inboxes. Put the settings back afterwards.
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: Projects
- β³ ποΈ Mobile: Files
- β³ π 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: Analysts
- β³ πΊοΈ 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
- β³ π Attachment size limits and large emails
- β³ 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
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Leases
- β³ π 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
- ποΈ Files
- 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
- β³ π Projects: adding a phase did nothing on Save
- β³ π Leavers could still sign in
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ 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