Merge pull request #115 from lanedirt/105-add-email-storage-to-server-database

Make SmtpServer save emails to database
This commit is contained in:
Leendert de Borst
2024-07-19 01:59:04 -07:00
committed by GitHub
21 changed files with 1402 additions and 34 deletions

View File

@@ -1 +1,3 @@
JWT_KEY=
SMTP_ALLOWED_DOMAINS=example.tld
SMTP_TLS_ENABLED=false

View File

@@ -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
```
@@ -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.

View File

@@ -31,4 +31,6 @@ services:
ports:
- "25:25"
- "587:587"
env_file:
- .env
restart: always

40
init.sh
View File

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

View File

@@ -47,6 +47,16 @@ public class AliasServerDbContext : IdentityDbContext<AliasVaultUser>
/// </summary>
public DbSet<Vault> Vaults { get; set; }
/// <summary>
/// Gets or sets the Emails DbSet.
/// </summary>
public DbSet<Email> Emails { get; set; }
/// <summary>
/// Gets or sets the EmailAttachments DbSet.
/// </summary>
public DbSet<EmailAttachment> EmailAttachments { get; set; }
/// <summary>
/// The OnModelCreating method.
/// </summary>
@@ -69,19 +79,24 @@ public class AliasServerDbContext : IdentityDbContext<AliasVaultUser>
}
}
// Configure the User - AspNetUserRefreshToken entity
// Configure the User - AspNetUserRefreshToken entity.
builder.Entity<AspNetUserRefreshToken>()
.HasOne(p => p.User)
.WithMany()
.HasForeignKey(p => p.UserId)
.IsRequired();
// Configure the Vault - UserId entity
// Configure the Vault - UserId entity.
builder.Entity<Vault>()
.HasOne(p => p.User)
.WithMany()
.HasForeignKey(p => p.UserId)
.IsRequired();
// Configure the Email - Attachments entity.
builder.Entity<EmailAttachment>().HasOne(d => d.Email)
.WithMany(p => p.Attachments)
.HasForeignKey(d => d.EmailId);
}
/// <summary>

View File

@@ -0,0 +1,114 @@
//-----------------------------------------------------------------------
// <copyright file="Email.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasServerDb;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Represents an email message.
/// </summary>
[Index(nameof(ToLocal))]
[Index(nameof(Date))]
[Index(nameof(DateSystem))]
[Index(nameof(Visible))]
[Index(nameof(PushNotificationSent))]
public class Email
{
/// <summary>
/// Initializes a new instance of the <see cref="Email"/> class.
/// </summary>
public Email()
{
Attachments = new HashSet<EmailAttachment>();
}
/// <summary>
/// Gets or sets the ID of the email.
/// </summary>
public int Id { get; set; }
/// <summary>
/// Gets or sets the subject of the email.
/// </summary>
public string Subject { get; set; } = null!;
/// <summary>
/// Gets or sets the sender's email address.
/// </summary>
public string From { get; set; } = null!;
/// <summary>
/// Gets or sets the local part of the sender's email address.
/// </summary>
public string FromLocal { get; set; } = null!;
/// <summary>
/// Gets or sets the domain part of the sender's email address.
/// </summary>
public string FromDomain { get; set; } = null!;
/// <summary>
/// Gets or sets the recipient's email address.
/// </summary>
public string To { get; set; } = null!;
/// <summary>
/// Gets or sets the local part of the recipient's email address.
/// </summary>
public string ToLocal { get; set; } = null!;
/// <summary>
/// Gets or sets the domain part of the recipient's email address.
/// </summary>
public string ToDomain { get; set; } = null!;
/// <summary>
/// Gets or sets the date and time when the email was sent.
/// </summary>
public DateTime Date { get; set; }
/// <summary>
/// Gets or sets the system date and time when the email was received.
/// </summary>
public DateTime DateSystem { get; set; }
/// <summary>
/// Gets or sets the HTML content of the email message.
/// </summary>
public string? MessageHtml { get; set; }
/// <summary>
/// Gets or sets the plain text content of the email message.
/// </summary>
public string? MessagePlain { get; set; }
/// <summary>
/// Gets or sets the preview of the email message.
/// </summary>
public string? MessagePreview { get; set; }
/// <summary>
/// Gets or sets the source of the email message.
/// </summary>
public string MessageSource { get; set; } = null!;
/// <summary>
/// Gets or sets a value indicating whether the email is visible.
/// </summary>
public bool Visible { get; set; }
/// <summary>
/// Gets or sets a value indicating whether a push notification has been sent for the email.
/// </summary>
public bool PushNotificationSent { get; set; }
/// <summary>
/// Gets or sets the collection of email attachments.
/// </summary>
public virtual ICollection<EmailAttachment> Attachments { get; set; }
}

View File

@@ -0,0 +1,57 @@
//-----------------------------------------------------------------------
// <copyright file="EmailAttachment.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasServerDb;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Represents an email attachment.
/// </summary>
[Index(nameof(EmailId))]
public class EmailAttachment
{
/// <summary>
/// Gets or sets the ID of the attachment.
/// </summary>
public int Id { get; set; }
/// <summary>
/// Gets or sets the bytes of the attachment.
/// </summary>
public byte[] Bytes { get; set; } = null!;
/// <summary>
/// Gets or sets the filename of the attachment.
/// </summary>
public string Filename { get; set; } = null!;
/// <summary>
/// Gets or sets the MIME type of the attachment.
/// </summary>
public string MimeType { get; set; } = null!;
/// <summary>
/// Gets or sets the filesize of the attachment.
/// </summary>
public int Filesize { get; set; }
/// <summary>
/// Gets or sets the date of the attachment.
/// </summary>
public DateTime Date { get; set; }
/// <summary>
/// Gets or sets the ID of the email that the attachment belongs to.
/// </summary>
public int EmailId { get; set; }
/// <summary>
/// Gets or sets the email that the attachment belongs to.
/// </summary>
public virtual Email Email { get; set; } = null!;
}

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore.Migrations;
// <auto-generated />
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable

View File

@@ -0,0 +1,494 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("Salt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DeviceIdentifier")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("ExpireDate")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserRefreshTokens");
});
modelBuilder.Entity("AliasServerDb.Email", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<DateTime>("DateSystem")
.HasColumnType("TEXT");
b.Property<string>("From")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FromDomain")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FromLocal")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("MessageHtml")
.HasColumnType("TEXT");
b.Property<string>("MessagePlain")
.HasColumnType("TEXT");
b.Property<string>("MessagePreview")
.HasColumnType("TEXT");
b.Property<string>("MessageSource")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("PushNotificationSent")
.HasColumnType("INTEGER");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("To")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ToDomain")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ToLocal")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("Bytes")
.IsRequired()
.HasColumnType("BLOB");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<int>("EmailId")
.HasColumnType("INTEGER");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Filesize")
.HasColumnType("INTEGER");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EmailId");
b.ToTable("EmailAttachments");
});
modelBuilder.Entity("AliasServerDb.Vault", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("VaultBlob")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Vaults");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("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<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("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<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", 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<string>", 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
}
}
}

View File

@@ -0,0 +1,107 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasServerDb.Migrations
{
/// <inheritdoc />
public partial class AddEmailTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Emails",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Subject = table.Column<string>(type: "TEXT", nullable: false),
From = table.Column<string>(type: "TEXT", nullable: false),
FromLocal = table.Column<string>(type: "TEXT", nullable: false),
FromDomain = table.Column<string>(type: "TEXT", nullable: false),
To = table.Column<string>(type: "TEXT", nullable: false),
ToLocal = table.Column<string>(type: "TEXT", nullable: false),
ToDomain = table.Column<string>(type: "TEXT", nullable: false),
Date = table.Column<DateTime>(type: "TEXT", nullable: false),
DateSystem = table.Column<DateTime>(type: "TEXT", nullable: false),
MessageHtml = table.Column<string>(type: "TEXT", nullable: true),
MessagePlain = table.Column<string>(type: "TEXT", nullable: true),
MessagePreview = table.Column<string>(type: "TEXT", nullable: true),
MessageSource = table.Column<string>(type: "TEXT", nullable: false),
Visible = table.Column<bool>(type: "INTEGER", nullable: false),
PushNotificationSent = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Emails", x => x.Id);
});
migrationBuilder.CreateTable(
name: "EmailAttachments",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Bytes = table.Column<byte[]>(type: "BLOB", nullable: false),
Filename = table.Column<string>(type: "TEXT", nullable: false),
MimeType = table.Column<string>(type: "TEXT", nullable: false),
Filesize = table.Column<int>(type: "INTEGER", nullable: false),
Date = table.Column<DateTime>(type: "TEXT", nullable: false),
EmailId = table.Column<int>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EmailAttachments");
migrationBuilder.DropTable(
name: "Emails");
}
}
}

View File

@@ -128,6 +128,114 @@ namespace AliasServerDb.Migrations
b.ToTable("AspNetUserRefreshTokens");
});
modelBuilder.Entity("AliasServerDb.Email", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<DateTime>("DateSystem")
.HasColumnType("TEXT");
b.Property<string>("From")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FromDomain")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FromLocal")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("MessageHtml")
.HasColumnType("TEXT");
b.Property<string>("MessagePlain")
.HasColumnType("TEXT");
b.Property<string>("MessagePreview")
.HasColumnType("TEXT");
b.Property<string>("MessageSource")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("PushNotificationSent")
.HasColumnType("INTEGER");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("To")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ToDomain")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ToLocal")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("Bytes")
.IsRequired()
.HasColumnType("BLOB");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<int>("EmailId")
.HasColumnType("INTEGER");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Filesize")
.HasColumnType("INTEGER");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EmailId");
b.ToTable("EmailAttachments");
});
modelBuilder.Entity("AliasServerDb.Vault", b =>
{
b.Property<Guid>("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
}
}

View File

@@ -16,6 +16,12 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.20.1" />
<PackageReference Include="MimeKit" Version="4.7.1" />
<PackageReference Include="NUglify" Version="1.21.9" />
<PackageReference Include="SmtpServer" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Databases\AliasServerDb\AliasServerDb.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,45 @@
//-----------------------------------------------------------------------
// <copyright file="AllowedDomainsFilter.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.SmtpService;
using SmtpServer;
using SmtpServer.Mail;
using SmtpServer.Storage;
/// <summary>
/// Filter to allow only emails from configured domains.
/// </summary>
public class AllowedDomainsFilter(Config config, ILogger<AllowedDomainsFilter> logger) : IMailboxFilter, IMailboxFilterFactory
{
private readonly TimeSpan _delay = TimeSpan.Zero;
public async Task<bool> CanAcceptFromAsync(ISessionContext context, IMailbox from, int size, CancellationToken cancellationToken)
{
await Task.Delay(_delay, cancellationToken);
return true;
}
public async Task<bool> 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<Config>(), context.ServiceProvider.GetRequiredService<ILogger<AllowedDomainsFilter>>());
}
}

View File

@@ -0,0 +1,323 @@
//-----------------------------------------------------------------------
// <copyright file="DatabaseMessageStore.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
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;
/// <summary>
/// Custom exception for when the email parsing fails to find the "to" address in the email.
/// </summary>
public class EmailParseMissingToException(string message) : Exception(message);
/// <summary>
/// Database message store.
/// </summary>
/// <param name="logger">ILogger instance.</param>
/// <param name="config">Config instance.</param>
public class DatabaseMessageStore(ILogger<DatabaseMessageStore> logger, Config config, IDbContextFactory<AliasServerDbContext> dbContextFactory) : MessageStore
{
/// <summary>
/// Override the SaveAsync method to save the email into the database.
/// </summary>
/// <param name="context">ISessionContext instance.</param>
/// <param name="transaction">IMessageTransaction instance.</param>
/// <param name="buffer">Buffer which contains the email contents.</param>
/// <param name="cancellationToken">CancellationToken instance.</param>
/// <returns>SmtpResponse.</returns>
public override async Task<SmtpResponse> SaveAsync(ISessionContext context, IMessageTransaction transaction, ReadOnlySequence<byte> 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);
var insertedId = await InsertEmailIntoDatabase(message);
logger.LogInformation("Email saved into database with ID {insertedId}.", insertedId);
}
return SmtpResponse.Ok;
}
/// <summary>
/// Insert email into database.
/// </summary>
/// <param name="message">MimeMessage to save into database.</param>
private async Task<int> InsertEmailIntoDatabase(MimeMessage message)
{
var dbContext = await dbContextFactory.CreateDbContextAsync();
var newEmail = ConvertMimeMessageToEmail(message);
await dbContext.Emails.AddAsync(newEmail);
await dbContext.SaveChangesAsync();
return newEmail.Id;
}
/// <summary>
/// Convert MimeMessage to Email database object.
/// </summary>
/// <param name="message"></param>
/// <returns></returns>
/// <exception cref="EmailParseMissingToException"></exception>
private static 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.First(x => x.Field == "x-receiver").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 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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="email"></param>
/// <returns></returns>
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)
{
// Replace any newline characters with a space
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,}", "", RegexOptions.NonBacktracking);
// Remove any non-printable characters
plainToPlainText = Regex.Replace(plainToPlainText, @"[^\u0020-\u007E]", "", RegexOptions.NonBacktracking);
// Replace multiple spaces with a single space
plainToPlainText = Regex.Replace(plainToPlainText, @"\s+", " ", RegexOptions.NonBacktracking);
// Trim start and end of string
plainToPlainText = plainToPlainText.Trim();
messagePreview = plainToPlainText.Length > maxPreviewLength
? plainToPlainText.Substring(0, maxPreviewLength)
: 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", " ", RegexOptions.NonBacktracking);
// Remove all "-" or "=" characters if there are 3 or more in a row
htmlToPlainText = Regex.Replace(htmlToPlainText, @"-{3,}|\={3,}", "", RegexOptions.NonBacktracking);
// Remove any non-printable characters
htmlToPlainText = Regex.Replace(htmlToPlainText, @"[^\u0020-\u007E]", "", RegexOptions.NonBacktracking);
// Replace multiple spaces with a single space
htmlToPlainText = Regex.Replace(htmlToPlainText, @"\s+", " ", RegexOptions.NonBacktracking);
// Trim start and end of string
htmlToPlainText = htmlToPlainText.Trim();
messagePreview =
htmlToPlainText.Length > maxPreviewLength ? htmlToPlainText.Substring(0, maxPreviewLength) : htmlToPlainText;
}
}
catch
{
// Extracting useful words from email failed.. Skip the step, do nothing..
}
return messagePreview;
}
/// <summary>
/// Create an EmailAttachment object from a MimeEntity attachment.
/// </summary>
/// <param name="attachment"></param>
/// <returns></returns>
private static EmailAttachment CreateEmailAttachment(MimeEntity attachment)
{
byte[] fileBytes = GetAttachmentBytes(attachment);
return new EmailAttachment
{
Bytes = fileBytes,
Filename = attachment.ContentDisposition?.FileName ?? "",
MimeType = attachment.ContentType.MimeType,
Filesize = fileBytes.Length,
Date = DateTime.Now
};
}
/// <summary>
/// Get the attachment bytes from a MimeEntity attachment.
/// </summary>
/// <param name="attachment"></param>
/// <returns></returns>
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);
}
return memory.ToArray();
}
}
}

View File

@@ -5,18 +5,50 @@
// </copyright>
//-----------------------------------------------------------------------
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<Worker>();
// Read settings from appsettings.json.
ConfigurationManager configuration = builder.Configuration;
Config config = configuration.GetSection("Config").Get<Config>()!;
// 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<DbConnection>(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<AliasServerDbContext>((container, options) =>
{
var connection = container.GetRequiredService<DbConnection>();
options.UseSqlite(connection).UseLazyLoadingProxies();
});
builder.Services.AddTransient<IMessageStore, DatabaseMessageStore>();
builder.Services.AddTransient<IMailboxFilter, AllowedDomainsFilter>();
builder.Services.AddSingleton(
provider =>
{
@@ -26,16 +58,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 +75,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)
);
}
/// <summary>
/// Helper method to create an X509Certificate2 object from a PEM file.
/// </summary>
return new SmtpServer.SmtpServer(options.Build(), provider.GetRequiredService<IServiceProvider>());
static X509Certificate2 CreateCertificate()
{
// Specify the directory where PEM files are stored.
@@ -79,16 +110,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<IServiceProvider>());
}
);
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
await host.RunAsync();

View File

@@ -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"
}
}

View File

@@ -0,0 +1 @@
curl --url "smtp://localhost:25" --mail-from "sender@example.com" --mail-rcpt "yourname@example.tld" --upload-file testEmail1.txt

View File

@@ -0,0 +1 @@
curl --url "smtp://localhost:25" --mail-from "sender@example.com" --mail-rcpt "yourname@unknowndomain.com" --upload-file testEmail1.txt

View File

@@ -0,0 +1,5 @@
From: sender@example.com
To: recipient@example.tld
Subject: Test Email
This is a test email.

View File

@@ -10,13 +10,13 @@ namespace AliasVault.SmtpService
public class Worker(ILogger<Worker> logger, SmtpServer.SmtpServer smtpServer) : BackgroundService
{
/// <inheritdoc />
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);
}
}
}

View File

@@ -5,10 +5,7 @@
"Microsoft.Hosting.Lifetime": "Information"
}
},
"Config": {
"SmtpTlsEnabled": "false",
"AllowedToDomains": [
"example.tld"
]
"ConnectionStrings": {
"AliasServerDbContext": "Data Source=../../../database/AliasServerDb.sqlite"
}
}