diff --git a/.env.example b/.env.example index 23265bf74..685d0b337 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ API_URL= JWT_KEY= -SMTP_ALLOWED_DOMAINS= +PRIVATE_EMAIL_DOMAINS= SMTP_TLS_ENABLED=false diff --git a/.github/workflows/dotnet-e2e-tests.yml b/.github/workflows/dotnet-e2e-tests.yml index 58cdeffb2..659155904 100644 --- a/.github/workflows/dotnet-e2e-tests.yml +++ b/.github/workflows/dotnet-e2e-tests.yml @@ -25,9 +25,24 @@ jobs: run: dotnet build - name: Ensure browsers are installed run: pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net8.0/playwright.ps1 install --with-deps - - name: Run AdminTests - run: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category=AdminTests" - - name: Run ClientTests - run: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category=ClientTests" - - name: Run remaining tests - run: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category!=AdminTests&Category!=ClientTests" + + - name: Run AdminTests with retry + uses: nick-invision/retry@v2 + with: + timeout_minutes: 10 + max_attempts: 3 + command: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category=AdminTests" + + - name: Run ClientTests with retry + uses: nick-invision/retry@v2 + with: + timeout_minutes: 10 + max_attempts: 3 + command: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category=ClientTests" + + - name: Run remaining tests with retry + uses: nick-invision/retry@v2 + with: + timeout_minutes: 10 + max_attempts: 3 + command: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category!=AdminTests&Category!=ClientTests" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 45e149a28..dec031280 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,7 +80,7 @@ Here is an example file with the various options explained: ``` { "ApiUrl": "http://localhost:5092", - "SmtpAllowedDomains": ["example.tld"], + "PrivateEmailDomains": ["example.tld"], "UseDebugEncryptionKey": "true" } ``` diff --git a/docs/setup/1-manually-setup-docker.md b/docs/setup/1-manually-setup-docker.md index 3dc132f5c..4c7d11051 100644 --- a/docs/setup/1-manually-setup-docker.md +++ b/docs/setup/1-manually-setup-docker.md @@ -30,11 +30,11 @@ This README provides step-by-step instructions for manually setting up AliasVaul ``` JWT_KEY=your_32_char_string_here -3. **Set SMTP_ALLOWED_DOMAINS** +3. **Set PRIVATE_EMAIL_DOMAINS** - Update the .env file and set the SMTP_ALLOWED_DOMAINS value the allowed domains that can be used for email addresses. Separate multiple domains with commas. + Update the .env file and set the PRIVATE_EMAIL_DOMAINS value the allowed domains that can be used for email addresses. Separate multiple domains with commas. ``` - SMTP_ALLOWED_DOMAINS=yourdomain.com,anotherdomain.com + PRIVATE_EMAIL_DOMAINS=yourdomain.com,anotherdomain.com ``` Replace `yourdomain.com,anotherdomain.com` with your actual allowed domains. diff --git a/install.sh b/install.sh index 7a497a7b1..9896e30a9 100755 --- a/install.sh +++ b/install.sh @@ -170,33 +170,33 @@ populate_jwt_key() { fi } -# Function to ask the user for SMTP_ALLOWED_DOMAINS -set_smtp_allowed_domains() { - printf "${CYAN}> Setting SMTP_ALLOWED_DOMAINS...${NC}\n" - if ! grep -q "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE" || [ -z "$(grep "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE" | cut -d '=' -f2)" ]; then +# Function to ask the user for PRIVATE_EMAIL_DOMAINS +set_private_email_domains() { + printf "${CYAN}> Setting PRIVATE_EMAIL_DOMAINS...${NC}\n" + if ! grep -q "^PRIVATE_EMAIL_DOMAINS=" "$ENV_FILE" || [ -z "$(grep "^PRIVATE_EMAIL_DOMAINS=" "$ENV_FILE" | cut -d '=' -f2)" ]; then printf "Please enter the domains that should be allowed to receive email, separated by commas (press Enter to disable email support): " - read -r smtp_allowed_domains + read -r private_email_domains # Set default value if user input is empty - smtp_allowed_domains=${smtp_allowed_domains:-"DISABLED.TLD"} + private_email_domains=${private_email_domains:-"DISABLED.TLD"} - 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" + if grep -q "^PRIVATE_EMAIL_DOMAINS=" "$ENV_FILE"; then + awk -v domains="$private_email_domains" '/^PRIVATE_EMAIL_DOMAINS=/ {$0="PRIVATE_EMAIL_DOMAINS="domains} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" else - echo "SMTP_ALLOWED_DOMAINS=${smtp_allowed_domains}" >> "$ENV_FILE" + echo "PRIVATE_EMAIL_DOMAINS=${private_email_domains}" >> "$ENV_FILE" fi - if [ "$smtp_allowed_domains" = "DISABLED.TLD" ]; then - printf "${GREEN}> SMTP_ALLOWED_DOMAINS has been set to 'DISABLED.TLD' in $ENV_FILE.${NC} ${RED}SMTP is disabled.${NC}\n" + if [ "$private_email_domains" = "DISABLED.TLD" ]; then + printf "${GREEN}> PRIVATE_EMAIL_DOMAINS has been set to 'DISABLED.TLD' in $ENV_FILE.${NC} ${RED}SMTP is disabled.${NC}\n" else - printf "${GREEN}> SMTP_ALLOWED_DOMAINS has been set to '${smtp_allowed_domains}' in $ENV_FILE.${NC}\n" + printf "${GREEN}> PRIVATE_EMAIL_DOMAINS has been set to '${private_email_domains}' in $ENV_FILE.${NC}\n" fi else - smtp_allowed_domains=$(grep "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE" | cut -d '=' -f2) - if [ "$smtp_allowed_domains" = "DISABLED.TLD" ]; then - printf "${GREEN}> SMTP_ALLOWED_DOMAINS already exists in $ENV_FILE.${NC} ${RED}SMTP is disabled.${NC}\n" + private_email_domains=$(grep "^private_email_domains=" "$ENV_FILE" | cut -d '=' -f2) + if [ "$private_email_domains" = "DISABLED.TLD" ]; then + printf "${GREEN}> PRIVATE_EMAIL_DOMAINS already exists in $ENV_FILE.${NC} ${RED}SMTP is disabled.${NC}\n" else - printf "${GREEN}> SMTP_ALLOWED_DOMAINS already exists in $ENV_FILE with value: ${smtp_allowed_domains}${NC}\n" + printf "${GREEN}> PRIVATE_EMAIL_DOMAINS already exists in $ENV_FILE with value: ${private_email_domains}${NC}\n" fi fi } @@ -316,7 +316,7 @@ main() { create_env_file || exit $? populate_api_url || exit $? populate_jwt_key || exit $? - set_smtp_allowed_domains || exit $? + set_private_email_domains || exit $? set_smtp_tls_enabled || exit $? generate_admin_password || exit $? printf "\n${YELLOW}+++ Building Docker containers +++${NC}\n" diff --git a/src/AliasVault.Api/Controllers/EmailBoxController.cs b/src/AliasVault.Api/Controllers/EmailBoxController.cs index d44c1dd6a..606eec73c 100644 --- a/src/AliasVault.Api/Controllers/EmailBoxController.cs +++ b/src/AliasVault.Api/Controllers/EmailBoxController.cs @@ -10,6 +10,7 @@ namespace AliasVault.Api.Controllers; using AliasServerDb; using AliasVault.Api.Helpers; using AliasVault.Shared.Models.Spamok; +using AliasVault.Shared.Models.WebApi; using Asp.Versioning; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -41,30 +42,54 @@ public class EmailBoxController(IDbContextFactory dbContex // See if this user has a valid claim to the email address. var emailClaim = await context.UserEmailClaims - .FirstOrDefaultAsync(x => x.UserId == user.Id && x.Address == to); + .FirstOrDefaultAsync(x => x.Address == to); if (emailClaim is null) { - return Unauthorized("User does not have a claim to this email address."); + return BadRequest(new ApiErrorResponse + { + Message = "No claim exists for this email address.", + Code = "CLAIM_DOES_NOT_EXIST", + Details = new { ProvidedEmail = to }, + StatusCode = StatusCodes.Status400BadRequest, + Timestamp = DateTime.UtcNow, + }); + } + + if (emailClaim.UserId != user.Id) + { + return BadRequest(new ApiErrorResponse + { + Message = "Claim does not match user.", + Code = "CLAIM_DOES_NOT_MATCH_USER", + Details = new { ProvidedEmail = to }, + StatusCode = StatusCodes.Status400BadRequest, + Timestamp = DateTime.UtcNow, + }); } // Retrieve emails from database. - List emails = await context.Emails.AsNoTracking().Select(x => new MailboxEmailApiModel() - { - Id = x.Id, - Subject = x.Subject, - FromDisplay = ConversionHelper.ConvertFromToFromDisplay(x.From), - FromDomain = x.FromDomain, - FromLocal = x.FromLocal, - ToDomain = x.ToDomain, - ToLocal = x.ToLocal, - Date = x.Date, - DateSystem = x.DateSystem, - SecondsAgo = (int)DateTime.UtcNow.Subtract(x.DateSystem).TotalSeconds, - MessagePreview = x.MessagePreview ?? string.Empty, - EncryptedSymmetricKey = x.EncryptedSymmetricKey, - EncryptionKey = x.EncryptionKey.PublicKey, - }).OrderByDescending(x => x.DateSystem).Take(75).ToListAsync(); + List emails = await context.Emails.AsNoTracking() + .Where(x => x.To == to) + .Select(x => new MailboxEmailApiModel() + { + Id = x.Id, + Subject = x.Subject, + FromDisplay = ConversionHelper.ConvertFromToFromDisplay(x.From), + FromDomain = x.FromDomain, + FromLocal = x.FromLocal, + ToDomain = x.ToDomain, + ToLocal = x.ToLocal, + Date = x.Date, + DateSystem = x.DateSystem, + SecondsAgo = (int)DateTime.UtcNow.Subtract(x.DateSystem).TotalSeconds, + MessagePreview = x.MessagePreview ?? string.Empty, + EncryptedSymmetricKey = x.EncryptedSymmetricKey, + EncryptionKey = x.EncryptionKey.PublicKey, + }) + .OrderByDescending(x => x.DateSystem) + .Take(50) + .ToListAsync(); MailboxApiModel returnValue = new MailboxApiModel(); returnValue.Address = to; diff --git a/src/AliasVault.Api/Controllers/VaultController.cs b/src/AliasVault.Api/Controllers/VaultController.cs index 825ce2dcb..d71ee0c7d 100644 --- a/src/AliasVault.Api/Controllers/VaultController.cs +++ b/src/AliasVault.Api/Controllers/VaultController.cs @@ -7,6 +7,7 @@ namespace AliasVault.Api.Controllers; +using System.ComponentModel.DataAnnotations; using AliasServerDb; using AliasVault.Api.Helpers; using AliasVault.Api.Vault; @@ -20,11 +21,12 @@ using Microsoft.EntityFrameworkCore; /// /// Vault controller for handling CRUD operations on the database for encrypted vault entities. /// +/// ILogger instance. /// DbContext instance. /// UserManager instance. /// ITimeProvider instance. [ApiVersion("1")] -public class VaultController(IDbContextFactory dbContextFactory, UserManager userManager, ITimeProvider timeProvider) : AuthenticatedRequestController(userManager) +public class VaultController(ILogger logger, IDbContextFactory dbContextFactory, UserManager userManager, ITimeProvider timeProvider) : AuthenticatedRequestController(userManager) { /// /// Default retention policy for vaults. @@ -149,17 +151,42 @@ public class VaultController(IDbContextFactory dbContextFa // Register new email addresses. foreach (var email in newEmailAddresses) { + // If email address is invalid according to the EmailAddressAttribute, skip it. + if (!new EmailAddressAttribute().IsValid(email)) + { + continue; + } + + // Check if the email address is already claimed (by another user). + var existingClaim = await context.UserEmailClaims + .FirstOrDefaultAsync(x => x.Address == email); + + if (existingClaim != null && existingClaim.UserId != userId) + { + // Email address is already claimed by another user. Log the error and continue. + logger.LogWarning("{User} tried to claim email address: {Email} but it is already claimed by another user.", userId, email); + continue; + } + if (!existingEmailClaims.Contains(email)) { - await context.UserEmailClaims.AddAsync(new UserEmailClaim + try { - UserId = userId, - Address = email, - AddressLocal = email.Split('@')[0], - AddressDomain = email.Split('@')[1], - CreatedAt = timeProvider.UtcNow, - UpdatedAt = timeProvider.UtcNow, - }); + await context.UserEmailClaims.AddAsync(new UserEmailClaim + { + UserId = userId, + Address = email, + AddressLocal = email.Split('@')[0], + AddressDomain = email.Split('@')[1], + CreatedAt = timeProvider.UtcNow, + UpdatedAt = timeProvider.UtcNow, + }); + } + catch (DbUpdateException ex) + { + // Error while adding email claim. Log the error and continue. + logger.LogWarning(ex, "Error while adding UserEmailClaim with email: {Email} for user: {UserId}.", email, userId); + } } } diff --git a/src/AliasVault.Client/Config.cs b/src/AliasVault.Client/Config.cs index 230af8f75..bf7da0c08 100644 --- a/src/AliasVault.Client/Config.cs +++ b/src/AliasVault.Client/Config.cs @@ -23,5 +23,22 @@ public class Config /// Email addresses that client vault users use will be registered at the server /// to get exclusive access to the email address. /// - public List SmtpAllowedDomains { get; set; } = []; + public List PrivateEmailDomains { get; set; } = []; + + /// + /// Gets or sets the public email domains that are allowed to be used by the client vault users. + /// + public List PublicEmailDomains { get; set; } = + [ + "spamok.com", + "solarflarecorp.com", + "spamok.nl", + "3060.nl", + "landmail.nl", + "asdasd.nl", + "spamok.de", + "spamok.com.ua", + "spamok.es", + "spamok.fr", + ]; } diff --git a/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor b/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor index fb3ff8182..f92666623 100644 --- a/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor +++ b/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor @@ -1,10 +1,15 @@ -@using AliasVault.Shared.Models.Spamok +@using System.Net +@using System.Text.Json +@using AliasVault.Shared.Models.Spamok +@using AliasVault.Shared.Models.WebApi @inherits ComponentBase @inject IHttpClientFactory HttpClientFactory @inject HttpClient HttpClient @inject JsInteropService JsInteropService @inject DbService DbService @inject Config Config +@using System.Timers +@implements IDisposable @if (EmailModalVisible) { @@ -15,10 +20,15 @@ {
-

Email

- +
+

Email

+
+
+
+ +
@if (IsLoading) @@ -80,11 +90,19 @@ public string EmailAddress { get; set; } = string.Empty; private List MailboxEmails { get; set; } = new(); - private bool IsLoading { get; set; } = true; private bool ShowComponent { get; set; } = false; private EmailApiModel Email { get; set; } = new(); private bool EmailModalVisible { get; set; } private string Error { get; set; } = string.Empty; + private Timer? RefreshTimer { get; set; } + private bool IsRefreshing { get; set; } = true; + + /// + /// Boolean for UI to indicate refreshing. This value is switched between false/true every 2 seconds. + /// + private bool RefreshTick { get; set; } = false; + + private bool IsLoading { get; set; } = true; /// protected override async Task OnInitializedAsync() @@ -96,6 +114,16 @@ { ShowComponent = true; } + + RefreshTimer = new Timer(2000); + RefreshTimer.Elapsed += async (sender, e) => await TimerRefresh(); + RefreshTimer.Start(); + } + + /// + public void Dispose() + { + RefreshTimer?.Dispose(); } /// @@ -110,7 +138,7 @@ if (firstRender) { - await LoadRecentEmailsAsync(); + await ManualRefresh(); } } @@ -119,17 +147,7 @@ /// private bool IsSpamOkDomain(string email) { - return email.EndsWith("@spamok.nl") || - email.EndsWith("@spamok.de") || - email.EndsWith("@spamok.es") || - email.EndsWith("@spamok.fr") || - email.EndsWith("@spamok.com") || - email.EndsWith("@spamok.com.ua") || - email.EndsWith("@landmail.nl") || - email.EndsWith("@landmeel.nl") || - email.EndsWith("@asdasd.nl") || - email.EndsWith("@sdfsdf.nl") || - email.EndsWith("@solarflarecorp.com"); + return Config.PublicEmailDomains.Exists(x => email.EndsWith(x)); } /// @@ -137,7 +155,25 @@ /// private bool IsAliasVaultDomain(string email) { - return Config.SmtpAllowedDomains.Exists(x => email.EndsWith(x)); + return Config.PrivateEmailDomains.Exists(x => email.EndsWith(x)); + } + + private async Task TimerRefresh() + { + IsRefreshing = true; + StateHasChanged(); + await LoadRecentEmailsAsync(); + IsRefreshing = false; + StateHasChanged(); + } + + private async Task ManualRefresh() + { + IsLoading = true; + StateHasChanged(); + await LoadRecentEmailsAsync(); + IsLoading = false; + StateHasChanged(); } private async Task LoadRecentEmailsAsync() @@ -148,7 +184,6 @@ } Error = string.Empty; - IsLoading = true; StateHasChanged(); // Get email prefix, which is the part before the @ symbol. @@ -162,9 +197,6 @@ { await LoadAliasVaultEmails(); } - - IsLoading = false; - StateHasChanged(); } /// @@ -175,7 +207,6 @@ // Get email prefix, which is the part before the @ symbol. string emailPrefix = EmailAddress.Split('@')[0]; - if (IsSpamOkDomain(EmailAddress)) { await ShowSpamOkEmailInModal(emailPrefix, emailId); @@ -223,31 +254,43 @@ /// private async Task LoadAliasVaultEmails() { + var request = new HttpRequestMessage(HttpMethod.Get, $"api/v1/EmailBox/{EmailAddress}"); try { - var mailbox = await HttpClient.GetFromJsonAsync($"api/v1/EmailBox/{EmailAddress}"); - if (mailbox?.Mails != null) + var response = await HttpClient.SendAsync(request); + if (response.IsSuccessStatusCode) { - // Show maximum of 10 recent emails. - MailboxEmails = mailbox.Mails.Take(10).ToList(); + var mailbox = await response.Content.ReadFromJsonAsync(); + await UpdateMailboxEmails(mailbox); } - - // Loop through emails and decrypt the subject locally. - var context = await DbService.GetDbContextAsync(); - var privateKeys = await context.EncryptionKeys.ToListAsync(); - foreach (var mail in MailboxEmails) + else { - var privateKey = privateKeys.First(x => x.PublicKey == mail.EncryptionKey); + var errorContent = await response.Content.ReadAsStringAsync(); + var errorResponse = JsonSerializer.Deserialize(errorContent); + switch (response.StatusCode) + { + case HttpStatusCode.BadRequest: + if (errorResponse != null) + { + switch (errorResponse.Code) + { + case "CLAIM_DOES_NOT_MATCH_USER": + Error = "The current chosen email address is already in use. Please change the email address by editing this credential."; + break; + case "CLAIM_DOES_NOT_EXIST": + Error = "An error occurred while trying to load the emails. Please try to edit and" + + "save the credential entry to synchronize the database, then again."; + break; + default: + throw new ArgumentException(errorResponse.Message); + } + } - try - { - var decryptedSymmetricKey = await JsInteropService.DecryptWithPrivateKey(mail.EncryptedSymmetricKey, privateKey.PrivateKey); - mail.Subject = await JsInteropService.SymmetricDecrypt(mail.Subject, Convert.ToBase64String(decryptedSymmetricKey)); - } - catch (Exception ex) - { - Error = ex.Message; - Console.WriteLine(ex); + break; + case HttpStatusCode.Unauthorized: + throw new UnauthorizedAccessException(errorResponse?.Message); + default: + throw new WebException(errorResponse?.Message); } } } @@ -258,6 +301,37 @@ } } + /// + /// Update the mailbox emails and decrypt the subject locally. + /// + private async Task UpdateMailboxEmails(MailboxApiModel? mailbox) + { + if (mailbox?.Mails != null) + { + // Show maximum of 10 recent emails. + MailboxEmails = mailbox.Mails.Take(10).ToList(); + } + + // Loop through emails and decrypt the subject locally. + var context = await DbService.GetDbContextAsync(); + var privateKeys = await context.EncryptionKeys.ToListAsync(); + foreach (var mail in MailboxEmails) + { + var privateKey = privateKeys.First(x => x.PublicKey == mail.EncryptionKey); + + try + { + var decryptedSymmetricKey = await JsInteropService.DecryptWithPrivateKey(mail.EncryptedSymmetricKey, privateKey.PrivateKey); + mail.Subject = await JsInteropService.SymmetricDecrypt(mail.Subject, Convert.ToBase64String(decryptedSymmetricKey)); + } + catch (Exception ex) + { + Error = ex.Message; + Console.WriteLine(ex); + } + } + } + /// /// Load recent emails from AliasVault. /// diff --git a/src/AliasVault.Client/Main/Components/Forms/CopyPastePasswordFormRow.razor b/src/AliasVault.Client/Main/Components/Forms/CopyPastePasswordFormRow.razor new file mode 100644 index 000000000..1fef43d2c --- /dev/null +++ b/src/AliasVault.Client/Main/Components/Forms/CopyPastePasswordFormRow.razor @@ -0,0 +1,80 @@ +@inject ClipboardCopyService ClipboardCopyService +@inject JsInteropService JsInteropService +@implements IDisposable + + +
+ + + @if (_copied) + { + + Copied! + + } +
+ +@code { + /// + /// The label for the input. + /// + [Parameter] + public string Label { get; set; } = "Password"; + + /// + /// The value to copy to the clipboard. + /// + [Parameter] + public string Value { get; set; } = string.Empty; + + private bool _copied => ClipboardCopyService.GetCopiedId() == _inputId; + private readonly string _inputId = Guid.NewGuid().ToString(); + private bool _isPasswordVisible = false; + + /// + protected override void OnInitialized() + { + ClipboardCopyService.OnCopy += HandleCopy; + } + + private async Task CopyToClipboard() + { + await JsInteropService.CopyToClipboard(Value); + ClipboardCopyService.SetCopied(_inputId); + + // After 2 seconds, reset the copied state if it's still the same element + await Task.Delay(2000); + if (ClipboardCopyService.GetCopiedId() == _inputId) + { + ClipboardCopyService.SetCopied(string.Empty); + } + } + + private void TogglePasswordVisibility() + { + _isPasswordVisible = !_isPasswordVisible; + } + + private void HandleCopy(string copiedElementId) + { + StateHasChanged(); + } + + /// + public void Dispose() + { + ClipboardCopyService.OnCopy -= HandleCopy; + } +} diff --git a/src/AliasVault.Client/Main/Components/Forms/EditEmailFormRow.razor b/src/AliasVault.Client/Main/Components/Forms/EditEmailFormRow.razor new file mode 100644 index 000000000..f64af69c2 --- /dev/null +++ b/src/AliasVault.Client/Main/Components/Forms/EditEmailFormRow.razor @@ -0,0 +1,186 @@ +@inject Config Config +@inject JsInteropService JsInteropService + + +
+
+ + @if (!IsCustomDomain) + { + + @@@SelectedDomain + + } +
+
+ +
+ @if (IsCustomDomain) + { + + } + else + { + + } +
+ +@if (IsPopupVisible) +{ +
+
+
+

Select Email Domain

+
+ @if (ShowPrivateDomains) + { +
+

Private Email (AliasVault server)

+

E2E encrypted, fully private.

+ @foreach (var domain in PrivateDomains) + { + + } +
+ } +
+

Public Temp Email Providers

+

Anonymous but limited privacy. Accessible to anyone that knows the address.

+ @foreach (var domain in PublicDomains) + { + + } +
+
+
+
+
+} + +@code { + /// + /// The id for the input field. + /// + [Parameter] + public string Id { get; set; } = string.Empty; + + /// + /// The label for the input field. + /// + [Parameter] + public string Label { get; set; } = "Email Address"; + + /// + /// The value of the input field. This should be the full email address. + /// + [Parameter] + public string Value { get; set; } = string.Empty; + + /// + /// Callback that is triggered when the value changes. + /// + [Parameter] + public EventCallback ValueChanged { get; set; } + + private bool IsCustomDomain { get; set; } = false; + private string LocalPart { get; set; } = string.Empty; + private string SelectedDomain = string.Empty; + private bool IsPopupVisible = false; + + private List PrivateDomains => Config.PrivateEmailDomains; + private List PublicDomains => Config.PublicEmailDomains; + + private bool ShowPrivateDomains => PrivateDomains.Count > 0 && !(PrivateDomains.Count == 1 && PrivateDomains[0] == "DISABLED.TLD"); + + /// + protected override void OnInitialized() + { + base.OnInitialized(); + + IsCustomDomain = !PublicDomains.Contains(SelectedDomain) && !PrivateDomains.Contains(SelectedDomain); + } + + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + if (Value.Contains('@')) + { + SelectedDomain = Value.Split('@')[1]; + } + else if (ShowPrivateDomains) + { + SelectedDomain = PrivateDomains[0]; + } + else + { + SelectedDomain = PublicDomains[0]; + } + + IsCustomDomain = !PublicDomains.Contains(SelectedDomain) && !PrivateDomains.Contains(SelectedDomain); + if (IsCustomDomain) + { + LocalPart = Value; + } + else + { + LocalPart = Value.Contains('@') ? Value.Split('@')[0] : Value; + } + } + + private async Task OnLocalPartChanged(ChangeEventArgs e) + { + string newLocalPart = e.Value?.ToString() ?? string.Empty; + + // Check if new value contains '@' symbol, if so, switch to custom domain mode. + if (newLocalPart.Contains('@')) + { + IsCustomDomain = true; + Value = newLocalPart; + await ValueChanged.InvokeAsync(Value); + return; + } + + Value = $"{newLocalPart}@{SelectedDomain}"; + await ValueChanged.InvokeAsync(Value); + } + + private void TogglePopup() + { + IsPopupVisible = !IsPopupVisible; + } + + private void ClosePopup() + { + IsPopupVisible = false; + } + + private async Task SelectDomain(string domain) + { + // Remove the '@' symbol and everything after if it exists. + LocalPart = LocalPart.Contains('@') ? LocalPart.Split('@')[0] : LocalPart; + Value = $"{LocalPart}@{domain}"; + await ValueChanged.InvokeAsync(Value); + IsCustomDomain = false; + ClosePopup(); + } + + private void ToggleCustomDomain() + { + IsCustomDomain = !IsCustomDomain; + if (!IsCustomDomain && !Value.Contains('@')) + { + Value = $"{Value}@{(ShowPrivateDomains ? PrivateDomains[0] : PublicDomains[0])}"; + ValueChanged.InvokeAsync(Value); + } + } +} diff --git a/src/AliasVault.Client/Main/Components/Forms/EditFormRow.razor b/src/AliasVault.Client/Main/Components/Forms/EditFormRow.razor index 0b4748785..ebfeccdc2 100644 --- a/src/AliasVault.Client/Main/Components/Forms/EditFormRow.razor +++ b/src/AliasVault.Client/Main/Components/Forms/EditFormRow.razor @@ -1,6 +1,4 @@ -@inject ClipboardCopyService ClipboardCopyService - - +
@if (Type == "textarea") { diff --git a/src/AliasVault.Client/Main/Pages/Credentials/AddEdit.razor b/src/AliasVault.Client/Main/Pages/Credentials/AddEdit.razor index d2e7b4987..8855a3026 100644 --- a/src/AliasVault.Client/Main/Pages/Credentials/AddEdit.razor +++ b/src/AliasVault.Client/Main/Pages/Credentials/AddEdit.razor @@ -92,7 +92,7 @@ else
- +
@@ -100,7 +100,7 @@ else
- +
@@ -240,6 +240,7 @@ else // Create new Obj var alias = new Credential(); alias.Alias = new Alias(); + alias.Alias.Email = string.Empty; alias.Service = new Service(); alias.Passwords = new List { new Password() }; @@ -280,7 +281,24 @@ else Obj.Alias.AddressZipCode = identity.Address.ZipCode; Obj.Alias.AddressCountry = identity.Address.Country; Obj.Alias.Hobbies = identity.Hobbies[0]; - Obj.Alias.Email = identity.EmailPrefix + "@landmail.nl"; + + var defaultDomain = Config.PrivateEmailDomains[0]; + if (defaultDomain == "DISABLED.TLD") + { + if (Config.PublicEmailDomains.Count == 0) + { + Obj.Alias.Email = identity.EmailPrefix + "@example.com"; + } + else + { + Obj.Alias.Email = identity.EmailPrefix + "@" + Config.PublicEmailDomains[0]; + } + } + else + { + Obj.Alias.Email = identity.EmailPrefix + "@" + defaultDomain; + } + Obj.Alias.PhoneMobile = identity.PhoneMobile; Obj.Alias.BankAccountIBAN = identity.BankAccountIBAN; diff --git a/src/AliasVault.Client/Main/Pages/Credentials/View.razor b/src/AliasVault.Client/Main/Pages/Credentials/View.razor index 63515cb8c..341c282f6 100644 --- a/src/AliasVault.Client/Main/Pages/Credentials/View.razor +++ b/src/AliasVault.Client/Main/Pages/Credentials/View.razor @@ -68,7 +68,7 @@ else
- +
diff --git a/src/AliasVault.Client/Main/Pages/MainBase.cs b/src/AliasVault.Client/Main/Pages/MainBase.cs index bd999bb84..ae1597ac0 100644 --- a/src/AliasVault.Client/Main/Pages/MainBase.cs +++ b/src/AliasVault.Client/Main/Pages/MainBase.cs @@ -66,6 +66,12 @@ public class MainBase : OwningComponentBase [Inject] public AuthService AuthService { get; set; } = null!; + /// + /// Gets or sets the Config instance with values from appsettings.json. + /// + [Inject] + public Config Config { get; set; } = null!; + /// /// Gets or sets the LocalStorage. /// diff --git a/src/AliasVault.Client/Program.cs b/src/AliasVault.Client/Program.cs index 176cb380b..576c3be60 100644 --- a/src/AliasVault.Client/Program.cs +++ b/src/AliasVault.Client/Program.cs @@ -23,9 +23,9 @@ if (string.IsNullOrEmpty(config.ApiUrl)) throw new KeyNotFoundException("ApiUrl is not set in the configuration."); } -if (config.SmtpAllowedDomains == null || config.SmtpAllowedDomains.Count == 0) +if (config.PrivateEmailDomains == null || config.PrivateEmailDomains.Count == 0) { - throw new KeyNotFoundException("SmtpAllowedDomains is not set in the configuration."); + throw new KeyNotFoundException("PrivateEmailDomains is not set in the configuration."); } builder.Services.AddSingleton(config); diff --git a/src/AliasVault.Client/Services/Database/DbService.cs b/src/AliasVault.Client/Services/Database/DbService.cs index b73447e91..7c610092e 100644 --- a/src/AliasVault.Client/Services/Database/DbService.cs +++ b/src/AliasVault.Client/Services/Database/DbService.cs @@ -458,11 +458,23 @@ public class DbService : IDisposable .Select(email => email!) .ToListAsync(); + Console.WriteLine("Before filtering email addresses:"); + foreach (var email in emailAddresses) + { + Console.WriteLine(email); + } + // Filter the list of email addresses to only include those that are in the allowed domains. emailAddresses = emailAddresses - .Where(email => _config.SmtpAllowedDomains.Exists(domain => email.EndsWith(domain))) + .Where(email => _config.PrivateEmailDomains.Exists(domain => email.EndsWith(domain))) .ToList(); + Console.WriteLine("After filtering email addresses:"); + foreach (var email in emailAddresses) + { + Console.WriteLine(email); + } + var databaseVersion = await GetCurrentDatabaseVersionAsync(); var vaultObject = new Vault(encryptedDatabase, databaseVersion, publicEncryptionKey, emailAddresses, DateTime.Now, DateTime.Now); diff --git a/src/AliasVault.Client/entrypoint.sh b/src/AliasVault.Client/entrypoint.sh index db89bc6a4..234f20e04 100755 --- a/src/AliasVault.Client/entrypoint.sh +++ b/src/AliasVault.Client/entrypoint.sh @@ -1,11 +1,11 @@ #!/bin/sh # Set the default API URL for localhost debugging DEFAULT_API_URL="http://localhost:81" -DEFAULT_SMTP_ALLOWED_DOMAINS="localmail.tld" +DEFAULT_PRIVATE_EMAIL_DOMAINS="localmail.tld" # Use the provided API_URL environment variable if it exists, otherwise use the default API_URL=${API_URL:-$DEFAULT_API_URL} -SMTP_ALLOWED_DOMAINS=${SMTP_ALLOWED_DOMAINS:-$DEFAULT_SMTP_ALLOWED_DOMAINS} +PRIVATE_EMAIL_DOMAINS=${PRIVATE_EMAIL_DOMAINS:-$DEFAULT_PRIVATE_EMAIL_DOMAINS} # Replace the default URL with the actual API URL sed -i "s|http://localhost:5092|${API_URL}|g" /usr/share/nginx/html/appsettings.json @@ -14,10 +14,10 @@ sed -i "s|http://localhost:5092|${API_URL}|g" /usr/share/nginx/html/appsettings. # in order to be able to receive emails. # Convert comma-separated list to JSON array -json_array=$(echo $SMTP_ALLOWED_DOMAINS | awk '{split($0,a,","); printf "["; for(i=1;i<=length(a);i++) {printf "\"%s\"", a[i]; if(i :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(0.5rem * var(--tw-space-x-reverse)); + margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); +} + .divide-y > :not([hidden]) ~ :not([hidden]) { --tw-divide-y-reverse: 0; border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); @@ -1053,6 +1149,10 @@ video { border-radius: 0.5rem; } +.rounded-md { + border-radius: 0.375rem; +} + .rounded-l-lg { border-top-left-radius: 0.5rem; border-bottom-left-radius: 0.5rem; @@ -1075,6 +1175,14 @@ video { border-bottom-width: 1px; } +.border-l-0 { + border-left-width: 0px; +} + +.border-t { + border-top-width: 1px; +} + .border-blue-700 { --tw-border-opacity: 1; border-color: rgb(29 78 216 / var(--tw-border-opacity)); @@ -1095,6 +1203,21 @@ video { border-color: rgb(34 197 94 / var(--tw-border-opacity)); } +.border-red-300 { + --tw-border-opacity: 1; + border-color: rgb(252 165 165 / var(--tw-border-opacity)); +} + +.border-primary-300 { + --tw-border-opacity: 1; + border-color: rgb(248 185 99 / var(--tw-border-opacity)); +} + +.border-primary-100 { + --tw-border-opacity: 1; + border-color: rgb(253 222 133 / var(--tw-border-opacity)); +} + .bg-blue-500 { --tw-bg-opacity: 1; background-color: rgb(59 130 246 / var(--tw-bg-opacity)); @@ -1120,6 +1243,11 @@ video { background-color: rgb(249 250 251 / var(--tw-bg-opacity)); } +.bg-gray-500 { + --tw-bg-opacity: 1; + background-color: rgb(107 114 128 / var(--tw-bg-opacity)); +} + .bg-gray-700 { --tw-bg-opacity: 1; background-color: rgb(55 65 81 / var(--tw-bg-opacity)); @@ -1145,11 +1273,6 @@ video { background-color: rgb(22 163 74 / var(--tw-bg-opacity)); } -.bg-primary-200 { - --tw-bg-opacity: 1; - background-color: rgb(251 203 116 / var(--tw-bg-opacity)); -} - .bg-primary-600 { --tw-bg-opacity: 1; background-color: rgb(214 131 56 / var(--tw-bg-opacity)); @@ -1175,9 +1298,19 @@ video { background-color: rgb(255 255 255 / var(--tw-bg-opacity)); } -.bg-gray-500 { +.bg-gray-300 { --tw-bg-opacity: 1; - background-color: rgb(107 114 128 / var(--tw-bg-opacity)); + background-color: rgb(209 213 219 / var(--tw-bg-opacity)); +} + +.bg-gray-600 { + --tw-bg-opacity: 1; + background-color: rgb(75 85 99 / var(--tw-bg-opacity)); +} + +.bg-primary-300 { + --tw-bg-opacity: 1; + background-color: rgb(248 185 99 / var(--tw-bg-opacity)); } .bg-opacity-50 { @@ -1212,6 +1345,10 @@ video { padding: 2rem; } +.p-5 { + padding: 1.25rem; +} + .px-2 { padding-left: 0.5rem; padding-right: 0.5rem; @@ -1267,10 +1404,19 @@ video { padding-bottom: 1.5rem; } +.px-7 { + padding-left: 1.75rem; + padding-right: 1.75rem; +} + .pr-10 { padding-right: 2.5rem; } +.pr-20 { + padding-right: 5rem; +} + .pr-3 { padding-right: 0.75rem; } @@ -1287,6 +1433,10 @@ video { padding-top: 2rem; } +.pt-4 { + padding-top: 1rem; +} + .text-left { text-align: left; } @@ -1565,6 +1715,16 @@ video { background-color: rgb(153 27 27 / var(--tw-bg-opacity)); } +.hover\:bg-gray-300:hover { + --tw-bg-opacity: 1; + background-color: rgb(209 213 219 / var(--tw-bg-opacity)); +} + +.hover\:bg-primary-400:hover { + --tw-bg-opacity: 1; + background-color: rgb(246 167 82 / var(--tw-bg-opacity)); +} + .hover\:text-gray-500:hover { --tw-text-opacity: 1; color: rgb(107 114 128 / var(--tw-text-opacity)); @@ -1595,6 +1755,11 @@ video { color: rgb(255 255 255 / var(--tw-text-opacity)); } +.hover\:text-blue-800:hover { + --tw-text-opacity: 1; + color: rgb(30 64 175 / var(--tw-text-opacity)); +} + .hover\:underline:hover { text-decoration-line: underline; } @@ -1615,6 +1780,12 @@ video { box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); } +.focus\:ring-2:focus { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + .focus\:ring-blue-300:focus { --tw-ring-opacity: 1; --tw-ring-color: rgb(147 197 253 / var(--tw-ring-opacity)); @@ -1650,6 +1821,20 @@ video { --tw-ring-color: rgb(252 165 165 / var(--tw-ring-opacity)); } +.focus\:ring-indigo-500:focus { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(99 102 241 / var(--tw-ring-opacity)); +} + +.focus\:ring-gray-400:focus { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(156 163 175 / var(--tw-ring-opacity)); +} + +.focus\:ring-offset-2:focus { + --tw-ring-offset-width: 2px; +} + .dark\:divide-gray-600:is(.dark *) > :not([hidden]) ~ :not([hidden]) { --tw-divide-opacity: 1; border-color: rgb(75 85 99 / var(--tw-divide-opacity)); @@ -1823,6 +2008,11 @@ video { background-color: rgb(220 38 38 / var(--tw-bg-opacity)); } +.dark\:hover\:bg-gray-500:hover:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(107 114 128 / var(--tw-bg-opacity)); +} + .dark\:hover\:text-primary-500:hover:is(.dark *) { --tw-text-opacity: 1; color: rgb(244 149 65 / var(--tw-text-opacity)); @@ -1833,6 +2023,11 @@ video { color: rgb(255 255 255 / var(--tw-text-opacity)); } +.dark\:hover\:text-blue-200:hover:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(191 219 254 / var(--tw-text-opacity)); +} + .dark\:focus\:border-primary-500:focus:is(.dark *) { --tw-border-opacity: 1; border-color: rgb(244 149 65 / var(--tw-border-opacity)); diff --git a/src/AliasVault.Shared/Models/WebApi/ApiErrorResponse.cs b/src/AliasVault.Shared/Models/WebApi/ApiErrorResponse.cs new file mode 100644 index 000000000..1c198517b --- /dev/null +++ b/src/AliasVault.Shared/Models/WebApi/ApiErrorResponse.cs @@ -0,0 +1,51 @@ +//----------------------------------------------------------------------- +// +// 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.Shared.Models.WebApi; + +using System.Text.Json.Serialization; + +/// +/// Represents the error response returned by the API. +/// +public class ApiErrorResponse +{ + /// + /// Gets or sets the main error message. + /// + /// A string containing a brief description of the error. + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + /// + /// Gets or sets the error code associated with this error. + /// + /// A string representing a unique identifier for this type of error. + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + /// + /// Gets or sets additional details about the error. + /// + /// An object containing any additional information about the error. + [JsonPropertyName("details")] + public object Details { get; set; } = new { }; + + /// + /// Gets or sets the HTTP status code associated with this error. + /// + /// An integer representing the HTTP status code. + [JsonPropertyName("statusCode")] + public int StatusCode { get; set; } = 500; + + /// + /// Gets or sets the timestamp when the error occurred. + /// + /// A DateTime representing when the error was generated. + [JsonPropertyName("timestamp")] + public DateTime Timestamp { get; set; } = DateTime.UtcNow; +} diff --git a/src/Services/AliasVault.SmtpService/Program.cs b/src/Services/AliasVault.SmtpService/Program.cs index 8d51dfb5a..e8bcb8cbf 100644 --- a/src/Services/AliasVault.SmtpService/Program.cs +++ b/src/Services/AliasVault.SmtpService/Program.cs @@ -26,8 +26,8 @@ builder.Services.ConfigureLogging(builder.Configuration, Assembly.GetExecutingAs // 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."); +var emailDomains = Environment.GetEnvironmentVariable("PRIVATE_EMAIL_DOMAINS") + ?? throw new KeyNotFoundException("PRIVATE_EMAIL_DOMAINS environment variable is not set."); config.AllowedToDomains = emailDomains.Split(',').ToList(); var tlsEnabled = Environment.GetEnvironmentVariable("SMTP_TLS_ENABLED") diff --git a/src/Services/AliasVault.SmtpService/Properties/launchSettings.json b/src/Services/AliasVault.SmtpService/Properties/launchSettings.json index 4f7c9c76e..834955beb 100644 --- a/src/Services/AliasVault.SmtpService/Properties/launchSettings.json +++ b/src/Services/AliasVault.SmtpService/Properties/launchSettings.json @@ -4,7 +4,7 @@ "commandName": "Project", "environmentVariables": { "DOTNET_ENVIRONMENT": "Development", - "SMTP_ALLOWED_DOMAINS": "example.tld", + "PRIVATE_EMAIL_DOMAINS": "example.tld", "SMTP_TLS_ENABLED": "false" }, "dotnetRunMessages": true diff --git a/src/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs b/src/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs index 214017721..aa70caaf8 100644 --- a/src/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs +++ b/src/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs @@ -89,7 +89,7 @@ public class ClientPlaywrightTest : PlaywrightTest await SetupPlaywrightBrowserAndContext(); // Intercept Blazor WASM app requests to override appsettings.json - string[] smtpAllowedDomains = ["example.tld"]; + string[] privateEmailDomains = ["example.tld"]; await Context.RouteAsync( "**/appsettings.json", @@ -98,7 +98,7 @@ public class ClientPlaywrightTest : PlaywrightTest var response = new { ApiUrl = ApiBaseUrl.TrimEnd('/'), - SmtpAllowedDomains = smtpAllowedDomains, + PrivateEmailDomains = privateEmailDomains, }; await route.FulfillAsync( new RouteFulfillOptions @@ -114,7 +114,7 @@ public class ClientPlaywrightTest : PlaywrightTest var response = new { ApiUrl = ApiBaseUrl.TrimEnd('/'), - SmtpAllowedDomains = smtpAllowedDomains, + PrivateEmailDomains = privateEmailDomains, }; await route.FulfillAsync( new RouteFulfillOptions