Private Email Service Support (#1028)

* Added ServerSettingKey's for SMTP and moved email service code to Kavita. Nothing integrated in the UI yet.

* Undo all the custom SMTP stuff and prepare for custom email service url.

* Foundation for email service to use a custom url is setup.

* Implemented the ability to hook up custom email url
This commit is contained in:
Joseph Milazzo 2022-02-04 09:54:54 -08:00 committed by GitHub
parent 2517ee75b2
commit 2ae9f8c203
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 193 additions and 54 deletions

View File

@ -101,6 +101,9 @@
<Compile Remove="logs\**" />
<Compile Remove="temp\**" />
<Compile Remove="covers\**" />
<Compile Remove="DTOs\Email\SmtpConfig.cs" />
<Compile Remove="DTOs\Email\EmailOptionsDto.cs" />
<Compile Remove="Helpers\Converters\SmtpConverter.cs" />
</ItemGroup>
<ItemGroup>

View File

@ -1,8 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Threading.Tasks;
using System.Web;
@ -17,8 +15,6 @@ using API.Errors;
using API.Extensions;
using API.Services;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Flurl.Util;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
@ -113,14 +109,6 @@ namespace API.Controllers
ApiKey = HashUtil.ApiKey()
};
// I am removing Authentication disabled code
// var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
// if (!settings.EnableAuthentication && !registerDto.IsAdmin)
// {
// _logger.LogInformation("User {UserName} is being registered as non-admin with no server authentication. Using default password", registerDto.Username);
// registerDto.Password = AccountService.DefaultPassword;
// }
var result = await _userManager.CreateAsync(user, registerDto.Password);
if (!result.Succeeded) return BadRequest(result.Errors);
@ -132,22 +120,6 @@ namespace API.Controllers
var roleResult = await _userManager.AddToRoleAsync(user, PolicyConstants.AdminRole);
if (!roleResult.Succeeded) return BadRequest(result.Errors);
// // When we register an admin, we need to grant them access to all Libraries.
// if (registerDto.IsAdmin)
// {
// _logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries",
// user.UserName);
// var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
// foreach (var lib in libraries)
// {
// lib.AppUsers ??= new List<AppUser>();
// lib.AppUsers.Add(user);
// }
//
// if (libraries.Any() && !await _unitOfWork.CommitAsync())
// _logger.LogError("There was an issue granting library access. Please do this manually");
// }
return new UserDto
{
Username = user.UserName,

View File

@ -4,14 +4,17 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Email;
using API.DTOs.Settings;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers.Converters;
using API.Services;
using AutoMapper;
using Flurl.Http;
using Kavita.Common;
using Kavita.Common.Extensions;
using Kavita.Common.Helpers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@ -25,15 +28,17 @@ namespace API.Controllers
private readonly ITaskScheduler _taskScheduler;
private readonly IDirectoryService _directoryService;
private readonly IMapper _mapper;
private readonly IEmailService _emailService;
public SettingsController(ILogger<SettingsController> logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler,
IDirectoryService directoryService, IMapper mapper)
IDirectoryService directoryService, IMapper mapper, IEmailService emailService)
{
_logger = logger;
_unitOfWork = unitOfWork;
_taskScheduler = taskScheduler;
_directoryService = directoryService;
_mapper = mapper;
_emailService = emailService;
}
[AllowAnonymous]
@ -64,6 +69,36 @@ namespace API.Controllers
return await UpdateSettings(_mapper.Map<ServerSettingDto>(Seed.DefaultSettings));
}
/// <summary>
/// Resets the email service url
/// </summary>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("reset-email-url")]
public async Task<ActionResult<ServerSettingDto>> ResetEmailServiceUrlSettings()
{
_logger.LogInformation("{UserName} is resetting Email Service Url Setting", User.GetUsername());
var emailSetting = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl);
emailSetting.Value = EmailService.DefaultApiUrl;
_unitOfWork.SettingsRepository.Update(emailSetting);
if (!await _unitOfWork.CommitAsync())
{
await _unitOfWork.RollbackAsync();
}
return Ok(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync());
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("test-email-url")]
public async Task<ActionResult<bool>> TestEmailServiceUrl(TestEmailDto dto)
{
return Ok(await _emailService.TestConnectivity(dto.Url));
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost]
public async Task<ActionResult<ServerSettingDto>> UpdateSettings(ServerSettingDto updateSettingsDto)
@ -173,6 +208,15 @@ namespace API.Controllers
await _taskScheduler.ScheduleStatsTasks();
}
}
if (setting.Key == ServerSettingKey.EmailServiceUrl && updateSettingsDto.EmailServiceUrl + string.Empty != setting.Value)
{
setting.Value = string.IsNullOrEmpty(updateSettingsDto.EmailServiceUrl) ? EmailService.DefaultApiUrl : updateSettingsDto.EmailServiceUrl;
FlurlHttp.ConfigureClient(setting.Value, cli =>
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
_unitOfWork.SettingsRepository.Update(setting);
}
}
if (!_unitOfWork.HasChanges()) return Ok(updateSettingsDto);

