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 } // EnsureMailboxProvisioned creates a mailbox or links an existing one to the requested user. func (s *Service) EnsureMailboxProvisioned(ctx context.Context, in ProvisionMailboxInput) (ProvisionMailboxResult, error) { email := strings.ToLower(strings.TrimSpace(in.Email)) existing, err := s.lookupMailboxByEmail(ctx, email) if errors.Is(err, pgx.ErrNoRows) { return s.ProvisionMailbox(ctx, in) } if err != nil { return ProvisionMailboxResult{}, err } return s.reconcileExistingMailbox(ctx, existing, in) } func (s *Service) lookupMailboxByEmail(ctx context.Context, email string) (MailboxRow, error) { email = strings.ToLower(strings.TrimSpace(email)) at := strings.LastIndex(email, "@") if at <= 0 { return MailboxRow{}, fmt.Errorf("invalid email") } localPart := email[:at] domainName := email[at+1:] localPart, err := normalizeLocalPart(localPart) if err != nil { return MailboxRow{}, err } var row MailboxRow err = s.db.QueryRow(ctx, ` SELECT mb.id::text, mb.domain_id::text, mb.local_part, lower(mb.local_part || '@' || md.name), COALESCE(mb.user_id::text, ''), COALESCE(mb.mail_account_id::text, ''), mb.stalwart_account_id, mb.quota_bytes, mb.status FROM mailboxes mb JOIN mail_domains md ON md.id = mb.domain_id WHERE md.name = $1 AND mb.local_part = $2 `, domainName, localPart).Scan( &row.ID, &row.DomainID, &row.LocalPart, &row.Email, &row.UserID, &row.MailAccountID, &row.StalwartAccountID, &row.QuotaBytes, &row.Status, ) return row, err } func (s *Service) reconcileExistingMailbox(ctx context.Context, existing MailboxRow, in ProvisionMailboxInput) (ProvisionMailboxResult, error) { userID := strings.TrimSpace(in.UserID) if userID != "" && existing.UserID != "" && existing.UserID != userID { return ProvisionMailboxResult{}, ErrAddressTaken } if userID == "" { userID = existing.UserID } mailAccountID := existing.MailAccountID if userID != "" && existing.UserID != userID { if err := s.LinkMailboxToUser(ctx, existing.ID, userID); err != nil { return ProvisionMailboxResult{}, err } existing.UserID = userID } if userID != "" && mailAccountID == "" && strings.TrimSpace(in.Password) != "" { email := strings.ToLower(strings.TrimSpace(in.Email)) err := s.db.QueryRow(ctx, ` SELECT id::text FROM mail_accounts WHERE user_id = $1::uuid AND lower(email) = $2 LIMIT 1 `, userID, email).Scan(&mailAccountID) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return ProvisionMailboxResult{}, err } if errors.Is(err, pgx.ErrNoRows) { enc, err := s.encryptHostedCredential(email, in.Password) if err != nil { return ProvisionMailboxResult{}, err } displayName := strings.TrimSpace(in.DisplayName) if displayName == "" { displayName = email } err = s.db.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 `, userID, displayName, email, s.imapHost, s.imapPort, s.imapTLS, s.smtpHost, s.smtpPort, s.smtpTLS, enc, ).Scan(&mailAccountID) if err != nil { return ProvisionMailboxResult{}, err } } _, err = s.db.Exec(ctx, ` UPDATE mailboxes SET mail_account_id = $1::uuid, updated_at = NOW() WHERE id = $2::uuid AND (mail_account_id IS NULL OR mail_account_id = $1::uuid) `, mailAccountID, existing.ID) if err != nil { return ProvisionMailboxResult{}, err } existing.MailAccountID = mailAccountID } return ProvisionMailboxResult{ Mailbox: existing, MailAccountID: mailAccountID, }, nil } 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 }