diff --git a/server/mail-templates/T15 Zeit eintragen.html b/server/mail-templates/T15 Zeit eintragen.html
new file mode 100644
index 0000000..3eaf866
--- /dev/null
+++ b/server/mail-templates/T15 Zeit eintragen.html
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+[Zeit] #{{woid}} {{kunde}} – {{anzahl_commits}} Commit(s)
+
+
+
+
+Push auf {{repo}} – trag kurz deine Zeit ein. ͏ ͏ ͏
+
+
+
+
+ | WEBklar |
|
+Intern · Ticketsystem |
+
|
+
+
+ |
+| Hallo {{vorname}}, wie lange hast du daran gearbeitet? |
+| Du hast gerade auf {{repo}} ({{branch}}) gepusht. Ein Klick trägt die Zeit im Ticket ein. |
+ |
+
+| Ticket | #{{woid}} · {{ticket_titel}} |
+| Kunde | {{kunde}} |
+| Status | {{ticket_status}} |
+| Beschreibung | {{ticket_beschreibung}} |
+| Push | {{push_zeit}} · {{anzahl_commits}} Commit(s) |
+ |
+ |
+ |
+| |
+
+ |
+| Der Link gilt 14 Tage und trägt nur die Zeit für diesen Push ein. Ändern kannst du sie danach im Ticketsystem. |
+
+ |
+
+
+
diff --git a/server/services/mailer.js b/server/services/mailer.js
index a4b83c7..e037280 100644
--- a/server/services/mailer.js
+++ b/server/services/mailer.js
@@ -129,3 +129,22 @@ export async function sendCustomerMail(to, { attachments = [], ...message }) {
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,
+ })
+}
diff --git a/server/services/pushTimeMail.js b/server/services/pushTimeMail.js
new file mode 100644
index 0000000..932e688
--- /dev/null
+++ b/server/services/pushTimeMail.js
@@ -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/).
+ */
+
+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 }
+ }
+}
diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js
index 4ca9cbd..7a6cb33 100644
--- a/test/mailTemplates.test.js
+++ b/test/mailTemplates.test.js
@@ -6,7 +6,7 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
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);
-// 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 = [
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
'K12',
@@ -16,6 +16,7 @@ const EXPECTED = [
'T12',
'T13',
'T14',
+ 'T15',
]
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 title = source.match(/([\s\S]*?)<\/title>/)[1]
assert.equal(title, tpl.subject, 'Betreff im Kopf und sind gleich')
- // F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben) oder [Datenschutz] (Loeschfristen)
- assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz)\]/.test(tpl.subject) || id === 'T01', 'Team-Hinweise ab T02 mit [Abo], [Speicher] oder [Datenschutz]')
+ // F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen) oder [Zeit] (Push-Zeitmail)
+ 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]))
assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter')
diff --git a/test/pushTimeMail.test.js b/test/pushTimeMail.test.js
new file mode 100644
index 0000000..c13643f
--- /dev/null
+++ b/test/pushTimeMail.test.js
@@ -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: ' & 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, /<script>x<\/script> & Co/)
+ assert.doesNotMatch(out.html, /