imap

package
v0.2.47 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 12, 2026 License: MPL-2.0 Imports: 23 Imported by: 0

Documentation

Overview

Package imap is the durable mailbox store behind the chassis `imap` personality and the txco://imap/* ops: accounts, a per-account tree of mailboxes, and per-mailbox UID-ordered message rows whose retained object (a canonical Record, or verbatim RFC 5322 bytes) lives in the filecas by sha256. Everything IMAP needs to serve a message without parsing it — ENVELOPE, BODYSTRUCTURE, size, flags — is cached on the row at append.

Storage is dialect-aware (registry.Dialect, the same seam auth and the scheduled store use); the bundled backend is a SQLite file of its own. It is deliberately NOT a set of runtime tables: the dbcache watcher reloads the whole runtime mirror on any runtime-DB write, and a mailbox index is written on every \Seen.

Immutability rules (the design doc's §25.6): bytes under a UID never change; same object_key + same sha is a no-op; a different sha is a NEW UID with the old one expunged; uidnext is stored, never derived; uidvalidity changes only on an explicit reset.

Index

Constants

View Source
const (
	KindRecord   = "record"
	KindVerbatim = "verbatim"
)

Kinds of retained object.

View Source
const (
	StatusActive   = "active"
	StatusDisabled = "disabled"
)

Account statuses.

View Source
const FormatVersion = 1

FormatVersion is the formatter version stamped on every record row at append. A message keeps the version it was appended under for life: the rendered bytes must be byte-identical on every FETCH (IMAP promises the client that a UID's content never changes), so a change to the renderer below is a NEW version for new messages, never a re-render of old ones.

Variables

View Source
var (
	// ErrMailboxExists is returned by CreateMailbox / RenameMailbox when
	// the target name is already a live mailbox.
	ErrMailboxExists = errors.New("imap: mailbox already exists")
	// ErrMailboxNotFound is returned when the named mailbox is absent.
	ErrMailboxNotFound = errors.New("imap: no such mailbox")
	// ErrINBOX is returned for DELETE / RENAME of INBOX (RFC 3501 §5.1:
	// INBOX always exists).
	ErrINBOX = errors.New("imap: INBOX cannot be deleted or renamed")
)
View Source
var ErrBadHash = apppass.ErrBadHash

ErrBadHash is returned when a stored hash is not a PHC argon2id string apppass produced. Passwords themselves live in the identity store (chassis/authn); an IMAP account holds none.

View Source
var ErrUsernameTaken = errors.New("imap: username belongs to another tenant")

ErrUsernameTaken is returned by UpsertAccount when the username already belongs to another tenant (usernames are globally unique: they are the LOGIN identity).

Functions

func HasFlag

func HasFlag(flags []string, f string) bool

HasFlag reports whether flags contains f, case-insensitively.

func NormalizeFlags

func NormalizeFlags(in []string) []string

NormalizeFlags trims, drops empties and \Recent (server-owned, never stored), dedupes case-insensitively, canonicalises system-flag spelling and sorts — so a flags column compares byte-wise.

func NormalizeMailboxName

func NormalizeMailboxName(name string) string

NormalizeMailboxName canonicalises a mailbox path: trims, collapses the delimiter, and maps any case of "inbox" to INBOX (RFC 3501 §5.1).

func NormalizeUsername

func NormalizeUsername(u string) string

NormalizeUsername lowercases and trims a LOGIN / op username. Usernames are e-mail addresses; the domain half is case-insensitive by RFC and the local part is treated so here (one account per mailbox name).

func Register

func Register(name string, c Constructor)

Register adds a backend constructor. Called from a backend package's init().

func Render

func Render(rec *Record, sha string, opt RenderOptions) ([]byte, error)

Render produces the RFC 5322 bytes for a record, deterministically: MIME boundaries derive from the record's canonical sha, header order is fixed, bodies are quoted-printable, lines end in CRLF. sha is the canonical sha256 hex Canonical returned (also the CAS key).

func WithOrigin

func WithOrigin(ctx context.Context, origin any) context.Context

WithOrigin tags a context with the writer's identity; the resulting Change carries it as Origin.

Types

type Account

type Account struct {
	Tenant    string
	Username  string
	Status    string
	Policy    json.RawMessage
	CreatedAt time.Time
	UpdatedAt time.Time
}

Account is an imap_accounts row.

type AppendResult

type AppendResult struct {
	UID         uint32
	UIDValidity uint32
	// Noop: the mailbox already held this object_key with this sha.
	Noop bool
	// Replaced: the object_key existed with a different sha; ReplacedUID
	// was expunged and UID is the new row.
	Replaced    bool
	ReplacedUID uint32
}

AppendResult reports what AppendMessage did.

type Change

type Change struct {
	MailboxID string
	Kind      ChangeKind
	UID       uint32
	Seq       uint32
	Total     uint32
	Flags     []string
	// Origin is whatever WithOrigin put on the writing context (the head
	// passes its session tracker so a session is not told about its own
	// STORE twice). nil for op-side writes.
	Origin any
}

Change is a post-commit notification the head turns into IDLE/Poll updates. Seq is the message's sequence number in UID order at the moment of the change (for an expunge: its former position); Total is the live count after the change.

type ChangeKind

type ChangeKind string

ChangeKind labels a post-commit change notification.

const (
	ChangeAppend  ChangeKind = "append"
	ChangeExpunge ChangeKind = "expunge"
	ChangeFlags   ChangeKind = "flags"
)

type Config

type Config struct {
	// DBPath is the bundled SQLite backend's file path (--imap-db-path).
	DBPath string
}

Config carries backend-selecting options resolved from chassis config. The bundled "sqlite" backend uses DBPath. Adding a field here doesn't affect existing backends (same posture as scheduled.Config / vector.Config).

type Constructor

type Constructor func(Config) (*Store, error)

Constructor builds a Store from resolved config. It is expected to open its backing DB and call Store.EnsureSchema before returning.

type Expunged

type Expunged struct {
	UID uint32
	Seq uint32
}

Expunged is one row removed by Expunge / MoveMessages: its UID and its sequence number at the moment of removal, reported in descending sequence order so a client applying them in order stays consistent.

type Mailbox

type Mailbox struct {
	ID          string
	Tenant      string
	Username    string
	Name        string
	Role        string
	Attrs       []string
	Policy      json.RawMessage
	UIDValidity uint32
	UIDNext     uint32
	ModSeq      int64
	Subscribed  bool
	CreatedAt   time.Time
}

Mailbox is an imap_mailboxes row. Name is the full '/'-delimited path; Role is the stack's opaque stable key (survives RENAME); Attrs are special-use attributes.

type Message

type Message struct {
	MailboxID     string
	UID           uint32
	ObjectKey     string
	Kind          string
	SHA256        string
	FormatVersion int
	Size          int64
	InternalDate  time.Time
	Flags         []string
	Envelope      json.RawMessage
	BodyStructure json.RawMessage
	Subject       string
	FromAddr      string
	TextExcerpt   string
	Parts         json.RawMessage
	ModSeq        int64
	State         string
	CreatedAt     time.Time
}

Message is an imap_messages row.

type MessageHead

type MessageHead struct {
	UID          uint32
	Flags        []string
	Size         int64
	InternalDate time.Time
	// ModSeq is the mailbox modseq at the row's last change (append or
	// flags); the head diffs snapshots by it.
	ModSeq int64
}

MessageHead is the per-message slice a session needs at SELECT/STATUS time: identity, flags, size, date — never the cached structures.

type PartRef

type PartRef struct {
	N      int    `json:"n"`
	Name   string `json:"name,omitempty"`
	Type   string `json:"type,omitempty"`
	Size   int64  `json:"size,omitempty"`
	SHA256 string `json:"sha256,omitempty"`
}

PartRef is an attachment reference inside a Record.

type Record

type Record struct {
	Version int               `json:"v"`
	Headers map[string]string `json:"headers,omitempty"`
	Text    string            `json:"text,omitempty"`
	HTML    string            `json:"html,omitempty"`
	Parts   []PartRef         `json:"parts,omitempty"`
}

Record is the canonical retained representation of a message the platform keeps — the thing a stack chose to remember, not the wire form it arrived in. RFC 5322 is derived from it by Render on FETCH.

Headers is a subset the stack chose (Message-ID, Date, From, To, Cc, Subject, In-Reply-To, References, plus whatever else it kept); Text is the normalized plain body; HTML the optional rich body; Parts are attachment references — name/type/size always, a sha256 only where retention was permitted and the bytes are in the CAS.

func ParseRecord

func ParseRecord(b []byte) (*Record, error)

ParseRecord decodes canonical record bytes read back from the CAS.

func (*Record) Canonical

func (r *Record) Canonical() ([]byte, string, error)

Canonical returns the record's canonical JSON — the bytes stored in the CAS and hashed for the row's sha256. encoding/json sorts map keys and emits struct fields in declaration order, so equal records give equal bytes.

func (*Record) Excerpt

func (r *Record) Excerpt(n int) string

Excerpt returns the first n bytes of the record's plain text (or a tag-stripped slice of the HTML when there is no text), for the text_excerpt column that column-backed SEARCH BODY/TEXT reads.

func (*Record) Normalize

func (r *Record) Normalize()

Normalize canonicalises header keys (textproto form), drops structural headers and empty values, strips CR/LF from every value (header injection guard) and stamps the format version. Called before Canonical so two stacks that spell "message-id" differently produce one sha.

type RenderOptions

type RenderOptions struct {
	// ObjectKey seeds the synthesized Message-ID when the record has none.
	ObjectKey string
	// Domain is the right-hand side of a synthesized Message-ID (the
	// account's domain). Empty falls back to "txco.invalid".
	Domain string
	// InternalDate is the Date: fallback when the record carries none.
	InternalDate time.Time
}

RenderOptions are the per-message inputs to the formatter that are not part of the record itself. All of them are stored on the row, so a re-render is reproducible.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store is the façade over the three tables. It carries the dialect (for `?`→`$n` rebinding) and a clock seam for tests, mirroring scheduled.Store.

func NewStore

func NewStore(db *sql.DB, d registry.Dialect) *Store

NewStore builds a Store over the opened DB and its dialect. A nil dialect defaults to SQLite (the in-tree default).

func Open

func Open(name string, cfg Config) (*Store, error)

Open constructs the named backend. Unknown name is a startup error listing what is available (so a misconfigured --imap-store fails loudly).

func (*Store) AppendMessage

func (s *Store) AppendMessage(ctx context.Context, mailboxID string, m Message) (AppendResult, error)

AppendMessage stores a message row in the mailbox and returns its UID. The retained object must already be in the CAS (write order is CAS → sha rows → index row, so a crash between leaves nothing dangling that a retry can't heal). Semantics by object_key (§25.6): same key + same sha → Noop; same key + different sha → the old row is expunged and a NEW UID allocated (Replaced); empty key → always a new row.

Appends to one mailbox are serialized: the transaction first locks the mailbox row (LockClause — FOR UPDATE on Postgres; on SQLite the _txlock=immediate connection already holds the write lock), so the object_key check and the uidnext allocation can never interleave across nodes. A unique-index violation (a concurrent writer that won the same key between an out-of-tx check and this call, e.g. CopyMessage) is retried once.

func (*Store) Close

func (s *Store) Close() error

Close releases the underlying DB handle.

func (*Store) CopyMessage

func (s *Store) CopyMessage(ctx context.Context, srcID string, uid uint32, destID string) (AppendResult, error)

CopyMessage inserts a new row in dest over the same retained object as (src, uid). The object_key travels with the copy unless dest already holds that key, in which case the copy is keyless (a client COPY is not a stack projection). Returns the dest UID.

func (*Store) CountByMailbox

func (s *Store) CountByMailbox(ctx context.Context, mailboxID string) (uint32, uint32, error)

CountByMailbox returns (messages, unseen) for a mailbox.

func (*Store) CreateMailbox

func (s *Store) CreateMailbox(ctx context.Context, tenant, username, name, role string, attrs []string, policy json.RawMessage) (Mailbox, error)

CreateMailbox creates a live mailbox at name with the given role, special-use attrs and policy. Parents need not exist (they LIST as \Noselect until created). ErrMailboxExists when the name is taken.

func (*Store) DB

func (s *Store) DB() *sql.DB

DB exposes the handle for tests and backends. Not for request paths.

func (*Store) DeleteMailbox

func (s *Store) DeleteMailbox(ctx context.Context, tenant, username, name string) (Mailbox, error)

DeleteMailbox soft-deletes a live mailbox (deleted_at). Its rows stay (immutable, unreferenced — the blob posture); children keep their names and the parent LISTs as \Noselect. INBOX is refused.

func (*Store) EnsureMailbox

func (s *Store) EnsureMailbox(ctx context.Context, tenant, username, name string) (Mailbox, bool, error)

EnsureMailbox returns the live mailbox with this name, creating it when absent. created reports a fresh row. UIDVALIDITY is the creation second (monotonic across delete-and-recreate at one-second resolution, the conventional choice).

func (*Store) EnsureSchema

func (s *Store) EnsureSchema(ctx context.Context) error

EnsureSchema creates the tables + indexes if absent. Portable DDL: TEXT ids (hxid) instead of engine-specific autoincrement, TEXT RFC3339 timestamps, JSON as TEXT, native partial indexes, and BIGINT for the uint32/int64 counters (uidvalidity is a Unix timestamp, uid copies uidnext, size and modseq are int64) — SQLite reads BIGINT as INTEGER affinity, Postgres as int8, so one DDL serves both engines.

func (*Store) Expunge

func (s *Store) Expunge(ctx context.Context, mailboxID string, uids []uint32) ([]Expunged, error)

Expunge removes the rows flagged \Deleted (restricted to uids when non-empty) and returns them in descending sequence order.

func (*Store) GetAccount

func (s *Store) GetAccount(ctx context.Context, username string) (Account, bool, error)

GetAccount looks an account up by username (the LOGIN identity).

func (*Store) GetAccountByLocalPart

func (s *Store) GetAccountByLocalPart(ctx context.Context, local string) (Account, int, error)

GetAccountByLocalPart resolves a bare local part ("paris") the way a mail client that drops the domain expects: the account whose username is <local>@<anything>, provided exactly one exists. n is the number of candidates, so a caller can tell "no such user" from "ambiguous" (both are a plain authentication failure on the wire).

func (*Store) GetMailbox

func (s *Store) GetMailbox(ctx context.Context, tenant, username, name string) (Mailbox, bool, error)

GetMailbox returns the live mailbox at name.

func (*Store) GetMailboxByID

func (s *Store) GetMailboxByID(ctx context.Context, id string) (Mailbox, bool, error)

GetMailboxByID returns a live mailbox by row id.

func (*Store) GetMailboxByRole

func (s *Store) GetMailboxByRole(ctx context.Context, tenant, username, role string) (Mailbox, bool, error)

GetMailboxByRole returns the tenant/account's live mailbox carrying role (the stack's stable key). The first by name wins if several share it.

func (*Store) GetMessage

func (s *Store) GetMessage(ctx context.Context, mailboxID string, uid uint32) (Message, bool, error)

GetMessage returns one full row.

func (*Store) GetMessageByKey

func (s *Store) GetMessageByKey(ctx context.Context, mailboxID, objectKey string) (Message, bool, error)

GetMessageByKey returns the row holding object_key.

func (*Store) ListMailboxes

func (s *Store) ListMailboxes(ctx context.Context, tenant, username string) ([]Mailbox, error)

ListMailboxes returns the account's live mailboxes sorted by name.

func (*Store) ListMessageHeads

func (s *Store) ListMessageHeads(ctx context.Context, mailboxID string) ([]MessageHead, error)

ListMessageHeads returns the mailbox's live messages in UID order — the slice a session holds while selected (sequence number = index + 1).

func (*Store) ListMessages

func (s *Store) ListMessages(ctx context.Context, mailboxID string, after uint32, limit int, flags []string) ([]Message, uint32, error)

ListMessages is the windowed read for txco://imap/messages: rows with uid > after, in UID order, at most limit, optionally only those carrying ANY of flags. next is the last uid returned when the window was full.

func (*Store) MoveMessages

func (s *Store) MoveMessages(ctx context.Context, srcID string, uids []uint32, destID string) (map[uint32]uint32, []Expunged, error)

MoveMessages copies each uid into dest and removes it from src, in one transaction per message (a crash between leaves a copy — never a loss). Returns src→dest UID pairs plus the src expunges in descending sequence.

func (*Store) RemoveMessage

func (s *Store) RemoveMessage(ctx context.Context, mailboxID string, uid uint32) (bool, error)

RemoveMessage expunges one row (an op-side removal). Returns false when no such UID. The mailbox row is bumped (and thereby locked) first so the reported sequence number cannot shift under a concurrent writer.

func (*Store) RenameMailbox

func (s *Store) RenameMailbox(ctx context.Context, tenant, username, oldName, newName string) (Mailbox, error)

RenameMailbox renames a live mailbox and its whole subtree. UIDs, UIDVALIDITY, roles and rows are untouched — only names change. INBOX cannot be renamed; the new name must be free.

func (*Store) ResetMailbox

func (s *Store) ResetMailbox(ctx context.Context, id string) (Mailbox, error)

ResetMailbox empties a mailbox and bumps UIDVALIDITY — the one sanctioned way UIDVALIDITY changes (§25.6). Sessions holding it selected must reselect; they are not notified (a reset is an operator act, not a client flow).

func (*Store) SetFlags

func (s *Store) SetFlags(ctx context.Context, mailboxID string, uid uint32, flags []string) ([]string, error)

SetFlags replaces a message's flag set and returns the stored (normalised) set. Flags are the only mutable state under a UID.

func (*Store) SetOnChange

func (s *Store) SetOnChange(fn func(Change))

SetOnChange installs the post-commit change listener (the head's notification hub). Called synchronously after each committing write, in order; the listener must not block.

func (*Store) SetSubscribed

func (s *Store) SetSubscribed(ctx context.Context, mailboxID string, subscribed bool) error

SetSubscribed persists the SUBSCRIBE/UNSUBSCRIBE state.

func (*Store) UpdateMailbox

func (s *Store) UpdateMailbox(ctx context.Context, id string, role *string, attrs []string, policy json.RawMessage) (Mailbox, error)

UpdateMailbox sets a mailbox's role / attrs / policy (nil = keep).

func (*Store) UpsertAccount

func (s *Store) UpsertAccount(ctx context.Context, tenant, username, status string, policy json.RawMessage) (created bool, err error)

UpsertAccount creates the account (and its INBOX) or updates it. An empty status / policy leaves the stored value unchanged on update. created reports whether the row was new. The account holds no password: who may sign in as it is the identity store's business (chassis/authn — a binding and a credential).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL