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

@@ -49,6 +49,17 @@ async function initCaches() {
:class="{ active: pageContext.routeParams.section === 'entities' }" :class="{ active: pageContext.routeParams.section === 'entities' }"
>Entity Health</a> >Entity Health</a>
</li> </li>
<li
v-if="pageContext.user.role === 'admin'"
class="nav-item"
>
<a
href="/admin/emails"
class="nav-link nolink"
:class="{ active: pageContext.urlPathname.startsWith('/admin/emails') }"
>E-mails</a>
</li>
</ul> </ul>
<button <button

307
components/emails/alert.vue Normal file
View File

@@ -0,0 +1,307 @@
<script setup>
import {
Body,
Column,
Container,
Heading,
Html,
Img,
Link,
Preview,
Row,
Text,
} from '@vue-email/components';
import { format } from 'date-fns';
defineProps({
trigger: {
type: Object,
default: () => mockTrigger,
},
address: {
type: String,
default: 'http://localhost:5100',
},
});
</script>
<script>
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
// <script setup> itself, since that block gets hoisted out of the setup() function.
const mockTrigger = {
userId: 1,
username: 'DebuggingLibrarian',
userEmail: 'debug@alerts.traxxx.me',
alerts: [
{
sceneId: 684061,
alert: {
id: 565,
userId: 1,
username: 'DebuggingLibrarian',
userEmail: 'debug@alerts.traxxx.me',
notify: true,
email: true,
all: true,
allActors: true,
allEntities: true,
allTags: true,
allMatches: true,
actors: [],
tags: [],
entities: [
{
id: 522,
name: 'Jules Jordan',
},
],
matches: [],
stashIds: [],
},
scene: {
id: 684061,
title: 'Thick & Bootylicious: Vanessa Blake Takes A BBC Up Her Ass',
slug: 'thick-bootylicious-vanessa-blake-takes-a-bbc-up',
date: '2026-02-17T00:00:00.000Z',
description: 'Vanessa Blakes curves are impossible to ignore—especially her thick, round, bootylicious ass. The scene opens with the camera following Vanessa through the house as she struts confidently in a slow, teasing booty walk, giving viewers a full view of her luscious curves. She pauses at the stairs, turning back to flaunt her massive ass before letting her hands wander over her body. Feeling the heat build, Vanessa begins to pleasure herself, showcasing her insatiable appetite for big black cock. Moving to the couch, she grabs a dildo and slowly works it into her ass, preparing herself for whats coming next. Sitting back and watching the show is Lexington Steele, enjoying every second as she stretches and teases in anticipation. Lexington finally steps in, and Vanessa eagerly drops to her knees, wrapping her lips around his massive BBC and giving him a deep, hungry blowjob. The action quickly escalates to the couch, where they fuck in multiple intense positions, Vanessas thick body bouncing with every thrust. When shes fully ready, Vanessa has Lex slide his BBC deep into her ass, and the two go at it hard in a variety of explosive anal positions. The scene builds to a powerful climax as Lexington pulls out and finishes with a messy facial, leaving Vanessa satisfied and dripping after an unforgettable session.',
actors: [
{
id: 919,
name: 'Vanessa Blake',
},
{
id: 1374,
name: 'Lexington Steele',
},
],
actorIds: [
919,
1374,
],
tags: [
{
name: 'MILF',
slug: 'milf',
},
{
name: 'facial',
slug: 'facial',
},
],
tagIds: [
4,
12,
19,
30,
29,
37,
41,
68,
82,
100,
112,
119,
null,
130,
],
entityId: 522,
parentEntityId: 47,
// posterUrl: 'http://localhost:5100/media/posters/lazy/cd/b5/99375c7998a20acffef2217a8a21c3546924312cec65.jpeg',
},
},
{
sceneId: 684060,
alert: {
id: 565,
userId: 1,
username: 'DebuggingLibrarian',
userEmail: 'debug@alerts.traxxx.me',
notify: true,
email: true,
all: true,
allActors: true,
allEntities: true,
allTags: true,
allMatches: true,
actors: [],
tags: [],
entities: [
{
id: 522,
name: 'Jules Jordan',
},
],
matches: [],
stashIds: [],
},
scene: {
id: 684060,
title: 'Elizabeth Skylars Big ASSets Drive The Milfomaniac Manuel Ferrara Wild',
slug: 'elizabeth-skylar-s-big-assets-drive-the-manuel',
date: '2026-02-21T00:00:00.000Z',
description: 'Elizabeth Skylar is ready to indulge her cravings, and theres only one man she wants—the Milfomaniac, Manuel Ferrara. The scene opens with Elizabeth stepping out onto the balcony in sexy lingerie, confidently showing off her busty curves as the camera captures every seductive angle. She teases the viewer with slow, deliberate poses before heading inside to the kitchen, where she perches on a stool with her ass on full display. Manuel joins her, grabbing and admiring her curves, letting the camera in on just how irresistible she is. The action moves to the living room, where Elizabeth stretches out on the couch and begins to pleasure herself, putting on a sultry show for Manuel. Unable to resist, she drops to her knees, her tits spilling free as she takes him deep into her mouth with an eager, hungry intensity. From there, the two tear into each other on the couch, fucking in multiple positions as Elizabeth moans for more. The scene builds to a explosive finish, ending with Manuel delivering a memorable facial that leaves Elizabeth completely satisfied.',
actors: [
{
id: 44001,
name: 'Elizabeth Skylar',
},
{
id: 512,
name: 'Manuel Ferrara',
},
],
actorIds: [
512,
44001,
],
tags: [
{
name: 'anal',
slug: 'anal',
},
{
name: 'ass to mouth',
tag: 'atm',
},
],
tagIds: [
4,
30,
29,
31,
37,
41,
68,
82,
119,
125,
],
entityId: 522,
parentEntityId: 47,
posterUrl: 'http://localhost:5100/media/posters/lazy/3c/84/f53e772e4710ca71fb74bdb6896af3d180725d17710b.jpeg',
},
},
],
};
</script>
<template>
<Html>
<Body style="font-family: sans-serif;">
<Preview>{{ trigger.alerts.length }} new scene{{ trigger.alerts.length === 1 ? '' : 's' }} matching your alerts on traxxx</Preview>
<Container>
<Img
:src="`${address}/img/logo.png`"
width="150"
alt="traxxx"
style="margin: 15px auto 25px auto;"
/>
<Heading
as="h3"
style="color: #f65596; margin: 0 auto 0 auto;"
>Hi, {{ trigger.username }}</Heading>
<Text style="font-size: 16px; margin: 20px 0;">Scenes for which you set e-mail alerts have been added or released:</Text>
<div
v-for="(alert, index) in trigger.alerts"
:key="alert.alert.id"
style="margin-bottom: 15px; font-size: 14px;"
:style="{
'padding-bottom': index < trigger.alerts.length - 1 ? '15px' : 0,
'border-bottom': index < trigger.alerts.length - 1 ? 'solid 1px #ddd' : 'none',
}"
>
<Row style="margin-bottom: 8px;">
<Column valign="top">
{{ format(alert.scene.date, 'yyyy-MM-dd') }} |
<span v-if="alert.alert.sceneIds?.length > 0">Scene is released</span>
<span v-else>
New
<template
v-if="alert.alert.tags.length > 0"
>{{ alert.alert.tags.map((tag) => tag.name).join(', ') }}</template>
scene
<template
v-if="alert.alert.actors.length > 0"
>with {{ alert.alert.actors.map((actor) => actor.name).join(', ') }}&nbsp;</template>
<template
v-if="alert.alert.entities.length > 0"
>for {{ alert.alert.entities[0].name }}&nbsp;</template>
<template
v-if="alert.alert.matches.length > 0"
>matching {{ alert.alert.matches.map((match) => match.expression).join(', ') }}</template>
</span>
</Column>
<Column
width="50"
valign="top"
style="text-align: right;"
>
<span style="font-size: 14px; color: #aaa;">#{{ alert.alert.id }}</span>
</Column>
</Row>
<div>
<Row>
<Column
width="175"
valign="top"
>
<Link :href="`${address}/scene/${alert.scene.id}/${alert.scene.slug}`">
<Img
v-if="alert.scene.posterUrl"
:src="alert.scene.posterUrl"
width="160"
:alt="alert.scene.title"
style="margin-top: 2px; border-radius: 8px;"
/>
<div
v-else
style="width: 144px; height: 74px; background: #eee; color: #aaa; text-align: center; border-radius: 8px; padding: 8px;"
>No thumb</div>
</Link>
</Column>
<Column valign="top">
<div style="margin-bottom: 5px;">
<Link
:href="`${address}/scene/${alert.scene.id}/${alert.scene.slug}`"
style="font-weight: bold; line-height: 1.5; color: inherit;"
>{{ alert.scene.title }}</Link>
</div>
<div style="margin-bottom: 8px;">
<span style="font-size: 14px;">{{ alert.scene.actors.slice(0, 3).map((actor) => actor.name).join(', ') }}</span>
</div>
<div style="margin-bottom: 8px;">
<span style="font-size: 14px; color: #666;">{{ alert.scene.tags.slice(0, 5).map((tag) => tag.name).join(', ') }}</span>
</div>
</Column>
</Row>
</div>
</div>
<Heading
as="h3"
style="margin-top: 30px;"
>Enjoy!</Heading>
<span style="font-size: 14px; color: #aaa;">Delivered by the Automated Notification and Alert Link</span>
</Container>
</Body>
</Html>
</template>

