1
0
mirror of https://github.com/bpatrik/pigallery2.git synced 2025-01-12 04:23:09 +02:00

Creating E-mail messenger job and e-mail messaging config #683

This commit is contained in:
Patrik J. Braun 2023-07-30 12:21:11 +02:00
parent 35340b2c04
commit 763e982d2d
18 changed files with 424 additions and 14 deletions

19
package-lock.json generated
View File

@ -76,6 +76,7 @@
"@types/leaflet.markercluster": "1.5.1",
"@types/node": "18.15.0",
"@types/node-geocoder": "4.2.0",
"@types/nodemailer": "^6.4.9",
"@types/sharp": "0.31.1",
"@types/xml2js": "0.4.11",
"@typescript-eslint/eslint-plugin": "5.54.1",
@ -4892,6 +4893,15 @@
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/@types/nodemailer": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.9.tgz",
"integrity": "sha512-XYG8Gv+sHjaOtUpiuytahMy2mM3rectgroNbs6R3djZEKmPNiIJwe9KqOJBGzKKnNZNKvnuvmugBgpq3w/S0ig==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/parse-json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@ -26718,6 +26728,15 @@
}
}
},
"@types/nodemailer": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.9.tgz",
"integrity": "sha512-XYG8Gv+sHjaOtUpiuytahMy2mM3rectgroNbs6R3djZEKmPNiIJwe9KqOJBGzKKnNZNKvnuvmugBgpq3w/S0ig==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/parse-json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",

View File

@ -46,7 +46,7 @@
"image-size": "1.0.2",
"locale": "0.1.0",
"node-geocoder": "4.2.0",
"nodemailer": "^6.9.4",
"nodemailer": "6.9.4",
"reflect-metadata": "0.1.13",
"sharp": "0.31.3",
"ts-exif-parser": "0.2.2",
@ -96,6 +96,7 @@
"@types/leaflet.markercluster": "1.5.1",
"@types/node": "18.15.0",
"@types/node-geocoder": "4.2.0",
"@types/nodemailer": "6.4.9",
"@types/sharp": "0.31.1",
"@types/xml2js": "0.4.11",
"@typescript-eslint/eslint-plugin": "5.54.1",

View File

