-
Notifications
You must be signed in to change notification settings - Fork 15
Email Send Log Developer Guide
How outbound email logging works, why it covers all eight routes rather than the obvious ones, and what you must do when you add a ninth. Shipped as #1082.
The user-facing page is Email send log.
Colour key: ποΈ schema Β· βοΈ shared logic Β· π API Β· π₯οΈ UI Β· π i18n Β· π docs
| π¨ | File | What it does |
|---|---|---|
| ποΈ | database/freeitsm.sql |
CREATE TABLE email_send_log β three indexes, one FK with ON DELETE SET NULL
|
| ποΈ | includes/db_verify_schema.php |
the same table in $schema, so a grown install gains it on verification |
| ποΈ | includes/db_verify_indexes.php |
generated β php scripts/gen_db_verify_indexes.php, never hand-edited |
| βοΈ | includes/email_log.php |
the whole thing. EMAIL_LOG_ROUTES, emailLogRecord() and its two wrappers, emailLogRouteLabel()
|
| βοΈ | includes/template_email.php |
route template β plus the token-failure early returns, which used to return silently |
| βοΈ | includes/sla_notifications.php |
route sla β logged per recipient, inside the loop |
| βοΈ | includes/self_service_email.php |
route portal β including the "no mailbox at all" case |
| π₯οΈ | workflow/includes/engine.php |
route workflow β logs, then rethrows so the run is still marked failed |
| π | api/tickets/send_email.php |
route reply β logs, then rethrows so the analyst is still told |
| π | api/auth/request_password_reset.php |
route password_reset β both the Graph and Gmail branches |
| π | api/knowledge/send_share_email.php |
route share_kb β logged at the call site, so the SMTP method is covered too |
| π | api/change-management/send_share_email.php |
route share_change β same shape |
| π | api/tickets/get_mailbox_outbound.php |
reads the log; mirrors api/tickets/get_mailbox_activity.php's response shape |
| π₯οΈ | tickets/settings/index.php |
the Inbound/Outbound tabs, the failure badge, .mbx-* CSS |
| π | lang/en/tickets.php |
modals.activity.tab_* / status_*, columns.to / sent_by / result
|
| π |
CHANGELOG.local.md, README.md, this wiki |
logged as #1082 |
Every outbound path logs, success and failure alike.
A log that covers some routes is worse than no log, because "no failures" then reads as "nothing failed" when it may only mean "that route was never instrumented". Somebody will use this page to rule a cause out. It has to be capable of ruling it out honestly.
This is why the count is eight and not the three or four that first come to mind. Two of the eight were found only by grepping for every send primitive rather than reasoning about which routes "matter":
grep -rn "sendMail\|gmailSendEmail(\|imapSmtpSend(\|templateSendViaGraph(" --include=*.php .The share screens turned out to have a second sending mechanism entirely β their own SMTP settings, with no configured mailbox behind them β which no amount of thinking about mailboxes would have surfaced.
emailLogRecord(?PDO $conn, ?array $mailbox, string $route, string $to,
string $subject, bool $sent, ?string $error = null, ?int $ticketId = null): void
emailLogSent(?PDO $conn, ?array $mailbox, string $route, string $to, string $subject, ?int $ticketId = null): void
emailLogFailed(?PDO $conn, ?array $mailbox, string $route, string $to, string $subject, string $error, ?int $ticketId = null): voidThree properties are deliberate and should not be "tidied up":
It never throws. Every write is inside a try { } catch (Throwable). Logging a send must not be able to break the send it is logging, and must never turn a delivered email into an error the caller reports as undelivered. On a part-upgraded install where the table doesn't exist yet, sending carries on working exactly as before.
$mailbox is nullable. A send can fail before any mailbox is resolved, and that is one of the most useful things to have on record. Those rows carry mailbox_id = NULL.
$conn is nullable. Some callers reach the logging point on a path where the connection may not have been established. Passing null is a silent no-op rather than a fatal.
Values are truncated to fit their columns (route 30, to_address 255, subject 500, error_message 2000) so an enormous provider error can't fail the insert.
The placement varies because the surrounding code varies. What is constant is that both outcomes reach the log.
| Route | Where it logs | Note |
|---|---|---|
reply |
wrapped try/catch around the send, rethrows
|
the analyst must still be told it failed; the log records, it does not swallow |
template |
after the send, plus the outer catch, plus two early returns
|
the early returns previously just returned on a token failure β silent by construction |
workflow |
wrapped try/catch, rethrows
|
the workflow run must still be marked failed |
sla |
inside the per-recipient loop, rethrows | one bad address must not read as the whole alert failing |
portal |
every return false branch, plus the outer catch
|
includes "no mailbox could send" with a NULL mailbox |
password_reset |
both the Graph HTTP-status branch and the Gmail catch
|
two providers, two failure shapes |
share_kb |
at the call site, from $result['success']
|
so the SMTP method is covered as well as the mailbox one |
share_change |
same | a separate route from share_kb on purpose |
The share routes log at the call site rather than inside sendMailboxEmail(), because that function is duplicated in both modules and returns a result array rather than throwing. Logging where the result is read covers both sending mechanisms and touches neither copy.
β οΈ share_kbandshare_changerun nearly identical code. They are still two routes, because a log that says only "share" makes you go and work out which module it was.
Both are denormalised onto every row rather than joined from target_mailboxes, for two reasons:
-
The mailbox may change, or be deleted. The FK is
ON DELETE SET NULL, so history survives a deleted mailbox β but only if the row already carries what it needs to be readable. -
They are what most sending faults hinge on. Issue #67 was entirely a function of
auth_mode. A log showingmicrosoft / app_only / failedon every row andmicrosoft / delegated / senton the rest diagnoses itself.
auth_mode is stored as NULL for non-Microsoft providers. Writing delegated there would be a lie: IMAP and Gmail have no such concept, and a column that always says "delegated" trains people to ignore it.
- Add the key and its human label to
EMAIL_LOG_ROUTESinincludes/email_log.php. The UI filter and the row labels both come from this constant, so a missing entry hides a whole route from the log β the exact failure this feature exists to prevent. -
require_onceincludes/email_log.phpat the new send site (many paths already inherit it viaincludes/template_email.php). - Call
emailLogSent()on success andemailLogFailed()on every failure branch, including early returns that currently justreturnβ those are the ones that get missed. - If the send throws and the caller needs to know, log then rethrow. Do not convert an exception into a log entry.
- Add a row to the eight-route table on Email send log. A route nobody has documented is a route nobody will think to check.
api/tickets/get_mailbox_outbound.php mirrors api/tickets/get_mailbox_activity.php so the modal can page either tab with one set of code.
-
mailbox_id=0is meaningful, not a mistake: it selects rows wheremailbox_id IS NULL, the sends that never resolved a mailbox. Without it those rows would be written and never seen. -
failedin the response ignores thestatusfilter, so the tab badge doesn't read zero merely because you are currently looking at the successes. -
route_labelis resolved server-side, so there is one list of routes rather than one in PHP and a stale copy in JavaScript.
There is none, deliberately β matching mailbox_activity_log, which has never pruned either. The table grows at the rate you send email, which for a service desk is modest, and an outbound log is most valuable precisely when somebody asks "did we ever email this customer?" about something months old.
If it ever needs pruning, prune successes and keep failures. The failures are the reason the table exists and they are a small fraction of the rows.
tests/ has no suite for this yet; the checks used when building it were:
- 17 assertions over
emailLogRecord()β both outcomes recorded, provider error text preserved, route and ticket linked,auth_modepresent for Microsoft and NULL for IMAP, the mailboxless failure recorded with a NULL mailbox, over-long values truncated rather than fatal, and a negative control that a null connection is survivable. - An end-to-end run of
sendTemplateEmail()against a mailbox with no token, confirming a real failure that previously produced only an error-log line now produces a row. - A headless-Chrome parse check of the settings page with a positive control (deliberately broken syntax must be caught, or the check proves nothing).
β οΈ Three of those assertions failed first time against correct code, because$row['col'] ?? 'x'substitutes the fallback for a genuine SQL NULL. Compare tonulldirectly when asserting that a column is NULL.
- Email send log β the user-facing page, with all eight routes described
- Why app-only mailboxes could not send email β the bug that prompted this
- Mailbox authentication β the inbound half, and mailbox setup
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)