View File

@@ -33,6 +33,7 @@ module.exports = {
}, },
}, },
web: { web: {
address: 'https://traxxx.me',
host: '0.0.0.0', host: '0.0.0.0',
port: 5000, port: 5000,
sfwHost: '0.0.0.0', sfwHost: '0.0.0.0',
@@ -203,6 +204,8 @@ module.exports = {
}, },
email: { email: {
// Resend // Resend
enabled: false,
apiKey: null, apiKey: null,
from: 'noreply@alerts.traxxx.me',
}, },
}; };

3727
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,9 @@
"@resvg/resvg-js": "^2.6.2", "@resvg/resvg-js": "^2.6.2",
"@toycode/markdown-it-class": "^1.2.4", "@toycode/markdown-it-class": "^1.2.4",
"@vitejs/plugin-vue": "^6.0.7", "@vitejs/plugin-vue": "^6.0.7",
"@vue-email/compiler": "^0.8.14",
"@vue-email/components": "^0.0.21",
"@vue-email/render": "^0.0.9",
"@vue/compiler-sfc": "^3.5.39", "@vue/compiler-sfc": "^3.5.39",
"@vue/server-renderer": "^3.5.39", "@vue/server-renderer": "^3.5.39",
"@vueuse/components": "^14.3.0", "@vueuse/components": "^14.3.0",

