In-place upgrade tool for Stalwart Mail Server (0.15.5 -> latest) with checkpointed rollback and post-migration validation. Design stage; see ARCHITECTURE.md.
74 lines
2.5 KiB
Go
74 lines
2.5 KiB
Go
package stalwartapi
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Client is the shared JMAP/management-API client every migration phase
|
|
// uses to talk to a Stalwart instance. It stays deliberately thin: phases
|
|
// that need Stalwart-specific behavior (recovery-mode control, apply-plan
|
|
// replay, account/mailbox introspection) get methods added here only as
|
|
// their wire-level details are confirmed against Stalwart's actual source
|
|
// and documentation, never guessed - see management.go and mailbox.go for
|
|
// what that grounding looked like for account enumeration and mailbox
|
|
// counts respectively.
|
|
type Client struct {
|
|
BaseURL string // e.g. "https://mail.example.com"
|
|
Username string
|
|
Password string
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
func (c *Client) httpClient() *http.Client {
|
|
if c.HTTPClient != nil {
|
|
return c.HTTPClient
|
|
}
|
|
return &http.Client{Timeout: 15 * time.Second}
|
|
}
|
|
|
|
// Ping confirms the instance is reachable and the given credentials are
|
|
// accepted, via JMAP session discovery (RFC 8620 §2, the well-known
|
|
// /.well-known/jmap endpoint) over HTTP Basic auth. This is preflight's
|
|
// "dry-run" reachability check (ARCHITECTURE.md §4.1): it does nothing but
|
|
// read the session document, so it's safe to run against a live production
|
|
// server before anything else in the migration happens.
|
|
func (c *Client) Ping(ctx context.Context) error {
|
|
url := strings.TrimRight(c.BaseURL, "/") + "/.well-known/jmap"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.SetBasicAuth(c.Username, c.Password)
|
|
resp, err := c.httpClient().Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("stalwartapi: reach %s: %w", url, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("stalwartapi: session request to %s returned %s", url, resp.Status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Snapshot mirrors checkpoint.PreflightSnapshot's shape without this
|
|
// package depending on the checkpoint package's format.
|
|
type Snapshot struct {
|
|
AccountCount int
|
|
Domains []string
|
|
MailboxCounts map[string][]MailboxCount // account email -> its mailboxes
|
|
// MailboxErrors records, per account email, why that account's mailbox
|
|
// counts couldn't be captured (e.g. impersonation not permitted for
|
|
// that account). A non-empty entry here means MailboxCounts has no
|
|
// entry for that account - it's not silently treated as zero messages.
|
|
MailboxErrors map[string]string
|
|
}
|
|
|
|
type MailboxCount struct {
|
|
Mailbox string
|
|
Messages int
|
|
}
|