- Added configuration options for Stalwart hosted mail in .env.example. - Updated Docker Compose to include Stalwart service with health checks. - Introduced new API endpoints for managing mail domains and migration projects. - Enhanced Authentik blueprints for user enrollment and post-migration security. - Updated OAuth handling for Google and Microsoft migration processes. - Improved error handling and response structures in the mail API. - Added integration tests for email claiming and migration workflows.
234 lines
5.5 KiB
Go
234 lines
5.5 KiB
Go
package stalwart
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
ErrDisabled = errors.New("stalwart client disabled")
|
|
ErrNotFound = errors.New("stalwart resource not found")
|
|
)
|
|
|
|
type Config struct {
|
|
Enabled bool
|
|
BaseURL string
|
|
APIKey string
|
|
IMAPHost string
|
|
IMAPPort int
|
|
IMAPTLS bool
|
|
SMTPHost string
|
|
SMTPPort int
|
|
SMTPTLS bool
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
type Client struct {
|
|
cfg Config
|
|
}
|
|
|
|
func NewClient(cfg Config) *Client {
|
|
if cfg.HTTPClient == nil {
|
|
cfg.HTTPClient = &http.Client{Timeout: 30 * time.Second}
|
|
}
|
|
if cfg.IMAPPort == 0 {
|
|
cfg.IMAPPort = 993
|
|
}
|
|
if cfg.SMTPPort == 0 {
|
|
cfg.SMTPPort = 587
|
|
}
|
|
return &Client{cfg: cfg}
|
|
}
|
|
|
|
func (c *Client) Enabled() bool {
|
|
return c != nil && c.cfg.Enabled && strings.TrimSpace(c.cfg.BaseURL) != ""
|
|
}
|
|
|
|
func (c *Client) IMAPEndpoint() (host string, port int, tls bool) {
|
|
return c.cfg.IMAPHost, c.cfg.IMAPPort, c.cfg.IMAPTLS
|
|
}
|
|
|
|
func (c *Client) SMTPEndpoint() (host string, port int, tls bool) {
|
|
return c.cfg.SMTPHost, c.cfg.SMTPPort, c.cfg.SMTPTLS
|
|
}
|
|
|
|
type Domain struct {
|
|
ID string
|
|
Name string
|
|
}
|
|
|
|
type Account struct {
|
|
ID string
|
|
Email string
|
|
}
|
|
|
|
type jmapRequest struct {
|
|
Using []string `json:"using"`
|
|
MethodCalls []any `json:"methodCalls"`
|
|
}
|
|
|
|
type jmapResponse struct {
|
|
MethodResponses []json.RawMessage `json:"methodResponses"`
|
|
}
|
|
|
|
func (c *Client) call(ctx context.Context, method string, args any, callID string) (json.RawMessage, error) {
|
|
if !c.Enabled() {
|
|
return nil, ErrDisabled
|
|
}
|
|
body, err := json.Marshal(jmapRequest{
|
|
Using: []string{"urn:ietf:params:jmap:core", "urn:stalwart:jmap"},
|
|
MethodCalls: []any{[]any{method, args, callID}},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
url := strings.TrimRight(c.cfg.BaseURL, "/") + "/api"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if c.cfg.APIKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
|
|
}
|
|
resp, err := c.cfg.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("stalwart api %s: %s", resp.Status, strings.TrimSpace(string(raw)))
|
|
}
|
|
var envelope jmapResponse
|
|
if err := json.Unmarshal(raw, &envelope); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(envelope.MethodResponses) == 0 {
|
|
return nil, fmt.Errorf("stalwart: empty method response")
|
|
}
|
|
var parts []json.RawMessage
|
|
if err := json.Unmarshal(envelope.MethodResponses[0], &parts); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(parts) < 2 {
|
|
return nil, fmt.Errorf("stalwart: malformed method response")
|
|
}
|
|
var status string
|
|
if err := json.Unmarshal(parts[0], &status); err != nil {
|
|
return nil, err
|
|
}
|
|
if status == "error" {
|
|
return nil, fmt.Errorf("stalwart error: %s", string(parts[1]))
|
|
}
|
|
return parts[1], nil
|
|
}
|
|
|
|
func (c *Client) CreateDomain(ctx context.Context, name string) (Domain, error) {
|
|
name = strings.ToLower(strings.TrimSpace(name))
|
|
if name == "" {
|
|
return Domain{}, fmt.Errorf("domain name required")
|
|
}
|
|
if !c.Enabled() {
|
|
return Domain{ID: "local-" + name, Name: name}, nil
|
|
}
|
|
raw, err := c.call(ctx, "x:Domain/set", map[string]any{
|
|
"create": map[string]any{
|
|
"d1": map[string]any{"name": name},
|
|
},
|
|
}, "c1")
|
|
if err != nil {
|
|
return Domain{}, err
|
|
}
|
|
var parsed struct {
|
|
Created map[string]struct {
|
|
ID string `json:"id"`
|
|
} `json:"created"`
|
|
}
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
return Domain{}, err
|
|
}
|
|
for _, v := range parsed.Created {
|
|
return Domain{ID: v.ID, Name: name}, nil
|
|
}
|
|
return Domain{}, fmt.Errorf("stalwart: domain not created")
|
|
}
|
|
|
|
func (c *Client) CreateAccount(ctx context.Context, domainID, localPart, password string, quotaBytes int64) (Account, error) {
|
|
localPart = strings.ToLower(strings.TrimSpace(localPart))
|
|
if localPart == "" {
|
|
return Account{}, fmt.Errorf("local part required")
|
|
}
|
|
if !c.Enabled() {
|
|
return Account{ID: "local-" + localPart, Email: localPart + "@local"}, nil
|
|
}
|
|
fields := map[string]any{
|
|
"name": localPart,
|
|
"domainId": domainID,
|
|
}
|
|
if password != "" {
|
|
fields["credentials"] = map[string]any{"password": password}
|
|
}
|
|
if quotaBytes > 0 {
|
|
fields["quota"] = map[string]any{"maxDiskQuota": quotaBytes}
|
|
}
|
|
raw, err := c.call(ctx, "x:Account/set", map[string]any{
|
|
"create": map[string]any{"a1": fields},
|
|
}, "c1")
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
var parsed struct {
|
|
Created map[string]struct {
|
|
ID string `json:"id"`
|
|
} `json:"created"`
|
|
}
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
return Account{}, err
|
|
}
|
|
for _, v := range parsed.Created {
|
|
return Account{ID: v.ID, Email: localPart}, nil
|
|
}
|
|
return Account{}, fmt.Errorf("stalwart: account not created")
|
|
}
|
|
|
|
func (c *Client) SetAccountPassword(ctx context.Context, accountID, password string) error {
|
|
if password == "" {
|
|
return fmt.Errorf("password required")
|
|
}
|
|
if !c.Enabled() {
|
|
return nil
|
|
}
|
|
_, err := c.call(ctx, "x:Account/set", map[string]any{
|
|
"update": map[string]any{
|
|
accountID: map[string]any{
|
|
"credentials": map[string]any{"password": password},
|
|
},
|
|
},
|
|
}, "c1")
|
|
return err
|
|
}
|
|
|
|
func (c *Client) DeleteAccount(ctx context.Context, accountID string) error {
|
|
if accountID == "" {
|
|
return nil
|
|
}
|
|
if !c.Enabled() {
|
|
return nil
|
|
}
|
|
_, err := c.call(ctx, "x:Account/set", map[string]any{
|
|
"destroy": []string{accountID},
|
|
}, "c1")
|
|
return err
|
|
}
|