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

This commit is contained in:
2026-07-20 14:17:07 +02:00
parent 7a9dd06fc6
commit 53ad9f23d8
11 changed files with 834 additions and 268 deletions

View File

@@ -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"
}
]

View File

@@ -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"
}
}

View File

@@ -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}`);

View 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 };
}