- Added endpoints for listing and importing migration rosters. - Introduced audit export functionality for migration jobs in CSV and NDJSON formats. - Implemented tenant mismatch validation for Microsoft migration claims. - Enhanced error handling for email claiming and migration processes. - Added integration tests for roster import and claim workflows.
548 lines
18 KiB
Go
548 lines
18 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
|
|
}
|
|
|
|
// 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
|
|
}
|