@ -9,6 +9,7 @@ import {SharingDTO} from '../../common/entities/SharingDTO';
import {Utils} from '../../common/Utils';
import {LoggerRouter} from '../routes/LoggerRouter';
import {TAGS} from '../../common/config/public/ClientConfig';
import {ConfigDiagnostics} from '../model/diagnostics/ConfigDiagnostics';
const forcedDebug = process.env['NODE_ENV'] === 'debug';
@ -108,6 +109,7 @@ export class RenderingMWs {
res: Response
): Promise<void> {
const originalConf = await Config.original();
await ConfigDiagnostics.checkEnvironment(originalConf);
// These are sensitive information, do not send to the client side
originalConf.Server.sessionSecret = null;
const message = new Message<PrivateConfigClass>(

View File

@ -26,6 +26,8 @@ import {
import {SearchQueryParser} from '../../../common/SearchQueryParser';
import {SearchQueryTypes, TextSearch,} from '../../../common/entities/SearchQueryDTO';
import {Utils} from '../../../common/Utils';
import {createTransport} from 'nodemailer';
import {EmailMessagingType, MessagingConfig} from '../../../common/config/private/MessagingConfig';
const LOG_TAG = '[ConfigDiagnostics]';
@ -79,6 +81,14 @@ export class ConfigDiagnostics {
}
}
private static async testEmailMessagingConfig(Messaging: MessagingConfig, config: PrivateConfigClass): Promise<void> {
Logger.debug(LOG_TAG, 'Testing EmailMessaging config');
if(Messaging.Email.type === EmailMessagingType.sendmail && !Config.Environment.sendMailAvailable){
throw new Error('sendmail e-mail sending method is not supported as the sendmail application cannot be found in the OS.')
}
}
static testVideoConfig(videoConfig: ServerVideoConfig,
config: PrivateConfigClass): Promise<void> {
Logger.debug(LOG_TAG, 'Testing video config with ffmpeg test');
@ -285,15 +295,48 @@ export class ConfigDiagnostics {
await ConfigDiagnostics.testSharingConfig(config.Sharing, config);
await ConfigDiagnostics.testRandomPhotoConfig(config.Sharing, config);
await ConfigDiagnostics.testMapConfig(config.Map);
await ConfigDiagnostics.testEmailMessagingConfig(config.Messaging, config);
}
static async checkEnvironment(Config:PrivateConfigClass): Promise<void> {
Logger.debug(LOG_TAG, 'Checking sendmail availability');
const transporter = createTransport({
sendmail: true,
});
try {
Config.Environment.sendMailAvailable = await transporter.verify();
} catch (e) {
Config.Environment.sendMailAvailable = false;
}
if (!Config.Environment.sendMailAvailable) {
Config.Messaging.Email.type = EmailMessagingType.SMTP;
Logger.info(LOG_TAG, 'Sendmail is not available on the OS. You will need to use an SMTP server if you wish the app to send mails.');
}
}
static async runDiagnostics(): Promise<void> {
if (process.env['NODE_ENV'] === 'debug') {
NotificationManager.warning('You are running the application with NODE_ENV=debug. This exposes a lot of debug information that can be a security vulnerability. Set NODE_ENV=production, when you finished debugging.');
}
try {
await ConfigDiagnostics.checkEnvironment(Config);
} catch (ex) {
const err: Error = ex;
NotificationManager.error(
'Error during checking environment',
err.toString()
);
Logger.error(
LOG_TAG,
'Error during checking environment',
err.toString()
);
process.exit(1);
}
try {
await ConfigDiagnostics.testDatabase(Config.Database);
} catch (ex) {
@ -525,5 +568,23 @@ export class ConfigDiagnostics {
);
Config.Map.mapProvider = MapProviders.OpenStreetMap;
}
try {
await ConfigDiagnostics.testEmailMessagingConfig(Config.Messaging, Config);
} catch (ex) {
const err: Error = ex;
NotificationManager.warning(
'Setting to SMTP method.',
err.toString()
);
Logger.warn(
LOG_TAG,
'Setting to SMTP method.',
err.toString()
);
Config.Messaging.Email.type = EmailMessagingType.SMTP;
}
}
}

View File

@ -154,6 +154,7 @@ export abstract class Job<T extends Record<string, any> = Record<string, any>> i
this.run();
} catch (e) {
Logger.error(LOG_TAG, e);
this.Progress.State = JobProgressStates.failed;
}
});
}

View File

