Skip to content

Latest commit

 

History

History
764 lines (585 loc) · 40.3 KB

File metadata and controls

764 lines (585 loc) · 40.3 KB

HTTP API and server-sent events

Hoomail exposes a same-origin JSON API, raw attachment responses, and one global server-sent events (SSE) stream from the same Go HTTP server that serves the web application.

This document describes the current implementation. It is a contract reference, not a promise of additional validation, authentication, or event delivery guarantees.

Conventions

Routes and methods

The API routes are:

Method Path Purpose
GET /api/mailboxes List mailboxes.
DELETE /api/mailboxes/{id} Delete a mailbox and its stored data.
GET /api/mailboxes/{id}/messages List or search messages in a mailbox.
GET /api/mailboxes/{id}/events List reconciled calendar events for a mailbox.
GET /api/messages/{id} Get parsed message detail and mark it read.
GET /api/messages/{id}/inspect Return a versioned offline inspection report.
GET /api/messages/{id}/source Return the exact stored RFC 822 source bytes without changing read state.
POST /api/messages/actions Delete messages or set their read state.
GET /api/attachments/{id} Return stored attachment bytes.
GET /api/events Open the global SSE stream.
POST /api/reset Delete all stored data and reset generated IDs.
POST /api/send-test Send a built-in sample through Hoomail's SMTP listener.

One non-API route exists alongside the embedded web application: GET /openapi.json returns the generated OpenAPI document describing these endpoints. It responds with application/json and a one-hour cache lifetime, does not depend on the configured static filesystem, and rejects other methods with a plain-text 405 Method Not Allowed carrying an Allow: GET, HEAD header. There is no interactive documentation page.

