mailbox

package module
v0.10.1 Latest Latest
Warning

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

Go to latest
Published: Jun 24, 2026 License: MIT Imports: 27 Imported by: 0

README

Mailbox

CI Go Reference Go Report Card Release OpenSSF Scorecard

A Go library for persistent, addressable messaging between users or services. Every message has an owner, a lifecycle, and mutable state — making it suitable for user-to-user communication, service-to-service coordination, and async job delivery where sender and receiver don't need to be online at the same time.

Why Mailbox

Most messaging infrastructure (Kafka, Redis Streams, NATS) treats messages as immutable records in a stream — consumed, acknowledged, and forgotten. Mailbox treats messages as documents in an addressable namespace: they persist until explicitly removed, carry mutable state (read/unread, folders, tags), and support rich queries. This makes it the right fit when:

  • Messages are addressed to a specific entity (user, service, worker) rather than broadcast to a topic
  • Recipients need to query, filter, and manage their messages — not just consume a stream
  • Sender and receiver may not exist at the same time — messages wait in the recipient's mailbox
  • Each message has a lifecycle beyond produce/consume — drafts, delivery, read receipts, archival, deletion
  • Independent per-recipient state matters — one recipient deletes their copy while others keep theirs

Use Cases

User-to-user messaging — in-app messaging, support tickets, notifications with read tracking, threaded conversations, folder organization.

Async service coordination — Service A sends a task to Service B's mailbox. Service B reads it when it starts, processes it, marks it read. If Service B crashes and restarts, unread messages are still in its inbox. Results go back to Service A's mailbox. Metadata carries structured payloads; tags categorize work; folders separate priorities.

Job delivery with persistence — A job producer fans out work to worker mailboxes. Workers query their inbox for unprocessed (unread) jobs, process them, and mark them done. Failed jobs stay in the inbox. No message is lost because delivery is idempotent and storage is durable.

Audit-friendly communication — Messages persist with user-controlled retention. Soft delete allows recovery. Every state change (send, read, delete) can publish events for downstream logging.

Features

  • Persistent Addressable Messaging - Messages belong to owners, persist until removed
  • Idempotent Delivery - Per-recipient deduplication with safe retry after partial failure
  • Mutable Message State - Read/unread, folders, tags, metadata on every message
  • First-Class Headers - Protocol-level map[string]string headers (Content-Type, Priority, Correlation-ID) separate from application metadata
  • Draft Composition - Fluent API for composing messages
  • Thread Support - Conversation threading with replies
  • Fan-Out with Independent State - Each recipient gets their own copy to manage
  • Full-Text Search - Native FTS via Postgres tsvector / MongoDB text index (opt-in)
  • Stats with Event-Driven Cache - Per-user aggregate counts with incremental updates; optional Redis L2 cache
  • File Attachments - S3, GCS, Azure Blob, and local filesystem backends (details)
  • Real-Time Events - Publish message lifecycle events to Redis Streams, NATS, Kafka, or any transport (details)
  • Multiple Backends - MongoDB, PostgreSQL, in-memory storage
  • CEL Rules Engine - Declarative per-user rules with custom functions and webhook/forward actions
  • Webhook Notifications - Signed HTTP delivery with exponential backoff via notify/webhook
  • Plugin System - Extensible hooks for send-time validation and filtering (details)
  • OpenTelemetry - Built-in tracing and metrics (details)
  • Soft Delete - Trash with restore and configurable retention cleanup

Installation

go get github.com/rbaliyan/mailbox

Quick Start

package main

import (
    "context"
    "log"

    "github.com/rbaliyan/mailbox"
    "github.com/rbaliyan/mailbox/store/memory"
)

func main() {
    ctx := context.Background()

    // Create in-memory store
    store := memory.New()

    // Create service
    svc, err := mailbox.New(mailbox.Config{},
        mailbox.WithStore(store),
    )
    if err != nil {
        log.Fatal(err)
    }

    // Connect to initialize indexes and start background tasks
    if err := svc.Connect(ctx); err != nil {
        log.Fatal(err)
    }
    defer svc.Close(ctx)

    // Get a mailbox client for a user
    mb := svc.Client("user123")

    // Create and send a message
    draft, err := mb.Compose()
    if err != nil {
        log.Fatal(err)
    }
    msg, err := draft.
        SetSubject("Hello").
        SetBody("World").
        SetRecipients("user456").
        Send(ctx)
    if err != nil {
        log.Fatal(err)
    }

    log.Printf("Sent message: %s", msg.GetID())
}
Service-to-Service Example

Services use mailboxes the same way users do. A service's identity is just a string.

// Job producer: send work to the image-resizer service
producer := svc.Client("api-gateway")
_, err := producer.SendMessage(ctx, mailbox.SendRequest{
    RecipientIDs: []string{"image-resizer"},
    Subject:      "resize",
    Body:         `{"url": "s3://bucket/photo.jpg", "width": 800}`,
    Headers:      map[string]string{store.HeaderContentType: "application/json", store.HeaderPriority: "high"},
    Metadata:     map[string]any{"job_id": "j-9281"},
})

// Job consumer: process pending work (possibly on a different host, started later)
worker := svc.Client("image-resizer")
inbox, _ := worker.Folder(ctx, store.FolderInbox, store.ListOptions{})
for _, job := range inbox.All() {
    process(job.GetBody(), job.GetMetadata())
    worker.UpdateFlags(ctx, job.GetID(), mailbox.Flags{Read: boolPtr(true)})
}

Messages wait in the recipient's inbox regardless of whether the consumer is running. Restarted consumers pick up where they left off by querying for unread messages.

Storage Backends

MongoDB
import (
    "go.mongodb.org/mongo-driver/v2/mongo"
    "go.mongodb.org/mongo-driver/v2/mongo/options"
    mongostore "github.com/rbaliyan/mailbox/store/mongo"
)

// Application manages the MongoDB client
client, _ := mongo.Connect(options.Client().ApplyURI("mongodb://localhost:27017"))
defer client.Disconnect(ctx)

// Create store with the client
store := mongostore.New(client,
    mongostore.WithDatabase("myapp"),
    mongostore.WithCollection("messages"),
)

// Optional: enable native full-text search (creates a text index on connect)
store = mongostore.New(client,
    mongostore.WithDatabase("myapp"),
    mongostore.WithFTSEnabled(true),
)
PostgreSQL
import (
    "github.com/jmoiron/sqlx"
    _ "github.com/lib/pq"
    pgstore "github.com/rbaliyan/mailbox/store/postgres"
)

// Application manages the database connection (store/postgres uses *sqlx.DB)
db, _ := sqlx.Connect("postgres", "postgres://localhost/myapp?sslmode=disable")
defer db.Close()

// Create store with the connection
store := pgstore.New(db,
    pgstore.WithTable("messages"),
)

// Alternatively, wrap a plain *sql.DB:
// store := pgstore.NewFromDB(sqlDB, pgstore.WithTable("messages"))

// Optional: enable native full-text search (adds tsvector column + trigger on connect)
store = pgstore.New(db,
    pgstore.WithTable("messages"),
    pgstore.WithFTSEnabled(true),
)

Note: WithFTSEnabled(true) runs DDL on Connect() — it adds a search_vector tsvector column, a GIN index, and a trigger to keep the vector current, then backfills existing rows. This is safe to run repeatedly (all DDL is idempotent), but expect a brief delay on first Connect() for large tables.

In-Memory (for testing)
import "github.com/rbaliyan/mailbox/store/memory"

store := memory.New()

Message Headers

Messages have two separate key-value stores:

  • Headers (map[string]string) — protocol-level metadata like Content-Type, Priority, Correlation-ID. Analogous to HTTP headers.
  • Metadata (map[string]any) — application-level arbitrary data. Unchanged from previous versions.
// Via draft composition (fluent API)
draft.SetSubject("Sensor Reading").
    SetBody(jsonBytes).
    SetRecipients("analytics-svc").
    SetHeader(store.HeaderContentType, "application/json").
    SetHeader(store.HeaderSchema, "sensor.reading/v1").
    SetHeader(store.HeaderPriority, "high")

Well-known header constants are defined in the store package: HeaderContentType, HeaderContentLength (auto-populated on send), HeaderSchema, HeaderPriority, HeaderCorrelationID, HeaderExpires, HeaderReplyToAddress, HeaderCustomID.

The content sub-package provides codec support for structured/binary message bodies using headers:

// Encode: struct -> bytes -> text-safe body + headers
data, _ := json.Marshal(reading)
body, headers, _ := content.EncodeWithHeaders(content.JSON, data, content.WithSchema("sensor.reading/v1"))

// Decode: message -> raw bytes
raw, _ := content.Decode(msg, content.DefaultRegistry())

Thread & Conversation Support

Messages can be organized into threads for conversation tracking:

// Send initial message (creates new thread)
draft, _ := mb.Compose()
msg, _ := draft.
    SetSubject("Project Discussion").
    SetBody("Let's discuss the roadmap.").
    SetRecipients("user456").
    Send(ctx)

// Reply to the message (joins the thread)
replyDraft, _ := mb.Compose()
replyDraft.SetSubject("Re: Project Discussion").
    SetBody("Sounds good, I have some ideas.").
    SetRecipients("user123")
replyDraft.ReplyTo(ctx, msg.GetID()) // separate call — returns error
reply, _ := replyDraft.Send(ctx)

// Get all messages in a thread
thread, _ := mb.GetThread(ctx, msg.GetThreadID(), store.ListOptions{})
for _, m := range thread.All() {
    fmt.Printf("%s: %s\n", m.GetSenderID(), m.GetSubject())
}

// Get direct replies to a message
replies, _ := mb.GetReplies(ctx, msg.GetID(), store.ListOptions{})

Bulk Operations

Perform operations on multiple messages at once. Each result, _ := ... line below is an independent example — pick one operation per call site rather than pasting the whole block verbatim (they reuse the same variable name):

// Get inbox messages
inbox, _ := mb.Folder(ctx, store.FolderInbox, store.ListOptions{Limit: 50})

// Mark all as read
result, _ := inbox.MarkRead(ctx)
if result.HasFailures() {
    log.Printf("Failed to mark %d messages", len(result.FailedIDs()))
}

// Move all to a folder
result, _ := inbox.Move(ctx, "important")

// Delete all
result, _ := inbox.Delete(ctx)

// Archive all (move to archive folder)
result, _ := inbox.Move(ctx, store.FolderArchived)

// Add tag to all
result, _ := inbox.AddTag(ctx, "processed")

Draft bulk operations:

// Get all drafts
drafts, _ := mb.Drafts(ctx, store.ListOptions{})

// Send all drafts
sendResult, _ := drafts.Send(ctx)
for _, msg := range sendResult.SentMessages() {
    log.Printf("Sent: %s", msg.GetID())
}

// Delete all drafts
result, _ := drafts.Delete(ctx)

Graceful Shutdown

svc, _ := mailbox.New(mailbox.Config{
    TrashCleanupInterval: time.Hour, // automatic background cleanup
},
    mailbox.WithStore(store),
)

// ... use the service ...

// Close waits for in-flight operations and background goroutines to finish
if err := svc.Close(ctx); err != nil {
    log.Printf("Shutdown error: %v", err)
}

Shutdown behavior:

  • Waits for in-flight send operations to complete
  • Closes event subscriptions
  • Closes store connections
Trash Cleanup

Config.TrashCleanupInterval starts automatic background cleanup. Alternatively, call it manually from your own scheduler:

svc, _ := mailbox.New(mailbox.Config{
    TrashCleanupInterval: time.Hour, // automatic — or leave zero and call manually
},
    mailbox.WithStore(store),
)

// Manual call (e.g., on demand or in tests):
result, err := svc.CleanupTrash(ctx)
if err != nil {
    log.Printf("Cleanup error: %v", err)
} else {
    log.Printf("Deleted %d expired messages", result.DeletedCount)
}

Distributed Stats Cache (Redis)

For multi-instance deployments, wire a Redis-backed L2 stats cache to avoid repeated database reads:

import (
    "github.com/redis/go-redis/v9"
    statscache "github.com/rbaliyan/mailbox/statscache/redis"
)

rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
cache := statscache.New(rdb,
    statscache.WithTTL(5 * time.Minute),
    statscache.WithPrefix("myapp:stats"),
)

svc, _ := mailbox.New(mailbox.Config{},
    mailbox.WithStore(store),
    mailbox.WithStatsCache(cache),
)

Stats are served from the in-process sync.Map (L1) first, then from Redis (L2), and only fall through to the database on a cold miss. Event-driven increments propagate to Redis atomically via HINCRBY.

CEL Rules Engine

The rules package provides a declarative rules engine backed by Common Expression Language:

import "github.com/rbaliyan/mailbox/rules"

provider := rules.NewStaticProvider(
    []rules.Rule{
        {
            ID:        "vip-tag",
            Scope:     rules.ScopeReceive,
            Condition: `contains_tag(tags, "vip") || sender == "admin"`,
            Actions:   []rules.Action{{Type: rules.ActionSetFolder, Value: "priority"}},
            Priority:  10,
        },
        {
            ID:        "webhook-on-send",
            Scope:     rules.ScopeSend,
            Condition: `has_header(headers, "X-Alert")`,
            Actions:   []rules.Action{{Type: rules.ActionWebhook, Value: "https://hooks.example.com/alert"}},
        },
    },
    nil, // per-user rules
)

engine, _ := rules.NewEngine(provider, store)
svc, _ := mailbox.New(mailbox.Config{},
    mailbox.WithStore(store),
    mailbox.WithPlugin(engine),
)
svc.Connect(ctx)

// Wire receive-side rules via the event bus
svc.Events().MessageReceived.Subscribe(ctx,
    func(ctx context.Context, _ event.Event[mailbox.MessageReceivedEvent], data mailbox.MessageReceivedEvent) error {
        return engine.OnMessageReceived(ctx, nil, rules.MessageReceivedData{
            MessageID:   data.MessageID,
            RecipientID: data.RecipientID,
        })
    },
    event.AsWorker("rules-engine"),
)

Available CEL variables: sender, subject, body, recipients, headers, metadata, has_attachments, attachment_count, thread_id, is_reply, folder, tags.

Custom functions: matches_regex, has_header, header_value, has_metadata, metadata_value, contains_tag.

Webhook Notification Router

The notify/webhook package delivers notification events to HTTP endpoints with HMAC-SHA256 signing:

import "github.com/rbaliyan/mailbox/notify/webhook"

router := webhook.New(
    webhook.WithEndpoints(
        webhook.EndpointConfig{
            URL:    "https://api.example.com/webhooks/mailbox",
            Events: []string{"message.received", "message.read"},
        },
    ),
    webhook.WithSigningKey([]byte("my-secret")),
)

// On the receiving end, verify the signature:
ok := webhook.VerifySignature(
    []byte("my-secret"),
    r.Header.Get("X-Mailbox-Timestamp"),
    body,
    r.Header.Get("X-Mailbox-Signature"),
    5*time.Minute, // timestamp tolerance (0 skips the freshness check)
)

API Reference

Mailbox Operations
Method Description
Compose() Create a new draft message (returns Draft, error)
SendMessage(ctx, req) Send a message directly without drafts
Get(ctx, id) Get a message by ID
Folder(ctx, folderID, opts) List messages in any folder
Drafts(ctx, opts) List draft messages
Search(ctx, query) Full-text search
Stream(ctx, filters, opts) Iterator-based streaming with filters
GetThread(ctx, threadID, opts) Get thread messages
GetReplies(ctx, messageID, opts) Get message replies
ListFolders(ctx) List all folders with counts
Stats(ctx) Aggregate statistics (total, unread, per-folder)
UnreadCount(ctx) Unread message count
LoadAttachment(ctx, msgID, attID) Download attachment
Message Operations
Method Description
msg.Update(ctx, flags) Update message flags
msg.Move(ctx, folderID) Move to folder
msg.Delete(ctx) Move to trash
msg.Restore(ctx) Restore from trash
msg.PermanentlyDelete(ctx) Permanently delete
msg.AddTag(ctx, tagID) Add tag
msg.RemoveTag(ctx, tagID) Remove tag

Read-only accessors carried from the storage layer include msg.GetExternalID() (the caller-defined correlation ID set via SetExternalID), msg.GetThreadID(), and msg.GetReplyToID().

Draft Builder
draft, _ := mb.Compose()

// Fluent setters (chainable)
draft.SetSubject("Meeting Tomorrow").
    SetBody("Let's discuss the project.").
    SetRecipients("user456", "user789").
    SetExternalID("order-1234").               // caller-defined ID for correlation
    SetDeliverTo("user456").                    // optional: only deliver locally to a subset of recipients
    SetHeader(store.HeaderPriority, "high").
    SetMetadata("category", "meetings").
    SetTTL(7 * 24 * time.Hour)                  // expires in 7 days
    // SetScheduleAt(time.Now().Add(time.Hour))  // optional: deliver in 1 hour

