add anomaly + fix billing + add emails templates

This commit is contained in:
Emily
2024-09-14 17:07:46 +02:00
parent c253846b86
commit 4c46a36c75
23 changed files with 336 additions and 48 deletions

View File

@@ -20,7 +20,7 @@ const { visible } = usePricingDrawer();
<Transition name="pdrawer">
<LazyPricingDrawer @onCloseClick="visible = false"
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if=visible>
class="bg-black fixed right-0 top-0 w-full xl:w-[60vw] xl:min-w-[65rem] h-full z-[20]" v-if="visible">
</LazyPricingDrawer>
</Transition>

View File

@@ -28,6 +28,7 @@ const route = useRoute();
const props = defineProps<Props>();
const { isAdmin } = useUserRoles();
const loggedUser = useLoggedUser()
const debugMode = process.dev;
@@ -101,8 +102,15 @@ function onLogout() {
}
const { projects } = useProjectsList();
const { data: guestProjects } = useGuestProjectsList()
const activeProject = useActiveProject();
const selectorProjects = computed(() => {
const result: TProject[] = [];
if (projects.value) result.push(...projects.value);
if (guestProjects.value) result.push(...guestProjects.value);
return result;
});
const { data: maxProjects } = useFetch("/api/user/max_projects", {
headers: computed(() => {
@@ -121,6 +129,11 @@ const isPremium = computed(() => {
return activeProject.value?.premium;
})
function isProjectMine(owner?: string) {
if (!owner) return false;
if (!loggedUser.value?.logged) return;
return loggedUser.value.id == owner;
}
const pricingDrawer = usePricingDrawer();
@@ -152,14 +165,14 @@ const pricingDrawer = usePricingDrawer();
base: 'hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-widget-lighter'
}
}" class="w-full" v-if="projects" v-model="selected" :options="projects">
}" class="w-full" v-if="selectorProjects" v-model="selected" :options="selectorProjects">
<template #option="{ option, active, selected }">
<div class="flex items-center gap-2">
<div>
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
</div>
<div> {{ option.name }} </div>
<div> {{ option.name }} {{ !isProjectMine(option.owner) ? '(Guest)' : '' }}</div>
</div>
</template>
@@ -168,7 +181,10 @@ const pricingDrawer = usePricingDrawer();
<div>
<img class="h-5 bg-black rounded-full" :src="'/logo_32.png'" alt="Litlyx logo">
</div>
<div> {{ activeProject?.name || '???' }} </div>
<div>
{{ activeProject?.name || '-' }}
{{ !isProjectMine(activeProject?.owner?.toString()) ? '(Guest)' : '' }}
</div>
</div>
</template>
</USelectMenu>
@@ -179,6 +195,7 @@ const pricingDrawer = usePricingDrawer();
</div>
<NuxtLink to="/project_creation" v-if="projects && (projects.length < (maxProjects || 1))"
class="flex items-center text-[.8rem] gap-1 justify-end pt-2 pr-2 text-lyx-text-dark hover:text-lyx-text cursor-pointer">
<div><i class="fas fa-plus"></i></div>

View File

@@ -118,11 +118,12 @@ function externalTooltipHandler(context: { chart: any, tooltip: TooltipModel<'li
tooltipEl.style.opacity = '0';
return;
}
const { offsetLeft: positionX, offsetTop: positionY } = chart.canvas;
const { left: positionX, top: positionY } = chart.canvas.getBoundingClientRect();
tooltipEl.style.opacity = '1';
tooltipEl.style.left = positionX + tooltip.caretX + 'px';
tooltipEl.style.top = positionY + tooltip.caretY + 'px';
tooltipEl.style.padding = tooltip.options.padding + 'px ' + tooltip.options.padding + 'px';
}

View File

@@ -13,6 +13,19 @@ function copyProjectId() {
navigator.clipboard.writeText((activeProject.value?._id || 0).toString());
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
}
function showAnomalyInfoAlert() {
createAlert('AI Anomaly Detector info',
`Anomaly detector is running. It helps you detect a spike in visits or events, it could mean an
attack or simply higher traffic due to good performance. Additionally, it can detect if someone is
stealing parts of your website and hosting a duplicate version—an unfortunately common practice.
Litlyx will notify you via email with actionable advices`,
'far fa-bug',
10000
)
}
</script>
@@ -29,8 +42,10 @@ function copyProjectId() {
<div class="flex md:gap-2 items-center md:justify-start flex-col md:flex-row">
<div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project:</div>
<div class="text-lyx-text poppins font-medium text-[1.2rem]"> {{ activeProject?.name || 'Loading...' }} </div>
<div class="text-lyx-text poppins font-medium text-[1.2rem]"> {{ activeProject?.name || 'Loading...' }}
</div>
</div>
<div class="flex flex-col md:flex-row md:gap-2 items-center md:justify-start">
<div class="poppins font-medium text-lyx-text-darker text-[1.2rem]">Project id:</div>
<div class="flex gap-2">
@@ -38,9 +53,20 @@ function copyProjectId() {
{{ activeProject?._id || 'Loading...' }}
</div>
<div class="flex items-center ml-3">
<i @click="copyProjectId()" class="far fa-copy text-lyx-text hover:text-lyx-primary cursor-pointer text-[1.2rem]"></i>
<i @click="copyProjectId()"
class="far fa-copy text-lyx-text hover:text-lyx-primary cursor-pointer text-[1.2rem]"></i>
</div>
</div>
</div>
<div class="flex gap-2 items-center text-text/90 justify-center md:justify-start">
<div class="animate-pulse w-[1rem] h-[1rem] bg-green-400 rounded-full"> </div>
<div class="poppins font-regular text-[1rem]"> AI Anomaly Detector </div>
<div class="flex items-center">
<i class="far fa-info-circle text-[.9rem] hover:text-lyx-primary cursor-pointer"
@click="showAnomalyInfoAlert"></i>
</div>
</div>
</div>
</template>

View File

@@ -14,7 +14,6 @@ const entries: SettingsTemplateEntry[] = [
const activeProject = useActiveProject();
const projectNameInputVal = ref<string>(activeProject.value?.name || '');
const apiKeys = ref<TApiSettings[]>([]);
const newApiKeyName = ref<string>('');
@@ -145,13 +144,15 @@ function copyProjectId() {
<template #pname>
<div class="flex items-center gap-4">
<LyxUiInput class="w-full px-4 py-2" v-model="projectNameInputVal"></LyxUiInput>
<LyxUiButton @click="changeProjectName()" :disabled="!canChange" type="primary"> Change </LyxUiButton>
<LyxUiButton v-if="!isGuest" @click="changeProjectName()" :disabled="!canChange" type="primary"> Change
</LyxUiButton>
</div>
</template>
<template #api>
<div class="flex items-center gap-4" v-if="apiKeys && apiKeys.length < 5">
<LyxUiInput class="grow px-4 py-2" placeholder="ApiKeyName" v-model="newApiKeyName"></LyxUiInput>
<LyxUiButton @click="createApiKey()" :disabled="newApiKeyName.length < 3" type="primary">
<LyxUiButton v-if="!isGuest" @click="createApiKey()" :disabled="newApiKeyName.length < 3"
type="primary">
<i class="far fa-plus"></i>
</LyxUiButton>
</div>
@@ -186,7 +187,7 @@ function copyProjectId() {
</LyxUiCard>
</template>
<template #pdelete >
<div class="flex justify-end">
<div class="flex justify-end" v-if="!isGuest">
<LyxUiButton type="danger" @click="deleteProject()">
Delete project
</LyxUiButton>

View File

@@ -31,6 +31,7 @@ const sections: Section[] = [
label: 'Slack support', icon: 'fab fa-slack',
premiumOnly: true,
action() {
if (isGuest) return;
if (isPremium.value === true) {
window.open('https://join.slack.com/t/litlyx/shared_invite/zt-2q3oawn29-hZlu_fBUBlc4052Ooe3FZg', '_blank');
} else {

View File

@@ -124,7 +124,7 @@ async function deleteChat(chat_id: string) {
<div class="flex flex-row h-full">
<div class="flex-[5] py-8 flex flex-col items-center relative">
<div class="flex-[5] py-8 flex flex-col items-center relative bg-lyx-background-light">
<div class="flex flex-col items-center mt-[20vh] px-28" v-if="currentChatMessages.length == 0">
<div class="w-[10rem]">
@@ -138,7 +138,7 @@ async function deleteChat(chat_id: string) {
</div>
<div class="grid grid-cols-2 gap-4 mt-6" v-if="!isGuest">
<div v-for="prompt of defaultPrompts" @click="currentText = prompt"
class="bg-[#2f2f2f] hover:bg-[#424242] cursor-pointer p-4 rounded-lg poppins text-center">
class="bg-lyx-widget-light hover:bg-lyx-widget-lighter cursor-pointer p-4 rounded-lg poppins text-center">
{{ prompt }}
</div>
</div>
@@ -148,7 +148,7 @@ async function deleteChat(chat_id: string) {
<div class="flex w-full" v-for="message of currentChatMessages">
<div class="flex justify-end w-full poppins text-[1.1rem]" v-if="message.role === 'user'">
<div class="bg-[#303030] px-5 py-3 rounded-lg">
<div class="bg-lyx-widget-light px-5 py-3 rounded-lg">
{{ message.content }}
</div>
</div>
@@ -177,13 +177,13 @@ async function deleteChat(chat_id: string) {
<div v-if="!isGuest" class="flex gap-2 items-center absolute bottom-8 left-0 w-full px-10 xl:px-28">
<input @keydown="onKeyDown" v-model="currentText"
class="bg-[#303030] w-full focus:outline-none px-4 py-2 rounded-lg" type="text">
class="bg-lyx-widget-light w-full focus:outline-none px-4 py-2 rounded-lg" type="text">
<div @click="sendMessage()"
class="bg-[#303030] hover:bg-[#464646] cursor-pointer px-4 py-2 rounded-full">
class="bg-lyx-widget-light hhover:bg-lyx-widget-lighter cursor-pointer px-4 py-2 rounded-full">
<i class="far fa-arrow-up"></i>
</div>
<div @click="menuOpen = !menuOpen"
class="bg-[#303030] lg:hidden hover:bg-[#464646] cursor-pointer px-4 py-2 rounded-full">
class="bg-lyx-widget-light lg:hidden hhover:bg-lyx-widget-lighter cursor-pointer px-4 py-2 rounded-full">
<i class="far fa-message"></i>
</div>
</div>
@@ -194,7 +194,7 @@ async function deleteChat(chat_id: string) {
<div :class="{
'absolute': menuOpen,
'hidden lg:flex': !menuOpen
}" class="flex-[2] bg-[#303030] p-6 flex flex-col gap-4 h-full overflow-hidden">
}" class="flex-[2] bg-lyx-widget-light p-6 flex flex-col gap-4 h-full overflow-hidden">
<div class="gap-2 flex flex-col">
<div class="lg:hidden absolute right-4 top-4 text-[1.5rem]">
@@ -219,7 +219,7 @@ async function deleteChat(chat_id: string) {
<div class="px-2">
<div @click="openChat()"
class="bg-menu cursor-pointer hover:bg-menu/80 rounded-lg px-4 py-3 poppins flex gap-2 items-center">
class="bg-lyx-widget-lighter cursor-pointer hover:bg-lyx-widget rounded-lg px-4 py-3 poppins flex gap-4 items-center">
<div> <i class="fas fa-plus"></i> </div>
<div> New chat </div>
</div>
@@ -228,12 +228,11 @@ async function deleteChat(chat_id: string) {
<div class="overflow-y-auto">
<div class="flex flex-col gap-2 px-2">
<div class="flex items-center gap-4 w-full" v-for="chat of chatsList?.toReversed()">
<div :class="{ '!bg-accent/60': chat._id.toString() === currentChatId }" class="flex rounded-lg items-center gap-4 w-full px-4 bg-lyx-widget-lighter hover:bg-lyx-widget" v-for="chat of chatsList?.toReversed()">
<i @click="deleteChat(chat._id.toString())"
class="fas fa-trash hover:text-gray-300 cursor-pointer"></i>
class="far fa-trash hover:text-gray-300 cursor-pointer"></i>
<div @click="openChat(chat._id.toString())"
class="bg-menu px-4 py-3 w-full cursor-pointer hover:bg-menu/80 poppins rounded-lg"
:class="{ '!bg-accent/60': chat._id.toString() === currentChatId }">
class="py-3 w-full cursor-pointer poppins rounded-lg">
{{ chat.title }}
</div>
</div>

View File

@@ -1,6 +0,0 @@
export default defineEventHandler(async event => {
console.log('TEST');
return;
});

View File

@@ -9,9 +9,13 @@ export default defineEventHandler(async event => {
if (!project_id) return;
const user = getRequestUser(event);
if (!user?.logged) return setResponseStatus(event, 400, 'User need to be logged');
const project = await getUserProjectFromId(project_id, user);
if (!project) return;
if (project.owner.toString() != user.id) return setResponseStatus(event, 400, 'You cannot upgrade a project as guest');
const body = await readBody(event);
const { planId } = body;

View File

@@ -9,9 +9,13 @@ export default defineEventHandler(async event => {
if (!project_id) return;
const user = getRequestUser(event);
if (!user?.logged) return setResponseStatus(event, 400, 'User need to be logged');
const project = await getUserProjectFromId(project_id, user);
if (!project) return;
if (project.owner.toString() != user.id) return setResponseStatus(event, 400, 'You cannot upgrade a project as guest');
const body = await readBody(event);
const { planId } = body;

View File

@@ -143,6 +143,7 @@ async function onPaymentSuccess(event: Event.InvoicePaidEvent) {
EmailService.sendPurchaseEmail(user.email, project.name);
}, 1);
return { ok: true };

View File

@@ -18,6 +18,8 @@ export default defineEventHandler(async event => {
const project = await ProjectModel.findById(project_id);
if (!project) return setResponseStatus(event, 400, 'Project not exist');
if (userData.id != project.owner.toString()) return setResponseStatus(event, 400, 'You cannot delete a project as guest');
const projects = await ProjectModel.countDocuments({ owner: userData.id });
if (projects == 1) return setResponseStatus(event, 400, 'Cannot delete last project');

View File

@@ -2,10 +2,17 @@ import mongoose from "mongoose";
import { Redis } from "~/server/services/CacheService";
import EmailService from '@services/EmailService';
import StripeService from '~/server/services/StripeService';
import { anomalyLoop } from "./services/AnomalyService";
const config = useRuntimeConfig();
let connection: mongoose.Mongoose;
let anomalyMinutesCount = 0;
function anomalyCheck() {
}
export default async () => {
console.log('[SERVER] Initializing');
@@ -37,4 +44,7 @@ export default async () => {
console.log('[SERVER] Completed');
console.log('[ANOMALY LOOP] Started');
anomalyLoop();
};

View File

@@ -0,0 +1,148 @@
import mongoose from "mongoose";
import { executeTimelineAggregation } from "./TimelineService";
import { VisitModel } from "@schema/metrics/VisitSchema";
import { AnomalyDomainModel } from '@schema/anomalies/AnomalyDomainSchema';
import { AnomalyVisitModel } from '@schema/anomalies/AnomalyVisitSchema';
import { AnomalyEventsModel } from '@schema/anomalies/AnomalyEventsSchema';
import { EventModel } from "@schema/metrics/EventSchema";
import EmailService from "@services/EmailService";
import * as url from 'url';
import { ProjectModel } from "@schema/ProjectSchema";
import { UserModel } from "@schema/UserSchema";
type TAvgInput = { _id: string, count: number }
const anomalyData = { minutes: 0 }
async function anomalyCheckAll() {
const start = performance.now();
console.log('START ANOMALY CHECK');
const projects = await ProjectModel.find({}, { _id: 1 });
for (const project of projects) {
await findAnomalies(project.id);
}
const end = start - performance.now();
console.log('END ANOMALY CHECK', end, 'ms');
}
export function anomalyLoop() {
if (anomalyData.minutes == 60 * 12) {
anomalyCheckAll();
anomalyData.minutes = 0;
}
setTimeout(() => anomalyLoop(), 1000 * 60);
}
function movingAverageAnomaly(visits: TAvgInput[], windowSize: number, threshold: number): TAvgInput[] {
const anomalies: TAvgInput[] = [];
for (let i = windowSize; i < visits.length; i++) {
const window = visits.slice(i - windowSize, i);
const mean = window.reduce((a, b) => a + b.count, 0) / window.length;
const stdDev = Math.sqrt(window.reduce((sum, visit) => sum + Math.pow(visit.count - mean, 2), 0) / window.length);
const currentVisit = visits[i];
if (Math.abs(currentVisit.count - mean) > threshold * stdDev) {
if (currentVisit.count <= mean) continue;
anomalies.push(currentVisit);
}
}
return anomalies;
}
function getUrlFromString(str: string) {
const res = str.startsWith('http') ? str : 'http://' + str;
return res;
}
export async function findAnomalies(project_id: string) {
const THRESHOLD = 6;
const WINDOW_SIZE = 14;
const pid = new mongoose.Types.ObjectId(project_id) as any;
const from = Date.now() - 1000 * 60 * 60 * 24 * 30;
const to = Date.now() - 1000 * 60 * 60 * 24;
const visitsTimelineData = await executeTimelineAggregation({
projectId: pid,
model: VisitModel,
from, to, slice: 'day'
});
const eventsTimelineData = await executeTimelineAggregation({
projectId: pid,
model: EventModel,
from, to, slice: 'day'
});
const websites: { _id: string, count: number }[] = await VisitModel.aggregate([
{ $match: { project_id: pid, created_at: { $gte: new Date(from), $lte: new Date(to) } }, },
{ $group: { _id: "$website", count: { $sum: 1, } } }
]);
const rootWebsite = websites.reduce((a, e) => {
return a.count > e.count ? a : e;
});
const rootDomain = new url.URL(getUrlFromString(rootWebsite._id)).hostname;
const detectedWebsites: string[] = [];
for (const website of websites) {
const websiteDomain = new url.URL(getUrlFromString(website._id)).hostname;
if (!websiteDomain.includes(rootDomain)) {
detectedWebsites.push(website._id);
}
}
const visitAnomalies = movingAverageAnomaly(visitsTimelineData, WINDOW_SIZE, THRESHOLD);
const eventAnomalies = movingAverageAnomaly(eventsTimelineData, WINDOW_SIZE, THRESHOLD);
const shouldSendMail = {
visitsEvents: false,
domains: false
}
for (const visit of visitAnomalies) {
const anomalyAlreadyExist = await AnomalyVisitModel.findOne({ visitDate: visit._id }, { _id: 1 });
if (anomalyAlreadyExist) continue;
await AnomalyVisitModel.create({ project_id: pid, visitDate: visit._id, created_at: Date.now() });
shouldSendMail.visitsEvents = true;
}
for (const event of eventAnomalies) {
const anomalyAlreadyExist = await AnomalyEventsModel.findOne({ eventDate: event._id }, { _id: 1 });
if (anomalyAlreadyExist) continue;
await AnomalyEventsModel.create({ project_id: pid, eventDate: event._id, created_at: Date.now() });
shouldSendMail.visitsEvents = true;
}
for (const website of detectedWebsites) {
const anomalyAlreadyExist = await AnomalyDomainModel.findOne({ domain: website }, { _id: 1 });
if (anomalyAlreadyExist) continue;
await AnomalyDomainModel.create({ project_id: pid, domain: website, created_at: Date.now() });
shouldSendMail.domains = true;
}
const project = await ProjectModel.findById(pid);
if (!project) return { ok: false, error: 'Cannot find project with id ' + pid.toString() }
const user = await UserModel.findById(project.owner);
if (!user) return { ok: false, error: 'Cannot find user with id ' + project.owner.toString() }
if (shouldSendMail.visitsEvents === true) {
await EmailService.sendAnomalyVisitsEventsEmail(user.email, project.name);
}
if (shouldSendMail.domains === true) {
await EmailService.sendAnomalyDomainEmail(user.email, project.name);
}
return { ok: true };
}

View File

@@ -6,7 +6,7 @@ import { RedisStreamService } from "@services/RedisStreamService";
const router = Router();
const allowAnyType = () => true;
const jsonOptions = { limit: '5mb', type: allowAnyType }
const jsonOptions = { limit: '25kb', type: allowAnyType }
const streamName = requireEnv('STREAM_NAME');

View File

@@ -9,7 +9,7 @@ const app = express();
app.use(cors());
const allowAnyType = () => true;
const jsonOptions = { limit: '5mb', type: allowAnyType }
const jsonOptions = { limit: '25kb', type: allowAnyType }
const streamName = requireEnv('STREAM_NAME');

View File

@@ -0,0 +1,16 @@
import { model, Schema, Types } from 'mongoose';
export type TAnomalyDomain = {
project_id: Schema.Types.ObjectId
domain: string,
created_at: Date
}
const AnomalyDomainSchema = new Schema<TAnomalyDomain>({
project_id: { type: Types.ObjectId, required: true },
domain: { type: String, required: true },
created_at: { type: Date, required: true },
})
export const AnomalyDomainModel = model<TAnomalyDomain>('anomaly_domains', AnomalyDomainSchema);

View File

@@ -0,0 +1,16 @@
import { model, Schema, Types } from 'mongoose';
export type TAnomalyEvents = {
project_id: Schema.Types.ObjectId
eventDate: string,
created_at: Date
}
const AnomalyEventsSchema = new Schema<TAnomalyEvents>({
project_id: { type: Types.ObjectId, required: true },
eventDate: { type: String, required: true },
created_at: { type: Date, required: true },
})
export const AnomalyEventsModel = model<TAnomalyEvents>('anomaly_events', AnomalyEventsSchema);

View File

@@ -0,0 +1,16 @@
import { model, Schema, Types } from 'mongoose';
export type TAnomalyVisit = {
project_id: Schema.Types.ObjectId
visitDate: string,
created_at: Date
}
const AnomalyVisitSchema = new Schema<TAnomalyVisit>({
project_id: { type: Types.ObjectId, required: true },
visitDate: { type: String, required: true },
created_at: { type: Date, required: true },
})
export const AnomalyVisitModel = model<TAnomalyVisit>('anomaly_visits', AnomalyVisitSchema);

View File

@@ -4,6 +4,8 @@ import { LIMIT_50_EMAIL } from './email_templates/Limit50Email';
import { LIMIT_90_EMAIL } from './email_templates/Limit90Email';
import { LIMIT_MAX_EMAIL } from './email_templates/LimitMaxEmail';
import { PURCHASE_EMAIL } from './email_templates/PurchaseEmail';
import { ANOMALY_VISITS_EVENTS_EMAIL } from './email_templates/AnomalyUsageEmail';
import { ANOMALY_DOMAIN_EMAIL } from './email_templates/AnomalyDomainEmail';
class EmailService {
@@ -99,6 +101,40 @@ class EmailService {
}
}
async sendAnomalyVisitsEventsEmail(target: string, projectName: string) {
try {
const sendSmtpEmail = new SendSmtpEmail();
sendSmtpEmail.subject = "🚨 Unexpected Activity Detected by our AI";
sendSmtpEmail.sender = { "name": "Litlyx", "email": "help@litlyx.com" };
sendSmtpEmail.to = [{ "email": target }];
sendSmtpEmail.htmlContent = ANOMALY_VISITS_EVENTS_EMAIL
.replace(/\[Project Name\]/, projectName)
.toString();;
await this.apiInstance.sendTransacEmail(sendSmtpEmail);
return true;
} catch (ex) {
console.error('ERROR SENDING EMAIL', ex);
return false;
}
}
async sendAnomalyDomainEmail(target: string, projectName: string) {
try {
const sendSmtpEmail = new SendSmtpEmail();
sendSmtpEmail.subject = "🚨 Anomaly detected by our AI";
sendSmtpEmail.sender = { "name": "Litlyx", "email": "help@litlyx.com" };
sendSmtpEmail.to = [{ "email": target }];
sendSmtpEmail.htmlContent = ANOMALY_DOMAIN_EMAIL
.replace(/\[Project Name\]/, projectName)
.toString();;
await this.apiInstance.sendTransacEmail(sendSmtpEmail);
return true;
} catch (ex) {
console.error('ERROR SENDING EMAIL', ex);
return false;
}
}
}
const instance = new EmailService();

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
export const ANOMALY_DOMAIN_EMAIL = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
@@ -9,17 +9,12 @@
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<!-- Email Content -->
<h2 style="color: #D32F2F;"> Anomaly detected by our AI</h2>
<p>Dear User,</p>
<p>We wanted to let you know that <strong>[Project Name]</strong> on <strong>Litlyx</strong> has an anomaly that our AI agent detected. Here is the report:</p>
<p>We wanted to let you know that <strong>[Project Name]</strong> on <strong>Litlyx</strong> has an anomaly that our AI agent detected.</p>
<p><strong>Anomaly:</strong> Suspicious DNS</p>
<p><strong>Message:</strong> [Suspicious DNS name] is logging data in your project. Is that you?</p>
<p><strong>Date:</strong> Current date!</p>
<p>You can analyze the suspicious DNS on your Litlyx dashboard. We put a symbol next to each suspicious DNS to let users know something might be wrong!</p>
<p>You can analyze a suspicious DNS on your Litlyx dashboard. We put a symbol next to each suspicious DNS to let users know something might be wrong!</p>
<h3>What can I do?</h3>
@@ -45,3 +40,4 @@
</body>
</html>
`

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
export const ANOMALY_VISITS_EVENTS_EMAIL = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
@@ -9,7 +9,6 @@
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<!-- Email Content -->
<h2 style="color: #D32F2F;">🚨 Unexpected Activity Detected by our AI</h2>
<p>Dear User,</p>
@@ -40,3 +39,4 @@
</body>
</html>
`