Routing is method-sensitive. An unsupported method, an /api/* path not captured by the route matchers described below, or a HEAD request for a GET-only API route returns Go's plain-text response:

HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8

404 page not found

The API does not return 405 Method Not Allowed or an Allow header.

Nested dynamic routes are matched by prefix and terminal suffix rather than by exact segment count. Consequently, greedy malformed paths such as /api/mailboxes/1/extra/messages, /api/mailboxes/1/extra/events, or /api/messages/1/extra/inspect reach the corresponding handler; the whole intervening value is parsed as the ID and currently returns the endpoint-specific JSON 400, not the unknown-route plain-text 404.

Outside /api/—except for /openapi.json above—production GET and HEAD requests are served from the embedded web application. If a static path does not exist, the server returns the SPA index. Other methods and deployments without a configured static filesystem return 404.

IDs

Path IDs are parsed as base-10 signed 64-bit integers with strconv.ParseInt. Only decimal syntax accepted by that parser is valid; values outside [-9223372036854775808, 9223372036854775807], fractional values, exponents, and hexadecimal forms return 400. JSON action IDs are decoded as exact json.Number values and undergo the same signed 64-bit validation, without IEEE-754 rounding or aliasing.

Invalid path IDs return 400 JSON with an endpoint-specific message:

{"error":"Invalid message id"}

The corresponding messages are Invalid mailbox id, Invalid message id, and Invalid attachment id.

JSON and content types

Successful JSON responses and documented JSON client errors are compact JSON without a trailing newline:

Content-Type: application/json

There is no charset parameter and no general JSON cache header. JSON request endpoints do not require or check a request Content-Type. JSON request bodies are limited to 1 MiB; oversized or otherwise invalid JSON returns the endpoint's documented JSON 400 error.

Most expected client errors use:

{"error":"Error text"}

Unexpected storage, decoding, or response-encoding failures return Go's plain-text 500 Internal Server Error response rather than JSON:

Internal Server Error

Unknown routes similarly return a plain-text 404, not the JSON error shape. The built-in POST /api/send-test endpoint is the documented exception for malformed or otherwise unusable JSON request bodies: it treats them as an empty object and applies its defaults instead of returning 400.

Values and timestamps

  • All documented timestamps are Unix time in milliseconds.
  • Nullable database values are represented as JSON null.
  • Message-list booleans is_read and has_ical are integers (0 or 1). Calendar allDay is a JSON boolean.
  • Field naming is route-specific. Mailbox and message-list responses use snake_case; message detail, calendar, inspection, and SSE payloads primarily use camelCase. Clients must use the exact names below.

Mailboxes

GET /api/mailboxes

Returns 200 OK:

{
  "mailboxes": [
    {
      "id": 1,
      "address": "test@example.com",
      "created_at": 1784808000000,
      "last_message_at": 1784808060000,
      "total_count": 3,
      "unread_count": 2
    }
  ]
}

Mailbox fields:

Field Type Meaning
id integer Mailbox ID.
address string Normalized mailbox address.
created_at integer Creation time, Unix milliseconds.
last_message_at integer or null Most recent message time. A mailbox created without a message can be null.
total_count integer Current number of stored messages.
unread_count integer Current number of messages whose read flag is 0.

Mailboxes are ordered by latest activity: last_message_at when present, otherwise created_at, descending.

Storage failures return the common plain-text 500 response.

DELETE /api/mailboxes/{id}

Deletes the mailbox. SQLite foreign-key cascades remove its messages, attachments, and calendar events.

Responses:

Status Body Condition
200 {"ok":true} The mailbox existed and was deleted.
400 {"error":"Invalid mailbox id"} The path ID is invalid.
404 {"error":"Mailbox not found"} No mailbox has that ID.
500 plain text Storage failure.

A successful deletion emits mailbox:deleted with the deleted ID.

Messages

GET /api/mailboxes/{id}/messages

Optional query parameter:

Parameter Meaning
q Search subject, sender address, sender name, and the stored plain-text body. Leading and trailing whitespace is ignored.

The search is a SQLite LIKE substring search. Literal %, _, and \ in q are escaped. The resulting escaped pattern is capped at 1024 bytes; each literal %, _, or \ character counts twice because escaping doubles it. A query whose escaped pattern exceeds that limit returns 400 {"error":"Search query too long"}. The search does not search headers or the HTML body. With an empty or whitespace-only q, all messages in the mailbox are listed.

Returns 200 OK:

{
  "messages": [
    {
      "id": 14,
      "from_address": "sender@example.com",
      "from_name": "Sender",
      "subject": "Hello",
      "snippet": "A normalized preview of the text body",
      "is_read": 0,
      "received_at": 1784808060000,
      "has_ical": 0,
      "attachment_count": 1
    }
  ]
}

Message-list fields:

Field Type Meaning
id integer Message ID.
from_address string or null Parsed sender address.
from_name string or null Parsed sender display name.
subject string or null Parsed subject.
snippet string Whitespace-normalized preview, limited to 140 Unicode characters. It uses the text body, or stripped HTML only when text is absent.
is_read 0 or 1 Stored read state.
received_at integer Receipt time, Unix milliseconds.
has_ical 0 or 1 Whether parsed calendar JSON is stored for the message.
attachment_count integer Number of attachments whose content_id is null. This can include a parsed calendar part that the detail endpoint later filters out.

Messages are ordered by received_at descending.

A valid but nonexistent mailbox ID returns 200 with {"messages":[]}; the route does not separately check mailbox existence. An invalid ID returns 400 with Invalid mailbox id. Storage failures return plain-text 500.

GET /api/messages/{id}

Returns parsed message detail and visible attachment metadata.

Read side effect: after finding the message, this GET decodes every stored recipient, header, and calendar JSON projection and prepares the sanitized HTML and attachment metadata before marking an unread message read. The unread-to-read mutation emits messages:changed for the mailbox. If a stored projection fails to decode, the request returns plain-text 500 and leaves the message unread. Retrieving an already-read message does not emit that event. Clients and intermediaries must not treat this route as a side-effect-free read.

Returns 200 OK:

{
  "message": {
    "id": 14,
    "mailboxId": 1,
    "fromAddress": "sender@example.com",
    "fromName": "Sender",
    "to": [
      {"address": "test@example.com", "name": "Test"}
    ],
    "cc": [],
    "subject": "Hello",
    "html": "<p>Hello</p>",
    "text": "Hello",
    "headers": {
      "message-id": "<example@example.com>"
    },
    "size": 1234,
    "receivedAt": 1784808060000,
    "icalEvents": []
  },
  "attachments": [
    {
      "id": 9,
      "filename": "note.txt",
      "contentType": "text/plain",
      "size": 42
    }
  ]
}

Message fields:

Field Type Meaning
id integer Message ID.
mailboxId integer Owning mailbox ID.
fromAddress string or null Parsed sender address.
fromName string or null Parsed sender display name.
to address array Parsed To recipients.
cc address array Parsed Cc recipients.
subject string or null Parsed subject.
html string or null Selected HTML representation after scoped CID rewriting and parsed allowlist sanitization, as described below.
text string or null Plain-text body.
headers object Parsed header names and string values.
size integer Stored message size in bytes.
receivedAt integer Receipt time, Unix milliseconds.
icalEvents calendar-message-event array Parsed calendar components, or an empty array when none were stored.

An address entry is:

{"address":"person@example.com","name":"Person"}

address is always present. name is omitted when unavailable.

A calendar-message-event is:

{
  "method": "REQUEST",
  "uid": "meeting@example.com",
  "sequence": 0,
  "summary": "Standup",
  "description": null,
  "location": null,
  "status": "CONFIRMED",
  "organizerAddress": "organizer@example.com",
  "organizerName": null,
  "attendees": [
    {
      "address": "test@example.com",
      "name": "Test",
      "partstat": "NEEDS-ACTION",
      "role": "REQ-PARTICIPANT"
    }
  ],
  "dtstart": 1784894400000,
  "dtend": 1784896200000,
  "allDay": false
}

For calendar-message-events, summary, description, location, status, organizerAddress, organizerName, and dtend can be null. Attendee name, partstat, and role are omitted when unavailable.

Attachment metadata fields are id (integer), filename (string or null), contentType (string or null), and size (integer bytes).

HTML sanitization, CID rewriting, and attachment filtering

The html field is a safe display projection, not a raw copy:

  1. MIME ingestion recursively selects the supported multipart/alternative representation and the multipart/related root (start Content-ID, or the first part). CID resources include that selected related branch and parts explicitly marked Content-Disposition: inline elsewhere in the selected message structure.
  2. Matching cid: image URLs are percent-decoded and rewritten to /api/attachments/{attachmentId}.
  3. A Bluemonday policy parses and allowlists the rewritten HTML. Safe email tables, ordinary text formatting, links, images, and conservative inline presentation properties remain; active elements/attributes, unsafe schemes, CSS network functions, remote subresources, fonts, frames, forms, media, and other fetch initiators are removed.

This policy intentionally accepts standards-valid rich HTML; it is a security boundary, not a rule that email must resemble plain correspondence. Client-specific CSS support remains a compatibility concern, and Hoomail does not emulate Gmail or Outlook pixel-for-pixel. The selected, charset-decoded HTML is stored without display sanitization, and the complete original MIME remains stored unchanged.

Safe absolute HTTP(S) and mailto: anchors are externalized with target="_blank" and rel="noopener noreferrer". The preview iframe permits these links to open a new tab with only allow-popups and allow-popups-to-escape-sandbox; the opened page runs outside the email sandbox and receives neither window.opener nor a referrer. Remote images may appear in inspection diagnostics but are never fetched by the detail projection or preview.

The response's attachments array omits:

  • every attachment with a non-null content ID, including CID resources referenced from rewritten HTML;
  • when parsed calendar JSON exists for the message, non-CID parts recognized as calendar parts. Recognition is based on a content type containing text/calendar or application/ics, or a filename ending in .ics, case-insensitively.

The underlying attachment remains addressable by its ID if a client already knows that ID.

GET /api/messages/{id}/source

Returns the exact stored RFC 822 source bytes without parsing, sanitizing, or marking the message read. The response uses Content-Type: message/rfc822, X-Content-Type-Options: nosniff, and Cache-Control: private, no-store; clients and intermediaries must not cache it.

Responses:

Status Body Condition
200 raw RFC 822 bytes Message exists and source was stored.
400 JSON Invalid message id Invalid path ID.
404 JSON Message not found No message exists for the ID.
500 plain text Storage failure.

The source endpoint does not change read state, emit messages:changed, or apply HTML/CID sanitization. It is intended for exact source inspection and should be treated as untrusted input.

GET /api/messages/{id}/inspect

Returns a deterministic, offline report for the stored message without marking it read. Raw bytes are authoritative when present; legacy selected bodies are fallback input only when raw parsing or presentation selection is unavailable. Stored header JSON is validated for corrupt-row error compatibility but is not analyzed as raw header evidence.

Successful analysis returns 200 OK, including sender-caused syntax defects, absent raw source, and bounded-analysis truncation. Such conditions are represented by findings and, when parsing cannot complete, analysis.state: "partial", not an HTTP error.

{
  "analysis": {
    "version": 1,
    "state": "complete",
    "parsedThroughPath": null,
    "unavailableRuleFamilies": [],
    "truncated": false
  },
  "summary": {
    "fail": 0,
    "warning": 0,
    "advisory": 1,
    "observed": 2,
    "pass": 12,
    "notEvaluated": 0
  },
  "findings": [
    {
      "id": "authentication.dkim.1",
      "category": "authentication",
      "outcome": "observed",
      "severity": "none",
      "basis": "evidence",
      "applicability": "all",
      "label": "DKIM signature syntax",
      "detail": "Signature and body hash were not cryptographically verified.",
      "evidence": [
        {"source":"raw-header","field":"DKIM-Signature","occurrence":1}
      ],
      "evidenceTruncated": false,
      "reference": {
        "label": "RFC 6376",
        "url": "https://www.rfc-editor.org/rfc/rfc6376"
      }
    }
  ],
  "headers": [
    {"name": "From", "value": "Sender <sender@example.com>", "occurrence": 1, "line": 2}
  ],
  "resources": [
    {
      "kind": "link",
      "path": "1.2",
      "url": "https://example.com",
      "text": "Example",
      "occurrenceCount": 1
    }
  ],
  "mimeTree": {
    "path": "1",
    "contentType": "multipart/alternative",
    "charset": null,
    "encoding": null,
    "disposition": null,
    "filename": null,
    "contentId": null,
    "rawSize": 456,
    "decodedSize": null,
    "children": []
  },
  "htmlCompatibility": {
    "dataVersion": "1.0.4",
    "dataUpdated": "2026-08-01",
    "nodes": 12,
    "tests": 46,
    "score": {"supported": 90, "partial": 10, "unsupported": 0},
    "platforms": [
      {"family": "Apple Mail", "platform": "macOS", "label": "Apple Mail macOS"}
    ],
    "warnings": [
      {
        "slug": "html-anim-img",
        "title": "Animated GIF images",
        "category": "images",
        "description": "Animated GIF images are supported.",
        "url": "https://www.caniemail.com/features/html-anim-img/",
        "occurrences": 1,
        "score": {"supported": 80, "partial": 20, "unsupported": 0},
        "clients": [
          {"name": "Apple Mail macOS (Version 16)", "family": "Apple Mail", "platform": "macOS", "version": "Version 16", "support": "yes", "note": null}
        ]
      }
    ]
  }
}

The schema version is currently analysis.version: 1. Arrays are always present and encode as arrays, including when empty. mimeTree is null when raw source is absent or no root header was safely completed. parsedThroughPath is non-null only for the last complete MIME node before a fatal or bounded stop. unavailableRuleFamilies lists unavailable categories in catalog order without duplicates.

Top-level and nested fields:

Object Required fields and types
report analysis object, summary object, findings array, headers array, resources array, mimeTree object or null, htmlCompatibility object or null
analysis version integer, state string, parsedThroughPath string or null, unavailableRuleFamilies string array, truncated boolean
summary integer fail, warning, advisory, observed, pass, notEvaluated
finding string id, category, outcome, severity, basis, applicability, label, detail; evidence array; evidenceTruncated boolean; reference object or null
evidence required string source; optional path, field, value strings and occurrence, line integers
reference string label and url
resource string kind, url, text; path string or null; integer occurrenceCount
header string name and value; integer occurrence and line
MIME node string path, contentType; nullable string charset, encoding, disposition, filename, contentId; nullable integer rawSize, decodedSize; optional checksums object; children array
checksums string md5, sha1, sha256
htmlCompatibility string dataVersion, dataUpdated; integer nodes, tests; score object; platforms array; warnings array; optional boolean warningsTruncated, clientsTruncated, truncated
compatibility score number supported, partial, unsupported
compatibility platform string family, platform, label
compatibility warning string slug, title, category, description, url; integer occurrences; score object; clients array
compatibility client string name, family, platform, version, support; note string or null

Closed enums:

Field Values
analysis.state complete, partial
category analysis, message, mime, authentication, unsubscribe, content, privacy, compatibility
outcome pass, fail, observed, not-evaluated
severity error, warning, advisory, none
basis standard, recommendation, heuristic, evidence
applicability all, html, mailing-list, one-click-claim, bulk-marketing, unknown
evidence source raw-header, raw-line, mime-part, html, text
resource kind link, image, tracking-pixel, cid, data, attachment

Summary buckets are mutually exclusive. fail counts failed findings; warning and advisory count passing recommendation/heuristic findings with those severities; observed and notEvaluated count their corresponding outcomes; pass counts only pass with severity none. Omitted rules are not counted.

Findings use category order shown above. Within a category they order failed errors, warnings, advisories, not-evaluated results, observations, and ordinary passes, then by ID. Dynamic IDs append an occurrence or MIME path. Resources retain first-seen order; MIME children retain wire order.

Every finding always includes evidence, evidenceTruncated, and reference; reference is null when unavailable. Evidence coordinates path, field, occurrence, line, and value are omitted when inapplicable. Every resource always includes path, which is null for fallback data. MIME charset, encoding, disposition, filename, contentId, rawSize, and decodedSize are present and nullable. A size of zero is distinct from null: raw size is known only for a complete indexed body range, and decoded size only after successful transfer decoding and text charset conversion; multipart decoded size is null. Report headers entries retain raw wire order and original field-name case; each value is unfolded to single spaces and carries the 1-based occurrence ordinal among same-named lowercase fields plus the field's first raw-source line, and values above 8 KiB end with a … [truncated] mark. The optional MIME checksums object appears only on leaf nodes whose body was completely decoded without error and holds hex-encoded MD5, SHA-1, and SHA-256 of the decoded bytes. htmlCompatibility is null when no HTML representation was selected for analysis; each warning's score is the percentage split of client support, while the report-level score aggregates occurrences across detected HTML nodes.

Reports may be partial because raw source is unavailable, semantic parsing stopped at a sender defect, or a deterministic parser/analyzer cap was reached. Unsupported encodings or charsets instead produce findings and nullable decoded sizes where applicable. Partial reports retain completed-prefix evidence. analysis.truncated is always retained when a cap is reached, and truncated becomes true. Authentication-Results, DKIM, ARC, unsubscribe, links, images, and MIME bytes are inspected statically; Hoomail performs no DNS or network access and does not verify SPF, DKIM signatures/body hashes, DMARC, ARC custody, alignment, endpoints, reputation, SMTP transport, delivery, or provider rendering. The 102 KiB compatibility heuristic measures selected decoded HTML only, never the total message or attachments.

Responses:

Status Body Condition
200 inspection report JSON Complete or partial analysis, including ordinary message/parser defects.
400 {"error":"Invalid message id"} The path ID is invalid.
404 {"error":"Message not found"} No message has that ID.
500 plain text Internal Server Error Store failure, invalid persisted header JSON, analyzer invariant failure, or response encoding failure.

The 200, 400, and 404 responses use Content-Type: application/json; 500 remains plain text.

POST /api/messages/actions

Request shape:

{
  "action": "read",
  "ids": [14, 15]
}

Supported action strings are:

Action Effect
delete Delete matching messages. Attachments are removed by SQLite cascade. Calendar rows retain their current lastMessageId value because it is not a foreign key.
read Set matching messages to read.
unread Set matching messages to unread.

Body handling is intentionally permissive in some places and strict in others:

  • The body must contain exactly one valid JSON value. Malformed JSON, an empty body, or a trailing second JSON value returns 400 Invalid JSON body.
  • The decoded top-level value is treated as an object; another JSON type consequently has no usable IDs or action.
  • ids is useful only when it is an array. Each entry must be an exact decimal integer token accepted by json.Number.Int64() and within the signed 64-bit range [-9223372036854775808, 9223372036854775807]. Fractional values, exponent notation, values outside that range, strings, booleans, objects, arrays, and null entries are silently ignored. Hexadecimal forms are not valid JSON numbers.
  • At least one valid numeric ID must remain. This check happens before action validation.
  • Duplicate IDs are removed while preserving first-seen order. If more than 10000 unique IDs remain after deduplication, the request returns 400 {"error":"Too many message ids provided; maximum is 10000"}.
  • action must be one of the exact lowercase strings above.

Responses:

Status Body Condition
200 {"ok":true} The action was accepted, including when none of the IDs exist or the requested read state was already set.
400 {"error":"Invalid JSON body"} The body is malformed, empty, or contains more than one JSON value.
400 {"error":"No valid message ids provided"} No acceptable numeric ID remains.
400 {"error":"Too many message ids provided; maximum is 10000"} More than 10000 unique IDs remain after deduplication.
400 {"error":"Unknown action"} IDs are valid but action is unsupported, missing, or not a string.
500 plain text Storage failure.

The operations are idempotent with respect to final stored state: deleting missing IDs and repeatedly setting the same read state still returns 200. The response does not report matched or changed counts. For read and unread, each mailbox containing at least one supplied existing ID receives one messages:changed event even if the stored state was already the requested value. delete emits one messages:changed event per affected mailbox; deleting only missing IDs emits none.

Calendar view

GET /api/mailboxes/{id}/events

Returns the mailbox's reconciled calendar state, ordered by dtstart ascending:

{
  "events": [
    {
      "id": 3,
      "uid": "meeting@example.com",
      "sequence": 1,
      "summary": "Updated standup",
      "description": null,
      "location": "Owl Tree Conference Room",
      "status": "CONFIRMED",
      "organizerAddress": "owl@hoomail.local",
      "organizerName": "The hoomail Owl",
      "attendees": [
        {
          "address": "test@example.com",
          "partstat": "NEEDS-ACTION"
        }
      ],
      "dtstart": 1784898000000,
      "dtend": 1784899800000,
      "allDay": false,
      "lastMessageId": 14,
      "updatedAt": 1784808060000
    }
  ]
}

Event fields:

Field Type Meaning
id integer Reconciled calendar-row ID.
uid string iCalendar UID, unique within the mailbox.
sequence integer Reconciled iCalendar sequence.
summary string or null Event summary.
description string or null Event description.
location string or null Event location.
status string Stored status, defaulting to CONFIRMED; cancellations use CANCELLED.
organizerAddress string or null Organizer address.
organizerName string or null Organizer display name.
attendees attendee array Decoded attendee state.
dtstart integer Start time, Unix milliseconds.
dtend integer or null End time, Unix milliseconds.
allDay boolean Whether the source event is all-day.
lastMessageId integer or null Last message associated with reconciliation. It may refer to a subsequently deleted message.
updatedAt integer Last reconciliation time, Unix milliseconds.

Attendees use the same {address, name?, partstat?, role?} shape described for message calendar data.

A valid but nonexistent mailbox returns 200 with {"events":[]}. Invalid IDs return 400 Invalid mailbox id; storage or attendee-JSON decoding failures return plain-text 500.

This endpoint returns reconciled state, not every calendar part from every message. REQUEST/PUBLISH updates with a sequence at least as high as the stored sequence replace the row, CANCEL marks it cancelled, and REPLY updates attendee participation for an existing row.

Attachments

GET /api/attachments/{id}

Returns the stored bytes directly, not JSON.

Successful inline-capable response example:

Content-Type: text/plain
Content-Length: 42
Content-Disposition: inline; filename="note.txt"
Cache-Control: private, no-store
X-Content-Type-Options: nosniff

Behavior:

  • The stored content type is parsed, lowercased, and stripped of parameters. Missing, empty, or malformed values become application/octet-stream.
  • PNG, JPEG, GIF, WebP, plain-text, and CSV attachments are inline-capable by default. PDF, HTML/XHTML, SVG/XML, MHTML, JavaScript, unknown, and other active formats use attachment.
  • The HTML sanitizer marks a resolved CID resource with ?inline=cid. For image/svg+xml only, that marker returns a separately parsed, fail-closed static SVG allowlist with Content-Disposition: inline and a restrictive CSP. A conventional UTF-8 XML declaration is accepted and stripped. Scripts, event/style attributes, active or animation elements, other XML processing instructions, directives/entities, and external/data/blob references are removed or rejected. Without the marker, the original SVG remains download-only.
  • ?download=1 forces attachment; other download values do not.
  • Every attachment response, including validation, missing-record, and storage-error responses from this route, includes X-Content-Type-Options: nosniff.
  • The filename is reduced to its basename and control/path-separator characters are removed; a missing or unusable value becomes attachment-{id}. Content-Disposition includes a safe ASCII quoted fallback and, for non-ASCII names, a UTF-8 filename* parameter.
  • Attachment responses are private and uncached (Cache-Control: private, no-store); clients must refetch after invalidation rather than relying on an intermediary cache.

Responses:

Status Body Condition
200 raw bytes Attachment exists and its content is non-null.
400 JSON Invalid attachment id Invalid path ID.
404 JSON Attachment not found Attachment is missing or has null content.
422 JSON SVG attachment is not safe to render inline inline=cid was requested for an SVG attachment, but sanitizer validation rejected the content.
500 plain text Storage failure.

Attachment bytes are not content-sanitized. The narrow inline allowlist, forced download for active/unknown formats, and nosniff reduce browser execution risk; Hoomail still must run on an isolated development origin.

Destructive reset

POST /api/reset

No request body, confirmation token, authentication, or CSRF token is required.

The operation transactionally deletes attachments, calendar events, messages, and mailboxes, then removes the SQLite autoincrement sequence entries for all four tables. It emits reset only after the transaction commits.

Returns 200:

{"ok":true}

Storage failure returns plain-text 500.

ID reuse warning: because the generated-ID sequences are reset, future mailboxes, messages, attachments, and calendar rows can reuse IDs that existed before the reset. Clients must discard all cached list, detail, inspection, attachment, and calendar state on reset; an old ID must not be assumed to identify the same object afterward.

Built-in test sender

POST /api/send-test

The optional request body is normally:

{
  "to": "test@example.com",
  "subject": "Optional subject",
  "kind": "plain"
}

Normalized request behavior:

Field Behavior
to A string is trimmed and lowercased. Empty, missing, or non-string values become test@hoomail.local. Validation is only the current simple pattern non-space/non-@ + @ + non-space/non-@ + . + non-space/non-@; it is not full email-address validation.
subject A string is trimmed. Missing or non-string values become empty. The built-in sender then defaults an empty plain subject to hoomail delivery test, and an empty calendar summary to Owl standup meeting.
kind Exact supported strings are plain, invite, update, and cancellation. Missing, non-string, differently cased, or unknown values silently become plain.

Unlike /api/messages/actions, body decoding failure is not a client error here. An empty body, malformed JSON, a trailing extra JSON value, or a valid non-object JSON value is treated as an empty object and therefore uses the defaults above. Recognized fields from a partially malformed document are not recovered; all defaults are used.

The production sender opens an SMTP connection to Hoomail's configured local SMTP listener and sends one generated message. plain includes text, HTML, and a text attachment. The calendar kinds generate REQUEST, updated REQUEST, or CANCEL calendar samples respectively.

Responses:

Status Body Condition
200 {"ok":true} The sender completed the SMTP transaction.
400 {"error":"Invalid recipient address"} The normalized recipient fails the simple pattern.
502 {"error":"Could not reach the SMTP server. Is it running?"} No sender is configured or any sender/SMTP step fails.

The response does not include the created mailbox or message ID. Normal SMTP ingestion subsequently produces the same database changes and SSE notifications as other delivered mail.

Server-sent events

GET /api/events

The global SSE endpoint responds 200 OK with exactly these handler-set headers:

Content-Type: text/event-stream
Cache-Control: no-cache, no-transform
Connection: keep-alive

It immediately flushes this stream-local greeting:

data: {"type":"connected"}

Broker notifications use one compact JSON object in an SSE data field and are flushed after each frame:

data: {"type":"messages:changed","mailboxId":1}

Every 25 seconds, the handler flushes an SSE comment heartbeat:

: ping

The stream does not send SSE event:, id:, or retry: fields. All broker notifications arrive through the browser message event; clients dispatch using the JSON type. The connected greeting is generated by each HTTP stream and is not a broadcast broker event.

If the response writer does not support HTTP flushing, the route returns the common plain-text 500 response before opening a stream. Once open, the handler ends on request cancellation or a write failure; it provides no terminal event.

Event payloads and triggers

JSON type Exact payload shape Current triggers
mailbox:new {"type":"mailbox:new","mailbox":{"id":7,"address":"inbox@example.com"}} A committed message delivery creates a mailbox for a recipient, or POP3 opens a previously missing normalized mailbox.
mailbox:deleted {"type":"mailbox:deleted","mailboxId":7} DELETE /api/mailboxes/{id} successfully deletes a mailbox.
messages:changed {"type":"messages:changed","mailboxId":7} An unread GET /api/messages/{id} marks the message read, or a message action affects existing messages in the mailbox.
calendar:changed {"type":"calendar:changed","mailboxId":7} A committed message delivery contains one or more parsed calendar events and calendar reconciliation runs for the mailbox. The event can be emitted even when sequence/reply rules leave the visible reconciled row unchanged.
message:new {"type":"message:new","mailboxId":7,"message":{"id":11,"subject":"Welcome","fromAddress":"sender@example.com","fromName":null}} A message is stored and its transaction commits for that recipient mailbox. subject, fromAddress, and fromName can each be null.
reset {"type":"reset"} POST /api/reset commits successfully.

For each recipient processed by a committed message delivery, notifications are queued in this order:

  1. mailbox:new, only if that recipient mailbox was created;
  2. calendar:changed, only when the message has parsed calendar events;
  3. message:new.

A new delivery does not additionally emit messages:changed.

These events are invalidation hints. Except for the small summaries in mailbox:new and message:new, they do not contain authoritative resource state. Clients should refetch:

  • /api/mailboxes after mailbox, message, or reset notifications;
  • /api/mailboxes/{mailboxId}/messages and any active searches after message:new or messages:changed;
  • /api/mailboxes/{mailboxId}/events after calendar:changed;
  • all relevant resources after reset or any stream interruption.

Delivery limits and reconnection

SSE delivery is in-memory, best-effort, and non-replayable:

  • Each connected subscription has a 64-event channel buffer.
  • Broadcasts preserve enqueue order for a subscriber while its buffer has capacity.
  • Producers never wait for a slow subscriber. When its 64-slot buffer cannot accept the next event immediately, the hub removes that subscriber and closes its subscription channel.
  • On overflow the hub removes that subscriber and closes its subscription channel. The HTTP stream detects the closed channel and ends promptly without serializing an empty-type event.
  • The protocol provides no event ID, replay cursor, resume token, persisted event log, or acknowledgement.
  • Events emitted before subscription, during disconnect, after buffer overflow, or during process restart can be missed.

Use the browser EventSource reconnection behavior or reconnect explicitly. After every reconnect, refetch authoritative API state rather than assuming that the next event repairs a gap. If output is malformed, incomplete, stalled, or has an unknown or empty type, treat the stream as invalid: reconnect and conservatively refetch authoritative state.

Current security boundaries and limitations

Hoomail is intended for controlled development and test environments. The current HTTP server has:

  • no authentication or authorization;
  • no built-in TLS termination;
  • no CSRF token validation or Origin/Referer enforcement;
  • no CORS middleware and no configured Access-Control-Allow-Origin policy;
  • no application-wide browser security-header middleware: no configured HSTS, X-Frame-Options, or Permissions-Policy; message frames receive their own restrictive CSP/referrer policy, and successful attachment responses receive X-Content-Type-Options: nosniff;
  • no authorization or confirmation requirement on destructive message, mailbox, and reset routes.

Some Go standard-library error responses may add their own response-specific headers, but there is no application-wide security-header policy.

CORS is not an authentication boundary, and non-browser network clients are unrestricted. Deploy Hoomail on an isolated origin behind a trusted reverse proxy or network boundary that provides TLS, authentication, access control, and an appropriate browser-security header policy. Do not share its origin with trusted applications. Captured attachment bytes remain untrusted even though active and unknown formats are download-only by default.

Implementation sources

The contract above is derived from the current repository implementation and tests:

  • internal/httpserver/httpserver.go — routing, schemas, response handling, attachment behavior, SSE framing, reset, and send-test normalization.
  • internal/events/events.go — event payloads and the 64-event best-effort subscription hub.
  • internal/store/store.go and internal/store/operations.go — stored field shapes, ordering, side effects, reconciliation, reset, and event triggers.
  • internal/calendar/calendar.go — calendar payload and part-recognition shapes.
  • internal/inspect/inspect.go and internal/mimeparse — display sanitization/CID rewriting plus the versioned offline analyzer and shared bounded MIME parser.
  • internal/sendtest/sendtest.go — built-in sample-message and SMTP behavior.
  • internal/httpserver/httpserver_test.go and internal/events/events_test.go — exact error, header, framing, payload, filtering, mutation, ordering, and slow-subscriber assertions.