diff --git a/API/API.csproj b/API/API.csproj index 86e62986f..42e0d1107 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -101,6 +101,9 @@ + + + diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index afb8f9ba7..c96bffb5f 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net; -using System.Net.Sockets; using System.Reflection; using System.Threading.Tasks; using System.Web; @@ -17,8 +15,6 @@ using API.Errors; using API.Extensions; using API.Services; using AutoMapper; -using AutoMapper.QueryableExtensions; -using Flurl.Util; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; @@ -113,14 +109,6 @@ namespace API.Controllers ApiKey = HashUtil.ApiKey() }; - // I am removing Authentication disabled code - // var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - // if (!settings.EnableAuthentication && !registerDto.IsAdmin) - // { - // _logger.LogInformation("User {UserName} is being registered as non-admin with no server authentication. Using default password", registerDto.Username); - // registerDto.Password = AccountService.DefaultPassword; - // } - var result = await _userManager.CreateAsync(user, registerDto.Password); if (!result.Succeeded) return BadRequest(result.Errors); @@ -132,22 +120,6 @@ namespace API.Controllers var roleResult = await _userManager.AddToRoleAsync(user, PolicyConstants.AdminRole); if (!roleResult.Succeeded) return BadRequest(result.Errors); - // // When we register an admin, we need to grant them access to all Libraries. - // if (registerDto.IsAdmin) - // { - // _logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries", - // user.UserName); - // var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); - // foreach (var lib in libraries) - // { - // lib.AppUsers ??= new List(); - // lib.AppUsers.Add(user); - // } - // - // if (libraries.Any() && !await _unitOfWork.CommitAsync()) - // _logger.LogError("There was an issue granting library access. Please do this manually"); - // } - return new UserDto { Username = user.UserName, diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index ec6be6145..ee95328d4 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -4,14 +4,17 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using API.Data; +using API.DTOs.Email; using API.DTOs.Settings; using API.Entities.Enums; using API.Extensions; using API.Helpers.Converters; using API.Services; using AutoMapper; +using Flurl.Http; using Kavita.Common; using Kavita.Common.Extensions; +using Kavita.Common.Helpers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -25,15 +28,17 @@ namespace API.Controllers private readonly ITaskScheduler _taskScheduler; private readonly IDirectoryService _directoryService; private readonly IMapper _mapper; + private readonly IEmailService _emailService; public SettingsController(ILogger logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler, - IDirectoryService directoryService, IMapper mapper) + IDirectoryService directoryService, IMapper mapper, IEmailService emailService) { _logger = logger; _unitOfWork = unitOfWork; _taskScheduler = taskScheduler; _directoryService = directoryService; _mapper = mapper; + _emailService = emailService; } [AllowAnonymous] @@ -64,6 +69,36 @@ namespace API.Controllers return await UpdateSettings(_mapper.Map(Seed.DefaultSettings)); } + /// + /// Resets the email service url + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("reset-email-url")] + public async Task> ResetEmailServiceUrlSettings() + { + _logger.LogInformation("{UserName} is resetting Email Service Url Setting", User.GetUsername()); + var emailSetting = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl); + emailSetting.Value = EmailService.DefaultApiUrl; + _unitOfWork.SettingsRepository.Update(emailSetting); + + if (!await _unitOfWork.CommitAsync()) + { + await _unitOfWork.RollbackAsync(); + } + + return Ok(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()); + } + + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("test-email-url")] + public async Task> TestEmailServiceUrl(TestEmailDto dto) + { + return Ok(await _emailService.TestConnectivity(dto.Url)); + } + + + [Authorize(Policy = "RequireAdminRole")] [HttpPost] public async Task> UpdateSettings(ServerSettingDto updateSettingsDto) @@ -173,6 +208,15 @@ namespace API.Controllers await _taskScheduler.ScheduleStatsTasks(); } } + + if (setting.Key == ServerSettingKey.EmailServiceUrl && updateSettingsDto.EmailServiceUrl + string.Empty != setting.Value) + { + setting.Value = string.IsNullOrEmpty(updateSettingsDto.EmailServiceUrl) ? EmailService.DefaultApiUrl : updateSettingsDto.EmailServiceUrl; + FlurlHttp.ConfigureClient(setting.Value, cli => + cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + + _unitOfWork.SettingsRepository.Update(setting); + } } if (!_unitOfWork.HasChanges()) return Ok(updateSettingsDto); diff --git a/API/DTOs/Email/TestEmailDto.cs b/API/DTOs/Email/TestEmailDto.cs new file mode 100644 index 000000000..dba9d05f0 --- /dev/null +++ b/API/DTOs/Email/TestEmailDto.cs @@ -0,0 +1,6 @@ +namespace API.DTOs.Email; + +public class TestEmailDto +{ + public string Url { get; set; } +} diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index e8abd2b74..03f853d33 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -32,5 +32,10 @@ namespace API.DTOs.Settings /// /// If null or empty string, will default back to default install setting aka public string BookmarksDirectory { get; set; } + /// + /// Email service to use for the invite user flow, forgot password, etc. + /// + /// If null or empty string, will default back to default install setting aka + public string EmailServiceUrl { get; set; } } } diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index b7590e168..131c0c1ec 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -60,6 +60,7 @@ namespace API.Data new () {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()}, new () {Key = ServerSettingKey.InstallVersion, Value = BuildInfo.Version.ToString()}, new () {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory}, + new () {Key = ServerSettingKey.EmailServiceUrl, Value = EmailService.DefaultApiUrl}, }; foreach (var defaultSetting in DefaultSettings) diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index 809115da9..1a1ab8073 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -71,6 +71,10 @@ namespace API.Entities.Enums /// [Description("BookmarkDirectory")] BookmarkDirectory = 12, - + /// + /// If SMTP is enabled on the server + /// + [Description("CustomEmailService")] + EmailServiceUrl = 13, } } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index edc3cf4e9..f5f440275 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -39,7 +39,6 @@ namespace API.Extensions services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 1c2426ae4..e1cdce7ad 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -2,6 +2,7 @@ using System.Linq; using API.DTOs; using API.DTOs.CollectionTags; +using API.DTOs.Email; using API.DTOs.Metadata; using API.DTOs.Reader; using API.DTOs.ReadingLists; @@ -148,7 +149,6 @@ namespace API.Helpers CreateMap, ServerSettingDto>() .ConvertUsing(); - } } } diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index 77ce9891f..31ea46d4b 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -42,6 +42,9 @@ namespace API.Helpers.Converters case ServerSettingKey.BookmarkDirectory: destination.BookmarksDirectory = row.Value; break; + case ServerSettingKey.EmailServiceUrl: + destination.EmailServiceUrl = row.Value; + break; } } diff --git a/API/Services/EmailService.cs b/API/Services/EmailService.cs index a9412671c..fe2aa64a6 100644 --- a/API/Services/EmailService.cs +++ b/API/Services/EmailService.cs @@ -1,10 +1,11 @@ using System; -using System.Net.Http; using System.Threading.Tasks; +using API.Data; using API.DTOs.Email; -using API.Services.Tasks; +using API.Entities.Enums; using Flurl.Http; using Kavita.Common.EnvironmentInfo; +using Kavita.Common.Helpers; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -16,25 +17,39 @@ public interface IEmailService Task CheckIfAccessible(string host); Task SendMigrationEmail(EmailMigrationDto data); Task SendPasswordResetEmail(PasswordResetEmailDto data); + Task TestConnectivity(string emailUrl); } public class EmailService : IEmailService { private readonly ILogger _logger; - private const string ApiUrl = "https://email.kavitareader.com"; + private readonly IUnitOfWork _unitOfWork; - public EmailService(ILogger logger) + /// + /// This is used to initially set or reset the ServerSettingKey. Do not access from the code, access via UnitOfWork + /// + public const string DefaultApiUrl = "https://email.kavitareader.com"; + + public EmailService(ILogger logger, IUnitOfWork unitOfWork) { _logger = logger; + _unitOfWork = unitOfWork; - FlurlHttp.ConfigureClient(ApiUrl, cli => + FlurlHttp.ConfigureClient(DefaultApiUrl, cli => cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); } + public async Task TestConnectivity(string emailUrl) + { + FlurlHttp.ConfigureClient(emailUrl, cli => + cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + + return await SendEmailWithGet(emailUrl + "/api/email/test"); + } + public async Task SendConfirmationEmail(ConfirmationEmailDto data) { - - var success = await SendEmailWithPost(ApiUrl + "/api/email/confirm", data); + var success = await SendEmailWithPost(DefaultApiUrl + "/api/email/confirm", data); if (!success) { _logger.LogError("There was a critical error sending Confirmation email"); @@ -43,17 +58,20 @@ public class EmailService : IEmailService public async Task CheckIfAccessible(string host) { - return await SendEmailWithGet(ApiUrl + "/api/email/reachable?host=" + host); + // This is the only exception for using the default because we need an external service to check if the server is accessible for emails + return await SendEmailWithGet(DefaultApiUrl + "/api/email/reachable?host=" + host); } public async Task SendMigrationEmail(EmailMigrationDto data) { - await SendEmailWithPost(ApiUrl + "/api/email/email-migration", data); + var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value; + await SendEmailWithPost(emailLink + "/api/email/email-migration", data); } public async Task SendPasswordResetEmail(PasswordResetEmailDto data) { - await SendEmailWithPost(ApiUrl + "/api/email/email-password-reset", data); + var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value; + await SendEmailWithPost(emailLink + "/api/email/email-password-reset", data); } private static async Task SendEmailWithGet(string url) @@ -106,4 +124,5 @@ public class EmailService : IEmailService } return true; } + } diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index 1b9f25593..a9d233e2a 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -7,6 +7,7 @@ using API.DTOs.Stats; using API.Entities.Enums; using Flurl.Http; using Kavita.Common.EnvironmentInfo; +using Kavita.Common.Helpers; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index 178111051..4b341ba36 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -1,14 +1,13 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net.Http; using System.Threading.Tasks; using API.DTOs.Update; using API.SignalR; using API.SignalR.Presence; using Flurl.Http; -using Flurl.Http.Configuration; using Kavita.Common.EnvironmentInfo; +using Kavita.Common.Helpers; using MarkdownDeep; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Hosting; @@ -44,15 +43,6 @@ internal class GithubReleaseMetadata public string Published_At { get; init; } } -public class UntrustedCertClientFactory : DefaultHttpClientFactory -{ - public override HttpMessageHandler CreateMessageHandler() { - return new HttpClientHandler { - ServerCertificateCustomValidationCallback = (_, _, _, _) => true - }; - } -} - public interface IVersionUpdaterService { Task CheckForUpdate(); diff --git a/Kavita.Common/Helpers/UntrustedCertClientFactory.cs b/Kavita.Common/Helpers/UntrustedCertClientFactory.cs new file mode 100644 index 000000000..6ddb2a9f3 --- /dev/null +++ b/Kavita.Common/Helpers/UntrustedCertClientFactory.cs @@ -0,0 +1,13 @@ +using System.Net.Http; +using Flurl.Http.Configuration; + +namespace Kavita.Common.Helpers; + +public class UntrustedCertClientFactory : DefaultHttpClientFactory +{ + public override HttpMessageHandler CreateMessageHandler() { + return new HttpClientHandler { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }; + } +} diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 31240b072..38f8f6b83 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -9,6 +9,7 @@ + diff --git a/Kavita.Email/Kavita.Email.csproj b/Kavita.Email/Kavita.Email.csproj new file mode 100644 index 000000000..5a9557890 --- /dev/null +++ b/Kavita.Email/Kavita.Email.csproj @@ -0,0 +1,22 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/UI/Web/src/app/admin/_models/server-settings.ts b/UI/Web/src/app/admin/_models/server-settings.ts index 95efc5aa7..101434a43 100644 --- a/UI/Web/src/app/admin/_models/server-settings.ts +++ b/UI/Web/src/app/admin/_models/server-settings.ts @@ -8,4 +8,5 @@ export interface ServerSettings { enableOpds: boolean; baseUrl: string; bookmarksDirectory: string; + emailServiceUrl: string; } diff --git a/UI/Web/src/app/admin/invite-user/invite-user.component.ts b/UI/Web/src/app/admin/invite-user/invite-user.component.ts index e8cebcb19..b24c545fa 100644 --- a/UI/Web/src/app/admin/invite-user/invite-user.component.ts +++ b/UI/Web/src/app/admin/invite-user/invite-user.component.ts @@ -1,6 +1,5 @@ import { Component, OnInit } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { ConfirmService } from 'src/app/shared/confirm.service'; import { Library } from 'src/app/_models/library'; diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html index a2daf69f6..ad37eba12 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html @@ -64,6 +64,28 @@ + +

