diff --git a/colaflow-api/DAY7-ARCHITECTURE.md b/colaflow-api/DAY7-ARCHITECTURE.md
new file mode 100644
index 0000000..527f20a
--- /dev/null
+++ b/colaflow-api/DAY7-ARCHITECTURE.md
@@ -0,0 +1,1893 @@
+# Day 7 Technical Architecture
+# Email Service & User Management
+
+**Version**: 1.0
+**Date**: 2025-11-03
+**Sprint**: M1 Sprint 2 - Day 7
+**Author**: Architecture Team
+**Status**: Ready for Implementation
+**Related PRD**: [DAY7-PRD.md](./DAY7-PRD.md)
+
+---
+
+## Table of Contents
+
+1. [Overview](#1-overview)
+2. [Technology Stack Decisions](#2-technology-stack-decisions)
+3. [Core Architecture Components](#3-core-architecture-components)
+4. [Database Schema Design](#4-database-schema-design)
+5. [Security Architecture (ADRs)](#5-security-architecture-adrs)
+6. [Integration Architecture](#6-integration-architecture)
+7. [Implementation Phases](#7-implementation-phases)
+8. [Deployment Considerations](#8-deployment-considerations)
+
+---
+
+## 1. Overview
+
+### 1.1 Scope
+
+Day 7 implements the foundation for secure user communication and team collaboration:
+
+| Component | Purpose | Priority |
+|-----------|---------|----------|
+| **Email Service** | Send transactional emails (verification, reset, invitations) | P0 |
+| **Email Verification** | Validate user email ownership | P0 |
+| **Password Reset** | Self-service password recovery | P0 |
+| **User Invitations** | Team member onboarding | P0 |
+
+### 1.2 Architecture Principles
+
+1. **Abstraction First**: Email provider is pluggable (SendGrid, SMTP, Mock)
+2. **Security by Design**: All tokens hashed (SHA-256), short expiration windows
+3. **Fail-Safe Operations**: Email delivery failures don't block user actions
+4. **Domain-Driven Design**: Token entities are first-class domain objects
+5. **Modular Boundaries**: Email service is infrastructure concern, separated from domain logic
+
+### 1.3 System Context
+
+```
+┌──────────────────────────────────────────────────────────┐
+│ Application Layer │
+│ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │
+│ │ Register │ │ ForgotPassword│ │ InviteUser │ │
+│ │ Tenant │ │ Command │ │ Command │ │
+│ └──────┬──────┘ └──────┬───────┘ └────────┬───────┘ │
+└─────────┼─────────────────┼──────────────────┼──────────┘
+ │ │ │
+ └─────────────────┼──────────────────┘
+ ↓
+┌──────────────────────────────────────────────────────────┐
+│ Infrastructure Layer │
+│ ┌───────────────────────────────────────────────────┐ │
+│ │ IEmailService (Abstraction) │ │
+│ │ + SendEmailAsync(EmailMessage message) │ │
+│ └───────────────────┬───────────────────────────────┘ │
+│ │ │
+│ ┌──────────────┼──────────────┐ │
+│ ↓ ↓ ↓ │
+│ ┌─────────┐ ┌──────────┐ ┌──────────┐ │
+│ │SendGrid │ │ SMTP │ │ Mock │ │
+│ │ Service │ │ Service │ │ Service │ │
+│ └─────────┘ └──────────┘ └──────────┘ │
+│ │
+│ ┌───────────────────────────────────────────────────┐ │
+│ │ IEmailTemplateRenderer │ │
+│ │ + RenderAsync(templateName, data) │ │
+│ └───────────────────────────────────────────────────┘ │
+└──────────────────────────────────────────────────────────┘
+ ↓
+┌──────────────────────────────────────────────────────────┐
+│ Domain Layer │
+│ ┌──────────────────┐ ┌──────────────────┐ │
+│ │ EmailVerification│ │ PasswordReset │ │
+│ │ Token (Entity) │ │ Token (Entity) │ │
+│ └──────────────────┘ └──────────────────┘ │
+│ ┌──────────────────┐ │
+│ │ Invitation │ │
+│ │ (Entity) │ │
+│ └──────────────────┘ │
+└──────────────────────────────────────────────────────────┘
+```
+
+---
+
+## 2. Technology Stack Decisions
+
+### 2.1 Email Service Provider
+
+**Decision**: Hybrid approach with **SendGrid as primary, SMTP as fallback**
+
+| Provider | Use Case | Pros | Cons |
+|----------|----------|------|------|
+| **SendGrid** | Production | • 99.9% delivery rate
• Built-in analytics
• Bounce handling
• 100 emails/day free tier | • External dependency
• Requires API key |
+| **SMTP** | Development & Self-hosted | • No external dependencies
• Air-gapped support
• Works with MailHog/Papercut | • Lower delivery rate
• Manual spam management |
+| **Mock** | Testing | • No actual sends
• Fast tests
• File logging | • Not for production |
+
+**Implementation Strategy**:
+```csharp
+// Configuration-driven selection
+services.AddScoped(provider =>
+{
+ var config = provider.GetRequiredService();
+ return config.Provider switch
+ {
+ "SendGrid" => new SendGridEmailService(config.SendGrid),
+ "Smtp" => new SmtpEmailService(config.Smtp),
+ "Mock" => new MockEmailService(config.MockSettings),
+ _ => throw new InvalidOperationException($"Unknown email provider: {config.Provider}")
+ };
+});
+```
+
+**Rationale**:
+- **Production**: SendGrid ensures high deliverability for critical emails (password resets, verifications)
+- **Development**: SMTP with MailHog allows offline development without external dependencies
+- **Testing**: Mock service enables fast, deterministic unit/integration tests
+
+### 2.2 Email Template Engine
+
+**Decision**: Use **C# String Interpolation + Razor Templates** (Hybrid)
+
+| Option | When to Use | Pros | Cons |
+|--------|-------------|------|------|
+| **C# String Interpolation** | Simple templates (text-only) | • No dependencies
• Fast rendering
• Type-safe | • Limited HTML support
• No layouts |
+| **Razor Templates** | Complex HTML templates | • Full HTML support
• Layout inheritance
• IntelliSense | • RazorLight dependency
• Slower rendering |
+
+**Implementation**:
+```csharp
+// Simple text emails: String interpolation
+public string RenderSimple(string userName, string link) =>
+ $"Hi {userName},\n\nClick here to verify: {link}";
+
+// Complex HTML emails: Razor
+public async Task RenderHtmlAsync(string templateName, object model)
+{
+ var engine = new RazorLightEngineBuilder()
+ .UseFileSystemProject(Path.Combine(AppContext.BaseDirectory, "EmailTemplates"))
+ .UseMemoryCachingProvider()
+ .Build();
+
+ return await engine.CompileRenderAsync(templateName, model);
+}
+```
+
+**Day 7 Recommendation**: Start with **String Interpolation** for simplicity. Upgrade to Razor if HTML complexity grows.
+
+### 2.3 Token Storage & Hashing
+
+**Decision**: Database storage with **SHA-256 hashing**
+
+| Aspect | Decision | Rationale |
+|--------|----------|-----------|
+| **Storage** | PostgreSQL tables (3 new tables) | • ACID guarantees
• Existing infrastructure
• Easy expiration queries |
+| **Hashing Algorithm** | SHA-256 | • Fast (token lookup performance)
• Sufficient for time-limited tokens
• Industry standard |
+| **Token Format** | Base64URL-encoded random 256-bit | • URL-safe
• Cryptographically secure
• Collision-resistant |
+
+**Alternative Rejected**: Redis for token storage
+- **Reason**: Adds operational complexity for marginal performance gain. PostgreSQL with indexing is sufficient for Day 7 scale.
+- **Future**: Revisit for 10M+ tokens/day scenarios.
+
+---
+
+## 3. Core Architecture Components
+
+### 3.1 Email Service Abstraction
+
+#### Interface Design
+
+```csharp
+namespace ColaFlow.Modules.Identity.Application.Services;
+
+///
+/// Abstraction for sending transactional emails.
+/// Implementations: SendGrid, SMTP, Mock (for testing).
+///
+public interface IEmailService
+{
+ ///
+ /// Send an email asynchronously.
+ ///
+ /// Email message details
+ /// Cancellation token
+ /// EmailSendResult with success status and metadata
+ Task SendEmailAsync(
+ EmailMessage message,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// Email message data transfer object.
+///
+public record EmailMessage(
+ string ToAddress,
+ string ToName,
+ string Subject,
+ string TextBody,
+ string? HtmlBody = null,
+ string? FromAddress = null,
+ string? FromName = null);
+
+///
+/// Result of email send operation.
+///
+public record EmailSendResult(
+ bool Success,
+ string? MessageId = null,
+ string? ErrorMessage = null);
+```
+
+#### Implementation: SendGrid
+
+```csharp
+namespace ColaFlow.Modules.Identity.Infrastructure.Services;
+
+public class SendGridEmailService : IEmailService
+{
+ private readonly SendGridClient _client;
+ private readonly string _defaultFromAddress;
+ private readonly string _defaultFromName;
+ private readonly ILogger _logger;
+
+ public SendGridEmailService(
+ SendGridSettings settings,
+ ILogger logger)
+ {
+ _client = new SendGridClient(settings.ApiKey);
+ _defaultFromAddress = settings.FromAddress;
+ _defaultFromName = settings.FromName;
+ _logger = logger;
+ }
+
+ public async Task SendEmailAsync(
+ EmailMessage message,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var from = new EmailAddress(
+ message.FromAddress ?? _defaultFromAddress,
+ message.FromName ?? _defaultFromName);
+
+ var to = new EmailAddress(message.ToAddress, message.ToName);
+
+ var msg = MailHelper.CreateSingleEmail(
+ from,
+ to,
+ message.Subject,
+ message.TextBody,
+ message.HtmlBody);
+
+ var response = await _client.SendEmailAsync(msg, cancellationToken);
+
+ if (response.IsSuccessStatusCode)
+ {
+ _logger.LogInformation(
+ "Email sent successfully to {Email}. Subject: {Subject}",
+ message.ToAddress,
+ message.Subject);
+
+ return new EmailSendResult(
+ Success: true,
+ MessageId: response.Headers.GetValues("X-Message-Id").FirstOrDefault());
+ }
+
+ var body = await response.Body.ReadAsStringAsync(cancellationToken);
+ _logger.LogWarning(
+ "Email send failed. StatusCode: {StatusCode}, Body: {Body}",
+ response.StatusCode,
+ body);
+
+ return new EmailSendResult(
+ Success: false,
+ ErrorMessage: $"SendGrid returned {response.StatusCode}");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Exception sending email to {Email}", message.ToAddress);
+ return new EmailSendResult(
+ Success: false,
+ ErrorMessage: ex.Message);
+ }
+ }
+}
+```
+
+**Key Design Decisions**:
+1. **Non-blocking**: Email failures return error result but don't throw exceptions
+2. **Logging**: All sends logged for audit trail
+3. **Graceful Degradation**: Commands continue even if email fails (see Integration section)
+
+#### Implementation: SMTP (Simplified)
+
+```csharp
+public class SmtpEmailService : IEmailService
+{
+ private readonly SmtpSettings _settings;
+ private readonly ILogger _logger;
+
+ public async Task SendEmailAsync(
+ EmailMessage message,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ using var smtpClient = new SmtpClient(_settings.Host, _settings.Port)
+ {
+ Credentials = new NetworkCredential(_settings.Username, _settings.Password),
+ EnableSsl = _settings.EnableSsl
+ };
+
+ var mailMessage = new MailMessage(
+ from: new MailAddress(_settings.FromAddress, _settings.FromName),
+ to: new MailAddress(message.ToAddress, message.ToName))
+ {
+ Subject = message.Subject,
+ Body = message.HtmlBody ?? message.TextBody,
+ IsBodyHtml = message.HtmlBody != null
+ };
+
+ await smtpClient.SendMailAsync(mailMessage, cancellationToken);
+
+ _logger.LogInformation("Email sent via SMTP to {Email}", message.ToAddress);
+ return new EmailSendResult(Success: true);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "SMTP send failed to {Email}", message.ToAddress);
+ return new EmailSendResult(Success: false, ErrorMessage: ex.Message);
+ }
+ }
+}
+```
+
+### 3.2 Security Token Base Class
+
+All security tokens (verification, reset, invitation) share common characteristics. Use an **abstract base class** for consistency.
+
+```csharp
+namespace ColaFlow.Modules.Identity.Domain.Aggregates.SecurityTokens;
+
+///
+/// Abstract base class for all time-limited security tokens.
+/// Enforces consistent token hashing and expiration logic.
+///
+public abstract class SecurityToken : Entity
+{
+ ///
+ /// SHA-256 hash of the actual token (never store plaintext token).
+ ///
+ public string TokenHash { get; protected set; } = string.Empty;
+
+ ///
+ /// Token expiration timestamp (UTC).
+ ///
+ public DateTime ExpiresAt { get; protected set; }
+
+ ///
+ /// Timestamp when token was created (UTC).
+ ///
+ public DateTime CreatedAt { get; protected set; }
+
+ ///
+ /// Timestamp when token was used/consumed (UTC). Null if not used.
+ ///
+ public DateTime? UsedAt { get; protected set; }
+
+ ///
+ /// User ID associated with this token.
+ ///
+ public UserId UserId { get; protected set; } = null!;
+
+ ///
+ /// Check if token is expired.
+ ///
+ public bool IsExpired => DateTime.UtcNow > ExpiresAt;
+
+ ///
+ /// Check if token has been used.
+ ///
+ public bool IsUsed => UsedAt.HasValue;
+
+ ///
+ /// Check if token is valid (not expired and not used).
+ ///
+ public bool IsValid => !IsExpired && !IsUsed;
+
+ ///
+ /// Mark token as used (prevents reuse).
+ ///
+ public void MarkAsUsed()
+ {
+ if (IsUsed)
+ throw new InvalidOperationException("Token has already been used");
+
+ if (IsExpired)
+ throw new InvalidOperationException("Cannot use expired token");
+
+ UsedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// Validate provided token against stored hash.
+ ///
+ /// The plaintext token to validate
+ /// True if token matches hash
+ public bool ValidateToken(string providedToken)
+ {
+ if (string.IsNullOrWhiteSpace(providedToken))
+ return false;
+
+ var providedHash = HashToken(providedToken);
+ return TokenHash == providedHash;
+ }
+
+ ///
+ /// Hash a token using SHA-256.
+ ///
+ protected static string HashToken(string token)
+ {
+ using var sha256 = SHA256.Create();
+ var bytes = Encoding.UTF8.GetBytes(token);
+ var hash = sha256.ComputeHash(bytes);
+ return Convert.ToBase64String(hash);
+ }
+
+ ///
+ /// Generate a cryptographically secure random token (256-bit, base64url-encoded).
+ ///
+ protected static string GenerateToken()
+ {
+ var randomBytes = new byte[32]; // 256 bits
+ using var rng = RandomNumberGenerator.Create();
+ rng.GetBytes(randomBytes);
+
+ // Base64URL encoding (URL-safe, no padding)
+ return Convert.ToBase64String(randomBytes)
+ .Replace("+", "-")
+ .Replace("/", "_")
+ .TrimEnd('=');
+ }
+}
+```
+
+**Design Rationale**:
+- **Inheritance over Duplication**: 3 token types share 80% of logic
+- **Immutability**: Protected setters prevent external modification
+- **Validation**: Centralized expiration/usage checks
+- **Security**: Token generation and hashing encapsulated
+
+### 3.3 Domain Entities
+
+#### EmailVerificationToken
+
+```csharp
+namespace ColaFlow.Modules.Identity.Domain.Aggregates.SecurityTokens;
+
+///
+/// Email verification token entity.
+/// Lifetime: 24 hours.
+///
+public sealed class EmailVerificationToken : SecurityToken
+{
+ ///
+ /// Email address being verified.
+ ///
+ public Email Email { get; private set; } = null!;
+
+ ///
+ /// Tenant ID for multi-tenant isolation.
+ ///
+ public TenantId TenantId { get; private set; } = null!;
+
+ // EF Core constructor
+ private EmailVerificationToken() : base() { }
+
+ ///
+ /// Factory method to create new verification token.
+ ///
+ /// Tuple of (entity, plaintext token for email)
+ public static (EmailVerificationToken entity, string plaintextToken) Create(
+ UserId userId,
+ Email email,
+ TenantId tenantId)
+ {
+ var plaintextToken = GenerateToken();
+ var tokenHash = HashToken(plaintextToken);
+
+ var entity = new EmailVerificationToken
+ {
+ Id = Guid.NewGuid(),
+ UserId = userId,
+ Email = email,
+ TenantId = tenantId,
+ TokenHash = tokenHash,
+ ExpiresAt = DateTime.UtcNow.AddHours(24),
+ CreatedAt = DateTime.UtcNow
+ };
+
+ return (entity, plaintextToken);
+ }
+}
+```
+
+#### PasswordResetToken
+
+```csharp
+///
+/// Password reset token entity.
+/// Lifetime: 1 hour.
+///
+public sealed class PasswordResetToken : SecurityToken
+{
+ public Email Email { get; private set; } = null!;
+ public TenantId TenantId { get; private set; } = null!;
+
+ // EF Core constructor
+ private PasswordResetToken() : base() { }
+
+ public static (PasswordResetToken entity, string plaintextToken) Create(
+ UserId userId,
+ Email email,
+ TenantId tenantId)
+ {
+ var plaintextToken = GenerateToken();
+ var tokenHash = HashToken(plaintextToken);
+
+ var entity = new PasswordResetToken
+ {
+ Id = Guid.NewGuid(),
+ UserId = userId,
+ Email = email,
+ TenantId = tenantId,
+ TokenHash = tokenHash,
+ ExpiresAt = DateTime.UtcNow.AddHours(1), // Shorter for security
+ CreatedAt = DateTime.UtcNow
+ };
+
+ return (entity, plaintextToken);
+ }
+}
+```
+
+#### Invitation
+
+```csharp
+///
+/// User invitation entity.
+/// Lifetime: 7 days.
+///
+public sealed class Invitation : SecurityToken
+{
+ public Email InviteeEmail { get; private set; } = null!;
+ public TenantId TenantId { get; private set; } = null!;
+ public TenantRole AssignedRole { get; private set; }
+ public UserId InvitedBy { get; private set; } = null!;
+ public InvitationStatus Status { get; private set; }
+
+ // EF Core constructor
+ private Invitation() : base() { }
+
+ public static (Invitation entity, string plaintextToken) Create(
+ Email inviteeEmail,
+ TenantId tenantId,
+ TenantRole assignedRole,
+ UserId invitedBy)
+ {
+ // Validate role (cannot invite as TenantOwner or AIAgent)
+ if (assignedRole == TenantRole.TenantOwner || assignedRole == TenantRole.AIAgent)
+ throw new ArgumentException($"Cannot invite user with role {assignedRole}");
+
+ var plaintextToken = GenerateToken();
+ var tokenHash = HashToken(plaintextToken);
+
+ var entity = new Invitation
+ {
+ Id = Guid.NewGuid(),
+ UserId = UserId.Empty, // Will be set when accepted
+ InviteeEmail = inviteeEmail,
+ TenantId = tenantId,
+ AssignedRole = assignedRole,
+ InvitedBy = invitedBy,
+ TokenHash = tokenHash,
+ Status = InvitationStatus.Pending,
+ ExpiresAt = DateTime.UtcNow.AddDays(7),
+ CreatedAt = DateTime.UtcNow
+ };
+
+ return (entity, plaintextToken);
+ }
+
+ public void Accept(UserId userId)
+ {
+ if (Status != InvitationStatus.Pending)
+ throw new InvalidOperationException($"Invitation is {Status}, cannot accept");
+
+ MarkAsUsed();
+ UserId = userId;
+ Status = InvitationStatus.Accepted;
+ }
+
+ public void Cancel()
+ {
+ if (Status != InvitationStatus.Pending)
+ throw new InvalidOperationException($"Invitation is {Status}, cannot cancel");
+
+ Status = InvitationStatus.Canceled;
+ }
+}
+
+public enum InvitationStatus
+{
+ Pending = 0,
+ Accepted = 1,
+ Canceled = 2,
+ Expired = 3
+}
+```
+
+### 3.4 Repository Interfaces
+
+```csharp
+namespace ColaFlow.Modules.Identity.Domain.Repositories;
+
+public interface IEmailVerificationTokenRepository
+{
+ Task GetByTokenHashAsync(
+ string tokenHash,
+ CancellationToken cancellationToken = default);
+
+ Task GetActiveByUserIdAsync(
+ UserId userId,
+ CancellationToken cancellationToken = default);
+
+ Task AddAsync(
+ EmailVerificationToken token,
+ CancellationToken cancellationToken = default);
+
+ Task UpdateAsync(
+ EmailVerificationToken token,
+ CancellationToken cancellationToken = default);
+}
+
+public interface IPasswordResetTokenRepository
+{
+ Task GetByTokenHashAsync(
+ string tokenHash,
+ CancellationToken cancellationToken = default);
+
+ Task InvalidateAllByUserIdAsync(
+ UserId userId,
+ CancellationToken cancellationToken = default);
+
+ Task AddAsync(
+ PasswordResetToken token,
+ CancellationToken cancellationToken = default);
+
+ Task UpdateAsync(
+ PasswordResetToken token,
+ CancellationToken cancellationToken = default);
+}
+
+public interface IInvitationRepository
+{
+ Task GetByTokenHashAsync(
+ string tokenHash,
+ CancellationToken cancellationToken = default);
+
+ Task GetByIdAsync(
+ Guid invitationId,
+ CancellationToken cancellationToken = default);
+
+ Task> GetAllByTenantAsync(
+ TenantId tenantId,
+ int pageNumber,
+ int pageSize,
+ InvitationStatus? status = null,
+ CancellationToken cancellationToken = default);
+
+ Task CountByTenantAsync(
+ TenantId tenantId,
+ InvitationStatus? status = null,
+ CancellationToken cancellationToken = default);
+
+ Task AddAsync(
+ Invitation invitation,
+ CancellationToken cancellationToken = default);
+
+ Task UpdateAsync(
+ Invitation invitation,
+ CancellationToken cancellationToken = default);
+}
+```
+
+### 3.5 Command/Query Structure
+
+#### Commands
+
+```csharp
+// Email Verification
+public record VerifyEmailCommand(string Token) : IRequest;
+public record ResendVerificationEmailCommand(string TenantSlug, string Email) : IRequest;
+
+// Password Reset
+public record ForgotPasswordCommand(string TenantSlug, string Email) : IRequest;
+public record ResetPasswordCommand(string Token, string NewPassword) : IRequest;
+
+// User Invitation
+public record InviteUserCommand(
+ Guid TenantId,
+ string Email,
+ TenantRole Role) : IRequest>;
+
+public record AcceptInvitationCommand(
+ string Token,
+ string FullName,
+ string Password) : IRequest>;
+
+public record CancelInvitationCommand(
+ Guid TenantId,
+ Guid InvitationId) : IRequest;
+```
+
+#### Queries
+
+```csharp
+public record GetTenantInvitationsQuery(
+ Guid TenantId,
+ int PageNumber = 1,
+ int PageSize = 20,
+ InvitationStatus? Status = null) : IRequest>>;
+
+public record GetInvitationByTokenQuery(
+ string Token) : IRequest>;
+```
+
+---
+
+## 4. Database Schema Design
+
+### 4.1 New Tables
+
+#### email_verification_tokens
+
+```sql
+CREATE TABLE email_verification_tokens (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL,
+ tenant_id UUID NOT NULL,
+ email VARCHAR(255) NOT NULL,
+ token_hash VARCHAR(64) NOT NULL, -- SHA-256 base64
+ expires_at TIMESTAMP NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ used_at TIMESTAMP NULL,
+
+ CONSTRAINT fk_email_verification_tokens_user
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_email_verification_tokens_tenant
+ FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
+);
+
+-- Indexes for performance
+CREATE INDEX idx_email_verification_tokens_token_hash
+ ON email_verification_tokens(token_hash);
+
+CREATE INDEX idx_email_verification_tokens_user_id
+ ON email_verification_tokens(user_id)
+ WHERE used_at IS NULL AND expires_at > NOW();
+
+CREATE INDEX idx_email_verification_tokens_expires_at
+ ON email_verification_tokens(expires_at)
+ WHERE used_at IS NULL;
+```
+
+**Design Notes**:
+- **token_hash**: Indexed for O(1) lookup during verification
+- **Partial index on user_id**: Only active tokens (performance optimization)
+- **Cascade delete**: Remove tokens when user/tenant deleted
+- **Expiration index**: Efficient cleanup of expired tokens
+
+#### password_reset_tokens
+
+```sql
+CREATE TABLE password_reset_tokens (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL,
+ tenant_id UUID NOT NULL,
+ email VARCHAR(255) NOT NULL,
+ token_hash VARCHAR(64) NOT NULL,
+ expires_at TIMESTAMP NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ used_at TIMESTAMP NULL,
+
+ CONSTRAINT fk_password_reset_tokens_user
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_password_reset_tokens_tenant
+ FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
+);
+
+-- Indexes
+CREATE INDEX idx_password_reset_tokens_token_hash
+ ON password_reset_tokens(token_hash);
+
+CREATE INDEX idx_password_reset_tokens_user_id
+ ON password_reset_tokens(user_id)
+ WHERE used_at IS NULL AND expires_at > NOW();
+
+CREATE INDEX idx_password_reset_tokens_expires_at
+ ON password_reset_tokens(expires_at)
+ WHERE used_at IS NULL;
+```
+
+#### invitations
+
+```sql
+CREATE TABLE invitations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL,
+ invitee_email VARCHAR(255) NOT NULL,
+ assigned_role VARCHAR(50) NOT NULL, -- TenantAdmin, Developer, Guest
+ invited_by_user_id UUID NOT NULL,
+ token_hash VARCHAR(64) NOT NULL,
+ status INT NOT NULL DEFAULT 0, -- 0=Pending, 1=Accepted, 2=Canceled, 3=Expired
+ user_id UUID NULL, -- Set when accepted
+ expires_at TIMESTAMP NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ used_at TIMESTAMP NULL,
+
+ CONSTRAINT fk_invitations_tenant
+ FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
+ CONSTRAINT fk_invitations_invited_by
+ FOREIGN KEY (invited_by_user_id) REFERENCES users(id) ON DELETE RESTRICT,
+ CONSTRAINT fk_invitations_user
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
+ CONSTRAINT ck_invitations_role
+ CHECK (assigned_role IN ('TenantAdmin', 'Developer', 'Guest'))
+);
+
+-- Indexes
+CREATE INDEX idx_invitations_token_hash
+ ON invitations(token_hash);
+
+CREATE INDEX idx_invitations_tenant_id_status
+ ON invitations(tenant_id, status);
+
+CREATE UNIQUE INDEX idx_invitations_unique_pending
+ ON invitations(tenant_id, invitee_email)
+ WHERE status = 0 AND expires_at > NOW();
+
+CREATE INDEX idx_invitations_expires_at
+ ON invitations(expires_at)
+ WHERE status = 0;
+```
+
+**Design Notes**:
+- **Unique constraint**: Prevent duplicate pending invitations for same email
+- **RESTRICT on invited_by**: Prevent deletion of user who sent invitations
+- **CHECK constraint**: Enforce valid roles at database level
+
+### 4.2 Schema Changes to Existing Tables
+
+#### users table modifications
+
+```sql
+-- Add email verification timestamp (if not already exists)
+ALTER TABLE users
+ ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMP NULL;
+
+-- Remove deprecated columns (if they exist on User entity)
+ALTER TABLE users
+ DROP COLUMN IF EXISTS email_verification_token,
+ DROP COLUMN IF EXISTS password_reset_token,
+ DROP COLUMN IF EXISTS password_reset_token_expires_at;
+```
+
+**Migration Strategy**:
+1. Check existing User entity for deprecated columns
+2. Create migration to drop them (data loss acceptable for Day 7, as these were never used)
+3. Add `email_verified_at` if missing
+
+### 4.3 EF Core Entity Configurations
+
+#### EmailVerificationTokenConfiguration
+
+```csharp
+namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Configurations;
+
+public class EmailVerificationTokenConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("email_verification_tokens");
+
+ builder.HasKey(t => t.Id);
+ builder.Property(t => t.Id).HasColumnName("id");
+
+ builder.Property(t => t.TokenHash)
+ .HasColumnName("token_hash")
+ .HasMaxLength(64)
+ .IsRequired();
+
+ builder.Property(t => t.ExpiresAt)
+ .HasColumnName("expires_at")
+ .IsRequired();
+
+ builder.Property(t => t.CreatedAt)
+ .HasColumnName("created_at")
+ .IsRequired();
+
+ builder.Property(t => t.UsedAt)
+ .HasColumnName("used_at");
+
+ // Value Objects
+ builder.Property(t => t.UserId)
+ .HasColumnName("user_id")
+ .HasConversion(
+ id => id.Value,
+ value => UserId.Create(value))
+ .IsRequired();
+
+ builder.Property(t => t.TenantId)
+ .HasColumnName("tenant_id")
+ .HasConversion(
+ id => id.Value,
+ value => TenantId.Create(value))
+ .IsRequired();
+
+ builder.Property(t => t.Email)
+ .HasColumnName("email")
+ .HasMaxLength(255)
+ .HasConversion(
+ email => email.Value,
+ value => Email.Create(value).Value)
+ .IsRequired();
+
+ // Relationships
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(t => t.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(t => t.TenantId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ // Indexes
+ builder.HasIndex(t => t.TokenHash)
+ .HasDatabaseName("idx_email_verification_tokens_token_hash");
+
+ builder.HasIndex(t => t.UserId)
+ .HasDatabaseName("idx_email_verification_tokens_user_id")
+ .HasFilter("used_at IS NULL AND expires_at > NOW()");
+ }
+}
+```
+
+**Note**: Repeat similar configurations for `PasswordResetTokenConfiguration` and `InvitationConfiguration`.
+
+### 4.4 Migration Approach
+
+```bash
+# Create migration
+cd src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure
+dotnet ef migrations add Day7_EmailAndInvitations --context IdentityDbContext --output-dir Persistence/Migrations
+
+# Review generated migration
+# Ensure it includes:
+# - CREATE TABLE for 3 new tables
+# - CREATE INDEX for all specified indexes
+# - ALTER TABLE users DROP COLUMN (deprecated token fields)
+# - ALTER TABLE users ADD COLUMN email_verified_at
+
+# Apply migration
+dotnet ef database update --context IdentityDbContext
+```
+
+---
+
+## 5. Security Architecture (ADRs)
+
+### ADR-013: Token Hashing with SHA-256
+
+**Status**: Accepted
+**Date**: 2025-11-03
+**Context**: We need to securely store security tokens (email verification, password reset, invitations) in the database.
+
+**Decision**: Use SHA-256 for hashing tokens before database storage.
+
+**Rationale**:
+
+| Aspect | SHA-256 | BCrypt | PBKDF2 | Decision |
+|--------|---------|--------|--------|----------|
+| **Purpose** | Token hashing | Password hashing | Password hashing | ✅ SHA-256 |
+| **Speed** | Very fast (~500K ops/sec) | Slow (12 rounds = ~50 ops/sec) | Medium | Fast lookup needed |
+| **Collision Resistance** | Excellent (256-bit) | N/A | N/A | Tokens are random |
+| **Rainbow Table Resistance** | Not applicable (tokens are random, not user-chosen) | Excellent | Excellent | Not a concern |
+| **Use Case Fit** | ✅ Time-limited random tokens | ❌ User passwords | ❌ User passwords | ✅ |
+
+**Why NOT BCrypt?**
+- BCrypt is designed for **slow** hashing (defense against brute force)
+- Tokens are **already cryptographically random** (256-bit entropy)
+- Token expiration provides time-bound security
+- Performance: Token lookups happen on every verification/reset request
+
+**Implementation**:
+```csharp
+protected static string HashToken(string token)
+{
+ using var sha256 = SHA256.Create();
+ var bytes = Encoding.UTF8.GetBytes(token);
+ var hash = sha256.ComputeHash(bytes);
+ return Convert.ToBase64String(hash); // 44 characters
+}
+```
+
+**Security Properties**:
+- **One-way**: Cannot reverse hash to plaintext token
+- **Deterministic**: Same token always produces same hash (lookup possible)
+- **Fast**: O(1) database lookup with indexed `token_hash` column
+- **Collision-resistant**: 2^256 possible hashes
+
+**Consequences**:
+- ✅ High-performance token validation
+- ✅ Database breach doesn't expose plaintext tokens
+- ✅ Simple implementation (no salt management)
+- ⚠️ Tokens must have high entropy (256-bit random)
+- ⚠️ Must enforce short expiration windows
+
+---
+
+### ADR-014: Email Enumeration Prevention
+
+**Status**: Accepted
+**Date**: 2025-11-03
+**Context**: Password reset and email verification endpoints could reveal if an email exists in the system.
+
+**Decision**: Always return success (200 OK) regardless of whether the email exists.
+
+**Vulnerability Scenario**:
+```
+❌ Bad Implementation:
+POST /api/auth/forgot-password { "email": "admin@victim.com" }
+→ 404 Not Found: "Email not found"
+
+Attacker learns: admin@victim.com is NOT a registered user
+
+✅ Good Implementation:
+POST /api/auth/forgot-password { "email": "admin@victim.com" }
+→ 200 OK: "If an account exists, a reset link has been sent"
+
+Attacker learns: Nothing
+```
+
+**Implementation Pattern**:
+```csharp
+public async Task Handle(ForgotPasswordCommand command, CancellationToken ct)
+{
+ // 1. Look up user (internal logic)
+ var user = await _userRepository.GetByEmailAsync(tenantId, email, ct);
+
+ // 2. If user exists, send email (internal)
+ if (user is not null)
+ {
+ var (token, plaintextToken) = PasswordResetToken.Create(user.Id, email, tenantId);
+ await _tokenRepository.AddAsync(token, ct);
+ await _emailService.SendPasswordResetEmailAsync(email, plaintextToken, ct);
+ }
+
+ // 3. ALWAYS return success (same response for exists/not-exists)
+ return Result.Success("If an account exists, a reset link has been sent.");
+}
+```
+
+**Mitigation Checklist**:
+- ✅ Same HTTP status code (200) for exists/not-exists
+- ✅ Same response message (generic)
+- ✅ Same response time (no timing attacks)
+- ✅ Rate limiting (prevent mass enumeration)
+- ✅ Audit logging (detect enumeration attempts)
+
+**Trade-offs**:
+- ✅ Prevents user enumeration attacks
+- ✅ Improves privacy (attackers can't harvest email lists)
+- ⚠️ Slightly worse UX (user doesn't know if email was wrong)
+- ⚠️ Support burden (users may not realize they used wrong email)
+
+**Related Security Measures**:
+- Rate limiting: Max 3 requests per email per hour
+- Honeypot emails: Log suspicious patterns (e.g., 100 different emails from same IP)
+
+---
+
+### ADR-015: Rate Limiting Strategy
+
+**Status**: Accepted
+**Date**: 2025-11-03
+**Context**: Email endpoints are vulnerable to abuse (spam, enumeration, DoS).
+
+**Decision**: Implement multi-layer rate limiting with different strategies per endpoint.
+
+#### Rate Limiting Tiers
+
+| Endpoint | Limit | Window | Scope | Rationale |
+|----------|-------|--------|-------|-----------|
+| **Verification Email** | 3 requests | 1 hour | Per email | Prevent spam, normal user needs 1-2 max |
+| **Password Reset** | 3 requests | 1 hour | Per email | Prevent enumeration, balance security vs UX |
+| **User Invitation** | 20 invitations | 1 hour | Per tenant | Prevent bulk spam, allow team onboarding |
+| **Accept Invitation** | 5 attempts | 15 minutes | Per token | Prevent brute force (token is 256-bit, near impossible) |
+
+#### Implementation: In-Memory + Redis Hybrid
+
+**Phase 1 (Day 7)**: In-memory rate limiting with `MemoryCache`
+```csharp
+public class RateLimitingService
+{
+ private readonly IMemoryCache _cache;
+
+ public async Task IsAllowedAsync(string key, int maxRequests, TimeSpan window)
+ {
+ var cacheKey = $"ratelimit:{key}";
+
+ if (_cache.TryGetValue(cacheKey, out int count))
+ {
+ if (count >= maxRequests)
+ return false;
+
+ _cache.Set(cacheKey, count + 1, window);
+ return true;
+ }
+
+ _cache.Set(cacheKey, 1, window);
+ return true;
+ }
+}
+```
+
+**Phase 2 (Post-Day 7)**: Redis-based distributed rate limiting
+```csharp
+// Use Redis INCR with expiration
+public async Task IsAllowedAsync(string key, int maxRequests, TimeSpan window)
+{
+ var redisKey = $"ratelimit:{key}";
+ var count = await _redis.StringIncrementAsync(redisKey);
+
+ if (count == 1)
+ await _redis.KeyExpireAsync(redisKey, window);
+
+ return count <= maxRequests;
+}
+```
+
+#### Integration with Commands
+
+```csharp
+public class ForgotPasswordCommandHandler : IRequestHandler
+{
+ private readonly IRateLimitingService _rateLimiter;
+
+ public async Task Handle(ForgotPasswordCommand command, CancellationToken ct)
+ {
+ // Rate limit check
+ var rateLimitKey = $"forgot-password:{command.Email}";
+ if (!await _rateLimiter.IsAllowedAsync(rateLimitKey, maxRequests: 3, TimeSpan.FromHours(1)))
+ {
+ return Result.Failure("Too many password reset requests. Please try again later.");
+ }
+
+ // ... rest of handler logic
+ }
+}
+```
+
+**Consequences**:
+- ✅ Prevents abuse (spam, DoS, enumeration)
+- ✅ Low latency (in-memory for Day 7)
+- ✅ Scalable (Redis for distributed systems)
+- ⚠️ In-memory limits don't work across multiple API instances (acceptable for Day 7)
+- ⚠️ Requires Redis for production multi-instance deployments
+
+---
+
+## 6. Integration Architecture
+
+### 6.1 Email Service Integration Points
+
+#### RegisterTenantCommandHandler (Modified)
+
+```csharp
+public class RegisterTenantCommandHandler : IRequestHandler>
+{
+ private readonly IEmailService _emailService;
+ private readonly IEmailVerificationTokenRepository _tokenRepository;
+ // ... other dependencies
+
+ public async Task> Handle(
+ RegisterTenantCommand command,
+ CancellationToken ct)
+ {
+ // 1. Create tenant and admin user (existing logic)
+ var tenant = Tenant.Create(...);
+ var adminUser = User.CreateLocal(...);
+ await _tenantRepository.AddAsync(tenant, ct);
+ await _userRepository.AddAsync(adminUser, ct);
+
+ // 2. Generate verification token (NEW)
+ var (verificationToken, plaintextToken) = EmailVerificationToken.Create(
+ adminUser.Id,
+ adminUser.Email,
+ tenant.Id);
+
+ await _tokenRepository.AddAsync(verificationToken, ct);
+
+ // 3. Send verification email (NEW - non-blocking)
+ var emailResult = await _emailService.SendEmailAsync(new EmailMessage(
+ ToAddress: adminUser.Email.Value,
+ ToName: adminUser.FullName.Value,
+ Subject: "Verify your email - ColaFlow",
+ TextBody: $"Click here to verify: https://app.colaflow.io/verify-email?token={plaintextToken}",
+ HtmlBody: RenderVerificationEmailHtml(adminUser.FullName.Value, plaintextToken)
+ ), ct);
+
+ // 4. Log email failure but don't block registration
+ if (!emailResult.Success)
+ {
+ _logger.LogWarning(
+ "Failed to send verification email to {Email}: {Error}",
+ adminUser.Email.Value,
+ emailResult.ErrorMessage);
+ }
+
+ // 5. Return success (even if email failed)
+ return Result.Success(MapToDto(tenant, adminUser, jwtToken));
+ }
+}
+```
+
+**Key Design Decision**: **Email failure is non-blocking**
+- ✅ User registration succeeds even if SendGrid is down
+- ✅ User can still login (verification is optional for Day 7)
+- ✅ User can request resend later
+
+#### ForgotPasswordCommandHandler (New)
+
+```csharp
+public class ForgotPasswordCommandHandler : IRequestHandler
+{
+ public async Task Handle(ForgotPasswordCommand command, CancellationToken ct)
+ {
+ // 1. Rate limiting
+ if (!await _rateLimiter.IsAllowedAsync($"forgot-password:{command.Email}", 3, TimeSpan.FromHours(1)))
+ return Result.Failure("Too many requests. Try again in 1 hour.");
+
+ // 2. Lookup tenant and user
+ var tenant = await _tenantRepository.GetBySlugAsync(command.TenantSlug, ct);
+ if (tenant is null)
+ return Result.Success("If an account exists, a reset link has been sent."); // Enumerate protection
+
+ var email = Email.Create(command.Email).Value;
+ var user = await _userRepository.GetByEmailAsync(tenant.Id, email, ct);
+ if (user is null)
+ return Result.Success("If an account exists, a reset link has been sent."); // Enumerate protection
+
+ // 3. Invalidate old reset tokens
+ await _resetTokenRepository.InvalidateAllByUserIdAsync(user.Id, ct);
+
+ // 4. Create new reset token
+ var (resetToken, plaintextToken) = PasswordResetToken.Create(user.Id, email, tenant.Id);
+ await _resetTokenRepository.AddAsync(resetToken, ct);
+
+ // 5. Send reset email
+ await _emailService.SendEmailAsync(new EmailMessage(
+ ToAddress: email.Value,
+ ToName: user.FullName.Value,
+ Subject: "Reset your password - ColaFlow",
+ TextBody: $"Reset link: https://app.colaflow.io/reset-password?token={plaintextToken}",
+ HtmlBody: RenderPasswordResetEmailHtml(user.FullName.Value, plaintextToken)
+ ), ct);
+
+ // 6. Always return success (enumerate protection)
+ return Result.Success("If an account exists, a reset link has been sent.");
+ }
+}
+```
+
+#### InviteUserCommandHandler (New)
+
+```csharp
+public class InviteUserCommandHandler : IRequestHandler>
+{
+ public async Task> Handle(InviteUserCommand command, CancellationToken ct)
+ {
+ // 1. Authorization check (must be TenantOwner or TenantAdmin)
+ var currentUser = await _currentUserService.GetCurrentUserAsync(ct);
+ var role = await _roleRepository.GetUserRoleAsync(currentUser.Id, command.TenantId, ct);
+ if (role != TenantRole.TenantOwner && role != TenantRole.TenantAdmin)
+ return Result.Failure("Insufficient permissions");
+
+ // 2. Validate tenant ownership
+ if (currentUser.TenantId != command.TenantId)
+ return Result.Failure("Cross-tenant invitation not allowed");
+
+ // 3. Validate email not already member
+ var email = Email.Create(command.Email).Value;
+ if (await _userRepository.ExistsByEmailAsync(command.TenantId, email, ct))
+ return Result.Failure("User already exists in this tenant");
+
+ // 4. Check for existing pending invitation
+ var existingInvitations = await _invitationRepository.GetAllByTenantAsync(
+ command.TenantId, 1, 100, InvitationStatus.Pending, ct);
+
+ if (existingInvitations.Any(i => i.InviteeEmail == email && i.IsValid))
+ return Result.Failure("A pending invitation already exists for this email");
+
+ // 5. Create invitation
+ var (invitation, plaintextToken) = Invitation.Create(
+ email,
+ command.TenantId,
+ command.Role,
+ currentUser.Id);
+
+ await _invitationRepository.AddAsync(invitation, ct);
+
+ // 6. Send invitation email
+ var tenant = await _tenantRepository.GetByIdAsync(command.TenantId, ct);
+ await _emailService.SendEmailAsync(new EmailMessage(
+ ToAddress: email.Value,
+ ToName: email.Value, // We don't know their name yet
+ Subject: $"You're invited to join {tenant.Name} on ColaFlow",
+ TextBody: $"Accept invitation: https://app.colaflow.io/accept-invitation?token={plaintextToken}",
+ HtmlBody: RenderInvitationEmailHtml(tenant.Name, currentUser.FullName.Value, command.Role, plaintextToken)
+ ), ct);
+
+ // 7. Return invitation details
+ return Result.Success(MapToDto(invitation));
+ }
+}
+```
+
+### 6.2 Domain Events Integration
+
+Day 7 introduces new domain events for audit logging and future integrations:
+
+```csharp
+// New Events
+public record EmailVerifiedEvent(UserId UserId, Email Email, TenantId TenantId, DateTime VerifiedAt);
+public record PasswordResetRequestedEvent(UserId UserId, Email Email, TenantId TenantId, string IpAddress);
+public record PasswordResetCompletedEvent(UserId UserId, TenantId TenantId);
+public record UserInvitedEvent(Guid InvitationId, Email InviteeEmail, TenantId TenantId, UserId InvitedBy, TenantRole Role);
+public record InvitationAcceptedEvent(Guid InvitationId, UserId NewUserId, TenantId TenantId);
+```
+
+**Event Handlers** (for audit logging):
+```csharp
+public class EmailVerifiedEventHandler : INotificationHandler
+{
+ private readonly IAuditLogService _auditLog;
+
+ public async Task Handle(EmailVerifiedEvent @event, CancellationToken ct)
+ {
+ await _auditLog.LogAsync(new AuditLogEntry(
+ EntityType: "User",
+ EntityId: @event.UserId.Value,
+ Action: "EmailVerified",
+ Details: $"Email {@event.Email.Value} verified",
+ TenantId: @event.TenantId.Value,
+ Timestamp: @event.VerifiedAt
+ ), ct);
+ }
+}
+```
+
+### 6.3 API Endpoint Integration
+
+New endpoints to add to `AuthController`:
+
+```csharp
+[ApiController]
+[Route("api/auth")]
+public class AuthController : ControllerBase
+{
+ // Email Verification
+ [HttpPost("verify-email")]
+ [AllowAnonymous]
+ public async Task VerifyEmail(
+ [FromBody] VerifyEmailRequest request,
+ CancellationToken ct)
+ {
+ var command = new VerifyEmailCommand(request.Token);
+ var result = await _mediator.Send(command, ct);
+ return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error);
+ }
+
+ [HttpPost("resend-verification")]
+ [AllowAnonymous]
+ public async Task ResendVerification(
+ [FromBody] ResendVerificationRequest request,
+ CancellationToken ct)
+ {
+ var command = new ResendVerificationEmailCommand(request.TenantSlug, request.Email);
+ var result = await _mediator.Send(command, ct);
+ return Ok(new { message = "If an account exists, a verification email has been sent." });
+ }
+
+ // Password Reset
+ [HttpPost("forgot-password")]
+ [AllowAnonymous]
+ public async Task ForgotPassword(
+ [FromBody] ForgotPasswordRequest request,
+ CancellationToken ct)
+ {
+ var command = new ForgotPasswordCommand(request.TenantSlug, request.Email);
+ var result = await _mediator.Send(command, ct);
+ return Ok(new { message = "If an account exists, a reset link has been sent." });
+ }
+
+ [HttpPost("reset-password")]
+ [AllowAnonymous]
+ public async Task ResetPassword(
+ [FromBody] ResetPasswordRequest request,
+ CancellationToken ct)
+ {
+ var command = new ResetPasswordCommand(request.Token, request.NewPassword);
+ var result = await _mediator.Send(command, ct);
+ return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error);
+ }
+}
+```
+
+New controller for invitations:
+
+```csharp
+[ApiController]
+[Route("api/tenants/{tenantId}/invitations")]
+[Authorize]
+public class InvitationsController : ControllerBase
+{
+ [HttpPost]
+ [RequireTenantOwner] // Custom authorization policy
+ public async Task InviteUser(
+ [FromRoute] Guid tenantId,
+ [FromBody] InviteUserRequest request,
+ CancellationToken ct)
+ {
+ var command = new InviteUserCommand(tenantId, request.Email, request.Role);
+ var result = await _mediator.Send(command, ct);
+ return result.IsSuccess ? CreatedAtAction(nameof(GetInvitation), new { id = result.Value.Id }, result.Value) : BadRequest(result.Error);
+ }
+
+ [HttpGet]
+ public async Task GetInvitations(
+ [FromRoute] Guid tenantId,
+ [FromQuery] int pageNumber = 1,
+ [FromQuery] int pageSize = 20,
+ [FromQuery] InvitationStatus? status = null,
+ CancellationToken ct)
+ {
+ var query = new GetTenantInvitationsQuery(tenantId, pageNumber, pageSize, status);
+ var result = await _mediator.Send(query, ct);
+ return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error);
+ }
+
+ [HttpDelete("{invitationId}")]
+ public async Task CancelInvitation(
+ [FromRoute] Guid tenantId,
+ [FromRoute] Guid invitationId,
+ CancellationToken ct)
+ {
+ var command = new CancelInvitationCommand(tenantId, invitationId);
+ var result = await _mediator.Send(command, ct);
+ return result.IsSuccess ? NoContent() : BadRequest(result.Error);
+ }
+}
+
+[ApiController]
+[Route("api/invitations")]
+public class PublicInvitationsController : ControllerBase
+{
+ [HttpPost("accept")]
+ [AllowAnonymous]
+ public async Task AcceptInvitation(
+ [FromBody] AcceptInvitationRequest request,
+ CancellationToken ct)
+ {
+ var command = new AcceptInvitationCommand(request.Token, request.FullName, request.Password);
+ var result = await _mediator.Send(command, ct);
+ return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error);
+ }
+}
+```
+
+---
+
+## 7. Implementation Phases
+
+### Phase 1: Email Infrastructure (Priority P0)
+**Duration**: 4-6 hours
+**Goal**: Get email sending working end-to-end
+
+**Tasks**:
+1. ✅ Create `IEmailService` interface
+2. ✅ Implement `SendGridEmailService`
+3. ✅ Implement `SmtpEmailService`
+4. ✅ Implement `MockEmailService` (for tests)
+5. ✅ Add email configuration to `appsettings.json`
+6. ✅ Create email templates (simple string interpolation for Day 7)
+7. ✅ Register services in DI container
+8. ✅ Write unit tests for email services
+
+**Acceptance Criteria**:
+- [ ] Email service can send test email via SendGrid
+- [ ] Email service can send test email via SMTP (MailHog)
+- [ ] Mock service logs emails to file in development
+- [ ] Configuration is environment-specific
+
+**Testing**:
+```bash
+# Start MailHog (Docker)
+docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
+
+# Test SMTP send
+curl -X POST http://localhost:5167/api/test/send-email \
+ -H "Content-Type: application/json" \
+ -d '{"to": "test@example.com", "subject": "Test", "body": "Hello"}'
+
+# Check MailHog UI: http://localhost:8025
+```
+
+---
+
+### Phase 2: Email Verification Flow (Priority P0)
+**Duration**: 6-8 hours
+**Goal**: Users can verify their email addresses
+
+**Tasks**:
+1. ✅ Create `SecurityToken` base class
+2. ✅ Create `EmailVerificationToken` entity
+3. ✅ Create `IEmailVerificationTokenRepository` interface and implementation
+4. ✅ Create EF Core entity configuration
+5. ✅ Generate and apply database migration
+6. ✅ Create `VerifyEmailCommand` and handler
+7. ✅ Create `ResendVerificationEmailCommand` and handler
+8. ✅ Modify `RegisterTenantCommandHandler` to send verification email
+9. ✅ Add `/api/auth/verify-email` endpoint
+10. ✅ Add `/api/auth/resend-verification` endpoint
+11. ✅ Write integration tests
+
+**Acceptance Criteria**:
+- [ ] Registration sends verification email
+- [ ] User can verify email with token
+- [ ] Invalid/expired tokens return 400
+- [ ] Resend works and invalidates old token
+- [ ] Email enumeration is prevented
+
+**Testing**:
+```powershell
+# 1. Register tenant
+$regResponse = Invoke-RestMethod -Uri "http://localhost:5167/api/tenants/register" `
+ -Method Post -ContentType "application/json" `
+ -Body '{"tenantName":"Test","tenantSlug":"test","adminEmail":"admin@test.com","adminPassword":"Admin@123","adminFullName":"Admin"}'
+
+# 2. Extract token from email logs (in development mode)
+$token = "...token-from-log..."
+
+# 3. Verify email
+Invoke-RestMethod -Uri "http://localhost:5167/api/auth/verify-email" `
+ -Method Post -ContentType "application/json" `
+ -Body "{`"token`":`"$token`"}"
+
+# 4. Test resend
+Invoke-RestMethod -Uri "http://localhost:5167/api/auth/resend-verification" `
+ -Method Post -ContentType "application/json" `
+ -Body '{"tenantSlug":"test","email":"admin@test.com"}'
+```
+
+---
+
+### Phase 3: Password Reset Flow (Priority P0)
+**Duration**: 6-8 hours
+**Goal**: Users can reset forgotten passwords
+
+**Tasks**:
+1. ✅ Create `PasswordResetToken` entity
+2. ✅ Create `IPasswordResetTokenRepository` interface and implementation
+3. ✅ Create EF Core entity configuration
+4. ✅ Generate and apply database migration
+5. ✅ Create `ForgotPasswordCommand` and handler
+6. ✅ Create `ResetPasswordCommand` and handler
+7. ✅ Implement password complexity validation
+8. ✅ Implement refresh token revocation on password reset
+9. ✅ Add `/api/auth/forgot-password` endpoint
+10. ✅ Add `/api/auth/reset-password` endpoint
+11. ✅ Write integration tests
+
+**Acceptance Criteria**:
+- [ ] User can request password reset
+- [ ] Reset email is sent with valid token
+- [ ] User can reset password with token
+- [ ] Password complexity is enforced
+- [ ] All refresh tokens are invalidated on reset
+- [ ] Used tokens cannot be reused
+- [ ] Email enumeration is prevented
+
+**Testing**:
+```powershell
+# 1. Request password reset
+Invoke-RestMethod -Uri "http://localhost:5167/api/auth/forgot-password" `
+ -Method Post -ContentType "application/json" `
+ -Body '{"tenantSlug":"test","email":"admin@test.com"}'
+
+# 2. Extract token from email
+$resetToken = "...token-from-email..."
+
+# 3. Reset password
+Invoke-RestMethod -Uri "http://localhost:5167/api/auth/reset-password" `
+ -Method Post -ContentType "application/json" `
+ -Body "{`"token`":`"$resetToken`",`"newPassword`":`"NewPassword@123`"}"
+
+# 4. Login with new password
+Invoke-RestMethod -Uri "http://localhost:5167/api/auth/login" `
+ -Method Post -ContentType "application/json" `
+ -Body '{"tenantSlug":"test","email":"admin@test.com","password":"NewPassword@123"}'
+```
+
+---
+
+### Phase 4: User Invitation System (Priority P0)
+**Duration**: 8-10 hours
+**Goal**: Tenant owners can invite team members
+
+**Tasks**:
+1. ✅ Create `Invitation` entity with `InvitationStatus` enum
+2. ✅ Create `IInvitationRepository` interface and implementation
+3. ✅ Create EF Core entity configuration
+4. ✅ Generate and apply database migration
+5. ✅ Create `InviteUserCommand` and handler
+6. ✅ Create `AcceptInvitationCommand` and handler
+7. ✅ Create `CancelInvitationCommand` and handler
+8. ✅ Create `GetTenantInvitationsQuery` and handler
+9. ✅ Create authorization policies (`RequireTenantOwner`, `RequireTenantAdmin`)
+10. ✅ Add `/api/tenants/{id}/invitations` endpoints (POST, GET, DELETE)
+11. ✅ Add `/api/invitations/accept` endpoint
+12. ✅ Write integration tests (unblock 3 skipped tests)
+
+**Acceptance Criteria**:
+- [ ] Tenant owner can invite user with role
+- [ ] Invitation email is sent
+- [ ] User can accept invitation and create account
+- [ ] Accepting invitation auto-logs user in
+- [ ] Cannot invite with TenantOwner or AIAgent role
+- [ ] Cannot invite existing members
+- [ ] Tenant owner can view pending invitations
+- [ ] Tenant owner can cancel pending invitations
+- [ ] 3 previously skipped integration tests now pass
+
+**Testing**:
+```powershell
+# 1. Login as tenant owner
+$loginResponse = Invoke-RestMethod -Uri "http://localhost:5167/api/auth/login" `
+ -Method Post -ContentType "application/json" `
+ -Body '{"tenantSlug":"test","email":"admin@test.com","password":"Admin@123"}'
+$token = $loginResponse.accessToken
+
+# 2. Invite user
+$inviteResponse = Invoke-RestMethod -Uri "http://localhost:5167/api/tenants/{tenantId}/invitations" `
+ -Method Post -ContentType "application/json" `
+ -Headers @{ Authorization = "Bearer $token" } `
+ -Body '{"email":"developer@test.com","role":"Developer"}'
+
+# 3. List invitations
+$invitations = Invoke-RestMethod -Uri "http://localhost:5167/api/tenants/{tenantId}/invitations" `
+ -Method Get -Headers @{ Authorization = "Bearer $token" }
+
+# 4. Extract token from email
+$inviteToken = "...token-from-email..."
+
+# 5. Accept invitation
+$acceptResponse = Invoke-RestMethod -Uri "http://localhost:5167/api/invitations/accept" `
+ -Method Post -ContentType "application/json" `
+ -Body "{`"token`":`"$inviteToken`",`"fullName`":`"John Developer`",`"password`":`"Dev@123`"}"
+
+# 6. Verify new user can login
+Invoke-RestMethod -Uri "http://localhost:5167/api/auth/login" `
+ -Method Post -ContentType "application/json" `
+ -Body '{"tenantSlug":"test","email":"developer@test.com","password":"Dev@123"}'
+```
+
+---
+
+### Phase 5: Polish & Testing (Priority P1)
+**Duration**: 4-6 hours
+**Goal**: Production readiness
+
+**Tasks**:
+1. ✅ Add rate limiting service
+2. ✅ Add comprehensive logging
+3. ✅ Write email template HTML (upgrade from string interpolation)
+4. ✅ Add password complexity validation
+5. ✅ Write unit tests for all commands/queries
+6. ✅ Write integration tests for all endpoints
+7. ✅ Update API documentation (Swagger)
+8. ✅ Test edge cases (expired tokens, invalid tokens, etc.)
+9. ✅ Load test email sending (ensure SendGrid quota)
+10. ✅ Update Day 7 implementation summary
+
+**Acceptance Criteria**:
+- [ ] All tests passing (unit + integration)
+- [ ] Code coverage >80%
+- [ ] Rate limiting working
+- [ ] Swagger docs updated
+- [ ] Email templates production-ready
+- [ ] No compiler warnings
+
+---
+
+## 8. Deployment Considerations
+
+### 8.1 Configuration Management
+
+**appsettings.Development.json**:
+```json
+{
+ "EmailSettings": {
+ "Provider": "Smtp",
+ "FromAddress": "noreply@colaflow.local",
+ "FromName": "ColaFlow Development",
+ "SaveEmailsToFile": true,
+ "EmailOutputPath": "temp/emails",
+ "Smtp": {
+ "Host": "localhost",
+ "Port": 1025,
+ "EnableSsl": false
+ }
+ }
+}
+```
+
+**appsettings.Production.json**:
+```json
+{
+ "EmailSettings": {
+ "Provider": "SendGrid",
+ "FromAddress": "noreply@colaflow.io",
+ "FromName": "ColaFlow",
+ "SendGrid": {
+ "ApiKey": "${SENDGRID_API_KEY}" // Injected from environment variable
+ }
+ }
+}
+```
+
+**Environment Variables** (Production):
+```bash
+SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxxx
+COLAFLOW_EMAIL_FROM=noreply@colaflow.io
+ASPNETCORE_ENVIRONMENT=Production
+```
+
+### 8.2 Database Migration Strategy
+
+**Deployment Steps**:
+```bash
+# 1. Backup production database
+pg_dump colaflow_prod > backup_before_day7.sql
+
+# 2. Apply migration (zero-downtime)
+dotnet ef database update --context IdentityDbContext --connection "Host=prod-db;Database=colaflow_prod"
+
+# 3. Verify migration
+psql colaflow_prod -c "\d email_verification_tokens"
+psql colaflow_prod -c "\d password_reset_tokens"
+psql colaflow_prod -c "\d invitations"
+
+# 4. Run smoke tests
+curl -X POST https://api.colaflow.io/api/auth/forgot-password \
+ -H "Content-Type: application/json" \
+ -d '{"tenantSlug":"test","email":"test@example.com"}'
+```
+
+**Rollback Plan** (if issues occur):
+```bash
+# 1. Revert database migration
+dotnet ef migrations remove --context IdentityDbContext
+
+# 2. Restore from backup
+psql colaflow_prod < backup_before_day7.sql
+
+# 3. Redeploy previous API version
+```
+
+### 8.3 Monitoring & Alerting
+
+**Metrics to Monitor**:
+| Metric | Threshold | Alert |
+|--------|-----------|-------|
+| Email send failure rate | >5% | Warning |
+| Email send failure rate | >20% | Critical |
+| Token validation failure rate | >10% | Warning |
+| Password reset requests | >1000/hour | Suspicious activity |
+| Invitation acceptance rate | <30% | Poor UX indicator |
+| Token expiration before use | >50% | Expiration too short |
+
+**Logging Strategy**:
+```csharp
+// INFO level
+_logger.LogInformation("Email sent to {Email} for {Purpose}", email, "EmailVerification");
+
+// WARNING level
+_logger.LogWarning("Email send failed to {Email}: {Error}", email, error);
+
+// ERROR level (only for unexpected exceptions)
+_logger.LogError(ex, "Unexpected error in email service");
+
+// AUDIT level (security events)
+_auditLogger.Log("PASSWORD_RESET_REQUESTED", userId, tenantId, ipAddress);
+```
+
+### 8.4 Security Checklist
+
+**Pre-Production**:
+- [ ] SendGrid API key stored in Azure Key Vault (not appsettings)
+- [ ] HTTPS enforced for all email links
+- [ ] Rate limiting enabled (Redis-based for multi-instance)
+- [ ] Email enumeration protection verified
+- [ ] Token expiration windows validated
+- [ ] SQL injection prevention verified (parameterized queries)
+- [ ] XSS prevention in email templates (HTML encoding)
+- [ ] CORS configured correctly
+- [ ] Audit logging enabled for all security events
+
+**Post-Deployment**:
+- [ ] Monitor for unusual password reset patterns
+- [ ] Monitor for email bounce rates
+- [ ] Monitor for failed token validations
+- [ ] Review security logs weekly
+
+---
+
+## Summary
+
+### Key Deliverables
+
+| Component | Status | Priority |
+|-----------|--------|----------|
+| Email Service Abstraction | ✅ Designed | P0 |
+| SendGrid Integration | ✅ Designed | P0 |
+| Email Verification Flow | ✅ Designed | P0 |
+| Password Reset Flow | ✅ Designed | P0 |
+| User Invitation System | ✅ Designed | P0 |
+| Database Schema (3 tables) | ✅ Designed | P0 |
+| Security ADRs (3 ADRs) | ✅ Written | P0 |
+| Integration Architecture | ✅ Defined | P0 |
+| Implementation Phases | ✅ Planned | P0 |
+
+### Architecture Highlights
+
+1. **Abstraction Layer**: Email provider is pluggable (SendGrid/SMTP/Mock)
+2. **Domain-Driven Design**: Security tokens are first-class domain entities with business logic
+3. **Security First**: SHA-256 token hashing, email enumeration prevention, rate limiting
+4. **Fail-Safe**: Email failures don't block user actions
+5. **Scalability**: In-memory rate limiting for Day 7, Redis-ready for production
+
+### Next Steps
+
+1. **Backend Team**: Implement Phase 1-4 in order
+2. **Frontend Team**: Build email verification/reset UI (Day 8)
+3. **QA Team**: Prepare test scenarios for all 4 features
+4. **DevOps Team**: Provision SendGrid account and configure secrets
+
+### Estimated Timeline
+
+- **Phase 1** (Email Infrastructure): 4-6 hours
+- **Phase 2** (Email Verification): 6-8 hours
+- **Phase 3** (Password Reset): 6-8 hours
+- **Phase 4** (Invitations): 8-10 hours
+- **Phase 5** (Polish): 4-6 hours
+
+**Total**: 28-38 hours (~4-5 working days for 1 backend engineer)
+
+---
+
+**Document Status**: ✅ Ready for Implementation
+**Last Updated**: 2025-11-03
+**Review Required**: Backend Lead, Security Team, Product Manager
diff --git a/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs b/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs
index 1838c9f..27bcb3d 100644
--- a/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs
+++ b/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs
@@ -1,5 +1,6 @@
using ColaFlow.API.Models;
using ColaFlow.Modules.Identity.Application.Commands.Login;
+using ColaFlow.Modules.Identity.Application.Commands.VerifyEmail;
using ColaFlow.Modules.Identity.Application.Services;
using MediatR;
using Microsoft.AspNetCore.Authorization;
@@ -148,6 +149,22 @@ public class AuthController(
return BadRequest(new { message = "Logout failed" });
}
}
+
+ ///
+ /// Verify email address using token
+ ///
+ [HttpPost("verify-email")]
+ [AllowAnonymous]
+ public async Task VerifyEmail([FromBody] VerifyEmailRequest request)
+ {
+ var command = new VerifyEmailCommand(request.Token);
+ var success = await mediator.Send(command);
+
+ if (!success)
+ return BadRequest(new { message = "Invalid or expired verification token" });
+
+ return Ok(new { message = "Email verified successfully" });
+ }
}
public record LoginRequest(
@@ -155,3 +172,5 @@ public record LoginRequest(
string Email,
string Password
);
+
+public record VerifyEmailRequest(string Token);
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/RegisterTenant/RegisterTenantCommandHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/RegisterTenant/RegisterTenantCommandHandler.cs
index c780c5c..346d323 100644
--- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/RegisterTenant/RegisterTenantCommandHandler.cs
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/RegisterTenant/RegisterTenantCommandHandler.cs
@@ -1,8 +1,10 @@
+using ColaFlow.Modules.Identity.Application.Commands.SendVerificationEmail;
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Repositories;
using MediatR;
+using Microsoft.Extensions.Configuration;
namespace ColaFlow.Modules.Identity.Application.Commands.RegisterTenant;
@@ -12,7 +14,9 @@ public class RegisterTenantCommandHandler(
IJwtService jwtService,
IPasswordHasher passwordHasher,
IRefreshTokenService refreshTokenService,
- IUserTenantRoleRepository userTenantRoleRepository)
+ IUserTenantRoleRepository userTenantRoleRepository,
+ IMediator mediator,
+ IConfiguration configuration)
: IRequestHandler
{
public async Task Handle(
@@ -64,7 +68,17 @@ public class RegisterTenantCommandHandler(
userAgent: null,
cancellationToken);
- // 6. Return result
+ // 7. Send verification email (non-blocking)
+ var baseUrl = configuration["App:BaseUrl"] ?? "http://localhost:3000";
+ var sendEmailCommand = new SendVerificationEmailCommand(
+ adminUser.Id,
+ request.AdminEmail,
+ baseUrl);
+
+ // Fire and forget - don't wait for email to send
+ _ = mediator.Send(sendEmailCommand, cancellationToken);
+
+ // 8. Return result
return new RegisterTenantResult(
new Dtos.TenantDto
{
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/SendVerificationEmail/SendVerificationEmailCommand.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/SendVerificationEmail/SendVerificationEmailCommand.cs
new file mode 100644
index 0000000..9e1b195
--- /dev/null
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/SendVerificationEmail/SendVerificationEmailCommand.cs
@@ -0,0 +1,9 @@
+using MediatR;
+
+namespace ColaFlow.Modules.Identity.Application.Commands.SendVerificationEmail;
+
+public sealed record SendVerificationEmailCommand(
+ Guid UserId,
+ string Email,
+ string BaseUrl
+) : IRequest;
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/SendVerificationEmail/SendVerificationEmailCommandHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/SendVerificationEmail/SendVerificationEmailCommandHandler.cs
new file mode 100644
index 0000000..05cd565
--- /dev/null
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/SendVerificationEmail/SendVerificationEmailCommandHandler.cs
@@ -0,0 +1,95 @@
+using ColaFlow.Modules.Identity.Application.Services;
+using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
+using ColaFlow.Modules.Identity.Domain.Entities;
+using ColaFlow.Modules.Identity.Domain.Repositories;
+using ColaFlow.Modules.Identity.Domain.Services;
+using MediatR;
+using Microsoft.Extensions.Logging;
+
+namespace ColaFlow.Modules.Identity.Application.Commands.SendVerificationEmail;
+
+public class SendVerificationEmailCommandHandler : IRequestHandler
+{
+ private readonly IUserRepository _userRepository;
+ private readonly IEmailVerificationTokenRepository _tokenRepository;
+ private readonly ISecurityTokenService _tokenService;
+ private readonly IEmailService _emailService;
+ private readonly IEmailTemplateService _templateService;
+ private readonly ILogger _logger;
+
+ public SendVerificationEmailCommandHandler(
+ IUserRepository userRepository,
+ IEmailVerificationTokenRepository tokenRepository,
+ ISecurityTokenService tokenService,
+ IEmailService emailService,
+ IEmailTemplateService templateService,
+ ILogger logger)
+ {
+ _userRepository = userRepository;
+ _tokenRepository = tokenRepository;
+ _tokenService = tokenService;
+ _emailService = emailService;
+ _templateService = templateService;
+ _logger = logger;
+ }
+
+ public async Task Handle(SendVerificationEmailCommand request, CancellationToken cancellationToken)
+ {
+ var userId = UserId.Create(request.UserId);
+ var user = await _userRepository.GetByIdAsync(userId, cancellationToken);
+
+ if (user == null)
+ {
+ _logger.LogWarning("User {UserId} not found, cannot send verification email", request.UserId);
+ return Unit.Value;
+ }
+
+ // If already verified, no need to send email
+ if (user.IsEmailVerified)
+ {
+ _logger.LogInformation("User {UserId} email already verified, skipping verification email", request.UserId);
+ return Unit.Value;
+ }
+
+ // Generate token
+ var token = _tokenService.GenerateToken();
+ var tokenHash = _tokenService.HashToken(token);
+
+ // Create verification token entity
+ var verificationToken = EmailVerificationToken.Create(
+ userId,
+ tokenHash,
+ DateTime.UtcNow.AddHours(24));
+
+ await _tokenRepository.AddAsync(verificationToken, cancellationToken);
+
+ // Send email (non-blocking)
+ var verificationLink = $"{request.BaseUrl}/verify-email?token={token}";
+ var htmlBody = _templateService.RenderVerificationEmail(user.FullName.Value, verificationLink);
+
+ var emailMessage = new EmailMessage(
+ To: request.Email,
+ Subject: "Verify your email address - ColaFlow",
+ HtmlBody: htmlBody,
+ PlainTextBody: $"Click the link to verify your email: {verificationLink}");
+
+ var success = await _emailService.SendEmailAsync(emailMessage, cancellationToken);
+
+ if (!success)
+ {
+ _logger.LogWarning(
+ "Failed to send verification email to {Email} for user {UserId}",
+ request.Email,
+ request.UserId);
+ }
+ else
+ {
+ _logger.LogInformation(
+ "Verification email sent to {Email} for user {UserId}",
+ request.Email,
+ request.UserId);
+ }
+
+ return Unit.Value;
+ }
+}
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/VerifyEmail/VerifyEmailCommand.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/VerifyEmail/VerifyEmailCommand.cs
new file mode 100644
index 0000000..e41e97a
--- /dev/null
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/VerifyEmail/VerifyEmailCommand.cs
@@ -0,0 +1,5 @@
+using MediatR;
+
+namespace ColaFlow.Modules.Identity.Application.Commands.VerifyEmail;
+
+public sealed record VerifyEmailCommand(string Token) : IRequest;
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/VerifyEmail/VerifyEmailCommandHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/VerifyEmail/VerifyEmailCommandHandler.cs
new file mode 100644
index 0000000..244695c
--- /dev/null
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/VerifyEmail/VerifyEmailCommandHandler.cs
@@ -0,0 +1,68 @@
+using ColaFlow.Modules.Identity.Application.Services;
+using ColaFlow.Modules.Identity.Domain.Repositories;
+using MediatR;
+using Microsoft.Extensions.Logging;
+
+namespace ColaFlow.Modules.Identity.Application.Commands.VerifyEmail;
+
+public class VerifyEmailCommandHandler : IRequestHandler
+{
+ private readonly IEmailVerificationTokenRepository _tokenRepository;
+ private readonly IUserRepository _userRepository;
+ private readonly ISecurityTokenService _tokenService;
+ private readonly ILogger _logger;
+
+ public VerifyEmailCommandHandler(
+ IEmailVerificationTokenRepository tokenRepository,
+ IUserRepository userRepository,
+ ISecurityTokenService tokenService,
+ ILogger logger)
+ {
+ _tokenRepository = tokenRepository;
+ _userRepository = userRepository;
+ _tokenService = tokenService;
+ _logger = logger;
+ }
+
+ public async Task Handle(VerifyEmailCommand request, CancellationToken cancellationToken)
+ {
+ // Hash the token to look it up
+ var tokenHash = _tokenService.HashToken(request.Token);
+ var verificationToken = await _tokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
+
+ if (verificationToken == null)
+ {
+ _logger.LogWarning("Email verification token not found");
+ return false;
+ }
+
+ if (!verificationToken.IsValid)
+ {
+ _logger.LogWarning(
+ "Email verification token is invalid. IsExpired: {IsExpired}, IsVerified: {IsVerified}",
+ verificationToken.IsExpired,
+ verificationToken.IsVerified);
+ return false;
+ }
+
+ // Get user and mark email as verified
+ var user = await _userRepository.GetByIdAsync(verificationToken.UserId, cancellationToken);
+ if (user == null)
+ {
+ _logger.LogError("User {UserId} not found for email verification", verificationToken.UserId);
+ return false;
+ }
+
+ // Mark token as verified
+ verificationToken.MarkAsVerified();
+ await _tokenRepository.UpdateAsync(verificationToken, cancellationToken);
+
+ // Mark user email as verified (will emit domain event)
+ user.VerifyEmail();
+ await _userRepository.UpdateAsync(user, cancellationToken);
+
+ _logger.LogInformation("Email verified for user {UserId}", user.Id);
+
+ return true;
+ }
+}
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Services/ISecurityTokenService.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Services/ISecurityTokenService.cs
new file mode 100644
index 0000000..0cbc616
--- /dev/null
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Services/ISecurityTokenService.cs
@@ -0,0 +1,22 @@
+namespace ColaFlow.Modules.Identity.Application.Services;
+
+///
+/// Service for generating and hashing security tokens
+///
+public interface ISecurityTokenService
+{
+ ///
+ /// Generate a cryptographically secure random token (256-bit, base64url-encoded)
+ ///
+ string GenerateToken();
+
+ ///
+ /// Hash a token using SHA-256
+ ///
+ string HashToken(string token);
+
+ ///
+ /// Verify a token against a hash
+ ///
+ bool VerifyToken(string token, string hash);
+}
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/EmailVerifiedEvent.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/EmailVerifiedEvent.cs
new file mode 100644
index 0000000..3bc54ce
--- /dev/null
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/Events/EmailVerifiedEvent.cs
@@ -0,0 +1,5 @@
+using ColaFlow.Shared.Kernel.Events;
+
+namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events;
+
+public sealed record EmailVerifiedEvent(Guid UserId, string Email) : DomainEvent;
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/User.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/User.cs
index 9c94cde..6ff0f70 100644
--- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/User.cs
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Aggregates/Users/User.cs
@@ -34,7 +34,10 @@ public sealed class User : AggregateRoot
public DateTime? LastLoginAt { get; private set; }
public DateTime? EmailVerifiedAt { get; private set; }
- // Security
+ // Email verification status
+ public bool IsEmailVerified => EmailVerifiedAt.HasValue;
+
+ // Security (deprecated - moved to separate token entities)
public string? EmailVerificationToken { get; private set; }
public string? PasswordResetToken { get; private set; }
public DateTime? PasswordResetTokenExpiresAt { get; private set; }
@@ -159,9 +162,14 @@ public sealed class User : AggregateRoot
public void VerifyEmail()
{
+ if (IsEmailVerified)
+ return; // Already verified, idempotent
+
EmailVerifiedAt = DateTime.UtcNow;
EmailVerificationToken = null;
UpdatedAt = DateTime.UtcNow;
+
+ AddDomainEvent(new EmailVerifiedEvent(Id, Email));
}
public void Suspend(string reason)
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Entities/EmailVerificationToken.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Entities/EmailVerificationToken.cs
new file mode 100644
index 0000000..d04fb80
--- /dev/null
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Entities/EmailVerificationToken.cs
@@ -0,0 +1,52 @@
+using ColaFlow.Shared.Kernel.Common;
+using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
+
+namespace ColaFlow.Modules.Identity.Domain.Entities;
+
+///
+/// Email verification token entity.
+/// Lifetime: 24 hours.
+///
+public sealed class EmailVerificationToken : Entity
+{
+ public UserId UserId { get; private set; } = null!;
+ public string TokenHash { get; private set; } = string.Empty;
+ public DateTime ExpiresAt { get; private set; }
+ public DateTime? VerifiedAt { get; private set; }
+ public DateTime CreatedAt { get; private set; }
+
+ // Private constructor for EF Core
+ private EmailVerificationToken() : base()
+ {
+ }
+
+ ///
+ /// Factory method to create new verification token.
+ ///
+ public static EmailVerificationToken Create(
+ UserId userId,
+ string tokenHash,
+ DateTime expiresAt)
+ {
+ return new EmailVerificationToken
+ {
+ Id = Guid.NewGuid(),
+ UserId = userId,
+ TokenHash = tokenHash,
+ ExpiresAt = expiresAt,
+ CreatedAt = DateTime.UtcNow
+ };
+ }
+
+ public bool IsExpired => DateTime.UtcNow > ExpiresAt;
+ public bool IsVerified => VerifiedAt.HasValue;
+ public bool IsValid => !IsExpired && !IsVerified;
+
+ public void MarkAsVerified()
+ {
+ if (!IsValid)
+ throw new InvalidOperationException("Token is not valid for verification");
+
+ VerifiedAt = DateTime.UtcNow;
+ }
+}
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Repositories/IEmailVerificationTokenRepository.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Repositories/IEmailVerificationTokenRepository.cs
new file mode 100644
index 0000000..391764a
--- /dev/null
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Domain/Repositories/IEmailVerificationTokenRepository.cs
@@ -0,0 +1,38 @@
+using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
+using ColaFlow.Modules.Identity.Domain.Entities;
+
+namespace ColaFlow.Modules.Identity.Domain.Repositories;
+
+///
+/// Repository interface for EmailVerificationToken entity
+///
+public interface IEmailVerificationTokenRepository
+{
+ ///
+ /// Get verification token by token hash
+ ///
+ Task GetByTokenHashAsync(
+ string tokenHash,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Get active verification token by user ID
+ ///
+ Task GetActiveByUserIdAsync(
+ UserId userId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Add a new verification token
+ ///
+ Task AddAsync(
+ EmailVerificationToken token,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Update an existing verification token
+ ///
+ Task UpdateAsync(
+ EmailVerificationToken token,
+ CancellationToken cancellationToken = default);
+}
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs
index b771d69..6f3638c 100644
--- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/DependencyInjection.cs
@@ -37,11 +37,13 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
// Application Services
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
// Email Services
var emailProvider = configuration["Email:Provider"] ?? "Mock";
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/EmailVerificationTokenConfiguration.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/EmailVerificationTokenConfiguration.cs
new file mode 100644
index 0000000..92c762b
--- /dev/null
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/EmailVerificationTokenConfiguration.cs
@@ -0,0 +1,51 @@
+using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
+using ColaFlow.Modules.Identity.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Configurations;
+
+public class EmailVerificationTokenConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("email_verification_tokens");
+
+ // Primary Key
+ builder.HasKey(t => t.Id);
+ builder.Property(t => t.Id).HasColumnName("id");
+
+ // User ID (foreign key) - stored as Guid, mapped to UserId value object
+ builder.Property(t => t.UserId)
+ .HasConversion(
+ userId => (Guid)userId,
+ value => UserId.Create(value))
+ .IsRequired()
+ .HasColumnName("user_id");
+
+ // Token hash
+ builder.Property(t => t.TokenHash)
+ .HasMaxLength(64)
+ .IsRequired()
+ .HasColumnName("token_hash");
+
+ // Timestamps
+ builder.Property(t => t.ExpiresAt)
+ .IsRequired()
+ .HasColumnName("expires_at");
+
+ builder.Property(t => t.CreatedAt)
+ .IsRequired()
+ .HasColumnName("created_at");
+
+ builder.Property(t => t.VerifiedAt)
+ .HasColumnName("verified_at");
+
+ // Indexes
+ builder.HasIndex(t => t.TokenHash)
+ .HasDatabaseName("ix_email_verification_tokens_token_hash");
+
+ builder.HasIndex(t => t.UserId)
+ .HasDatabaseName("ix_email_verification_tokens_user_id");
+ }
+}
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs
index f6f22bf..db895ef 100644
--- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs
@@ -1,5 +1,6 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
+using ColaFlow.Modules.Identity.Domain.Entities;
using ColaFlow.Modules.Identity.Infrastructure.Services;
using ColaFlow.Shared.Kernel.Common;
using MediatR;
@@ -17,6 +18,7 @@ public class IdentityDbContext(
public DbSet Users => Set();
public DbSet RefreshTokens => Set();
public DbSet UserTenantRoles => Set();
+ public DbSet EmailVerificationTokens => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103202856_AddEmailVerification.Designer.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103202856_AddEmailVerification.Designer.cs
new file mode 100644
index 0000000..e576040
--- /dev/null
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103202856_AddEmailVerification.Designer.cs
@@ -0,0 +1,370 @@
+//
+using System;
+using ColaFlow.Modules.Identity.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(IdentityDbContext))]
+ [Migration("20251103202856_AddEmailVerification")]
+ partial class AddEmailVerification
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Tenant", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("MaxProjects")
+ .HasColumnType("integer")
+ .HasColumnName("max_projects");
+
+ b.Property("MaxStorageGB")
+ .HasColumnType("integer")
+ .HasColumnName("max_storage_gb");
+
+ b.Property("MaxUsers")
+ .HasColumnType("integer")
+ .HasColumnName("max_users");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("name");
+
+ b.Property("Plan")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("plan");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("slug");
+
+ b.Property("SsoConfig")
+ .HasColumnType("jsonb")
+ .HasColumnName("sso_config");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("status");
+
+ b.Property("SuspendedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("suspended_at");
+
+ b.Property("SuspensionReason")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("suspension_reason");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Slug")
+ .IsUnique()
+ .HasDatabaseName("ix_tenants_slug");
+
+ b.ToTable("tenants", (string)null);
+ });
+
+ modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.RefreshToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("DeviceInfo")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("device_info");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("IpAddress")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("ip_address");
+
+ b.Property("ReplacedByToken")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("replaced_by_token");
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("revoked_at");
+
+ b.Property("RevokedReason")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("revoked_reason");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("token_hash");
+
+ b.Property("UserAgent")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("user_agent");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ExpiresAt")
+ .HasDatabaseName("ix_refresh_tokens_expires_at");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_refresh_tokens_tenant_id");
+
+ b.HasIndex("TokenHash")
+ .IsUnique()
+ .HasDatabaseName("ix_refresh_tokens_token_hash");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_refresh_tokens_user_id");
+
+ b.ToTable("refresh_tokens", "identity");
+ });
+
+ modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AuthProvider")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("auth_provider");
+
+ b.Property("AvatarUrl")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("avatar_url");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("email");
+
+ b.Property("EmailVerificationToken")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("email_verification_token");
+
+ b.Property("EmailVerifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("email_verified_at");
+
+ b.Property("ExternalEmail")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("external_email");
+
+ b.Property("ExternalUserId")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("external_user_id");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("full_name");
+
+ b.Property("JobTitle")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("job_title");
+
+ b.Property("LastLoginAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_login_at");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("password_hash");
+
+ b.Property("PasswordResetToken")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("password_reset_token");
+
+ b.Property("PasswordResetTokenExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("password_reset_token_expires_at");
+
+ b.Property("PhoneNumber")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("phone_number");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("status");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "Email")
+ .IsUnique()
+ .HasDatabaseName("ix_users_tenant_id_email");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.UserTenantRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AssignedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("assigned_at");
+
+ b.Property("AssignedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("assigned_by_user_id");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("role");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Role")
+ .HasDatabaseName("ix_user_tenant_roles_role");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_user_tenant_roles_tenant_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_user_tenant_roles_user_id");
+
+ b.HasIndex("UserId", "TenantId")
+ .IsUnique()
+ .HasDatabaseName("uq_user_tenant_roles_user_tenant");
+
+ b.ToTable("user_tenant_roles", "identity");
+ });
+
+ modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Entities.EmailVerificationToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("token_hash");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("VerifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("verified_at");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TokenHash")
+ .HasDatabaseName("ix_email_verification_tokens_token_hash");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_email_verification_tokens_user_id");
+
+ b.ToTable("email_verification_tokens", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103202856_AddEmailVerification.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103202856_AddEmailVerification.cs
new file mode 100644
index 0000000..31c883b
--- /dev/null
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/20251103202856_AddEmailVerification.cs
@@ -0,0 +1,48 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
+{
+ ///
+ public partial class AddEmailVerification : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "email_verification_tokens",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ user_id = table.Column(type: "uuid", nullable: false),
+ token_hash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false),
+ expires_at = table.Column(type: "timestamp with time zone", nullable: false),
+ verified_at = table.Column(type: "timestamp with time zone", nullable: true),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_email_verification_tokens", x => x.id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_email_verification_tokens_token_hash",
+ table: "email_verification_tokens",
+ column: "token_hash");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_email_verification_tokens_user_id",
+ table: "email_verification_tokens",
+ column: "user_id");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "email_verification_tokens");
+ }
+ }
+}
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs
index 59b06d8..19c2d7d 100644
--- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs
@@ -321,6 +321,46 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
b.ToTable("user_tenant_roles", "identity");
});
+
+ modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Entities.EmailVerificationToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("token_hash");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("VerifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("verified_at");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TokenHash")
+ .HasDatabaseName("ix_email_verification_tokens_token_hash");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_email_verification_tokens_user_id");
+
+ b.ToTable("email_verification_tokens", (string)null);
+ });
#pragma warning restore 612, 618
}
}
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/EmailVerificationTokenRepository.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/EmailVerificationTokenRepository.cs
new file mode 100644
index 0000000..f4953d7
--- /dev/null
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/EmailVerificationTokenRepository.cs
@@ -0,0 +1,43 @@
+using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
+using ColaFlow.Modules.Identity.Domain.Entities;
+using ColaFlow.Modules.Identity.Domain.Repositories;
+using Microsoft.EntityFrameworkCore;
+
+namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
+
+public class EmailVerificationTokenRepository(IdentityDbContext context) : IEmailVerificationTokenRepository
+{
+ public async Task GetByTokenHashAsync(
+ string tokenHash,
+ CancellationToken cancellationToken = default)
+ {
+ return await context.EmailVerificationTokens
+ .FirstOrDefaultAsync(t => t.TokenHash == tokenHash, cancellationToken);
+ }
+
+ public async Task GetActiveByUserIdAsync(
+ UserId userId,
+ CancellationToken cancellationToken = default)
+ {
+ return await context.EmailVerificationTokens
+ .Where(t => t.UserId == userId && t.VerifiedAt == null && t.ExpiresAt > DateTime.UtcNow)
+ .OrderByDescending(t => t.CreatedAt)
+ .FirstOrDefaultAsync(cancellationToken);
+ }
+
+ public async Task AddAsync(
+ EmailVerificationToken token,
+ CancellationToken cancellationToken = default)
+ {
+ await context.EmailVerificationTokens.AddAsync(token, cancellationToken);
+ await context.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task UpdateAsync(
+ EmailVerificationToken token,
+ CancellationToken cancellationToken = default)
+ {
+ context.EmailVerificationTokens.Update(token);
+ await context.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/SecurityTokenService.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/SecurityTokenService.cs
new file mode 100644
index 0000000..f835fdf
--- /dev/null
+++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/SecurityTokenService.cs
@@ -0,0 +1,36 @@
+using System.Security.Cryptography;
+using System.Text;
+using ColaFlow.Modules.Identity.Application.Services;
+
+namespace ColaFlow.Modules.Identity.Infrastructure.Services;
+
+public class SecurityTokenService : ISecurityTokenService
+{
+ public string GenerateToken()
+ {
+ var tokenBytes = new byte[32]; // 256 bits
+ using var rng = RandomNumberGenerator.Create();
+ rng.GetBytes(tokenBytes);
+
+ // Base64URL encoding (URL-safe, no padding)
+ return Convert.ToBase64String(tokenBytes)
+ .Replace("+", "-")
+ .Replace("/", "_")
+ .TrimEnd('=');
+ }
+
+ public string HashToken(string token)
+ {
+ using var sha256 = SHA256.Create();
+ var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(token));
+ return Convert.ToBase64String(hashBytes);
+ }
+
+ public bool VerifyToken(string token, string hash)
+ {
+ var computedHash = HashToken(token);
+ return CryptographicOperations.FixedTimeEquals(
+ Encoding.UTF8.GetBytes(computedHash),
+ Encoding.UTF8.GetBytes(hash));
+ }
+}