From 2213ab94da599c20a38c9e1a63f0ffdfaaa9c695 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Thu, 18 Jul 2024 21:06:18 +0200 Subject: [PATCH 1/3] Add email table migration, update SmtpServer to save emails to database (#105) --- README.md | 3 +- .../AliasServerDb/AliasServerDbContext.cs | 19 +- src/Databases/AliasServerDb/Email.cs | 114 ++++ .../AliasServerDb/EmailAttachment.cs | 57 ++ .../20240708113743_AddVaultVersionColumn.cs | 3 +- .../20240720151458_AddEmailTables.Designer.cs | 494 ++++++++++++++++++ .../20240720151458_AddEmailTables.cs | 107 ++++ .../AliasServerDbContextModelSnapshot.cs | 124 +++++ .../AliasVault.SmtpService.csproj | 6 + .../AllowedDomainsFilter.cs | 45 ++ .../DatabaseMessageStore.cs | 283 ++++++++++ .../AliasVault.SmtpService/Program.cs | 59 ++- .../Scripts/sendEmailAllowed.sh | 1 + .../Scripts/sendEmailNotAllowed.sh | 1 + .../Scripts/testEmail1.txt | 5 + src/Services/AliasVault.SmtpService/Worker.cs | 4 +- .../AliasVault.SmtpService/appsettings.json | 3 + 17 files changed, 1305 insertions(+), 23 deletions(-) create mode 100644 src/Databases/AliasServerDb/Email.cs create mode 100644 src/Databases/AliasServerDb/EmailAttachment.cs create mode 100644 src/Databases/AliasServerDb/Migrations/20240720151458_AddEmailTables.Designer.cs create mode 100644 src/Databases/AliasServerDb/Migrations/20240720151458_AddEmailTables.cs create mode 100644 src/Services/AliasVault.SmtpService/AllowedDomainsFilter.cs create mode 100644 src/Services/AliasVault.SmtpService/DatabaseMessageStore.cs create mode 100755 src/Services/AliasVault.SmtpService/Scripts/sendEmailAllowed.sh create mode 100755 src/Services/AliasVault.SmtpService/Scripts/sendEmailNotAllowed.sh create mode 100644 src/Services/AliasVault.SmtpService/Scripts/testEmail1.txt diff --git a/README.md b/README.md index f9850d6c5..9b531f440 100644 --- a/README.md +++ b/README.md @@ -80,4 +80,5 @@ The following technologies, frameworks and libraries are used in this project: - [Flowbite](https://flowbite.com/) - A free and open-source UI component library based on Tailwind CSS. - [Konscious.Security.Cryptography](https://github.com/kmaragon/Konscious.Security.Cryptography) - A .NET library that implements Argon2id, a memory-hard password hashing algorithm. - [SRP.net](https://github.com/secure-remote-password/srp.net) - SRP6a Secure Remote Password protocol for secure password authentication. -- [SqliteWasmHelper](https://github.com/JeremyLikness/SqliteWasmHelper) - The AliasVault SQLite WASM implementation is loosely based on this library. +- [SmtpServer](https://github.com/cosullivan/SmtpServer) - A SMTP server library for .NET that is used for the virtual email address feature. +- [MimeKit](https://github.com/jstedfast/MimeKit) - A .NET MIME creation and parser library used for the virtual email address feature. diff --git a/src/Databases/AliasServerDb/AliasServerDbContext.cs b/src/Databases/AliasServerDb/AliasServerDbContext.cs index 00681527c..14e4b3154 100644 --- a/src/Databases/AliasServerDb/AliasServerDbContext.cs +++ b/src/Databases/AliasServerDb/AliasServerDbContext.cs @@ -47,6 +47,16 @@ public class AliasServerDbContext : IdentityDbContext /// public DbSet Vaults { get; set; } + /// + /// Gets or sets the Emails DbSet. + /// + public DbSet Emails { get; set; } + + /// + /// Gets or sets the EmailAttachments DbSet. + /// + public DbSet EmailAttachments { get; set; } + /// /// The OnModelCreating method. /// @@ -69,19 +79,24 @@ public class AliasServerDbContext : IdentityDbContext } } - // Configure the User - AspNetUserRefreshToken entity + // Configure the User - AspNetUserRefreshToken entity. builder.Entity() .HasOne(p => p.User) .WithMany() .HasForeignKey(p => p.UserId) .IsRequired(); - // Configure the Vault - UserId entity + // Configure the Vault - UserId entity. builder.Entity() .HasOne(p => p.User) .WithMany() .HasForeignKey(p => p.UserId) .IsRequired(); + + // Configure the Email - Attachments entity. + builder.Entity().HasOne(d => d.Email) + .WithMany(p => p.Attachments) + .HasForeignKey(d => d.EmailId); } /// diff --git a/src/Databases/AliasServerDb/Email.cs b/src/Databases/AliasServerDb/Email.cs new file mode 100644 index 000000000..daf1f95b5 --- /dev/null +++ b/src/Databases/AliasServerDb/Email.cs @@ -0,0 +1,114 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasServerDb; + +using Microsoft.EntityFrameworkCore; + +/// +/// Represents an email message. +/// +[Index(nameof(ToLocal))] +[Index(nameof(Date))] +[Index(nameof(DateSystem))] +[Index(nameof(Visible))] +[Index(nameof(PushNotificationSent))] +public class Email +{ + /// + /// Initializes a new instance of the class. + /// + public Email() + { + Attachments = new HashSet(); + } + + /// + /// Gets or sets the ID of the email. + /// + public int Id { get; set; } + + /// + /// Gets or sets the subject of the email. + /// + public string Subject { get; set; } = null!; + + /// + /// Gets or sets the sender's email address. + /// + public string From { get; set; } = null!; + + /// + /// Gets or sets the local part of the sender's email address. + /// + public string FromLocal { get; set; } = null!; + + /// + /// Gets or sets the domain part of the sender's email address. + /// + public string FromDomain { get; set; } = null!; + + /// + /// Gets or sets the recipient's email address. + /// + public string To { get; set; } = null!; + + /// + /// Gets or sets the local part of the recipient's email address. + /// + public string ToLocal { get; set; } = null!; + + /// + /// Gets or sets the domain part of the recipient's email address. + /// + public string ToDomain { get; set; } = null!; + + /// + /// Gets or sets the date and time when the email was sent. + /// + public DateTime Date { get; set; } + + /// + /// Gets or sets the system date and time when the email was received. + /// + public DateTime DateSystem { get; set; } + + /// + /// Gets or sets the HTML content of the email message. + /// + public string? MessageHtml { get; set; } + + /// + /// Gets or sets the plain text content of the email message. + /// + public string? MessagePlain { get; set; } + + /// + /// Gets or sets the preview of the email message. + /// + public string? MessagePreview { get; set; } + + /// + /// Gets or sets the source of the email message. + /// + public string MessageSource { get; set; } = null!; + + /// + /// Gets or sets a value indicating whether the email is visible. + /// + public bool Visible { get; set; } + + /// + /// Gets or sets a value indicating whether a push notification has been sent for the email. + /// + public bool PushNotificationSent { get; set; } + + /// + /// Gets or sets the collection of email attachments. + /// + public virtual ICollection Attachments { get; set; } +} diff --git a/src/Databases/AliasServerDb/EmailAttachment.cs b/src/Databases/AliasServerDb/EmailAttachment.cs new file mode 100644 index 000000000..88ef387e5 --- /dev/null +++ b/src/Databases/AliasServerDb/EmailAttachment.cs @@ -0,0 +1,57 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasServerDb; + +using Microsoft.EntityFrameworkCore; + +/// +/// Represents an email attachment. +/// +[Index(nameof(EmailId))] +public class EmailAttachment +{ + /// + /// Gets or sets the ID of the attachment. + /// + public int Id { get; set; } + + /// + /// Gets or sets the bytes of the attachment. + /// + public byte[] Bytes { get; set; } = null!; + + /// + /// Gets or sets the filename of the attachment. + /// + public string Filename { get; set; } = null!; + + /// + /// Gets or sets the MIME type of the attachment. + /// + public string MimeType { get; set; } = null!; + + /// + /// Gets or sets the filesize of the attachment. + /// + public int Filesize { get; set; } + + /// + /// Gets or sets the date of the attachment. + /// + public DateTime Date { get; set; } + + /// + /// Gets or sets the ID of the email that the attachment belongs to. + /// + public int EmailId { get; set; } + + /// + /// Gets or sets the email that the attachment belongs to. + /// + public virtual Email Email { get; set; } = null!; +} diff --git a/src/Databases/AliasServerDb/Migrations/20240708113743_AddVaultVersionColumn.cs b/src/Databases/AliasServerDb/Migrations/20240708113743_AddVaultVersionColumn.cs index 874a44306..4a15a9f46 100644 --- a/src/Databases/AliasServerDb/Migrations/20240708113743_AddVaultVersionColumn.cs +++ b/src/Databases/AliasServerDb/Migrations/20240708113743_AddVaultVersionColumn.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore.Migrations; +// +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Databases/AliasServerDb/Migrations/20240720151458_AddEmailTables.Designer.cs b/src/Databases/AliasServerDb/Migrations/20240720151458_AddEmailTables.Designer.cs new file mode 100644 index 000000000..f9668f784 --- /dev/null +++ b/src/Databases/AliasServerDb/Migrations/20240720151458_AddEmailTables.Designer.cs @@ -0,0 +1,494 @@ +// +using System; +using AliasServerDb; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + [DbContext(typeof(AliasServerDbContext))] + [Migration("20240718151458_AddEmailTables")] + partial class AddEmailTables + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true); + + modelBuilder.Entity("AliasServerDb.AliasVaultUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Salt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Verifier") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.AspNetUserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceIdentifier") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ExpireDate") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserRefreshTokens"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DateSystem") + .HasColumnType("TEXT"); + + b.Property("From") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromLocal") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MessageHtml") + .HasColumnType("TEXT"); + + b.Property("MessagePlain") + .HasColumnType("TEXT"); + + b.Property("MessagePreview") + .HasColumnType("TEXT"); + + b.Property("MessageSource") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PushNotificationSent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("To") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToLocal") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DateSystem"); + + b.HasIndex("PushNotificationSent"); + + b.HasIndex("ToLocal"); + + b.HasIndex("Visible"); + + b.ToTable("Emails"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("EmailId") + .HasColumnType("INTEGER"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Filesize") + .HasColumnType("INTEGER"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EmailId"); + + b.ToTable("EmailAttachments"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("VaultBlob") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Vaults"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.AspNetUserRefreshToken", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.HasOne("AliasServerDb.Email", "Email") + .WithMany("Attachments") + .HasForeignKey("EmailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Email"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AliasServerDb.AliasVaultUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Navigation("Attachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Databases/AliasServerDb/Migrations/20240720151458_AddEmailTables.cs b/src/Databases/AliasServerDb/Migrations/20240720151458_AddEmailTables.cs new file mode 100644 index 000000000..c10d70c8b --- /dev/null +++ b/src/Databases/AliasServerDb/Migrations/20240720151458_AddEmailTables.cs @@ -0,0 +1,107 @@ +// +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + /// + public partial class AddEmailTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Emails", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Subject = table.Column(type: "TEXT", nullable: false), + From = table.Column(type: "TEXT", nullable: false), + FromLocal = table.Column(type: "TEXT", nullable: false), + FromDomain = table.Column(type: "TEXT", nullable: false), + To = table.Column(type: "TEXT", nullable: false), + ToLocal = table.Column(type: "TEXT", nullable: false), + ToDomain = table.Column(type: "TEXT", nullable: false), + Date = table.Column(type: "TEXT", nullable: false), + DateSystem = table.Column(type: "TEXT", nullable: false), + MessageHtml = table.Column(type: "TEXT", nullable: true), + MessagePlain = table.Column(type: "TEXT", nullable: true), + MessagePreview = table.Column(type: "TEXT", nullable: true), + MessageSource = table.Column(type: "TEXT", nullable: false), + Visible = table.Column(type: "INTEGER", nullable: false), + PushNotificationSent = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Emails", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "EmailAttachments", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Bytes = table.Column(type: "BLOB", nullable: false), + Filename = table.Column(type: "TEXT", nullable: false), + MimeType = table.Column(type: "TEXT", nullable: false), + Filesize = table.Column(type: "INTEGER", nullable: false), + Date = table.Column(type: "TEXT", nullable: false), + EmailId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EmailAttachments", x => x.Id); + table.ForeignKey( + name: "FK_EmailAttachments_Emails_EmailId", + column: x => x.EmailId, + principalTable: "Emails", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_EmailAttachments_EmailId", + table: "EmailAttachments", + column: "EmailId"); + + migrationBuilder.CreateIndex( + name: "IX_Emails_Date", + table: "Emails", + column: "Date"); + + migrationBuilder.CreateIndex( + name: "IX_Emails_DateSystem", + table: "Emails", + column: "DateSystem"); + + migrationBuilder.CreateIndex( + name: "IX_Emails_PushNotificationSent", + table: "Emails", + column: "PushNotificationSent"); + + migrationBuilder.CreateIndex( + name: "IX_Emails_ToLocal", + table: "Emails", + column: "ToLocal"); + + migrationBuilder.CreateIndex( + name: "IX_Emails_Visible", + table: "Emails", + column: "Visible"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "EmailAttachments"); + + migrationBuilder.DropTable( + name: "Emails"); + } + } +} diff --git a/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs b/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs index 0ec29796d..8d980b6c1 100644 --- a/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs +++ b/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs @@ -128,6 +128,114 @@ namespace AliasServerDb.Migrations b.ToTable("AspNetUserRefreshTokens"); }); + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DateSystem") + .HasColumnType("TEXT"); + + b.Property("From") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromLocal") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MessageHtml") + .HasColumnType("TEXT"); + + b.Property("MessagePlain") + .HasColumnType("TEXT"); + + b.Property("MessagePreview") + .HasColumnType("TEXT"); + + b.Property("MessageSource") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PushNotificationSent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("To") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToLocal") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DateSystem"); + + b.HasIndex("PushNotificationSent"); + + b.HasIndex("ToLocal"); + + b.HasIndex("Visible"); + + b.ToTable("Emails"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("EmailId") + .HasColumnType("INTEGER"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Filesize") + .HasColumnType("INTEGER"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EmailId"); + + b.ToTable("EmailAttachments"); + }); + modelBuilder.Entity("AliasServerDb.Vault", b => { b.Property("Id") @@ -300,6 +408,17 @@ namespace AliasServerDb.Migrations b.Navigation("User"); }); + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.HasOne("AliasServerDb.Email", "Email") + .WithMany("Attachments") + .HasForeignKey("EmailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Email"); + }); + modelBuilder.Entity("AliasServerDb.Vault", b => { b.HasOne("AliasServerDb.AliasVaultUser", "User") @@ -361,6 +480,11 @@ namespace AliasServerDb.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Navigation("Attachments"); + }); #pragma warning restore 612, 618 } } diff --git a/src/Services/AliasVault.SmtpService/AliasVault.SmtpService.csproj b/src/Services/AliasVault.SmtpService/AliasVault.SmtpService.csproj index b03fe7973..cf16cdd4e 100644 --- a/src/Services/AliasVault.SmtpService/AliasVault.SmtpService.csproj +++ b/src/Services/AliasVault.SmtpService/AliasVault.SmtpService.csproj @@ -16,6 +16,12 @@ + + + + + + diff --git a/src/Services/AliasVault.SmtpService/AllowedDomainsFilter.cs b/src/Services/AliasVault.SmtpService/AllowedDomainsFilter.cs new file mode 100644 index 000000000..50a55b854 --- /dev/null +++ b/src/Services/AliasVault.SmtpService/AllowedDomainsFilter.cs @@ -0,0 +1,45 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.SmtpService; + +using SmtpServer; +using SmtpServer.Mail; +using SmtpServer.Storage; + +/// +/// Filter to allow only emails from configured domains. +/// +public class AllowedDomainsFilter(Config config, ILogger logger) : IMailboxFilter, IMailboxFilterFactory +{ + private readonly TimeSpan _delay = TimeSpan.Zero; + + public async Task CanAcceptFromAsync(ISessionContext context, IMailbox from, int size, CancellationToken cancellationToken) + { + await Task.Delay(_delay, cancellationToken); + return true; + } + + public async Task CanDeliverToAsync(ISessionContext context, IMailbox to, IMailbox from, CancellationToken cancellationToken) + { + await Task.Delay(_delay, cancellationToken); + + if (!config.AllowedToDomains.Contains(to.Host.ToLowerInvariant())) + { + // ToAddress host is not allowed, return error to sender. + logger.LogWarning("Email to {ToAddress} is not allowed", to); + return false; + } + + return true; + } + + public IMailboxFilter CreateInstance(ISessionContext context) + { + return new AllowedDomainsFilter(context.ServiceProvider.GetRequiredService(), context.ServiceProvider.GetRequiredService>()); + } +} diff --git a/src/Services/AliasVault.SmtpService/DatabaseMessageStore.cs b/src/Services/AliasVault.SmtpService/DatabaseMessageStore.cs new file mode 100644 index 000000000..a8fba6e73 --- /dev/null +++ b/src/Services/AliasVault.SmtpService/DatabaseMessageStore.cs @@ -0,0 +1,283 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +using System.Buffers; +using System.Net.Mail; +using System.Text.RegularExpressions; +using AliasServerDb; +using Microsoft.EntityFrameworkCore; +using MimeKit; +using NUglify; +using SmtpServer; +using SmtpServer.Protocol; +using SmtpServer.Storage; + +namespace AliasVault.SmtpService; + +/// +/// Custom exception for when the email parsing fails to find the "to" address in the email. +/// +public class EmailParseMissingToException(string message) : Exception(message); + +/// +/// Database message store. +/// +/// ILogger instance. +/// Config instance. +public class DatabaseMessageStore(ILogger logger, Config config, IDbContextFactory dbContextFactory) : MessageStore +{ + public override async Task SaveAsync(ISessionContext context, IMessageTransaction transaction, ReadOnlySequence buffer, CancellationToken cancellationToken) + { + await using var stream = new MemoryStream(); + + var position = buffer.GetPosition(0); + while (buffer.TryGet(ref position, out var memory)) + { + stream.Write(memory.Span); + } + + // Max email filesize limit: 10MB. If the mail is larger in size, reject it. + // Because of base64 encoding which has approx 33% increase in binary size + // we multiply the limit by 1.4 to be safe. + var maxEmailSizeInMegabytes = 10; + if (stream.Length > ((maxEmailSizeInMegabytes * 1024 * 1024) * 1.4)) + { + return SmtpResponse.SizeLimitExceeded; + } + + stream.Position = 0; + var message = await MimeKit.MimeMessage.LoadAsync(stream, cancellationToken); + // Retrieve all addresses from the SMTP transaction which should contain all recipients for this mail instance. + var allAddresses = transaction.To + .Distinct() + .ToList(); + // Limit list to 15 addresses max. (to prevent mailbomb spam abuse) + var toAddresses = allAddresses.Take(15).ToList(); + // For every toAddress + foreach (var toAddress in toAddresses) + { + if (toAddress == null) + { + // No toAddress, skip. + logger.LogWarning("Skip email, no toAddress available"); + return SmtpResponse.NoValidRecipientsGiven; + } + if (!config.AllowedToDomains.Contains(toAddress.Host.ToLowerInvariant())) + { + // ToAddress domain is not allowed, return error to sender. + logger.LogWarning("Email to {ToAddress} is not allowed", toAddress.User + "@" + toAddress.Host); + return SmtpResponse.NoValidRecipientsGiven; + } + + // Remove existing x-receiver and x-sender headers to avoid duplication. + message.Headers.RemoveAll("x-receiver"); + message.Headers.RemoveAll("x-sender"); + + // Add new x-receiver and x-sender headers. + message.Headers.Add("x-receiver", toAddress.User + "@" + toAddress.Host); + message.Headers.Add("x-sender", transaction.From.User + "@" + transaction.From.Host); + + // Insert into database. + var insertedId = await InsertEmailIntoDatabase(message); + + logger.LogInformation("Email saved into database with ID {insertedId}.", insertedId); + } + + return SmtpResponse.Ok; + } + + /// + /// Insert email into database. + /// + /// ISessionContext instance. + /// MimeMessage to save into database. + private async Task InsertEmailIntoDatabase(MimeMessage message) + { + var dbContext = await dbContextFactory.CreateDbContextAsync(); + + // Add the new vault and commit to database. + var newEmail = ConvertMimeMessageToEmail(message); + await dbContext.Emails.AddAsync(newEmail); + await dbContext.SaveChangesAsync(); + + return newEmail.Id; + } + + /// + /// Convert MimeMessage to Email database object. + /// + /// + /// + /// + private Email ConvertMimeMessageToEmail(MimeMessage message) + { + string from = ""; + + try + { + from = message.From.FirstOrDefault()?.ToString() ?? string.Empty; + } + catch + { + // Do nothing. + } + + string fromLocal; + string fromDomain; + // Try to extract from address firstly from "from" in the mail. + try + { + MailAddress fromAddress = new MailAddress(message.From.FirstOrDefault()?.ToString() ?? string.Empty); + fromLocal = fromAddress.User; + fromDomain = fromAddress.Host; + + } + catch + { + // If the above fails, try to find the x-sender in the mail + try + { + MailAddress fromAddress = new MailAddress(message.Headers.First(x => x.Field == "x-sender").Value.ToString()); + fromLocal = fromAddress.User; + fromDomain = fromAddress.Host; + } + catch + { + // If this fails as well, then simply use a blank value + fromLocal = ""; + fromDomain = ""; + } + } + + MailAddress toAddress; + string to; + + // Try to extract to address firstly from x-receiver address.. + try + { + to = message.Headers.Where(x => x.Field == "x-receiver").First().Value.ToString(); + toAddress = new MailAddress(to); + } + catch + { + // If the above fails, try to find the "to" in the mail + try + { + to = message.To.FirstOrDefault()?.ToString() ?? ""; + toAddress = new MailAddress(to); + } + catch + { + // If this fails as well, then simply let it throw an error to the caller. + throw new EmailParseMissingToException("Could not find x-receiver or to address in email."); + } + } + + // Create email object + var email = new Email(); + email.From = from; + email.FromLocal = fromLocal; + email.FromDomain = fromDomain; + + email.To = to; + // Local part to lowercase, as mailboxes are always lowercase + email.ToLocal = toAddress.User.ToLower(); + email.ToDomain = toAddress.Host; + + email.Subject = message.Subject ?? ""; + email.MessageHtml = message.HtmlBody; + email.MessagePlain = message.TextBody; + email.MessageSource = message.ToString(); + + // Extract first 180 characters from plain text message, and if its null, then extract from html message but remove all html tags + email.MessagePreview = ""; + try + { + if (email.MessagePlain != null && !String.IsNullOrEmpty(email.MessagePlain) && email.MessagePlain.Length > 3) + { + // Replace any newline characters with a space + string plainToPlainText = Regex.Replace(email.MessagePlain, @"\t|\n|\r", " "); + + // Remove all "-" or "=" characters if there are 3 or more in a row + plainToPlainText = Regex.Replace(plainToPlainText, @"-{3,}|\={3,}", ""); + + // Remove any non-printable characters + plainToPlainText = Regex.Replace(plainToPlainText, @"[^\u0020-\u007E]", ""); + + // Replace multiple spaces with a single space + plainToPlainText = Regex.Replace(plainToPlainText, @"\s+", " "); + + // Trim start and end of string + plainToPlainText = plainToPlainText.Trim(); + + email.MessagePreview = plainToPlainText.Length > 180 + ? plainToPlainText.Substring(0, 180) + : plainToPlainText; + } + else if (email.MessageHtml != null) + { + string htmlToPlainText = Uglify.HtmlToText(email.MessageHtml).ToString(); + + // Replace any newline characters with a space + htmlToPlainText = Regex.Replace(htmlToPlainText, @"\t|\n|\r", " "); + + // Remove all "-" or "=" characters if there are 3 or more in a row + htmlToPlainText = Regex.Replace(htmlToPlainText, @"-{3,}|\={3,}", ""); + + // Remove any non-printable characters + htmlToPlainText = Regex.Replace(htmlToPlainText, @"[^\u0020-\u007E]", ""); + + // Replace multiple spaces with a single space + htmlToPlainText = Regex.Replace(htmlToPlainText, @"\s+", " "); + + // Trim start and end of string + htmlToPlainText = htmlToPlainText.Trim(); + + email.MessagePreview = + htmlToPlainText.Length > 180 ? htmlToPlainText.Substring(0, 180) : htmlToPlainText; + } + } + catch + { + // Extracting useful words from email failed.. Skip the step, do nothing.. + } + + email.Date = message.Date.DateTime; + email.DateSystem = DateTime.UtcNow; + email.Visible = true; + + // Parse attachments from email, and create separate attachment records in database for each attachment + foreach (var attachment in message.Attachments) + { + byte[] fileBytes; + using (var memory = new MemoryStream ()) + { + if (attachment is MimePart) + { + ((MimePart)attachment).Content.DecodeTo(memory); + } + else + { + ((MessagePart) attachment).Message.WriteTo(memory); + } + + fileBytes = memory.ToArray(); + } + + email.Attachments.Add(new EmailAttachment + { + Bytes = fileBytes, + Filename = attachment.ContentDisposition?.FileName ?? "", + MimeType = attachment.ContentType.MimeType, + Filesize = fileBytes.Length, + Date = DateTime.Now + }); + } + + return email; + } +} diff --git a/src/Services/AliasVault.SmtpService/Program.cs b/src/Services/AliasVault.SmtpService/Program.cs index 7d8f7ddeb..adb267a8e 100644 --- a/src/Services/AliasVault.SmtpService/Program.cs +++ b/src/Services/AliasVault.SmtpService/Program.cs @@ -5,18 +5,44 @@ // //----------------------------------------------------------------------- +using System.Data.Common; using AliasVault.SmtpService; using SmtpServer; using System.Security.Cryptography.X509Certificates; +using AliasServerDb; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using SmtpServer.Storage; var builder = Host.CreateApplicationBuilder(args); -builder.Services.AddHostedService(); // Read settings from appsettings.json. ConfigurationManager configuration = builder.Configuration; Config config = configuration.GetSection("Config").Get()!; builder.Services.AddSingleton(config); +builder.Services.AddSingleton(container => +{ + var configFile = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json") + .Build(); + + var connection = new SqliteConnection(configFile.GetConnectionString("AliasServerDbContext")); + connection.Open(); + + return connection; +}); + +builder.Services.AddDbContextFactory((container, options) => +{ + var connection = container.GetRequiredService(); + options.UseSqlite(connection).UseLazyLoadingProxies(); +}); + +builder.Services.AddTransient(); +builder.Services.AddTransient(); + builder.Services.AddSingleton( provider => { @@ -26,16 +52,16 @@ builder.Services.AddSingleton( if (config.SmtpTlsEnabled == "true") { // With TLS and certificate support. - options.Endpoint(builder => - builder + options.Endpoint(serverBuilder => + serverBuilder .Port(25, false) - .AllowUnsecureAuthentication(true) + .AllowUnsecureAuthentication() .Certificate(CreateCertificate()) .SupportedSslProtocols(System.Security.Authentication.SslProtocols.Tls12)) - .Endpoint(builder => - builder + .Endpoint(serverBuilder => + serverBuilder .Port(587, false) - .AllowUnsecureAuthentication(true) + .AllowUnsecureAuthentication() .Certificate(CreateCertificate()) .SupportedSslProtocols(System.Security.Authentication.SslProtocols.Tls12) ); @@ -43,18 +69,17 @@ builder.Services.AddSingleton( else { // No TLS - options.Endpoint(builder => - builder + options.Endpoint(serverBuilder => + serverBuilder .Port(25, false)) - .Endpoint(builder => - builder + .Endpoint(serverBuilder => + serverBuilder .Port(587, false) ); } - /// - /// Helper method to create an X509Certificate2 object from a PEM file. - /// + return new SmtpServer.SmtpServer(options.Build(), provider.GetRequiredService()); + static X509Certificate2 CreateCertificate() { // Specify the directory where PEM files are stored. @@ -79,16 +104,16 @@ builder.Services.AddSingleton( // NOTE: this is important because saving the object to a PFX file to disk for a brief // second will allow Windows to correctly load the certificate with the private key. // If we don't do this, the certificate will be loaded without the private key and - // will throw error on Windows: + // will throw error on Windows: // "The TLS server credential's certificate does not have a private key information property attached to it" cert = new X509Certificate2(cert.Export(X509ContentType.Pfx)); return cert; } - - return new SmtpServer.SmtpServer(options.Build(), provider.GetRequiredService()); } ); +builder.Services.AddHostedService(); + var host = builder.Build(); await host.RunAsync(); diff --git a/src/Services/AliasVault.SmtpService/Scripts/sendEmailAllowed.sh b/src/Services/AliasVault.SmtpService/Scripts/sendEmailAllowed.sh new file mode 100755 index 000000000..c45f3a511 --- /dev/null +++ b/src/Services/AliasVault.SmtpService/Scripts/sendEmailAllowed.sh @@ -0,0 +1 @@ +curl --url "smtp://localhost:25" --mail-from "sender@example.com" --mail-rcpt "yourname@example.tld" --upload-file testEmail1.txt diff --git a/src/Services/AliasVault.SmtpService/Scripts/sendEmailNotAllowed.sh b/src/Services/AliasVault.SmtpService/Scripts/sendEmailNotAllowed.sh new file mode 100755 index 000000000..8b075a44b --- /dev/null +++ b/src/Services/AliasVault.SmtpService/Scripts/sendEmailNotAllowed.sh @@ -0,0 +1 @@ +curl --url "smtp://localhost:25" --mail-from "sender@example.com" --mail-rcpt "yourname@unknowndomain.com" --upload-file testEmail1.txt \ No newline at end of file diff --git a/src/Services/AliasVault.SmtpService/Scripts/testEmail1.txt b/src/Services/AliasVault.SmtpService/Scripts/testEmail1.txt new file mode 100644 index 000000000..3245a4a10 --- /dev/null +++ b/src/Services/AliasVault.SmtpService/Scripts/testEmail1.txt @@ -0,0 +1,5 @@ +From: sender@example.com +To: recipient@example.tld +Subject: Test Email + +This is a test email. diff --git a/src/Services/AliasVault.SmtpService/Worker.cs b/src/Services/AliasVault.SmtpService/Worker.cs index 55c56eb4b..028d9707a 100644 --- a/src/Services/AliasVault.SmtpService/Worker.cs +++ b/src/Services/AliasVault.SmtpService/Worker.cs @@ -10,13 +10,13 @@ namespace AliasVault.SmtpService public class Worker(ILogger logger, SmtpServer.SmtpServer smtpServer) : BackgroundService { /// - protected override async Task ExecuteAsync(CancellationToken stoppingToken) + protected override Task ExecuteAsync(CancellationToken stoppingToken) { if (logger.IsEnabled(LogLevel.Information)) { logger.LogInformation("AliasVault.SmtpService running at: {Time}", DateTimeOffset.Now); } - await smtpServer.StartAsync(stoppingToken); + return smtpServer.StartAsync(stoppingToken); } } } diff --git a/src/Services/AliasVault.SmtpService/appsettings.json b/src/Services/AliasVault.SmtpService/appsettings.json index 9939a39f3..258416faf 100644 --- a/src/Services/AliasVault.SmtpService/appsettings.json +++ b/src/Services/AliasVault.SmtpService/appsettings.json @@ -5,6 +5,9 @@ "Microsoft.Hosting.Lifetime": "Information" } }, + "ConnectionStrings": { + "AliasServerDbContext": "Data Source=../../../database/AliasServerDb.sqlite" + }, "Config": { "SmtpTlsEnabled": "false", "AllowedToDomains": [ From b3ddf9408936625b6020b3ff65221e546b695255 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Thu, 18 Jul 2024 21:36:26 +0200 Subject: [PATCH 2/3] Add SMTP service settings to environment variables so it can be exposed via Docker (#105) --- .env.example | 2 + README.md | 2 +- docker-compose.yml | 2 + init.sh | 40 +++++++++++++++++++ .../DatabaseMessageStore.cs | 16 ++++---- .../AliasVault.SmtpService/Program.cs | 12 ++++-- .../Properties/launchSettings.json | 6 ++- .../AliasVault.SmtpService/appsettings.json | 6 --- 8 files changed, 66 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index 67eed3107..6f97da333 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,3 @@ JWT_KEY= +SMTP_ALLOWED_DOMAINS=example.tld +SMTP_TLS_ENABLED=false diff --git a/README.md b/README.md index 9b531f440..7c04840fe 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ $ cd AliasVault # Make init script executable $ chmod +x init.sh -# Run the init script +# Run the init script and follow the steps $ ./init.sh ``` diff --git a/docker-compose.yml b/docker-compose.yml index 743b5130a..3e0e51f75 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,4 +31,6 @@ services: ports: - "25:25" - "587:587" + env_file: + - .env restart: always diff --git a/init.sh b/init.sh index 4e01c35c1..560b99198 100755 --- a/init.sh +++ b/init.sh @@ -49,6 +49,44 @@ populate_jwt_key() { fi } +# Function to ask the user for SMTP_ALLOWED_DOMAINS +set_smtp_allowed_domains() { + if ! grep -q "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE" || [ -z "$(grep "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE" | cut -d '=' -f2)" ]; then + printf "${YELLOW}Please enter the domains that should be allowed to send email, separated by commas:${NC}\n" + read -r smtp_allowed_domains + if grep -q "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE"; then + awk -v domains="$smtp_allowed_domains" '/^SMTP_ALLOWED_DOMAINS=/ {$0="SMTP_ALLOWED_DOMAINS="domains} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" + else + printf "SMTP_ALLOWED_DOMAINS=${smtp_allowed_domains}\n" >> "$ENV_FILE" + fi + printf "${GREEN}> SMTP_ALLOWED_DOMAINS has been set in $ENV_FILE.${NC}\n" + else + printf "${CYAN}> SMTP_ALLOWED_DOMAINS already exists and has a value in $ENV_FILE.${NC}\n" + fi +} + +# Function to ask the user if TLS should be enabled for email +set_smtp_tls_enabled() { + if ! grep -q "^SMTP_TLS_ENABLED=" "$ENV_FILE" || [ -z "$(grep "^SMTP_TLS_ENABLED=" "$ENV_FILE" | cut -d '=' -f2)" ]; then + printf "${YELLOW}Do you want TLS enabled for email? (yes/no):${NC}\n" + read -r tls_enabled + tls_enabled=$(echo "$tls_enabled" | tr '[:upper:]' '[:lower:]') + if [ "$tls_enabled" = "yes" ] || [ "$tls_enabled" = "y" ]; then + tls_enabled="true" + else + tls_enabled="false" + fi + if grep -q "^SMTP_TLS_ENABLED=" "$ENV_FILE"; then + awk -v tls="$tls_enabled" '/^SMTP_TLS_ENABLED=/ {$0="SMTP_TLS_ENABLED="tls} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" + else + printf "SMTP_TLS_ENABLED=${tls_enabled}\n" >> "$ENV_FILE" + fi + printf "${GREEN}> SMTP_TLS_ENABLED has been set to ${tls_enabled} in $ENV_FILE.${NC}\n" + else + printf "${CYAN}> SMTP_TLS_ENABLED already exists and has a value in $ENV_FILE.${NC}\n" + fi +} + # Function to print the CLI logo print_logo() { printf "${MAGENTA}\n" @@ -69,6 +107,8 @@ print_logo printf "${BLUE}Initializing AliasVault...${NC}\n" create_env_file populate_jwt_key +set_smtp_allowed_domains +set_smtp_tls_enabled printf "${BLUE}Initialization complete.${NC}\n" printf "\n" printf "To build the images and start the containers, run the following command:\n" diff --git a/src/Services/AliasVault.SmtpService/DatabaseMessageStore.cs b/src/Services/AliasVault.SmtpService/DatabaseMessageStore.cs index a8fba6e73..fe071411e 100644 --- a/src/Services/AliasVault.SmtpService/DatabaseMessageStore.cs +++ b/src/Services/AliasVault.SmtpService/DatabaseMessageStore.cs @@ -200,16 +200,16 @@ public class DatabaseMessageStore(ILogger logger, Config c if (email.MessagePlain != null && !String.IsNullOrEmpty(email.MessagePlain) && email.MessagePlain.Length > 3) { // Replace any newline characters with a space - string plainToPlainText = Regex.Replace(email.MessagePlain, @"\t|\n|\r", " "); + string plainToPlainText = Regex.Replace(email.MessagePlain, @"\t|\n|\r", " ", RegexOptions.NonBacktracking); // Remove all "-" or "=" characters if there are 3 or more in a row - plainToPlainText = Regex.Replace(plainToPlainText, @"-{3,}|\={3,}", ""); + plainToPlainText = Regex.Replace(plainToPlainText, @"-{3,}|\={3,}", "", RegexOptions.NonBacktracking); // Remove any non-printable characters - plainToPlainText = Regex.Replace(plainToPlainText, @"[^\u0020-\u007E]", ""); + plainToPlainText = Regex.Replace(plainToPlainText, @"[^\u0020-\u007E]", "", RegexOptions.NonBacktracking); // Replace multiple spaces with a single space - plainToPlainText = Regex.Replace(plainToPlainText, @"\s+", " "); + plainToPlainText = Regex.Replace(plainToPlainText, @"\s+", " ", RegexOptions.NonBacktracking); // Trim start and end of string plainToPlainText = plainToPlainText.Trim(); @@ -223,16 +223,16 @@ public class DatabaseMessageStore(ILogger logger, Config c string htmlToPlainText = Uglify.HtmlToText(email.MessageHtml).ToString(); // Replace any newline characters with a space - htmlToPlainText = Regex.Replace(htmlToPlainText, @"\t|\n|\r", " "); + htmlToPlainText = Regex.Replace(htmlToPlainText, @"\t|\n|\r", " ", RegexOptions.NonBacktracking); // Remove all "-" or "=" characters if there are 3 or more in a row - htmlToPlainText = Regex.Replace(htmlToPlainText, @"-{3,}|\={3,}", ""); + htmlToPlainText = Regex.Replace(htmlToPlainText, @"-{3,}|\={3,}", "", RegexOptions.NonBacktracking); // Remove any non-printable characters - htmlToPlainText = Regex.Replace(htmlToPlainText, @"[^\u0020-\u007E]", ""); + htmlToPlainText = Regex.Replace(htmlToPlainText, @"[^\u0020-\u007E]", "", RegexOptions.NonBacktracking); // Replace multiple spaces with a single space - htmlToPlainText = Regex.Replace(htmlToPlainText, @"\s+", " "); + htmlToPlainText = Regex.Replace(htmlToPlainText, @"\s+", " ", RegexOptions.NonBacktracking); // Trim start and end of string htmlToPlainText = htmlToPlainText.Trim(); diff --git a/src/Services/AliasVault.SmtpService/Program.cs b/src/Services/AliasVault.SmtpService/Program.cs index adb267a8e..b12d71ce5 100644 --- a/src/Services/AliasVault.SmtpService/Program.cs +++ b/src/Services/AliasVault.SmtpService/Program.cs @@ -16,9 +16,15 @@ using SmtpServer.Storage; var builder = Host.CreateApplicationBuilder(args); -// Read settings from appsettings.json. -ConfigurationManager configuration = builder.Configuration; -Config config = configuration.GetSection("Config").Get()!; +// Create global config object, get values from environment variables. +Config config = new Config(); +var emailDomains = Environment.GetEnvironmentVariable("SMTP_ALLOWED_DOMAINS") + ?? throw new KeyNotFoundException("SMTP_ALLOWED_DOMAINS environment variable is not set."); +config.AllowedToDomains = emailDomains.Split(',').ToList(); + +var tlsEnabled = Environment.GetEnvironmentVariable("SMTP_TLS_ENABLED") + ?? throw new KeyNotFoundException("SMTP_TLS_ENABLED environment variable is not set."); +config.SmtpTlsEnabled = tlsEnabled; builder.Services.AddSingleton(config); builder.Services.AddSingleton(container => diff --git a/src/Services/AliasVault.SmtpService/Properties/launchSettings.json b/src/Services/AliasVault.SmtpService/Properties/launchSettings.json index a6513c699..4f7c9c76e 100644 --- a/src/Services/AliasVault.SmtpService/Properties/launchSettings.json +++ b/src/Services/AliasVault.SmtpService/Properties/launchSettings.json @@ -3,7 +3,9 @@ "AliasVault.SmtpService": { "commandName": "Project", "environmentVariables": { - "DOTNET_ENVIRONMENT": "Development" + "DOTNET_ENVIRONMENT": "Development", + "SMTP_ALLOWED_DOMAINS": "example.tld", + "SMTP_TLS_ENABLED": "false" }, "dotnetRunMessages": true }, @@ -12,4 +14,4 @@ } }, "$schema": "http://json.schemastore.org/launchsettings.json" -} \ No newline at end of file +} diff --git a/src/Services/AliasVault.SmtpService/appsettings.json b/src/Services/AliasVault.SmtpService/appsettings.json index 258416faf..c74fb8b2a 100644 --- a/src/Services/AliasVault.SmtpService/appsettings.json +++ b/src/Services/AliasVault.SmtpService/appsettings.json @@ -7,11 +7,5 @@ }, "ConnectionStrings": { "AliasServerDbContext": "Data Source=../../../database/AliasServerDb.sqlite" - }, - "Config": { - "SmtpTlsEnabled": "false", - "AllowedToDomains": [ - "example.tld" - ] } } From e9a95fcc53fcc9aad9fc4019f12c82d5c205ec14 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 19 Jul 2024 10:50:20 +0200 Subject: [PATCH 3/3] Refactor DatabaseMessageStore.cs structure (#105) --- .../DatabaseMessageStore.cs | 118 ++++++++++++------ 1 file changed, 79 insertions(+), 39 deletions(-) diff --git a/src/Services/AliasVault.SmtpService/DatabaseMessageStore.cs b/src/Services/AliasVault.SmtpService/DatabaseMessageStore.cs index fe071411e..6bbb12a93 100644 --- a/src/Services/AliasVault.SmtpService/DatabaseMessageStore.cs +++ b/src/Services/AliasVault.SmtpService/DatabaseMessageStore.cs @@ -30,6 +30,14 @@ public class EmailParseMissingToException(string message) : Exception(message); /// Config instance. public class DatabaseMessageStore(ILogger logger, Config config, IDbContextFactory dbContextFactory) : MessageStore { + /// + /// Override the SaveAsync method to save the email into the database. + /// + /// ISessionContext instance. + /// IMessageTransaction instance. + /// Buffer which contains the email contents. + /// CancellationToken instance. + /// SmtpResponse. public override async Task SaveAsync(ISessionContext context, IMessageTransaction transaction, ReadOnlySequence buffer, CancellationToken cancellationToken) { await using var stream = new MemoryStream(); @@ -81,9 +89,7 @@ public class DatabaseMessageStore(ILogger logger, Config c message.Headers.Add("x-receiver", toAddress.User + "@" + toAddress.Host); message.Headers.Add("x-sender", transaction.From.User + "@" + transaction.From.Host); - // Insert into database. var insertedId = await InsertEmailIntoDatabase(message); - logger.LogInformation("Email saved into database with ID {insertedId}.", insertedId); } @@ -93,14 +99,13 @@ public class DatabaseMessageStore(ILogger logger, Config c /// /// Insert email into database. /// - /// ISessionContext instance. /// MimeMessage to save into database. private async Task InsertEmailIntoDatabase(MimeMessage message) { var dbContext = await dbContextFactory.CreateDbContextAsync(); - // Add the new vault and commit to database. var newEmail = ConvertMimeMessageToEmail(message); + await dbContext.Emails.AddAsync(newEmail); await dbContext.SaveChangesAsync(); @@ -113,7 +118,7 @@ public class DatabaseMessageStore(ILogger logger, Config c /// /// /// - private Email ConvertMimeMessageToEmail(MimeMessage message) + private static Email ConvertMimeMessageToEmail(MimeMessage message) { string from = ""; @@ -159,7 +164,7 @@ public class DatabaseMessageStore(ILogger logger, Config c // Try to extract to address firstly from x-receiver address.. try { - to = message.Headers.Where(x => x.Field == "x-receiver").First().Value.ToString(); + to = message.Headers.First(x => x.Field == "x-receiver").Value.ToString(); toAddress = new MailAddress(to); } catch @@ -193,8 +198,34 @@ public class DatabaseMessageStore(ILogger logger, Config c email.MessagePlain = message.TextBody; email.MessageSource = message.ToString(); - // Extract first 180 characters from plain text message, and if its null, then extract from html message but remove all html tags - email.MessagePreview = ""; + // Extract message preview text based on body contents. + email.MessagePreview = ExtractMessagePreview(email); + + email.Date = message.Date.DateTime; + email.DateSystem = DateTime.UtcNow; + email.Visible = true; + + // Parse attachments from email, and create separate attachment records in database for each attachment + foreach (var attachment in message.Attachments) + { + var emailAttachment = CreateEmailAttachment(attachment); + email.Attachments.Add(emailAttachment); + } + + return email; + } + + /// + /// Extracts a preview of the email message body to be used in the email listing preview in the UI. + /// This so the client does not need to load the full email body. + /// + /// + /// + private static string ExtractMessagePreview(Email email) + { + var messagePreview = string.Empty; + const int maxPreviewLength = 180; + try { if (email.MessagePlain != null && !String.IsNullOrEmpty(email.MessagePlain) && email.MessagePlain.Length > 3) @@ -214,8 +245,8 @@ public class DatabaseMessageStore(ILogger logger, Config c // Trim start and end of string plainToPlainText = plainToPlainText.Trim(); - email.MessagePreview = plainToPlainText.Length > 180 - ? plainToPlainText.Substring(0, 180) + messagePreview = plainToPlainText.Length > maxPreviewLength + ? plainToPlainText.Substring(0, maxPreviewLength) : plainToPlainText; } else if (email.MessageHtml != null) @@ -237,8 +268,8 @@ public class DatabaseMessageStore(ILogger logger, Config c // Trim start and end of string htmlToPlainText = htmlToPlainText.Trim(); - email.MessagePreview = - htmlToPlainText.Length > 180 ? htmlToPlainText.Substring(0, 180) : htmlToPlainText; + messagePreview = + htmlToPlainText.Length > maxPreviewLength ? htmlToPlainText.Substring(0, maxPreviewLength) : htmlToPlainText; } } catch @@ -246,38 +277,47 @@ public class DatabaseMessageStore(ILogger logger, Config c // Extracting useful words from email failed.. Skip the step, do nothing.. } - email.Date = message.Date.DateTime; - email.DateSystem = DateTime.UtcNow; - email.Visible = true; + return messagePreview; + } - // Parse attachments from email, and create separate attachment records in database for each attachment - foreach (var attachment in message.Attachments) + /// + /// Create an EmailAttachment object from a MimeEntity attachment. + /// + /// + /// + private static EmailAttachment CreateEmailAttachment(MimeEntity attachment) + { + byte[] fileBytes = GetAttachmentBytes(attachment); + + return new EmailAttachment { - byte[] fileBytes; - using (var memory = new MemoryStream ()) - { - if (attachment is MimePart) - { - ((MimePart)attachment).Content.DecodeTo(memory); - } - else - { - ((MessagePart) attachment).Message.WriteTo(memory); - } + Bytes = fileBytes, + Filename = attachment.ContentDisposition?.FileName ?? "", + MimeType = attachment.ContentType.MimeType, + Filesize = fileBytes.Length, + Date = DateTime.Now + }; + } - fileBytes = memory.ToArray(); + /// + /// Get the attachment bytes from a MimeEntity attachment. + /// + /// + /// + private static byte[] GetAttachmentBytes(MimeEntity attachment) + { + using (var memory = new MemoryStream()) + { + if (attachment is MimePart mimePartAttachment) + { + mimePartAttachment.Content.DecodeTo(memory); + } + else + { + ((MessagePart)attachment).Message.WriteTo(memory); } - email.Attachments.Add(new EmailAttachment - { - Bytes = fileBytes, - Filename = attachment.ContentDisposition?.FileName ?? "", - MimeType = attachment.ContentType.MimeType, - Filesize = fileBytes.Length, - Date = DateTime.Now - }); + return memory.ToArray(); } - - return email; } }