Email is now Built-in! (#2635)

This commit is contained in:
Joe Milazzo 2024-01-20 11:16:54 -06:00 committed by GitHub
parent 2a539da24c
commit a85644fb6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 5129 additions and 1047 deletions

View file

@ -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();
});
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -2,25 +2,10 @@
<div class="container-fluid">
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
<h4>{{t('title')}}</h4>
<p [innerHTML]="t('description', {link: link}) | safeHtml">
<span class="text-warning">{{t('send-to-warning')}}</span>
</p>
<div class="mb-3">
<label for="settings-emailservice" class="form-label">{{t('email-url-label')}}</label><i class="ms-1 fa fa-info-circle" placement="right" [ngbTooltip]="emailServiceTooltip" role="button" tabindex="0"></i>
<ng-template #emailServiceTooltip>{{t('email-url-tooltip')}}</ng-template>
<span class="visually-hidden" 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="url" autocapitalize="off" inputmode="url">
<button class="btn btn-outline-secondary" (click)="resetEmailServiceUrl()">
{{t('reset')}}
</button>
<button class="btn btn-outline-secondary" (click)="testEmailServiceUrl()">
{{t('test')}}
</button>
</div>
</div>
<div class="mb-3">
<p>You must fill out both Host Name and SMTP settings to use email-based functionality within Kavita.</p>
<div class="mb-3 pe-2 ps-2 ">
<label for="settings-hostname" class="form-label">{{t('host-name-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="hostNameTooltip" role="button" tabindex="0"></i>
<ng-template #hostNameTooltip>{{t('host-name-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-hostname-help">
@ -35,7 +20,93 @@
</div>
</div>
<div class="mt-3">
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 ps-2 mb-2">
<label for="settings-sender-address" class="form-label">{{t('sender-address-label')}}</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="senderAddressTooltip" role="button" tabindex="0"></i>
<ng-template #senderAddressTooltip>{{t('sender-address-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-sender-address-help"><ng-container [ngTemplateOutlet]="senderAddressTooltip"></ng-container></span>
<input type="text" class="form-control" aria-describedby="manga-header" formControlName="senderAddress" id="settings-sender-address" />
</div>
<div class="col-md-6 col-sm-12 pe-2 ps-2 mb-2">
<label for="settings-sender-displayname" class="form-label">{{t('sender-displayname-label')}}</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="senderDisplayNameTooltip" role="button" tabindex="0"></i>
<ng-template #senderDisplayNameTooltip>{{t('sender-displayname-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-sender-displayname-help"><ng-container [ngTemplateOutlet]="senderDisplayNameTooltip"></ng-container></span>
<input type="text" class="form-control" aria-describedby="manga-header" formControlName="senderDisplayName" id="settings-sender-displayname" />
</div>
</div>
<div class="row g-0">
<div class="col-md-4 col-sm-12 pe-2 ps-2 mb-2">
<label for="settings-sender-address" class="form-label">{{t('host-label')}}</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="hostTooltip" role="button" tabindex="0"></i>
<ng-template #hostTooltip>{{t('host-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-host-help"><ng-container [ngTemplateOutlet]="hostTooltip"></ng-container></span>
<input type="text" class="form-control" aria-describedby="manga-header" formControlName="host" id="settings-host" />
</div>
<div class="col-md-4 col-sm-12 pe-2 ps-2 mb-2">
<label for="settings-port" class="form-label">{{t('port-label')}}</label>
<input type="number" min="1" class="form-control" aria-describedby="manga-header" formControlName="port" id="settings-port" />
</div>
<div class="col-md-4 col-sm-12 pe-2 ps-2 mb-2">
<div class="form-check form-switch" style="margin-top: 36px">
<input type="checkbox" id="settings-enable-ssl" role="switch" formControlName="enableSsl" class="form-check-input"
aria-labelledby="auto-close-label">
<label class="form-check-label" for="settings-enable-ssl">{{t('enable-ssl-label')}}</label>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 ps-2 mb-2">
<label for="settings-username" class="form-label">{{t('username-label')}}</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="usernameTooltip" role="button" tabindex="0"></i>
<ng-template #usernameTooltip>{{t('username-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-username-help"><ng-container [ngTemplateOutlet]="usernameTooltip"></ng-container></span>
<input type="text" class="form-control" aria-describedby="manga-header" formControlName="userName" id="settings-username" />
</div>
<div class="col-md-6 col-sm-12 pe-2 ps-2 mb-2">
<label for="settings-password" class="form-label">{{t('password-label')}}</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="passwordTooltip" role="button" tabindex="0"></i>
<ng-template #passwordTooltip>{{t('password-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-password-help"><ng-container [ngTemplateOutlet]="usernameTooltip"></ng-container></span>
<input type="password" class="form-control" aria-describedby="manga-header" formControlName="password" id="settings-password" />
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 ps-2 mb-2">
<label for="settings-size-limit" class="form-label">{{t('size-limit-label')}}</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="sizeLimitTooltip" role="button" tabindex="0"></i>
<ng-template #sizeLimitTooltip>{{t('size-limit-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-size-limit-help"><ng-container [ngTemplateOutlet]="sizeLimitTooltip"></ng-container></span>
<input type="text" class="form-control" aria-describedby="manga-header" formControlName="sizeLimit" id="settings-size-limit" />
</div>
<div class="col-md-6 col-sm-12 pe-2 ps-2 mb-2">
<div class="form-check form-switch" style="margin-top: 36px">
<input type="checkbox" id="settings-customized-templates" role="switch" formControlName="customizedTemplates" class="form-check-input"
aria-labelledby="auto-close-label">
<label class="form-check-label" for="settings-customized-templates">{{t('customized-templates-label')}}</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="customizedTemplatesTooltip" role="button" tabindex="0"></i>
<ng-template #customizedTemplatesTooltip>{{t('customized-templates-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-customized-templates-help"><ng-container [ngTemplateOutlet]="customizedTemplatesTooltip"></ng-container></span>
</div>
</div>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="test()">{{t('test')}}</button>
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetToDefaults()">{{t('reset-to-default')}}</button>
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()">{{t('reset')}}</button>
<button type="submit" class="flex-fill btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.dirty">{{t('save')}}</button>

View file

@ -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 = '<a href="https://github.com/Kareadita/KavitaEmail" target="_blank" rel="noopener noreferrer">Kavita Email</a>';
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);
});
}
}

View file

@ -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') + '<br/> <a href="' + email + '" target="_blank" rel="noopener noreferrer">' + email + '</a>');
});
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') + '<br/> <a href="' + response.emailLink + '" target="_blank" rel="noopener noreferrer">' + response.emailLink + '</a>');
});
}

View file

@ -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<ServerSettings>(this.baseUrl + 'settings/reset-base-url', {});
}
resetEmailServerSettings() {
return this.http.post<ServerSettings>(this.baseUrl + 'settings/reset-email-url', {});
testEmailServerSettings() {
return this.http.post<EmailTestResult>(this.baseUrl + 'settings/test-email-url', {});
}
testEmailServerSettings(emailUrl: string) {
return this.http.post<EmailTestResult>(this.baseUrl + 'settings/test-email-url', {url: emailUrl});
isEmailSetup() {
return this.http.get<string>(this.baseUrl + 'server/is-email-setup', TextResonse).pipe(map(d => d == "true"));
}
getTaskFrequencies() {