View File

@@ -0,0 +1,38 @@
<script setup>
import { inject } from 'vue';
import Admin from '#/components/admin/admin.vue';
const { pageProps } = inject('pageContext');
const { templates } = pageProps;
</script>
<template>
<Admin class="page">
<ul
v-if="templates.length > 0"
class="menu"
>
<li v-for="template in templates" :key="template">
<a
:href="`/admin/emails/${template}`"
class="link"
>{{ template }}</a>
</li>
</ul>
<p v-else>No e-mail templates found in components/emails.</p>
</Admin>
</template>
<style scoped>
.menu {
margin: 0;
.link {
display: block;
padding: .25rem;
}
}
</style>

View File

@@ -0,0 +1,18 @@
import { render } from 'vike/abort';
import { listEmailTemplates } from '#/src/email-templates.js';
export default function onBeforeRender(pageContext) {
if (!pageContext.user || pageContext.user.role === 'user') {
throw render(404);
}
return {
pageContext: {
title: 'E-mail Previews',
pageProps: {
templates: listEmailTemplates(),
},
},
};
}

View File

@@ -0,0 +1,70 @@
<script setup>
import { inject } from 'vue';
import Admin from '#/components/admin/admin.vue';
const { pageProps } = inject('pageContext');
const {
template,
html,
text,
error,
} = pageProps;
</script>
<template>
<Admin class="page">
<div class="header">
<h2 class="heading">{{ template }}</h2>
</div>
<p v-if="error" class="error">Failed to render template: {{ error }}</p>
<template v-else>
<iframe
:srcdoc="html"
class="frame"
title="E-mail preview"
/>
<details v-if="text" class="text">
<summary>Plain-text version</summary>
<pre>{{ text }}</pre>
</details>
</template>
</Admin>
</template>
<style scoped>
.header {
display: flex;
align-items: baseline;
gap: 1rem;
margin-bottom: 1rem;
}
.error {
color: var(--warn);
font-weight: bold;
}
.frame {
width: 100%;
min-height: 60rem;
border: solid 1px var(--shadow-weak-30);
border-radius: .5rem;
background: #fff;
}
.text {
margin-top: 1rem;
pre {
white-space: pre-wrap;
padding: 1rem;
background: var(--background-dark-20);
border-radius: .5rem;
}
}
</style>

