Two-phase implementation:
- Phase A: Remove Entity Framework Core, migrate to RoboDodd.OrmLite (fresh DB start)
- Phase B: Add webhook capture feature
- A1: Add git submodule and update csproj
- A2: Update model attributes (User, Mail, MailGroup, RefreshToken, UserMailRead, Contact)
- A3: Create DatabaseService
- A4: Update Program.cs
- A5: Update controllers (AuthController, MailController, WebhookController)
- A6: Update services (DatabaseInitializer, RefreshTokenService, MailGroupService, UserManagementService, MailCleanupService)
- A7: Delete EF files (MailVoidDbContext, Migrations folder)
- A8: Test - verify app runs with fresh DB
- B1: Create Webhook and WebhookBucket models
- B2: Update DatabaseService to create webhook tables
- B3: Create WebhookBucketService and WebhookCleanupService
- B4: Create HooksController and WebhookManagementController
- B5: Update SignalR service (frontend)
- B6: Create webhook.service.ts (frontend)
- B7: Create Hooks page components
- B8: Create HookDetail page components
- B9: Update routes and app config
- B10: Test - verify webhook capture and viewing works
git submodule add https://github.com/timothydodd/RoboDodd.OrmLite.git src/RoboDodd.OrmLiteUpdate src/MailVoidApi/MailVoidApi.csproj:
- Add project reference to OrmLite
- Remove EF Core packages:
Microsoft.EntityFrameworkCoreMicrosoft.EntityFrameworkCore.DesignPomelo.EntityFrameworkCore.MySql
Models need to use OrmLite attributes. Most are already compatible ([Key], [Required]), but we need to add [Table] and [Index] attributes.
src/MailVoidApi/Models/User.cs
[Table("User")]
[Index("IX_User_UserName", nameof(UserName), IsUnique = true)]
public class User
{
[Key]
public Guid Id { get; set; }
[Required]
public required string UserName { get; set; }
[Required]
public required string PasswordHash { get; set; }
[Required]
public required DateTime TimeStamp { get; set; }
public Role Role { get; set; } = Role.User;
}src/MailVoidApi/Models/Mail.cs
[Table("Mail")]
[Index("IX_Mail_To", nameof(To))]
[Index("IX_Mail_From", nameof(From))]
[Index("IX_Mail_MailGroupPath", nameof(MailGroupPath))]
public class Mail
{
[Key]
public long Id { get; set; }
[Required]
public required string To { get; set; }
[Required]
public required string Text { get; set; }
public bool IsHtml { get; set; }
[Required]
public required string From { get; set; }
public string? FromName { get; set; }
public string? ToOthers { get; set; }
[Required]
public required string Subject { get; set; }
public string? Charsets { get; set; }
[Default(DefaultType.CurrentTimestamp)]
public DateTime CreatedOn { get; set; } = DateTime.UtcNow;
public string? MailGroupPath { get; set; }
}src/MailVoidApi/Models/MailGroup.cs
[Table("MailGroup")]
[Index("IX_MailGroup_Path", nameof(Path))]
[Index("IX_MailGroup_Subdomain", nameof(Subdomain))]
public class MailGroup
{
[Key]
public long Id { get; set; }
public string? Path { get; set; }
public string? Subdomain { get; set; }
public string? Description { get; set; }
[Required]
public required Guid OwnerUserId { get; set; }
public bool IsPublic { get; set; }
public bool IsUserPrivate { get; set; }
public bool IsDefaultMailbox { get; set; }
[Default(DefaultType.CurrentTimestamp)]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastActivity { get; set; }
public int? RetentionDays { get; set; } = 3;
}
[Table("MailGroupUser")]
[CompositeIndex("IX_MailGroupUser_Unique", nameof(MailGroupId), nameof(UserId), IsUnique = true)]
public class MailGroupUser
{
[Key]
public long Id { get; set; }
[Required]
public required long MailGroupId { get; set; }
[Required]
public required Guid UserId { get; set; }
[Default(DefaultType.CurrentTimestamp)]
public DateTime GrantedAt { get; set; } = DateTime.UtcNow;
}src/MailVoidApi/Models/RefreshToken.cs
[Table("RefreshToken")]
[Index("IX_RefreshToken_Token", nameof(Token))]
[CompositeIndex("IX_RefreshToken_TokenUser", nameof(Token), nameof(UserId))]
public class RefreshToken
{
[Key]
public int Id { get; set; }
[Required]
public required string Token { get; set; }
[Required]
public required Guid UserId { get; set; }
public DateTime ExpiryDate { get; set; }
public bool IsRevoked { get; set; }
[Default(DefaultType.CurrentTimestamp)]
public DateTime CreatedDate { get; set; } = DateTime.UtcNow;
}src/MailVoidApi/Models/UserMailRead.cs
[Table("UserMailRead")]
[CompositeIndex("IX_UserMailRead_Unique", nameof(UserId), nameof(MailId), IsUnique = true)]
public class UserMailRead
{
[Key]
public long Id { get; set; }
[Required]
public required Guid UserId { get; set; }
[Required]
public required long MailId { get; set; }
[Default(DefaultType.CurrentTimestamp)]
public DateTime ReadAt { get; set; } = DateTime.UtcNow;
}src/MailVoidApi/Models/Contact.cs (if exists)
[Table("Contact")]
[Index("IX_Contact_From", nameof(From), IsUnique = true)]
public class Contact
{
[Key]
public long Id { get; set; }
[Required]
public required string From { get; set; }
[Required]
public required string Name { get; set; }
}New file: src/MailVoidApi/Data/DatabaseService.cs
using MySqlConnector;
using RoboDodd.OrmLite;
namespace MailVoidApi.Data;
public interface IDatabaseService
{
Task<MySqlConnection> GetConnectionAsync();
Task InitializeAsync();
}
public class DatabaseService : IDatabaseService
{
private readonly string _connectionString;
private readonly ILogger<DatabaseService> _logger;
public DatabaseService(IConfiguration configuration, ILogger<DatabaseService> logger)
{
_connectionString = configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string not found");
_logger = logger;
}
public async Task<MySqlConnection> GetConnectionAsync()
{
var connection = new MySqlConnection(_connectionString);
await connection.OpenAsync();
return connection;
}
public async Task InitializeAsync()
{
_logger.LogInformation("Initializing database tables...");
using var db = await GetConnectionAsync();
await db.CreateTableIfNotExistsAsync<User>();
await db.CreateTableIfNotExistsAsync<Mail>();
await db.CreateTableIfNotExistsAsync<MailGroup>();
await db.CreateTableIfNotExistsAsync<MailGroupUser>();
await db.CreateTableIfNotExistsAsync<RefreshToken>();
await db.CreateTableIfNotExistsAsync<Contact>();
await db.CreateTableIfNotExistsAsync<UserMailRead>();
_logger.LogInformation("Database tables initialized");
}
}File: src/MailVoidApi/Program.cs
Remove:
- EF DbContext registration
- Migration check/apply code
- All
Microsoft.EntityFrameworkCoreusings
Add:
builder.Services.AddSingleton<IDatabaseService, DatabaseService>();
// In startup:
using (var scope = app.Services.CreateScope())
{
var dbService = scope.ServiceProvider.GetRequiredService<IDatabaseService>();
await dbService.InitializeAsync();
var dbInitializer = scope.ServiceProvider.GetRequiredService<DatabaseInitializer>();
await dbInitializer.SeedDefaultData();
}Replace MailVoidDbContext _context with IDatabaseService _db
| EF Pattern | OrmLite Pattern |
|---|---|
_context.Users.FirstOrDefaultAsync(u => u.UserName == x) |
db.SingleAsync<User>(u => u.UserName == x) |
_context.Users.FindAsync(id) |
db.SingleByIdAsync<User>(id) |
_context.SaveChangesAsync() |
(not needed - operations auto-commit) |
| EF Pattern | OrmLite Pattern |
|---|---|
_context.Mails.FindAsync(id) |
db.SingleByIdAsync<Mail>(id) |
_context.Mails.Where(x).ToListAsync() |
db.SelectAsync<Mail>(x) |
_context.MailGroups.FindAsync(id) |
db.SingleByIdAsync<MailGroup>(id) |
_context.Mails.Add(mail) + SaveChangesAsync() |
db.InsertAsync(mail) |
_context.Mails.Remove(mail) + SaveChangesAsync() |
db.DeleteAsync(mail) |
_context.Database.ExecuteSqlRawAsync(...) |
db.ExecuteAsync(sql, params) |
Same patterns as above.
Delegates to UserManagementService - update service instead.
public class DatabaseInitializer
{
private readonly IDatabaseService _db;
private readonly PasswordService _passwordService;
private readonly ILogger<DatabaseInitializer> _logger;
public async Task SeedDefaultData()
{
using var db = await _db.GetConnectionAsync();
var admin = await db.SingleAsync<User>(u => u.UserName == "admin");
if (admin == null)
{
var user = new User { Id = Guid.NewGuid(), UserName = "admin", ... };
await db.InsertAsync(user);
}
}
}| EF Pattern | OrmLite Pattern |
|---|---|
_context.RefreshTokens.Add(token) |
db.InsertAsync(token) |
_context.RefreshTokens.FirstOrDefaultAsync(x => ...) |
db.SingleAsync<RefreshToken>(x => ...) |
_context.RefreshTokens.Where(...).ToListAsync() |
db.SelectAsync<RefreshToken>(x => ...) |
_context.RefreshTokens.RemoveRange(tokens) |
db.DeleteAllAsync(tokens) |
Same patterns. Note: No navigation properties - must do explicit joins or separate queries.
Same patterns.
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var db = await _dbService.GetConnectionAsync();
var groups = await db.SelectAsync<MailGroup>(g => g.RetentionDays != null && g.RetentionDays > 0);
// ... cleanup logic
await Task.Delay(_cleanupInterval, stoppingToken);
}
}- Delete:
src/MailVoidApi/Data/MailVoidDbContext.cs - Delete:
src/MailVoidApi/Migrations/(entire folder)
| File | Purpose |
|---|---|
src/RoboDodd.OrmLite/ |
Git submodule |
src/MailVoidApi/Data/DatabaseService.cs |
Connection management & table init |
| File | Changes |
|---|---|
src/MailVoidApi/MailVoidApi.csproj |
Remove EF packages, add OrmLite reference |
src/MailVoidApi/Program.cs |
Replace EF setup with OrmLite |
src/MailVoidApi/Models/User.cs |
Add OrmLite attributes |
src/MailVoidApi/Models/Mail.cs |
Add OrmLite attributes |
src/MailVoidApi/Models/MailGroup.cs |
Add OrmLite attributes |
src/MailVoidApi/Models/RefreshToken.cs |
Add OrmLite attributes |
src/MailVoidApi/Models/UserMailRead.cs |
Add OrmLite attributes |
src/MailVoidApi/Controllers/AuthController.cs |
Use OrmLite queries |
src/MailVoidApi/Controllers/MailController.cs |
Use OrmLite queries |
src/MailVoidApi/Controllers/WebhookController.cs |
Use OrmLite queries |
src/MailVoidApi/Services/*.cs (5 files) |
Use OrmLite queries |
| File | Reason |
|---|---|
src/MailVoidApi/Data/MailVoidDbContext.cs |
Replaced by DatabaseService |
src/MailVoidApi/Migrations/* |
Fresh start - OrmLite creates tables |
New file: src/MailVoidApi/Models/WebhookBucket.cs
[Table("WebhookBucket")]
[Index("IX_WebhookBucket_Name", nameof(Name), IsUnique = true)]
public class WebhookBucket
{
[Key]
public long Id { get; set; }
[Required]
public required string Name { get; set; }
public string? Description { get; set; }
[Required]
public required Guid OwnerUserId { get; set; }
public bool IsPublic { get; set; } = true;
[Default(DefaultType.CurrentTimestamp)]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastActivity { get; set; }
public int? RetentionDays { get; set; } = 3;
}New file: src/MailVoidApi/Models/Webhook.cs
[Table("Webhook")]
[Index("IX_Webhook_BucketName", nameof(BucketName))]
[Index("IX_Webhook_CreatedOn", nameof(CreatedOn))]
public class Webhook
{
[Key]
public long Id { get; set; }
[Required]
public required string BucketName { get; set; }
[Required]
public required string HttpMethod { get; set; }
[Required]
public required string Path { get; set; }
public string? QueryString { get; set; }
[Required]
public required string Headers { get; set; } // JSON
[Required]
public required string Body { get; set; }
public string? ContentType { get; set; }
public string? SourceIp { get; set; }
[Default(DefaultType.CurrentTimestamp)]
public DateTime CreatedOn { get; set; } = DateTime.UtcNow;
}Add to InitializeAsync():
await db.CreateTableIfNotExistsAsync<WebhookBucket>();
await db.CreateTableIfNotExistsAsync<Webhook>();New file: src/MailVoidApi/Services/WebhookBucketService.cs
GetOrCreateBucket(name)- auto-creates with admin ownerHasUserAccess(bucketId, userId)- access check
New file: src/MailVoidApi/Services/WebhookCleanupService.cs
- BackgroundService for retention cleanup (same pattern as MailCleanupService)
New file: src/MailVoidApi/Controllers/HooksController.cs
- Route:
/hooks/{bucket}and/hooks/{bucket}/{**path} - Methods: POST, PUT, PATCH (no auth)
- Captures request and broadcasts via SignalR
New file: src/MailVoidApi/Controllers/WebhookManagementController.cs
- Route:
/api/webhooks(requires auth) - CRUD endpoints for buckets and webhooks
File: src/MailVoidWeb/src/app/services/signalr.service.ts
- Add
WebhookNotificationinterface - Add
newWebhook$observable - Handle
NewWebhookevent
New file: src/MailVoidWeb/src/app/_services/api/webhook.service.ts
getBuckets(),getWebhooks(),getWebhookDetail()- TypeScript interfaces for Webhook, WebhookBucket
New files:
src/MailVoidWeb/src/app/Pages/hooks/hooks.component.tssrc/MailVoidWeb/src/app/Pages/hooks/hooks.component.htmlsrc/MailVoidWeb/src/app/Pages/hooks/hooks.component.scsssrc/MailVoidWeb/src/app/Pages/hook-detail/hook-detail.component.tssrc/MailVoidWeb/src/app/Pages/hook-detail/hook-detail.component.htmlsrc/MailVoidWeb/src/app/Pages/hook-detail/hook-detail.component.scss
src/MailVoidWeb/src/app/app.routes.ts
- Add
/hooksand/hooks/:bucket/:idroutes
src/MailVoidWeb/src/app/app.config.ts
- Add Lucide icons:
Clipboard,Folder
src/MailVoidWeb/src/app/Pages/main-nav-bar/main-nav-bar.component.ts
- Add "Hooks" navigation link
| File | Purpose |
|---|---|
src/MailVoidApi/Models/Webhook.cs |
Webhook entity |
src/MailVoidApi/Models/WebhookBucket.cs |
Bucket entity |
src/MailVoidApi/Services/WebhookBucketService.cs |
Bucket service |
src/MailVoidApi/Services/WebhookCleanupService.cs |
Cleanup service |
src/MailVoidApi/Controllers/HooksController.cs |
Public capture endpoint |
src/MailVoidApi/Controllers/WebhookManagementController.cs |
Management API |
src/MailVoidWeb/src/app/_services/api/webhook.service.ts |
Frontend service |
src/MailVoidWeb/src/app/Pages/hooks/hooks.component.* |
Hooks list page (3 files) |
src/MailVoidWeb/src/app/Pages/hook-detail/hook-detail.component.* |
Detail page (3 files) |
| File | Changes |
|---|---|
src/MailVoidApi/Data/DatabaseService.cs |
Add webhook table creation |
src/MailVoidApi/Program.cs |
Register webhook services |
src/MailVoidWeb/src/app/app.routes.ts |
Add hooks routes |
src/MailVoidWeb/src/app/app.config.ts |
Add Lucide icons |
src/MailVoidWeb/src/app/services/signalr.service.ts |
Add webhook notifications |
src/MailVoidWeb/src/app/Pages/main-nav-bar/main-nav-bar.component.ts |
Add nav link |
-
Phase A (EF → OrmLite migration)
- A1: Add submodule, update csproj
- A2: Update all model attributes
- A3: Create DatabaseService
- A4: Update Program.cs
- A5-A6: Update controllers and services
- A7: Delete EF files
- Test: Verify app runs with fresh DB
-
Phase B (Webhook feature)
- B1-B2: Create webhook models, update DatabaseService
- B3-B4: Create backend services and controllers
- B5-B8: Create frontend components and configuration
- Test: Verify webhook capture and viewing works
This document will be updated as implementation progresses. Check boxes at the top track completion status.