// Failable operations (separate calls — return error)
draft.AddAttachment(attachment)
draft.ReplyTo(ctx, parentMessageID)

// Send immediately
msg, err := draft.Send(ctx)

// Or save as draft
saved, err := draft.Save(ctx)

Validation

The package exports its message-validation toolkit so applications can run the same checks the library applies internally on send. Limits are described by the MessageLimits struct; DefaultLimits() returns the built-in defaults, and the Config Max* fields (e.g. MaxBodySize, MaxRecipientCount) feed the same struct the service uses.

limits := mailbox.DefaultLimits()

if err := mailbox.ValidateSubjectWithLimits(subject, limits); err != nil { /* ... */ }
if err := mailbox.ValidateBodyWithLimits(body, limits); err != nil { /* ... */ }
if err := mailbox.ValidateRecipients(recipientIDs, limits); err != nil { /* ... */ }
if err := mailbox.ValidateHeaders(headers, limits); err != nil { /* ... */ }
if err := mailbox.ValidateMetadata(metadata); err != nil { /* ... */ }

Field validators come in a default-limits form (ValidateSubject, ValidateBody, ValidateMessageContent, ValidateMetadata) and a configurable *WithLimits form. Whole-object validators (ValidateMessage, ValidateDraft) check recipients, content, headers, metadata, and attachments together.

