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.Http; using Kavita.Common; using Kavita.Common.EnvironmentInfo; 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 SendInviteEmail(ConfirmationEmailDto data); Task CheckIfAccessible(string host); Task SendForgotPasswordEmail(PasswordResetEmailDto dto); Task SendFilesToEmail(SendToDto data); Task SendTestEmail(string adminEmail); Task IsDefaultEmailService(); Task SendEmailChangeEmail(ConfirmationEmailDto data); bool IsValidEmail(string email); } 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, IDirectoryService directoryService) { _logger = logger; _unitOfWork = unitOfWork; _downloadService = downloadService; _directoryService = directoryService; } /// /// Test if the email settings are working. Rejects if user email isn't valid or not all data is setup in server settings. /// /// public async Task SendTestEmail(string adminEmail) { 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 { var emailOptions = new EmailOptionsDto() { Subject = "KavitaEmail Test", Body = UpdatePlaceHolders(await GetEmailBody("EmailTest"), placeholders), ToEmails = new List() { adminEmail } }; await SendEmail(emailOptions); result.Successful = true; } catch (KavitaException ex) { result.Successful = false; result.ErrorMessage = ex.Message; } 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 placeholders = new List> { new ("{{InvitingUser}}", data.InvitingUser), new ("{{Link}}", data.ServerConfirmationLink) }; var emailOptions = new EmailOptionsDto() { Subject = UpdatePlaceHolders("Your email has been changed on {{InvitingUser}}'s Server", placeholders), Body = UpdatePlaceHolders(await GetEmailBody("EmailChange"), placeholders), ToEmails = new List() { data.EmailAddress } }; await SendEmail(emailOptions); } /// /// Validates the email address. Does not test it actually receives mail /// /// /// public bool IsValidEmail(string email) { return new EmailAddressAttribute().IsValid(email); } /// /// Sends an invite email to a user to setup their account /// /// public async Task SendInviteEmail(ConfirmationEmailDto data) { var placeholders = new List> { new ("{{InvitingUser}}", data.InvitingUser), new ("{{Link}}", data.ServerConfirmationLink) }; var emailOptions = new EmailOptionsDto() { Subject = UpdatePlaceHolders("You've been invited to join {{InvitingUser}}'s Server", placeholders), Body = UpdatePlaceHolders(await GetEmailBody("EmailConfirm"), placeholders), ToEmails = new List() { data.EmailAddress } }; await SendEmail(emailOptions); } public Task CheckIfAccessible(string host) { return Task.FromResult(true); } public async Task SendForgotPasswordEmail(PasswordResetEmailDto dto) { var placeholders = new List> { new ("{{Link}}", dto.ServerConfirmationLink), }; var emailOptions = new EmailOptionsDto() { Subject = UpdatePlaceHolders("A password reset has been requested", placeholders), Body = UpdatePlaceHolders(await GetEmailBody("EmailPasswordReset"), placeholders), ToEmails = new List() { dto.EmailAddress } }; await SendEmail(emailOptions); return true; } public async Task SendFilesToEmail(SendToDto data) { 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 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 { await smtpClient.SendAsync(email); } catch (Exception ex) { _logger.LogError(ex, "There was an issue sending the email"); throw; } finally { await smtpClient.DisconnectAsync(true); } } private async Task GetTemplatePath(string templateName) { if ((await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).SmtpConfig.CustomizedTemplates) { 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); } 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)) { text = text.Replace(key, value); } } return text; } }