Email Services (SMTP)

+

Kavita comes out of the box with an email service to power flows like invite user, forgot password, etc. Emails sent via our service are deleted immediately. You can use your own + email service. Set the url of the email service and use the Test button to ensure it works. At any time you can reset to ours. There is no way to disable emails. +

+
+   + Location where bookmarks will be stored. Bookmarks are source files and can be large. Choose a location with adequate storage. Directory is managed, other files within directory will be deleted. + +
+ +
+ + +
+
+
+

Reoccuring Tasks

diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts b/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts index acc336689..7073b15ea 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts @@ -41,6 +41,7 @@ export class ManageSettingsComponent implements OnInit { this.settingsForm.addControl('allowStatCollection', new FormControl(this.serverSettings.allowStatCollection, [Validators.required])); this.settingsForm.addControl('enableOpds', new FormControl(this.serverSettings.enableOpds, [Validators.required])); this.settingsForm.addControl('baseUrl', new FormControl(this.serverSettings.baseUrl, [Validators.required])); + this.settingsForm.addControl('emailServiceUrl', new FormControl(this.serverSettings.emailServiceUrl, [Validators.required])); }); } @@ -54,6 +55,7 @@ export class ManageSettingsComponent implements OnInit { this.settingsForm.get('allowStatCollection')?.setValue(this.serverSettings.allowStatCollection); this.settingsForm.get('enableOpds')?.setValue(this.serverSettings.enableOpds); this.settingsForm.get('baseUrl')?.setValue(this.serverSettings.baseUrl); + this.settingsForm.get('emailServiceUrl')?.setValue(this.serverSettings.emailServiceUrl); } async saveSettings() { @@ -90,4 +92,28 @@ export class ManageSettingsComponent implements OnInit { }); } + resetEmailServiceUrl() { + this.settingsService.resetEmailServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => { + this.serverSettings.emailServiceUrl = settings.emailServiceUrl; + this.resetForm(); + this.toastr.success('Email Service Reset'); + }, (err: any) => { + console.error('error: ', err); + }); + } + + testEmailServiceUrl() { + this.settingsService.testEmailServerSettings(this.settingsForm.get('emailServiceUrl')?.value || '').pipe(take(1)).subscribe(async (successful: boolean) => { + if (successful) { + this.toastr.success('Email Service Url validated'); + } else { + this.toastr.error('Email Service Url did not respond'); + } + + }, (err: any) => { + console.error('error: ', err); + }); + + } + } diff --git a/UI/Web/src/app/admin/settings.service.ts b/UI/Web/src/app/admin/settings.service.ts index 92ae8b5c7..01b7da5f1 100644 --- a/UI/Web/src/app/admin/settings.service.ts +++ b/UI/Web/src/app/admin/settings.service.ts @@ -25,6 +25,14 @@ export class SettingsService { return this.http.post(this.baseUrl + 'settings/reset', {}); } + resetEmailServerSettings() { + return this.http.post(this.baseUrl + 'settings/reset-email-url', {}); + } + + testEmailServerSettings(emailUrl: string) { + return this.http.post(this.baseUrl + 'settings/test-email-url', {url: emailUrl}, {responseType: 'text' as 'json'}); + } + getTaskFrequencies() { return this.http.get(this.baseUrl + 'settings/task-frequencies'); }