@ -5,9 +5,18 @@ import {SortingMethods} from '../../../../common/entities/SortingMethods';
import {DatePatternFrequency, DatePatternSearch, SearchQueryDTO, SearchQueryTypes} from '../../../../common/entities/SearchQueryDTO';
import {ObjectManagers} from '../../ObjectManagers';
import {PhotoEntity} from '../../database/enitites/PhotoEntity';
import {EmailMediaMessenger} from '../../mediamessengers/EmailMediaMessenger';
export class TopPickSendJob extends Job<{ searchQuery: SearchQueryDTO, sortBy: SortingMethods[], pickAmount: number }> {
export class TopPickSendJob extends Job<{
searchQuery: SearchQueryDTO,
sortBy: SortingMethods[],
pickAmount: number,
emailTo: string,
emailFrom: string,
emailSubject: string,
emailText: string,
}> {
public readonly Name = DefaultsJobs[DefaultsJobs['Top Pick Sending']];
public readonly Supported: boolean = true;
public readonly ConfigTemplate: ConfigTemplateEntry[] = [
@ -33,6 +42,30 @@ export class TopPickSendJob extends Job<{ searchQuery: SearchQueryDTO, sortBy: S
name: backendTexts.pickAmount.name,
description: backendTexts.pickAmount.description,
defaultValue: 5,
}, {
id: 'emailTo',
type: 'email',
name: backendTexts.emailTo.name,
description: backendTexts.emailTo.description,
defaultValue: '',
}, {
id: 'emailFrom',
type: 'email',
name: backendTexts.emailFrom.name,
description: backendTexts.emailFrom.description,
defaultValue: 'norelpy@pigallery2.com',
}, {
id: 'emailSubject',
type: 'string',
name: backendTexts.emailSubject.name,
description: backendTexts.emailSubject.description,
defaultValue: 'Latest photos for you',
}, {
id: 'emailText',
type: 'string',
name: backendTexts.emailText.name,
description: backendTexts.emailText.description,
defaultValue: 'I hand picked these photos just for you:',
},
];
private status: 'Listing' | 'Sending' = 'Listing';
@ -64,11 +97,20 @@ export class TopPickSendJob extends Job<{ searchQuery: SearchQueryDTO, sortBy: S
this.Progress.log('Collecting Photos and videos to Send');
this.Progress.Processed++;
this.mediaList = await ObjectManagers.getInstance().SearchManager.getNMedia(this.config.searchQuery, this.config.sortBy, this.config.pickAmount);
// console.log(this.mediaList);
// console.log(this.mediaList);
return false;
}
private async stepSending(): Promise<boolean> {
this.Progress.log('Sending emails');
const messenger = new EmailMediaMessenger();
await messenger.sendMedia({
from: this.config.emailFrom,
to: this.config.emailTo,
subject: this.config.emailSubject,
text: this.config.emailText
}, this.mediaList);
this.Progress.Processed++;
return false;
}
}

View File

@ -0,0 +1,43 @@
import {createTransport, Transporter} from 'nodemailer';
import {MediaDTO} from '../../../common/entities/MediaDTO';
import {Config} from '../../../common/config/private/Config';
import {EmailMessagingType} from '../../../common/config/private/MessagingConfig';
export class EmailMediaMessenger {
transporter: Transporter;
constructor() {
if (Config.Messaging.Email.type === EmailMessagingType.sendmail) {
this.transporter = createTransport({
sendmail: true
});
} else {
this.transporter = createTransport({
host: Config.Messaging.Email.smtp.host,
port: Config.Messaging.Email.smtp.port,
secure: Config.Messaging.Email.smtp.secure,
requireTLS: Config.Messaging.Email.smtp.requireTLS,
auth: {
user: Config.Messaging.Email.smtp.user,
pass: Config.Messaging.Email.smtp.password
}
});
}
}
public async sendMedia(mailSettings: {
from: string,
to: string,
subject: string,
text: string
}, media: MediaDTO[]) {
return await this.transporter.sendMail({
from: mailSettings.from,
to: mailSettings.to,
subject: mailSettings.subject,
text: mailSettings.text + media.map(m => m.name).join(', ')
});
}
}

View File

@ -1,10 +1,14 @@
export type backendText = number;
export const backendTexts = {
indexedFilesOnly: { name: 10, description: 12 },
sizeToGenerate: { name: 20, description: 22 },
indexChangesOnly: { name: 30, description: 32 },
searchQuery: { name: 40, description: 42 },
sortBy: { name: 40, description: 42 },
pickAmount: { name: 40, description: 42 },
indexedFilesOnly: {name: 10, description: 12},
sizeToGenerate: {name: 20, description: 22},
indexChangesOnly: {name: 30, description: 32},
searchQuery: {name: 40, description: 42},
sortBy: {name: 100, description: 102},
pickAmount: {name: 50, description: 52},
emailTo: {name: 60, description: 62},
emailFrom: {name: 70, description: 72},
emailSubject: {name: 80, description: 82},
emailText: {name: 90, description: 92}
};

View File

@ -0,0 +1,109 @@
/* eslint-disable @typescript-eslint/no-inferrable-types */
import {SubConfigClass} from '../../../../node_modules/typeconfig/src/decorators/class/SubConfigClass';
import {ConfigPriority, TAGS} from '../public/ClientConfig';
import {ConfigProperty} from '../../../../node_modules/typeconfig/src/decorators/property/ConfigPropoerty';
import {ServerConfig} from './PrivateConfig';
export enum EmailMessagingType {
sendmail = 1,
SMTP = 2,
}
@SubConfigClass<TAGS>({softReadonly: true})
export class EmailSMTPMessagingConfig {
@ConfigProperty({
tags: {
name: $localize`Host`,
priority: ConfigPriority.advanced,
hint: 'smtp.example.com'
},
description: $localize`SMTP host server`
})
host: string = '';
@ConfigProperty({
tags: {
name: $localize`Port`,
priority: ConfigPriority.advanced,
},
description: $localize`SMTP server's port`
})
port: number = 587;
@ConfigProperty({
tags: {
name: $localize`isSecure`,
priority: ConfigPriority.advanced,
},
description: $localize`Is the connection secure. See https://nodemailer.com/smtp/#tls-options for more details`
})
secure: boolean = false;
@ConfigProperty({
tags: {
name: $localize`TLS required`,
priority: ConfigPriority.advanced,
},
description: $localize`if this is true and secure is false then Nodemailer (used library in the background) tries to use STARTTLS. See https://nodemailer.com/smtp/#tls-options for more details`
})
requireTLS: boolean = true;
@ConfigProperty({
tags: {
name: $localize`User`,
priority: ConfigPriority.advanced,
},
description: $localize`User to connect to the SMTP server.`
})
user: string = '';
@ConfigProperty({
tags: {
name: $localize`Password`,
priority: ConfigPriority.advanced,
},
type: 'password',
description: $localize`Password to connect to the SMTP server.`
})
password: string = '';
}
@SubConfigClass<TAGS>({softReadonly: true})
export class EmailMessagingConfig {
@ConfigProperty<EmailMessagingType, EmailMessagingConfig>({
type: EmailMessagingType,
tags:
{
name: $localize`Sending method`,
priority: ConfigPriority.advanced,
uiDisabled: (sc: EmailMessagingConfig, c: ServerConfig) => !c.Environment.sendMailAvailable
} as TAGS,
description: $localize`Sendmail uses the built in unix binary if available. STMP connects to any STMP server of your choice.`
})
type: EmailMessagingType = EmailMessagingType.sendmail;
@ConfigProperty({
tags:
{
name: $localize`SMTP`,
relevant: (c: any) => c.type === EmailMessagingType.SMTP,
}
})
smtp?: EmailSMTPMessagingConfig = new EmailSMTPMessagingConfig();
}
@SubConfigClass<TAGS>({softReadonly: true})
export class MessagingConfig {
@ConfigProperty({
tags:
{
name: $localize`Email`,
},
description: $localize`The app uses Nodemailer in the background for sending e-mails. Refer to https://nodemailer.com/usage/ if some options are not clear.`
})
Email: EmailMessagingConfig = new EmailMessagingConfig();
}

View File

@ -30,6 +30,7 @@ import {DefaultsJobs} from '../../entities/job/JobDTO';
import {SearchQueryDTO, SearchQueryTypes, TextSearch,} from '../../entities/SearchQueryDTO';
import {SortingMethods} from '../../entities/SortingMethods';
import {UserRoles} from '../../entities/UserDTO';
import {MessagingConfig} from './MessagingConfig';
declare let $localize: (s: TemplateStringsArray) => string;
@ -395,7 +396,7 @@ export class ServerMetaFileConfig extends ClientMetaFileConfig {
uiJob: [{
job: DefaultsJobs[DefaultsJobs['GPX Compression']],
relevant: (c) => c.MetaFile.GPXCompressing.enabled
},{
}, {
job: DefaultsJobs[DefaultsJobs['Delete Compressed GPX']],
relevant: (c) => c.MetaFile.GPXCompressing.enabled
}]
@ -1049,8 +1050,14 @@ export class ServerEnvironmentConfig {
buildCommitHash: string | undefined;
@ConfigProperty({volatile: true})
isDocker: boolean | undefined;
@ConfigProperty({
volatile: true,
description: 'App updates on start-up if sendmail binary is available'
})
sendMailAvailable: boolean | undefined;
}
@SubConfigClass<TAGS>({softReadonly: true})
export class ServerConfig extends ClientConfig {
@ -1144,6 +1151,16 @@ export class ServerConfig extends ClientConfig {
})
Duplicates: ServerDuplicatesConfig = new ServerDuplicatesConfig();
@ConfigProperty({
tags: {
name: $localize`Messaging`,
uiIcon: 'chat',
githubIssue: 683
} as TAGS,
description: $localize`The App can send messages (like photos on the same day a year ago. aka: "Top Pick"). Here you can configure the delivery method.`
})
Messaging: MessagingConfig = new MessagingConfig();
@ConfigProperty({
tags: {
name: $localize`Jobs`,

View File

@ -1,6 +1,6 @@
import {backendText} from '../../BackendTexts';
export type fieldType = 'string' | 'number' | 'boolean' | 'number-array' | 'SearchQuery' | 'sort-array';
export type fieldType = 'string' | 'email' | 'number' | 'boolean' | 'number-array' | 'SearchQuery' | 'sort-array';
export enum DefaultsJobs {
Indexing = 1,

View File

@ -4,6 +4,7 @@ export enum JobProgressStates {
interrupted = 3,
canceled = 4,
finished = 5,
failed = 6,
}
export interface JobProgressLogDTO {

View File

@ -17,8 +17,34 @@ export class BackendtextService {
return $localize`Only checks indexed files.`;
case backendTexts.indexChangesOnly.name:
return $localize`Index changes only`;
case backendTexts.indexChangesOnly.description:
return $localize`Only indexes a folder if it got changed.`;
case backendTexts.searchQuery.name:
return $localize`Search query`;
case backendTexts.searchQuery.description:
return $localize`Search query to list photos and videos.`;
case backendTexts.sortBy.name:
return $localize`Sorting`;
case backendTexts.sortBy.description:
return $localize`Sorts the photos and videos by this.`;
case backendTexts.pickAmount.name:
return $localize`Pick`;
case backendTexts.pickAmount.description:
return $localize`Number of photos and videos to pick.`;
case backendTexts.emailTo.name:
return $localize`E-mail to`;
case backendTexts.emailTo.description:
return $localize`E-mail address of the recipient.`;
case backendTexts.emailFrom.name:
return $localize`E-mail From`;
case backendTexts.emailFrom.description:
return $localize`E-mail sender address.`;
case backendTexts.emailSubject.name:
return $localize`Subject`;
case backendTexts.emailSubject.description:
return $localize`E-mail subject.`;
case backendTexts.emailText.name:
return $localize`Message`;
case backendTexts.emailText.description:
return $localize`E-mail text.`;
default:
return null;
}

View File

@ -71,6 +71,10 @@
let-confPath="confPath">
<div class="alert alert-secondary" role="alert" *ngIf="rStates.description">
{{rStates.description}}
<a *ngIf="rStates.tags?.githubIssue"
[href]="'https://github.com/bpatrik/pigallery2/issues/'+rStates.tags?.githubIssue">
<ng-container i18n>See</ng-container>
#{{rStates.tags?.githubIssue}}.</a>
</div>
<ng-container *ngFor="let ck of getKeys(rStates)">
<ng-container *ngIf="!(rStates.value.__state[ck].shouldHide && rStates.value.__state[ck].shouldHide())">

View File

@ -115,6 +115,8 @@ export class JobProgressComponent implements OnDestroy, OnChanges {
return $localize`interrupted`;
case JobProgressStates.finished:
return $localize`finished`;
case JobProgressStates.failed:
return $localize`failed`;
default:
return 'unknown state';
}

View File

@ -0,0 +1,3 @@
app-gallery-search-field {
width: 100%;
}

View File

@ -1,6 +1,7 @@
<div [hidden]="!error" class="alert alert-danger" role="alert"><strong>Error: </strong>{{error}}</div>
<div *ngFor="let schedule of sortedSchedules() as sortedSchedules; let i= index">
<div class="card bg-body-tertiary mt-2 mb-2 no-changed-settings {{shouldIdent(schedule,sortedSchedules[i-1])? 'ms-4' : ''}}">
<div
class="card bg-body-tertiary mt-2 mb-2 no-changed-settings {{shouldIdent(schedule,sortedSchedules[i-1])? 'ms-4' : ''}}">
<div class="card-header">
<div class="d-flex justify-content-between">
<div (click)="showDetails[schedule.name]=!showDetails[schedule.name]">
@ -175,6 +176,14 @@
[(ngModel)]="schedule.config[configEntry.id]" required>
</ng-container>
<ng-container *ngSwitchCase="'email'">
<input type="email" class="form-control" [name]="configEntry.id+'_'+i"
[id]="configEntry.id+'_'+i"
placeholder="adress@domain.com"
(ngModelChange)="onChange($event)"
[(ngModel)]="schedule.config[configEntry.id]" required>
</ng-container>
<ng-container *ngSwitchCase="'number'">
<input type="number" class="form-control" [name]="configEntry.id+'_'+i"
[id]="configEntry.id+'_'+i"
@ -189,6 +198,54 @@
(ngModelChange)="setNumberArray(schedule.config,configEntry.id,$event); onChange($event);"
[ngModel]="getNumberArray($any(schedule.config),configEntry.id)" required>
</ng-container>
<app-gallery-search-field
*ngSwitchCase="'SearchQuery'"
[(ngModel)]="schedule.config[configEntry.id]"
[id]="configEntry.id+'_'+i"
[name]="configEntry.id+'_'+i"
(change)="onChange($event)"
placeholder="Search Query">
</app-gallery-search-field>
<ng-container *ngSwitchCase="'sort-array'">
<ng-container *ngFor="let _ of AsSortArray(schedule.config[configEntry.id]); let j=index">
<div class="row col-12 mt-1 m-0 p-0">
<div class="col p-0">
<select
[id]="configEntry.id+'_'+i+'_'+j"
[name]="configEntry.id+'_'+i+'_'+j"
(ngModelChange)="onChange($event)"
class="form-select" [(ngModel)]="AsSortArray(schedule.config[configEntry.id])[j]">
<option *ngFor="let opt of SortingMethods" [ngValue]="opt.key">{{opt.value}}
</option>
</select>
</div>
<ng-container>
<div class="col-auto pe-0">
<button class="btn btn-secondary float-end"
[id]="'list_btn_'+configEntry.id+'_'+i+'_'+j"
[name]="'list_btn_'+configEntry.id+'_'+i+'_'+j"
(click)="removeSorting(schedule.config[configEntry.id],j)"><span
class="oi oi-trash"></span>
</button>
</div>
</ng-container>
</div>
</ng-container>
<ng-container>
<div class="col-12 p-0">
<button class="btn btn-primary mt-1 float-end"
[id]="'btn_add_'+configEntry.id+'_'+i"
[name]="'btn_add_'+configEntry.id+'_'+i"
(click)="AddNewSorting(schedule.config[configEntry.id])" i18n>+ Add
</button>
</div>
</ng-container>
</ng-container>
</ng-container>
</div>
<small class="form-text text-muted">

View File

@ -19,6 +19,8 @@ import {
ScheduledJobTriggerConfig
} from '../../../../../common/config/private/PrivateConfig';
import {ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator} from '@angular/forms';
import {enumToTranslatedArray} from '../../EnumTranslations';
import {SortingMethods} from '../../../../../common/entities/SortingMethods';
@Component({
selector: 'app-settings-workflow',
@ -61,6 +63,9 @@ export class WorkflowComponent implements ControlValueAccessor, Validator, OnIni
allowParallelRun: false,
};
SortingMethods = enumToTranslatedArray(SortingMethods);
error: string;
constructor(
@ -278,4 +283,17 @@ export class WorkflowComponent implements ControlValueAccessor, Validator, OnIni
this.onTouched = fn;
}
AsSortArray(configElement: string | number | string[] | number[]): SortingMethods[] {
return configElement as SortingMethods[];
}
removeSorting(configElement: string | number | string[] | number[], j: number): void {
(configElement as SortingMethods[]).splice(j);
}
AddNewSorting(configElement: string | number | string[] | number[]): void {
(configElement as SortingMethods[]).push(SortingMethods.ascDate)
}
}