Skip to content

Emailed Reports Developer Guide

Ed Mozley edited this page Nov 14, 2026 · 1 revision

πŸ› οΈ Emailed reports - Developer Guide

Since 5.3.0 Β· Asked on #261 Β· First user: the "what if" in Imported people (PR #264 by vsc55) Β· User guide: Emailed reports Β· The sender's attachments: Outbound email attachments Β§6

A page that builds a PDF in the browser can email it to the analyst who is signed in - and only to them. What the email carries is decided once, by an administrator, on System β†’ Emailed reports: the report in the body, the PDF attached, a guarded download link, or any mix.

Why the attachment exists at all: some installs are not reachable from the internet, so a link in an email opens nowhere there - the report has to travel in the email (Ed, 2026-11-14). Why the link is guarded: a link in a mailbox is easy to forward, so it opens only for its owner, signed in, until it expires or is revoked, and every use is audited (Ed, 2026-11-14).

πŸ“ Files

File Role
assets/js/email-report.js window.emailReportToMe() - the one call a page makes
api/system/email_report.php POST: checks the request, applies the settings, sends through ssSendSystemEmail()
includes/self_service_email.php ssSendSystemEmail(..., $attachments), ssSystemEmailAttachmentParts(), ssAttachmentFilename(), SYSTEM_EMAIL_ATTACH_MAX_BYTES - see Β§6
includes/report_downloads.php The settings (reportEmailSettings(), reportEmailSaveSettings()), the links (reportDownloadCreate(), reportDownloadAuthorise(), reportDownloadRevoke(), reportDownloadsTidy()), the audit (reportDownloadAudit()), the readiness check (reportDownloadsReady()), the address warning (reportLinkReachabilityWarning())
api/system/report_download.php GET ?t=<token> - the link itself
index.php Reads the freeitsm_report_return cookie after sign-in and goes back to the link
system/emailed-reports/index.php + api/system/emailed_reports.php The administrators' page: the ticks, the links with Revoke, recent activity
cron/report_downloads_tidy.php The daily tidy-up, job report_downloads_tidy in includes/job_registry.php
report_downloads table database/freeitsm.sql and includes/db_verify_schema.php
system/help/emailed-reports.php In-app help
tests/system-email-attachments.php 86 checks - see the end

βœ‰οΈ Emailing a report from a page

Load the script (or load it the first time Email is pressed, as directory-maintenance.js does), build the PDF with pdf-writer.js, and pass both the PDF and an HTML version of the report:

<script src="../../assets/js/email-report.js?v=1"></script>
const r = await emailReportToMe({
    subject:  'What if: delete directory source "Contoso AD"',
    html:     reportHtml,                // for the body - optional, but pass it when there is one
    filename: 'what-if-contoso-ad.pdf',
    pdf:      doc                        // a pdf-writer document, Blob, ArrayBuffer or Uint8Array
});
showToast(r.ok ? r.message : r.error, r.ok ? 'success' : 'error');
// r = { ok: true, sentTo, delivered: {body, attachment, link}, message }
//   | { ok: false, code, error }
  • It never rejects - a dialog can await it without a try. message and error are already translated (common.email_report.*).
  • It works out the endpoint from its own src, so it works from any page depth (TRAP in the file: document.currentScript is only set while the script first runs).
  • csrf.js adds the token; the script sets it too.
  • Codes: not_signed_in, bad_request, not_pdf, too_large, no_email, no_mailbox, send_failed, network.

Do not add a recipient. The caller decides nothing about where it goes or what the email holds; that is the point.

🚦 The endpoint's rules (api/system/email_report.php)

  • Any signed-in analyst - no module check: the report is already theirs.
  • Only to their own address: analysts.email of the signed-in, active analyst. There is no recipient field; a to in the request is ignored. No valid address β†’ no_email.
  • POST with the CSRF token (csrfRequireWriteMethod(), then csrfRequireToken() again in the file).
  • PDF only: the decoded bytes must start %PDF-; the name is cleaned by ssAttachmentFilename() and given .pdf if it lacks it.
  • 3 MB (SYSTEM_EMAIL_ATTACH_MAX_BYTES): checked on Content-Length before the body is read, and again through the sender's own rules.
  • The HTML goes through sanitiseUserHtml() - it came from a browser, even though it only ever goes back to the person who made it.
  • Logged in email_send_log as route report_to_self, sent or failed.
  • A failed send revokes the link it made, so no link exists that nobody was sent.

