ultisuite-backend/internal/mail/hosted/service.go
R3D347HR4Y 7143a36c19
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
feat(mail): integrate Stalwart hosted mail and migration features
- 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.
2026-06-13 12:47:08 +02:00

436 lines
14 KiB
Go

package hosted
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/mail/credentials"
"github.com/ultisuite/ulti-backend/internal/mail/stalwart"
)
var (
ErrAddressTaken = errors.New("mail address already taken")
ErrInvalidLocalPart = errors.New("invalid local part")
ErrDomainNotActive = errors.New("mail domain not active")
)
type Service struct {
db *pgxpool.Pool
stlw *stalwart.Client
creds *credentials.Manager
imapHost string
imapPort int
imapTLS bool
smtpHost string
smtpPort int
smtpTLS bool
}
func NewService(db *pgxpool.Pool, stlw *stalwart.Client, creds *credentials.Manager) *Service {
s := &Service{db: db, stlw: stlw, creds: creds}
if stlw != nil {
s.imapHost, s.imapPort, s.imapTLS = stlw.IMAPEndpoint()
s.smtpHost, s.smtpPort, s.smtpTLS = stlw.SMTPEndpoint()
}
return s
}
type DomainRow struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
VerificationToken string `json:"verification_token,omitempty"`
DKIMSelector string `json:"dkim_selector,omitempty"`
DKIMPublicKey string `json:"dkim_public_key,omitempty"`
StalwartDomainID string `json:"stalwart_domain_id,omitempty"`
IsPlatformDomain bool `json:"is_platform_domain"`
MXVerifiedAt *string `json:"mx_verified_at,omitempty"`
TXTVerifiedAt *string `json:"txt_verified_at,omitempty"`
CreatedAt string `json:"created_at"`
}
type MailboxRow struct {
ID string `json:"id"`
DomainID string `json:"domain_id"`
LocalPart string `json:"local_part"`
Email string `json:"email"`
UserID string `json:"user_id,omitempty"`
MailAccountID string `json:"mail_account_id,omitempty"`
StalwartAccountID string `json:"stalwart_account_id,omitempty"`
QuotaBytes int64 `json:"quota_bytes"`
Status string `json:"status"`
}
func normalizeLocalPart(v string) (string, error) {
v = strings.ToLower(strings.TrimSpace(v))
if v == "" || len(v) > 64 {
return "", ErrInvalidLocalPart
}
for _, ch := range v {
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '.' || ch == '-' || ch == '_' || ch == '+' {
continue
}
return "", ErrInvalidLocalPart
}
return v, nil
}
func (s *Service) IsAddressAvailable(ctx context.Context, domainName, localPart string) (bool, error) {
localPart, err := normalizeLocalPart(localPart)
if err != nil {
return false, err
}
domainName = strings.ToLower(strings.TrimSpace(domainName))
var exists bool
err = s.db.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM mailboxes m
JOIN mail_domains d ON d.id = m.domain_id
WHERE d.name = $1 AND m.local_part = $2
)
`, domainName, localPart).Scan(&exists)
return !exists, err
}
func (s *Service) EnsurePlatformDomain(ctx context.Context, name string) (DomainRow, error) {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return DomainRow{}, fmt.Errorf("platform domain name required")
}
var row DomainRow
err := s.db.QueryRow(ctx, `
SELECT id::text, name, status, verification_token, dkim_selector, dkim_public_key,
stalwart_domain_id, is_platform_domain,
mx_verified_at::text, txt_verified_at::text, created_at::text
FROM mail_domains WHERE name = $1
`, name).Scan(
&row.ID, &row.Name, &row.Status, &row.VerificationToken, &row.DKIMSelector, &row.DKIMPublicKey,
&row.StalwartDomainID, &row.IsPlatformDomain, &row.MXVerifiedAt, &row.TXTVerifiedAt, &row.CreatedAt,
)
if err == nil {
return row, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return DomainRow{}, err
}
token, err := randomToken(16)
if err != nil {
return DomainRow{}, err
}
stlwID := ""
if s.stlw != nil && s.stlw.Enabled() {
d, err := s.stlw.CreateDomain(ctx, name)
if err != nil {
return DomainRow{}, fmt.Errorf("stalwart create domain: %w", err)
}
stlwID = d.ID
}
status := "active"
if !strings.HasSuffix(name, ".local") {
status = "pending_verification"
}
err = s.db.QueryRow(ctx, `
INSERT INTO mail_domains (name, status, verification_token, stalwart_domain_id, is_platform_domain, txt_verified_at)
VALUES ($1, $2, $3, $4, true, CASE WHEN $2 = 'active' THEN NOW() ELSE NULL END)
RETURNING id::text, name, status, verification_token, dkim_selector, dkim_public_key,
stalwart_domain_id, is_platform_domain,
mx_verified_at::text, txt_verified_at::text, created_at::text
`, name, status, token, stlwID).Scan(
&row.ID, &row.Name, &row.Status, &row.VerificationToken, &row.DKIMSelector, &row.DKIMPublicKey,
&row.StalwartDomainID, &row.IsPlatformDomain, &row.MXVerifiedAt, &row.TXTVerifiedAt, &row.CreatedAt,
)
return row, err
}
func (s *Service) CreateDomain(ctx context.Context, name string, platform bool) (DomainRow, error) {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return DomainRow{}, fmt.Errorf("domain name required")
}
token, err := randomToken(16)
if err != nil {
return DomainRow{}, err
}
stlwID := ""
if s.stlw != nil && s.stlw.Enabled() {
d, err := s.stlw.CreateDomain(ctx, name)
if err != nil {
return DomainRow{}, fmt.Errorf("stalwart create domain: %w", err)
}
stlwID = d.ID
}
var row DomainRow
err = s.db.QueryRow(ctx, `
INSERT INTO mail_domains (name, status, verification_token, stalwart_domain_id, is_platform_domain)
VALUES ($1, 'pending_verification', $2, $3, $4)
RETURNING id::text, name, status, verification_token, dkim_selector, dkim_public_key,
stalwart_domain_id, is_platform_domain,
mx_verified_at::text, txt_verified_at::text, created_at::text
`, name, token, stlwID, platform).Scan(
&row.ID, &row.Name, &row.Status, &row.VerificationToken, &row.DKIMSelector, &row.DKIMPublicKey,
&row.StalwartDomainID, &row.IsPlatformDomain, &row.MXVerifiedAt, &row.TXTVerifiedAt, &row.CreatedAt,
)
return row, err
}
func (s *Service) ListDomains(ctx context.Context) ([]DomainRow, error) {
rows, err := s.db.Query(ctx, `
SELECT id::text, name, status, verification_token, dkim_selector, dkim_public_key,
stalwart_domain_id, is_platform_domain,
mx_verified_at::text, txt_verified_at::text, created_at::text
FROM mail_domains ORDER BY is_platform_domain DESC, name ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []DomainRow
for rows.Next() {
var row DomainRow
if err := rows.Scan(
&row.ID, &row.Name, &row.Status, &row.VerificationToken, &row.DKIMSelector, &row.DKIMPublicKey,
&row.StalwartDomainID, &row.IsPlatformDomain, &row.MXVerifiedAt, &row.TXTVerifiedAt, &row.CreatedAt,
); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
func (s *Service) GetDomain(ctx context.Context, domainID string) (DomainRow, error) {
var row DomainRow
err := s.db.QueryRow(ctx, `
SELECT id::text, name, status, verification_token, dkim_selector, dkim_public_key,
stalwart_domain_id, is_platform_domain,
mx_verified_at::text, txt_verified_at::text, created_at::text
FROM mail_domains WHERE id = $1
`, domainID).Scan(
&row.ID, &row.Name, &row.Status, &row.VerificationToken, &row.DKIMSelector, &row.DKIMPublicKey,
&row.StalwartDomainID, &row.IsPlatformDomain, &row.MXVerifiedAt, &row.TXTVerifiedAt, &row.CreatedAt,
)
return row, err
}
func (s *Service) MarkDomainVerified(ctx context.Context, domainID string) (DomainRow, error) {
var row DomainRow
err := s.db.QueryRow(ctx, `
UPDATE mail_domains
SET status = 'active', txt_verified_at = COALESCE(txt_verified_at, NOW()), updated_at = NOW()
WHERE id = $1
RETURNING id::text, name, status, verification_token, dkim_selector, dkim_public_key,
stalwart_domain_id, is_platform_domain,
mx_verified_at::text, txt_verified_at::text, created_at::text
`, domainID).Scan(
&row.ID, &row.Name, &row.Status, &row.VerificationToken, &row.DKIMSelector, &row.DKIMPublicKey,
&row.StalwartDomainID, &row.IsPlatformDomain, &row.MXVerifiedAt, &row.TXTVerifiedAt, &row.CreatedAt,
)
return row, err
}
func (s *Service) MarkDomainMXVerified(ctx context.Context, domainID string) (DomainRow, error) {
var row DomainRow
err := s.db.QueryRow(ctx, `
UPDATE mail_domains
SET mx_verified_at = NOW(), status = 'active', updated_at = NOW()
WHERE id = $1
RETURNING id::text, name, status, verification_token, dkim_selector, dkim_public_key,
stalwart_domain_id, is_platform_domain,
mx_verified_at::text, txt_verified_at::text, created_at::text
`, domainID).Scan(
&row.ID, &row.Name, &row.Status, &row.VerificationToken, &row.DKIMSelector, &row.DKIMPublicKey,
&row.StalwartDomainID, &row.IsPlatformDomain, &row.MXVerifiedAt, &row.TXTVerifiedAt, &row.CreatedAt,
)
return row, err
}
type ProvisionMailboxInput struct {
UserID string
Email string
DisplayName string
Password string
QuotaBytes int64
DomainID string
}
type ProvisionMailboxResult struct {
Mailbox MailboxRow
MailAccountID string
}
func (s *Service) ProvisionMailbox(ctx context.Context, in ProvisionMailboxInput) (ProvisionMailboxResult, error) {
email := strings.ToLower(strings.TrimSpace(in.Email))
at := strings.LastIndex(email, "@")
if at <= 0 {
return ProvisionMailboxResult{}, fmt.Errorf("invalid email")
}
localPart := email[:at]
domainName := email[at+1:]
localPart, err := normalizeLocalPart(localPart)
if err != nil {
return ProvisionMailboxResult{}, err
}
var domain DomainRow
if strings.TrimSpace(in.DomainID) != "" {
domain, err = s.GetDomain(ctx, in.DomainID)
if err != nil {
return ProvisionMailboxResult{}, err
}
if !strings.EqualFold(domain.Name, domainName) {
return ProvisionMailboxResult{}, fmt.Errorf("email domain %q does not match project domain %q", domainName, domain.Name)
}
} else {
err = s.db.QueryRow(ctx, `
SELECT id::text, name, status, verification_token, dkim_selector, dkim_public_key,
stalwart_domain_id, is_platform_domain,
mx_verified_at::text, txt_verified_at::text, created_at::text
FROM mail_domains WHERE name = $1
`, domainName).Scan(
&domain.ID, &domain.Name, &domain.Status, &domain.VerificationToken, &domain.DKIMSelector, &domain.DKIMPublicKey,
&domain.StalwartDomainID, &domain.IsPlatformDomain, &domain.MXVerifiedAt, &domain.TXTVerifiedAt, &domain.CreatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
domain, err = s.EnsurePlatformDomain(ctx, domainName)
if err != nil {
return ProvisionMailboxResult{}, err
}
} else {
return ProvisionMailboxResult{}, err
}
}
}
if domain.Status != "active" && !domain.IsPlatformDomain {
return ProvisionMailboxResult{}, ErrDomainNotActive
}
available, err := s.IsAddressAvailable(ctx, domainName, localPart)
if err != nil {
return ProvisionMailboxResult{}, err
}
if !available {
return ProvisionMailboxResult{}, ErrAddressTaken
}
quota := in.QuotaBytes
if quota <= 0 {
quota = 5 * 1024 * 1024 * 1024
}
stlwAccountID := ""
if s.stlw != nil {
acct, err := s.stlw.CreateAccount(ctx, domain.StalwartDomainID, localPart, in.Password, quota)
if err != nil && !errors.Is(err, stalwart.ErrDisabled) {
return ProvisionMailboxResult{}, fmt.Errorf("stalwart account: %w", err)
}
stlwAccountID = acct.ID
}
tx, err := s.db.Begin(ctx)
if err != nil {
return ProvisionMailboxResult{}, err
}
defer tx.Rollback(ctx)
var mailboxID string
err = tx.QueryRow(ctx, `
INSERT INTO mailboxes (domain_id, local_part, user_id, stalwart_account_id, quota_bytes, status)
VALUES ($1, $2, NULLIF($3, '')::uuid, $4, $5, 'active')
RETURNING id::text
`, domain.ID, localPart, in.UserID, stlwAccountID, quota).Scan(&mailboxID)
if err != nil {
return ProvisionMailboxResult{}, err
}
var mailAccountID string
if in.UserID != "" {
enc, err := s.encryptHostedCredential(email, in.Password)
if err != nil {
return ProvisionMailboxResult{}, err
}
err = tx.QueryRow(ctx, `
INSERT INTO mail_accounts (
user_id, name, email, provider,
imap_host, imap_port, imap_tls,
smtp_host, smtp_port, smtp_tls,
credentials, is_active
)
VALUES ($1, $2, $3, 'hosted', $4, $5, $6, $7, $8, $9, $10, true)
RETURNING id::text
`, in.UserID, in.DisplayName, email,
s.imapHost, s.imapPort, s.imapTLS,
s.smtpHost, s.smtpPort, s.smtpTLS,
enc,
).Scan(&mailAccountID)
if err != nil {
return ProvisionMailboxResult{}, err
}
_, err = tx.Exec(ctx, `
UPDATE mailboxes SET user_id = $1::uuid, mail_account_id = $2::uuid, updated_at = NOW()
WHERE id = $3::uuid
`, in.UserID, mailAccountID, mailboxID)
if err != nil {
return ProvisionMailboxResult{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return ProvisionMailboxResult{}, err
}
return ProvisionMailboxResult{
Mailbox: MailboxRow{
ID: mailboxID,
DomainID: domain.ID,
LocalPart: localPart,
Email: email,
UserID: in.UserID,
MailAccountID: mailAccountID,
StalwartAccountID: stlwAccountID,
QuotaBytes: quota,
Status: "active",
},
MailAccountID: mailAccountID,
}, nil
}
func (s *Service) encryptHostedCredential(email, password string) ([]byte, error) {
if s.creds == nil {
return nil, fmt.Errorf("credential manager not configured")
}
return s.creds.EncryptCredential(credentials.Credential{
AuthType: credentials.AuthPassword,
Username: email,
Password: password,
})
}
func (s *Service) LinkMailboxToUser(ctx context.Context, mailboxID, userID string) error {
_, err := s.db.Exec(ctx, `
UPDATE mailboxes SET user_id = $1::uuid, updated_at = NOW()
WHERE id = $2::uuid AND (user_id IS NULL OR user_id = $1::uuid)
`, userID, mailboxID)
return err
}
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func NewInviteToken() (string, error) {
return uuid.NewString(), nil
}