Files
ColaFlow/colaflow-api/DAY7-ARCHITECTURE.md
Yaojia Wang 3dcecc656f feat(backend): Implement email verification flow - Phase 2
Add complete email verification system with token-based verification.

Changes:
- Created EmailVerificationToken domain entity with expiration and verification tracking
- Created EmailVerifiedEvent domain event for audit trail
- Updated User entity with IsEmailVerified property and VerifyEmail method
- Created IEmailVerificationTokenRepository interface and implementation
- Created SecurityTokenService for secure token generation and SHA-256 hashing
- Created EmailVerificationTokenConfiguration for EF Core mapping
- Updated IdentityDbContext to include EmailVerificationTokens DbSet
- Created SendVerificationEmailCommand and handler for sending verification emails
- Created VerifyEmailCommand and handler for email verification
- Added POST /api/auth/verify-email endpoint to AuthController
- Integrated email verification into RegisterTenantCommandHandler
- Registered all new services in DependencyInjection
- Created and applied AddEmailVerification database migration
- Build successful with no compilation errors

Database Schema:
- email_verification_tokens table with indexes on token_hash and user_id
- 24-hour token expiration
- One-time use tokens with verification tracking

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 21:30:40 +01:00

65 KiB

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


Table of Contents

  1. Overview
  2. Technology Stack Decisions
  3. Core Architecture Components
  4. Database Schema Design
  5. Security Architecture (ADRs)
  6. Integration Architecture
  7. Implementation Phases
  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:

// Configuration-driven selection
services.AddScoped<IEmailService>(provider =>
{
    var config = provider.GetRequiredService<EmailSettings>();
    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:

// 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<string> 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

namespace ColaFlow.Modules.Identity.Application.Services;

/// <summary>
/// Abstraction for sending transactional emails.
/// Implementations: SendGrid, SMTP, Mock (for testing).
/// </summary>
public interface IEmailService
{
    /// <summary>
    /// Send an email asynchronously.
    /// </summary>
    /// <param name="message">Email message details</param>
    /// <param name="cancellationToken">Cancellation token</param>
    /// <returns>EmailSendResult with success status and metadata</returns>
    Task<EmailSendResult> SendEmailAsync(
        EmailMessage message,
        CancellationToken cancellationToken = default);
}

/// <summary>
/// Email message data transfer object.
/// </summary>
public record EmailMessage(
    string ToAddress,
    string ToName,
    string Subject,
    string TextBody,
    string? HtmlBody = null,
    string? FromAddress = null,
    string? FromName = null);

/// <summary>
/// Result of email send operation.
/// </summary>
public record EmailSendResult(
    bool Success,
    string? MessageId = null,
    string? ErrorMessage = null);

Implementation: SendGrid

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<SendGridEmailService> _logger;

    public SendGridEmailService(
        SendGridSettings settings,
        ILogger<SendGridEmailService> logger)
    {
        _client = new SendGridClient(settings.ApiKey);
        _defaultFromAddress = settings.FromAddress;
        _defaultFromName = settings.FromName;
        _logger = logger;
    }

    public async Task<EmailSendResult> 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)

public class SmtpEmailService : IEmailService
{
    private readonly SmtpSettings _settings;
    private readonly ILogger<SmtpEmailService> _logger;

    public async Task<EmailSendResult> 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.

namespace ColaFlow.Modules.Identity.Domain.Aggregates.SecurityTokens;

/// <summary>
/// Abstract base class for all time-limited security tokens.
/// Enforces consistent token hashing and expiration logic.
/// </summary>
public abstract class SecurityToken : Entity
{
    /// <summary>
    /// SHA-256 hash of the actual token (never store plaintext token).
    /// </summary>
    public string TokenHash { get; protected set; } = string.Empty;

    /// <summary>
    /// Token expiration timestamp (UTC).
    /// </summary>
    public DateTime ExpiresAt { get; protected set; }

    /// <summary>
    /// Timestamp when token was created (UTC).
    /// </summary>
    public DateTime CreatedAt { get; protected set; }

    /// <summary>
    /// Timestamp when token was used/consumed (UTC). Null if not used.
    /// </summary>
    public DateTime? UsedAt { get; protected set; }

    /// <summary>
    /// User ID associated with this token.
    /// </summary>
    public UserId UserId { get; protected set; } = null!;

    /// <summary>
    /// Check if token is expired.
    /// </summary>
    public bool IsExpired => DateTime.UtcNow > ExpiresAt;

    /// <summary>
    /// Check if token has been used.
    /// </summary>
    public bool IsUsed => UsedAt.HasValue;

    /// <summary>
    /// Check if token is valid (not expired and not used).
    /// </summary>
    public bool IsValid => !IsExpired && !IsUsed;

    /// <summary>
    /// Mark token as used (prevents reuse).
    /// </summary>
    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;
    }

    /// <summary>
    /// Validate provided token against stored hash.
    /// </summary>
    /// <param name="providedToken">The plaintext token to validate</param>
    /// <returns>True if token matches hash</returns>
    public bool ValidateToken(string providedToken)
    {
        if (string.IsNullOrWhiteSpace(providedToken))
            return false;

        var providedHash = HashToken(providedToken);
        return TokenHash == providedHash;
    }

    /// <summary>
    /// Hash a token using SHA-256.
    /// </summary>
    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);
    }

    /// <summary>
    /// Generate a cryptographically secure random token (256-bit, base64url-encoded).
    /// </summary>
    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

namespace ColaFlow.Modules.Identity.Domain.Aggregates.SecurityTokens;

