imap

package
v0.2.27 Latest Latest
Warning

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

Go to latest
Published: Sep 8, 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.

View Source
const WordPasswordDefaultWords = apppass.WordPasswordDefaultWords

WordPasswordDefaultWords is apppass.WordPasswordDefaultWords.

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 is returned when a stored hash is not a PHC argon2id string
	// apppass produced.
	ErrBadHash = apppass.ErrBadHash

	HashPassword         = apppass.HashPassword
	VerifyPassword       = apppass.VerifyPassword
	VerifyDummy          = apppass.VerifyDummy
	GeneratePassword     = apppass.GeneratePassword
	GenerateWordPassword = apppass.GenerateWordPassword
)

The password mechanism lives in chassis/apppass, shared with the calendar head so one credential can open both. These names are kept so the ops, the head and their tests read as they always did.

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
	PwHash    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, pwHash, status string, policy json.RawMessage) (created bool, err error)

UpsertAccount creates the account (and its INBOX) or updates it. An empty pwHash / status / policy leaves the stored value unchanged on update; pwHash is required on create. created reports whether the row was new.

Jump to

Keyboard shortcuts

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