Skip to content

Email Send Log Developer Guide

Ed Mozley edited this page Aug 15, 2026 · 1 revision

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.


1. πŸ“ The files involved

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

2. The one rule that matters

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.


3. The helper

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): void

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


4. Where each route logs, and why there

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_kb and share_change run 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.


5. provider and auth_mode on the row

Both are denormalised onto every row rather than joined from target_mailboxes, for two reasons:

  1. 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.
  2. They are what most sending faults hinge on. Issue #67 was entirely a function of auth_mode. A log showing microsoft / app_only / failed on every row and microsoft / delegated / sent on 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.


6. Adding a ninth route

  1. Add the key and its human label to EMAIL_LOG_ROUTES in includes/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.
  2. require_once includes/email_log.php at the new send site (many paths already inherit it via includes/template_email.php).
  3. Call emailLogSent() on success and emailLogFailed() on every failure branch, including early returns that currently just return β€” those are the ones that get missed.
  4. If the send throws and the caller needs to know, log then rethrow. Do not convert an exception into a log entry.
  5. 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.

7. Reading the log

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=0 is meaningful, not a mistake: it selects rows where mailbox_id IS NULL, the sends that never resolved a mailbox. Without it those rows would be written and never seen.
  • failed in the response ignores the status filter, so the tab badge doesn't read zero merely because you are currently looking at the successes.
  • route_label is resolved server-side, so there is one list of routes rather than one in PHP and a stale copy in JavaScript.

8. Retention

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.


9. Verification

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_mode present 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 to null directly when asserting that a column is NULL.


Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally