Implemented e-mail notification trigger.

This commit is contained in:
2026-07-18 00:01:18 +02:00
parent fab895b85e
commit b6408f3244
13 changed files with 4248 additions and 149 deletions

View File

@@ -1,9 +1,12 @@
import config from 'config';
import { CronJob } from 'cron';
import escapeRegexp from 'escape-string-regexp';
import markdownIt from 'markdown-it';
import { Resend } from 'resend';
import promiseProps from '../utils/promise-props.js';
import { getIdsBySlug } from './cache.js';
import { emailTemplates } from './email-templates.js';
import { HttpError } from './errors.js';
import { knexOwner as knex } from './knex.js';
import initLogger from './logger.js';
@@ -11,6 +14,25 @@ import { indexApi } from './manticore.js';
import { fetchScenesById } from './scenes.js';
const logger = initLogger();
const resend = new Resend(config.email.apiKey);
function getImagePath(media) {
if (!media) {
return null;
}
if (media.isS3) {
return new URL(media.lazy, config.media.s3Path).href;
}
return new URL(`${config.media.mediaPath}/${media.lazy}`, config.web.address).href;
}
const md = markdownIt({
html: true,
});
md.disable('code');
function curateAlert(alert, context = {}) {
return {
@@ -400,12 +422,68 @@ function groupByKey(data, byKey, itemKey) {
acc[item[byKey]] = [];
}
acc[item[byKey]].push(item[itemKey]);
acc[item[byKey]].push(itemKey ? item[itemKey] : item);
return acc;
}, {});
}
async function sendEmailAlerts(triggers, scenes) {
if (!config.email.enabled) {
return;
}
const scenesBySceneId = Object.fromEntries(scenes.map((scene) => [scene.id, scene]));
const triggersByUserId = Object.values(triggers // prevent multiple emails for the same scene
.filter((trigger) => trigger.alert.email && trigger.alert.userEmail)
.reduce((acc, trigger) => {
if (!acc[trigger.alert.userId]) {
acc[trigger.alert.userId] = {
userId: trigger.alert.userId,
username: trigger.alert.username,
userEmail: trigger.alert.userEmail,
alerts: [],
};
}
acc[trigger.alert.userId].alerts.push({
...trigger,
scene: scenesBySceneId[trigger.sceneId] || null,
});
return acc;
}, {}));
const emails = await Promise.all(triggersByUserId.map(async (trigger) => {
const { html, text } = await emailTemplates.render('alert.vue', {
props: {
trigger,
address: config.web.address,
},
});
return {
from: config.email.from,
to: [trigger.userEmail],
subject: `Notification for ${trigger.alerts.length} scenes on traxxx`,
html,
text,
};
}));
const { data, error } = await resend.batch.send(emails);
if (error) {
logger.error(`Failed to send ${emails.length} alert e-mails: ${error}`);
}
else {
logger.info(`Sent ${data.data.length}/${emails.length} alert e-mails`);
}
}
const genderOrder = ['female', 'transsexual', 'male'];
export async function notify(sceneIds) {
const scenes = await fetchScenesById(sceneIds);
@@ -419,21 +497,36 @@ export async function notify(sceneIds) {
alertsMatches,
alertsStashes,
] = await Promise.all([
knex('releases_actors').whereIn('release_id', sceneIds),
knex('releases_tags').whereIn('release_id', sceneIds),
knex('alerts'),
knex('alerts_actors'),
knex('alerts_tags'),
knex('alerts_entities'),
knex('releases_actors')
.select('releases_actors.*', 'actors.name as actor_name', 'actors.gender as actor_gender')
.whereIn('release_id', sceneIds)
.leftJoin('actors', 'actors.id', 'releases_actors.actor_id'),
knex('releases_tags')
.select('releases_tags.*', 'tags.name as tag_name', 'tags.slug as tag_slug')
.leftJoin('tags', 'tags.id', 'releases_tags.tag_id')
.whereIn('release_id', sceneIds)
.orderBy('tags.priority', 'desc'),
knex('alerts')
.select('alerts.*', 'users.username', 'users.email as user_email')
.leftJoin('users', 'users.id', 'alerts.user_id'),
knex('alerts_actors')
.select('alerts_actors.*', 'actors.name as actor_name', 'actors.slug as actor_slug')
.leftJoin('actors', 'actors.id', 'alerts_actors.actor_id'),
knex('alerts_tags')
.select('alerts_tags.*', 'tags.name as tag_name')
.leftJoin('tags', 'tags.id', 'alerts_tags.tag_id'),
knex('alerts_entities')
.select('alerts_entities.*', 'entities.name as entity_name')
.leftJoin('entities', 'entities.id', 'alerts_entities.entity_id'),
knex('alerts_matches'),
knex('alerts_stashes'),
]);
const actorIdsByReleaseId = groupByKey(releasesActors, 'release_id', 'actor_id');
const tagIdsByReleaseId = groupByKey(releasesTags, 'release_id', 'tag_id');
const alertsActorsByAlertId = groupByKey(alertsActors, 'alert_id', 'actor_id');
const alertsTagsByAlertId = groupByKey(alertsTags, 'alert_id', 'tag_id');
const alertsEntitiesByAlertId = groupByKey(alertsEntities, 'alert_id', 'entity_id');
const actorsByReleaseId = groupByKey(releasesActors, 'release_id', null);
const tagsByReleaseId = groupByKey(releasesTags, 'release_id', null);
const alertsActorsByAlertId = groupByKey(alertsActors, 'alert_id', null);
const alertsTagsByAlertId = groupByKey(alertsTags, 'alert_id', null);
const alertsEntitiesByAlertId = groupByKey(alertsEntities, 'alert_id', null);
const alertsStashesByAlertId = groupByKey(alertsStashes, 'alert_id', 'stash_id');
const alertsMatchesByAlertId = alertsMatches.reduce((acc, alertMatch) => {
@@ -452,6 +545,8 @@ export async function notify(sceneIds) {
const alerts = rawAlerts.map((alert) => ({
id: alert.id,
userId: alert.user_id,
username: alert.username,
userEmail: alert.user_email,
notify: alert.notify,
email: alert.email,
all: alert.all,
@@ -459,23 +554,31 @@ export async function notify(sceneIds) {
allEntities: alert.all_entities,
allTags: alert.all_tags,
allMatches: alert.all_matches,
actors: alertsActorsByAlertId[alert.id] || [],
tags: alertsTagsByAlertId[alert.id] || [],
entities: alertsEntitiesByAlertId[alert.id] || [],
actors: alertsActorsByAlertId[alert.id]
?.map((actor) => ({ id: actor.actor_id, name: actor.actor_name, gender: actor.actor_gender }))
.toSorted((actorA, actorB) => genderOrder.indexOf(actorA.gender) - genderOrder.indexOf(actorB.gender)) || [],
tags: alertsTagsByAlertId[alert.id]?.map((tag) => ({ id: tag.tag_id, name: tag.tag_name })) || [],
entities: alertsEntitiesByAlertId[alert.id]?.map((entity) => ({ id: entity.entity_id, name: entity.entity_name })) || [],
matches: alertsMatchesByAlertId[alert.id] || [],
stashes: alertsStashesByAlertId[alert.id] || [],
stashIds: alertsStashesByAlertId[alert.id] || [],
}));
const curatedScenes = scenes
.filter((scene) => scene.isNew)
.toSorted((sceneA, sceneB) => sceneB.date - sceneA.date)
.map((scene) => ({
id: scene.id,
title: scene.title,
slug: scene.slug,
date: scene.date,
description: scene.description,
actorIds: actorIdsByReleaseId[scene.id] || [],
tagIds: tagIdsByReleaseId[scene.id] || [],
actors: (actorsByReleaseId[scene.id] || []).filter(Boolean).map((actor) => ({ id: actor.actor_id, name: actor.actor_name, slug: actor.slug })),
actorIds: (actorsByReleaseId[scene.id] || []).map((actor) => actor.actor_id),
tagIds: (tagsByReleaseId[scene.id] || []).map((tag) => tag.id),
tags: (tagsByReleaseId[scene.id] || []).filter(Boolean).map((tag) => ({ slug: tag.tag_slug, name: tag.tag_name })),
entityId: scene.channel?.id || scene.network?.id || null,
parentEntityId: scene.network?.id,
posterUrl: getImagePath(scene.poster),
}));
const triggers = alerts.flatMap((alert) => {
@@ -489,16 +592,16 @@ export async function notify(sceneIds) {
return false;
}
if (alert.actors.length > 0 && !alert.actors[alert.allActors ? 'every' : 'some']((actorId) => scene.actorIds.includes(actorId))) {
if (alert.actors.length > 0 && !alert.actors[alert.allActors ? 'every' : 'some']((actor) => scene.actorIds.includes(actor.id))) {
return false;
}
if (alert.tags.length > 0 && !alert.tags[alert.allTags ? 'every' : 'some']((tagId) => scene.tagIds.includes(tagId))) {
if (alert.tags.length > 0 && !alert.tags[alert.allTags ? 'every' : 'some']((tag) => scene.tagIds.includes(tag.id))) {
return false;
}
// multiple entities can only be matched in OR mode
if (alert.entities.length > 0 && !alert.entities.some((alertEntityId) => alertEntityId === scene.entityId || alertEntityId === scene.parentEntityId)) {
if (alert.entities.length > 0 && !alert.entities.some((alertEntity) => alertEntity.id === scene.entityId || alertEntity.id === scene.parentEntityId)) {
return false;
}
@@ -544,7 +647,7 @@ export async function notify(sceneIds) {
scene_id: trigger.sceneId,
}));
const uniqueStashes = Object.values(Object.fromEntries(triggers.flatMap((trigger) => trigger.alert.stashes.map((stashId) => ({
const uniqueStashes = Object.values(Object.fromEntries(triggers.flatMap((trigger) => trigger.alert.stashIds.map((stashId) => ({
stashId,
sceneId: trigger.sceneId,
userId: trigger.alert.userId,
@@ -593,6 +696,8 @@ export async function notify(sceneIds) {
}
}
await sendEmailAlerts(triggers, curatedScenes);
return triggers;
}

15
src/email-templates.js Normal file
View File

@@ -0,0 +1,15 @@
import { readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { config as configureEmailTemplates } from '@vue-email/compiler';
export const emailsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '../components/emails');
export const emailTemplates = configureEmailTemplates(emailsDir, { verbose: false });
export function listEmailTemplates() {
return readdirSync(emailsDir)
.filter((file) => file.endsWith('.vue'))
.map((file) => file.replace(/\.vue$/, ''));
}