package externalemail import ( "errors" "fmt" "strings" "time" "git.pcux.cn/xuans/Smail/models" "xorm.io/xorm" ) var ( ErrInvalidBinding = errors.New("invalid external email binding") ErrBindingExists = errors.New("external email binding already exists") ErrPrimaryLimit = errors.New("primary external email already exists") ErrBackupLimit = errors.New("backup external email already exists") ErrBindingNotFound = errors.New("external email binding not found") ErrBindingOwnership = errors.New("external email binding ownership denied") ) type Repository struct { engine *xorm.Engine vault *Vault now func() time.Time } type BindRequest struct { UserID int Email string Type string } // BeginReplacement 创建或更新待验证邮箱。旧邮箱会一直保留到新邮箱验证成功。 func (r *Repository) BeginReplacement(request BindRequest) (*models.ExternalEmail, error) { if r == nil || r.engine == nil || r.vault == nil || request.UserID <= 0 { return nil, ErrInvalidBinding } if request.Type != models.ExternalEmailTypePrimary && request.Type != models.ExternalEmailTypeBackup { return nil, ErrInvalidBinding } normalized, err := NormalizeEmail(request.Email) if err != nil { return nil, ErrInvalidBinding } fingerprint, err := r.vault.Fingerprint(normalized) if err != nil { return nil, err } ciphertext, err := r.vault.Encrypt(normalized) if err != nil { return nil, err } var sameAddress models.ExternalEmail hasSameAddress, err := r.engine.Where("email_fingerprint = ?", fingerprint).Get(&sameAddress) if err != nil { return nil, fmt.Errorf("check replacement external email: %w", err) } if hasSameAddress { if sameAddress.UserID != request.UserID || sameAddress.Status == models.ExternalEmailStatusVerified { return nil, ErrBindingExists } if sameAddress.EmailType != request.Type { return nil, ErrBindingExists } if sameAddress.Status == models.ExternalEmailStatusPending { return &sameAddress, nil } } now := r.now().UTC() if hasSameAddress && sameAddress.Status == models.ExternalEmailStatusDisabled { sameAddress.Status = models.ExternalEmailStatusPending sameAddress.VerifiedAt = nil sameAddress.CredentialVersion++ sameAddress.UpdatedAt = now if _, err := r.engine.ID(sameAddress.ID).Cols("status", "verified_at", "credential_version", "updated_at").Update(&sameAddress); err != nil { return nil, fmt.Errorf("reuse disabled replacement external email: %w", err) } return &sameAddress, nil } var pending models.ExternalEmail hasPending, err := r.engine.Where( "user_id = ? AND email_type = ? AND status = ?", request.UserID, request.Type, models.ExternalEmailStatusPending, ).Get(&pending) if err != nil { return nil, fmt.Errorf("load pending replacement external email: %w", err) } if hasPending { pending.EmailCiphertext = ciphertext pending.EmailFingerprint = fingerprint pending.CredentialVersion++ pending.UpdatedAt = now if _, err := r.engine.ID(pending.ID).Cols("email_ciphertext", "email_fingerprint", "credential_version", "updated_at").Update(&pending); err != nil { return nil, fmt.Errorf("update pending replacement external email: %w", err) } return &pending, nil } item := &models.ExternalEmail{ UserID: request.UserID, EmailCiphertext: ciphertext, EmailFingerprint: fingerprint, EmailType: request.Type, Status: models.ExternalEmailStatusPending, CredentialVersion: 1, CreatedAt: now, UpdatedAt: now, } if _, err := r.engine.Insert(item); err != nil { return nil, fmt.Errorf("create replacement external email: %w", err) } return item, nil } func NewRepository(engine *xorm.Engine, vault *Vault) (*Repository, error) { if engine == nil || vault == nil { return nil, ErrVaultUnavailable } return &Repository{engine: engine, vault: vault, now: time.Now}, nil } func (r *Repository) Bind(request BindRequest) (*models.ExternalEmail, error) { if r == nil || r.engine == nil || r.vault == nil || request.UserID <= 0 { return nil, ErrInvalidBinding } if request.Type != models.ExternalEmailTypePrimary && request.Type != models.ExternalEmailTypeBackup { return nil, ErrInvalidBinding } normalized, err := NormalizeEmail(request.Email) if err != nil { return nil, ErrInvalidBinding } fingerprint, err := r.vault.Fingerprint(normalized) if err != nil { return nil, err } ciphertext, err := r.vault.Encrypt(normalized) if err != nil { return nil, err } var existing models.ExternalEmail has, err := r.engine.Where("user_id = ? AND email_fingerprint = ?", request.UserID, fingerprint).Get(&existing) if err != nil { return nil, fmt.Errorf("check external email binding: %w", err) } if has { return nil, ErrBindingExists } var count int64 count, err = r.engine.Where("user_id = ? AND email_type = ? AND status != ?", request.UserID, request.Type, models.ExternalEmailStatusDisabled).Count(&models.ExternalEmail{}) if err != nil { return nil, fmt.Errorf("count external email bindings: %w", err) } if request.Type == models.ExternalEmailTypePrimary && count >= 1 { return nil, ErrPrimaryLimit } if request.Type == models.ExternalEmailTypeBackup && count >= 1 { return nil, ErrBackupLimit } now := r.now().UTC() item := &models.ExternalEmail{ UserID: request.UserID, EmailCiphertext: ciphertext, EmailFingerprint: fingerprint, EmailType: request.Type, Status: models.ExternalEmailStatusPending, CredentialVersion: 1, CreatedAt: now, UpdatedAt: now, } if _, err := r.engine.Insert(item); err != nil { return nil, fmt.Errorf("create external email binding: %w", err) } return item, nil } // FindPending 返回当前用户同一地址和用途尚未完成的绑定,供重新发送验证码复用。 func (r *Repository) FindPending(userID int, email, emailType string) (*models.ExternalEmail, error) { if r == nil || r.engine == nil || r.vault == nil || userID <= 0 { return nil, ErrInvalidBinding } if emailType != models.ExternalEmailTypePrimary && emailType != models.ExternalEmailTypeBackup { return nil, ErrInvalidBinding } normalized, err := NormalizeEmail(email) if err != nil { return nil, ErrInvalidBinding } fingerprint, err := r.vault.Fingerprint(normalized) if err != nil { return nil, err } item := &models.ExternalEmail{} has, err := r.engine.Where( "user_id = ? AND email_fingerprint = ? AND email_type = ? AND status = ?", userID, fingerprint, emailType, models.ExternalEmailStatusPending, ).Get(item) if err != nil { return nil, fmt.Errorf("find pending external email binding: %w", err) } if !has { return nil, ErrBindingNotFound } return item, nil } func (r *Repository) List(userID int) ([]models.ExternalEmail, error) { if r == nil || r.engine == nil || userID <= 0 { return nil, ErrInvalidBinding } var items []models.ExternalEmail if err := r.engine.Where("user_id = ?", userID).OrderBy("id asc").Find(&items); err != nil { return nil, fmt.Errorf("list external email bindings: %w", err) } return items, nil } // FindVerifiedByEmail 仅返回已验证且仍启用的邮箱绑定,调用方不能跨用户使用结果。 func (r *Repository) FindVerifiedByEmail(email string) (*models.ExternalEmail, error) { if r == nil || r.engine == nil || r.vault == nil { return nil, ErrInvalidBinding } normalized, err := NormalizeEmail(email) if err != nil { return nil, ErrBindingNotFound } fingerprint, err := r.vault.Fingerprint(normalized) if err != nil { return nil, err } item := &models.ExternalEmail{} has, err := r.engine.Where("email_fingerprint = ? AND status = ?", fingerprint, models.ExternalEmailStatusVerified).Get(item) if err != nil { return nil, fmt.Errorf("find verified external email: %w", err) } if !has { return nil, ErrBindingNotFound } return item, nil } func (r *Repository) FindVerifiedByID(userID, id int) (*models.ExternalEmail, error) { if r == nil || r.engine == nil || userID <= 0 || id <= 0 { return nil, ErrInvalidBinding } item := &models.ExternalEmail{} has, err := r.engine.Where("id = ? AND user_id = ? AND status = ?", id, userID, models.ExternalEmailStatusVerified).Get(item) if err != nil { return nil, fmt.Errorf("find verified external email by id: %w", err) } if !has { return nil, ErrBindingNotFound } return item, nil } func (r *Repository) Verify(userID, id int) error { if r == nil || r.engine == nil || userID <= 0 || id <= 0 { return ErrInvalidBinding } item := &models.ExternalEmail{} has, err := r.engine.Where("id = ? AND user_id = ?", id, userID).Get(item) if err != nil { return fmt.Errorf("load external email binding: %w", err) } if !has { return ErrBindingOwnership } if item.Status == models.ExternalEmailStatusDisabled { return ErrBindingNotFound } item.Status = models.ExternalEmailStatusVerified item.VerifiedAt = timePtr(r.now().UTC()) item.UpdatedAt = r.now().UTC() if _, err := r.engine.ID(id).Cols("status", "verified_at", "updated_at").Where("user_id = ?", userID).Update(item); err != nil { return fmt.Errorf("verify external email binding: %w", err) } return nil } // VerifyReplacement 验证新邮箱后原子停用同用途旧邮箱,并启用新邮箱。 func (r *Repository) VerifyReplacement(userID, id int) error { if r == nil || r.engine == nil || userID <= 0 || id <= 0 { return ErrInvalidBinding } session := r.engine.NewSession() if session == nil { return ErrInvalidBinding } defer session.Close() if err := session.Begin(); err != nil { return fmt.Errorf("begin external email replacement: %w", err) } committed := false defer func() { if !committed { _ = session.Rollback() } }() item := &models.ExternalEmail{} has, err := session.Where("id = ? AND user_id = ? AND status = ?", id, userID, models.ExternalEmailStatusPending).Get(item) if err != nil { return fmt.Errorf("load replacement external email: %w", err) } if !has { return ErrBindingOwnership } now := r.now().UTC() if _, err := session.Where( "user_id = ? AND email_type = ? AND id != ? AND status != ?", userID, item.EmailType, id, models.ExternalEmailStatusDisabled, ).Cols("status", "updated_at").Update(&models.ExternalEmail{ Status: models.ExternalEmailStatusDisabled, UpdatedAt: now, }); err != nil { return fmt.Errorf("disable old external email: %w", err) } item.Status = models.ExternalEmailStatusVerified item.VerifiedAt = timePtr(now) item.UpdatedAt = now updated, err := session.ID(id).Where("user_id = ? AND status = ?", userID, models.ExternalEmailStatusPending). Cols("status", "verified_at", "updated_at").Update(item) if err != nil { return fmt.Errorf("verify replacement external email: %w", err) } if updated != 1 { return ErrBindingOwnership } if err := session.Commit(); err != nil { return fmt.Errorf("commit external email replacement: %w", err) } committed = true return nil } // AbandonPending 删除当前请求刚创建且尚未验证的绑定,供投递失败时回滚。 func (r *Repository) AbandonPending(userID, id int) error { if r == nil || r.engine == nil || userID <= 0 || id <= 0 { return ErrInvalidBinding } affected, err := r.engine.Where("id = ? AND user_id = ? AND status = ?", id, userID, models.ExternalEmailStatusPending).Delete(&models.ExternalEmail{}) if err != nil { return fmt.Errorf("abandon pending external email: %w", err) } if affected != 1 { return ErrBindingOwnership } return nil } func (r *Repository) Disable(userID, id int) error { if r == nil || r.engine == nil || userID <= 0 || id <= 0 { return ErrInvalidBinding } updated, err := r.engine.Table(&models.ExternalEmail{}). Where("id = ? AND user_id = ? AND status != ?", id, userID, models.ExternalEmailStatusDisabled). Cols("status", "updated_at").Update(&models.ExternalEmail{Status: models.ExternalEmailStatusDisabled, UpdatedAt: r.now().UTC()}) if err != nil { return fmt.Errorf("disable external email binding: %w", err) } if updated == 0 { return ErrBindingOwnership } return nil } // FindVerifiedPrimaryByUser 返回当前用户唯一有效的主要外部邮箱。 func (r *Repository) FindVerifiedPrimaryByUser(userID int) (*models.ExternalEmail, error) { if r == nil || r.engine == nil || r.vault == nil || userID <= 0 { return nil, ErrInvalidBinding } item := &models.ExternalEmail{} has, err := r.engine.Where("user_id = ? AND email_type = ? AND status = ?", userID, models.ExternalEmailTypePrimary, models.ExternalEmailStatusVerified).Get(item) if err != nil { return nil, fmt.Errorf("find verified primary external email: %w", err) } if !has { return nil, ErrBindingNotFound } return item, nil } func (r *Repository) Decrypt(item models.ExternalEmail) (string, error) { if r == nil || r.vault == nil || strings.TrimSpace(item.EmailCiphertext) == "" { return "", ErrInvalidBinding } return r.vault.Decrypt(item.EmailCiphertext) } func timePtr(value time.Time) *time.Time { return &value }