Documentation
¶
Overview ¶
Package store provides interfaces and types for mailbox storage. Implementations are in store/mongo, store/memory, and store/postgres subpackages.
Architectural Principle: No Distributed Locks ¶
This package is designed to avoid distributed locks entirely. Distributed locks introduce complexity, single points of failure, and performance bottlenecks. Instead, all concurrency concerns are handled through:
Atomic Database Operations: Use database-native atomic operations like MongoDB's findOneAndUpdate with upsert, or PostgreSQL's INSERT ON CONFLICT. These operations are guaranteed to be atomic by the database engine.
Idempotency via Unique Constraints: Instead of locking before write, use unique indexes/constraints and handle conflicts via return status. The database enforces uniqueness atomically - no external coordination needed.
Optimistic Concurrency: For updates, use version fields or timestamps and let the database reject stale updates. Retry on conflict.
Transactional Batches: Multi-document operations use database transactions (MongoDB sessions, PostgreSQL transactions) for atomicity, not distributed locks.
Example - Idempotent Message Send:
// WRONG: Distributed lock approach (DO NOT USE)
lock.Acquire("send:" + idempotencyKey)
defer lock.Release()
if exists := store.Get(idempotencyKey); exists { return exists }
msg := store.Create(data)
return msg
// CORRECT: Atomic upsert approach
msg, created, err := store.CreateMessageIdempotent(ctx, data, idempotencyKey)
if !created {
return msg, nil // Already existed, return cached result
}
return msg, nil // Newly created
Example - Concurrent Trash Cleanup:
// WRONG: Distributed lock approach (DO NOT USE)
if !lock.TryAcquire("trash-cleanup") { return }
messages := store.FindExpired()
for _, msg := range messages { store.Delete(msg) }
// CORRECT: Atomic bulk delete
deleted, err := store.DeleteExpiredTrash(ctx, cutoff)
// Multiple instances can call this safely - database handles atomicity
This design provides:
- Simpler architecture (no external lock service like Redis/Consul/etcd)
- Better reliability (database ACID guarantees vs lock service availability)
- Higher performance (no extra round-trips for lock acquire/release)
- Automatic deadlock prevention (no distributed deadlocks possible)
- Cleaner failure handling (database transactions auto-rollback)
Index ¶
- Constants
- Variables
- func ApplyMoveOptions(opts []MoveOption) moveOptions
- func IsDuplicateEntry(err error) bool
- func IsFolderMismatch(err error) bool
- func IsInvalidID(err error) bool
- func IsNotConnected(err error) bool
- func IsNotFound(err error) bool
- func IsReservedFolder(folderID string) bool
- func IsSentByOwner(ownerID, senderID string) bool
- func IsValidFolderID(folderID string) bool
- func MessageFieldKey(field string) (string, bool)
- func MessageOrderingKey(field string) (string, bool)
- type Attachment
- type AttachmentCreate
- type AttachmentFileStore
- type AttachmentManager
- type AttachmentMetadata
- type AttachmentMetadataStore
- type BulkReadMarker
- type BulkUpdater
- type DraftList
- type DraftMessage
- type DraftStore
- type EventOutboxProvider
- type Filter
- func ExternalIDIs(id string) Filter
- func HasAnyTag() Filter
- func HasTag(tagID string) Filter
- func HasTags(tags []string) []Filter
- func HasThread() Filter
- func InFolder(folderID string) Filter
- func IsAvailable() Filter
- func IsDraftFilter(isDraft bool) Filter
- func IsReadFilter(isRead bool) Filter
- func NewFilter(key, operator string, value any) (Filter, error)
- func NotDeleted() Filter
- func NotExpired() Filter
- func NotInFolder(folderID string) Filter
- func OwnerIs(ownerID string) Filter
- func RecipientIs(recipientID string) Filter
- func ReplyToIs(messageID string) Filter
- func SenderIs(senderID string) Filter
- func StatusIs(status MessageStatus) Filter
- func ThreadIs(threadID string) Filter
- type FilterBuilder
- func (b *FilterBuilder) Contains(v any) (Filter, error)
- func (b *FilterBuilder) Equal(v any) (Filter, error)
- func (b *FilterBuilder) Exists(v bool) (Filter, error)
- func (b *FilterBuilder) GreaterThan(v any) (Filter, error)
- func (b *FilterBuilder) GreaterThanEqual(v any) (Filter, error)
- func (b *FilterBuilder) In(v ...any) (Filter, error)
- func (b *FilterBuilder) LessThan(v any) (Filter, error)
- func (b *FilterBuilder) LessThanEqual(v any) (Filter, error)
- func (b *FilterBuilder) NotEqual(v any) (Filter, error)
- func (b *FilterBuilder) NotIn(v ...any) (Filter, error)
- type FilterError
- type FindWithCounter
- type FolderCounter
- type FolderCounts
- type FolderLister
- type IdempotentCreateEntry
- type IdempotentCreateResult
- type ListOptions
- type MailboxStats
- type MaintenanceStore
- type Message
- type MessageData
- type MessageList
- type MessageReader
- type MessageStatus
- type MessageStore
- type MessageStoreCreator
- type MessageStoreMutator
- type MessageStoreReader
- type MoveOption
- type OutboxPersister
- type SearchQuery
- type SortOrder
- type StatsStore
- type Store
- type ThreadParticipantLister
Constants ¶
const ( // HeaderContentType is the MIME type of the message body. // Example: "application/json", "text/plain". HeaderContentType = "Content-Type" // HeaderContentLength is the byte length of the message body. // Auto-populated at send time if not already set. HeaderContentLength = "Content-Length" // HeaderContentEncoding is the encoding applied to the body. // Example: "gzip", "base64". HeaderContentEncoding = "Content-Encoding" // HeaderSchema is an application-defined schema identifier. // Example: "sensor.reading/v1", "order.placed/v2". HeaderSchema = "Schema" // HeaderPriority is the message priority level. // Example: "high", "normal", "low". HeaderPriority = "Priority" // HeaderCorrelationID links related messages for tracing. HeaderCorrelationID = "Correlation-ID" // HeaderExpires is the expiration timestamp (RFC 3339). HeaderExpires = "Expires" // HeaderReplyToAddress is the address to send replies to, // when it differs from the sender. HeaderReplyToAddress = "Reply-To-Address" // HeaderCustomID is a caller-defined external identifier. HeaderCustomID = "Custom-ID" // HeaderEncryption indicates the message body is encrypted. // Example: "aes-256-gcm". HeaderEncryption = "X-Encryption" )
Well-known header keys for message headers. Headers carry protocol-level metadata (like HTTP headers), while Metadata carries application-level arbitrary data. Headers are always string→string.
const ( FolderInbox = "__inbox" FolderSent = "__sent" FolderArchived = "__archived" FolderTrash = "__trash" FolderSpam = "__spam" FolderOutbox = "__outbox" FolderDrafts = "__drafts" // FolderPrefix is the prefix for reserved system folders. FolderPrefix = "__" )
Reserved folder names. All messages belong to a folder. Use these constants for system folders. Reserved folders start with "__" prefix - user-defined folders must not use this prefix.
Variables ¶
var ( // ErrNotFound is returned when a message cannot be found. ErrNotFound = errors.New("store: not found") // ErrInvalidID is returned when an invalid ID is provided. ErrInvalidID = errors.New("store: invalid id") // ErrDuplicateEntry is returned when a duplicate entry is detected. ErrDuplicateEntry = errors.New("store: duplicate entry") // ErrNotConnected is returned when operations are attempted before Connect(). ErrNotConnected = errors.New("store: not connected") // ErrAlreadyConnected is returned when Connect() is called twice. ErrAlreadyConnected = errors.New("store: already connected") // ErrEmptyRecipients is returned when no recipients are provided. ErrEmptyRecipients = errors.New("store: empty recipients") // ErrEmptySubject is returned when subject is empty. ErrEmptySubject = errors.New("store: empty subject") // ErrFilterInvalid is returned when a filter is invalid. ErrFilterInvalid = errors.New("store: invalid filter") // ErrRegexSearchDisabled is returned when text search is attempted but regex is disabled. ErrRegexSearchDisabled = errors.New("store: regex search is disabled") // ErrInvalidFolderID is returned when an invalid folder ID is provided. ErrInvalidFolderID = errors.New("store: invalid folder id") // ErrInvalidIdempotencyKey is returned when an empty idempotency key is provided. ErrInvalidIdempotencyKey = errors.New("store: invalid idempotency key") // ErrTransactionFailed is returned when a database transaction fails. // This indicates the atomic operation could not complete and no changes were made. ErrTransactionFailed = errors.New("store: transaction failed") // ErrFolderMismatch is returned when a conditional move finds the message // but it is not in the expected source folder. This enables atomic // compare-and-swap folder moves where the caller can detect that another // process already moved (claimed) the message. ErrFolderMismatch = errors.New("store: folder mismatch") )
Sentinel errors for the store package.
Functions ¶
func ApplyMoveOptions ¶ added in v0.6.0
func ApplyMoveOptions(opts []MoveOption) moveOptions
ApplyMoveOptions resolves variadic MoveOption into a moveOptions struct. This is intended for Store implementations that need to inspect move options. Application code should pass MoveOption values directly to MoveToFolder.
func IsDuplicateEntry ¶
func IsFolderMismatch ¶ added in v0.6.0
func IsInvalidID ¶
func IsNotConnected ¶
func IsNotFound ¶
func IsReservedFolder ¶
IsReservedFolder returns true if the folder ID is a reserved system folder.
func IsSentByOwner ¶
IsSentByOwner returns true if the message was sent by its owner. ownerID == senderID means "sent", otherwise "received".
func IsValidFolderID ¶
IsValidFolderID validates a folder ID. Returns true if the folder ID is either: - A valid reserved folder (starts with "__" and is in the known set) - A valid user-defined folder (non-empty, doesn't start with "__")
func MessageFieldKey ¶
MessageFieldKey maps field names to storage keys.
func MessageOrderingKey ¶
MessageOrderingKey returns the storage key for sorting.
Types ¶
type Attachment ¶
type Attachment interface {
GetID() string
GetFilename() string
GetContentType() string
GetSize() int64
GetURI() string
GetCreatedAt() time.Time
}
Attachment is the interface for attachment data.
type AttachmentCreate ¶ added in v0.3.0
type AttachmentCreate struct {
Filename string
ContentType string
Size int64
URI string
Hash string
}
AttachmentCreate holds data for creating new attachment metadata.
type AttachmentFileStore ¶
type AttachmentFileStore interface {
// Upload stores content and returns a URI for later retrieval.
Upload(ctx context.Context, filename, contentType string, content io.Reader) (uri string, err error)
// Load returns a reader for the attachment content.
// Caller is responsible for closing the reader.
Load(ctx context.Context, uri string) (io.ReadCloser, error)
// Delete removes the attachment file from storage.
Delete(ctx context.Context, uri string) error
}
AttachmentFileStore handles the actual file storage operations. Implementations can support S3, GCS, local filesystem, GridFS, etc.
type AttachmentManager ¶
type AttachmentManager interface {
// Upload uploads a file and creates metadata with the given hash.
// If an attachment with the same hash exists, returns existing metadata
// without uploading (deduplication).
Upload(ctx context.Context, filename, contentType, hash string, content io.Reader) (AttachmentMetadata, error)
// Load returns a reader for the attachment content.
Load(ctx context.Context, id string) (io.ReadCloser, error)
// GetMetadata retrieves attachment metadata by ID.
// Returns ErrNotFound if the attachment doesn't exist.
GetMetadata(ctx context.Context, id string) (AttachmentMetadata, error)
// AddRef increments the reference count for an attachment.
// Called when a message with this attachment is created.
AddRef(ctx context.Context, id string) error
// RemoveRef decrements the reference count for an attachment.
// If the count reaches 0, the file and metadata are deleted.
// Called when a message is permanently deleted.
RemoveRef(ctx context.Context, id string) error
}
AttachmentManager combines metadata and file storage with reference-counted deletion. This is the recommended way to manage attachments.
type AttachmentMetadata ¶
type AttachmentMetadata interface {
Attachment
// GetHash returns the content hash for deduplication.
GetHash() string
// GetRefCount returns the number of messages referencing this attachment.
GetRefCount() int
}
AttachmentMetadata extends Attachment with reference tracking. This is stored separately from messages to enable safe deletion.
type AttachmentMetadataStore ¶
type AttachmentMetadataStore interface {
// Create stores new attachment metadata with initial ref count of 0.
// Returns the created metadata with generated ID and timestamps.
Create(ctx context.Context, data AttachmentCreate) (AttachmentMetadata, error)
// Get retrieves attachment metadata by ID.
Get(ctx context.Context, id string) (AttachmentMetadata, error)
// GetByHash finds attachment by content hash for deduplication.
// Returns ErrNotFound if no attachment with the hash exists.
GetByHash(ctx context.Context, hash string) (AttachmentMetadata, error)
// IncrementRef atomically increments the reference count.
// Called when a message with this attachment is created.
IncrementRef(ctx context.Context, id string) error
// DecrementRefAndDeleteIfZero atomically decrements the reference count
// and deletes the metadata if the count reaches zero.
// Returns (true, uri) if deleted, (false, "") if not deleted.
// This MUST be atomic to prevent race conditions where two concurrent
// releases both see count=1 and both try to delete.
//
// For MongoDB: Use findOneAndUpdate with $inc and return the document,
// then delete in same transaction if count <= 0.
// For PostgreSQL: Use DELETE ... WHERE id = $1 AND ref_count <= 1 RETURNING uri,
// or UPDATE + DELETE in a transaction.
DecrementRefAndDeleteIfZero(ctx context.Context, id string) (deleted bool, uri string, err error)
// Delete removes attachment metadata.
// Should only be called when ref count is 0.
Delete(ctx context.Context, id string) error
}
AttachmentMetadataStore manages attachment metadata with reference counting. This enables safe deletion of attachment files when no messages reference them.
type BulkReadMarker ¶ added in v0.4.0
type BulkReadMarker interface {
// MarkAllRead marks all unread non-draft messages in a folder as read.
// Returns the number of messages that were marked as read.
MarkAllRead(ctx context.Context, ownerID string, folderID string) (int64, error)
}
BulkReadMarker is an optional interface for efficient bulk read marking. When implemented, MarkAllRead uses a single database operation instead of N individual MarkRead calls. All three built-in backends implement this.
type BulkUpdater ¶ added in v0.6.5
type BulkUpdater interface {
MarkReadByFilter(ctx context.Context, ownerID string, filters []Filter, read bool) (int64, error)
MoveByFilter(ctx context.Context, ownerID string, filters []Filter, folderID string) (int64, error)
DeleteByFilter(ctx context.Context, ownerID string, filters []Filter) (int64, error)
AddTagByFilter(ctx context.Context, ownerID string, filters []Filter, tagID string) (int64, error)
RemoveTagByFilter(ctx context.Context, ownerID string, filters []Filter, tagID string) (int64, error)
}
BulkUpdater provides filter-based bulk mutation operations. Implementations use native database bulk operations (updateMany, UPDATE WHERE) for single-round-trip efficiency. When a store implements this interface, the service layer uses it as a fast path; otherwise it falls back to paginated iteration with per-message operations.
type DraftList ¶
type DraftList struct {
Drafts []DraftMessage
Total int64
HasMore bool
NextCursor string
}
DraftList represents a paginated list of drafts.
type DraftMessage ¶
type DraftMessage interface {
MessageReader
// Write operations (fluent API)
SetSubject(subject string) DraftMessage
SetBody(body string) DraftMessage
SetRecipients(recipientIDs ...string) DraftMessage
SetHeader(key, value string) DraftMessage
SetMetadata(key string, value any) DraftMessage
AddAttachment(attachment Attachment) DraftMessage
// SetTTL sets the message time-to-live. The message will be eligible for
// automatic deletion after this duration from send time. A zero duration
// clears any previously set TTL.
SetTTL(d time.Duration) DraftMessage
// SetScheduleAt sets the time at which the message becomes visible to
// recipients. Before this time, the message is hidden from queries.
// A zero time clears any previously set schedule.
SetScheduleAt(t time.Time) DraftMessage
// SetThreadID sets the conversation thread ID this draft belongs to.
// An empty string clears any previously set thread ID.
SetThreadID(id string) DraftMessage
// SetReplyToID sets the ID of the message this draft replies to.
// An empty string clears any previously set reply target.
SetReplyToID(id string) DraftMessage
// SetExternalID sets a caller-defined external identifier for correlation
// with external systems (e.g. an SMTP Message-ID).
SetExternalID(id string) DraftMessage
}
DraftMessage is a mutable message being composed. Drafts can only be created via Store.NewDraft() and are always owned by a single user. They cannot be moved to folders, marked as read, or tagged - those operations only apply to sent messages.
type DraftStore ¶
type DraftStore interface {
// NewDraft creates a new empty draft for the given owner.
// This is the only way to create a DraftMessage.
NewDraft(ownerID string) DraftMessage
// GetDraft retrieves a draft by ID.
// Returns ErrNotFound if the draft doesn't exist.
GetDraft(ctx context.Context, id string) (DraftMessage, error)
// SaveDraft persists a draft. If the draft has no ID, a new one is assigned.
// Returns the saved draft (may have updated fields like ID, timestamps).
SaveDraft(ctx context.Context, draft DraftMessage) (DraftMessage, error)
// DeleteDraft permanently removes a draft.
// Returns ErrNotFound if the draft doesn't exist.
DeleteDraft(ctx context.Context, id string) error
// ListDrafts returns all drafts for a user.
ListDrafts(ctx context.Context, ownerID string, opts ListOptions) (*DraftList, error)
}
DraftStore provides operations for draft messages. Drafts are mutable and owned by a single user.
type EventOutboxProvider ¶ added in v0.6.7
type EventOutboxProvider interface {
// EventOutboxStore returns an event.OutboxStore backed by the same database.
// Returns nil if outbox is not enabled.
EventOutboxStore() event.OutboxStore
}
EventOutboxProvider is an optional interface for stores that can provide an event.OutboxStore for bus-level outbox integration. When implemented, the service configures the event bus with event.WithOutbox(store) so that Event.Publish() calls inside transactions are automatically routed to the outbox table.
type Filter ¶
type Filter struct {
// contains filtered or unexported fields
}
Filter represents a query filter with a field key, comparison operator, and value.
func ExternalIDIs ¶ added in v0.8.0
ExternalIDIs returns a filter for messages with a specific external identifier.
func HasAnyTag ¶
func HasAnyTag() Filter
HasAnyTag returns a filter for messages that have any tags.
func HasThread ¶
func HasThread() Filter
HasThread returns a filter for messages that belong to any thread.
func IsAvailable ¶ added in v0.6.5
func IsAvailable() Filter
IsAvailable returns a filter for messages whose available_at is at or before now. Messages with nil available_at (immediately available) are not matched by this filter alone; use it in combination with store-level availability checks.
func IsDraftFilter ¶ added in v0.6.3
IsDraftFilter returns a filter for draft/non-draft messages.
func IsReadFilter ¶
IsReadFilter returns a filter for read/unread messages.
func NewFilter ¶
NewFilter creates a filter with the given key, operator, and value. The key must be a valid message field (validated via MessageFieldKey). The operator must be one of: eq, ne, gt, gte, lt, lte, in, nin, exists, contains. Returns ErrFilterInvalid if the key or operator is invalid.
func NotDeleted ¶
func NotDeleted() Filter
NotDeleted returns a filter that excludes messages in the trash folder.
func NotExpired ¶ added in v0.6.5
func NotExpired() Filter
NotExpired returns a filter for messages whose expires_at is after now. Messages with nil expires_at (no TTL) are not matched by this filter alone; use it in combination with store-level availability checks.
func NotInFolder ¶
NotInFolder returns a filter for messages not in a specific folder.
func RecipientIs ¶
RecipientIs returns a filter for messages to a specific recipient.
func StatusIs ¶
func StatusIs(status MessageStatus) Filter
StatusIs returns a filter for messages with a specific status.
type FilterBuilder ¶ added in v0.3.0
type FilterBuilder struct {
// contains filtered or unexported fields
}
FilterBuilder builds filters for a specific message field. Use MessageFilter() to create one, then chain a comparison method:
filter, err := store.MessageFilter("CreatedAt").GreaterThan(cutoff)
func MessageFilter ¶
func MessageFilter(field string) *FilterBuilder
MessageFilter returns a filter builder for message fields.
func (*FilterBuilder) Contains ¶ added in v0.3.0
func (b *FilterBuilder) Contains(v any) (Filter, error)
func (*FilterBuilder) Exists ¶ added in v0.3.0
func (b *FilterBuilder) Exists(v bool) (Filter, error)
func (*FilterBuilder) GreaterThan ¶ added in v0.3.0
func (b *FilterBuilder) GreaterThan(v any) (Filter, error)
func (*FilterBuilder) GreaterThanEqual ¶ added in v0.3.0
func (b *FilterBuilder) GreaterThanEqual(v any) (Filter, error)
func (*FilterBuilder) LessThan ¶ added in v0.3.0
func (b *FilterBuilder) LessThan(v any) (Filter, error)
func (*FilterBuilder) LessThanEqual ¶ added in v0.3.0
func (b *FilterBuilder) LessThanEqual(v any) (Filter, error)
type FilterError ¶
FilterError represents an error in filter building.
func (*FilterError) Error ¶
func (e *FilterError) Error() string
func (*FilterError) Unwrap ¶
func (e *FilterError) Unwrap() error
type FindWithCounter ¶
type FindWithCounter interface {
// FindWithCount retrieves messages matching the filters and returns
// both the messages and the total count in a single operation.
FindWithCount(ctx context.Context, filters []Filter, opts ListOptions) (*MessageList, int64, error)
}
FindWithCounter is an optional interface that Store implementations can implement to return messages and total count in a single query. When implemented, list operations avoid a separate Count round-trip.
type FolderCounter ¶
type FolderCounter interface {
// CountByFolders returns message counts and unread counts for the given folders.
// The returned map is keyed by folder ID. Missing keys indicate zero counts.
CountByFolders(ctx context.Context, ownerID string, folderIDs []string) (map[string]FolderCounts, error)
}
FolderCounter is an optional interface that Store implementations can implement to provide optimized batch folder counting. When implemented, ListFolders uses a single query instead of N separate Count calls (one per folder).
type FolderCounts ¶
FolderCounts holds the message and unread counts for a folder.
type FolderLister ¶
type FolderLister interface {
// ListDistinctFolders returns all distinct folder IDs for a user's non-deleted messages.
// This is used to discover custom folders that are not system folders.
ListDistinctFolders(ctx context.Context, ownerID string) ([]string, error)
}
FolderLister is an optional interface that Store implementations can implement to discover custom (non-system) folders for a user. When implemented, ListFolders includes custom folders alongside system folders.
type IdempotentCreateEntry ¶ added in v0.6.5
type IdempotentCreateEntry struct {
Data MessageData
IdempotencyKey string
}
IdempotentCreateEntry pairs a MessageData with an idempotency key for batch idempotent creation.
type IdempotentCreateResult ¶ added in v0.6.5
type IdempotentCreateResult struct {
Message Message
Created bool // true if newly created, false if existing
Err error // non-nil if this entry failed
}
IdempotentCreateResult contains the result of a single idempotent create.
type ListOptions ¶
type ListOptions struct {
Limit int
Offset int
SortBy string
SortOrder SortOrder
StartAfter string // cursor-based pagination
}
ListOptions configures message listing.
type MailboxStats ¶ added in v0.3.0
type MailboxStats struct {
// TotalMessages is the total number of non-draft messages.
TotalMessages int64
// UnreadCount is the total number of unread non-draft messages.
UnreadCount int64
// DraftCount is the total number of drafts.
DraftCount int64
// Folders contains per-folder message counts (non-draft messages only).
// Keys are folder IDs (e.g., "__inbox", "__sent").
Folders map[string]FolderCounts
}
MailboxStats holds aggregate statistics for a user's mailbox.
func (*MailboxStats) Clone ¶ added in v0.3.0
func (s *MailboxStats) Clone() *MailboxStats
Clone returns a deep copy of the stats.
type MaintenanceStore ¶
type MaintenanceStore interface {
// DeleteExpiredTrash atomically deletes all messages in trash older than cutoff.
//
// This operation is safe to call concurrently from multiple instances.
// The database handles atomicity - if two instances call this simultaneously,
// each message is deleted exactly once (one instance succeeds, the other
// finds no matching documents).
//
// Implementation should use atomic bulk delete:
// - MongoDB: deleteMany({ folder: "__trash", trashedAt: { $lt: cutoff } })
// - PostgreSQL: DELETE FROM messages WHERE folder = '__trash' AND trashed_at < $1
//
// Returns the number of messages deleted and any error encountered.
DeleteExpiredTrash(ctx context.Context, cutoff time.Time) (int64, error)
// DeleteExpiredMessages atomically deletes all non-draft messages
// whose created_at is older than cutoff, regardless of folder.
//
// This operation is safe to call concurrently from multiple instances.
// The database handles atomicity.
//
// Returns the number of messages deleted and any error encountered.
DeleteExpiredMessages(ctx context.Context, cutoff time.Time) (int64, error)
// DeleteMessagesByIDs deletes the specified messages and returns the IDs
// that were actually deleted. In a multi-instance environment, only the
// instance that wins the delete for each message will see that ID in the
// returned slice. This enables safe attachment ref release: only the winner
// releases refs, preventing double-decrements.
//
// Messages that do not exist or were already deleted are silently skipped.
DeleteMessagesByIDs(ctx context.Context, ids []string) ([]string, error)
// DeleteTTLExpiredMessages atomically deletes all non-draft messages
// whose expires_at is non-null and before the given time.
//
// This handles per-message TTL cleanup, as opposed to DeleteExpiredMessages
// which handles global retention based on created_at.
//
// Returns the number of messages deleted and any error encountered.
DeleteTTLExpiredMessages(ctx context.Context, now time.Time) (int64, error)
}
MaintenanceStore provides operations for background maintenance tasks. These operations are designed to be safely called concurrently from multiple service instances without requiring distributed coordination.
type Message ¶
type Message interface {
MessageReader
GetStatus() MessageStatus
GetIsRead() bool
GetReadAt() *time.Time
GetFolderID() string
GetTags() []string
}
Message is a read-only view of a sent or received message. Messages cannot be directly modified - use specific Store operations like MarkRead, MoveToFolder, AddTag, etc.
type MessageData ¶
type MessageData struct {
OwnerID string
SenderID string
RecipientIDs []string
Subject string
Body string
Headers map[string]string
Metadata map[string]any
Status MessageStatus
FolderID string
Attachments []Attachment
Tags []string
ThreadID string
ReplyToID string
ExternalID string // caller-defined external identifier (e.g. SMTP Message-ID); indexed
// ExpiresAt is the UTC time after which this message is eligible for
// automatic deletion via DeleteTTLExpiredMessages. Nil means no expiry.
ExpiresAt *time.Time
// AvailableAt is the UTC time before which this message is hidden from
// queries. Nil means immediately available.
AvailableAt *time.Time
}
MessageData contains data for creating a new message. Used internally when sending a draft to create message copies.
type MessageList ¶
MessageList represents a paginated list of messages.
type MessageReader ¶
type MessageReader interface {
GetID() string
GetOwnerID() string
GetSenderID() string
GetSubject() string
GetBody() string
GetRecipientIDs() []string
GetHeaders() map[string]string
GetMetadata() map[string]any
GetAttachments() []Attachment
GetCreatedAt() time.Time
GetUpdatedAt() time.Time
// GetExpiresAt returns the UTC time after which this message is eligible
// for automatic deletion. Returns nil if the message has no TTL.
GetExpiresAt() *time.Time
// GetAvailableAt returns the UTC time before which this message is hidden
// from queries. Returns nil if the message is immediately available.
GetAvailableAt() *time.Time
// GetThreadID returns the conversation thread ID, or "" if none.
GetThreadID() string
// GetReplyToID returns the ID of the message this one replies to, or "".
GetReplyToID() string
// GetExternalID returns the caller-defined external identifier, or "".
GetExternalID() string
}
MessageReader provides read access to the fields shared by both sent messages and drafts: identity, content, and timestamps.
type MessageStatus ¶
type MessageStatus string
MessageStatus represents the status of a message.
const ( MessageStatusDraft MessageStatus = "draft" MessageStatusQueued MessageStatus = "queued" MessageStatusSent MessageStatus = "sent" MessageStatusDelivered MessageStatus = "delivered" MessageStatusFailed MessageStatus = "failed" )
Message status constants.
type MessageStore ¶
type MessageStore interface {
MessageStoreReader
MessageStoreMutator
MessageStoreCreator
}
MessageStore provides operations for sent/received messages. Messages are read-only - modifications are done via specific operations.
Composed of:
- MessageStoreReader: Read operations (Get, Find, Count, Search)
- MessageStoreMutator: Mutation operations (MarkRead, MoveToFolder, tags, delete)
- MessageStoreCreator: Creation operations (CreateMessage, CreateMessageIdempotent)
Concurrency: All operations are safe for concurrent use and rely on database-level atomicity. No external locking is required or desired.
type MessageStoreCreator ¶
type MessageStoreCreator interface {
// CreateMessage creates a new message from the given data.
// Used internally when sending a draft to create sender/recipient copies.
CreateMessage(ctx context.Context, data MessageData) (Message, error)
// CreateMessageIdempotent atomically creates a message or returns existing.
//
// This operation MUST be atomic at the database level using mechanisms like:
// - MongoDB: findOneAndUpdate with upsert
// - PostgreSQL: INSERT ... ON CONFLICT DO NOTHING RETURNING ...
//
// The idempotency key combined with owner ID forms a unique constraint.
// If a message with the same (ownerID, idempotencyKey) exists, it is returned
// without modification and created=false.
//
// This design eliminates the need for distributed locks when handling
// duplicate requests (e.g., network retries, user double-clicks).
//
// Returns:
// - (message, true, nil): New message was created
// - (message, false, nil): Existing message was found and returned
// - (nil, false, error): Operation failed
CreateMessageIdempotent(ctx context.Context, data MessageData, idempotencyKey string) (Message, bool, error)
// CreateMessagesIdempotent creates multiple messages with idempotency keys.
//
// Each entry pairs a MessageData with an idempotency key. Messages whose
// (ownerID, idempotencyKey) already exist are returned as-is (created=false).
// New messages are inserted (created=true).
//
// Unlike CreateMessages, this is NOT necessarily atomic across the batch —
// some may succeed while others find existing entries. This matches the
// semantics needed for multi-recipient delivery with retry support.
//
// Returns a result per input entry in the same order.
CreateMessagesIdempotent(ctx context.Context, entries []IdempotentCreateEntry) ([]IdempotentCreateResult, error)
// CreateMessages creates multiple messages atomically in a single transaction.
//
// This operation MUST be atomic - either all messages are created or none are.
// Implementations should use:
// - MongoDB: insertMany with ordered=true in a session/transaction
// - PostgreSQL: Single INSERT with multiple VALUES in a transaction
//
// This atomicity guarantee eliminates the need for distributed locks when
// sending to multiple recipients. If the operation fails, callers know
// that no partial state exists - they can safely retry the entire batch.
//
// Returns:
// - (messages, nil): All messages created successfully
// - (nil, error): Operation failed, no messages were created
CreateMessages(ctx context.Context, data []MessageData) ([]Message, error)
}
MessageStoreCreator provides message creation operations.
Concurrency: All operations are safe for concurrent use and rely on database-level atomicity. No external locking is required or desired.
type MessageStoreMutator ¶
type MessageStoreMutator interface {
// MarkRead sets the read status of a message.
MarkRead(ctx context.Context, id string, read bool) error
// MoveToFolder moves a message to a different folder.
//
// When called without options, the move is unconditional (existing behavior).
// When called with FromFolder, the move is conditional: it succeeds only if
// the message is currently in the specified source folder. If the message
// exists but is in a different folder, ErrFolderMismatch is returned.
MoveToFolder(ctx context.Context, id string, folderID string, opts ...MoveOption) error
// AddTag adds a tag to a message.
AddTag(ctx context.Context, id string, tagID string) error
// RemoveTag removes a tag from a message.
RemoveTag(ctx context.Context, id string, tagID string) error
// Delete soft-deletes a message (moves to trash).
Delete(ctx context.Context, id string) error
// HardDelete permanently removes a message.
HardDelete(ctx context.Context, id string) error
// Restore restores a soft-deleted message from trash.
Restore(ctx context.Context, id string) error
}
MessageStoreMutator provides mutation operations for messages. Mutations are specific operations, not general setters.
type MessageStoreReader ¶
type MessageStoreReader interface {
// Get retrieves a message by ID.
// Returns ErrNotFound if the message doesn't exist.
Get(ctx context.Context, id string) (Message, error)
// Find retrieves messages matching the filters.
Find(ctx context.Context, filters []Filter, opts ListOptions) (*MessageList, error)
// Count returns the count of messages matching the filters.
Count(ctx context.Context, filters []Filter) (int64, error)
// Search performs full-text search on messages.
Search(ctx context.Context, query SearchQuery) (*MessageList, error)
}
MessageStoreReader provides read operations for messages.
type MoveOption ¶ added in v0.6.0
type MoveOption func(*moveOptions)
MoveOption configures the behavior of MoveToFolder.
func FromFolder ¶ added in v0.6.0
func FromFolder(folderID string) MoveOption
FromFolder constrains MoveToFolder to only move the message if it is currently in fromFolderID. If the message exists but is in a different folder, the store returns ErrFolderMismatch instead of moving it.
This turns MoveToFolder into an atomic compare-and-swap on the folder field, which callers can use as a claim primitive: attempt the move, check the error.
type OutboxPersister ¶ added in v0.6.5
type OutboxPersister interface {
// OutboxEnabled returns whether the outbox is configured.
OutboxEnabled() bool
// WithOutboxCtx wraps fn in a database transaction. The context passed
// to fn has both the store's transaction and event.WithOutboxTx set,
// so store methods use the same tx and Event.Publish() routes to outbox.
// If outbox is disabled, calls fn directly with zero overhead.
WithOutboxCtx(ctx context.Context, fn func(ctx context.Context) error) error
}
OutboxPersister is an optional interface for stores that support wrapping mutations in a database transaction with outbox context.
When outbox is enabled, the service layer calls WithOutboxCtx to wrap mutation + event publish in a single database transaction. The store sets event.WithOutboxTx on the context so the event bus automatically routes Event.Publish() calls to the outbox table within the same transaction.
The background relay (from the event library) reads from the outbox and publishes to the event transport.
type SearchQuery ¶
type SearchQuery struct {
OwnerID string // required: owner of the messages
Query string // text search query
Fields []string // fields to search in (subject, body)
Tags []string // filter by tags (messages must have all specified tags)
Filters []Filter // additional filters
Options ListOptions // pagination and sorting
}
SearchQuery represents a search request.
type StatsStore ¶ added in v0.3.0
type StatsStore interface {
// MailboxStats returns aggregate statistics for a user's mailbox.
// This should be implemented as a single efficient query (e.g., MongoDB $facet,
// PostgreSQL conditional aggregation) rather than multiple round-trips.
MailboxStats(ctx context.Context, ownerID string) (*MailboxStats, error)
}
StatsStore provides aggregate mailbox statistics.
type Store ¶
type Store interface {
// Lifecycle
Connect(ctx context.Context) error
Close(ctx context.Context) error
// Draft operations - drafts are mutable messages being composed
DraftStore
// Message operations - messages are read-only sent/received items
MessageStore
// Maintenance operations - for background cleanup tasks
MaintenanceStore
// Stats operations - aggregate mailbox statistics
StatsStore
}
Store is the storage interface for the mailbox. It provides separate operations for drafts (mutable) and messages (read-only).
All operations must be safe for concurrent use. Implementations must use database-level atomicity (transactions, atomic operations) rather than external locking mechanisms. See package documentation for details.
type ThreadParticipantLister ¶ added in v0.7.9
type ThreadParticipantLister interface {
// ThreadParticipants returns the distinct owner IDs of all non-deleted,
// non-draft messages with the given thread_id. The result is unordered.
// Returns ErrNotFound when no messages exist for the given thread_id.
ThreadParticipants(ctx context.Context, threadID string) ([]string, error)
}
ThreadParticipantLister is an optional interface for cross-owner thread queries. When implemented, Service.ThreadParticipants delegates to this single-query path. All three built-in backends implement this.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
attachment
|
|
|
azblob
Package azblob provides an Azure Blob Storage-based attachment file store.
|
Package azblob provides an Azure Blob Storage-based attachment file store. |
|
cached
Package cached provides a file-based caching wrapper for attachment stores.
|
Package cached provides a file-based caching wrapper for attachment stores. |
|
gcs
Package gcs provides a Google Cloud Storage-based attachment file store.
|
Package gcs provides a Google Cloud Storage-based attachment file store. |
|
local
Package local provides a local filesystem-based attachment file store.
|
Package local provides a local filesystem-based attachment file store. |
|
otel
Package otel provides OpenTelemetry instrumentation for attachment stores.
|
Package otel provides OpenTelemetry instrumentation for attachment stores. |
|
s3
Package s3 provides an S3-based attachment file store.
|
Package s3 provides an S3-based attachment file store. |
|
Package memory provides an in-memory Store implementation for testing.
|
Package memory provides an in-memory Store implementation for testing. |
|
Package mongo provides a MongoDB implementation of store.Store.
|
Package mongo provides a MongoDB implementation of store.Store. |
|
Package postgres provides a PostgreSQL implementation of store.Store.
|
Package postgres provides a PostgreSQL implementation of store.Store. |
|
Package storetest provides a reusable, backend-agnostic conformance suite for store.Store implementations.
|
Package storetest provides a reusable, backend-agnostic conformance suite for store.Store implementations. |