Attachment MIME types are validated via ValidateMIMEType and ValidateAttachmentsWithMIME, which accept allow/block lists (wildcards like image/* are supported). DefaultBlockedMIMETypes() returns commonly blocked executable types and SafeAttachmentMIMETypes() returns a safe allow-list starting point.

Further Reading

License

MIT License

Documentation

Overview

Package mailbox provides an email-like messaging library for Go.

It supports sending messages to recipients (identified by user IDs), marking messages as read/unread, archiving, listing, and searching. All functionality is exposed via interfaces, with pluggable storage backends (MongoDB, PostgreSQL, in-memory).

Basic Usage

// Create in-memory store for testing
store := memory.New()

// Create mailbox service with automatic background maintenance
svc, err := mailbox.New(
    mailbox.Config{
        TrashCleanupInterval:          1 * time.Hour,
        ExpiredMessageCleanupInterval: 1 * time.Hour,
    },
    mailbox.WithStore(store),
)
if err != nil {
    log.Fatal(err)
}

// Connect initializes indexes/schema and starts background tasks
if err := svc.Connect(ctx); err != nil {
    log.Fatal(err)
}
defer svc.Close(ctx) // stops background goroutines and waits

// Get a mailbox client for a user
mb := svc.Client("user123")

// Send a message
draft, _ := mb.Compose()
msg, err := draft.
    SetSubject("Hello").
    SetBody("World").
    SetRecipients("user456").
    Send(ctx)

Mailbox Operations

  • Compose: Create and send messages
  • Get: Retrieve a message by ID
  • Folder: List messages in any folder, using the store.FolderInbox, store.FolderSent, store.FolderArchived, and store.FolderTrash constants
  • Search: Full-text search
  • Stream: Iterator-based streaming with filters

Storage Backends

The store package provides implementations for:

  • MongoDB (store/mongo) - accepts *mongo.Client
  • PostgreSQL (store/postgres) - accepts *sqlx.DB (or *sql.DB via NewFromDB)
  • In-memory (store/memory) - for testing

Events

Mailbox provides typed events for message lifecycle notifications. Events use the github.com/rbaliyan/event/v3 library which supports multiple transports (Redis Streams, NATS, Kafka, in-memory channel).

To enable events, pass WithRedisClient or WithEventTransport when creating the service:

svc, err := mailbox.New(mailbox.Config{},
    mailbox.WithStore(store),
    mailbox.WithRedisClient(redisClient),
)

Events are automatically registered during Connect(). Access per-service events via the Events() method:

events := svc.Events()
events.MessageSent.Subscribe(ctx, handler)
events.MessageRead.Subscribe(ctx, handler)
events.MessageDeleted.Subscribe(ctx, handler)

Available events:

  • MessageSent - when a message is sent
  • MessageReceived - when a message is delivered to a recipient
  • MessageRead - when a message is marked as read
  • MessageDeleted - when a message is permanently deleted
  • MessageMoved - when a message is moved between folders
  • MarkAllRead - when all messages in a folder are marked as read

Threads

Every sent message is automatically assigned a thread ID. When no ThreadID is provided, one is generated as follows:

  1. If ReplyToID is set and the sender owns the referenced message, the thread ID is inherited from that message (or the message ID itself is used as the thread root for the first reply to a threadless message).
  2. Otherwise a new UUID is generated, starting a fresh conversation.

Access per-user thread views via the Mailbox interface:

thread, _ := mb.GetThread(ctx, threadID, store.ListOptions{Limit: 50})
replies, _ := mb.GetReplies(ctx, messageID, store.ListOptions{Limit: 20})

For external delivery systems (e.g., SMTP gateways) that know only the thread_id and need to find all participants:

participants, err := svc.ThreadParticipants(ctx, threadID)
if err != nil { ... } // store.ErrNotFound when thread does not exist

_, err = svc.Client(senderID).SendMessage(ctx, mailbox.SendRequest{
    RecipientIDs: participants,
    ThreadID:     threadID,
    Subject:      "Re: original",
    Body:         body,
})

ThreadParticipants is a cross-owner query (not scoped to a single user) so it lives on Service rather than Mailbox.

Transactional Outbox

For guaranteed event delivery, enable the transactional outbox on your store:

store := mongostore.New(client, mongostore.WithOutbox(true))

Events are then persisted atomically with DB writes via the event library's outbox package. The store provides an event.OutboxStore to the bus, and a background relay (outbox.Relay) publishes pending events to the transport. See store.OutboxPersister and store.EventOutboxProvider for the interfaces.

Example (CleanupExpiredMessages)

Example_cleanupExpiredMessages demonstrates message retention cleanup.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/rbaliyan/mailbox"
	"github.com/rbaliyan/mailbox/store"
	"github.com/rbaliyan/mailbox/store/memory"
)

func main() {
	ctx := context.Background()
	memStore := memory.New()
	svc, _ := mailbox.New(mailbox.Config{MessageRetention: 24 * time.Hour},
		mailbox.WithStore(memStore),
	)
	svc.Connect(ctx)
	defer svc.Close(ctx)

	alice := svc.Client("alice")
	alice.SendMessage(ctx, mailbox.SendRequest{
		RecipientIDs: []string{"bob"},
		Subject:      "Old message",
		Body:         "body",
	})

	// Age messages past retention period.
	memStore.AgeMessages(48 * time.Hour)

	// Send a fresh message that should survive.
	alice.SendMessage(ctx, mailbox.SendRequest{
		RecipientIDs: []string{"bob"},
		Subject:      "Fresh message",
		Body:         "body",
	})

	result, _ := svc.CleanupExpiredMessages(ctx)
	fmt.Println("expired:", result.DeletedCount)

	bob := svc.Client("bob")
	inbox, _ := bob.Folder(ctx, store.FolderInbox, store.ListOptions{})
	fmt.Println("surviving:", len(inbox.All()))
}
Output:
expired: 2
surviving: 1
Example (CleanupTrash)

Example_cleanupTrash demonstrates scheduled trash cleanup.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/rbaliyan/mailbox"
	"github.com/rbaliyan/mailbox/store"
	"github.com/rbaliyan/mailbox/store/memory"
)

func main() {
	ctx := context.Background()
	memStore := memory.New()
	svc, _ := mailbox.New(mailbox.Config{TrashRetention: 24 * time.Hour},
		mailbox.WithStore(memStore),
	)
	svc.Connect(ctx)
	defer svc.Close(ctx)

	// Send and delete a message.
	alice := svc.Client("alice")
	alice.SendMessage(ctx, mailbox.SendRequest{
		RecipientIDs: []string{"bob"},
		Subject:      "Old message",
		Body:         "body",
	})
	bob := svc.Client("bob")
	inbox, _ := bob.Folder(ctx, store.FolderInbox, store.ListOptions{})
	bob.Delete(ctx, inbox.All()[0].GetID())

	// Age messages past retention period.
	memStore.AgeMessages(48 * time.Hour)

	// Run cleanup.
	result, _ := svc.CleanupTrash(ctx)
	fmt.Println("cleaned:", result.DeletedCount)
}
Output:
cleaned: 1
Example (EncryptedMessage)

Example_encryptedMessage demonstrates E2E encryption: send encrypted, receive and decrypt.

package main

import (
	"context"
	"fmt"

	"crypto/rand"

	"github.com/rbaliyan/mailbox"
	"github.com/rbaliyan/mailbox/compress"
	"github.com/rbaliyan/mailbox/crypto"
	"github.com/rbaliyan/mailbox/store"
	"github.com/rbaliyan/mailbox/store/memory"
	"golang.org/x/crypto/curve25519"
)

func main() {
	ctx := context.Background()

	// Generate X25519 keypairs for sender and recipient.
	keys := crypto.NewStaticKeyResolver()
	for _, user := range []string{"alice", "bob"} {
		priv := make([]byte, 32)
		rand.Read(priv)
		pub, _ := curve25519.X25519(priv, curve25519.Basepoint)
		keys.AddUser(user, pub, priv)
	}

	// Create service with compression + encryption plugins.
	svc, _ := mailbox.New(mailbox.Config{},
		mailbox.WithStore(memory.New()),
		mailbox.WithPlugins(
			compress.NewPlugin(compress.Gzip), // compress first
			crypto.NewEncryptionPlugin(keys),  // then encrypt
		),
	)
	svc.Connect(ctx)
	defer svc.Close(ctx)

	// Alice sends an encrypted message — subject stays searchable.
	alice := svc.Client("alice")
	alice.SendMessage(ctx, mailbox.SendRequest{
		RecipientIDs: []string{"bob"},
		Subject:      "Confidential report",
		Body:         "Q3 revenue: $1.2M",
		Metadata:     map[string]any{"department": "finance"},
	})

	// Bob receives and decrypts.
	bob := svc.Client("bob")
	inbox, _ := bob.Folder(ctx, store.FolderInbox, store.ListOptions{})
	msg := inbox.All()[0]

	// Subject is plaintext (searchable).
	fmt.Println("subject:", msg.GetSubject())
	fmt.Println("encrypted:", crypto.IsEncrypted(msg))
	fmt.Println("compressed:", compress.IsCompressed(msg))

	// Body is encrypted — decrypt with Open.
	plaintext, _ := crypto.Open(ctx, msg, "bob", keys)
	fmt.Println("body:", string(plaintext))

	// Application metadata is preserved.
	fmt.Println("department:", msg.GetMetadata()["department"])
}
Output:
subject: Confidential report
encrypted: true
compressed: true
body: Q3 revenue: $1.2M
department: finance
Example (EventSubscription)

Example_eventSubscription demonstrates subscribing to mailbox events.

package main

import (
	"context"
	"fmt"
	"log"
	"sort"
	"sync"
	"time"

	"github.com/rbaliyan/event/v3"
	"github.com/rbaliyan/event/v3/transport/channel"

	"github.com/rbaliyan/mailbox"
	"github.com/rbaliyan/mailbox/store/memory"
)

// newTestService creates a connected service for examples using in-memory backends.
// Channel transport enables synchronous event delivery without Redis.
func newTestService() mailbox.Service {
	svc, err := mailbox.New(mailbox.Config{},
		mailbox.WithStore(memory.New()),
		mailbox.WithEventTransport(channel.New()),
	)
	if err != nil {
		log.Fatal(err)
	}
	if err := svc.Connect(context.Background()); err != nil {
		log.Fatal(err)
	}
	return svc
}

func main() {
	ctx := context.Background()
	svc := newTestService()
	defer svc.Close(ctx)

	// Track events.
	var mu sync.Mutex
	var received []string

	svc.Events().MessageSent.Subscribe(ctx,
		func(ctx context.Context, _ event.Event[mailbox.MessageSentEvent], data mailbox.MessageSentEvent) error {
			mu.Lock()
			received = append(received, "sent:"+data.Subject)
			mu.Unlock()
			return nil
		},
	)
	svc.Events().MessageReceived.Subscribe(ctx,
		func(ctx context.Context, _ event.Event[mailbox.MessageReceivedEvent], data mailbox.MessageReceivedEvent) error {
			mu.Lock()
			received = append(received, "received:"+data.RecipientID)
			mu.Unlock()
			return nil
		},
	)

	// Send a message (events fire synchronously with channel transport).
	alice := svc.Client("alice")
	alice.SendMessage(ctx, mailbox.SendRequest{
		RecipientIDs: []string{"bob"},
		Subject:      "Hello",
		Body:         "body",
	})

	// Block until both events (sent + received) have been delivered, rather
	// than sleeping for a fixed duration. The handlers append under mu, so the
	// count is read under the same lock. A deadline bounds the wait so a
	// delivery regression fails fast instead of hanging.
	deadline := time.Now().Add(5 * time.Second)
	for {
		mu.Lock()
		done := len(received) == 2
		mu.Unlock()
		if done || time.Now().After(deadline) {
			break
		}
	}

	mu.Lock()
	sort.Strings(received)
	for _, e := range received {
		fmt.Println(e)
	}
	mu.Unlock()
}
Output:
received:bob
sent:Hello
Example (FilterBulkOps)

Example_filterBulkOps demonstrates filter-based bulk operations.

package main

import (
	"context"
	"fmt"

	"github.com/rbaliyan/mailbox"
	"github.com/rbaliyan/mailbox/store"
	"github.com/rbaliyan/mailbox/store/memory"
)

func main() {
	ctx := context.Background()
	svc, _ := mailbox.New(mailbox.Config{}, mailbox.WithStore(memory.New()))
	svc.Connect(ctx)
	defer svc.Close(ctx)

	// Send 5 messages to bob.
	for i := 0; i < 5; i++ {
		svc.Client("alice").SendMessage(ctx, mailbox.SendRequest{
			RecipientIDs: []string{"bob"},
			Subject:      fmt.Sprintf("Msg %d", i),
			Body:         "body",
		})
	}

	bob := svc.Client("bob")

	// Mark all unread in inbox as read using filter.
	count, _ := bob.UpdateByFilter(ctx, []store.Filter{
		store.InFolder(store.FolderInbox),
		store.IsReadFilter(false),
	}, mailbox.MarkRead())
	fmt.Println("marked read:", count)

	// Move all from alice to archive.
	moved, _ := bob.MoveByFilter(ctx, []store.Filter{
		store.SenderIs("alice"),
	}, store.FolderArchived)
	fmt.Println("archived:", moved)
}
Output:
marked read: 5
archived: 5
Example (Filters)

Example_filters demonstrates type-safe message filters.

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/rbaliyan/event/v3/transport/channel"

	"github.com/rbaliyan/mailbox"
	"github.com/rbaliyan/mailbox/store"
	"github.com/rbaliyan/mailbox/store/memory"
)

// newTestService creates a connected service for examples using in-memory backends.
// Channel transport enables synchronous event delivery without Redis.
func newTestService() mailbox.Service {
	svc, err := mailbox.New(mailbox.Config{},
		mailbox.WithStore(memory.New()),
		mailbox.WithEventTransport(channel.New()),
	)
	if err != nil {
		log.Fatal(err)
	}
	if err := svc.Connect(context.Background()); err != nil {
		log.Fatal(err)
	}
	return svc
}

func main() {
	ctx := context.Background()
	svc := newTestService()
	defer svc.Close(ctx)

	alice := svc.Client("alice")
	for i := 1; i <= 5; i++ {
		alice.SendMessage(ctx, mailbox.SendRequest{
			RecipientIDs: []string{"bob"},
			Subject:      fmt.Sprintf("Msg %d", i),
			Body:         "body",
		})
	}

	bob := svc.Client("bob")

	// Mark first 2 messages as read.
	inbox, _ := bob.Folder(ctx, store.FolderInbox, store.ListOptions{})
	for i, m := range inbox.All() {
		if i < 2 {
			bob.UpdateFlags(ctx, m.GetID(), mailbox.MarkRead())
		}
	}

	// Stream only unread messages using filters.
	iter, _ := bob.Stream(ctx, []store.Filter{
		store.InFolder(store.FolderInbox),
		store.IsReadFilter(false),
	}, mailbox.StreamOptions{})

	var count int
	for {
		ok, _ := iter.Next(ctx)
		if !ok {
			break
		}
		count++
	}
	fmt.Println("unread messages:", count)
}
Output:
unread messages: 3
Example (ListFolders)

Example_listFolders demonstrates discovering folders.

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/rbaliyan/event/v3/transport/channel"

	"github.com/rbaliyan/mailbox"
	"github.com/rbaliyan/mailbox/store"
	"github.com/rbaliyan/mailbox/store/memory"
)

// newTestService creates a connected service for examples using in-memory backends.
// Channel transport enables synchronous event delivery without Redis.
func newTestService() mailbox.Service {
	svc, err := mailbox.New(mailbox.Config{},
		mailbox.WithStore(memory.New()),
		mailbox.WithEventTransport(channel.New()),
	)
	if err != nil {
		log.Fatal(err)
	}
	if err := svc.Connect(context.Background()); err != nil {
		log.Fatal(err)
	}
	return svc
}

func main() {
	ctx := context.Background()
	svc := newTestService()
	defer svc.Close(ctx)

	alice := svc.Client("alice")
	alice.SendMessage(ctx, mailbox.SendRequest{
		RecipientIDs: []string{"bob"},
		Subject:      "test",
		Body:         "body",
	})

	bob := svc.Client("bob")

	// Move to a custom folder.
	inbox, _ := bob.Folder(ctx, store.FolderInbox, store.ListOptions{})
	bob.MoveToFolder(ctx, inbox.All()[0].GetID(), "work-queue")

	// List all folders with counts.
	folders, _ := bob.ListFolders(ctx)
	for _, f := range folders {
		if f.MessageCount > 0 {
			fmt.Printf("%s: %d messages (%d unread)\n", f.ID, f.MessageCount, f.UnreadCount)
		}
	}
}
Output:
work-queue: 1 messages (1 unread)
Example (MessageTTL)

Example_messageTTL demonstrates per-message TTL (time-to-live). Messages with TTL are automatically deleted by CleanupExpiredMessages.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/rbaliyan/mailbox"
	"github.com/rbaliyan/mailbox/store"
	"github.com/rbaliyan/mailbox/store/memory"
)

func main() {
	ctx := context.Background()
	memStore := memory.New()
	svc, _ := mailbox.New(mailbox.Config{MessageRetention: 24 * time.Hour},
		mailbox.WithStore(memStore),
	)
	svc.Connect(ctx)
	defer svc.Close(ctx)

	// Send a message with 2-hour TTL.
	alice := svc.Client("alice")
	alice.SendMessage(ctx, mailbox.SendRequest{
		RecipientIDs: []string{"bob"},
		Subject:      "Ephemeral message",
		Body:         "This self-destructs in 2 hours.",
		TTL:          2 * time.Hour,
	})

	// Message is visible now.
	bob := svc.Client("bob")
	inbox, _ := bob.Folder(ctx, store.FolderInbox, store.ListOptions{})
	fmt.Println("before expiry:", len(inbox.All()))

	// Age messages past TTL.
	memStore.AgeTTLAll(3 * time.Hour)

	// CleanupExpiredMessages deletes TTL-expired messages.
	result, _ := svc.CleanupExpiredMessages(ctx)
	fmt.Println("cleaned:", result.DeletedCount)

	inbox, _ = bob.Folder(ctx, store.FolderInbox, store.ListOptions{})
	fmt.Println("after expiry:", len(inbox.All()))
}
Output:
before expiry: 1
cleaned: 2
after expiry: 0
Example (Notifications)

Example_notifications demonstrates real-time notification delivery. This example uses the in-memory notify store with channel-based delivery. In production, use Redis Streams (notifyredis.New) for cross-instance delivery.

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/rbaliyan/event/v3/transport/channel"

	"github.com/rbaliyan/mailbox"
	"github.com/rbaliyan/mailbox/notify"

	notifymem "github.com/rbaliyan/mailbox/notify/memory"
	"github.com/rbaliyan/mailbox/store/memory"
)

// newTestServiceWithNotifications creates a connected service with notification support.
func newTestServiceWithNotifications() mailbox.Service {
	notifier := notify.NewNotifier(
		notify.WithStore(notifymem.New()),
		notify.WithPollInterval(50*time.Millisecond),
	)
	svc, err := mailbox.New(mailbox.Config{},
		mailbox.WithStore(memory.New()),
		mailbox.WithEventTransport(channel.New()),
		mailbox.WithNotifier(notifier),
	)
	if err != nil {
		log.Fatal(err)
	}
	if err := svc.Connect(context.Background()); err != nil {
		log.Fatal(err)
	}
	return svc
}

func main() {
	ctx := context.Background()
	svc := newTestServiceWithNotifications()
	defer svc.Close(ctx)

	// Open notification stream for bob BEFORE sending the message.
	stream, err := svc.Notifications(ctx, "bob", "")
	if err != nil {
		log.Fatal(err)
	}
	defer stream.Close()

	// Send a message to bob. The stream's store poller backfills persisted
	// events, so no artificial delay is needed before reading.
	alice := svc.Client("alice")
	alice.SendMessage(ctx, mailbox.SendRequest{
		RecipientIDs: []string{"bob"},
		Subject:      "Real-time!",
		Body:         "body",
	})

	// Block on Next until we get the received event.
	// Multiple events may fire (sent, received) — find the one for bob.
	readCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	for {
		evt, err := stream.Next(readCtx)
		if err != nil {
			log.Fatal(err)
		}
		if evt.Type == "mailbox.message.received" {
			fmt.Println("type:", evt.Type)
			break
		}
	}
}
Output:
type: mailbox.message.received
Example (PartialDelivery)

Example_partialDelivery demonstrates handling partial delivery errors.

package main

import (
	"context"
	"fmt"

	"github.com/rbaliyan/mailbox"
	"github.com/rbaliyan/mailbox/store/memory"
)

func main() {
	ctx := context.Background()

	// Set up with a small quota so one recipient hits the limit.
	svc, _ := mailbox.New(mailbox.Config{},
		mailbox.WithStore(memory.New()),
		mailbox.WithGlobalQuota(mailbox.QuotaPolicy{
			MaxMessages:  2,
			ExceedAction: mailbox.QuotaActionReject,
		}),
	)
	svc.Connect(ctx)
	defer svc.Close(ctx)

	// Fill bob's quota.
	for i := 0; i < 2; i++ {
		svc.Client(fmt.Sprintf("sender%d", i)).SendMessage(ctx, mailbox.SendRequest{
			RecipientIDs: []string{"bob"},
			Subject:      "fill",
			Body:         "body",
		})
	}

	// Try to send to both bob (over quota) and charlie (fine).
	alice := svc.Client("alice")
	msg, err := alice.SendMessage(ctx, mailbox.SendRequest{
		RecipientIDs: []string{"bob", "charlie"},
		Subject:      "Partial",
		Body:         "body",
	})

	if pde, ok := mailbox.IsPartialDelivery(err); ok {
		fmt.Println("delivered to:", pde.DeliveredTo)
		fmt.Println("failed:", len(pde.FailedRecipients))
		fmt.Println("message sent:", msg != nil)
	}
}
Output:
delivered to: [charlie]
failed: 1
message sent: true
Example (ScheduledDelivery)

Example_scheduledDelivery demonstrates scheduled message delivery. Messages with ScheduleAt are hidden until the scheduled time.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/rbaliyan/mailbox"
	"github.com/rbaliyan/mailbox/store"
	"github.com/rbaliyan/mailbox/store/memory"
)

func main() {
	ctx := context.Background()
	memStore := memory.New()
	svc, _ := mailbox.New(mailbox.Config{}, mailbox.WithStore(memStore))
	svc.Connect(ctx)
	defer svc.Close(ctx)

	// Schedule a message for 1 hour from now.
	future := time.Now().UTC().Add(1 * time.Hour)
	alice := svc.Client("alice")
	alice.SendMessage(ctx, mailbox.SendRequest{
		RecipientIDs: []string{"bob"},
		Subject:      "Good morning!",
		Body:         "Scheduled for 9am.",
		ScheduleAt:   &future,
	})

	// Bob's inbox is empty — message not yet available.
	bob := svc.Client("bob")
	inbox, _ := bob.Folder(ctx, store.FolderInbox, store.ListOptions{})
	fmt.Println("before schedule:", len(inbox.All()))

	// Age schedule past availability.
	memStore.AgeScheduleAll(2 * time.Hour)

	// Now visible.
	inbox, _ = bob.Folder(ctx, store.FolderInbox, store.ListOptions{})
	fmt.Println("after schedule:", len(inbox.All()))
	if len(inbox.All()) > 0 {
		fmt.Println("subject:", inbox.All()[0].GetSubject())
	}
}
Output:
before schedule: 0
after schedule: 1
subject: Good morning!
Example (SseServer)

Example_sseServer demonstrates a complete HTTP server with SSE notifications and a client that consumes the stream. This is a self-contained server+client example that runs in-process using httptest.

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"time"

	"github.com/rbaliyan/event/v3/transport/channel"

	"github.com/rbaliyan/mailbox"
	"github.com/rbaliyan/mailbox/notify"

	notifymem "github.com/rbaliyan/mailbox/notify/memory"
	"github.com/rbaliyan/mailbox/store/memory"
)

// newTestServiceWithNotifications creates a connected service with notification support.
func newTestServiceWithNotifications() mailbox.Service {
	notifier := notify.NewNotifier(
		notify.WithStore(notifymem.New()),
		notify.WithPollInterval(50*time.Millisecond),
	)
	svc, err := mailbox.New(mailbox.Config{},
		mailbox.WithStore(memory.New()),
		mailbox.WithEventTransport(channel.New()),
		mailbox.WithNotifier(notifier),
	)
	if err != nil {
		log.Fatal(err)
	}
	if err := svc.Connect(context.Background()); err != nil {
		log.Fatal(err)
	}
	return svc
}

func main() {
	ctx := context.Background()
	svc := newTestServiceWithNotifications()
	defer svc.Close(ctx)

	// --- Server side: SSE endpoint ---
	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		flusher, ok := w.(http.Flusher)
		if !ok {
			http.Error(w, "streaming not supported", http.StatusInternalServerError)
			return
		}

		userID := r.URL.Query().Get("user_id")
		lastEventID := r.Header.Get("Last-Event-ID")

		stream, err := svc.Notifications(r.Context(), userID, lastEventID)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		defer stream.Close()

		w.Header().Set("Content-Type", "text/event-stream")
		w.Header().Set("Cache-Control", "no-cache")
		w.Header().Set("Connection", "keep-alive")
		w.Header().Set("X-Accel-Buffering", "no")
		w.WriteHeader(http.StatusOK)
		flusher.Flush()

		for {
			evt, err := stream.Next(r.Context())
			if err != nil {
				return
			}
			fmt.Fprintf(w, "id: %s\nevent: %s\ndata: %s\n\n", evt.ID, evt.Type, string(evt.Payload))
			flusher.Flush()
		}
	})

	ts := httptest.NewServer(handler)
	defer ts.Close()

	// --- Client side: consume SSE ---
	done := make(chan bool, 1)

	go func() {
		resp, err := http.Get(ts.URL + "?user_id=bob")
		if err != nil {
			done <- false
			return
		}
		defer resp.Body.Close()

		buf := make([]byte, 4096)
		n, _ := resp.Body.Read(buf)
		done <- n > 0
	}()

	// Send a message to trigger an SSE event. No connect delay is needed: the
	// notification is persisted, and the SSE handler's stream poller backfills
	// it from the store regardless of connection ordering.
	alice := svc.Client("alice")
	alice.SendMessage(ctx, mailbox.SendRequest{
		RecipientIDs: []string{"bob"},
		Subject:      "SSE test",
		Body:         "body",
	})

	select {
	case ok := <-done:
		fmt.Println("received:", ok)
	case <-time.After(5 * time.Second):
		fmt.Println("received: false")
	}
}
Output:
received: true
Example (Stats)

Example_stats demonstrates aggregate statistics.

package main

import (
	"context"
	"fmt"

	"github.com/rbaliyan/mailbox"
	"github.com/rbaliyan/mailbox/store/memory"
)

func main() {
	ctx := context.Background()
	// Use a plain service (no event transport) to avoid stats cache interference
	// from other examples running in the same process.
	svc, _ := mailbox.New(mailbox.Config{}, mailbox.WithStore(memory.New()))
	svc.Connect(ctx)
	defer svc.Close(ctx)

	alice := svc.Client("alice")
	for i := 0; i < 3; i++ {
		alice.SendMessage(ctx, mailbox.SendRequest{
			RecipientIDs: []string{"bob"},
			Subject:      fmt.Sprintf("Msg %d", i),
			Body:         "body",
		})
	}

	bob := svc.Client("bob")

	// Stats returns total messages and unread count.
	stats, _ := bob.Stats(ctx)
	fmt.Println("total:", stats.TotalMessages)
	fmt.Println("unread:", stats.UnreadCount)

	// UnreadCount is a convenience method.
	unread, _ := bob.UnreadCount(ctx)
	fmt.Println("unread count:", unread)
}
Output:
total: 3
unread: 3
unread count: 3
Example (SystemMessages)

Example_systemMessages demonstrates sending messages from a system user.

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/rbaliyan/event/v3/transport/channel"

	"github.com/rbaliyan/mailbox"
	"github.com/rbaliyan/mailbox/store"
	"github.com/rbaliyan/mailbox/store/memory"
)

// newTestService creates a connected service for examples using in-memory backends.
// Channel transport enables synchronous event delivery without Redis.
func newTestService() mailbox.Service {
	svc, err := mailbox.New(mailbox.Config{},
		mailbox.WithStore(memory.New()),
		mailbox.WithEventTransport(channel.New()),
	)
	if err != nil {
		log.Fatal(err)
	}
	if err := svc.Connect(context.Background()); err != nil {
		log.Fatal(err)
	}
	return svc
}

func main() {
	ctx := context.Background()
	svc := newTestService()
	defer svc.Close(ctx)

	// System sends a notification to bob.
	system := svc.Client("system")
	system.SendMessage(ctx, mailbox.SendRequest{
		RecipientIDs: []string{"bob"},
		Subject:      "Welcome to the platform",
		Body:         "<h1>Welcome!</h1><p>Your account is ready.</p>",
		Headers:      map[string]string{"Content-Type": "text/html"},
		Metadata: map[string]any{
			"template":  "welcome",
			"deep_link": "/onboarding",
		},
	})

	bob := svc.Client("bob")
	inbox, _ := bob.Folder(ctx, store.FolderInbox, store.ListOptions{})
	msg := inbox.All()[0]
	fmt.Println("from:", msg.GetSenderID())
	fmt.Println("subject:", msg.GetSubject())
	fmt.Println("content-type:", msg.GetHeaders()["Content-Type"])
	fmt.Println("template:", msg.GetMetadata()["template"])
}
Output:
from: system
subject: Welcome to the platform
content-type: text/html
template: welcome
Example (Threads)

ExampleMailbox_threads demonstrates thread-based messaging.

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/rbaliyan/event/v3/transport/channel"

	"github.com/rbaliyan/mailbox"
	"github.com/rbaliyan/mailbox/store"
	"github.com/rbaliyan/mailbox/store/memory"
)

// newTestService creates a connected service for examples using in-memory backends.
// Channel transport enables synchronous event delivery without Redis.
func newTestService() mailbox.Service {
	svc, err := mailbox.New(mailbox.Config{},
		mailbox.WithStore(memory.New()),
		mailbox.WithEventTransport(channel.New()),
	)
	if err != nil {
		log.Fatal(err)
	}
	if err := svc.Connect(context.Background()); err != nil {
		log.Fatal(err)
	}
	return svc
}

func main() {
	ctx := context.Background()
	svc := newTestService()
	defer svc.Close(ctx)

	alice := svc.Client("alice")

	// Start a thread.
	msg, _ := alice.SendMessage(ctx, mailbox.SendRequest{
		RecipientIDs: []string{"bob"},
		Subject:      "Thread starter",
		Body:         "Let's discuss.",
		ThreadID:     "thread-001",
	})

	// Reply in the same thread.
	bob := svc.Client("bob")
	bobInbox, _ := bob.Folder(ctx, store.FolderInbox, store.ListOptions{})
	bobMsgID := bobInbox.All()[0].GetID()
	bob.SendMessage(ctx, mailbox.SendRequest{
		RecipientIDs: []string{"alice"},
		Subject:      "Re: Thread starter",
		Body:         "Sounds good!",
		ThreadID:     "thread-001",
		ReplyToID:    bobMsgID,
	})

	// Get all messages in the thread.
	thread, _ := alice.GetThread(ctx, "thread-001", store.ListOptions{})
	fmt.Println("thread messages:", len(thread.All()))

	// Get replies to the original message.
	replies, _ := bob.GetReplies(ctx, msg.GetID(), store.ListOptions{})
	fmt.Println("replies:", len(replies.All()))
}
Output:
thread messages: 2
replies: 0

Index

Examples

Constants

View Source
const (
	EventNameMessageSent     = "mailbox.message.sent"
	EventNameMessageReceived = "mailbox.message.received"
	EventNameMessageRead     = "mailbox.message.read"
	EventNameMessageDeleted  = "mailbox.message.deleted"
	EventNameMessageMoved    = "mailbox.message.moved"
	EventNameMarkAllRead     = "mailbox.mark_all_read"
)

Event names for mailbox events.

View Source
const (
	SortAsc  = store.SortAsc
	SortDesc = store.SortDesc
)

Re-exported sort order constants.

View Source
const (
	DefaultTrashRetention                 = 30 * 24 * time.Hour // 30 days
	MinTrashRetention                     = 24 * time.Hour      // 1 day minimum
	DefaultMessageRetention time.Duration = 0                   // disabled by default
	MinMessageRetention                   = 24 * time.Hour      // 1 day minimum when enabled
	DefaultShutdownTimeout                = 30 * time.Second    // default graceful shutdown timeout
	MinShutdownTimeout                    = 1 * time.Second     // minimum shutdown timeout

	// Default message limits
	DefaultMaxSubjectLength   = 998              // RFC 5322 max line length
	DefaultMaxBodySize        = 10 * 1024 * 1024 // 10 MB
	DefaultMaxAttachmentSize  = 25 * 1024 * 1024 // 25 MB per attachment
	DefaultMaxAttachmentCount = 20               // max attachments per message
	DefaultMaxRecipientCount  = 100              // max recipients per message
	DefaultMaxMetadataSize    = 64 * 1024        // 64 KB total metadata
	DefaultMaxMetadataKeys    = 100              // max metadata keys

	// Default header limits
	DefaultMaxHeaderCount       = 50        // max headers per message
	DefaultMaxHeaderKeyLength   = 128       // max header key length
	DefaultMaxHeaderValueLength = 8 * 1024  // 8 KB max header value
	DefaultMaxHeadersTotalSize  = 64 * 1024 // 64 KB total headers

	// Query limits
	DefaultMaxQueryLimit = 100 // max messages per query
	DefaultQueryLimit    = 20  // default messages per query

	// Concurrency limits
	DefaultMaxConcurrentSends = 10 // max concurrent send operations per service

	// Stats cache
	DefaultStatsRefreshInterval = 30 * time.Second // TTL for cached stats

	// Orphan message claiming defaults (Redis transport only)
	DefaultClaimInterval  = 30 * time.Second // scan for orphans every 30s
	DefaultClaimMinIdle   = 60 * time.Second // claim messages idle for 60s+
	DefaultClaimBatchSize = int64(100)       // claim up to 100 messages per cycle

	// DefaultEventStreamMaxLen is the default approximate maximum number of entries
	// per Redis event stream. Set EventStreamMaxLen = -1 to disable trimming.
	DefaultEventStreamMaxLen = int64(10_000)
)

Default configuration values.

View Source
const (
	StatsCacheFieldTotal  = "total"
	StatsCacheFieldUnread = "unread"
	StatsCacheFieldDraft  = "draft"
)

Known field names for StatsCache.IncrBy. Using constants prevents typos and makes it easy to change the encoding in one place.

View Source
const (
	MetadataSenderFirstName = "sender.firstname"
	MetadataSenderLastName  = "sender.lastname"
	MetadataSenderEmail     = "sender.email"
)

Metadata keys populated by UserResolver during message delivery. When a UserResolver is configured, these keys are set on message metadata with the sender's identity before validation and delivery.

View Source
const (
	// Metadata limits
	MaxMetadataKeyLength = 256 // Maximum length of a metadata key

	// MaxTagIDLength is the maximum length for a tag ID.
	MaxTagIDLength = 256

	// MinSubjectLength is the minimum subject length (non-empty after trimming)
	MinSubjectLength = 1
)

Validation constants for message content. These are the default values; use MessageLimits for configurable validation.

View Source
const MetadataMessageID = "message_id"

MetadataMessageID is the event metadata key used for coalescing. When notification coalescing is enabled, events with the same message_id in metadata are collapsed so only the latest is delivered.

Variables

View Source
var (
	// ErrNotFound is returned when a message cannot be found.
	// Wraps store.ErrNotFound for consistent error checking.
	ErrNotFound = fmt.Errorf("mailbox: %w", store.ErrNotFound)

	// ErrUnauthorized is returned when user doesn't have access to a message.
	ErrUnauthorized = errors.New("mailbox: unauthorized")

	// ErrInvalidMessage is returned for message validation failures.
	ErrInvalidMessage = errors.New("mailbox: invalid message")

	// ErrEmptyRecipients is returned when no recipients are provided.
	// Wraps store.ErrEmptyRecipients for consistent error checking.
	ErrEmptyRecipients = fmt.Errorf("mailbox: %w", store.ErrEmptyRecipients)

	// ErrEmptySubject is returned when subject is empty.
	// Wraps store.ErrEmptySubject for consistent error checking.
	ErrEmptySubject = fmt.Errorf("mailbox: %w", store.ErrEmptySubject)

	// ErrStoreRequired is returned when no store is configured.
	ErrStoreRequired = errors.New("mailbox: store is required")

	// ErrNotConnected is returned when operations are attempted before Connect().
	// Wraps store.ErrNotConnected for consistent error checking.
	ErrNotConnected = fmt.Errorf("mailbox: %w", store.ErrNotConnected)

	// ErrAlreadyConnected is returned when Connect() is called twice.
	// Wraps store.ErrAlreadyConnected for consistent error checking.
	ErrAlreadyConnected = fmt.Errorf("mailbox: %w", store.ErrAlreadyConnected)

	// ErrInvalidID is returned when an invalid ID is provided.
	// Wraps store.ErrInvalidID for consistent error checking.
	ErrInvalidID = fmt.Errorf("mailbox: %w", store.ErrInvalidID)

	// ErrDuplicateEntry is returned when a duplicate entry is detected.
	// Wraps store.ErrDuplicateEntry for consistent error checking.
	ErrDuplicateEntry = fmt.Errorf("mailbox: %w", store.ErrDuplicateEntry)

	// ErrFilterInvalid is returned when a filter is invalid.
	// Wraps store.ErrFilterInvalid for consistent error checking.
	ErrFilterInvalid = fmt.Errorf("mailbox: %w", store.ErrFilterInvalid)

	// ErrEventClientRequired is returned when event client is nil.
	ErrEventClientRequired = errors.New("mailbox: event client is required")

	// ErrNotInTrash is returned when trying to restore a message not in trash.
	ErrNotInTrash = errors.New("mailbox: message not in trash")

	// ErrAlreadyInTrash is returned when trying to trash an already trashed message.
	ErrAlreadyInTrash = errors.New("mailbox: message already in trash")

	// ErrAttachmentNotFound is returned when an attachment cannot be found.
	ErrAttachmentNotFound = errors.New("mailbox: attachment not found")

	// ErrAttachmentStoreNotConfigured is returned when attachment store is not configured.
	ErrAttachmentStoreNotConfigured = errors.New("mailbox: attachment store not configured")

	// ErrPartialDelivery is returned when some recipients failed to receive the message.
	ErrPartialDelivery = errors.New("mailbox: partial delivery")

	// ErrInvalidMetadata is returned when metadata validation fails.
	ErrInvalidMetadata = errors.New("mailbox: invalid metadata")

	// ErrMetadataKeyTooLong is returned when a metadata key exceeds the maximum length.
	ErrMetadataKeyTooLong = errors.New("mailbox: metadata key too long")

	// ErrMetadataTooLarge is returned when metadata exceeds the maximum size.
	ErrMetadataTooLarge = errors.New("mailbox: metadata too large")

	// ErrSubjectTooLong is returned when subject exceeds maximum length.
	ErrSubjectTooLong = errors.New("mailbox: subject too long")

	// ErrBodyTooLarge is returned when body exceeds maximum size.
	ErrBodyTooLarge = errors.New("mailbox: body too large")

	// ErrInvalidContent is returned when message content contains invalid characters.
	ErrInvalidContent = errors.New("mailbox: invalid content")

	// ErrTooManyRecipients is returned when recipient count exceeds the limit.
	ErrTooManyRecipients = errors.New("mailbox: too many recipients")

	// ErrInvalidRecipient is returned when a recipient ID is invalid.
	ErrInvalidRecipient = errors.New("mailbox: invalid recipient")

	// ErrTooManyAttachments is returned when attachment count exceeds the limit.
	ErrTooManyAttachments = errors.New("mailbox: too many attachments")

	// ErrAttachmentTooLarge is returned when an attachment exceeds the size limit.
	ErrAttachmentTooLarge = errors.New("mailbox: attachment too large")

	// ErrInvalidAttachment is returned when attachment data is invalid.
	ErrInvalidAttachment = errors.New("mailbox: invalid attachment")

	// ErrInvalidMIMEType is returned when an attachment has an invalid or disallowed MIME type.
	ErrInvalidMIMEType = errors.New("mailbox: invalid mime type")

	// ErrInvalidHeaders is returned when headers validation fails.
	ErrInvalidHeaders = errors.New("mailbox: invalid headers")

	// ErrTooManyHeaders is returned when header count exceeds the limit.
	ErrTooManyHeaders = errors.New("mailbox: too many headers")

	// ErrHeaderKeyTooLong is returned when a header key exceeds the maximum length.
	ErrHeaderKeyTooLong = errors.New("mailbox: header key too long")

	// ErrHeaderValueTooLong is returned when a header value exceeds the maximum length.
	ErrHeaderValueTooLong = errors.New("mailbox: header value too long")

	// ErrHeadersTooLarge is returned when total headers size exceeds the limit.
	ErrHeadersTooLarge = errors.New("mailbox: headers too large")

	// ErrInvalidFolderID is returned when a folder ID is invalid.
	// Wraps store.ErrInvalidFolderID for consistent error checking.
	ErrInvalidFolderID = fmt.Errorf("mailbox: %w", store.ErrInvalidFolderID)

	// ErrRateLimited is returned when a user exceeds their rate limit.
	ErrRateLimited = errors.New("mailbox: rate limited")

	// ErrInvalidUserID is returned when a user ID contains invalid characters.
	ErrInvalidUserID = errors.New("mailbox: invalid user id")

	// ErrInvalidIdempotencyKey is returned when an idempotency key is invalid.
	// Wraps store.ErrInvalidIdempotencyKey for consistent error checking.
	ErrInvalidIdempotencyKey = fmt.Errorf("mailbox: %w", store.ErrInvalidIdempotencyKey)

	// ErrCacheInvalidationFailed is returned when cache invalidation fails in strict mode.
	// The underlying operation succeeded, but cached data may be stale.
	ErrCacheInvalidationFailed = errors.New("mailbox: cache invalidation failed")

	// ErrFolderMismatch is returned by conditional MoveToFolder when the message
	// exists but is not in the expected source folder.
	// Wraps store.ErrFolderMismatch for consistent error checking.
	ErrFolderMismatch = fmt.Errorf("mailbox: %w", store.ErrFolderMismatch)

	// ErrNotifierNotConfigured is returned when Notifications() is called
	// without a notifier configured via WithNotifier.
	ErrNotifierNotConfigured = errors.New("mailbox: notifier not configured")

	// ErrInvalidTTL is returned when a TTL value is outside configured bounds.
	ErrInvalidTTL = errors.New("mailbox: invalid TTL")

	// ErrInvalidSchedule is returned when a schedule delay is outside configured bounds.
	ErrInvalidSchedule = errors.New("mailbox: invalid schedule")

	// ErrQuotaExceeded is returned when a user's mailbox has reached its message quota.
	// Use errors.As with *QuotaExceededError to get details (user ID, current count, limit).
	ErrQuotaExceeded = errors.New("mailbox: quota exceeded")

	// ErrQuotaUserListerNotConfigured is returned by RunQuotaEnforcement when no
	// QuotaUserLister has been configured in Config.
	ErrQuotaUserListerNotConfigured = errors.New("mailbox: quota user lister not configured")

	// ErrUserResolveFailed is returned when the UserResolver fails to resolve
	// the sender's identity. The message is not sent.
	ErrUserResolveFailed = errors.New("mailbox: user resolve failed")

	// ErrRecipientNotFound is returned by RecipientResolver implementations
	// when a user ID cannot be resolved to a recipient.
	ErrRecipientNotFound = errors.New("mailbox: recipient not found")
)

Sentinel errors for the mailbox package. Use errors.Is() to check for these errors.

These errors wrap corresponding store-level errors where applicable, so errors.Is(err, mailbox.ErrNotFound) will match both mailbox-level and store-level "not found" errors.

View Source
var (
	// EventMessageSent is published when a message is sent.
	// Deprecated: Use Service.Events().MessageSent instead.
	EventMessageSent = event.New[MessageSentEvent](EventNameMessageSent)

	// EventMessageReceived is published when a message is delivered to a recipient.
	// Deprecated: Use Service.Events().MessageReceived instead.
	EventMessageReceived = event.New[MessageReceivedEvent](EventNameMessageReceived)

	// EventMessageRead is published when a message is marked as read.
	// Deprecated: Use Service.Events().MessageRead instead.
	EventMessageRead = event.New[MessageReadEvent](EventNameMessageRead)

	// EventMessageDeleted is published when a message is permanently deleted.
	// Deprecated: Use Service.Events().MessageDeleted instead.
	EventMessageDeleted = event.New[MessageDeletedEvent](EventNameMessageDeleted)

	// EventMessageMoved is published when a message is moved between folders.
	// Deprecated: Use Service.Events().MessageMoved instead.
	EventMessageMoved = event.New[MessageMovedEvent](EventNameMessageMoved)

	// EventMarkAllRead is published when all messages in a folder are marked as read.
	// Deprecated: Use Service.Events().MarkAllRead instead.
	EventMarkAllRead = event.New[MarkAllReadEvent](EventNameMarkAllRead)
)

Global event instances.

Deprecated: These global events use "first registration wins" semantics, which makes parallel testing unreliable and prevents multiple independent services in the same process. Prefer using Service.Events() for per-service event access.

View Source
var (
	// FlagsMarkRead marks a message as read.
	FlagsMarkRead = Flags{Read: ptrTrue}
	// FlagsMarkUnread marks a message as unread.
	FlagsMarkUnread = Flags{Read: ptrFalse}
	// FlagsMarkArchived archives a message.
	FlagsMarkArchived = Flags{Archived: ptrTrue}
	// FlagsMarkUnarchived unarchives a message.
	FlagsMarkUnarchived = Flags{Archived: ptrFalse}
)

Pre-allocated flag values for common operations. These are more efficient than calling MarkRead(), etc. in hot paths.

View Source
var ErrIteratorOutOfBounds = errors.New("mailbox: iterator out of bounds - call Next() first")

MessageIterator provides streaming access to messages. It implements a pull-based iteration pattern for memory-efficient processing of large result sets.

Iterator vs List: When to Use Each

Use MessageIterator (Stream* methods) when:

  • Processing large result sets where memory is a concern
  • You need to process messages one at a time
  • You want early termination without loading all data
  • Building ETL pipelines or data exports

Use MessageList (Inbox, Sent, etc.) when:

  • Building paginated UIs with total counts
  • You need bulk operations (MarkRead, Move, Delete all)
  • Result sets are small and fit comfortably in memory
  • You need random access to results

Example comparison:

// Iterator: memory-efficient, process one at a time
iter, _ := mb.Stream(ctx, nil, StreamOptions{BatchSize: 100})
for hasNext, err := iter.Next(ctx); hasNext && err == nil; hasNext, err = iter.Next(ctx) {
    msg, _ := iter.Message()
    // process each message individually
}

// List: loads page into memory, supports bulk ops
list, _ := mb.Folder(ctx, store.FolderInbox, store.ListOptions{Limit: 50})
list.MarkRead(ctx)  // bulk operation on all 50 messages
fmt.Printf("Showing %d of %d total", len(list.All()), list.Total())

Iterator Usage

The iterator is stateless and holds no resources requiring cleanup. Simply stop calling Next() when done - the GC handles the rest.

Example:

iter, _ := mb.Stream(ctx, nil, StreamOptions{BatchSize: 100})

for {
    hasNext, err := iter.Next(ctx)
    if err != nil {
        // handle error (e.g., service disconnected, context cancelled)
        break
    }
    if !hasNext {
        break
    }
    msg, _ := iter.Message()
    // process message - can use msg.Update(), msg.Delete(), etc.
}

ErrIteratorOutOfBounds is returned when Message() is called without a successful Next().

Functions

func DefaultBlockedMIMETypes

func DefaultBlockedMIMETypes() []string

DefaultBlockedMIMETypes returns MIME types that are commonly blocked for security.

func IsRetryableError

func IsRetryableError(err error) bool

IsRetryableError determines if an error is retryable. Returns true for temporary/transient errors, false for permanent errors. Handles both mailbox-level and store-level errors.

func NewAttachmentManager

func NewAttachmentManager(metadata store.AttachmentMetadataStore, files store.AttachmentFileStore) store.AttachmentManager

NewAttachmentManager creates an attachment manager with reference-counted deletion.

func SafeAttachmentMIMETypes

func SafeAttachmentMIMETypes() []string

SafeAttachmentMIMETypes returns commonly allowed safe MIME types.

func StatsCacheFolderTotal added in v0.7.6

func StatsCacheFolderTotal(folderID string) string

StatsCacheFolderTotal returns the IncrBy field name for a folder's total count.

func StatsCacheFolderUnread added in v0.7.6

func StatsCacheFolderUnread(folderID string) string

StatsCacheFolderUnread returns the IncrBy field name for a folder's unread count.

func ValidateAttachments

func ValidateAttachments(attachments []store.Attachment, limits MessageLimits) error

ValidateAttachments validates the attachment list.

func ValidateAttachmentsWithMIME

func ValidateAttachmentsWithMIME(attachments []store.Attachment, limits MessageLimits, allowedTypes, blockedTypes []string) error

ValidateAttachmentsWithMIME validates attachments with MIME type restrictions. allowedTypes: if non-empty, only these MIME types are allowed. blockedTypes: these MIME types are always blocked, even if in allowedTypes.

func ValidateBody

func ValidateBody(body string) error

ValidateBody validates a message body using default limits. For configurable limits, use ValidateBodyWithLimits.

func ValidateBodyWithLimits

func ValidateBodyWithLimits(body string, limits MessageLimits) error

ValidateBodyWithLimits validates a message body against configurable limits.

func ValidateDraft

func ValidateDraft(draft store.DraftMessage, limits MessageLimits) error

ValidateDraft performs full validation of a draft before sending.

func ValidateHeaders added in v0.5.0

func ValidateHeaders(headers map[string]string, limits MessageLimits) error

ValidateHeaders validates message headers against configurable limits.

func ValidateMIMEType

func ValidateMIMEType(contentType string, allowedTypes, blockedTypes []string) error

ValidateMIMEType validates a MIME type against allowed and blocked lists. Returns nil if the MIME type is valid.

func ValidateMessage

func ValidateMessage(msg store.Message, limits MessageLimits) error

ValidateMessage performs full validation of a message.

func ValidateMessageContent

func ValidateMessageContent(subject, body string) error

ValidateMessageContent validates subject and body together using default limits.

func ValidateMessageContentWithLimits

func ValidateMessageContentWithLimits(subject, body string, limits MessageLimits) error

ValidateMessageContentWithLimits validates subject and body with configurable limits.

func ValidateMetadata

func ValidateMetadata(metadata map[string]any) error

ValidateMetadata validates metadata against size and key constraints using default limits. For configurable limits, use ValidateMetadataWithLimits.

func ValidateMetadataKey

func ValidateMetadataKey(key string) error

ValidateMetadataKey validates a single metadata key.

func ValidateMetadataWithLimits

func ValidateMetadataWithLimits(metadata map[string]any, limits MessageLimits) error

ValidateMetadataWithLimits validates metadata against configurable limits.

func ValidateRecipients

func ValidateRecipients(recipientIDs []string, limits MessageLimits) error

ValidateRecipients validates the recipient list.

func ValidateSubject

func ValidateSubject(subject string) error

ValidateSubject validates a message subject using default limits. For configurable limits, use ValidateSubjectWithLimits.

func ValidateSubjectWithLimits

func ValidateSubjectWithLimits(subject string, limits MessageLimits) error

ValidateSubjectWithLimits validates a message subject against configurable limits.

Types

type AttachmentLoader

type AttachmentLoader interface {
	LoadAttachment(ctx context.Context, messageID, attachmentID string) (io.ReadCloser, error)
}

AttachmentLoader provides attachment access.

type AttachmentRefError

type AttachmentRefError struct {
	// Operation is either "add" or "release"
	Operation string
	// Failed maps attachment IDs to their errors
	Failed map[string]error
	// RollbackFailed maps attachment IDs to rollback errors (only for "add" operations)
	RollbackFailed map[string]error
}

AttachmentRefError provides details about attachment reference counting failures. This error is returned when adding or releasing attachment references fails. Use errors.As() to extract and inspect the details.

func (*AttachmentRefError) Error

func (e *AttachmentRefError) Error() string

func (*AttachmentRefError) FailedIDs

func (e *AttachmentRefError) FailedIDs() []string

FailedIDs returns the list of attachment IDs that failed.

func (*AttachmentRefError) HasRollbackFailures

func (e *AttachmentRefError) HasRollbackFailures() bool

HasRollbackFailures returns true if rollback also failed during an add operation.

func (*AttachmentRefError) Unwrap

func (e *AttachmentRefError) Unwrap() []error

Unwrap returns the individual errors from failed operations.

type AttachmentResolver

type AttachmentResolver interface {
	ResolveAttachments(ctx context.Context, attachmentIDs []string) ([]store.Attachment, error)
}

AttachmentResolver provides attachment metadata resolution by ID. This is useful for server integrations that need to resolve attachment references without loading the full message.

type AttachmentStore

type AttachmentStore = store.AttachmentFileStore

AttachmentStore is an alias for store.AttachmentFileStore. Deprecated: Use store.AttachmentFileStore directly.

type BulkOperationError

type BulkOperationError struct {
	Result *BulkResult
}

BulkOperationError is returned when a bulk operation has partial failures. It wraps BulkResult to provide error interface while guaranteeing non-empty Error().

func (*BulkOperationError) Error

func (e *BulkOperationError) Error() string

Error implements the error interface. Always returns a non-empty string describing the failure.

func (*BulkOperationError) Unwrap

func (e *BulkOperationError) Unwrap() []error

Unwrap returns the individual errors from failed operations.

type BulkOperator

type BulkOperator interface {
	BulkUpdateFlags(ctx context.Context, messageIDs []string, flags Flags) (*BulkResult, error)
	BulkMove(ctx context.Context, messageIDs []string, folderID string, opts ...store.MoveOption) (*BulkResult, error)
	BulkDelete(ctx context.Context, messageIDs []string) (*BulkResult, error)
	BulkPermanentlyDelete(ctx context.Context, messageIDs []string) (*BulkResult, error)
	BulkAddTag(ctx context.Context, messageIDs []string, tagID string) (*BulkResult, error)
	BulkRemoveTag(ctx context.Context, messageIDs []string, tagID string) (*BulkResult, error)
}

BulkOperator provides bulk mutation operations by message IDs. Each method operates on a list of message IDs and returns a BulkResult with per-ID success/failure information.

type BulkResult

type BulkResult struct {
	// Results contains the outcome of each operation in input order.
	Results []OperationResult
}

BulkResult contains the result of a bulk operation. Used consistently across MessageList and DraftList operations.

Results are returned in order, matching the input order. Use helper methods to check status and iterate results.

func (*BulkResult) Err

func (r *BulkResult) Err() error

Err returns an error if there are failures, nil otherwise.

func (*BulkResult) FailedIDs

func (r *BulkResult) FailedIDs() []string

FailedIDs returns the IDs of items that failed.

func (*BulkResult) FailureCount

func (r *BulkResult) FailureCount() int

FailureCount returns the number of failed operations.

func (*BulkResult) HasFailures

func (r *BulkResult) HasFailures() bool

HasFailures returns true if any operations failed.

func (*BulkResult) SuccessCount

func (r *BulkResult) SuccessCount() int

SuccessCount returns the number of successful operations.

func (*BulkResult) SuccessfulIDs

func (r *BulkResult) SuccessfulIDs() []string

SuccessfulIDs returns the IDs of items that succeeded.

func (*BulkResult) TotalCount

func (r *BulkResult) TotalCount() int

TotalCount returns the total number of items processed.

type CleanupExpiredMessagesResult added in v0.6.3

type CleanupExpiredMessagesResult struct {
	// DeletedCount is the number of messages permanently deleted.
	DeletedCount int
	// Interrupted indicates if the cleanup was interrupted (e.g., context cancelled).
	Interrupted bool
}

CleanupExpiredMessagesResult contains the result of a message retention cleanup.

type CleanupTrashResult

type CleanupTrashResult struct {
	// DeletedCount is the number of messages permanently deleted.
	DeletedCount int
	// Interrupted indicates if the cleanup was interrupted (e.g., context cancelled).
	Interrupted bool
}

CleanupTrashResult contains the result of a trash cleanup operation.

type Config added in v0.7.1

type Config struct {

	// TrashCleanupInterval is how often to run automatic trash cleanup.
	// Zero disables automatic trash cleanup (caller must invoke CleanupTrash manually).
	TrashCleanupInterval time.Duration

	// ExpiredMessageCleanupInterval is how often to run automatic expired message cleanup.
	// This covers both global retention (MessageRetention) and per-message TTL expiry.
	// Zero disables automatic cleanup (caller must invoke CleanupExpiredMessages manually).
	ExpiredMessageCleanupInterval time.Duration

	// QuotaEnforcementInterval is how often to run automatic quota enforcement.
	// Requires QuotaUserLister to be set; ignored if nil.
	// Zero disables automatic quota enforcement (caller must invoke EnforceQuotas manually).
	QuotaEnforcementInterval time.Duration

	// QuotaUserLister provides the list of user IDs for background quota enforcement.
	// Required when QuotaEnforcementInterval > 0. If nil, quota enforcement is skipped
	// even when the interval is set.
	QuotaUserLister QuotaUserLister

	// TrashRetention is how long messages stay in trash before cleanup.
	// Default is 30 days. Minimum is 1 day; shorter values are ignored.
	TrashRetention time.Duration

	// MessageRetention is the global message TTL based on creation time.
	// Messages older than this are permanently deleted by CleanupExpiredMessages.
	// Default is 0 (disabled). Minimum is 1 day when enabled.
	MessageRetention time.Duration

	// MaxSubjectLength is the maximum subject length in characters.
	// Default is 998 (RFC 5322).
	MaxSubjectLength int

	// MaxBodySize is the maximum body size in bytes.
	// Default is 10 MB.
	MaxBodySize int

	// MaxAttachmentSize is the maximum size per attachment in bytes.
	// Default is 25 MB.
	MaxAttachmentSize int64

	// MaxAttachmentCount is the maximum number of attachments per message.
	// Default is 20.
	MaxAttachmentCount int

	// MaxRecipientCount is the maximum number of recipients per message.
	// Default is 100.
	MaxRecipientCount int

	// MaxMetadataSize is the maximum total metadata size in bytes.
	// Default is 64 KB.
	MaxMetadataSize int

	// MaxMetadataKeys is the maximum number of metadata keys per message.
	// Default is 100.
	MaxMetadataKeys int

	// MaxHeaderCount is the maximum number of headers per message.
	// Default is 50.
	MaxHeaderCount int

	// MaxHeaderKeyLength is the maximum length of a single header key.
	// Default is 128 bytes.
	MaxHeaderKeyLength int

	// MaxHeaderValueLength is the maximum length of a single header value.
	// Default is 8 KB.
	MaxHeaderValueLength int

	// MaxHeadersTotalSize is the maximum total size of all headers combined.
	// Default is 64 KB.
	MaxHeadersTotalSize int

	// MaxQueryLimit is the maximum number of messages per query.
	// Any query requesting more than this limit will be capped.
	// Default is 100.
	MaxQueryLimit int

	// DefaultQueryLimit is the default number of messages per query
	// when no limit is specified. Capped to MaxQueryLimit.
	// Default is 20.
	DefaultQueryLimit int

	// MaxConcurrentSends is the maximum number of concurrent send operations.
	// Default is 10.
	MaxConcurrentSends int

	// ShutdownTimeout is the maximum time to wait for in-flight operations
	// during graceful shutdown. Minimum is 1 second.
	// Default is 30 seconds.
	ShutdownTimeout time.Duration

	// DefaultTTL is the default per-message time-to-live. When a message is
	// sent without an explicit TTL, this default is applied.
	// Default is 0 (disabled — messages do not expire unless explicitly set).
	DefaultTTL time.Duration

	// MinTTL is the minimum allowed TTL. Messages with a shorter TTL are rejected.
	// Default is 1 minute.
	MinTTL time.Duration

	// MaxTTL is the maximum allowed TTL. Messages with a longer TTL are rejected.
	// Default is 0 (unlimited).
	MaxTTL time.Duration

	// MinScheduleDelay is the minimum allowed schedule delay from now.
	// Default is 0 (no minimum).
	MinScheduleDelay time.Duration

	// MaxScheduleDelay is the maximum allowed schedule delay from now.
	// Default is 0 (unlimited).
	MaxScheduleDelay time.Duration

	// StatsRefreshInterval is the TTL for cached mailbox stats.
	// Default is 30 seconds.
	StatsRefreshInterval time.Duration

	// ClaimInterval is how often the background goroutine scans for orphaned
	// messages in Redis consumer groups. Default is 30 seconds.
	ClaimInterval time.Duration

	// ClaimMinIdle is the minimum idle time before a message is eligible
	// for claiming from dead consumers. Default is 60 seconds.
	ClaimMinIdle time.Duration

	// ClaimBatchSize is the maximum number of orphaned messages to claim per cycle.
	// Default is 100.
	ClaimBatchSize int64

	// EventStreamMaxLen is the approximate maximum number of entries per
	// Redis event stream. Older entries are trimmed automatically on each publish.
	// Default is 10,000 (see DefaultEventStreamMaxLen).
	// Set to -1 to disable trimming (not recommended for production — streams grow without bound).
	EventStreamMaxLen int64
}

Config holds service-level configuration. Zero values use library defaults (see Default* constants in option.go). Use DefaultConfig() for a configuration with all defaults, preserving backward-compatible behavior.

func DefaultConfig added in v0.7.1

func DefaultConfig() Config

DefaultConfig returns a Config with all fields set to their library defaults. Background maintenance tasks are disabled (intervals are zero); enable them by setting the relevant interval fields.

Use DefaultConfig as a starting point and override individual fields:

cfg := mailbox.DefaultConfig()
cfg.TrashCleanupInterval = 1 * time.Hour
cfg.EventStreamMaxLen = 50_000

func (*Config) Validate added in v0.7.3

func (c *Config) Validate() error

Validate checks that all critical resource limits are explicitly bounded. Call this after constructing a Config to catch missing limits before Connect.

Returns a joined error listing every field that is zero or unlimited. Fields that are intentionally unbounded (MessageRetention, MaxTTL, MaxScheduleDelay) are not checked — they have documented "0 = disabled/unlimited" semantics.

type Draft

Draft represents a message being composed. Use Mailbox.Compose() to create a new draft.

Composed of:

  • DraftReader: Read draft content (ID, Subject, Body, etc.)
  • DraftComposer: Fluent setters (SetSubject, SetBody, SetRecipients, SetMetadata)
  • DraftPreparer: Failable operations (AddAttachment, ReplyTo)
  • DraftPublisher: Lifecycle operations (Send, Save)
  • DraftMutator: Mutation operations (Delete)

Usage pattern:

draft, _ := mailbox.Compose()
draft.SetRecipients("user1").SetSubject("Hello").SetBody("World")  // fluent chain
if err := draft.AddAttachment(att); err != nil { ... }             // separate call
if err := draft.ReplyTo(ctx, parentID); err != nil { ... }         // separate call
msg, err := draft.Send(ctx)

type DraftClient

type DraftClient interface {
	DraftLister
	MessageComposer
}

DraftClient provides draft-related operations. Use this interface when you only need draft access.

type DraftComposer

type DraftComposer interface {
	SetRecipients(recipientIDs ...string) DraftComposer
	SetDeliverTo(recipientIDs ...string) DraftComposer
	SetSubject(subject string) DraftComposer
	SetBody(body string) DraftComposer
	SetHeader(key, value string) DraftComposer
	SetMetadata(key string, value any) DraftComposer

	// 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) DraftComposer

	// 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) DraftComposer

	// SetExternalID sets a caller-defined external identifier for correlation
	// with external systems (e.g. SMTP Message-ID). Stored indexed; use
	// store.ExternalIDIs to look up messages by this value.
	SetExternalID(id string) DraftComposer
}

DraftComposer provides fluent setter methods for composing a draft. All setter methods return DraftComposer to enable chaining:

draft.SetRecipients("user1").SetSubject("Hello").SetBody("World")

For operations that can fail (AddAttachment, ReplyTo), use the methods on Draft directly — they are not part of the fluent interface.

type DraftList

type DraftList interface {
	DraftListReader
	DraftListMutator
}

DraftList provides access to a paginated list of drafts with bulk operations.

Composed of:

  • DraftListReader: Read-only access (All, Total, HasMore, NextCursor)
  • DraftListMutator: Bulk mutations (Delete, Send)

type DraftListMutator

type DraftListMutator interface {
	// Delete deletes all drafts in this list.
	Delete(ctx context.Context) (*BulkResult, error)
	// Send sends all drafts in this list.
	// Returns results for each draft (success or failure).
	// Use result.SentMessages() to access the sent messages.
	Send(ctx context.Context) (*DraftSendResult, error)
}

DraftListMutator provides bulk mutation operations on a list of drafts.

type DraftListReader

type DraftListReader interface {
	// All returns all drafts in this list.
	All() []Draft
	// Total returns the total count of drafts matching the query (not just this page).
	Total() int64
	// HasMore returns true if there are more drafts after this page.
	HasMore() bool
	// NextCursor returns the cursor for fetching the next page.
	NextCursor() string
}

DraftListReader provides read-only access to a paginated list of drafts.

type DraftLister

type DraftLister interface {
	Drafts(ctx context.Context, opts store.ListOptions) (DraftList, error)
	GetDraft(ctx context.Context, id string) (Draft, error)
}

DraftLister provides draft listing.

type DraftMutator

type DraftMutator interface {
	// Delete deletes the draft.
	// If the draft was saved, it's permanently deleted from storage.
	// If the draft was not saved, this is a no-op.
	Delete(ctx context.Context) error
}

DraftMutator provides mutation operations on a draft.

type DraftPreparer

type DraftPreparer interface {
	// AddAttachment adds an attachment after validating it.
	// Returns an error if the attachment is invalid or limits are exceeded.
	AddAttachment(attachment store.Attachment) error

	// ReplyTo sets this draft as a reply to another message.
	// It looks up the parent message to inherit the thread ID.
	// Returns an error if the parent message cannot be found or accessed.
	ReplyTo(ctx context.Context, messageID string) error

	// Forward prepares this draft as a forward of another message.
	// Copies subject (with "Fwd:" prefix), body, and attachments from the original.
	// Returns an error if the original message cannot be found or accessed.
	Forward(ctx context.Context, messageID string) error
}

DraftPreparer provides draft preparation methods that can fail. These methods return errors and are intentionally not part of the fluent DraftComposer interface to keep the builder pattern clean.

type DraftPublisher

type DraftPublisher interface {
	// Send validates fully and sends the draft.
	// Requires: at least one recipient and a non-empty subject.
	// Creates recipient copies and moves sender's copy to sent folder.
	// Returns ErrEmptyRecipients if no recipients, ErrEmptySubject if no subject.
	Send(ctx context.Context) (Message, error)

	// Save saves the draft without sending.
	// Allows incomplete drafts (empty recipients, empty subject) for later editing.
	// Validates: body size, metadata size, attachment limits.
	// The draft can be retrieved later via Drafts() and completed.
	Save(ctx context.Context) (Draft, error)
}

DraftPublisher provides lifecycle operations that transform or persist a draft.

Validation differences between Save() and Send():

  • Send() requires: non-empty recipients, non-empty subject, valid content
  • Save() allows: empty recipients, empty subject (for work-in-progress drafts)
  • Both validate: body size limits, metadata limits, attachment limits

This allows users to save incomplete drafts and complete them later.

type DraftReader

type DraftReader interface {
	ID() string
	OwnerID() string
	Subject() string
	Body() string
	RecipientIDs() []string
	DeliverTo() []string
	Headers() map[string]string
	Metadata() map[string]any
	Attachments() []store.Attachment
	ThreadID() string
	ReplyToID() string
	ExternalID() string
}

DraftReader provides read access to draft content.

type DraftSendResult added in v0.3.0

type DraftSendResult struct {
	*BulkResult
	// contains filtered or unexported fields
}

DraftSendResult extends BulkResult with sent messages from DraftList.Send().

func (*DraftSendResult) SentMessages added in v0.3.0

func (r *DraftSendResult) SentMessages() []Message

SentMessages returns all successfully sent messages in order.

type EnforceQuotasResult added in v0.6.3

type EnforceQuotasResult struct {
	// UsersChecked is the number of users whose quotas were evaluated.
	UsersChecked int
	// UsersOverQuota is the number of users found over their quota limit.
	UsersOverQuota int
	// MessagesDeleted is the total number of messages deleted across all users.
	MessagesDeleted int
	// Interrupted indicates if the enforcement was stopped early (e.g., context cancelled).
	Interrupted bool
}

EnforceQuotasResult contains the result of a quota enforcement run.

type EventPublishError

type EventPublishError struct {
	Event     string // The event name (e.g., "MessageSent", "MessageRead")
	MessageID string // The message ID the event was for
	Err       error  // The underlying publish error
}

EventPublishError is returned when event publishing fails but the operation succeeded. The message was sent/read/deleted, but the event notification failed. Check the MessageID field to identify which message this applies to.

func IsEventPublishError

func IsEventPublishError(err error) (*EventPublishError, bool)

IsEventPublishError checks if the error is an event publish error and returns details. This is useful when eventErrorsFatal=true but you still want to know the message was sent.

func (*EventPublishError) Error

func (e *EventPublishError) Error() string

func (*EventPublishError) Unwrap

func (e *EventPublishError) Unwrap() error

type EventPublishFailureFunc

type EventPublishFailureFunc func(eventName string, err error)

EventPublishFailureFunc is called when an event fails to publish. The eventName is the name of the event (e.g., "MessageSent"), and err is the publish error.

type Flags

type Flags struct {
	Read     *bool // nil = no change, true = mark read, false = mark unread
	Archived *bool // nil = no change, true = archive, false = unarchive
}

Flags represents message flags that can be updated atomically. Use nil values to indicate no change.

func MarkArchived

func MarkArchived() Flags

MarkArchived returns flags to archive a message.

func MarkRead

func MarkRead() Flags

MarkRead returns flags to mark a message as read.

func MarkUnarchived

func MarkUnarchived() Flags

MarkUnarchived returns flags to unarchive a message.

func MarkUnread

func MarkUnread() Flags

MarkUnread returns flags to mark a message as unread.

func NewFlags

func NewFlags() Flags

NewFlags creates empty flags (no changes).

func (Flags) WithArchived

func (f Flags) WithArchived(archived bool) Flags

WithArchived returns flags with archived status set.

func (Flags) WithRead

func (f Flags) WithRead(read bool) Flags

WithRead returns flags with read status set.

type FolderInfo

type FolderInfo struct {
	// ID is the folder identifier (e.g., "__inbox", "custom-folder").
	ID string
	// Name is the display name for the folder.
	Name string
	// IsSystem indicates if this is a system folder (starts with "__").
	IsSystem bool
	// MessageCount is the total number of messages in the folder.
	MessageCount int64
	// UnreadCount is the number of unread messages in the folder.
	UnreadCount int64
}

FolderInfo provides information about a folder.

type FolderReader

type FolderReader interface {
	ListFolders(ctx context.Context) ([]FolderInfo, error)
}

FolderReader provides folder information.

type ListOptions

type ListOptions = store.ListOptions

Type aliases for commonly used store types. These allow users to work with the mailbox package without importing store directly.

type Mailbox

Mailbox provides email-like messaging functionality for a user. This is the main interface for mailbox operations.

Composed of focused client interfaces:

  • MessageClient: All message read operations (Get, List, Search, Stream, Threads)
  • DraftClient: Draft operations (List drafts, Compose new)
  • StorageClient: Storage operations (Folders, Attachments)
  • MailboxMutator: Direct mutation by message ID (UpdateFlags, Move, Delete, Tags)
  • MessageSender: Direct message sending without drafts (SendMessage)
  • BulkOperator: Bulk mutations by message IDs (BulkUpdateFlags, BulkMove, etc.)
  • AttachmentResolver: Resolve attachment metadata by ID
  • StatsReader: Aggregate statistics (Stats, UnreadCount)

For applications needing only a subset of functionality, use the focused interfaces directly (MessageClient, DraftClient, StorageClient).

For single message operations via a message handle, use the methods on the Message interface returned by Get().

For bulk operations on listed messages, use the methods on MessageList:

inbox, _ := mailbox.Folder(ctx, store.FolderInbox, opts)
inbox.MarkRead(ctx)           // mark all as read
inbox.Move(ctx, "archive")    // move all to archive
inbox.Delete(ctx)             // delete all

type MailboxMutator

type MailboxMutator interface {
	UpdateFlags(ctx context.Context, messageID string, flags Flags) error
	MoveToFolder(ctx context.Context, messageID string, folderID string, opts ...store.MoveOption) error
	Delete(ctx context.Context, messageID string) error
	Restore(ctx context.Context, messageID string) error
	PermanentlyDelete(ctx context.Context, messageID string) error
	AddTag(ctx context.Context, messageID string, tagID string) error
	RemoveTag(ctx context.Context, messageID string, tagID string) error
	// MarkAllRead marks all unread messages in a folder as read.
	// Uses store.BulkReadMarker for a single database operation when available,
	// falling back to individual MarkRead calls otherwise.
	// Returns the number of messages that were marked as read.
	MarkAllRead(ctx context.Context, folderID string) (int64, error)

	// UpdateByFilter marks messages matching filters as read/unread.
	UpdateByFilter(ctx context.Context, filters []store.Filter, flags Flags) (int64, error)
	// MoveByFilter moves messages matching filters to the given folder.
	MoveByFilter(ctx context.Context, filters []store.Filter, folderID string) (int64, error)
	// DeleteByFilter soft-deletes messages matching filters (moves to trash).
	DeleteByFilter(ctx context.Context, filters []store.Filter) (int64, error)
	// TagByFilter adds a tag to messages matching filters.
	TagByFilter(ctx context.Context, filters []store.Filter, tagID string) (int64, error)
	// UntagByFilter removes a tag from messages matching filters.
	UntagByFilter(ctx context.Context, filters []store.Filter, tagID string) (int64, error)
}

MailboxMutator provides mutation operations on messages by ID. These methods are equivalent to calling Get() then mutating the Message, but skip the intermediate Get for efficiency in server integrations.

type MarkAllReadEvent added in v0.5.0

type MarkAllReadEvent struct {
	UserID   string    `json:"user_id"`
	FolderID string    `json:"folder_id"`
	Count    int64     `json:"count"`
	MarkedAt time.Time `json:"marked_at"`
}

MarkAllReadEvent is published when all messages in a folder are marked as read. Use this for bulk read receipt tracking and cache invalidation.

type Message

type Message interface {
	store.Message
	MessageMutator
}

Message provides access to a message with mutation capabilities.

This is the application-level message type returned by Mailbox operations. It wraps store.Message (the storage-level type) and adds user-scoped mutations. Read methods (GetID, GetSubject, etc.) come from store.Message; mutation methods (Update, Move, Delete, etc.) come from MessageMutator and are scoped to the owning user's mailbox.

Important: Message is a snapshot of state at retrieval time. After mutations, getter methods (GetIsRead, GetFolderID, etc.) may return stale values. To get fresh state after mutations, call Mailbox.Get() again.

Composed of:

  • store.Message: Read-only access (GetID, GetSubject, GetBody, etc.)
  • MessageMutator: Mutations (Update, Move, Delete, AddTag, etc.)

type MessageClient

MessageClient provides all message-related read operations. Use this interface when you only need message access without draft/folder operations.

type MessageComposer

type MessageComposer interface {
	Compose() (Draft, error)
}

MessageComposer provides message composition.

type MessageDeletedEvent

type MessageDeletedEvent struct {
	MessageID string    `json:"message_id"`
	UserID    string    `json:"user_id"`
	FolderID  string    `json:"folder_id"`
	WasUnread bool      `json:"was_unread"`
	DeletedAt time.Time `json:"deleted_at"`
}

MessageDeletedEvent is published when a message is permanently deleted. This event is only published for permanent deletions, not moves to trash.

type MessageIterator

type MessageIterator interface {
	// Next advances to the next message.
	// Returns (true, nil) if there is a message available.
	// Returns (false, nil) if iteration is done (no more messages).
	// Returns (false, error) if an error occurred (e.g., service disconnected, context cancelled).
	// Must be called before accessing Message().
	Next(ctx context.Context) (bool, error)

	// Message returns the current message with full mutation capabilities.
	// Must be called after a successful Next() call that returned (true, nil).
	// Returns ErrIteratorOutOfBounds if called before Next() or after iteration ends.
	Message() (Message, error)
}

MessageIterator provides streaming access to messages. Use Next() to advance, Message() to get current message.

Ownership: MessageIterator holds no resources requiring cleanup. There is no Close method — simply stop calling Next() when done.

Thread Safety: MessageIterator is NOT safe for concurrent use. Each iterator should be used by a single goroutine. If you need concurrent access, create separate iterators for each goroutine.

type MessageLimits

type MessageLimits struct {
	MaxSubjectLength     int
	MaxBodySize          int
	MaxAttachmentSize    int64
	MaxAttachmentCount   int
	MaxRecipientCount    int
	MaxMetadataSize      int
	MaxMetadataKeys      int
	MaxHeaderCount       int
	MaxHeaderKeyLength   int
	MaxHeaderValueLength int
	MaxHeadersTotalSize  int
}

MessageLimits holds all message validation limits. Used to pass limits to validation functions.

func DefaultLimits

func DefaultLimits() MessageLimits

DefaultLimits returns the default message limits.

type MessageList

type MessageList interface {
	MessageListReader
	MessageListMutator
}

MessageList provides access to a paginated list of messages with bulk operations.

Composed of:

  • MessageListReader: Read-only access (All, Total, HasMore, NextCursor)
  • MessageListMutator: Bulk mutations (MarkRead, Move, Delete, AddTag, etc.)

type MessageListMutator

type MessageListMutator interface {
	// MarkRead marks all messages in this list as read.
	MarkRead(ctx context.Context) (*BulkResult, error)
	// MarkUnread marks all messages in this list as unread.
	MarkUnread(ctx context.Context) (*BulkResult, error)
	// Move moves all messages in this list to the specified folder.
	Move(ctx context.Context, folderID string, opts ...store.MoveOption) (*BulkResult, error)
	// Delete moves all messages in this list to trash.
	Delete(ctx context.Context) (*BulkResult, error)
	// AddTag adds a tag to all messages in this list.
	AddTag(ctx context.Context, tagID string) (*BulkResult, error)
	// RemoveTag removes a tag from all messages in this list.
	RemoveTag(ctx context.Context, tagID string) (*BulkResult, error)
}

MessageListMutator provides bulk mutation operations on a list of messages.

type MessageListReader

type MessageListReader interface {
	// All returns all messages in this list.
	All() []Message
	// Total returns the total count of messages matching the query (not just this page).
	Total() int64
	// HasMore returns true if there are more messages after this page.
	HasMore() bool
	// NextCursor returns the cursor for fetching the next page.
	NextCursor() string
}

MessageListReader provides read-only access to a paginated list of messages.

type MessageLister

type MessageLister interface {
	Folder(ctx context.Context, folderID string, opts store.ListOptions) (MessageList, error)
}

MessageLister provides message listing by folder.

type MessageMovedEvent added in v0.3.0

type MessageMovedEvent struct {
	MessageID    string    `json:"message_id"`
	UserID       string    `json:"user_id"`
	FromFolderID string    `json:"from_folder_id"`
	ToFolderID   string    `json:"to_folder_id"`
	WasUnread    bool      `json:"was_unread"`
	MovedAt      time.Time `json:"moved_at"`
}

MessageMovedEvent is published when a message is moved between folders. This event is published for all folder moves including archive, trash, and restore.

type MessageMutator

type MessageMutator interface {
	// Update updates the message flags (read, archived status).
	// Use MarkRead(), MarkUnread(), MarkArchived(), MarkUnarchived() helpers.
	Update(ctx context.Context, flags Flags) error

	// Move moves the message to a folder.
	// When called with store.FromFolder, the move is conditional.
	Move(ctx context.Context, folderID string, opts ...store.MoveOption) error

	// Delete moves the message to trash.
	Delete(ctx context.Context) error

	// Restore restores the message from trash.
	Restore(ctx context.Context) error

	// PermanentlyDelete permanently deletes the message from trash.
	PermanentlyDelete(ctx context.Context) error

	// AddTag adds a tag to the message.
	AddTag(ctx context.Context, tagID string) error

	// RemoveTag removes a tag from the message.
	RemoveTag(ctx context.Context, tagID string) error
}

MessageMutator provides mutation operations on a single message.

type MessageReadEvent

type MessageReadEvent struct {
	MessageID string    `json:"message_id"`
	UserID    string    `json:"user_id"`
	FolderID  string    `json:"folder_id"`
	ReadAt    time.Time `json:"read_at"`
}

MessageReadEvent is published when a message is marked as read. Use this for read receipts and tracking message engagement.

type MessageReader

type MessageReader interface {
	Get(ctx context.Context, messageID string) (Message, error)
}

MessageReader provides single message retrieval.

type MessageReceivedEvent added in v0.3.0

type MessageReceivedEvent struct {
	MessageID   string    `json:"message_id"`
	RecipientID string    `json:"recipient_id"`
	SenderID    string    `json:"sender_id"`
	Subject     string    `json:"subject"`
	ReceivedAt  time.Time `json:"received_at"`
}

MessageReceivedEvent is published when a message is delivered to a recipient. One event is published per recipient. Use this for notifications and presence updates.

type MessageSearcher

type MessageSearcher interface {
	Search(ctx context.Context, query SearchQuery) (MessageList, error)
}

MessageSearcher provides message search capability.

type MessageSender

type MessageSender interface {
	SendMessage(ctx context.Context, req SendRequest) (Message, error)
}

MessageSender provides direct message sending without drafts. This is useful for server integrations where the draft composition flow is handled externally (e.g., via gRPC).

type MessageSentEvent

type MessageSentEvent struct {
	MessageID    string    `json:"message_id"`
	SenderID     string    `json:"sender_id"`
	RecipientIDs []string  `json:"recipient_ids"`
	Subject      string    `json:"subject"`
	SentAt       time.Time `json:"sent_at"`
}

MessageSentEvent is published when a message is sent. This is the primary event for notifying recipients of new messages.

type MessageStreamer

type MessageStreamer interface {
	// Stream returns an iterator for messages matching the given filters.
	// The owner filter and not-deleted filter are automatically added.
	Stream(ctx context.Context, filters []store.Filter, opts StreamOptions) (MessageIterator, error)
	// StreamSearch returns an iterator for search results.
	StreamSearch(ctx context.Context, query SearchQuery, opts StreamOptions) (MessageIterator, error)
}

MessageStreamer provides streaming access to messages. Use streaming for memory-efficient processing of large result sets. For paginated UI with bulk operations, use MessageLister instead.

Example - stream inbox messages:

iter, _ := mb.Stream(ctx, []store.Filter{store.InFolder(store.FolderInbox)}, mailbox.StreamOptions{BatchSize: 100})
for iter.Next(ctx) { msg, _ := iter.Message(); ... }

type OperationResult

type OperationResult struct {
	// ID is the identifier of the item that was processed.
	ID string
	// Success indicates whether the operation succeeded.
	Success bool
	// Error contains the error if the operation failed (nil if successful).
	Error error
}

OperationResult contains the result of a single operation within a bulk operation. Results are returned in the same order as the input items.

type Option

type Option func(*options)

Option configures a mailbox.

func WithAttachmentManager

func WithAttachmentManager(m store.AttachmentManager) Option

WithAttachmentManager sets the attachment manager for reference-counted attachments. When provided, attachments are tracked with reference counting and automatically deleted when no messages reference them.

func WithEventBus added in v0.7.9

func WithEventBus(bus *event.Bus) Option

WithEventBus sets a pre-created event bus for the service. The caller retains ownership of the bus — the service will NOT close it. This is useful in tests where multiple services share a single bus, or when the bus must be configured with specific options (outbox, transport, etc.) before the service is created.

WithEventBus takes priority over WithEventTransport and WithRedisClient. Outbox wiring is skipped because the caller configured the bus.

Shutdown order: always call svc.Close(ctx) BEFORE bus.Close(ctx). The service may publish events during its own shutdown (notifier teardown, plugin hooks), and a closed bus will cause those publishes to fail.

Example:

bus, _ := event.NewBus("test", event.WithTransport(channel.New()))
svc, _ := mailbox.New(cfg, mailbox.WithStore(store), mailbox.WithEventBus(bus))
// ...
svc.Close(ctx) // close service first
bus.Close(ctx) // then close bus

func WithEventErrorsFatal

func WithEventErrorsFatal(fatal bool) Option

WithEventErrorsFatal configures whether event publishing failures should cause the operation to fail. By default, event failures are logged but the operation succeeds (the message is still sent).

Set to true if your application requires guaranteed event delivery, for example when events drive critical downstream processes. Set to false (default) for fire-and-forget event publishing.

func WithEventPublishFailureHandler

func WithEventPublishFailureHandler(fn EventPublishFailureFunc) Option

WithEventPublishFailureHandler sets a callback for event publishing failures. This callback is invoked whenever an event fails to publish (and eventErrorsFatal is false). Use this for custom logging, metrics, or alerting on event failures.

By default, failures are logged using the configured logger.

func WithEventTransport

func WithEventTransport(t transport.Transport) Option

WithEventTransport sets the event transport for publishing and subscribing. When provided, events are published via the given transport for reliable delivery. If not provided, a noop transport is used (events are silently dropped).

Example with Redis:

transport, _ := redis.New(redisClient)
svc, _ := mailbox.New(mailbox.Config{}, mailbox.WithEventTransport(transport))

func WithGlobalQuota added in v0.6.3

func WithGlobalQuota(policy QuotaPolicy) Option

WithGlobalQuota sets a uniform quota policy for all users. This is a convenience wrapper around WithQuotaProvider using a StaticQuotaProvider.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger sets a custom logger.

func WithMeterProvider

func WithMeterProvider(mp metric.MeterProvider) Option

WithMeterProvider sets a custom OpenTelemetry meter provider. Default uses the global meter provider from otel.GetMeterProvider().

func WithMetrics

func WithMetrics(enabled bool) Option

WithMetrics enables or disables OpenTelemetry metrics. When enabled, metrics are collected for all mailbox operations. Default is disabled.

func WithNotificationCoalescing added in v0.6.5

func WithNotificationCoalescing(enabled bool) Option

WithNotificationCoalescing enables event coalescing for notification handlers. When enabled, multiple events for the same message ID within the coalescing window are collapsed — only the latest event is delivered to the notification stream. This reduces SSE noise for rapidly-updated messages (e.g., a message that is sent, then immediately moved or read).

Requires message_id metadata on published events (automatically added). Default is disabled.

func WithNotifier added in v0.6.1

func WithNotifier(n *notify.Notifier) Option

WithNotifier sets the per-user notification system. When provided, the service subscribes to mailbox events using AsWorker (worker model) and pushes notifications to connected users. Use svc.Notifications(ctx, userID, lastEventID) to open a notification stream.

func WithOTel

func WithOTel(enabled bool) Option

WithOTel enables both OpenTelemetry tracing and metrics. This is a convenience function equivalent to calling WithTracing(true) and WithMetrics(true).

func WithPlugin

func WithPlugin(p Plugin) Option

WithPlugin registers a plugin with the mailbox service. Plugins can hook into message lifecycle events. Multiple plugins can be registered by calling this option multiple times.

func WithPlugins

func WithPlugins(plugins ...Plugin) Option

WithPlugins registers multiple plugins at once.

func WithQuotaProvider added in v0.6.3

func WithQuotaProvider(p QuotaProvider) Option

WithQuotaProvider sets a custom quota provider for per-user message limits. The provider is consulted during message delivery to check recipient quotas. When nil (default), quotas are disabled and delivery is unrestricted.

func WithRedisClient

func WithRedisClient(client redis.UniversalClient) Option

WithRedisClient sets a Redis client for the event transport. When provided, events are published to Redis Streams for reliable delivery. If not provided, a noop transport is used (events are silently dropped).

Compatible with *redis.Client, *redis.ClusterClient, and redis.UniversalClient.

func WithRegistrar added in v0.7.1

func WithRegistrar(r Registrar) Option

WithRegistrar sets a registrar that the service uses during Connect to announce this mailbox instance to a shared registry. The registrar returns the mailbox ID assigned to this instance; if Register returns an error, Connect fails. The assigned ID is available via Service.MailboxID().

func WithServiceName

func WithServiceName(name string) Option

WithServiceName sets the service name for OpenTelemetry telemetry. Default is "mailbox".

func WithStatsCache added in v0.7.6

func WithStatsCache(c StatsCache) Option

WithStatsCache sets a distributed cache for aggregate mailbox stats. When set, stats reads check this cache (L2) before the primary store, and event-driven incremental updates propagate to it via HINCRBY. This reduces database load during cold starts and across many instances.

func WithStore

func WithStore(s store.Store) Option

WithStore sets the storage backend (required).

func WithTracerProvider

func WithTracerProvider(tp trace.TracerProvider) Option

WithTracerProvider sets a custom OpenTelemetry tracer provider. Default uses the global tracer provider from otel.GetTracerProvider().

func WithTracing

func WithTracing(enabled bool) Option

WithTracing enables or disables OpenTelemetry tracing. When enabled, spans are created for all mailbox operations. Default is disabled.

func WithUserResolver added in v0.7.1

func WithUserResolver(r UserResolver) Option

WithUserResolver sets an optional user resolver for sender identity enrichment. When configured, the service resolves the sender's identity during message delivery and populates metadata keys (sender.firstname, sender.lastname, sender.email). If resolution fails, the send operation is aborted with ErrUserResolveFailed.

type PartialDeliveryError

type PartialDeliveryError struct {
	// MessageID is the sender's message ID.
	MessageID string
	// DeliveredTo contains recipient IDs that received the message.
	DeliveredTo []string
	// FailedRecipients maps recipient IDs to their delivery errors.
	FailedRecipients map[string]error
}

PartialDeliveryError provides details about which recipients failed. Use the helper methods to determine retry strategy.

func IsPartialDelivery

func IsPartialDelivery(err error) (*PartialDeliveryError, bool)

IsPartialDelivery checks if the error is a partial delivery error and returns details.

func (*PartialDeliveryError) AllFailed

func (e *PartialDeliveryError) AllFailed() bool

AllFailed returns true if no recipients received the message.

func (*PartialDeliveryError) Error

func (e *PartialDeliveryError) Error() string

func (*PartialDeliveryError) HasRetryableFailures

func (e *PartialDeliveryError) HasRetryableFailures() bool

HasRetryableFailures returns true if at least one failure can be retried.

func (*PartialDeliveryError) PermanentFailures

func (e *PartialDeliveryError) PermanentFailures() []string

PermanentFailures returns the list of recipient IDs with permanent failures. These recipients should not be retried as the error is deterministic.

func (*PartialDeliveryError) RetryableRecipients

func (e *PartialDeliveryError) RetryableRecipients() []string

RetryableRecipients returns the list of recipient IDs whose delivery can be retried. An error is considered retryable if it's a temporary/transient failure (e.g., timeout, connection error) rather than a permanent failure (e.g., recipient not found, unauthorized).

func (*PartialDeliveryError) SuccessRate

func (e *PartialDeliveryError) SuccessRate() float64

SuccessRate returns the fraction of recipients that received the message (0.0 to 1.0).

func (*PartialDeliveryError) Unwrap

func (e *PartialDeliveryError) Unwrap() error

type Plugin

type Plugin interface {
	// Name returns the plugin identifier.
	Name() string
	// Init initializes the plugin. Called when service connects.
	Init(ctx context.Context) error
	// Close cleans up plugin resources. Called when service closes.
	Close(ctx context.Context) error
}

Plugin defines the interface for mailbox extensions. Plugins can hook into message sending to add custom behavior such as spam filtering, rate limiting, or content validation.

For observing other operations (read, delete, archive, etc.), use the event system instead (EventMessageSent, EventMessageRead, etc.).

type PluginError

type PluginError struct {
	Plugin string
	Op     string
	Err    error
}

PluginError represents an error from a plugin.

func (*PluginError) Error

func (e *PluginError) Error() string

func (*PluginError) Unwrap

func (e *PluginError) Unwrap() error

type QuotaAction added in v0.6.3

type QuotaAction int

QuotaAction defines what happens when a user exceeds their quota.

const (
	// QuotaActionReject rejects new incoming messages for over-quota users.
	// Delivery to the over-quota recipient fails with ErrQuotaExceeded,
	// while other recipients receive the message normally (partial delivery).
	//
	// Note: The check is best-effort due to the "no distributed locks" principle.
	// Concurrent deliveries may both pass the check before either creates a message,
	// resulting in a small overshoot. The background EnforceQuotas method handles drift.
	QuotaActionReject QuotaAction = iota

	// QuotaActionDeleteOldest accepts all messages and trims old ones in the background.
	// Messages older than QuotaPolicy.DeleteOlderThan are deleted when EnforceQuotas is called.
	// Delivery is never blocked; enforcement happens via the caller-scheduled EnforceQuotas method.
	QuotaActionDeleteOldest
)

func (QuotaAction) String added in v0.6.3

func (a QuotaAction) String() string

String returns a human-readable name for the quota action.

type QuotaExceededError added in v0.6.3

type QuotaExceededError struct {
	UserID       string
	CurrentCount int64
	MaxMessages  int64
}

QuotaExceededError provides details about a quota violation.

func (*QuotaExceededError) Error added in v0.6.3

func (e *QuotaExceededError) Error() string

func (*QuotaExceededError) Unwrap added in v0.6.3

func (e *QuotaExceededError) Unwrap() error

type QuotaPolicy added in v0.6.3

type QuotaPolicy struct {
	// MaxMessages is the maximum number of messages allowed in a user's mailbox.
	// Set to 0 for unlimited (no quota enforcement).
	MaxMessages int64

	// ExceedAction determines what happens when the quota is exceeded.
	// Default is QuotaActionReject.
	ExceedAction QuotaAction

	// DeleteOlderThan is the minimum age of messages eligible for deletion
	// when ExceedAction is QuotaActionDeleteOldest. Messages younger than
	// this duration are never deleted by quota enforcement.
	// Must be positive when ExceedAction is QuotaActionDeleteOldest;
	// enforcement is skipped if zero to prevent accidental deletion of recent messages.
	DeleteOlderThan time.Duration
}

QuotaPolicy defines the quota limits and enforcement behavior for a user.

type QuotaProvider added in v0.6.3

type QuotaProvider interface {
	// GetQuota returns the quota policy for the given user.
	// Return a nil policy or a policy with MaxMessages=0 for unlimited quota.
	GetQuota(ctx context.Context, userID string) (*QuotaPolicy, error)
}

QuotaProvider resolves quota policies for users. Implementations can look up per-user quotas from any source (database, config, etc.).

type QuotaUserLister added in v0.7.1

type QuotaUserLister interface {
	// ListUsers returns user IDs that should be checked for quota enforcement.
	ListUsers(ctx context.Context) ([]string, error)
}

QuotaUserLister provides the list of user IDs for background quota enforcement. Implementations should be safe for concurrent use.

type Recipient added in v0.3.0

type Recipient struct {
	// UserID is the unique user identifier.
	UserID string
	// Name is the display name of the user.
	Name string
	// Email is the user's email address (optional).
	Email string
}

Recipient contains resolved information about a user.

type RecipientResolver added in v0.3.0

type RecipientResolver interface {
	// Resolve returns recipient information for a single user ID.
	// Returns ErrRecipientNotFound if the user ID is unknown.
	Resolve(ctx context.Context, userID string) (*Recipient, error)

	// ResolveBatch returns recipient information for multiple user IDs.
	// Returns results in the same order as input. Unknown IDs have nil entries.
	ResolveBatch(ctx context.Context, userIDs []string) ([]*Recipient, error)
}

RecipientResolver maps user IDs to recipient information. Implementations should be safe for concurrent use.

Example use cases:

  • Populate "From" display names in inbox views
  • Resolve email addresses for notification delivery
  • Validate that recipient IDs are valid users

type Registrar added in v0.9.0

type Registrar interface {
	// Register announces this mailbox instance and returns the mailbox ID
	// assigned by the registrar. Returning an error aborts Connect.
	Register(ctx context.Context) (mailboxID string, err error)
}

Registrar registers a mailbox instance in a shared registry so that Routers can discover it. A mailbox calls Register during Connect; if Register returns an error, Connect fails. The returned mailbox ID is assigned by the registrar and stored on the service for its lifecycle.

Implementations must be safe for concurrent use.

type Router added in v0.9.0

type Router interface {
	// Route returns the Mailbox that holds messages for the given user.
	// Returns an error if the user is unknown or the lookup fails.
	Route(ctx context.Context, userID string) (Mailbox, error)
}

Router resolves a user ID to the Mailbox that owns their messages.

It decouples "where are this user's messages stored" from user identity, so user and mailbox registries can evolve and scale independently. Implementations might back this with a consistent-hash ring, a database shard table, or a single-node no-op that returns the local service's client.

Implementations must be safe for concurrent use.

type SearchQuery

type SearchQuery = store.SearchQuery

Type aliases for commonly used store types. These allow users to work with the mailbox package without importing store directly.

type SendHook

type SendHook interface {
	Plugin
	// BeforeSend is called before a draft is sent. Return an error to abort.
	// Use this for spam filtering, rate limiting, or content validation.
	BeforeSend(ctx context.Context, userID string, draft store.DraftMessage) error
	// AfterSend is called after a message is successfully sent.
	// Return an error to signal post-send failures (e.g., notification errors).
	// Note: The message is already sent and cannot be rolled back.
	AfterSend(ctx context.Context, userID string, msg store.Message) error
}

SendHook is called before/after sending messages. This is the primary extension point for message validation and filtering.

type SendRequest

type SendRequest struct {
	RecipientIDs  []string
	Subject       string
	Body          string
	Headers       map[string]string
	Metadata      map[string]any
	Attachments   []store.Attachment
	AttachmentIDs []string
	ThreadID      string
	ReplyToID     string
	ExternalID    string // caller-defined external identifier (e.g. SMTP Message-ID); stored indexed

	// DeliverTo optionally restricts which recipients receive inbox copies
	// on this instance. The message's RecipientIDs stores the full recipient
	// list for display. When empty, delivery targets default to RecipientIDs.
	DeliverTo []string

	// TTL is an optional message time-to-live. When set, the message will be
	// eligible for automatic deletion after this duration from send time.
	// Zero means no per-message TTL (the service default TTL may still apply).
	TTL time.Duration

	// ScheduleAt is an optional delivery schedule. When set, the message is
	// hidden from recipient queries until this UTC time.
	// Nil means the message is immediately available.
	ScheduleAt *time.Time
}

SendRequest contains the data needed to send a message directly, without going through the draft composition flow.

type Service

type Service interface {
	// IsConnected returns true if the service is connected and ready.
	IsConnected() bool

	// Connect establishes connections to storage backends and starts
	// background maintenance goroutines (if configured via Config).
	Connect(ctx context.Context) error
	// Close stops background goroutines, waits for in-flight operations,
	// and closes all connections.
	Close(ctx context.Context) error
	// Client returns a mailbox client for the given user.
	// The returned client shares the service's connections.
	// Connection state is checked lazily on each operation; if the service
	// is not connected, operations will return ErrNotConnected.
	Client(userID string) Mailbox
	// CleanupTrash permanently deletes messages that have been in trash
	// longer than the configured retention period. Called automatically
	// when Config.TrashCleanupInterval is set; can also be called manually.
	CleanupTrash(ctx context.Context) (*CleanupTrashResult, error)
	// CleanupExpiredMessages permanently deletes messages older than the
	// configured message retention period (based on created_at).
	// Returns a zero result if message retention is not configured.
	// Called automatically when Config.ExpiredMessageCleanupInterval is set.
	CleanupExpiredMessages(ctx context.Context) (*CleanupExpiredMessagesResult, error)
	// EnforceQuotas evaluates quotas for the given users and applies enforcement
	// actions. Only users with QuotaActionDeleteOldest policies are processed;
	// QuotaActionReject is enforced at delivery time. Called automatically when
	// Config.QuotaEnforcementInterval and Config.QuotaUserLister are set.
	EnforceQuotas(ctx context.Context, userIDs []string) (*EnforceQuotasResult, error)
	// RunQuotaEnforcement lists all users via the configured QuotaUserLister and
	// calls EnforceQuotas for them. Returns ErrQuotaUserListerNotConfigured when no
	// lister is set. Prefer this over EnforceQuotas in admin/on-demand contexts
	// because it does not accept user IDs from external callers.
	RunQuotaEnforcement(ctx context.Context) (*EnforceQuotasResult, error)

	// Events returns per-service event instances for subscribing and publishing.
	// Each service has its own events bound to its own event bus, enabling
	// independent event routing and parallel testing.
	Events() *ServiceEvents

	// Notifications returns a real-time notification stream for the given user.
	// Requires WithNotifier to be configured. Returns ErrNotifierNotConfigured otherwise.
	// lastEventID enables backfill of missed events ("" for new events only).
	// The caller must close the returned Stream when done.
	Notifications(ctx context.Context, userID string, lastEventID string) (notify.Stream, error)

	// MailboxID returns the mailbox ID assigned by the Registrar during Connect.
	// Returns an empty string when no registrar was configured or Connect has not
	// completed successfully.
	MailboxID() string

	// ThreadParticipants returns the distinct user IDs of all non-deleted participants
	// in the given thread. A participant is any user who has a non-trash copy of a
	// message with that thread_id (sender or recipient).
	//
	// This is a cross-owner query used by external delivery systems (e.g., SMTP gateways)
	// that know the thread_id but not the individual recipient IDs:
	//
	//	participants, err := svc.ThreadParticipants(ctx, threadID)
	//	if err != nil { ... }
	//	_, err = svc.Client(senderID).SendMessage(ctx, mailbox.SendRequest{
	//	    RecipientIDs: participants,
	//	    ThreadID:     threadID,
	//	    Subject:      subject,
	//	    Body:         body,
	//	})
	//
	// Returns store.ErrNotFound when no messages exist for the given thread_id.
	ThreadParticipants(ctx context.Context, threadID string) ([]string, error)
}

Service manages the mailbox system (server-side). It handles connections to storage and creates mailbox clients.

func New added in v0.7.1

func New(cfg Config, opts ...Option) (Service, error)

New creates a new mailbox service with the given configuration and options. Call Connect() to establish connections to backends and start background tasks.

When Config specifies non-zero cleanup intervals, background goroutines are started during Connect() and stopped during Close(). Close() blocks until all background goroutines have finished.

Caching is NOT included in this library. If you need caching, wrap your store with a caching decorator (see store/cached package for an example).

Example

ExampleNew demonstrates creating and connecting a mailbox service.

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/rbaliyan/mailbox"
	"github.com/rbaliyan/mailbox/store/memory"
)

func main() {
	svc, err := mailbox.New(mailbox.Config{},
		mailbox.WithStore(memory.New()),
		// Production: use mailbox.WithRedisClient(redisClient) for events
		// Production: use mongostore.New(mongoClient) for storage
	)
	if err != nil {
		log.Fatal(err)
	}

	ctx := context.Background()
	if err := svc.Connect(ctx); err != nil {
		log.Fatal(err)
	}
	defer svc.Close(ctx)

	fmt.Println("connected:", svc.IsConnected())
}
Output:
connected: true
Example (WithConfig)

ExampleNew_withConfig demonstrates all major configuration options.

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/rbaliyan/mailbox"
	"github.com/rbaliyan/mailbox/store/memory"
)

func main() {
	svc, err := mailbox.New(mailbox.Config{
		// Message limits
		MaxBodySize:        5 * 1024 * 1024,  // 5 MB
		MaxSubjectLength:   200,              // 200 chars
		MaxRecipientCount:  50,               // 50 recipients
		MaxAttachmentCount: 10,               // 10 attachments
		MaxAttachmentSize:  50 * 1024 * 1024, // 50 MB per attachment

		// Query limits
		MaxQueryLimit:     200,
		DefaultQueryLimit: 25,

		// Trash and retention
		TrashRetention:   7 * 24 * time.Hour,  // 7 days
		MessageRetention: 90 * 24 * time.Hour, // 90 days

		// Concurrency
		MaxConcurrentSends: 20,
		ShutdownTimeout:    60 * time.Second,
	},
		mailbox.WithStore(memory.New()),
	)
	if err != nil {
		log.Fatal(err)
	}

	ctx := context.Background()
	if err := svc.Connect(ctx); err != nil {
		log.Fatal(err)
	}
	defer svc.Close(ctx)

	fmt.Println("connected:", svc.IsConnected())
}
Output:
connected: true

type ServiceEvents

type ServiceEvents struct {
	// MessageSent is published when a message is sent.
	MessageSent event.Event[MessageSentEvent]

	// MessageReceived is published when a message is delivered to a recipient.
	MessageReceived event.Event[MessageReceivedEvent]

	// MessageRead is published when a message is marked as read.
	MessageRead event.Event[MessageReadEvent]

	// MessageDeleted is published when a message is permanently deleted.
	MessageDeleted event.Event[MessageDeletedEvent]

	// MessageMoved is published when a message is moved between folders.
	MessageMoved event.Event[MessageMovedEvent]

	// MarkAllRead is published when all messages in a folder are marked as read.
	MarkAllRead event.Event[MarkAllReadEvent]
}

ServiceEvents provides access to per-service event instances. Each service creates its own events bound to its own event bus, enabling independent event routing and parallel testing.

Subscribe to events:

svc.Events().MessageSent.Subscribe(ctx, handler)
svc.Events().MessageRead.Subscribe(ctx, handler)
svc.Events().MessageDeleted.Subscribe(ctx, handler)

type SortOrder

type SortOrder = store.SortOrder

Type aliases for commonly used store types. These allow users to work with the mailbox package without importing store directly.

type StaticQuotaProvider added in v0.6.3

type StaticQuotaProvider struct {
	Policy QuotaPolicy
}

StaticQuotaProvider returns the same quota policy for all users. Use this for global quotas that apply uniformly.

func (*StaticQuotaProvider) GetQuota added in v0.6.3

GetQuota returns the static policy for any user.

type StatsCache added in v0.7.6

type StatsCache interface {
	// Get retrieves cached stats for a user. Returns nil, nil when not found.
	Get(ctx context.Context, ownerID string) (*store.MailboxStats, error)

	// Set stores stats for a user with the given TTL.
	Set(ctx context.Context, ownerID string, stats *store.MailboxStats, ttl time.Duration) error

	// IncrBy atomically increments a named counter for a user.
	// field is one of the well-known keys defined in the statscache package.
	IncrBy(ctx context.Context, ownerID string, field string, delta int64) error

	// Invalidate removes cached stats for a user, forcing the next read
	// to fall through to the store.
	Invalidate(ctx context.Context, ownerID string) error
}

StatsCache is an optional distributed cache for mailbox stats. When configured via WithStatsCache, it acts as an L2 cache behind the in-process sync.Map (L1), enabling consistent stats across instances. Implementations must be safe for concurrent use.

type StatsReader added in v0.3.0

type StatsReader interface {
	// Stats returns aggregate statistics for this user's mailbox.
	// Results are cached with event-driven incremental updates and periodic TTL refresh.
	Stats(ctx context.Context) (*store.MailboxStats, error)
	// UnreadCount returns the total unread message count for this user.
	// This is a convenience method equivalent to calling Stats() and reading UnreadCount.
	UnreadCount(ctx context.Context) (int64, error)
}

StatsReader provides access to aggregate mailbox statistics.

type StorageClient

type StorageClient interface {
	FolderReader
	AttachmentLoader
}

StorageClient provides folder and attachment operations. Use this interface when you only need storage metadata access.

type StreamOptions

type StreamOptions struct {
	// BatchSize is the number of messages fetched per batch.
	// Larger batches reduce round-trips but use more memory.
	// Default: 100
	BatchSize int
}

StreamOptions configures streaming behavior.

type ThreadReader

type ThreadReader interface {
	// GetThread returns all messages in a thread, ordered by creation time.
	GetThread(ctx context.Context, threadID string, opts store.ListOptions) (MessageList, error)
	// GetReplies returns all direct replies to a message.
	GetReplies(ctx context.Context, messageID string, opts store.ListOptions) (MessageList, error)
}

ThreadReader provides access to message threads.

type User added in v0.7.1

type User interface {
	// ID returns the unique identifier for this principal (typically their email address).
	ID() string
	FirstName() string
	LastName() string
	Email() string
	// Type classifies the principal: "human", "agent", "service", or "" (unset).
	Type() string
	// PublicKey returns the principal's Ed25519 public key, base64-encoded.
	// Empty for human users; set for agents and services that sign their payloads.
	PublicKey() string
	// Capabilities returns routing and capability metadata: skills, endpoint, region, model, etc.
	// Callers must not mutate the returned map.
	Capabilities() map[string]string
}

User is the full principal — identity, type, capabilities, and signing key. Implementations should be safe for concurrent use.

type UserResolver added in v0.7.1

type UserResolver interface {
	ResolveUser(ctx context.Context, userID string) (User, error)
}

UserResolver resolves user IDs to the full User principal. When configured via WithUserResolver, the service calls ResolveUser during message delivery to populate sender metadata. If resolution fails, the send operation is aborted.

Implementations should be safe for concurrent use.

type ValidationError

type ValidationError struct {
	Field   string // The field that failed validation
	Message string // Human-readable error message
}

ValidationError provides details about a validation failure.

func (*ValidationError) Error

func (e *ValidationError) Error() string

func (*ValidationError) Unwrap

func (e *ValidationError) Unwrap() error

Directories

Path Synopsis
Package admin provides an HTTP management surface for mailbox service operations.
Package admin provides an HTTP management surface for mailbox service operations.
Package compress provides message body compression as a mailbox plugin.
Package compress provides message body compression as a mailbox plugin.
Package content provides a content-type codec layer for mailbox messages.
Package content provides a content-type codec layer for mailbox messages.
Package crypto provides optional E2E encryption for mailbox messages.
Package crypto provides optional E2E encryption for mailbox messages.
Package mailboxtest provides test helpers for mailbox integration tests.
Package mailboxtest provides test helpers for mailbox integration tests.
Package notify provides per-user real-time notification streams.
Package notify provides per-user real-time notification streams.
memory
Package memory provides an in-memory notify.Store for testing.
Package memory provides an in-memory notify.Store for testing.
redis
Package redis provides a Redis Streams-backed notify.Store and notify.StreamStore.
Package redis provides a Redis Streams-backed notify.Store and notify.StreamStore.
webhook
Package webhook provides a notify.Router that delivers notification events to configured HTTP endpoints via signed JSON POST requests.
Package webhook provides a notify.Router that delivers notification events to configured HTTP endpoints via signed JSON POST requests.
Package presence provides user online/offline tracking with optional routing.
Package presence provides user online/offline tracking with optional routing.
memory
Package memory provides an in-memory presence.Tracker for testing.
Package memory provides an in-memory presence.Tracker for testing.
redis
Package redis provides a Redis Hash-backed presence.Tracker.
Package redis provides a Redis Hash-backed presence.Tracker.
proto
Package resolver provides RecipientResolver and UserResolver implementations.
Package resolver provides RecipientResolver and UserResolver implementations.
Package retry provides exponential backoff retry logic for transient failures.
Package retry provides exponential backoff retry logic for transient failures.
Package router held the Router and Registrar interfaces.
Package router held the Router and Registrar interfaces.
Package rules provides a CEL-based message rules engine for the mailbox library.
Package rules provides a CEL-based message rules engine for the mailbox library.
Package search provides a pluggable full-text search layer for the mailbox library.
Package search provides a pluggable full-text search layer for the mailbox library.
elasticsearch
Package elasticsearch provides an Elasticsearch-backed search.Provider.
Package elasticsearch provides an Elasticsearch-backed search.Provider.
meilisearch
Package meilisearch provides a Meilisearch-backed search.Provider.
Package meilisearch provides a Meilisearch-backed search.Provider.
Package server provides a gRPC service implementation for the mailbox library.
Package server provides a gRPC service implementation for the mailbox library.
statscache
redis
Package redis provides a Redis-backed distributed stats cache for mailbox.
Package redis provides a Redis-backed distributed stats cache for mailbox.
Package store provides interfaces and types for mailbox storage.
Package store provides interfaces and types for mailbox storage.
attachment/azblob
Package azblob provides an Azure Blob Storage-based attachment file store.
Package azblob provides an Azure Blob Storage-based attachment file store.
attachment/cached
Package cached provides a file-based caching wrapper for attachment stores.
Package cached provides a file-based caching wrapper for attachment stores.
attachment/gcs
Package gcs provides a Google Cloud Storage-based attachment file store.
Package gcs provides a Google Cloud Storage-based attachment file store.
attachment/local
Package local provides a local filesystem-based attachment file store.
Package local provides a local filesystem-based attachment file store.
attachment/otel
Package otel provides OpenTelemetry instrumentation for attachment stores.
Package otel provides OpenTelemetry instrumentation for attachment stores.
attachment/s3
Package s3 provides an S3-based attachment file store.
Package s3 provides an S3-based attachment file store.
memory
Package memory provides an in-memory Store implementation for testing.
Package memory provides an in-memory Store implementation for testing.
mongo
Package mongo provides a MongoDB implementation of store.Store.
Package mongo provides a MongoDB implementation of store.Store.
postgres
Package postgres provides a PostgreSQL implementation of store.Store.
Package postgres provides a PostgreSQL implementation of store.Store.
storetest
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.

Jump to

Keyboard shortcuts

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