diff --git a/API/API.csproj b/API/API.csproj index f43c56602..38c433d19 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -53,6 +53,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -190,6 +191,9 @@ + + Always + diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index c220eb6c0..0c51391bd 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -334,7 +334,9 @@ public class AccountController : BaseApiController /// - /// Initiates the flow to update a user's email address. The email address is not changed in this API. A confirmation link is sent/dumped which will + /// Initiates the flow to update a user's email address. + /// + /// If email is not setup, then the email address is not changed in this API. A confirmation link is sent/dumped which will /// validate the email. It must be confirmed for the email to update. /// /// @@ -374,10 +376,22 @@ public class AccountController : BaseApiController return BadRequest(await _localizationService.Translate(User.GetUserId(), "generate-token")); } - user.EmailConfirmed = false; + var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var shouldEmailUser = serverSettings.IsEmailSetup() || !_emailService.IsValidEmail(user.Email); + user.EmailConfirmed = !shouldEmailUser; user.ConfirmationToken = token; await _userManager.UpdateAsync(user); + if (!shouldEmailUser) + { + return Ok(new InviteUserResponse + { + EmailLink = string.Empty, + EmailSent = false + }); + } + + // Send a confirmation email try { @@ -396,30 +410,27 @@ public class AccountController : BaseApiController } - var accessible = await _accountService.CheckIfAccessible(Request); - if (accessible) + try { - try + var invitingUser = (await _unitOfWork.UserRepository.GetAdminUsersAsync()).First().UserName!; + // Email the old address of the update change + BackgroundJob.Enqueue(() => _emailService.SendEmailChangeEmail(new ConfirmationEmailDto() { - // Email the old address of the update change - await _emailService.SendEmailChangeEmail(new ConfirmationEmailDto() - { - EmailAddress = string.IsNullOrEmpty(user.Email) ? dto.Email : user.Email, - InstallId = BuildInfo.Version.ToString(), - InvitingUser = (await _unitOfWork.UserRepository.GetAdminUsersAsync()).First().UserName!, - ServerConfirmationLink = emailLink - }); - } - catch (Exception) - { - /* Swallow exception */ - } + EmailAddress = string.IsNullOrEmpty(user.Email) ? dto.Email : user.Email, + InstallId = BuildInfo.Version.ToString(), + InvitingUser = invitingUser, + ServerConfirmationLink = emailLink + })); + } + catch (Exception) + { + /* Swallow exception */ } return Ok(new InviteUserResponse { EmailLink = string.Empty, - EmailSent = accessible + EmailSent = true }); } catch (Exception ex) @@ -579,8 +590,7 @@ public class AccountController : BaseApiController /// - /// Invites a user to the server. Will generate a setup link for continuing setup. If the server is not accessible, no - /// email will be sent. + /// Invites a user to the server. Will generate a setup link for continuing setup. If email is not setup, a link will be presented to user to continue setup. /// /// /// @@ -679,15 +689,15 @@ public class AccountController : BaseApiController return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-invite-user")); } - try { var emailLink = await _accountService.GenerateEmailLink(Request, user.ConfirmationToken, "confirm-email", dto.Email); _logger.LogCritical("[Invite User]: Email Link for {UserName}: {Link}", user.UserName, emailLink); - if (!_emailService.IsValidEmail(dto.Email)) + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (!_emailService.IsValidEmail(dto.Email) || !settings.IsEmailSetup()) { - _logger.LogInformation("[Invite User] {Email} doesn't appear to be an email, so will not send an email to address", dto.Email.Replace(Environment.NewLine, string.Empty)); + _logger.LogInformation("[Invite User] {Email} doesn't appear to be an email or email is not setup", dto.Email.Replace(Environment.NewLine, string.Empty)); return Ok(new InviteUserResponse { EmailLink = emailLink, @@ -696,22 +706,17 @@ public class AccountController : BaseApiController }); } - var accessible = await _accountService.CheckIfAccessible(Request); - if (accessible) + BackgroundJob.Enqueue(() => _emailService.SendInviteEmail(new ConfirmationEmailDto() { - // Do the email send on a background thread to ensure UI can move forward without having to wait for a timeout when users use fake emails - BackgroundJob.Enqueue(() => _emailService.SendConfirmationEmail(new ConfirmationEmailDto() - { - EmailAddress = dto.Email, - InvitingUser = adminUser.UserName, - ServerConfirmationLink = emailLink - })); - } + EmailAddress = dto.Email, + InvitingUser = adminUser.UserName, + ServerConfirmationLink = emailLink + })); return Ok(new InviteUserResponse { EmailLink = emailLink, - EmailSent = accessible + EmailSent = true }); } catch (Exception ex) @@ -837,7 +842,6 @@ public class AccountController : BaseApiController await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate, MessageFactory.UserUpdateEvent(user.Id, user.UserName!), user.Id); - // Perform Login code return Ok(); } @@ -882,6 +886,10 @@ public class AccountController : BaseApiController [EnableRateLimiting("Authentication")] public async Task> ForgotPassword([FromQuery] string email) { + + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (!settings.IsEmailSetup()) return Ok(await _localizationService.Get("en", "email-not-enabled")); + var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email); if (user == null) { @@ -896,26 +904,28 @@ public class AccountController : BaseApiController if (string.IsNullOrEmpty(user.Email) || !user.EmailConfirmed) return BadRequest(await _localizationService.Translate(user.Id, "confirm-email")); - var token = await _userManager.GeneratePasswordResetTokenAsync(user); - var emailLink = await _accountService.GenerateEmailLink(Request, token, "confirm-reset-password", user.Email); - _logger.LogCritical("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink); if (!_emailService.IsValidEmail(user.Email)) { - _logger.LogCritical("[Forgot Password]: User is trying to do a forgot password flow, but their email ({Email}) isn't valid. No email will be send", user.Email); + _logger.LogCritical("[Forgot Password]: User is trying to do a forgot password flow, but their email ({Email}) isn't valid. No email will be send. Admin must change it in UI", user.Email); return Ok(await _localizationService.Translate(user.Id, "invalid-email")); } - if (await _accountService.CheckIfAccessible(Request)) - { - await _emailService.SendPasswordResetEmail(new PasswordResetEmailDto() - { - EmailAddress = user.Email, - ServerConfirmationLink = emailLink, - InstallId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value - }); - return Ok(await _localizationService.Translate(user.Id, "email-sent")); - } - return Ok(await _localizationService.Translate(user.Id, "not-accessible-password")); + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + var emailLink = await _accountService.GenerateEmailLink(Request, token, "confirm-reset-password", user.Email); + user.ConfirmationToken = token; + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + _logger.LogCritical("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink); + + var installId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value; + BackgroundJob.Enqueue(() => _emailService.SendForgotPasswordEmail(new PasswordResetEmailDto() + { + EmailAddress = user.Email, + ServerConfirmationLink = emailLink, + InstallId = installId + })); + + return Ok(await _localizationService.Translate(user.Id, "email-sent")); } [HttpGet("email-confirmed")] @@ -965,7 +975,7 @@ public class AccountController : BaseApiController /// [HttpPost("resend-confirmation-email")] [EnableRateLimiting("Authentication")] - public async Task> ResendConfirmationSendEmail([FromQuery] int userId) + public async Task> ResendConfirmationSendEmail([FromQuery] int userId) { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (user == null) return BadRequest(await _localizationService.Get("en", "no-user")); @@ -976,96 +986,47 @@ public class AccountController : BaseApiController if (user.EmailConfirmed) return BadRequest(await _localizationService.Translate(user.Id, "user-already-confirmed")); var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); - var emailLink = await _accountService.GenerateEmailLink(Request, token, "confirm-email", user.Email); + user.ConfirmationToken = token; + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + var emailLink = await _accountService.GenerateEmailLink(Request, token, "confirm-email-update", user.Email); _logger.LogCritical("[Email Migration]: Email Link for {UserName}: {Link}", user.UserName, emailLink); if (!_emailService.IsValidEmail(user.Email)) { _logger.LogCritical("[Email Migration]: User {UserName} is trying to resend an invite flow, but their email ({Email}) isn't valid. No email will be send", user.UserName, user.Email); - return BadRequest(await _localizationService.Translate(user.Id, "invalid-email")); } - if (await _accountService.CheckIfAccessible(Request)) + + var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var shouldEmailUser = serverSettings.IsEmailSetup() || !_emailService.IsValidEmail(user.Email); + + if (!shouldEmailUser) { - try + return Ok(new InviteUserResponse() { - await _emailService.SendMigrationEmail(new EmailMigrationDto() - { - EmailAddress = user.Email!, - Username = user.UserName!, - ServerConfirmationLink = emailLink, - InstallId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an issue resending invite email"); - return BadRequest(await _localizationService.Translate(user.Id, "generic-invite-email")); - } - return Ok(emailLink); + EmailLink = emailLink, + EmailSent = false, + InvalidEmail = !_emailService.IsValidEmail(user.Email) + }); } - return BadRequest(await _localizationService.Translate(user.Id, "not-accessible")); + BackgroundJob.Enqueue(() => _emailService.SendInviteEmail(new ConfirmationEmailDto() + { + EmailAddress = user.Email!, + InvitingUser = User.GetUsername(), + ServerConfirmationLink = emailLink, + InstallId = serverSettings.InstallId + })); + + return Ok(new InviteUserResponse() + { + EmailLink = emailLink, + EmailSent = true, + InvalidEmail = !_emailService.IsValidEmail(user.Email) + }); } - /// - /// This is similar to invite. Essentially we authenticate the user's password then go through invite email flow - /// - /// - /// - [AllowAnonymous] - [HttpPost("migrate-email")] - public async Task> MigrateEmail(MigrateUserEmailDto dto) - { - // If there is an admin account already, return - var users = await _unitOfWork.UserRepository.GetAdminUsersAsync(); - if (users.Any()) return BadRequest(await _localizationService.Get("en", "admin-already-exists")); - - // Check if there is an existing invite - var emailValidationErrors = await _accountService.ValidateEmail(dto.Email); - if (emailValidationErrors.Any()) - { - var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); - if (await _userManager.IsEmailConfirmedAsync(invitedUser!)) - return BadRequest(await _localizationService.Get("en", "user-already-registered", invitedUser!.UserName)); - - _logger.LogInformation("A user is attempting to login, but hasn't accepted email invite"); - return BadRequest(await _localizationService.Get("en", "user-already-invited")); - } - - - var user = await _userManager.Users - .Include(u => u.UserPreferences) - .SingleOrDefaultAsync(x => x.NormalizedUserName == dto.Username.ToUpper()); - if (user == null) return BadRequest(await _localizationService.Get("en", "invalid-username")); - - var validPassword = await _signInManager.UserManager.CheckPasswordAsync(user, dto.Password); - if (!validPassword) return BadRequest(await _localizationService.Get("en", "bad-credentials")); - - try - { - var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); - - user.Email = dto.Email; - if (!await ConfirmEmailToken(token, user)) return BadRequest(await _localizationService.Get("en", "critical-email-migration")); - _unitOfWork.UserRepository.Update(user); - - await _unitOfWork.CommitAsync(); - - return Ok(); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an issue during email migration. Contact support"); - _unitOfWork.UserRepository.Delete(user); - await _unitOfWork.CommitAsync(); - } - - return BadRequest(await _localizationService.Get("en", "critical-email-migration")); - } - - - private async Task ConfirmEmailToken(string token, AppUser user) { var result = await _userManager.ConfirmEmailAsync(user, token); diff --git a/API/Controllers/DeviceController.cs b/API/Controllers/DeviceController.cs index 3e1b57fec..c25b392e6 100644 --- a/API/Controllers/DeviceController.cs +++ b/API/Controllers/DeviceController.cs @@ -92,18 +92,28 @@ public class DeviceController : BaseApiController return Ok(await _unitOfWork.DeviceRepository.GetDevicesForUserAsync(User.GetUserId())); } + /// + /// Sends a collection of chapters to the user's device + /// + /// + /// [HttpPost("send-to")] public async Task SendToDevice(SendToDeviceDto dto) { if (dto.ChapterIds.Any(i => i < 0)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "ChapterIds")); if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "DeviceId")); - if (await _emailService.IsDefaultEmailService()) + var isEmailSetup = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetup(); + if (!isEmailSetup) return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email")); + // // Validate that the device belongs to the user + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Devices); + if (user == null || user.Devices.All(d => d.Id != dto.DeviceId)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-unallowed")); + var userId = User.GetUserId(); await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress, - MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"), + MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(userId, "send-to-device-status"), "started"), userId); try { @@ -112,16 +122,16 @@ public class DeviceController : BaseApiController } catch (KavitaException ex) { - return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); + return BadRequest(await _localizationService.Translate(userId, ex.Message)); } finally { await _eventHub.SendMessageToAsync(MessageFactory.SendingToDevice, - MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"), + MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(userId, "send-to-device-status"), "ended"), userId); } - return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-send-to")); + return BadRequest(await _localizationService.Translate(userId, "generic-send-to")); } diff --git a/API/Controllers/PluginController.cs b/API/Controllers/PluginController.cs index ff33cf8e1..ce2e4eced 100644 --- a/API/Controllers/PluginController.cs +++ b/API/Controllers/PluginController.cs @@ -14,19 +14,9 @@ namespace API.Controllers; #nullable enable -public class PluginController : BaseApiController +public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService, ILogger logger) + : BaseApiController { - private readonly IUnitOfWork _unitOfWork; - private readonly ITokenService _tokenService; - private readonly ILogger _logger; - - public PluginController(IUnitOfWork unitOfWork, ITokenService tokenService, ILogger logger) - { - _unitOfWork = unitOfWork; - _tokenService = tokenService; - _logger = logger; - } - /// /// Authenticate with the Server given an apiKey. This will log you in by returning the user object and the JWT token. /// @@ -42,11 +32,11 @@ public class PluginController : BaseApiController // NOTE: In order to log information about plugins, we need some Plugin Description information for each request // Should log into access table so we can tell the user var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); - var userAgent = HttpContext.Request.Headers["User-Agent"]; - var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + var userAgent = HttpContext.Request.Headers.UserAgent; + var userId = await unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); if (userId <= 0) { - _logger.LogInformation("A Plugin ({PluginName}) tried to authenticate with an apiKey that doesn't match. Information {@Information}", pluginName.Replace(Environment.NewLine, string.Empty), new + logger.LogInformation("A Plugin ({PluginName}) tried to authenticate with an apiKey that doesn't match. Information {@Information}", pluginName.Replace(Environment.NewLine, string.Empty), new { IpAddress = ipAddress, UserAgent = userAgent, @@ -54,15 +44,15 @@ public class PluginController : BaseApiController }); throw new KavitaUnauthenticatedUserException(); } - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - _logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId); + var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId); + logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId); return new UserDto { Username = user.UserName!, - Token = await _tokenService.CreateToken(user), - RefreshToken = await _tokenService.CreateRefreshToken(user), + Token = await tokenService.CreateToken(user), + RefreshToken = await tokenService.CreateRefreshToken(user), ApiKey = user.ApiKey, - KavitaVersion = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value + KavitaVersion = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value }; } @@ -76,8 +66,8 @@ public class PluginController : BaseApiController [HttpGet("version")] public async Task> GetVersion([Required] string apiKey) { - var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + var userId = await unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); if (userId <= 0) throw new KavitaUnauthenticatedUserException(); - return Ok((await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value); + return Ok((await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value); } } diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index ba4258531..c5da8e497 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -275,22 +275,6 @@ public class ServerController : BaseApiController return Ok(); } - /// - /// Returns the KavitaEmail version for non-default instances - /// - /// - [Authorize("RequireAdminRole")] - [HttpGet("email-version")] - public async Task> GetEmailVersion() - { - var emailServiceUrl = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)) - .Value; - - if (emailServiceUrl.Equals(EmailService.DefaultApiUrl)) return Ok(null); - - return Ok(await _emailService.GetVersion(emailServiceUrl)); - } - /// /// Checks for updates and pushes an event to the UI /// @@ -301,5 +285,4 @@ public class ServerController : BaseApiController await _taskScheduler.CheckForUpdate(); return Ok(); } - } diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 6277d709b..2e30fa09d 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using API.Data; using API.DTOs.Email; using API.DTOs.Settings; +using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Helpers.Converters; @@ -119,28 +120,7 @@ public class SettingsController : BaseApiController } /// - /// 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()); - } - - /// - /// Sends a test email from the Email Service. Will not send if email service is the Default Provider + /// Sends a test email from the Email Service. /// /// /// @@ -149,8 +129,19 @@ public class SettingsController : BaseApiController public async Task> TestEmailServiceUrl(TestEmailDto dto) { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId()); - var emailService = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value; - return Ok(await _emailService.TestConnectivity(dto.Url, user!.Email, !emailService.Equals(EmailService.DefaultApiUrl))); + return Ok(await _emailService.SendTestEmail(user!.Email)); + } + + /// + /// Is the minimum information setup for Email to work + /// + /// + [Authorize] + [HttpGet("is-email-setup")] + public async Task> IsEmailSetup() + { + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + return Ok(settings.IsEmailSetup()); } @@ -233,6 +224,10 @@ public class SettingsController : BaseApiController _unitOfWork.SettingsRepository.Update(setting); } + UpdateEmailSettings(setting, updateSettingsDto); + + + if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value) { if (OsInfo.IsDocker) continue; @@ -289,17 +284,6 @@ public class SettingsController : BaseApiController _unitOfWork.SettingsRepository.Update(setting); } - if (setting.Key == ServerSettingKey.EmailServiceUrl && updateSettingsDto.EmailServiceUrl + string.Empty != setting.Value) - { - setting.Value = string.IsNullOrEmpty(updateSettingsDto.EmailServiceUrl) ? EmailService.DefaultApiUrl : updateSettingsDto.EmailServiceUrl; - setting.Value = UrlHelper.RemoveEndingSlash(setting.Value); - FlurlHttp.ConfigureClient(setting.Value, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); - - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value) { // Validate new directory can be used @@ -392,6 +376,63 @@ public class SettingsController : BaseApiController return Ok(updateSettingsDto); } + private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) + { + if (setting.Key == ServerSettingKey.EmailHost && updateSettingsDto.SmtpConfig.Host + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.Host + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailPort && updateSettingsDto.SmtpConfig.Port + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.Port + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailAuthPassword && updateSettingsDto.SmtpConfig.Password + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.Password + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailAuthUserName && updateSettingsDto.SmtpConfig.UserName + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.UserName + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailSenderAddress && updateSettingsDto.SmtpConfig.SenderAddress + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.SenderAddress + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailSenderDisplayName && updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailSizeLimit && updateSettingsDto.SmtpConfig.SizeLimit + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.SizeLimit + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailEnableSsl && updateSettingsDto.SmtpConfig.EnableSsl + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.EnableSsl + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailCustomizedTemplates && updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + } + [Authorize(Policy = "RequireAdminRole")] [HttpGet("task-frequencies")] public ActionResult> GetTaskFrequencies() diff --git a/API/DTOs/Email/EmailTestResultDto.cs b/API/DTOs/Email/EmailTestResultDto.cs index 6659e3a45..263e725c4 100644 --- a/API/DTOs/Email/EmailTestResultDto.cs +++ b/API/DTOs/Email/EmailTestResultDto.cs @@ -7,4 +7,5 @@ public class EmailTestResultDto { public bool Successful { get; set; } public string ErrorMessage { get; set; } = default!; + public string EmailAddress { get; set; } = default!; } diff --git a/API/DTOs/Settings/SMTPConfigDto.cs b/API/DTOs/Settings/SMTPConfigDto.cs new file mode 100644 index 000000000..07cc58cb8 --- /dev/null +++ b/API/DTOs/Settings/SMTPConfigDto.cs @@ -0,0 +1,20 @@ +namespace API.DTOs.Settings; + +public class SmtpConfigDto +{ + public string SenderAddress { get; set; } = string.Empty; + public string SenderDisplayName { get; set; } = string.Empty; + public string UserName { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string Host { get; set; } = string.Empty; + public int Port { get; set; } = 0; + public bool EnableSsl { get; set; } = true; + /// + /// Limit in bytes for allowing files to be added as attachments. Defaults to 25MB + /// + public int SizeLimit { get; set; } = 26_214_400; + /// + /// Should Kavita use config/templates for Email templates or the default ones + /// + public bool CustomizedTemplates { get; set; } = false; +} diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index e405758bc..27b3db862 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -38,11 +38,6 @@ public class ServerSettingDto /// /// If null or empty string, will default back to default install setting aka public string BookmarksDirectory { get; set; } = default!; - /// - /// 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; } = default!; public string InstallVersion { get; set; } = default!; /// /// Represents a unique Id to this Kavita installation. Only used in Stats to identify unique installs. @@ -88,4 +83,20 @@ public class ServerSettingDto /// How large the cover images should be /// public CoverImageSize CoverImageSize { get; set; } + /// + /// SMTP Configuration + /// + public SmtpConfigDto SmtpConfig { get; set; } + + /// + /// Are at least some basics filled in + /// + /// + public bool IsEmailSetup() + { + //return false; + return !string.IsNullOrEmpty(SmtpConfig.Host) + && !string.IsNullOrEmpty(SmtpConfig.UserName) + && !string.IsNullOrEmpty(HostName); + } } diff --git a/API/Data/ManualMigrations/MigrateEmailTemplates.cs b/API/Data/ManualMigrations/MigrateEmailTemplates.cs new file mode 100644 index 000000000..ca0dc125b --- /dev/null +++ b/API/Data/ManualMigrations/MigrateEmailTemplates.cs @@ -0,0 +1,59 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Services; +using Flurl.Http; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +public static class MigrateEmailTemplates +{ + private const string EmailChange = "https://raw.githubusercontent.com/Kareadita/KavitaEmail/main/KavitaEmail/config/templates/EmailChange.html"; + private const string EmailConfirm = "https://raw.githubusercontent.com/Kareadita/KavitaEmail/main/KavitaEmail/config/templates/EmailConfirm.html"; + private const string EmailPasswordReset = "https://raw.githubusercontent.com/Kareadita/KavitaEmail/main/KavitaEmail/config/templates/EmailPasswordReset.html"; + private const string SendToDevice = "https://raw.githubusercontent.com/Kareadita/KavitaEmail/main/KavitaEmail/config/templates/SendToDevice.html"; + private const string EmailTest = "https://raw.githubusercontent.com/Kareadita/KavitaEmail/main/KavitaEmail/config/templates/EmailTest.html"; + + public static async Task Migrate(IDirectoryService directoryService, ILogger logger) + { + var files = directoryService.GetFiles(directoryService.CustomizedTemplateDirectory); + if (files.Any()) + { + logger.LogCritical("Running MigrateEmailTemplates migration - Completed. This is not an error"); + return; + } + + // Write files to directory + await DownloadAndWriteToFile(EmailChange, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailChange.html"), logger); + await DownloadAndWriteToFile(EmailConfirm, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailConfirm.html"), logger); + await DownloadAndWriteToFile(EmailPasswordReset, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailPasswordReset.html"), logger); + await DownloadAndWriteToFile(SendToDevice, Path.Join(directoryService.CustomizedTemplateDirectory, "SendToDevice.html"), logger); + await DownloadAndWriteToFile(EmailTest, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailTest.html"), logger); + + + + logger.LogCritical("Running MigrateEmailTemplates migration - Please be patient, this may take some time. This is not an error"); + } + + private static async Task DownloadAndWriteToFile(string url, string filePath, ILogger logger) + { + try + { + // Download the raw text using Flurl + var content = await url.GetStringAsync(); + + // Write the content to a file + await File.WriteAllTextAsync(filePath, content); + + logger.LogInformation("{File} downloaded and written successfully", filePath); + } + catch (FlurlHttpException ex) + { + logger.LogError(ex, "Unable to download {Url} to {FilePath}. Please perform yourself!", url, filePath); + } + } + + +} diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index c5fe643ea..495e08207 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -223,12 +223,10 @@ public static class Seed }, // Not used from DB, but DB is sync with appSettings.json new() {Key = ServerSettingKey.AllowStatCollection, Value = "true"}, new() {Key = ServerSettingKey.EnableOpds, Value = "true"}, - new() {Key = ServerSettingKey.EnableAuthentication, Value = "true"}, new() {Key = ServerSettingKey.BaseUrl, Value = "/"}, 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}, new() {Key = ServerSettingKey.TotalBackups, Value = "30"}, new() {Key = ServerSettingKey.TotalLogs, Value = "30"}, new() {Key = ServerSettingKey.EnableFolderWatching, Value = "false"}, @@ -241,6 +239,16 @@ public static class Seed new() { Key = ServerSettingKey.CacheSize, Value = Configuration.DefaultCacheMemory + string.Empty }, // Not used from DB, but DB is sync with appSettings.json + + new() {Key = ServerSettingKey.EmailHost, Value = string.Empty}, + new() {Key = ServerSettingKey.EmailPort, Value = string.Empty}, + new() {Key = ServerSettingKey.EmailAuthPassword, Value = string.Empty}, + new() {Key = ServerSettingKey.EmailAuthUserName, Value = string.Empty}, + new() {Key = ServerSettingKey.EmailSenderAddress, Value = string.Empty}, + new() {Key = ServerSettingKey.EmailSenderDisplayName, Value = string.Empty}, + new() {Key = ServerSettingKey.EmailEnableSsl, Value = "true"}, + new() {Key = ServerSettingKey.EmailSizeLimit, Value = 26_214_400 + string.Empty}, + new() {Key = ServerSettingKey.EmailCustomizedTemplates, Value = "false"}, }.ToArray()); foreach (var defaultSetting in DefaultSettings) diff --git a/API/EmailTemplates/EmailChange.html b/API/EmailTemplates/EmailChange.html new file mode 100644 index 000000000..c81fca95a --- /dev/null +++ b/API/EmailTemplates/EmailChange.html @@ -0,0 +1,348 @@ + + + + + + + + + + + + + Kavita - [Plain HTML] + + + + + + + + + + + + + + + + + + + + + + +
+ +
Your account's email has been updated on {{InvitingUser}}'s Kavita instance. Click the button to validate your email.
+ + + +
+ + + diff --git a/API/EmailTemplates/EmailConfirm.html b/API/EmailTemplates/EmailConfirm.html new file mode 100644 index 000000000..eef990b35 --- /dev/null +++ b/API/EmailTemplates/EmailConfirm.html @@ -0,0 +1,348 @@ + + + + + + + + + + + + + Kavita - [Plain HTML] + + + + + + + + + + + + + + + + + + + + + + +
+ +
You have been invited to {{InvitingUser}}'s Kavita instance. Click the button to accept the invite.
+ + + +
+ + + diff --git a/API/EmailTemplates/EmailPasswordReset.html b/API/EmailTemplates/EmailPasswordReset.html new file mode 100644 index 000000000..8c7c0a920 --- /dev/null +++ b/API/EmailTemplates/EmailPasswordReset.html @@ -0,0 +1,348 @@ + + + + + + + + + + + + + Kavita - [Plain HTML] + + + + + + + + + + + + + + + + + + + + + + +
+ +
Email confirmation is required for continued access. Click the button to confirm your email.
+ + + +
+ + + \ No newline at end of file diff --git a/API/EmailTemplates/EmailTest.html b/API/EmailTemplates/EmailTest.html new file mode 100644 index 000000000..d408460c4 --- /dev/null +++ b/API/EmailTemplates/EmailTest.html @@ -0,0 +1,325 @@ + + + + + + + + + + + + + Event - [Plain HTML] + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ This is a Test Email +
+ + + +
+ + + diff --git a/API/EmailTemplates/SendToDevice.html b/API/EmailTemplates/SendToDevice.html new file mode 100644 index 000000000..4f82e1975 --- /dev/null +++ b/API/EmailTemplates/SendToDevice.html @@ -0,0 +1,323 @@ + + + + + + + + + + + + + Event - [Plain HTML] + + + + + + + + + + + + + + + + + + + + + + +
+ +
You've been sent a file from Kavita!
+ + + +
+ + + \ No newline at end of file diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index af699a3d9..8a7d274eb 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -52,6 +52,7 @@ public enum ServerSettingKey /// Is Authentication needed for non-admin accounts /// /// Deprecated. This is no longer used v0.5.1+. Assume Authentication is always in effect + [Obsolete("Not supported as of v0.5.1")] [Description("EnableAuthentication")] EnableAuthentication = 8, /// @@ -79,6 +80,7 @@ public enum ServerSettingKey /// If SMTP is enabled on the server /// [Description("CustomEmailService")] + [Obsolete("Use Email settings instead")] EmailServiceUrl = 13, /// /// If Kavita should save bookmarks as WebP images @@ -147,6 +149,38 @@ public enum ServerSettingKey /// The size of the cover image thumbnail. Defaults to .Default /// [Description("CoverImageSize")] - CoverImageSize = 27 + CoverImageSize = 27, + #region EmailSettings + /// + /// The address of the emailer host + /// + [Description("EmailSenderAddress")] + EmailSenderAddress = 28, + /// + /// What the email name should be + /// + [Description("EmailSenderDisplayName")] + EmailSenderDisplayName = 29, + [Description("EmailAuthUserName")] + EmailAuthUserName = 30, + [Description("EmailAuthPassword")] + EmailAuthPassword = 31, + [Description("EmailHost")] + EmailHost = 32, + [Description("EmailPort")] + EmailPort = 33, + [Description("EmailEnableSsl")] + EmailEnableSsl = 34, + /// + /// Number of bytes that the sender allows to be sent through + /// + [Description("EmailSizeLimit")] + EmailSizeLimit = 35, + /// + /// Should Kavita use config/templates for Email templates or the default ones + /// + [Description("EmailCustomizedTemplates")] + EmailCustomizedTemplates = 36, + #endregion } diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index ffae4d5a8..a97c47122 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -47,9 +47,6 @@ public class ServerSettingConverter : ITypeConverter, case ServerSettingKey.BookmarkDirectory: destination.BookmarksDirectory = row.Value; break; - case ServerSettingKey.EmailServiceUrl: - destination.EmailServiceUrl = row.Value; - break; case ServerSettingKey.InstallVersion: destination.InstallVersion = row.Value; break; @@ -83,6 +80,45 @@ public class ServerSettingConverter : ITypeConverter, case ServerSettingKey.CoverImageSize: destination.CoverImageSize = Enum.Parse(row.Value); break; + case ServerSettingKey.BackupDirectory: + destination.BookmarksDirectory = row.Value; + break; + case ServerSettingKey.EmailHost: + destination.SmtpConfig ??= new SmtpConfigDto(); + destination.SmtpConfig.Host = row.Value; + break; + case ServerSettingKey.EmailPort: + destination.SmtpConfig ??= new SmtpConfigDto(); + destination.SmtpConfig.Port = string.IsNullOrEmpty(row.Value) ? 0 : int.Parse(row.Value); + break; + case ServerSettingKey.EmailAuthPassword: + destination.SmtpConfig ??= new SmtpConfigDto(); + destination.SmtpConfig.Password = row.Value; + break; + case ServerSettingKey.EmailAuthUserName: + destination.SmtpConfig ??= new SmtpConfigDto(); + destination.SmtpConfig.UserName = row.Value; + break; + case ServerSettingKey.EmailSenderAddress: + destination.SmtpConfig ??= new SmtpConfigDto(); + destination.SmtpConfig.SenderAddress = row.Value; + break; + case ServerSettingKey.EmailSenderDisplayName: + destination.SmtpConfig ??= new SmtpConfigDto(); + destination.SmtpConfig.SenderDisplayName = row.Value; + break; + case ServerSettingKey.EmailEnableSsl: + destination.SmtpConfig ??= new SmtpConfigDto(); + destination.SmtpConfig.EnableSsl = bool.Parse(row.Value); + break; + case ServerSettingKey.EmailSizeLimit: + destination.SmtpConfig ??= new SmtpConfigDto(); + destination.SmtpConfig.SizeLimit = int.Parse(row.Value); + break; + case ServerSettingKey.EmailCustomizedTemplates: + destination.SmtpConfig ??= new SmtpConfigDto(); + destination.SmtpConfig.CustomizedTemplates = bool.Parse(row.Value); + break; } } diff --git a/API/I18N/en.json b/API/I18N/en.json index b7ddc1128..f24e76d9d 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -39,6 +39,7 @@ "admin-already-exists": "Admin already exists", "invalid-username": "Invalid username", "critical-email-migration": "There was an issue during email migration. Contact support", + "email-not-enabled": "Email is not enabled on this server. You cannot perform this action.", "chapter-doesnt-exist": "Chapter does not exist", "file-missing": "File was not found in book", @@ -53,7 +54,9 @@ "generic-device-update": "There was an error when updating the device", "generic-device-delete": "There was an error when deleting the device", "greater-0": "{0} must be greater than 0", - "send-to-kavita-email": "Send to device cannot be used with Kavita's email service. Please configure your own.", + "send-to-kavita-email": "Send to device cannot be used without Email setup", + "send-to-unallowed":"You cannot send to a device that isn't yours", + "send-to-size-limit": "The file(s) you are trying to send are too large for your emailer", "send-to-device-status": "Transferring files to your device", "generic-send-to": "There was an error sending the file(s) to the device", "series-doesnt-exist": "Series does not exist", diff --git a/API/Services/DeviceService.cs b/API/Services/DeviceService.cs index 02cdddd62..1e4d82ab5 100644 --- a/API/Services/DeviceService.cs +++ b/API/Services/DeviceService.cs @@ -107,6 +107,10 @@ public class DeviceService : IDeviceService public async Task SendTo(IReadOnlyList chapterIds, int deviceId) { + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (!settings.IsEmailSetup()) + throw new KavitaException("send-to-kavita-email"); + var device = await _unitOfWork.DeviceRepository.GetDeviceById(deviceId); if (device == null) throw new KavitaException("device-doesnt-exist"); @@ -114,6 +118,10 @@ public class DeviceService : IDeviceService if (files.Any(f => f.Format is not (MangaFormat.Epub or MangaFormat.Pdf)) && device.Platform == DevicePlatform.Kindle) throw new KavitaException("send-to-permission"); + // If the size of the files is too big + if (files.Sum(f => f.Bytes) >= settings.SmtpConfig.SizeLimit) + throw new KavitaException("send-to-size-limit"); + device.UpdateLastUsed(); _unitOfWork.DeviceRepository.Update(device); diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 15afddf95..e3dede802 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -26,6 +26,8 @@ public interface IDirectoryService string SiteThemeDirectory { get; } string FaviconDirectory { get; } string LocalizationDirectory { get; } + string CustomizedTemplateDirectory { get; } + string TemplateDirectory { get; } /// /// Original BookmarkDirectory. Only used for resetting directory. Use for actual path. /// @@ -81,6 +83,8 @@ public class DirectoryService : IDirectoryService public string SiteThemeDirectory { get; } public string FaviconDirectory { get; } public string LocalizationDirectory { get; } + public string CustomizedTemplateDirectory { get; } + public string TemplateDirectory { get; } private readonly ILogger _logger; private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase; @@ -114,6 +118,10 @@ public class DirectoryService : IDirectoryService FaviconDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "favicons"); ExistOrCreate(FaviconDirectory); LocalizationDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "I18N"); + CustomizedTemplateDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "templates"); + ExistOrCreate(CustomizedTemplateDirectory); + TemplateDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "EmailTemplates"); + ExistOrCreate(TemplateDirectory); } /// diff --git a/API/Services/EmailService.cs b/API/Services/EmailService.cs index 3086abb25..8158618a4 100644 --- a/API/Services/EmailService.cs +++ b/API/Services/EmailService.cs @@ -1,34 +1,45 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.IO; using System.Linq; using System.Net; using System.Threading.Tasks; using API.Data; using API.DTOs.Email; using API.Entities.Enums; -using Flurl; using Flurl.Http; using Kavita.Common; using Kavita.Common.EnvironmentInfo; -using Kavita.Common.Helpers; +using MailKit.Security; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; +using MimeKit; namespace API.Services; #nullable enable +internal class EmailOptionsDto +{ + public IList ToEmails { get; set; } + public string Subject { get; set; } + public string Body { get; set; } + public IList> PlaceHolders { get; set; } + /// + /// Filenames to attach + /// + public IList? Attachments { get; set; } +} + public interface IEmailService { - Task SendConfirmationEmail(ConfirmationEmailDto data); + Task SendInviteEmail(ConfirmationEmailDto data); Task CheckIfAccessible(string host); - Task SendMigrationEmail(EmailMigrationDto data); - Task SendPasswordResetEmail(PasswordResetEmailDto data); + Task SendForgotPasswordEmail(PasswordResetEmailDto dto); Task SendFilesToEmail(SendToDto data); - Task TestConnectivity(string emailUrl, string adminEmail, bool sendEmail); + Task SendTestEmail(string adminEmail); Task IsDefaultEmailService(); Task SendEmailChangeEmail(ConfirmationEmailDto data); - Task GetVersion(string emailUrl); bool IsValidEmail(string email); } @@ -37,41 +48,62 @@ public class EmailService : IEmailService private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; private readonly IDownloadService _downloadService; + private readonly IDirectoryService _directoryService; + private const string TemplatePath = @"{0}.html"; /// /// 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, IDownloadService downloadService) + + public EmailService(ILogger logger, IUnitOfWork unitOfWork, IDownloadService downloadService, IDirectoryService directoryService) { _logger = logger; _unitOfWork = unitOfWork; _downloadService = downloadService; - - - FlurlHttp.ConfigureClient(DefaultApiUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + _directoryService = directoryService; } /// - /// Test if this instance is accessible outside the network + /// Test if the email settings are working. Rejects if user email isn't valid or not all data is setup in server settings. /// - /// This will do some basic filtering to auto return false if the emailUrl is a LAN ip - /// - /// Should an email be sent if connectivity is successful /// - public async Task TestConnectivity(string emailUrl, string adminEmail, bool sendEmail) + public async Task SendTestEmail(string adminEmail) { - var result = new EmailTestResultDto(); + var result = new EmailTestResultDto + { + EmailAddress = adminEmail + }; + + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (!IsValidEmail(adminEmail) || !settings.IsEmailSetup()) + { + result.ErrorMessage = "You need to fill in more information in settings and ensure your account has a valid email to send a test email"; + result.Successful = false; + return result; + } + + // TODO: Come back and update the template. We can't do it with the v0.8.0 release + var placeholders = new List> + { + new ("{{Host}}", settings.HostName), + }; + try { - if (IsLocalIpAddress(emailUrl)) + var emailOptions = new EmailOptionsDto() { - result.Successful = false; - result.ErrorMessage = "This is a local IP address"; - } - result.Successful = await SendEmailWithGet($"{emailUrl}/api/test?adminEmail={Url.Encode(adminEmail)}&sendEmail={sendEmail}"); + Subject = "KavitaEmail Test", + Body = UpdatePlaceHolders(await GetEmailBody("EmailTest"), placeholders), + ToEmails = new List() + { + adminEmail + } + }; + + await SendEmail(emailOptions); + result.Successful = true; } catch (KavitaException ex) { @@ -82,229 +114,210 @@ public class EmailService : IEmailService return result; } + + [Obsolete] public async Task IsDefaultEmailService() { return (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl))!.Value! .Equals(DefaultApiUrl); } + /// + /// Sends an email that has a link that will finalize an Email Change + /// + /// public async Task SendEmailChangeEmail(ConfirmationEmailDto data) { - var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl))!.Value; - var success = await SendEmailWithPost(emailLink + "/api/account/email-change", data); - if (!success) + var placeholders = new List> { - _logger.LogError("There was a critical error sending Confirmation email"); - } - } + new ("{{InvitingUser}}", data.InvitingUser), + new ("{{Link}}", data.ServerConfirmationLink) + }; - public async Task GetVersion(string emailUrl) - { - try + var emailOptions = new EmailOptionsDto() { - var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - var response = await $"{emailUrl}/api/about/version" - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("x-kavita-installId", settings.InstallId) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(10)) - .GetStringAsync(); - - if (!string.IsNullOrEmpty(response)) + Subject = UpdatePlaceHolders("Your email has been changed on {{InvitingUser}}'s Server", placeholders), + Body = UpdatePlaceHolders(await GetEmailBody("EmailChange"), placeholders), + ToEmails = new List() { - return response.Replace("\"", string.Empty); + data.EmailAddress } - } - catch (Exception) - { - return null; - } + }; - return null; + await SendEmail(emailOptions); } + /// + /// Validates the email address. Does not test it actually receives mail + /// + /// + /// public bool IsValidEmail(string email) { return new EmailAddressAttribute().IsValid(email); } - public async Task SendConfirmationEmail(ConfirmationEmailDto data) + /// + /// Sends an invite email to a user to setup their account + /// + /// + public async Task SendInviteEmail(ConfirmationEmailDto data) { - var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value; - var success = await SendEmailWithPost(emailLink + "/api/invite/confirm", data); - if (!success) + var placeholders = new List> { - _logger.LogError("There was a critical error sending Confirmation email"); - } - } + new ("{{InvitingUser}}", data.InvitingUser), + new ("{{Link}}", data.ServerConfirmationLink) + }; - public async Task CheckIfAccessible(string 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 - try + var emailOptions = new EmailOptionsDto() { - if (IsLocalIpAddress(host)) + Subject = UpdatePlaceHolders("You've been invited to join {{InvitingUser}}'s Server", placeholders), + Body = UpdatePlaceHolders(await GetEmailBody("EmailConfirm"), placeholders), + ToEmails = new List() { - _logger.LogDebug("[EmailService] Server is not accessible, using local ip"); - return false; + data.EmailAddress } + }; - var url = DefaultApiUrl + "/api/reachable?host=" + host; - _logger.LogDebug("[EmailService] Checking if this server is accessible for sending an email to: {Url}", url); - return await SendEmailWithGet(url); - } - catch (Exception) + await SendEmail(emailOptions); + } + + public Task CheckIfAccessible(string host) + { + return Task.FromResult(true); + } + + public async Task SendForgotPasswordEmail(PasswordResetEmailDto dto) + { + var placeholders = new List> { - return false; - } - } + new ("{{Link}}", dto.ServerConfirmationLink), + }; - public async Task SendMigrationEmail(EmailMigrationDto data) - { - var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value; - return await SendEmailWithPost(emailLink + "/api/invite/email-migration", data); - } + var emailOptions = new EmailOptionsDto() + { + Subject = UpdatePlaceHolders("A password reset has been requested", placeholders), + Body = UpdatePlaceHolders(await GetEmailBody("EmailPasswordReset"), placeholders), + ToEmails = new List() + { + dto.EmailAddress + } + }; - public async Task SendPasswordResetEmail(PasswordResetEmailDto data) - { - var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value; - return await SendEmailWithPost(emailLink + "/api/invite/email-password-reset", data); + await SendEmail(emailOptions); + return true; } public async Task SendFilesToEmail(SendToDto data) { - if (await IsDefaultEmailService()) return false; - var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value; - return await SendEmailWithFiles(emailLink + "/api/sendto", data.FilePaths, data.DestinationEmail); + var serverSetting = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (!serverSetting.IsEmailSetup()) return false; + + var emailOptions = new EmailOptionsDto() + { + Subject = "Send file from Kavita", + ToEmails = new List() + { + data.DestinationEmail + }, + Body = await GetEmailBody("SendToDevice"), + Attachments = data.FilePaths.ToList() + }; + + await SendEmail(emailOptions); + return true; } - private async Task SendEmailWithGet(string url, int timeoutSecs = 30) + private async Task SendEmail(EmailOptionsDto userEmailOptions) { + var smtpConfig = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).SmtpConfig; + var email = new MimeMessage() + { + Subject = userEmailOptions.Subject, + }; + email.From.Add(new MailboxAddress(smtpConfig.SenderDisplayName, smtpConfig.SenderAddress)); + + + var body = new BodyBuilder + { + HtmlBody = userEmailOptions.Body + }; + + if (userEmailOptions.Attachments != null) + { + foreach (var attachment in userEmailOptions.Attachments) + { + await body.Attachments.AddAsync(attachment); + } + } + + email.Body = body.ToMessageBody(); + + foreach (var toEmail in userEmailOptions.ToEmails) + { + email.To.Add(new MailboxAddress(toEmail, toEmail)); + } + + using var smtpClient = new MailKit.Net.Smtp.SmtpClient(); + smtpClient.Timeout = 20000; + var ssl = smtpConfig.EnableSsl ? SecureSocketOptions.Auto : SecureSocketOptions.None; + + await smtpClient.ConnectAsync(smtpConfig.Host, smtpConfig.Port, ssl); + if (!string.IsNullOrEmpty(smtpConfig.UserName) && !string.IsNullOrEmpty(smtpConfig.Password)) + { + await smtpClient.AuthenticateAsync(smtpConfig.UserName, smtpConfig.Password); + } + + ServicePointManager.SecurityProtocol = SecurityProtocolType.SystemDefault; + try { - var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - var response = await (url) - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("x-kavita-installId", settings.InstallId) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(timeoutSecs)) - .GetStringAsync(); - - if (!string.IsNullOrEmpty(response) && bool.Parse(response)) - { - return true; - } + await smtpClient.SendAsync(email); } catch (Exception ex) { - throw new KavitaException(ex.Message); + _logger.LogError(ex, "There was an issue sending the email"); + throw; + } + finally + { + await smtpClient.DisconnectAsync(true); } - return false; } - - private async Task SendEmailWithPost(string url, object data, int timeoutSecs = 30) + private async Task GetTemplatePath(string templateName) { - try + if ((await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).SmtpConfig.CustomizedTemplates) { - var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - var response = await (url) - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("x-kavita-installId", settings.InstallId) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(timeoutSecs)) - .PostJsonAsync(data); + var templateDirectory = Path.Join(_directoryService.CustomizedTemplateDirectory, TemplatePath); + var fullName = string.Format(templateDirectory, templateName); + if (_directoryService.FileSystem.File.Exists(fullName)) return fullName; + _logger.LogError("Customized Templates is on, but template {TemplatePath} is missing", fullName); + } - if (response.StatusCode != StatusCodes.Status200OK) + return string.Format(Path.Join(_directoryService.TemplateDirectory, TemplatePath), templateName); + } + + private async Task GetEmailBody(string templateName) + { + var templatePath = await GetTemplatePath(templateName); + + var body = await File.ReadAllTextAsync(templatePath); + return body; + } + + private static string UpdatePlaceHolders(string text, IList> keyValuePairs) + { + if (string.IsNullOrEmpty(text) || keyValuePairs == null) return text; + + foreach (var (key, value) in keyValuePairs) + { + if (text.Contains(key)) { - var errorMessage = await response.GetStringAsync(); - throw new KavitaException(errorMessage); + text = text.Replace(key, value); } } - catch (FlurlHttpException ex) - { - _logger.LogError(ex, "There was an exception when interacting with Email Service"); - return false; - } - return true; + + return text; } - - - private async Task SendEmailWithFiles(string url, IEnumerable filePaths, string destEmail, int timeoutSecs = 300) - { - try - { - var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - var response = await (url) - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("x-kavita-installId", settings.InstallId) - .WithTimeout(timeoutSecs) - .AllowHttpStatus("4xx") - .PostMultipartAsync(mp => - { - mp.AddString("email", destEmail); - var index = 1; - foreach (var filepath in filePaths) - { - mp.AddFile("file" + index, filepath, _downloadService.GetContentTypeFromFile(filepath)); - index++; - } - } - ); - - if (response.StatusCode != StatusCodes.Status200OK) - { - var errorMessage = await response.GetStringAsync(); - throw new KavitaException(errorMessage); - } - } - catch (FlurlHttpException ex) - { - _logger.LogError(ex, "There was an exception when sending Email for SendTo"); - return false; - } - return true; - } - - private static bool IsLocalIpAddress(string url) - { - var host = url.Split(':')[0]; - try - { - // get host IP addresses - var hostIPs = Dns.GetHostAddresses(host); - // get local IP addresses - var localIPs = Dns.GetHostAddresses(Dns.GetHostName()); - - // test if any host IP equals to any local IP or to localhost - foreach (var hostIp in hostIPs) - { - // is localhost - if (IPAddress.IsLoopback(hostIp)) return true; - // is local address - if (localIPs.Contains(hostIp)) - { - return true; - } - } - } - catch - { - // ignored - } - - return false; - } - } diff --git a/API/Startup.cs b/API/Startup.cs index 21c4fa459..f65d5a64b 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -244,6 +244,9 @@ public class Startup await MigrateSmartFilterEncoding.Migrate(unitOfWork, dataContext, logger); await MigrateLibrariesToHaveAllFileTypes.Migrate(unitOfWork, dataContext, logger); + // v0.7.14 + await MigrateEmailTemplates.Migrate(directoryService, logger); + // Update the version in the DB after all migrations are run var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); installVersion.Value = BuildInfo.Version.ToString(); diff --git a/API/config/templates/EmailChange.html b/API/config/templates/EmailChange.html new file mode 100644 index 000000000..f5d661294 --- /dev/null +++ b/API/config/templates/EmailChange.html @@ -0,0 +1,348 @@ + + + + + + + + + + + + + Event - [Plain HTML] + + + + + + + + + + + + + + + + + + + + + + +
+ +
Your account's email has been updated on {{InvitingUser}}'s Kavita instance. Click the button to validate your email.
+ + + +
+ + + diff --git a/API/config/templates/EmailConfirm.html b/API/config/templates/EmailConfirm.html new file mode 100644 index 000000000..dff300dc6 --- /dev/null +++ b/API/config/templates/EmailConfirm.html @@ -0,0 +1,348 @@ + + + + + + + + + + + + + Event - [Plain HTML] + + + + + + + + + + + + + + + + + + + + + + +
+ +
You have been invited to {{InvitingUser}}'s Kavita instance. Click the button to accept the invite.
+ + + +
+ + + \ No newline at end of file diff --git a/API/config/templates/EmailMigration.html b/API/config/templates/EmailMigration.html new file mode 100644 index 000000000..f7ea0aed1 --- /dev/null +++ b/API/config/templates/EmailMigration.html @@ -0,0 +1,343 @@ + + + + + + + + + + + + + Kavita - [Plain HTML] + + + + + + + + + + + + + + + + + + + + + + +
+ +
Email confirmation is required for continued access. Click the button to confirm your email.
+ + + +
+ + + \ No newline at end of file diff --git a/API/config/templates/EmailPasswordReset.html b/API/config/templates/EmailPasswordReset.html new file mode 100644 index 000000000..8c7c0a920 --- /dev/null +++ b/API/config/templates/EmailPasswordReset.html @@ -0,0 +1,348 @@ + + + + + + + + + + + + + Kavita - [Plain HTML] + + + + + + + + + + + + + + + + + + + + + + +
+ +
Email confirmation is required for continued access. Click the button to confirm your email.
+ + + +
+ + + \ No newline at end of file diff --git a/API/config/templates/EmailTest.html b/API/config/templates/EmailTest.html new file mode 100644 index 000000000..d408460c4 --- /dev/null +++ b/API/config/templates/EmailTest.html @@ -0,0 +1,325 @@ + + + + + + + + + + + + + Event - [Plain HTML] + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ This is a Test Email +
+ + + +
+ + + diff --git a/API/config/templates/SendToDevice.html b/API/config/templates/SendToDevice.html new file mode 100644 index 000000000..4f82e1975 --- /dev/null +++ b/API/config/templates/SendToDevice.html @@ -0,0 +1,323 @@ + + + + + + + + + + + + + Event - [Plain HTML] + + + + + + + + + + + + + + + + + + + + + + +
+ +
You've been sent a file from Kavita!
+ + + +
+ + + \ No newline at end of file diff --git a/Kavita.Email/DTOs/ConfirmationEmailDto.cs b/Kavita.Email/DTOs/ConfirmationEmailDto.cs new file mode 100644 index 000000000..d157b4d53 --- /dev/null +++ b/Kavita.Email/DTOs/ConfirmationEmailDto.cs @@ -0,0 +1,10 @@ +namespace Skeleton.DTOs; + +public record ConfirmationEmailDto +{ + public string InvitingUser { get; init; } + public string EmailAddress { get; init; } + public string ServerConfirmationLink { get; init; } + public string InstallId { get; init; } + +} \ No newline at end of file diff --git a/Kavita.Email/DTOs/EmailMigrationDto.cs b/Kavita.Email/DTOs/EmailMigrationDto.cs new file mode 100644 index 000000000..dc210dbdb --- /dev/null +++ b/Kavita.Email/DTOs/EmailMigrationDto.cs @@ -0,0 +1,9 @@ +namespace Skeleton.DTOs; + +public class EmailMigrationDto +{ + public string EmailAddress { get; init; } + public string ServerConfirmationLink { get; init; } + public string Username { get; init; } + public string InstallId { get; init; } +} \ No newline at end of file diff --git a/Kavita.Email/DTOs/EmailOptionsDto.cs b/Kavita.Email/DTOs/EmailOptionsDto.cs new file mode 100644 index 000000000..242e618ee --- /dev/null +++ b/Kavita.Email/DTOs/EmailOptionsDto.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace Skeleton.DTOs; + +public class EmailOptionsDto +{ + public IList ToEmails { get; set; } + public string Subject { get; set; } + public string Body { get; set; } + public IList> PlaceHolders { get; set; } + /// + /// Filenames to attach + /// + public IList Attachments { get; set; } +} \ No newline at end of file diff --git a/Kavita.Email/DTOs/PasswordResetDto.cs b/Kavita.Email/DTOs/PasswordResetDto.cs new file mode 100644 index 000000000..901eaa79c --- /dev/null +++ b/Kavita.Email/DTOs/PasswordResetDto.cs @@ -0,0 +1,8 @@ +namespace Skeleton.DTOs; + +public class PasswordResetDto +{ + public string EmailAddress { get; init; } + public string ServerConfirmationLink { get; init; } + public string InstallId { get; init; } +} \ No newline at end of file diff --git a/Kavita.Email/Kavita.Email.csproj b/Kavita.Email/Kavita.Email.csproj new file mode 100644 index 000000000..3a6353295 --- /dev/null +++ b/Kavita.Email/Kavita.Email.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/Kavita.sln b/Kavita.sln index 670808870..5bc8d96dc 100644 --- a/Kavita.sln +++ b/Kavita.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kavita.Common", "Kavita.Com EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API.Benchmark", "API.Benchmark\API.Benchmark.csproj", "{3D781D18-2452-421F-A81A-59254FEE1FEC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kavita.Email", "Kavita.Email\Kavita.Email.csproj", "{1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -72,5 +74,17 @@ Global {3D781D18-2452-421F-A81A-59254FEE1FEC}.Release|x64.Build.0 = Release|Any CPU {3D781D18-2452-421F-A81A-59254FEE1FEC}.Release|x86.ActiveCfg = Release|Any CPU {3D781D18-2452-421F-A81A-59254FEE1FEC}.Release|x86.Build.0 = Release|Any CPU + {1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Debug|x64.ActiveCfg = Debug|Any CPU + {1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Debug|x64.Build.0 = Debug|Any CPU + {1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Debug|x86.ActiveCfg = Debug|Any CPU + {1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Debug|x86.Build.0 = Debug|Any CPU + {1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Release|Any CPU.Build.0 = Release|Any CPU + {1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Release|x64.ActiveCfg = Release|Any CPU + {1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Release|x64.Build.0 = Release|Any CPU + {1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Release|x86.ActiveCfg = Release|Any CPU + {1CC0DACD-A6E0-4A9F-8286-69F1EE247F7F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/UI/Web/.gitignore b/UI/Web/.gitignore index dbd64df83..73b400165 100644 --- a/UI/Web/.gitignore +++ b/UI/Web/.gitignore @@ -1,3 +1,4 @@ node_modules/ test-results/ playwright-report/ +i18n-cache-busting.json diff --git a/UI/Web/i18n-cache-busting.json b/UI/Web/i18n-cache-busting.json index bba3cf429..9ddbbed4f 100644 --- a/UI/Web/i18n-cache-busting.json +++ b/UI/Web/i18n-cache-busting.json @@ -1 +1 @@ -{"zh_Hant":"05191aaae25a26a8597559e8318f97db","zh_Hans":"ca663a190b259b41ac365b6b5537558e","uk":"ccf59f571821ab842882378395ccf48c","tr":"5d6427179210cc370400b816c9d1116d","th":"1e27a1e1cadb2b9f92d85952bffaab95","sk":"24de417448b577b4899e917b70a43263","ru":"c547f0995c167817dd2408e4e9279de2","pt_BR":"44c7cd3da6baad38887fb03ac4ec5581","pt":"af4162a48f01c5260d6436e7e000c5ef","pl":"c6488fdb9a1ecfe5cde6bd1c264902aa","nl":"3ff322f7b24442bd6bceb5c692146d4f","nb_NO":"99914b932bd37a50b983c5e7c90ae93b","ms":"9fdfcc11a2e8a58a4baa691b93d93ff7","ko":"447e24f9f60e1b9f36bc0b087d059dbd","ja":"2f46a5ad1364a71255dd76c0094a9264","it":"4ef0a0ef56bab4650eda37e0dd841982","id":"0beab79883a28035c393768fb8c8ecbd","hi":"d850bb49ec6b5a5ccf9986823f095ab8","fr":"b41b7065960b431a9833140e9014189e","es":"151b6c17ef7382da9f0f22b87a346b7d","en":"d07463979db4cc7ab6e0089889cfc730","de":"f8e3ec31790044be07e222703ed0575a","cs":"0f0c433b9fd641977e89a42e92e4b884"} \ No newline at end of file +{"zh_Hant":"05191aaae25a26a8597559e8318f97db","zh_Hans":"7edb04f6c2439da2cde73996aed08029","uk":"ccf59f571821ab842882378395ccf48c","tr":"5d6427179210cc370400b816c9d1116d","th":"1e27a1e1cadb2b9f92d85952bffaab95","sk":"24de417448b577b4899e917b70a43263","ru":"c547f0995c167817dd2408e4e9279de2","pt_BR":"5acd3a08c1d9aabfae5a74a438cff79b","pt":"af4162a48f01c5260d6436e7e000c5ef","pl":"c6488fdb9a1ecfe5cde6bd1c264902aa","nl":"3ff322f7b24442bd6bceb5c692146d4f","nb_NO":"99914b932bd37a50b983c5e7c90ae93b","ms":"9fdfcc11a2e8a58a4baa691b93d93ff7","ko":"447e24f9f60e1b9f36bc0b087d059dbd","ja":"27bec4796972f0338404ebdb5829af14","it":"4ef0a0ef56bab4650eda37e0dd841982","id":"cfaff69f0a68d9b6196b6c11986508f8","hi":"d850bb49ec6b5a5ccf9986823f095ab8","fr":"c648c43f9ea0bb20ddb00c0566bbd85a","es":"5816bb68d1d64c40de890c0be0222c71","en":"9ba658d4565ee2d245791896559b2271","de":"c3a4fd22b51fd5a675363a6a35d1611e","cs":"bd76bfbd0e5538378dfe99d034b2adfe"} \ No newline at end of file diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 23e377411..598539ab8 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -199,7 +199,7 @@ export class AccountService { } resendConfirmationEmail(userId: number) { - return this.httpClient.post(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}, TextResonse); + return this.httpClient.post(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}); } inviteUser(model: {email: string, roles: Array, libraries: Array, ageRestriction: AgeRestriction}) { @@ -310,7 +310,7 @@ export class AccountService { } - private refreshAccount() { + refreshAccount() { if (this.currentUser === null || this.currentUser === undefined) return of(); return this.httpClient.get(this.baseUrl + 'account/refresh-account').pipe(map((user: User) => { if (user) { diff --git a/UI/Web/src/app/_services/server.service.ts b/UI/Web/src/app/_services/server.service.ts index 803857554..e4936efd9 100644 --- a/UI/Web/src/app/_services/server.service.ts +++ b/UI/Web/src/app/_services/server.service.ts @@ -6,6 +6,7 @@ import { UpdateVersionEvent } from '../_models/events/update-version-event'; import { Job } from '../_models/job/job'; import { KavitaMediaError } from '../admin/_models/media-error'; import {TextResonse} from "../_types/text-response"; +import {map} from "rxjs/operators"; @Injectable({ providedIn: 'root' @@ -14,66 +15,62 @@ export class ServerService { baseUrl = environment.apiUrl; - constructor(private httpClient: HttpClient) { } + constructor(private http: HttpClient) { } getServerInfo() { - return this.httpClient.get(this.baseUrl + 'server/server-info-slim'); + return this.http.get(this.baseUrl + 'server/server-info-slim'); } clearCache() { - return this.httpClient.post(this.baseUrl + 'server/clear-cache', {}); + return this.http.post(this.baseUrl + 'server/clear-cache', {}); } cleanupWantToRead() { - return this.httpClient.post(this.baseUrl + 'server/cleanup-want-to-read', {}); + return this.http.post(this.baseUrl + 'server/cleanup-want-to-read', {}); } backupDatabase() { - return this.httpClient.post(this.baseUrl + 'server/backup-db', {}); + return this.http.post(this.baseUrl + 'server/backup-db', {}); } analyzeFiles() { - return this.httpClient.post(this.baseUrl + 'server/analyze-files', {}); + return this.http.post(this.baseUrl + 'server/analyze-files', {}); } checkForUpdate() { - return this.httpClient.get(this.baseUrl + 'server/check-update', {}); + return this.http.get(this.baseUrl + 'server/check-update', {}); } checkForUpdates() { - return this.httpClient.get(this.baseUrl + 'server/check-for-updates', {}); + return this.http.get(this.baseUrl + 'server/check-for-updates', {}); } getChangelog() { - return this.httpClient.get(this.baseUrl + 'server/changelog', {}); + return this.http.get(this.baseUrl + 'server/changelog', {}); } isServerAccessible() { - return this.httpClient.get(this.baseUrl + 'server/accessible'); + return this.http.get(this.baseUrl + 'server/accessible'); } getRecurringJobs() { - return this.httpClient.get(this.baseUrl + 'server/jobs'); + return this.http.get(this.baseUrl + 'server/jobs'); } convertMedia() { - return this.httpClient.post(this.baseUrl + 'server/convert-media', {}); + return this.http.post(this.baseUrl + 'server/convert-media', {}); } bustCache() { - return this.httpClient.post(this.baseUrl + 'server/bust-review-and-rec-cache', {}); + return this.http.post(this.baseUrl + 'server/bust-review-and-rec-cache', {}); } getMediaErrors() { - return this.httpClient.get>(this.baseUrl + 'server/media-errors', {}); + return this.http.get>(this.baseUrl + 'server/media-errors', {}); } clearMediaAlerts() { - return this.httpClient.post(this.baseUrl + 'server/clear-media-alerts', {}); - } - - getEmailVersion() { - return this.httpClient.get(this.baseUrl + 'server/email-version', TextResonse); + return this.http.post(this.baseUrl + 'server/clear-media-alerts', {}); } } diff --git a/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.ts b/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.ts index b96619e4f..a764b3885 100644 --- a/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.ts +++ b/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.ts @@ -1,11 +1,12 @@ -import { Component, Input } from '@angular/core'; +import {Component, inject, Input} from '@angular/core'; import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { Member } from 'src/app/_models/auth/member'; import { AccountService } from 'src/app/_services/account.service'; import { SentenceCasePipe } from '../../../_pipes/sentence-case.pipe'; import { NgIf } from '@angular/common'; -import {TranslocoDirective} from "@ngneat/transloco"; +import {translate, TranslocoDirective} from "@ngneat/transloco"; +import {ToastrService} from "ngx-toastr"; @Component({ selector: 'app-reset-password-modal', @@ -16,16 +17,21 @@ import {TranslocoDirective} from "@ngneat/transloco"; }) export class ResetPasswordModalComponent { + private readonly toastr = inject(ToastrService); + private readonly accountService = inject(AccountService); + public readonly modal = inject(NgbActiveModal); + @Input({required: true}) member!: Member; + errorMessage = ''; resetPasswordForm: FormGroup = new FormGroup({ password: new FormControl('', [Validators.required]), }); - constructor(public modal: NgbActiveModal, private accountService: AccountService) { } save() { this.accountService.resetPassword(this.member.username, this.resetPasswordForm.value.password,'').subscribe(() => { + this.toastr.success(translate('toasts.password-updated')) this.modal.close(); }); } diff --git a/UI/Web/src/app/admin/_models/server-settings.ts b/UI/Web/src/app/admin/_models/server-settings.ts index e58aa5190..e403e44a8 100644 --- a/UI/Web/src/app/admin/_models/server-settings.ts +++ b/UI/Web/src/app/admin/_models/server-settings.ts @@ -1,5 +1,6 @@ import { EncodeFormat } from "./encode-format"; import {CoverImageSize} from "./cover-image-size"; +import {SmtpConfig} from "./smtp-config"; export interface ServerSettings { cacheDirectory: string; @@ -22,4 +23,5 @@ export interface ServerSettings { onDeckProgressDays: number; onDeckUpdateDays: number; coverImageSize: CoverImageSize; + smtpConfig: SmtpConfig; } diff --git a/UI/Web/src/app/admin/_models/smtp-config.ts b/UI/Web/src/app/admin/_models/smtp-config.ts new file mode 100644 index 000000000..ee8c30f04 --- /dev/null +++ b/UI/Web/src/app/admin/_models/smtp-config.ts @@ -0,0 +1,11 @@ +export interface SmtpConfig { + senderAddress: string; + senderDisplayName: string; + userName: string; + password: string; + host: string; + port: number; + enableSsl: boolean; + sizeLimit: number; + customizedTemplates: boolean; +} diff --git a/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.html b/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.html index 78a4007d0..2912655f5 100644 --- a/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.html +++ b/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.html @@ -2,25 +2,10 @@

{{t('title')}}

-

- {{t('send-to-warning')}} -

-
- - {{t('email-url-tooltip')}} - -
- - - -
-
-
+

You must fill out both Host Name and SMTP settings to use email-based functionality within Kavita.

+ +
{{t('host-name-tooltip')}} @@ -35,7 +20,93 @@
+
+
+
+ + + {{t('sender-address-tooltip')}} + + +
+ +
+ + + {{t('sender-displayname-tooltip')}} + + +
+
+ +
+
+ + + {{t('host-tooltip')}} + + +
+ +
+ + +
+ +
+
+ + +
+
+
+ +
+
+ + + {{t('username-tooltip')}} + + +
+ +
+ + + {{t('password-tooltip')}} + + +
+
+ +
+
+ + + {{t('size-limit-tooltip')}} + + +
+ +
+
+ + + + + {{t('customized-templates-tooltip')}} + +
+
+
+
+ + +
+ diff --git a/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.ts b/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.ts index eb17d2744..1ace95653 100644 --- a/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.ts +++ b/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.ts @@ -1,14 +1,20 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; import {ToastrService} from 'ngx-toastr'; -import {forkJoin, take} from 'rxjs'; -import {EmailTestResult, SettingsService} from '../settings.service'; +import {take} from 'rxjs'; +import {SettingsService} from '../settings.service'; import {ServerSettings} from '../_models/server-settings'; -import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; -import {NgIf, NgTemplateOutlet} from '@angular/common'; -import {translate, TranslocoModule, TranslocoService} from "@ngneat/transloco"; +import { + NgbAccordionBody, + NgbAccordionButton, + NgbAccordionCollapse, + NgbAccordionDirective, NgbAccordionHeader, NgbAccordionItem, + NgbTooltip +} from '@ng-bootstrap/ng-bootstrap'; +import {NgForOf, NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common'; +import {translate, TranslocoModule} from "@ngneat/transloco"; import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; -import {ServerService} from "../../_services/server.service"; +import {ManageAlertsComponent} from "../manage-alerts/manage-alerts.component"; @Component({ selector: 'app-manage-email-settings', @@ -16,38 +22,47 @@ import {ServerService} from "../../_services/server.service"; styleUrls: ['./manage-email-settings.component.scss'], standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoModule, SafeHtmlPipe] + imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoModule, SafeHtmlPipe, ManageAlertsComponent, NgbAccordionBody, NgbAccordionButton, NgbAccordionCollapse, NgbAccordionDirective, NgbAccordionHeader, NgbAccordionItem, NgForOf, TitleCasePipe] }) export class ManageEmailSettingsComponent implements OnInit { - serverSettings!: ServerSettings; - settingsForm: FormGroup = new FormGroup({}); - link = 'Kavita Email'; - emailVersion: string | null = null; private readonly cdRef = inject(ChangeDetectorRef); - private readonly serverService = inject(ServerService); private readonly settingsService = inject(SettingsService); private readonly toastr = inject(ToastrService); - constructor() { } + serverSettings!: ServerSettings; + settingsForm: FormGroup = new FormGroup({}); ngOnInit(): void { this.settingsService.getServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => { this.serverSettings = settings; - this.settingsForm.addControl('emailServiceUrl', new FormControl(this.serverSettings.emailServiceUrl, [Validators.required])); this.settingsForm.addControl('hostName', new FormControl(this.serverSettings.hostName, [])); - this.cdRef.markForCheck(); - }); - this.serverService.getEmailVersion().subscribe(version => { - this.emailVersion = version; + this.settingsForm.addControl('host', new FormControl(this.serverSettings.smtpConfig.host, [])); + this.settingsForm.addControl('port', new FormControl(this.serverSettings.smtpConfig.port, [])); + this.settingsForm.addControl('userName', new FormControl(this.serverSettings.smtpConfig.userName, [])); + this.settingsForm.addControl('enableSsl', new FormControl(this.serverSettings.smtpConfig.enableSsl, [])); + this.settingsForm.addControl('password', new FormControl(this.serverSettings.smtpConfig.password, [])); + this.settingsForm.addControl('senderAddress', new FormControl(this.serverSettings.smtpConfig.senderAddress, [])); + this.settingsForm.addControl('senderDisplayName', new FormControl(this.serverSettings.smtpConfig.senderDisplayName, [])); + this.settingsForm.addControl('sizeLimit', new FormControl(this.serverSettings.smtpConfig.sizeLimit, [Validators.min(1)])); + this.settingsForm.addControl('customizedTemplates', new FormControl(this.serverSettings.smtpConfig.customizedTemplates, [Validators.min(1)])); this.cdRef.markForCheck(); }); } resetForm() { - this.settingsForm.get('emailServiceUrl')?.setValue(this.serverSettings.emailServiceUrl); this.settingsForm.get('hostName')?.setValue(this.serverSettings.hostName); + + this.settingsForm.addControl('host', new FormControl(this.serverSettings.smtpConfig.host, [])); + this.settingsForm.addControl('port', new FormControl(this.serverSettings.smtpConfig.port, [])); + this.settingsForm.addControl('userName', new FormControl(this.serverSettings.smtpConfig.userName, [])); + this.settingsForm.addControl('enableSsl', new FormControl(this.serverSettings.smtpConfig.enableSsl, [])); + this.settingsForm.addControl('password', new FormControl(this.serverSettings.smtpConfig.password, [])); + this.settingsForm.addControl('senderAddress', new FormControl(this.serverSettings.smtpConfig.senderAddress, [])); + this.settingsForm.addControl('senderDisplayName', new FormControl(this.serverSettings.smtpConfig.senderDisplayName, [])); + this.settingsForm.addControl('sizeLimit', new FormControl(this.serverSettings.smtpConfig.sizeLimit, [Validators.min(1)])); + this.settingsForm.addControl('customizedTemplates', new FormControl(this.serverSettings.smtpConfig.customizedTemplates, [Validators.min(1)])); this.settingsForm.markAsPristine(); this.cdRef.markForCheck(); } @@ -57,6 +72,15 @@ export class ManageEmailSettingsComponent implements OnInit { modelSettings.emailServiceUrl = this.settingsForm.get('emailServiceUrl')?.value; modelSettings.hostName = this.settingsForm.get('hostName')?.value; + modelSettings.smtpConfig.host = this.settingsForm.get('host')?.value; + modelSettings.smtpConfig.port = this.settingsForm.get('port')?.value; + modelSettings.smtpConfig.userName = this.settingsForm.get('userName')?.value; + modelSettings.smtpConfig.enableSsl = this.settingsForm.get('enableSsl')?.value; + modelSettings.smtpConfig.password = this.settingsForm.get('password')?.value; + modelSettings.smtpConfig.senderAddress = this.settingsForm.get('senderAddress')?.value; + modelSettings.smtpConfig.senderDisplayName = this.settingsForm.get('senderDisplayName')?.value; + modelSettings.smtpConfig.sizeLimit = this.settingsForm.get('sizeLimit')?.value; + modelSettings.smtpConfig.customizedTemplates = this.settingsForm.get('customizedTemplates')?.value; this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe((settings: ServerSettings) => { this.serverSettings = settings; @@ -77,32 +101,13 @@ export class ManageEmailSettingsComponent implements OnInit { }); } - resetEmailServiceUrl() { - this.settingsService.resetEmailServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => { - this.serverSettings.emailServiceUrl = settings.emailServiceUrl; - this.resetForm(); - this.toastr.success(translate('toasts.email-service-reset')); - }, (err: any) => { - console.error('error: ', err); - }); - } - - testEmailServiceUrl() { - if (this.settingsForm.get('emailServiceUrl')?.value === '') return; - forkJoin([this.settingsService.testEmailServerSettings(this.settingsForm.get('emailServiceUrl')?.value), this.serverService.getEmailVersion()]) - .pipe(take(1)).subscribe(async (results) => { - const result = results[0] as EmailTestResult; - if (result.successful) { - const version = ('. Kavita Email: ' + results[1] ? 'v' + results[1] : ''); - this.toastr.success(translate('toasts.email-service-reachable') + ' - ' + version); + test() { + this.settingsService.testEmailServerSettings().subscribe(res => { + if (res.successful) { + this.toastr.success(translate('toasts.email-sent', {email: res.emailAddress})); } else { - this.toastr.error(translate('toasts.email-service-unresponsive') + result.errorMessage.split('(')[0]); + this.toastr.error(translate('toasts.email-not-sent-test')) } - - }, (err: any) => { - console.error('error: ', err); }); - } - } diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.ts b/UI/Web/src/app/admin/manage-users/manage-users.component.ts index 1394a2eca..bdea28f41 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.ts +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.ts @@ -99,7 +99,7 @@ export class ManageUsersComponent implements OnInit { setTimeout(() => { this.loadMembers(); this.toastr.success(this.translocoService.translate('toasts.user-deleted', {user: member.username})); - }, 30); // SetTimeout because I've noticed this can run superfast and not give enough time for data to flush + }, 30); // SetTimeout because I've noticed this can run super fast and not give enough time for data to flush }); } } @@ -112,15 +112,13 @@ export class ManageUsersComponent implements OnInit { } resendEmail(member: Member) { - this.serverService.isServerAccessible().subscribe(canAccess => { - this.accountService.resendConfirmationEmail(member.id).subscribe(async (email) => { - if (canAccess) { - this.toastr.info(this.translocoService.translate('toasts.email-sent', {user: member.username})); - return; - } - await this.confirmService.alert( - this.translocoService.translate('toasts.click-email-link') + '
' + email + ''); - }); + this.accountService.resendConfirmationEmail(member.id).subscribe(async (response) => { + if (response.emailSent) { + this.toastr.info(this.translocoService.translate('toasts.email-sent', {email: member.username})); + return; + } + await this.confirmService.alert( + this.translocoService.translate('toasts.click-email-link') + '
' + response.emailLink + ''); }); } diff --git a/UI/Web/src/app/admin/settings.service.ts b/UI/Web/src/app/admin/settings.service.ts index 558eade38..eda7e4ccc 100644 --- a/UI/Web/src/app/admin/settings.service.ts +++ b/UI/Web/src/app/admin/settings.service.ts @@ -11,6 +11,7 @@ import { ServerSettings } from './_models/server-settings'; export interface EmailTestResult { successful: boolean; errorMessage: string; + emailAddress: string; } @Injectable({ @@ -46,12 +47,12 @@ export class SettingsService { return this.http.post(this.baseUrl + 'settings/reset-base-url', {}); } - resetEmailServerSettings() { - return this.http.post(this.baseUrl + 'settings/reset-email-url', {}); + testEmailServerSettings() { + return this.http.post(this.baseUrl + 'settings/test-email-url', {}); } - testEmailServerSettings(emailUrl: string) { - return this.http.post(this.baseUrl + 'settings/test-email-url', {url: emailUrl}); + isEmailSetup() { + return this.http.get(this.baseUrl + 'server/is-email-setup', TextResonse).pipe(map(d => d == "true")); } getTaskFrequencies() { diff --git a/UI/Web/src/app/registration/_components/confirm-email-change/confirm-email-change.component.ts b/UI/Web/src/app/registration/_components/confirm-email-change/confirm-email-change.component.ts index ef7489760..ddb383ff6 100644 --- a/UI/Web/src/app/registration/_components/confirm-email-change/confirm-email-change.component.ts +++ b/UI/Web/src/app/registration/_components/confirm-email-change/confirm-email-change.component.ts @@ -49,6 +49,9 @@ export class ConfirmEmailChangeComponent implements OnInit { this.accountService.confirmEmailUpdate({email: this.email, token: this.token}).subscribe((errors) => { this.confirmed = true; this.cdRef.markForCheck(); + + // Once we are confirmed, we need to refresh our user information (in case the user is already authenticated) + this.accountService.refreshAccount().subscribe(); setTimeout(() => this.router.navigateByUrl('login'), 2000); }); } diff --git a/UI/Web/src/app/user-settings/change-email/change-email.component.html b/UI/Web/src/app/user-settings/change-email/change-email.component.html index a87374efe..565d7704d 100644 --- a/UI/Web/src/app/user-settings/change-email/change-email.component.html +++ b/UI/Web/src/app/user-settings/change-email/change-email.component.html @@ -4,7 +4,7 @@
-

{{t('email-label')}} +

{{t('email-title')}} @if(emailConfirmed) { {{t('email-confirmed')}} diff --git a/UI/Web/src/app/user-settings/change-email/change-email.component.ts b/UI/Web/src/app/user-settings/change-email/change-email.component.ts index f320a6dfb..d8d0a6166 100644 --- a/UI/Web/src/app/user-settings/change-email/change-email.component.ts +++ b/UI/Web/src/app/user-settings/change-email/change-email.component.ts @@ -74,7 +74,13 @@ export class ChangeEmailComponent implements OnInit { if (updateEmailResponse.emailSent) { this.toastr.success(translate('toasts.email-sent-to')); } else { - this.toastr.success(translate('toasts.change-email-private')); + this.toastr.success(translate('toasts.change-email-no-email')); + this.accountService.refreshAccount().subscribe(user => { + this.user = user; + this.form.get('email')?.setValue(this.user?.email); + this.cdRef.markForCheck(); + }); + } this.isViewMode = true; diff --git a/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.html b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.html index 7c379ead9..b59ba815f 100644 --- a/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.html +++ b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.html @@ -14,6 +14,10 @@ {{t('description')}}

+ @if(hasEmailSetup) { + + } +
diff --git a/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.ts b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.ts index fcaa6df5a..f365eb886 100644 --- a/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.ts +++ b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.ts @@ -1,6 +1,10 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; -import { ToastrService } from 'ngx-toastr'; -import { Subject } from 'rxjs'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + inject, + OnInit +} from '@angular/core'; import { Device } from 'src/app/_models/device/device'; import { DeviceService } from 'src/app/_services/device.service'; import { DevicePlatformPipe } from '../../_pipes/device-platform.pipe'; @@ -9,6 +13,7 @@ import { NgIf, NgFor } from '@angular/common'; import { EditDeviceComponent } from '../edit-device/edit-device.component'; import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap'; import {TranslocoDirective} from "@ngneat/transloco"; +import {SettingsService} from "../../admin/settings.service"; @Component({ selector: 'app-manage-devices', @@ -18,26 +23,25 @@ import {TranslocoDirective} from "@ngneat/transloco"; standalone: true, imports: [NgbCollapse, EditDeviceComponent, NgIf, NgFor, SentenceCasePipe, DevicePlatformPipe, TranslocoDirective] }) -export class ManageDevicesComponent implements OnInit, OnDestroy { +export class ManageDevicesComponent implements OnInit { + + private readonly cdRef = inject(ChangeDetectorRef); + private readonly deviceService = inject(DeviceService); + private readonly settingsService = inject(SettingsService); devices: Array = []; addDeviceIsCollapsed: boolean = true; device: Device | undefined; - - - private readonly onDestroy = new Subject(); - - constructor(public deviceService: DeviceService, private toastr: ToastrService, - private readonly cdRef: ChangeDetectorRef) { } + hasEmailSetup = false; ngOnInit(): void { + this.settingsService.isEmailSetup().subscribe(res => { + this.hasEmailSetup = res; + this.cdRef.markForCheck(); + }); this.loadDevices(); } - ngOnDestroy(): void { - this.onDestroy.next(); - this.onDestroy.complete(); - } loadDevices() { this.addDeviceIsCollapsed = true; diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html index 2d538d2cf..33d662a24 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html @@ -17,406 +17,404 @@ } @defer (when tab.fragment === FragmentID.Preferences; prefetch on idle) { - -

- {{t('pref-description')}} -

+ +

+ {{t('pref-description')}} +

- -
-
-

- -

-
-
- -
-
- - - {{t('page-layout-mode-tooltip')}} - - - - -
- -
- - - {{t('locale-tooltip')}} - - - - -
- -
- -
-
-
- - -
- - {{t('blur-unread-summaries-tooltip')}} - - - -
-
- -
-
-
- - -
- - {{t('prompt-on-download-tooltip', {size: '100'})}} - - - -
-
- -
-
-
- - -
- - {{t('disable-animations-tooltip')}} - - - -
-
- -
-
-
- - - -
- {{t('collapse-series-relationships-tooltip')}} - - - -
-
- -
-
-
- - - -
- {{t('share-series-reviews-tooltip')}} - - - -
-
- -
- - -
-
-
-
-
- -
+ +
+

- +

- - {{t('reading-direction-tooltip')}} - - - - + + + {{t('page-layout-mode-tooltip')}} + + + + +
+ +
+ + + {{t('locale-tooltip')}} + + + + +
+
-
- - {{t('scaling-option-tooltip')}} - - - - -
-
- -
-
- - {{t('page-splitting-tooltip')}} - - - - -
-
- - -
-
- -
-
- - {{t('layout-mode-tooltip')}} - - - - -
-
- - -
-
- -
-
-
+
+
- - + +
+ + {{t('blur-unread-summaries-tooltip')}} + + +
-
-
-
- - -
-
-
-
-
-
-
+
+
- - + +
-
-
-
-
-
- - -
-
-
-
-
- - -
- + {{t('prompt-on-download-tooltip', {size: '100'})}} + + + +
+
+ +
+
+
+ + +
+ + {{t('disable-animations-tooltip')}} + + + +
+
+ +
+
+
+ + + +
+ {{t('collapse-series-relationships-tooltip')}} + + + +
+
+ +
+
+
+ + + +
+ {{t('share-series-reviews-tooltip')}} + + + +
+
+ +
+ + +
+ +
+
+
+ +
+

+ +

+
+
+ +
+
+ + {{t('reading-direction-tooltip')}} + + + + +
+ +
+ + {{t('scaling-option-tooltip')}} + + + + +
+
+ +
+
+ + {{t('page-splitting-tooltip')}} + + + + +
+
+ + +
+
+ +
+
+ + {{t('layout-mode-tooltip')}} + + + + +
+
+ + +
+
+ +
+
+
+
+ +
- -
-

- -

-
-
- -
-
- -
-
- - - {{t('tap-to-paginate-tooltip')}} - - - -
-
-
-
- -
-
- - - {{t('immersive-mode-label')}} - - - -
-
-
-
- -
-
- - {{t('reading-direction-book-tooltip')}} - - - - -
- - -
- - {{t('font-family-tooltip')}} - - - - -
-
- -
-
- - {{t('writing-style-tooltip')}} - - - - -
- -
- - {{t('layout-mode-book-tooltip')}} - - - - -
-
- -
- -
- - {{t('color-theme-book-tooltip')}} - - - - -
-
- -
-
- - - {{settingsForm.get('bookReaderFontSize')?.value + '%'}} -
- - -
-
- - {{t('line-height-book-tooltip')}} - - - -
- - {{settingsForm.get('bookReaderLineSpacing')?.value + '%'}} -
- -
-
- - {{t('margin-book-tooltip')}} - - - -
- - - {{settingsForm.get('bookReaderMargin')?.value + '%'}} -
-
- -
- - -
-
+
+
+
+ +
- - + +
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ +
+ + +
+ +
+
+
+ +
+

+ +

+
+
+ +
+
+ +
+
+ + + {{t('tap-to-paginate-tooltip')}} + + + +
+
+
+
+ +
+
+ + + {{t('immersive-mode-label')}} + + + +
+
+
+
+ +
+
+ + {{t('reading-direction-book-tooltip')}} + + + + +
+ + +
+ + {{t('font-family-tooltip')}} + + + + +
+
+ +
+
+ + {{t('writing-style-tooltip')}} + + + + +
+ +
+ + {{t('layout-mode-book-tooltip')}} + + + + +
+
+ +
+ +
+ + {{t('color-theme-book-tooltip')}} + + + + +
+
+ +
+
+ + + {{settingsForm.get('bookReaderFontSize')?.value + '%'}} +
+ + +
+
+ + {{t('line-height-book-tooltip')}} + + + +
+ + {{settingsForm.get('bookReaderLineSpacing')?.value + '%'}} +
+ +
+
+ + {{t('margin-book-tooltip')}} + + + +
+ + + {{settingsForm.get('bookReaderMargin')?.value + '%'}} +
+
+ +
+ + +
+
+
+
+
+
+ + } - - @defer (when tab.fragment === FragmentID.Clients; prefetch on idle) {

{{t('clients-opds-description')}}

diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 25e1507a6..16b1c9cc3 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -212,6 +212,7 @@ "no-devices": "There are no devices setup yet", "platform-label": "Platform: ", "email-label": "Email: ", + "email-setup-alert": "Want to send files to your devices? Have the your admin setup Email settings first!", "add": "{{common.add}}", "delete": "{{common.delete}}", "edit": "{{common.edit}}" @@ -242,7 +243,8 @@ }, "change-email": { - "email-label": "{{common.email}}", + "email-title": "Email", + "email-label": "New Email", "current-password-label": "Current Password", "email-not-confirmed": "This email is not confirmed", "email-confirmed": "This email is confirmed", @@ -1066,15 +1068,33 @@ "manage-email-settings": { "title": "Email Services (SMTP)", - "description": "Kavita comes out of the box with an email service to power tasks like inviting users, password reset requests, etc. Emails sent via our service are deleted immediately. You can use your own email service by setting up the {{link}} service. Set the URL of the email service and use the Test button to ensure it works. You can reset these settings to default at any time. There is no way to disable emails for authentication, although you are not required to use a valid email address for users. Confirmation links will always be saved to logs and presented in the UI. Registration/confirmation emails will not be sent if you are not accessing Kavita via a publicly reachable URL or unless the Host Name feature is configured.", - "send-to-warning": "If you want Send to Device to work you must host your own email service.", + "description": "In order to use some functions of Kavita like Forgot Password and Send To Device, an email provider must be setup. Other features like Password change are less secure without Email setup.", + "send-to-warning": "If you want Send to Device to work you must setup your email settings", "email-url-label": "Email Service URL", "email-url-tooltip": "Use fully qualified URL of the email service. Do not include ending slash.", + "email-settings-title": "Email Settings", "reset": "{{common.reset}}", "test": "Test", "host-name-label": "Host Name", "host-name-tooltip": "Domain Name (of Reverse Proxy). If set, email generation will always use this.", "host-name-validation": "Host name must start with http(s) and not end in /", + + "sender-address-label": "Sender Address", + "sender-address-tooltip": "", + "sender-displayname-label": "Display Name", + "sender-displayname-tooltip": "", + "host-label": "Host", + "host-tooltip": "", + "port-label": "Port", + "port-tooltip": "", + "username-label": "Username", + "password-label": "Password", + "enable-ssl-label": "Use SSL on Email Server", + "size-limit-label": "Size Limit", + "size-limit-tooltip": "How many bytes can the Email Server handle for attachments", + "customized-templates-label": "Customized Templates", + "customized-templates-tooltip": "Should Kavita use config/templates directory for templates rather than default? You are responsible to keep up to date with template changes.", + "reset-to-default": "{{common.reset-to-default}}", "save": "{{common.save}}" }, @@ -1964,6 +1984,7 @@ "file-send-to": "File(s) emailed to {{name}}", "theme-missing": "The active theme no longer exists. Please refresh the page.", "email-sent": "Email sent to {{email}}", + "email-not-sent-test": "There was an exception when sending the email. Check logs for details. This indicates improper settings.", "email-not-sent": "Email on file is not a valid email and can not be sent. A link has been dumped in logs. The admin can provide this link to complete flow.", "k+-license-saved": "License Key saved, but it is not valid. Click check to revalidate the subscription. First time registration may take a min to propagate.", "k+-unlocked": "Kavita+ unlocked!", @@ -1988,7 +2009,7 @@ "age-restriction-updated": "Age Restriction has been updated", "email-sent-to-no-existing": "Existing email is not valid. A link has been dumped to logs. Ask admin for link to complete email change.", "email-sent-to": "An email has been sent to your old email address for confirmation.", - "change-email-private": "The server is not publicly accessible. Ask the admin to fetch your confirmation link from the logs", + "change-email-no-email": "Email has been updated", "device-updated": "Device updated", "device-created": "Device created", "confirm-regen-covers": "Refresh covers will force all cover images to be recalculated. This is a heavy operation. Are you sure you don't want to perform a Scan instead?", diff --git a/build.sh b/build.sh index 33f081f9c..673620e3b 100755 --- a/build.sh +++ b/build.sh @@ -133,6 +133,8 @@ then cd "$dir" Package "osx-x64" cd "$dir" + Package "osx-arm64" + cd "$dir" else Package "$RID" cd "$dir" diff --git a/openapi.json b/openapi.json index 7db4fdfc3..35d5ce70a 100644 --- a/openapi.json +++ b/openapi.json @@ -315,7 +315,7 @@ "tags": [ "Account" ], - "summary": "Initiates the flow to update a user's email address. The email address is not changed in this API. A confirmation link is sent/dumped which will\r\nvalidate the email. It must be confirmed for the email to update.", + "summary": "Initiates the flow to update a user's email address.\r\n \r\nIf email is not setup, then the email address is not changed in this API. A confirmation link is sent/dumped which will\r\nvalidate the email. It must be confirmed for the email to update.", "requestBody": { "description": "", "content": { @@ -461,7 +461,7 @@ "tags": [ "Account" ], - "summary": "Invites a user to the server. Will generate a setup link for continuing setup. If the server is not accessible, no\r\nemail will be sent.", + "summary": "Invites a user to the server. Will generate a setup link for continuing setup. If email is not setup, a link will be presented to user to continue setup.", "requestBody": { "description": "", "content": { @@ -778,67 +778,17 @@ "content": { "text/plain": { "schema": { - "type": "string" + "$ref": "#/components/schemas/InviteUserResponse" } }, "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/InviteUserResponse" } }, "text/json": { "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/api/Account/migrate-email": { - "post": { - "tags": [ - "Account" - ], - "summary": "This is similar to invite. Essentially we authenticate the user's password then go through invite email flow", - "requestBody": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MigrateUserEmailDto" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/MigrateUserEmailDto" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/MigrateUserEmailDto" - } - } - } - }, - "responses": { - "200": { - "description": "Success", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - }, - "application/json": { - "schema": { - "type": "string" - } - }, - "text/json": { - "schema": { - "type": "string" + "$ref": "#/components/schemas/InviteUserResponse" } } } @@ -1670,7 +1620,9 @@ "tags": [ "Device" ], + "summary": "Sends a collection of chapters to the user's device", "requestBody": { + "description": "", "content": { "application/json": { "schema": { @@ -9848,36 +9800,6 @@ } } }, - "/api/Server/email-version": { - "get": { - "tags": [ - "Server" - ], - "summary": "Returns the KavitaEmail version for non-default instances", - "responses": { - "200": { - "description": "Success", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - }, - "application/json": { - "schema": { - "type": "string" - } - }, - "text/json": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, "/api/Server/check-for-updates": { "get": { "tags": [ @@ -10084,42 +10006,12 @@ } } }, - "/api/Settings/reset-email-url": { - "post": { - "tags": [ - "Settings" - ], - "summary": "Resets the email service url", - "responses": { - "200": { - "description": "Success", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/ServerSettingDto" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServerSettingDto" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ServerSettingDto" - } - } - } - } - } - } - }, "/api/Settings/test-email-url": { "post": { "tags": [ "Settings" ], - "summary": "Sends a test email from the Email Service. Will not send if email service is the Default Provider", + "summary": "Sends a test email from the Email Service.", "requestBody": { "description": "", "content": { @@ -10164,6 +10056,36 @@ } } }, + "/api/Settings/is-email-setup": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Is the minimum information setup for Email to work", + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "type": "boolean" + } + }, + "application/json": { + "schema": { + "type": "boolean" + } + }, + "text/json": { + "schema": { + "type": "boolean" + } + } + } + } + } + } + }, "/api/Settings/task-frequencies": { "get": { "tags": [ @@ -14965,6 +14887,10 @@ "errorMessage": { "type": "string", "nullable": true + }, + "emailAddress": { + "type": "string", + "nullable": true } }, "additionalProperties": false, @@ -15615,6 +15541,25 @@ }, "additionalProperties": false }, + "InviteUserResponse": { + "type": "object", + "properties": { + "emailLink": { + "type": "string", + "description": "Email link used to setup the user account", + "nullable": true + }, + "emailSent": { + "type": "boolean", + "description": "Was an email sent (ie is this server accessible)" + }, + "invalidEmail": { + "type": "boolean", + "description": "When a user has an invalid email and is attempting to perform a flow." + } + }, + "additionalProperties": false + }, "JobDto": { "type": "object", "properties": { @@ -16258,24 +16203,6 @@ "additionalProperties": false, "description": "Represents a member of a Kavita server." }, - "MigrateUserEmailDto": { - "type": "object", - "properties": { - "email": { - "type": "string", - "nullable": true - }, - "username": { - "type": "string", - "nullable": true - }, - "password": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, "NextExpectedChapterDto": { "type": "object", "properties": { @@ -18572,11 +18499,6 @@ "description": "Where Bookmarks are stored.", "nullable": true }, - "emailServiceUrl": { - "type": "string", - "description": "Email service to use for the invite user flow, forgot password, etc.", - "nullable": true - }, "installVersion": { "type": "string", "nullable": true @@ -18640,6 +18562,9 @@ "type": "integer", "description": "How large the cover images should be", "format": "int32" + }, + "smtpConfig": { + "$ref": "#/components/schemas/SmtpConfigDto" } }, "additionalProperties": false @@ -18922,6 +18847,48 @@ }, "additionalProperties": false }, + "SmtpConfigDto": { + "type": "object", + "properties": { + "senderAddress": { + "type": "string", + "nullable": true + }, + "senderDisplayName": { + "type": "string", + "nullable": true + }, + "userName": { + "type": "string", + "nullable": true + }, + "password": { + "type": "string", + "nullable": true + }, + "host": { + "type": "string", + "nullable": true + }, + "port": { + "type": "integer", + "format": "int32" + }, + "enableSsl": { + "type": "boolean" + }, + "sizeLimit": { + "type": "integer", + "description": "Limit in bytes for allowing files to be added as attachments. Defaults to 25MB", + "format": "int32" + }, + "customizedTemplates": { + "type": "boolean", + "description": "Should Kavita use config/templates for Email templates or the default ones" + } + }, + "additionalProperties": false + }, "SortOptions": { "type": "object", "properties": {