What the email holds:

$how        = reportEmailSettings($conn);
$wantBody   = $how['body'];
$report     = $wantBody ? sanitiseUserHtml((string)($in['html'] ?? '')) : '';
$hasReport  = trim(strip_tags($report)) !== '';
$wantLink   = $how['link'] && reportDownloadsReady($conn);
// Attached when ticked - and whenever nothing else ticked would carry the report.
$wantAttach = $how['attach'] || (!$wantLink && !$hasReport);

So the report always arrives: a link before Database Verification, or a body with no HTML sent, falls back to the attachment.

βš™οΈ The settings

system_settings, read by reportEmailSettings() with defaults for anything missing:

Key Values Default
report_email_body '1' / '0' on
report_email_attach '1' / '0' on
report_email_link '1' / '0' off
report_link_days 1, 7, 30 (REPORT_LINK_DAY_CHOICES) 7

reportEmailSaveSettings() refuses nothing ticked and any other number of days, with a sentence fit to show. If "nothing ticked" is ever found stored, it reads as body + attachment. The page renders the ticks server-side (as Date and time formats does), so a failed fetch can never show a guess that Save then writes back.

reportLinkReachabilityWarning() returns the address links would start with when it looks unreachable from elsewhere - nothing configured, localhost, a .local name, a private or loopback IP, a name with no dot - and the page shows it under the link tick. The address is publicBaseUrl() (Tickets β†’ Settings, Public web address).

πŸ”— The download link's life

Create - reportDownloadCreate()