View File

@ -0,0 +1,6 @@
namespace API.DTOs.Email;
public class TestEmailDto
{
public string Url { get; set; }
}

View File

@ -32,5 +32,10 @@ namespace API.DTOs.Settings
/// </summary>
/// <remarks>If null or empty string, will default back to default install setting aka <see cref="DirectoryService.BookmarkDirectory"/></remarks>
public string BookmarksDirectory { get; set; }
/// <summary>
/// Email service to use for the invite user flow, forgot password, etc.
/// </summary>
/// <remarks>If null or empty string, will default back to default install setting aka <see cref="EmailService.DefaultApiUrl"/></remarks>
public string EmailServiceUrl { get; set; }
}
}

View File

@ -60,6 +60,7 @@ namespace API.Data
new () {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()},
new () {Key = ServerSettingKey.InstallVersion, Value = BuildInfo.Version.ToString()},
new () {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory},
new () {Key = ServerSettingKey.EmailServiceUrl, Value = EmailService.DefaultApiUrl},
};
foreach (var defaultSetting in DefaultSettings)

View File

@ -71,6 +71,10 @@ namespace API.Entities.Enums
/// </summary>
[Description("BookmarkDirectory")]
BookmarkDirectory = 12,
/// <summary>
/// If SMTP is enabled on the server
/// </summary>
[Description("CustomEmailService")]
EmailServiceUrl = 13,
}
}

View File

@ -39,7 +39,6 @@ namespace API.Extensions
services.AddScoped<IAccountService, AccountService>();
services.AddScoped<IEmailService, EmailService>();
services.AddScoped<IFileSystem, FileSystem>();
services.AddScoped<IFileService, FileService>();
services.AddScoped<ICacheHelper, CacheHelper>();

View File

@ -2,6 +2,7 @@
using System.Linq;
using API.DTOs;
using API.DTOs.CollectionTags;
using API.DTOs.Email;
using API.DTOs.Metadata;
using API.DTOs.Reader;
using API.DTOs.ReadingLists;
@ -148,7 +149,6 @@ namespace API.Helpers
CreateMap<IEnumerable<ServerSetting>, ServerSettingDto>()
.ConvertUsing<ServerSettingConverter>();
}
}
}

View File

@ -42,6 +42,9 @@ namespace API.Helpers.Converters
case ServerSettingKey.BookmarkDirectory:
destination.BookmarksDirectory = row.Value;
break;
case ServerSettingKey.EmailServiceUrl:
destination.EmailServiceUrl = row.Value;
break;
}
}

View File

