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.
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.
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.
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.
- All documented timestamps are Unix time in milliseconds.
- Nullable database values are represented as JSON
null. - Message-list booleans
is_readandhas_icalare integers (0or1). CalendarallDayis 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.
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.
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.
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.
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).
The html field is a safe display projection, not a raw copy:
- MIME ingestion recursively selects the supported
multipart/alternativerepresentation and themultipart/relatedroot (startContent-ID, or the first part). CID resources include that selected related branch and parts explicitly markedContent-Disposition: inlineelsewhere in the selected message structure. - Matching
cid:image URLs are percent-decoded and rewritten to/api/attachments/{attachmentId}. - 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/calendarorapplication/ics, or a filename ending in.ics, case-insensitively.
The underlying attachment remains addressable by its ID if a client already knows that ID.
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.
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.
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.
idsis useful only when it is an array. Each entry must be an exact decimal integer token accepted byjson.Number.Int64()and within the signed 64-bit range[-9223372036854775808, 9223372036854775807]. Fractional values, exponent notation, values outside that range, strings, booleans, objects, arrays, andnullentries 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"}. actionmust 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.
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.
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. Forimage/svg+xmlonly, that marker returns a separately parsed, fail-closed static SVG allowlist withContent-Disposition: inlineand 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=1forcesattachment; 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-Dispositionincludes a safe ASCII quoted fallback and, for non-ASCII names, a UTF-8filename*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.
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.
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.
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.
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:
mailbox:new, only if that recipient mailbox was created;calendar:changed, only when the message has parsed calendar events;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/mailboxesafter mailbox, message, or reset notifications;/api/mailboxes/{mailboxId}/messagesand any active searches aftermessage:newormessages:changed;/api/mailboxes/{mailboxId}/eventsaftercalendar:changed;- all relevant resources after
resetor any stream interruption.
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.
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-Originpolicy; - no application-wide browser security-header middleware: no configured HSTS,
X-Frame-Options, orPermissions-Policy; message frames receive their own restrictive CSP/referrer policy, and successful attachment responses receiveX-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.
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.goandinternal/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.goandinternal/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.goandinternal/events/events_test.go— exact error, header, framing, payload, filtering, mutation, ordering, and slow-subscriber assertions.