$token = bin2hex(random_bytes(32));                 // 64 hex, in the email only
$rel   = bin2hex(random_bytes(16)) . '.bin';        // the stored file's name
// ... file_put_contents(reportDownloadsDir() . '/' . $rel, $bytes)
INSERT INTO report_downloads (token_hash, analyst_id, filename, mime, size_bytes, storage_path, created_at, expires_at)
     VALUES (hash('sha256', $token), ...)
  • Only the SHA-256 of the token is stored, so a copy of the database cannot be turned back into working links.
  • The file lives in uploads/report-downloads/, denied to the web by uploadPrepareDir(); nothing from the caller reaches the file system (a random name, not the report's).
  • Audited: create.

Download - api/system/report_download.php?t=

  1. Not signed in: a well-formed token goes in the freeitsm_report_return cookie (15 minutes, HttpOnly, SameSite=Lax), and the browser goes to sign in. index.php reads the cookie, clears it, and goes back to the link - only a 64-hex token is accepted and the destination is fixed, so the cookie cannot send anybody anywhere else.
  2. reportDownloadAuthorise(): looks the row up by hash and compares again with hash_equals(); then, in order:
Outcome Status Page
not_ready (no table), unknown (malformed or no row) 404 Link not found
wrong_analyst 403 This link is not yours
revoked, missing_file 410 This link no longer works
expired 410 This link has expired
ok 200 the file, Content-Disposition: attachment, nosniff, private, no-store; download_count + 1

Every answer is audited - download or refused with the outcome. It is a GET that writes (the count and the audit row) because a link in an email can only be a GET: a log of a read, as api/documents/download.php's is.

Audit - reportDownloadAudit()

One system_logs row per event, log_type = 'report_download', analyst_id (null when nobody is signed in), and details JSON with action (create, download, refused, revoke, tidy), outcome, ip_address, user_agent (255 characters), and download_id / filename / owner_id where there is a link. It never throws: an audit row that cannot be written must not break a download. System β†’ Emailed reports shows the latest 50 (reportDownloadsActivity()).

Revoke - reportDownloadRevoke()

Administrators, on System β†’ Emailed reports (POST {action:'revoke', id}): sets revoked_at / revoked_by, deletes the file at once (file_deleted_at), audits revoke. Also called by the endpoint when a send fails.

Tidy - reportDownloadsTidy()

The report_downloads_tidy job, daily (interval 1440), and only while some link still has a file (needed):

  • deletes the files of expired and revoked links - the row is kept, file_deleted_at set, so the list and the audit still make sense;
  • deletes rows REPORT_DL_KEEP_DAYS (90) days after they expired;
  • deletes stored files no row knows, once a day old (an analyst deleted with their links);
  • audits tidy with the counts, only when it did something.

πŸ—„οΈ The table

CREATE TABLE IF NOT EXISTS `report_downloads` (
    `id`                  INT NOT NULL AUTO_INCREMENT,
    `token_hash`          CHAR(64) NOT NULL,
    `analyst_id`          INT NOT NULL,
    `filename`            VARCHAR(255) NOT NULL,
    `mime`                VARCHAR(100) NOT NULL DEFAULT 'application/pdf',
    `size_bytes`          INT NOT NULL DEFAULT 0,
    `storage_path`        VARCHAR(100) NOT NULL,
    `created_at`          DATETIME NOT NULL,
    `expires_at`          DATETIME NOT NULL,
    `revoked_at`          DATETIME NULL,
    `revoked_by`          INT NULL,
    `download_count`      INT NOT NULL DEFAULT 0,
    `last_downloaded_at`  DATETIME NULL,
    `file_deleted_at`     DATETIME NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uq_report_downloads_token` (`token_hash`),
    KEY `idx_report_downloads_analyst` (`analyst_id`, `id`),
    KEY `idx_report_downloads_expires` (`expires_at`)
);

All times UTC.

πŸ“ Why 3 MB

Microsoft Graph refuses a sendMail request over 4 MB, and base64 makes an attachment a third bigger. 3 MB is the most that sends on every provider; a bigger cap would fail only on Microsoft 365, which is the worst kind of limit. Going past it needs Graph upload sessions. A report over the cap is refused up front with too_large and a sentence saying to download it instead.

⏳ Before Database Verification

reportDownloadsReady() (SELECT 1 FROM report_downloads LIMIT 0) is the one test, and every function copes with it being false:

  • the link option steps aside - the email carries the body and/or the attachment, and the attachment if the link was the only way ticked;
  • the page says Run Database Verification to add download links.; Revoke refuses with the same sentence;
  • a link that somehow arrives is not_ready β†’ 404; the tidy-up does nothing.

Nothing errors.

⚠️ TRAPs

  • TRAP: never take a recipient from the request. email_report.php must stay unable to send to anybody but the signed-in analyst, or it becomes a relay for any signed-in account.
  • TRAP: never store the token. Only its hash; compare with hash_equals() after the lookup.
  • TRAP: a failed send must revoke the link it made.
  • TRAP: a new caller passes both the PDF and the HTML. The settings decide which is used; a caller that sends only one breaks the setting it did not expect.

πŸ§ͺ Verifying

cd c:/wamp64/www/freeitsm-prtest
FREEITSM_TEST_DATABASE=freeitsm_prtest FREEITSM_TEST_URL=http://localhost/freeitsm-prtest FREEITSM_BASE_URL=http://localhost/freeitsm-prtest php tests/system-email-attachments.php

Six parts: the attachment rules (names, types, the 3 MB / 10-file caps); the Graph body (no attachments key without files - the shape sent before 5.3.0); the raw MIME for SMTP and Gmail (the PDF byte-for-byte after base64); ssSendSystemEmail() through the real SMTP client to a fake SMTP server the test starts on 127.0.0.1; the download links (hash only, owner downloads, the four refusals, every answer audited, the tidy-up, the table missing); and over HTTP, the endpoint's refusals, that it sends only to the signed-in analyst whatever to says, and that it follows the settings. Nothing is really emailed: the only sending mailbox during the run is the ZZ one, and every ZZ row, file, session and setting is put back in finally.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Pull requests

Links

Clone this wiki locally