Push-Zeitmail: Vorlage T15, Zuordnung Pusher → Mitarbeiter, Versand

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-30 13:39:12 +00:00
1 parent ec3f22d98e
commit 1a1005d099
5 files changed
+306 -3

No files matched your search

@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
T15 · Zeit für einen Git-Push eintragen
Betreff: [Zeit] #{{woid}} {{kunde}} – {{anzahl_commits}} Commit(s)
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: die Person, die gepusht hat (employees.email)
Platzhalter: {{vorname}}, {{woid}}, {{kunde}}, {{ticket_titel}}, {{ticket_beschreibung}}, {{ticket_status}}, {{repo}}, {{branch}}, {{push_zeit}}, {{anzahl_commits}}, {{commits}}, {{link_15}}, {{link_30}}, {{link_45}}, {{link_60}}, {{link_90}}, {{link_120}}, {{link_180}}, {{link_andere}}, {{ticket_link}}
commits ist Text (je Zeile Kurz-SHA und Titel); die link_*-Adressen öffnen die Bestätigungsseite /zeit/… (speichert erst nach Klick auf „Speichern“).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>[Zeit] #{{woid}} {{kunde}} – {{anzahl_commits}} Commit(s)</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.btn2{background:#2a2a28!important;border-color:#3a3a37!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Push auf {{repo}} – trag kurz deine Zeit ein.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Intern · Ticketsystem</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#fdebd3;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#8a4b00;">Zeit eintragen</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Hallo {{vorname}}, wie lange hast du daran gearbeitet?</td></tr>
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Du hast gerade auf {{repo}} ({{branch}}) gepusht. Ein Klick trägt die Zeit im Ticket ein.</td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="btn" bgcolor="#111111" align="center" style="background:#111111;border-radius:10px;"><a href="{{link_15}}" style="display:block;padding:12px 4px;font-family:Arial,Helvetica,sans-serif;font-size:14px;font-weight:bold;color:#ffffff;text-decoration:none;">15 Min</a></td><td width="8" style="width:8px;font-size:0;line-height:0;">&nbsp;</td><td class="btn" bgcolor="#111111" align="center" style="background:#111111;border-radius:10px;"><a href="{{link_30}}" style="display:block;padding:12px 4px;font-family:Arial,Helvetica,sans-serif;font-size:14px;font-weight:bold;color:#ffffff;text-decoration:none;">30 Min</a></td><td width="8" style="width:8px;font-size:0;line-height:0;">&nbsp;</td><td class="btn" bgcolor="#111111" align="center" style="background:#111111;border-radius:10px;"><a href="{{link_45}}" style="display:block;padding:12px 4px;font-family:Arial,Helvetica,sans-serif;font-size:14px;font-weight:bold;color:#ffffff;text-decoration:none;">45 Min</a></td><td width="8" style="width:8px;font-size:0;line-height:0;">&nbsp;</td><td class="btn" bgcolor="#111111" align="center" style="background:#111111;border-radius:10px;"><a href="{{link_60}}" style="display:block;padding:12px 4px;font-family:Arial,Helvetica,sans-serif;font-size:14px;font-weight:bold;color:#ffffff;text-decoration:none;">1 Std</a></td></tr>
<tr><td colspan="7" style="height:8px;font-size:0;line-height:0;">&nbsp;</td></tr>
<tr><td class="btn" bgcolor="#111111" align="center" style="background:#111111;border-radius:10px;"><a href="{{link_90}}" style="display:block;padding:12px 4px;font-family:Arial,Helvetica,sans-serif;font-size:14px;font-weight:bold;color:#ffffff;text-decoration:none;">1,5 Std</a></td><td width="8" style="width:8px;font-size:0;line-height:0;">&nbsp;</td><td class="btn" bgcolor="#111111" align="center" style="background:#111111;border-radius:10px;"><a href="{{link_120}}" style="display:block;padding:12px 4px;font-family:Arial,Helvetica,sans-serif;font-size:14px;font-weight:bold;color:#ffffff;text-decoration:none;">2 Std</a></td><td width="8" style="width:8px;font-size:0;line-height:0;">&nbsp;</td><td class="btn" bgcolor="#111111" align="center" style="background:#111111;border-radius:10px;"><a href="{{link_180}}" style="display:block;padding:12px 4px;font-family:Arial,Helvetica,sans-serif;font-size:14px;font-weight:bold;color:#ffffff;text-decoration:none;">3 Std</a></td><td width="8" style="width:8px;font-size:0;line-height:0;">&nbsp;</td><td class="btn" bgcolor="#111111" align="center" style="background:#111111;border-radius:10px;"><a href="{{link_andere}}" style="display:block;padding:12px 4px;font-family:Arial,Helvetica,sans-serif;font-size:14px;font-weight:bold;color:#ffffff;text-decoration:none;">Andere Zeit</a></td></tr>
</table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Ticket</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;">#{{woid}} · {{ticket_titel}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Kunde</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;">{{kunde}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Status</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;">{{ticket_status}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Beschreibung</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;">{{ticket_beschreibung}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Push</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;">{{push_zeit}} · {{anzahl_commits}} Commit(s)</td></tr>
</table></td></tr>
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:16px 20px;font-family:Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#111111;">{{commits}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn2" bgcolor="#ffffff" style="background:#ffffff;border:1px solid #d9d6ce;border-radius:10px;"><a href="{{ticket_link}}" style="display:block;padding:12px 22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#111111;text-decoration:none;">Ticket öffnen &rarr;</a></td></tr></table></td></tr>
<tr><td style="padding:0 0 40px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Der Link gilt 14 Tage und trägt nur die Zeit für diesen Push ein. Ändern kannst du sie danach im Ticketsystem.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+19
View File
@@ -129,3 +129,22 @@ export async function sendCustomerMail(to, { attachments = [], ...message }) {
attachments, attachments,
}) })
} }
/**
* Mail an eine einzelne Person im Team aus einer T-Vorlage (z. B. T15 Push-Zeitmail), ohne BCC.
* Wirft bei fehlendem SMTP oder Versandfehler - der Aufrufer entscheidet, ob das schlimm ist.
* @param {string} to
* @param {{ template: string, values?: object, blocks?: object }} message
*/
export async function sendStaffMail(to, message) {
const mailer = getTransporter()
if (!mailer) throw new Error('SMTP nicht konfiguriert')
const mail = render(message)
return mailer.sendMail({
from: `"${TEAM_SENDER}" <${config.smtp.from}>`,
to,
subject: mail.subject,
text: mail.text,
html: mail.html,
})
}
+106
View File
@@ -0,0 +1,106 @@
import { config } from '../config.js'
import { berlinDateTime } from './berlinTime.js'
import { listDocuments, Query } from './appwriteAdmin.js'
import { sendStaffMail } from './mailer.js'
import { createPushTimeToken } from './pushTimeToken.js'
/**
* Push-Zeitmail (Vorlage T15): nach einem neuen Git-Push-Arbeitsblatt bekommt die pushende Person
* eine Mail mit Ticket- und Push-Infos und Schnell-Knoepfen zum Eintragen der Zeit (/zeit/<token>).
*/
const QUICK_MINUTES = [15, 30, 45, 60, 90, 120, 180]
const MAX_COMMITS = 10
const DESCRIPTION_MAX = 300
// Deutsche Bezeichnungen wie in WOMS (lib/ticketStatus.js)
const STATUS_LABELS = {
Open: 'Offen',
Assigned: 'Eingeplant',
Occupied: 'In Arbeit',
Awaiting: 'Wartet',
'In Test': 'Prüfung',
Closed: 'Erledigt',
Cancelled: 'Abgebrochen',
}
const norm = (value) => String(value || '').trim().toLowerCase()
/**
* Gitea-Pusher einem Mitarbeiter zuordnen - dieselbe Regel wie canEditWorksheetTimes in WOMS:
* Gitea-Name gegen Kuerzel oder Anzeigenamen, ohne Gross-/Kleinschreibung. Nur mit E-Mail-Adresse.
*/
export function findEmployeeForPusher(pusher, employees) {
const names = [pusher?.username, pusher?.login, pusher?.full_name].map(norm).filter(Boolean)
if (!names.length) return null
const sorted = [...(employees || [])].sort((a, b) => String(a.$createdAt || '').localeCompare(String(b.$createdAt || '')))
return (
sorted.find(
(emp) => String(emp.email || '').trim() && names.some((n) => n === norm(emp.shortcode) || n === norm(emp.displayName))
) || null
)
}
function commitLines(commits) {
const list = Array.isArray(commits) ? commits : []
const lines = list.slice(0, MAX_COMMITS).map((commit) => {
const sha = String(commit.id || commit.sha || '').slice(0, 7)
const title = String(commit.message || '').trim().split('\n')[0]
return `${sha} ${title}`.trim()
})
if (list.length > MAX_COMMITS) lines.push(`… und ${list.length - MAX_COMMITS} weitere`)
return lines.join('\n') || '(keine Commit-Details)'
}
function shorten(text, max) {
const value = String(text || '').trim()
return value.length > max ? `${value.slice(0, max)}…` : value
}
/** Werte fuer Vorlage T15 */
export function buildPushTimeMail({ employee, ticket, worksheet, repoFullName, branch, commits, pushedAt = new Date() }) {
const token = createPushTimeToken({ worksheetId: worksheet.$id, employeeId: employee.$id })
const base = `${config.publicBaseUrl}/zeit/${token}`
const { date, time } = berlinDateTime(pushedAt)
const woid = String(ticket?.woid || worksheet?.woid || '')
const links = Object.fromEntries(QUICK_MINUTES.map((m) => [`link_${m}`, `${base}?min=${m}`]))
return {
template: 'T15',
values: {
vorname: String(employee.displayName || '').trim().split(/\s+/)[0] || 'du',
woid,
kunde: ticket?.customerName || 'ohne Kunde',
ticket_titel: ticket?.topic || '',
ticket_beschreibung: shorten(ticket?.details, DESCRIPTION_MAX),
ticket_status: STATUS_LABELS[ticket?.status] || ticket?.status || '',
repo: repoFullName,
branch,
push_zeit: `${date} ${time.slice(0, 2)}:${time.slice(2)}`,
anzahl_commits: Array.isArray(commits) ? commits.length : 0,
commits: commitLines(commits),
...links,
link_andere: base,
ticket_link: `${config.womsBaseUrl}/tickets?woid=${encodeURIComponent(woid)}`,
},
}
}
/** Mail verschicken; wirft nie (Webhook darf daran nicht scheitern). */
export async function sendPushTimeMail({ ticket, worksheet, repoFullName, branch, commits, pusher, pushedAt }) {
try {
const employees = await listDocuments(config.collections.employees, [Query.limit(100)])
const employee = findEmployeeForPusher(pusher, employees)
if (!employee) {
const name = pusher?.username || pusher?.login || pusher?.full_name || '?'
console.warn(`[pushTimeMail] Pusher "${name}" keinem Mitarbeiter mit E-Mail zugeordnet – keine Zeitmail`)
return { sent: false, reason: 'no_employee' }
}
const mail = buildPushTimeMail({ employee, ticket, worksheet, repoFullName, branch, commits, pushedAt })
await sendStaffMail(employee.email, mail)
return { sent: true }
} catch (err) {
console.error('[pushTimeMail] Versand fehlgeschlagen:', err.message)
return { sent: false, reason: err.message }
}
}
+4 -3
View File
@@ -6,7 +6,7 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
const DIR = new URL('../server/mail-templates/', import.meta.url) const DIR = new URL('../server/mail-templates/', import.meta.url)
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); // K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1);
// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39) // Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail
const EXPECTED = [ const EXPECTED = [
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`), ...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
'K12', 'K12',
@@ -16,6 +16,7 @@ const EXPECTED = [
'T12', 'T12',
'T13', 'T13',
'T14', 'T14',
'T15',
] ]
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`])) const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
@@ -31,8 +32,8 @@ for (const id of EXPECTED) {
const source = readFileSync(new URL(tpl.file, DIR), 'utf8') const source = readFileSync(new URL(tpl.file, DIR), 'utf8')
const title = source.match(/<title>([\s\S]*?)<\/title>/)[1] const title = source.match(/<title>([\s\S]*?)<\/title>/)[1]
assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich') assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich')
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben) oder [Datenschutz] (Loeschfristen) // F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen) oder [Zeit] (Push-Zeitmail)
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz)\]/.test(tpl.subject) || id === 'T01', 'Team-Hinweise ab T02 mit [Abo], [Speicher] oder [Datenschutz]') assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit)\]/.test(tpl.subject) || id === 'T01', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz] oder [Zeit]')
const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1])) const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))
assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter') assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter')
+117
View File
@@ -0,0 +1,117 @@
import { test, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
import { mailSink } from './support/mailSink.js'
let fake
let findEmployeeForPusher, buildPushTimeMail, sendPushTimeMail
let renderMail, verifyPushTimeToken, setMailTransportForTests
const KENSO = { $id: 'k', displayName: 'Kenso Grimm', shortcode: 'KNSO', email: 'kenso@webklar.com', $createdAt: '2026-01-01T00:00:00.000Z' }
const ANDREJ = { $id: 'a', displayName: 'Andrej Stevanovski', shortcode: 'ANDJ', email: '', $createdAt: '2026-01-02T00:00:00.000Z' }
const ticket = { $id: 't1', woid: '100118', customerName: 'Amin Friseur', topic: 'Website Amin Friseur', details: 'Neue Seite', status: 'Occupied' }
const worksheet = { $id: 'ws9', woid: '100118', serviceType: 'GIT' }
const commits = [
{ id: 'abc1234def', message: 'Startseite: Hero neu\n\nlanger Text' },
{ id: 'def5678abc', message: '<script>x</script> & Co' },
]
const input = (extra = {}) => ({
ticket, worksheet, repoFullName: 'WEBklar/amin-friseur', branch: 'main', commits,
pushedAt: new Date('2026-09-30T14:46:00Z'), ...extra,
})
before(async () => {
fake = await startFakeAppwrite({ seed: { employees: [ANDREJ, KENSO] } })
Object.assign(process.env, {
APPWRITE_ENDPOINT: fake.endpoint,
APPWRITE_API_KEY: 'test-key',
PUSH_TIME_SECRET: 'z'.repeat(40),
})
;({ findEmployeeForPusher, buildPushTimeMail, sendPushTimeMail } = await import('../server/services/pushTimeMail.js'))
;({ renderMail } = await import('../server/services/mailTemplates.js'))
;({ verifyPushTimeToken } = await import('../server/services/pushTimeToken.js'))
;({ setMailTransportForTests } = await import('../server/services/mailer.js'))
})
after(async () => {
setMailTransportForTests?.(null)
await fake.close()
})
test('findEmployeeForPusher: Kürzel, voller Name, ohne E-Mail, unbekannt', () => {
const employees = [ANDREJ, KENSO]
assert.equal(findEmployeeForPusher({ username: 'knso' }, employees)?.$id, 'k')
assert.equal(findEmployeeForPusher({ full_name: ' kenso grimm ' }, employees)?.$id, 'k')
assert.equal(findEmployeeForPusher({ login: 'ANDJ' }, employees), null)
assert.equal(findEmployeeForPusher({ username: 'fremd' }, employees), null)
assert.equal(findEmployeeForPusher({}, employees), null)
})
test('buildPushTimeMail: Betreff, Links, Escaping, gültiger Token', () => {
const mail = buildPushTimeMail({ employee: KENSO, ...input() })
assert.equal(mail.template, 'T15')
const out = renderMail('T15', mail.values)
assert.deepEqual(out.problems, [])
assert.equal(out.subject, '[Zeit] #100118 Amin Friseur – 2 Commit(s)')
assert.match(out.html, /Hallo Kenso,/)
assert.match(out.html, /In Arbeit/)
assert.match(out.html, /30\.09\.2026 16:46/)
for (const m of [15, 30, 45, 60, 90, 120, 180]) {
assert.match(out.html, new RegExp(`/zeit/[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\?min=${m}"`))
}
assert.match(out.html, /\/zeit\/[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"/, 'Andere Zeit ohne min')
assert.match(out.html, /https:\/\/ticket\.webklar\.com\/tickets\?woid=100118/)
assert.match(out.html, /abc1234 Startseite: Hero neu/)
assert.doesNotMatch(out.html, /langer Text/)
assert.match(out.html, /&lt;script&gt;x&lt;\/script&gt; &amp; Co/)
assert.doesNotMatch(out.html, /<script>/)
const token = mail.values.link_andere.split('/zeit/')[1]
assert.deepEqual(verifyPushTimeToken(token), { worksheetId: 'ws9', employeeId: 'k' })
})
test('buildPushTimeMail: 12 Commits → 10 Zeilen + Rest', () => {
const many = Array.from({ length: 12 }, (_, i) => ({ id: `${String(i).padStart(7, '0')}ffff`, message: `Commit ${i}` }))
const { values } = buildPushTimeMail({ employee: KENSO, ...input({ commits: many }) })
const lines = values.commits.split('\n')
assert.equal(lines.length, 11)
assert.equal(lines.at(-1), '… und 2 weitere')
assert.equal(values.anzahl_commits, 12)
})
test('buildPushTimeMail: lange Beschreibung gekürzt, ohne Kunde', () => {
const { values } = buildPushTimeMail({
employee: KENSO,
...input({ ticket: { ...ticket, customerName: '', details: 'x'.repeat(400), status: 'Weird' } }),
})
assert.equal(values.ticket_beschreibung.length, 301)
assert.ok(values.ticket_beschreibung.endsWith('…'))
assert.equal(values.kunde, 'ohne Kunde')
assert.equal(values.ticket_status, 'Weird')
})
test('sendPushTimeMail: an den Pusher, ohne BCC', async () => {
const sink = mailSink()
setMailTransportForTests(sink)
const r = await sendPushTimeMail({ ...input(), pusher: { username: 'knso' } })
assert.deepEqual(r, { sent: true })
assert.equal(sink.sent.length, 1)
assert.deepEqual(sink.sent[0].to.map((t) => t.address), ['kenso@webklar.com'])
assert.equal(sink.sent[0].from.name, 'WEBklar Kundenportal')
assert.equal(sink.sent[0].bcc, undefined)
})
test('sendPushTimeMail: unbekannter Pusher → keine Mail', async () => {
const sink = mailSink()
setMailTransportForTests(sink)
const r = await sendPushTimeMail({ ...input(), pusher: { username: 'fremd' } })
assert.equal(r.sent, false)
assert.equal(sink.sent.length, 0)
})
test('sendPushTimeMail: Versandfehler wird geschluckt', async () => {
setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP kaputt') } })
const r = await sendPushTimeMail({ ...input(), pusher: { username: 'knso' } })
assert.equal(r.sent, false)
})