/// <summary>
/// Email verification token entity.
/// Lifetime: 24 hours.
/// </summary>
public sealed class EmailVerificationToken : SecurityToken
{
    /// <summary>
    /// Email address being verified.
    /// </summary>
    public Email Email { get; private set; } = null!;

    /// <summary>
    /// Tenant ID for multi-tenant isolation.
    /// </summary>
    public TenantId TenantId { get; private set; } = null!;

    // EF Core constructor
    private EmailVerificationToken() : base() { }

    /// <summary>
    /// Factory method to create new verification token.
    /// </summary>
    /// <returns>Tuple of (entity, plaintext token for email)</returns>
    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

/// <summary>
/// Password reset token entity.
/// Lifetime: 1 hour.
/// </summary>
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

/// <summary>
/// User invitation entity.
/// Lifetime: 7 days.
/// </summary>
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

namespace ColaFlow.Modules.Identity.Domain.Repositories;

public interface IEmailVerificationTokenRepository
{
    Task<EmailVerificationToken?> GetByTokenHashAsync(
        string tokenHash,
        CancellationToken cancellationToken = default);

    Task<EmailVerificationToken?> GetActiveByUserIdAsync(
        UserId userId,
        CancellationToken cancellationToken = default);

    Task AddAsync(
        EmailVerificationToken token,
        CancellationToken cancellationToken = default);

    Task UpdateAsync(
        EmailVerificationToken token,
        CancellationToken cancellationToken = default);
}

public interface IPasswordResetTokenRepository
{
    Task<PasswordResetToken?> 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<Invitation?> GetByTokenHashAsync(
        string tokenHash,
        CancellationToken cancellationToken = default);

    Task<Invitation?> GetByIdAsync(
        Guid invitationId,
        CancellationToken cancellationToken = default);

    Task<IReadOnlyList<Invitation>> GetAllByTenantAsync(
        TenantId tenantId,
        int pageNumber,
        int pageSize,
        InvitationStatus? status = null,
        CancellationToken cancellationToken = default);

    Task<int> 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

// Email Verification
public record VerifyEmailCommand(string Token) : IRequest<Result>;
public record ResendVerificationEmailCommand(string TenantSlug, string Email) : IRequest<Result>;

// Password Reset
public record ForgotPasswordCommand(string TenantSlug, string Email) : IRequest<Result>;
public record ResetPasswordCommand(string Token, string NewPassword) : IRequest<Result>;

// User Invitation
public record InviteUserCommand(
    Guid TenantId,
    string Email,
    TenantRole Role) : IRequest<Result<InvitationDto>>;

public record AcceptInvitationCommand(
    string Token,
    string FullName,
    string Password) : IRequest<Result<LoginResponseDto>>;

public record CancelInvitationCommand(
    Guid TenantId,
    Guid InvitationId) : IRequest<Result>;

Queries

public record GetTenantInvitationsQuery(
    Guid TenantId,
    int PageNumber = 1,
    int PageSize = 20,
    InvitationStatus? Status = null) : IRequest<Result<PagedList<InvitationDto>>>;

public record GetInvitationByTokenQuery(
    string Token) : IRequest<Result<InvitationDetailsDto>>;

4. Database Schema Design

4.1 New Tables

email_verification_tokens

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

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

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

-- 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

namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Configurations;

public class EmailVerificationTokenConfiguration : IEntityTypeConfiguration<EmailVerificationToken>
{
    public void Configure(EntityTypeBuilder<EmailVerificationToken> 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<User>()
            .WithMany()
            .HasForeignKey(t => t.UserId)
            .OnDelete(DeleteBehavior.Cascade);

        builder.HasOne<Tenant>()
            .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

# 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:

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:

public async Task<Result> 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

public class RateLimitingService
{
    private readonly IMemoryCache _cache;

    public async Task<bool> 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

// Use Redis INCR with expiration
public async Task<bool> 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

public class ForgotPasswordCommandHandler : IRequestHandler<ForgotPasswordCommand, Result>
{
    private readonly IRateLimitingService _rateLimiter;

    public async Task<Result> 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)

public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantCommand, Result<TenantDto>>
{
    private readonly IEmailService _emailService;
    private readonly IEmailVerificationTokenRepository _tokenRepository;
    // ... other dependencies

    public async Task<Result<TenantDto>> 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)

public class ForgotPasswordCommandHandler : IRequestHandler<ForgotPasswordCommand, Result>
{
    public async Task<Result> 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)

public class InviteUserCommandHandler : IRequestHandler<InviteUserCommand, Result<InvitationDto>>
{
    public async Task<Result<InvitationDto>> 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:

// 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):

public class EmailVerifiedEventHandler : INotificationHandler<EmailVerifiedEvent>
{
    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:

[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
    // Email Verification
    [HttpPost("verify-email")]
    [AllowAnonymous]
    public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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:

[ApiController]
[Route("api/tenants/{tenantId}/invitations")]
[Authorize]
public class InvitationsController : ControllerBase
{
    [HttpPost]
    [RequireTenantOwner] // Custom authorization policy
    public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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:

# 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:

# 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:

# 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:

# 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:

{
  "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:

{
  "EmailSettings": {
    "Provider": "SendGrid",
    "FromAddress": "noreply@colaflow.io",
    "FromName": "ColaFlow",
    "SendGrid": {
      "ApiKey": "${SENDGRID_API_KEY}" // Injected from environment variable
    }
  }
}

Environment Variables (Production):

SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxxx
COLAFLOW_EMAIL_FROM=noreply@colaflow.io
ASPNETCORE_ENVIRONMENT=Production

8.2 Database Migration Strategy

Deployment Steps:

# 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):

# 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:

// 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