feat(analytics-mailer): Redesign email to Umami format with 3 periods, German translation, and inline glossary
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 3s
Monorepo Pipeline / 🧹 Lint (push) Failing after 1m24s
Monorepo Pipeline / 🧪 Test (push) Successful in 41s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m4s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
🏥 Server Maintenance / 🧹 Prune & Clean (push) Failing after 7s
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 3s
Monorepo Pipeline / 🧹 Lint (push) Failing after 1m24s
Monorepo Pipeline / 🧪 Test (push) Successful in 41s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m4s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
🏥 Server Maintenance / 🧹 Prune & Clean (push) Failing after 7s
This commit is contained in:
@@ -1,14 +1,34 @@
|
||||
[
|
||||
{
|
||||
"websiteId": "d773ea10-a3b3-4ccf-9024-987e14c4d669",
|
||||
"domain": "e-tib.com",
|
||||
"clientEmail": "marc@mintel.me",
|
||||
"clientName": "E-TIB GmbH"
|
||||
},
|
||||
{
|
||||
"websiteId": "59a7db94-0100-4c7e-98ef-99f45b17f9c3",
|
||||
"domain": "klz-cables.com",
|
||||
"clientEmail": "marc@mintel.me",
|
||||
"clientName": "KLZ"
|
||||
"clientName": "KLZ Cables",
|
||||
"greeting": "Hallo Herr Künzel,",
|
||||
"interval": "monthly"
|
||||
},
|
||||
{
|
||||
"websiteId": "b9035279-72f1-49bc-909d-b073d184c388",
|
||||
"domain": "mintel.me",
|
||||
"clientEmail": "marc@mintel.me",
|
||||
"clientName": "Mintel.me",
|
||||
"greeting": "Hallo Team Mintel,",
|
||||
"interval": "monthly"
|
||||
},
|
||||
{
|
||||
"websiteId": "caa402bc-b2d2-4349-a254-84c0dd2a4460",
|
||||
"domain": "mb-grid-solutions.com",
|
||||
"clientEmail": "marc@mintel.me",
|
||||
"clientName": "MB Grid Solutions",
|
||||
"greeting": "Hallo Herr Bartenbach,",
|
||||
"interval": "monthly"
|
||||
},
|
||||
{
|
||||
"websiteId": "d773ea10-a3b3-4ccf-9024-987e14c4d669",
|
||||
"domain": "e-tib.com",
|
||||
"clientEmail": "marc@mintel.me",
|
||||
"clientName": "E-TIB GmbH",
|
||||
"greeting": "Sehr geehrter Herr Mintel,",
|
||||
"interval": "monthly"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -18,9 +18,10 @@
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/node": "^22.19.10",
|
||||
"tsup": "^8.3.5",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import axios from "axios";
|
||||
import { render } from "@mintel/mail";
|
||||
import { MonthlyAnalyticsTemplate } from "@mintel/mail";
|
||||
import { AnalyticsTemplate } from "@mintel/mail";
|
||||
import { getLast7Days, getThisMonth, getThisYear } from "./utils/time-calculator";
|
||||
import Mailgun from "mailgun.js";
|
||||
import formData from "form-data";
|
||||
import * as dotenv from "dotenv";
|
||||
@@ -24,6 +25,8 @@ interface ConfigClient {
|
||||
domain: string;
|
||||
clientEmail: string;
|
||||
clientName: string;
|
||||
greeting?: string;
|
||||
interval?: "weekly" | "monthly";
|
||||
}
|
||||
|
||||
async function getUmamiToken(): Promise<string> {
|
||||
@@ -40,27 +43,67 @@ async function getUmamiToken(): Promise<string> {
|
||||
return res.data.token;
|
||||
}
|
||||
|
||||
async function fetchUmamiData(api: any, websiteId: string, startAt: number, endAt: number) {
|
||||
const [statsRes, pagesRes, refRes] = await Promise.all([
|
||||
import https from "https";
|
||||
|
||||
async function fetchUmamiData(api: any, websiteId: string, startAt: number, endAt: number, label: string) {
|
||||
let unit = "day";
|
||||
if (label === "Dieses Jahr") unit = "month";
|
||||
|
||||
const [statsRes, pagesRes, refRes, viewsRes] = await Promise.all([
|
||||
api.get(`/websites/${websiteId}/stats`, { params: { startAt, endAt } }),
|
||||
api.get(`/websites/${websiteId}/metrics`, { params: { type: "url", limit: 5, startAt, endAt } }),
|
||||
api.get(`/websites/${websiteId}/metrics`, { params: { type: "referrer", limit: 3, startAt, endAt } }),
|
||||
api.get(`/websites/${websiteId}/metrics`, { params: { type: "path", limit: 5, startAt, endAt } }),
|
||||
api.get(`/websites/${websiteId}/metrics`, { params: { type: "referrer", limit: 5, startAt, endAt } }),
|
||||
api.get(`/websites/${websiteId}/pageviews`, { params: { startAt, endAt, unit, timezone: "Europe/Berlin" } }).catch(() => ({ data: { pageviews: [] } })),
|
||||
]);
|
||||
|
||||
const stats = statsRes.data;
|
||||
const topPages = (pagesRes.data || []).map((p: any) => ({ url: p.x, views: p.y }));
|
||||
const topReferrers = (refRes.data || []).map((r: any) => ({ url: r.x, visitors: r.y }));
|
||||
const topPages = (pagesRes.data || []).map((p: any) => ({ url: p.x, views: p.y || 0 }));
|
||||
const topReferrers = (refRes.data || []).map((r: any) => ({ url: r.x, visitors: r.y || 0 }));
|
||||
|
||||
const visitors = typeof stats.visitors === "object" ? stats.visitors?.value || 0 : stats.visitors || 0;
|
||||
const visits = typeof stats.visits === "object" ? stats.visits?.value || 0 : stats.visits || 0;
|
||||
const views = typeof stats.pageviews === "object" ? stats.pageviews?.value || 0 : stats.pageviews || 0;
|
||||
const bounces = typeof stats.bounces === "object" ? stats.bounces?.value || 0 : stats.bounces || 0;
|
||||
const totaltime = typeof stats.totaltime === "object" ? stats.totaltime?.value || 0 : stats.totaltime || 0;
|
||||
|
||||
const bounceRate = visits > 0 ? Math.round((bounces / visits) * 100) : 0;
|
||||
|
||||
let visitDuration = "0s";
|
||||
if (visits > 0) {
|
||||
const avgSeconds = Math.round(totaltime / visits);
|
||||
if (avgSeconds < 60) {
|
||||
visitDuration = `${avgSeconds}s`;
|
||||
} else {
|
||||
visitDuration = `${Math.floor(avgSeconds / 60)}m ${avgSeconds % 60}s`;
|
||||
}
|
||||
}
|
||||
|
||||
const chartData = (viewsRes.data?.pageviews || []).map((pv: any) => ({
|
||||
label: pv.x,
|
||||
value: pv.y || 0
|
||||
}));
|
||||
|
||||
return {
|
||||
totalVisitors: stats.visitors?.value || 0,
|
||||
totalPageviews: stats.pageviews?.value || 0,
|
||||
label,
|
||||
visitors,
|
||||
visits,
|
||||
views,
|
||||
bounceRate,
|
||||
visitDuration,
|
||||
topPages,
|
||||
topReferrers,
|
||||
chartData,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const isDryRun = process.argv.includes("--dry-run");
|
||||
const intervalArg = process.argv.find(a => a.startsWith("--interval="));
|
||||
const runInterval = intervalArg ? intervalArg.split("=")[1] : "monthly";
|
||||
|
||||
if (runInterval !== "weekly" && runInterval !== "monthly") {
|
||||
throw new Error("Invalid interval. Use --interval=weekly or --interval=monthly");
|
||||
}
|
||||
|
||||
// Load config
|
||||
const configPath = path.resolve(process.cwd(), "config.json");
|
||||
@@ -70,47 +113,39 @@ async function main() {
|
||||
// Setup Umami API
|
||||
const token = await getUmamiToken();
|
||||
const headers = UMAMI_API_KEY ? { "x-umami-api-key": token } : { Authorization: `Bearer ${token}` };
|
||||
const api = axios.create({ baseURL: `${UMAMI_BASE_URL}/api`, headers });
|
||||
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
||||
const api = axios.create({ baseURL: `${UMAMI_BASE_URL}/api`, headers, httpsAgent });
|
||||
|
||||
// Setup Mailgun
|
||||
const mailgun = new Mailgun(formData);
|
||||
const mg = mailgun.client({ username: "api", key: MAILGUN_API_KEY!, url: MAILGUN_URL });
|
||||
const mg = mailgun.client({ username: "api", key: MAILGUN_API_KEY || "dummy", url: MAILGUN_URL });
|
||||
|
||||
// Time range calculation (previous month)
|
||||
const now = new Date();
|
||||
// Ensure we get the correct previous month relative to current local time, but use UTC boundary or local? Umami uses ms timestamps.
|
||||
// Standard way: First day of previous month, last day of previous month.
|
||||
const startOfPrevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const endOfPrevMonth = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
|
||||
|
||||
const startAt = startOfPrevMonth.getTime();
|
||||
const endAt = endOfPrevMonth.getTime();
|
||||
const timeRanges = [getLast7Days(), getThisMonth(), getThisYear()];
|
||||
|
||||
const monthName = startOfPrevMonth.toLocaleString("de-DE", { month: "long" });
|
||||
const year = startOfPrevMonth.getFullYear().toString();
|
||||
|
||||
console.log(`Generating reports for ${monthName} ${year}...`);
|
||||
console.log(`Generating reports...`);
|
||||
|
||||
for (const client of clients) {
|
||||
if (client.websiteId === "replace-with-umami-website-id") continue;
|
||||
const clientInterval = client.interval || "monthly";
|
||||
if (clientInterval !== runInterval) {
|
||||
console.log(`Skipping ${client.domain} (runs ${clientInterval}, currently executing ${runInterval})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Processing ${client.domain} (${client.clientName})...`);
|
||||
|
||||
try {
|
||||
const data = await fetchUmamiData(api, client.websiteId, startAt, endAt);
|
||||
const periods = await Promise.all(
|
||||
timeRanges.map((tr) => fetchUmamiData(api, client.websiteId, tr.startAt, tr.endAt, tr.label))
|
||||
);
|
||||
|
||||
const html = await render(MonthlyAnalyticsTemplate({
|
||||
clientName: client.clientName,
|
||||
const html = await render(AnalyticsTemplate({
|
||||
greeting: client.greeting || `Hallo ${client.clientName},`,
|
||||
domain: client.domain,
|
||||
month: monthName,
|
||||
year,
|
||||
totalVisitors: data.totalVisitors,
|
||||
totalPageviews: data.totalPageviews,
|
||||
topPages: data.topPages,
|
||||
topReferrers: data.topReferrers,
|
||||
periods,
|
||||
}));
|
||||
|
||||
const subject = `Ihr Website-Report für ${monthName} ${year} - ${client.domain}`;
|
||||
const subject = `Performance-Report - ${client.domain}`;
|
||||
|
||||
if (isDryRun) {
|
||||
console.log(`[DRY-RUN] Would send email to ${client.clientEmail} for ${client.domain}`);
|
||||
|
||||
19
apps/analytics-mailer/src/utils/time-calculator.ts
Normal file
19
apps/analytics-mailer/src/utils/time-calculator.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export function getLast7Days() {
|
||||
const endAt = Date.now();
|
||||
const startAt = endAt - 7 * 24 * 60 * 60 * 1000;
|
||||
return { label: "Letzte 7 Tage", startAt, endAt };
|
||||
}
|
||||
|
||||
export function getThisMonth() {
|
||||
const now = new Date();
|
||||
const startAt = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
|
||||
const endAt = Date.now();
|
||||
return { label: "Dieser Monat", startAt, endAt };
|
||||
}
|
||||
|
||||
export function getThisYear() {
|
||||
const now = new Date();
|
||||
const startAt = new Date(now.getFullYear(), 0, 1).getTime();
|
||||
const endAt = Date.now();
|
||||
return { label: "Dieses Jahr", startAt, endAt };
|
||||
}
|
||||
Reference in New Issue
Block a user