diff --git a/server/mail-templates/T16 Repo zuordnen.html b/server/mail-templates/T16 Repo zuordnen.html
new file mode 100644
index 0000000..eaae0f1
--- /dev/null
+++ b/server/mail-templates/T16 Repo zuordnen.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+{{betreff_vorsatz}}Neues Repo zuordnen: {{repo}}
+
+
+
+
+Neues Repo {{repo}} – ordne es kurz zu. ͏ ͏ ͏
+
+
+
+
+ | WEBklar |
|
+Intern · Ticketsystem |
+
|
+
+
+ |
+| Hallo {{vorname}}, zu welchem Projekt gehört dein neues Repo? |
+| Du hast {{repo}} in Gitea angelegt. Wähle im Ticketsystem die Art des Projekts und den Kunden aus – danach entsteht das Ticket. |
+
+| Das Repo ist seit gestern nicht zugeordnet. Das ist die einzige Erinnerung. |
|
+
+
+| Repo | {{repo}} |
+| Gitea | {{repo_link}} |
+| Angelegt von | {{angelegt_von}} |
+| Angelegt am | {{angelegt_am}} |
+ |
+ |
+| |
+
+ |
+| Du bekommst diese Mail, weil du das Repo angelegt hast. Es folgt höchstens eine Erinnerung nach 24 Stunden. |
+
+ |
+
+
+
diff --git a/server/services/repoAssignMail.js b/server/services/repoAssignMail.js
new file mode 100644
index 0000000..6d49ca4
--- /dev/null
+++ b/server/services/repoAssignMail.js
@@ -0,0 +1,83 @@
+import { config } from '../config.js'
+import { berlinDateTime } from './berlinTime.js'
+import { listDocuments, updateDocument, Query } from './appwriteAdmin.js'
+import { sendStaffMail } from './mailer.js'
+import { findEmployeeForPusher } from './pushTimeMail.js'
+
+/**
+ * Mail "Neues Repo zuordnen" (Vorlage T16): wer in Gitea ein Repo anlegt, bekommt eine Mail mit
+ * Knopf in die Zuordnung im Ticketsystem. Eine Erinnerung nach 24 Stunden, danach nichts mehr.
+ */
+
+const DAY_MS = 24 * 60 * 60 * 1000
+const MAX_ATTEMPTS = 3
+
+const time = (value) => {
+ const ms = new Date(value || '').getTime()
+ return Number.isNaN(ms) ? null : ms
+}
+
+/**
+ * Soll fuer dieses Repo die erste Mail raus? Nur wenn das Projekt-Dokument jetzt neu angelegt wurde,
+ * das Gitea-Repo juenger als 24 h ist, noch keine Art gesetzt ist und noch keine Mail ging.
+ */
+export function shouldSendAssignMail(project, repo, { now = new Date(), createdNow = false } = {}) {
+ if (!createdNow) return false
+ if (project?.kind || project?.assignMailSentAt) return false
+ if (Number(project?.assignMailAttempts || 0) >= MAX_ATTEMPTS) return false
+ const created = time(repo?.created_at)
+ if (created === null) return false
+ return new Date(now).getTime() - created < DAY_MS
+}
+
+/** Erinnerung faellig: Mail ging vor >= 24 h, Art fehlt noch, noch keine Erinnerung. */
+export function dueReminder(project, { now = new Date() } = {}) {
+ if (project?.kind || project?.assignReminderSentAt) return false
+ const sent = time(project?.assignMailSentAt)
+ if (sent === null) return false
+ return new Date(now).getTime() - sent >= DAY_MS
+}
+
+/** Mail verschicken; wirft nie (Webhook/Sync darf daran nicht scheitern). */
+export async function sendRepoAssignMail(project, { giteaLogin, reminder = false, repoCreatedAt } = {}) {
+ try {
+ const employees = await listDocuments(config.collections.employees, [Query.limit(100)])
+ const employee = findEmployeeForPusher({ username: giteaLogin }, employees)
+ if (!employee) {
+ console.warn(`[repoAssignMail] Gitea-Login "${giteaLogin || '?'}" keinem Mitarbeiter mit E-Mail zugeordnet – keine Mail`)
+ return { sent: false, reason: 'no_employee' }
+ }
+
+ const created = new Date(repoCreatedAt || project.$createdAt || Date.now())
+ const { date, time: hhmm } = berlinDateTime(Number.isNaN(created.getTime()) ? new Date() : created)
+ const attempts = Number(project.assignMailAttempts || 0) + 1
+ const message = {
+ template: 'T16',
+ values: {
+ betreff_vorsatz: reminder ? 'Erinnerung: ' : '',
+ vorname: String(employee.displayName || '').trim().split(/\s+/)[0] || 'du',
+ repo: project.repoFullName,
+ repo_link: project.giteaRepoUrl,
+ angelegt_von: employee.displayName || giteaLogin,
+ angelegt_am: `${date} ${hhmm.slice(0, 2)}:${hhmm.slice(2)}`,
+ zuordnen_link: `https://ticket.webklar.com/projects/zuordnen/${project.$id}`,
+ },
+ blocks: { erinnerung: Boolean(reminder) },
+ }
+
+ const patch = { assignMailAttempts: attempts }
+ try {
+ await sendStaffMail(employee.email, message)
+ } catch (err) {
+ console.error('[repoAssignMail] Versand fehlgeschlagen:', err.message)
+ await updateDocument(config.collections.websiteProjects, project.$id, patch)
+ return { sent: false, reason: err.message }
+ }
+ patch[reminder ? 'assignReminderSentAt' : 'assignMailSentAt'] = new Date().toISOString()
+ await updateDocument(config.collections.websiteProjects, project.$id, patch)
+ return { sent: true }
+ } catch (err) {
+ console.error('[repoAssignMail] Fehler:', err.message)
+ return { sent: false, reason: err.message }
+ }
+}
diff --git a/test/repoAssignMail.test.js b/test/repoAssignMail.test.js
new file mode 100644
index 0000000..caf340b
--- /dev/null
+++ b/test/repoAssignMail.test.js
@@ -0,0 +1,121 @@
+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 shouldSendAssignMail, dueReminder, sendRepoAssignMail
+let renderMail, setMailTransportForTests, getDocument
+
+const NOW = new Date('2026-09-30T15:00:00Z')
+const hoursAgo = (h) => new Date(NOW.getTime() - h * 3600_000).toISOString()
+
+const KENSO = { $id: 'k', displayName: 'Kenso Grimm', shortcode: 'KNSO', email: 'kenso@webklar.com', $createdAt: '2026-01-01T00:00:00.000Z' }
+const projectDoc = (extra = {}) => ({
+ $id: 'p1',
+ repoFullName: 'WEBklar/amin-friseur',
+ giteaRepoUrl: 'https://git.webklar.com/WEBklar/amin-friseur',
+ ...extra,
+})
+
+before(async () => {
+ fake = await startFakeAppwrite({
+ seed: {
+ employees: [KENSO],
+ websiteProjects: [projectDoc(), projectDoc({ $id: 'p2' }), projectDoc({ $id: 'p3' })],
+ },
+ })
+ Object.assign(process.env, { APPWRITE_ENDPOINT: fake.endpoint, APPWRITE_API_KEY: 'test-key' })
+ ;({ shouldSendAssignMail, dueReminder, sendRepoAssignMail } = await import('../server/services/repoAssignMail.js'))
+ ;({ renderMail } = await import('../server/services/mailTemplates.js'))
+ ;({ setMailTransportForTests } = await import('../server/services/mailer.js'))
+ ;({ getDocument } = await import('../server/services/appwriteAdmin.js'))
+})
+
+after(async () => {
+ setMailTransportForTests?.(null)
+ await fake.close()
+})
+
+const doc = (id) => getDocument(process.env.APPWRITE_COLLECTION_WEBSITE_PROJECTS || 'websiteProjects', id)
+
+test('shouldSendAssignMail: nur neu angelegt, < 24 h, ohne Art, < 3 Versuche', () => {
+ const repo = { created_at: hoursAgo(1) }
+ assert.equal(shouldSendAssignMail({}, repo, { now: NOW, createdNow: true }), true)
+ assert.equal(shouldSendAssignMail({}, repo, { now: NOW, createdNow: false }), false)
+ assert.equal(shouldSendAssignMail({}, { created_at: hoursAgo(25) }, { now: NOW, createdNow: true }), false)
+ assert.equal(shouldSendAssignMail({ kind: 'website' }, repo, { now: NOW, createdNow: true }), false)
+ assert.equal(shouldSendAssignMail({ assignMailAttempts: 3 }, repo, { now: NOW, createdNow: true }), false)
+ assert.equal(shouldSendAssignMail({ assignMailAttempts: 2 }, repo, { now: NOW, createdNow: true }), true)
+ assert.equal(shouldSendAssignMail({ assignMailSentAt: hoursAgo(1) }, repo, { now: NOW, createdNow: true }), false)
+ assert.equal(shouldSendAssignMail({}, {}, { now: NOW, createdNow: true }), false, 'ohne created_at keine Mail')
+})
+
+test('dueReminder: genau nach 24 h, ohne Art, ohne frühere Erinnerung', () => {
+ const base = { assignMailSentAt: hoursAgo(25) }
+ assert.equal(dueReminder(base, { now: NOW }), true)
+ assert.equal(dueReminder({ assignMailSentAt: hoursAgo(23) }, { now: NOW }), false)
+ assert.equal(dueReminder({}, { now: NOW }), false)
+ assert.equal(dueReminder({ ...base, kind: 'app' }, { now: NOW }), false)
+ assert.equal(dueReminder({ ...base, assignReminderSentAt: hoursAgo(1) }, { now: NOW }), false)
+})
+
+test('T16: rendert ohne Probleme, Betreff mit und ohne Erinnerung', () => {
+ const values = {
+ betreff_vorsatz: '', vorname: 'Kenso', repo: 'WEBklar/x', repo_link: 'https://git.webklar.com/WEBklar/x',
+ angelegt_von: 'Kenso Grimm', angelegt_am: '30.09.2026 17:00', zuordnen_link: 'https://ticket.webklar.com/projects/zuordnen/p1',
+ }
+ const plain = renderMail('T16', values)
+ assert.deepEqual(plain.problems, [])
+ assert.equal(plain.subject, 'Neues Repo zuordnen: WEBklar/x')
+ assert.doesNotMatch(plain.html, /einzige Erinnerung/)
+ const rem = renderMail('T16', { ...values, betreff_vorsatz: 'Erinnerung: ' }, { erinnerung: true })
+ assert.equal(rem.subject, 'Erinnerung: Neues Repo zuordnen: WEBklar/x')
+ assert.match(rem.html, /einzige Erinnerung/)
+})
+
+test('sendRepoAssignMail: Mail mit exaktem Link, setzt assignMailSentAt + Versuche', async () => {
+ const sink = mailSink()
+ setMailTransportForTests(sink)
+ const r = await sendRepoAssignMail(projectDoc({ $id: 'p1', $createdAt: '2026-09-30T14:46:00Z' }), { giteaLogin: '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].subject, 'Neues Repo zuordnen: WEBklar/amin-friseur')
+ assert.match(sink.sent[0].html, /href="https:\/\/ticket\.webklar\.com\/projects\/zuordnen\/p1"/)
+ assert.match(sink.sent[0].html, /30\.09\.2026 16:46/)
+ const d = await doc('p1')
+ assert.ok(d.assignMailSentAt)
+ assert.equal(d.assignMailAttempts, 1)
+})
+
+test('sendRepoAssignMail: Erinnerung setzt assignReminderSentAt', async () => {
+ const sink = mailSink()
+ setMailTransportForTests(sink)
+ const r = await sendRepoAssignMail(projectDoc({ $id: 'p1', assignMailAttempts: 1 }), { giteaLogin: 'knso', reminder: true })
+ assert.deepEqual(r, { sent: true })
+ assert.match(sink.sent[0].subject, /^Erinnerung: Neues Repo zuordnen/)
+ const d = await doc('p1')
+ assert.ok(d.assignReminderSentAt)
+ assert.equal(d.assignMailAttempts, 2)
+})
+
+test('sendRepoAssignMail: unbekannter Login → no_employee, kein Zähler', async () => {
+ const sink = mailSink()
+ setMailTransportForTests(sink)
+ const r = await sendRepoAssignMail(projectDoc({ $id: 'p2' }), { giteaLogin: 'fremd' })
+ assert.deepEqual(r, { sent: false, reason: 'no_employee' })
+ assert.equal(sink.sent.length, 0)
+ const d = await doc('p2')
+ assert.equal(d.assignMailAttempts, undefined)
+ assert.equal(d.assignMailSentAt, undefined)
+})
+
+test('sendRepoAssignMail: SMTP-Fehler zählt Versuch, setzt kein SentAt, wirft nie', async () => {
+ setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP kaputt') } })
+ const r = await sendRepoAssignMail(projectDoc({ $id: 'p3', assignMailAttempts: 1 }), { giteaLogin: 'knso' })
+ assert.equal(r.sent, false)
+ const d = await doc('p3')
+ assert.equal(d.assignMailAttempts, 2)
+ assert.equal(d.assignMailSentAt, undefined)
+})