@ -1,10 +1,11 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Email;
using API.Services.Tasks;
using API.Entities.Enums;
using Flurl.Http;
using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Helpers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
@ -16,25 +17,39 @@ public interface IEmailService
Task<bool> CheckIfAccessible(string host);
Task SendMigrationEmail(EmailMigrationDto data);
Task SendPasswordResetEmail(PasswordResetEmailDto data);
Task<bool> TestConnectivity(string emailUrl);
}
public class EmailService : IEmailService
{
private readonly ILogger<EmailService> _logger;
private const string ApiUrl = "https://email.kavitareader.com";
private readonly IUnitOfWork _unitOfWork;
public EmailService(ILogger<EmailService> logger)
/// <summary>
/// This is used to initially set or reset the ServerSettingKey. Do not access from the code, access via UnitOfWork
/// </summary>
public const string DefaultApiUrl = "https://email.kavitareader.com";
public EmailService(ILogger<EmailService> logger, IUnitOfWork unitOfWork)
{
_logger = logger;
_unitOfWork = unitOfWork;
FlurlHttp.ConfigureClient(ApiUrl, cli =>
FlurlHttp.ConfigureClient(DefaultApiUrl, cli =>
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
}
public async Task<bool> TestConnectivity(string emailUrl)
{
FlurlHttp.ConfigureClient(emailUrl, cli =>
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
return await SendEmailWithGet(emailUrl + "/api/email/test");
}
public async Task SendConfirmationEmail(ConfirmationEmailDto data)
{
var success = await SendEmailWithPost(ApiUrl + "/api/email/confirm", data);
var success = await SendEmailWithPost(DefaultApiUrl + "/api/email/confirm", data);
if (!success)
{
_logger.LogError("There was a critical error sending Confirmation email");
@ -43,17 +58,20 @@ public class EmailService : IEmailService
public async Task<bool> CheckIfAccessible(string host)
{
return await SendEmailWithGet(ApiUrl + "/api/email/reachable?host=" + host);
// This is the only exception for using the default because we need an external service to check if the server is accessible for emails
return await SendEmailWithGet(DefaultApiUrl + "/api/email/reachable?host=" + host);
}
public async Task SendMigrationEmail(EmailMigrationDto data)
{
await SendEmailWithPost(ApiUrl + "/api/email/email-migration", data);
var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value;
await SendEmailWithPost(emailLink + "/api/email/email-migration", data);
}
public async Task SendPasswordResetEmail(PasswordResetEmailDto data)
{
await SendEmailWithPost(ApiUrl + "/api/email/email-password-reset", data);
var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value;
await SendEmailWithPost(emailLink + "/api/email/email-password-reset", data);
}
private static async Task<bool> SendEmailWithGet(string url)
@ -106,4 +124,5 @@ public class EmailService : IEmailService
}
return true;
}
}

View File

@ -7,6 +7,7 @@ using API.DTOs.Stats;
using API.Entities.Enums;
using Flurl.Http;
using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Helpers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;

View File

@ -1,14 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using API.DTOs.Update;
using API.SignalR;
using API.SignalR.Presence;
using Flurl.Http;
using Flurl.Http.Configuration;
using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Helpers;
using MarkdownDeep;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Hosting;
@ -44,15 +43,6 @@ internal class GithubReleaseMetadata
public string Published_At { get; init; }
}
public class UntrustedCertClientFactory : DefaultHttpClientFactory
{
public override HttpMessageHandler CreateMessageHandler() {
return new HttpClientHandler {
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
}
}
public interface IVersionUpdaterService
{
Task<UpdateNotificationDto> CheckForUpdate();

View File

@ -0,0 +1,13 @@
using System.Net.Http;
using Flurl.Http.Configuration;
namespace Kavita.Common.Helpers;
public class UntrustedCertClientFactory : DefaultHttpClientFactory
{
public override HttpMessageHandler CreateMessageHandler() {
return new HttpClientHandler {
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
}
}

View File

@ -9,6 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Flurl.Http" Version="3.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="6.0.0" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.33.0.40503">

View File

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Flurl.Http" Version="3.2.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Kavita.Common\Kavita.Common.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="DTOs" />
</ItemGroup>
</Project>

View File

@ -8,4 +8,5 @@ export interface ServerSettings {
enableOpds: boolean;
baseUrl: string;
bookmarksDirectory: string;
emailServiceUrl: string;
}

View File

@ -1,6 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { Library } from 'src/app/_models/library';

View File

@ -64,6 +64,28 @@
<label for="opds" class="form-check-label">Enable OPDS</label>
</div>
</div>
<h4>Email Services (SMTP)</h4>
<p class="accent">Kavita comes out of the box with an email service to power flows like invite user, forgot password, etc. Emails sent via our service are deleted immediately. You can use your own
email service. Set the url of the email service and use the Test button to ensure it works. At any time you can reset to ours. There is no way to disable emails.
</p>
<div class="form-group">
<label for="settings-emailservice">Email Service Url</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="emailServiceTooltip" role="button" tabindex="0"></i>
<ng-template #emailServiceTooltip>Location where bookmarks will be stored. Bookmarks are source files and can be large. Choose a location with adequate storage. Directory is managed, other files within directory will be deleted.</ng-template>
<span class="sr-only" id="settings-emailservice-help"><ng-container [ngTemplateOutlet]="emailServiceTooltip"></ng-container></span>
<div class="input-group">
<input id="settings-emailservice" aria-describedby="settings-emailservice-help" class="form-control" formControlName="emailServiceUrl" type="text" aria-describedby="change-bookmarks-dir">
<div class="input-group-append">
<button class="btn btn-secondary" (click)="resetEmailServiceUrl()">
Reset
</button>
<button class="btn btn-secondary" (click)="testEmailServiceUrl()">
Test
</button>
</div>
</div>
</div>
<h4>Reoccuring Tasks</h4>
<div class="form-group">

View File

@ -41,6 +41,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.addControl('allowStatCollection', new FormControl(this.serverSettings.allowStatCollection, [Validators.required]));
this.settingsForm.addControl('enableOpds', new FormControl(this.serverSettings.enableOpds, [Validators.required]));
this.settingsForm.addControl('baseUrl', new FormControl(this.serverSettings.baseUrl, [Validators.required]));
this.settingsForm.addControl('emailServiceUrl', new FormControl(this.serverSettings.emailServiceUrl, [Validators.required]));
});
}
@ -54,6 +55,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.get('allowStatCollection')?.setValue(this.serverSettings.allowStatCollection);
this.settingsForm.get('enableOpds')?.setValue(this.serverSettings.enableOpds);
this.settingsForm.get('baseUrl')?.setValue(this.serverSettings.baseUrl);
this.settingsForm.get('emailServiceUrl')?.setValue(this.serverSettings.emailServiceUrl);
}
async saveSettings() {
@ -90,4 +92,28 @@ export class ManageSettingsComponent implements OnInit {
});
}
resetEmailServiceUrl() {
this.settingsService.resetEmailServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => {
this.serverSettings.emailServiceUrl = settings.emailServiceUrl;
this.resetForm();
this.toastr.success('Email Service Reset');
}, (err: any) => {
console.error('error: ', err);
});
}
testEmailServiceUrl() {
this.settingsService.testEmailServerSettings(this.settingsForm.get('emailServiceUrl')?.value || '').pipe(take(1)).subscribe(async (successful: boolean) => {
if (successful) {
this.toastr.success('Email Service Url validated');
} else {
this.toastr.error('Email Service Url did not respond');
}
}, (err: any) => {
console.error('error: ', err);
});
}
}

View File

@ -25,6 +25,14 @@ export class SettingsService {
return this.http.post<ServerSettings>(this.baseUrl + 'settings/reset', {});
}
resetEmailServerSettings() {
return this.http.post<ServerSettings>(this.baseUrl + 'settings/reset-email-url', {});
}
testEmailServerSettings(emailUrl: string) {
return this.http.post<boolean>(this.baseUrl + 'settings/test-email-url', {url: emailUrl}, {responseType: 'text' as 'json'});
}
getTaskFrequencies() {
return this.http.get<string[]>(this.baseUrl + 'settings/task-frequencies');
}