View File

@@ -0,0 +1,38 @@
import { render } from 'vike/abort';
import { emailTemplates, listEmailTemplates } from '#/src/email-templates.js';
export default async function onBeforeRender(pageContext) {
if (!pageContext.user || pageContext.user.role === 'user') {
throw render(404);
}
const { template } = pageContext.routeParams;
if (!listEmailTemplates().includes(template)) {
throw render(404);
}
let html = null;
let text = null;
let error = null;
try {
({ html, text } = await emailTemplates.render(`${template}.vue`));
}
catch (renderError) {
error = renderError.message;
}
return {
pageContext: {
title: `E-mail Preview: ${template}`,
pageProps: {
template,
html,
text,
error,
},
},
};
}

View File

@@ -0,0 +1,18 @@
import { match } from 'path-to-regexp';
const path = '/admin/emails/:template';
const urlMatch = match(path, { decode: decodeURIComponent });
export default (pageContext) => {
const matched = urlMatch(pageContext.urlPathname);
if (matched) {
return {
routeParams: {
template: matched.params.template,
},
};
}
return false;
};

BIN
public/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -1,9 +1,12 @@
import config from 'config'; import config from 'config';
import { CronJob } from 'cron'; import { CronJob } from 'cron';
import escapeRegexp from 'escape-string-regexp'; import escapeRegexp from 'escape-string-regexp';
import markdownIt from 'markdown-it';
import { Resend } from 'resend';
import promiseProps from '../utils/promise-props.js'; import promiseProps from '../utils/promise-props.js';
import { getIdsBySlug } from './cache.js'; import { getIdsBySlug } from './cache.js';
import { emailTemplates } from './email-templates.js';
import { HttpError } from './errors.js'; import { HttpError } from './errors.js';
import { knexOwner as knex } from './knex.js'; import { knexOwner as knex } from './knex.js';
import initLogger from './logger.js'; import initLogger from './logger.js';
@@ -11,6 +14,25 @@ import { indexApi } from './manticore.js';
import { fetchScenesById } from './scenes.js'; import { fetchScenesById } from './scenes.js';
const logger = initLogger(); 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 = {}) { function curateAlert(alert, context = {}) {
return { return {
@@ -400,12 +422,68 @@ function groupByKey(data, byKey, itemKey) {
acc[item[byKey]] = []; acc[item[byKey]] = [];
} }
acc[item[byKey]].push(item[itemKey]); acc[item[byKey]].push(itemKey ? item[itemKey] : item);
return acc; 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) { export async function notify(sceneIds) {
const scenes = await fetchScenesById(sceneIds); const scenes = await fetchScenesById(sceneIds);
@@ -419,21 +497,36 @@ export async function notify(sceneIds) {
alertsMatches, alertsMatches,
alertsStashes, alertsStashes,
] = await Promise.all([ ] = await Promise.all([
knex('releases_actors').whereIn('release_id', sceneIds), knex('releases_actors')
knex('releases_tags').whereIn('release_id', sceneIds), .select('releases_actors.*', 'actors.name as actor_name', 'actors.gender as actor_gender')
knex('alerts'), .whereIn('release_id', sceneIds)
knex('alerts_actors'), .leftJoin('actors', 'actors.id', 'releases_actors.actor_id'),
knex('alerts_tags'), knex('releases_tags')
knex('alerts_entities'), .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_matches'),
knex('alerts_stashes'), knex('alerts_stashes'),
]); ]);
const actorIdsByReleaseId = groupByKey(releasesActors, 'release_id', 'actor_id'); const actorsByReleaseId = groupByKey(releasesActors, 'release_id', null);
const tagIdsByReleaseId = groupByKey(releasesTags, 'release_id', 'tag_id'); const tagsByReleaseId = groupByKey(releasesTags, 'release_id', null);
const alertsActorsByAlertId = groupByKey(alertsActors, 'alert_id', 'actor_id'); const alertsActorsByAlertId = groupByKey(alertsActors, 'alert_id', null);
const alertsTagsByAlertId = groupByKey(alertsTags, 'alert_id', 'tag_id'); const alertsTagsByAlertId = groupByKey(alertsTags, 'alert_id', null);
const alertsEntitiesByAlertId = groupByKey(alertsEntities, 'alert_id', 'entity_id'); const alertsEntitiesByAlertId = groupByKey(alertsEntities, 'alert_id', null);
const alertsStashesByAlertId = groupByKey(alertsStashes, 'alert_id', 'stash_id'); const alertsStashesByAlertId = groupByKey(alertsStashes, 'alert_id', 'stash_id');
const alertsMatchesByAlertId = alertsMatches.reduce((acc, alertMatch) => { const alertsMatchesByAlertId = alertsMatches.reduce((acc, alertMatch) => {
@@ -452,6 +545,8 @@ export async function notify(sceneIds) {
const alerts = rawAlerts.map((alert) => ({ const alerts = rawAlerts.map((alert) => ({
id: alert.id, id: alert.id,
userId: alert.user_id, userId: alert.user_id,
username: alert.username,
userEmail: alert.user_email,
notify: alert.notify, notify: alert.notify,
email: alert.email, email: alert.email,
all: alert.all, all: alert.all,
@@ -459,23 +554,31 @@ export async function notify(sceneIds) {
allEntities: alert.all_entities, allEntities: alert.all_entities,
allTags: alert.all_tags, allTags: alert.all_tags,
allMatches: alert.all_matches, allMatches: alert.all_matches,
actors: alertsActorsByAlertId[alert.id] || [], actors: alertsActorsByAlertId[alert.id]
tags: alertsTagsByAlertId[alert.id] || [], ?.map((actor) => ({ id: actor.actor_id, name: actor.actor_name, gender: actor.actor_gender }))
entities: alertsEntitiesByAlertId[alert.id] || [], .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] || [], matches: alertsMatchesByAlertId[alert.id] || [],
stashes: alertsStashesByAlertId[alert.id] || [], stashIds: alertsStashesByAlertId[alert.id] || [],
})); }));
const curatedScenes = scenes const curatedScenes = scenes
.filter((scene) => scene.isNew) .filter((scene) => scene.isNew)
.toSorted((sceneA, sceneB) => sceneB.date - sceneA.date)
.map((scene) => ({ .map((scene) => ({
id: scene.id, id: scene.id,
title: scene.title, title: scene.title,
slug: scene.slug,
date: scene.date,
description: scene.description, description: scene.description,
actorIds: actorIdsByReleaseId[scene.id] || [], actors: (actorsByReleaseId[scene.id] || []).filter(Boolean).map((actor) => ({ id: actor.actor_id, name: actor.actor_name, slug: actor.slug })),
tagIds: tagIdsByReleaseId[scene.id] || [], 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, entityId: scene.channel?.id || scene.network?.id || null,
parentEntityId: scene.network?.id, parentEntityId: scene.network?.id,
posterUrl: getImagePath(scene.poster),
})); }));
const triggers = alerts.flatMap((alert) => { const triggers = alerts.flatMap((alert) => {
@@ -489,16 +592,16 @@ export async function notify(sceneIds) {
return false; 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; 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; return false;
} }
// multiple entities can only be matched in OR mode // 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; return false;
} }
@@ -544,7 +647,7 @@ export async function notify(sceneIds) {
scene_id: trigger.sceneId, 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, stashId,
sceneId: trigger.sceneId, sceneId: trigger.sceneId,
userId: trigger.alert.userId, userId: trigger.alert.userId,
@@ -593,6 +696,8 @@ export async function notify(sceneIds) {
} }
} }
await sendEmailAlerts(triggers, curatedScenes);
return triggers; 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$/, ''));
}