Hustler: Name in der Oberfläche, Budget höchstens 150 $/Monat (6 $/Tag) mit hartem Deckel

Kenso, 17.10.: "150 € im Monat max", "nenne es Hustler". Technische
Namen (workers*) bleiben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-17 20:26:42 +00:00
1 parent 740089c997
commit 11e4280a92
8 files changed
+184 -156

No files matched your search

@@ -454,7 +454,7 @@ WOMS `/home/webklar/apps/ticket.webklar.com/dev-workers/`: `src/lib/workersPult.
**Interfaces:**
- Consumes: `onInbound`, `lastOutbound` (T06), `startBuild` (T13), `suppress`, `suppressAddress` (T02), `recordHardBounce` (T14a, wird vor T08 ausgeführt), `addPult` (P3, `emit` inklusive), `enqueue`, `deps.notify`, `emit`, `getSettings`.
- Produces: `applyClassification(deps, {message, thread, prospect, result, senderEmail}) → {action: string}` · `routeFrage(topic, {hasPreview}) → 'antwort'|'angebot'|'preisfrage'|'mensch_gewuenscht'` · `blockProspect(deps, {prospect, thread, emails, reason}) → void` · `expireOffersFor(deps, prospectId) → number` (offene `workersOffers` ⇒ `abgelaufen`).
- Regeln: `confident = confidence ≥ 0.7`. Ereignis `sortiert` (`nora`, Text `Antwort eingeordnet`, `meta.classification`); `onInbound(thread, {classification, confident})`; **zuletzt**, nach allen Folgen unten, erhält die Nachricht `classification`/`classificationConfidence` – das ist die Marke „erledigt“, auf die T15 beim Wiederaufnehmen nach einem Absturz prüft (alle Folgen sind wiederholbar: `suppress` ist idempotent, `startBuild` legt keine zweite Vorschau an). Nicht sicher oder `unklar` ⇒ Pult `unsicher` (Titel `Unsichere Antwort`, `originalText` = Nachrichtentext). `ja`: Interessent gesperrt/verloren/deal ⇒ ignorieren; Vorschau `gesendet|freigabe|fertig` ⇒ Job `angebot` (theo, `payload {previewId}`); Vorschau im Bau ⇒ nichts; sonst `startBuild`. `frage`: `routeFrage` – `allgemein` ⇒ Job `antwort` (max, `payload {author:'max'}`; bei einem Plattform-Faden stattdessen `clara`, T34); `kaufen` mit Vorschau ⇒ Job `angebot`, ohne Vorschau ⇒ `antwort` (ebenso); `preis_verhandlung` ⇒ Pult `preisfrage`; `mensch_gewuenscht` ⇒ Pult `mensch_gewuenscht`. `nein`/`abmelden` ⇒ `blockProspect` (Gründe `nein`/`abmeldung`). `beschwerde` ⇒ `blockProspect` (`beschwerde`), Pult `beschwerde` (Titel `Beschwerde oder rechtliche Drohung`), sofort `deps.notify({subject:'Beschwerde oder rechtliche Drohung', text})` mit Betrieb, Auszug (≤ 600 Zeichen) und WOMS-Link `https://ticket.webklar.com/workers`. `abwesend` ⇒ nichts. `unzustellbar` ⇒ bei `hardBounce` `suppressAddress(bounceEmail,'bounce','nora')` (nur die Adresse), `recordHardBounce(deps, {source:'dsn', messageDocId})` (H4, T14a: markiert die ausgehende Nachricht `unzustellbar`, vermerkt den Bounce im Tageszähler und prüft die Notbremse; `messageDocId` = Dokument-Id der ausgehenden Nachricht des Fadens mit `messageId === result.originalMessageId`, sonst `lastOutbound(deps, thread.$id)`, ohne ausgehende Nachricht leer), Interessent `verloren`; bei weichem Bounce (`hardBounce` falsch) nichts davon und nichts im Zähler.
- Regeln: `confident = confidence ≥ 0.7`. Ereignis `sortiert` (`nora`, Text `Antwort eingeordnet`, `meta.classification`); `onInbound(thread, {classification, confident})`; **zuletzt**, nach allen Folgen unten, erhält die Nachricht `classification`/`classificationConfidence` – das ist die Marke „erledigt“, auf die T15 beim Wiederaufnehmen nach einem Absturz prüft (alle Folgen sind wiederholbar: `suppress` ist idempotent, `startBuild` legt keine zweite Vorschau an). Nicht sicher oder `unklar` ⇒ Pult `unsicher` (Titel `Unsichere Antwort`, `originalText` = Nachrichtentext). `ja`: Interessent gesperrt/verloren/deal ⇒ ignorieren; Vorschau `gesendet|freigabe|fertig` ⇒ Job `angebot` (theo, `payload {previewId}`); Vorschau im Bau ⇒ nichts; sonst `startBuild`. `frage`: `routeFrage` – `allgemein` ⇒ Job `antwort` (max, `payload {author:'max'}`; bei einem Plattform-Faden stattdessen `clara`, T34); `kaufen` mit Vorschau ⇒ Job `angebot`, ohne Vorschau ⇒ `antwort` (ebenso); `preis_verhandlung` ⇒ Pult `preisfrage`; `mensch_gewuenscht` ⇒ Pult `mensch_gewuenscht`. `nein`/`abmelden` ⇒ `blockProspect` (Gründe `nein`/`abmeldung`). `beschwerde` ⇒ `blockProspect` (`beschwerde`), Pult `beschwerde` (Titel `Beschwerde oder rechtliche Drohung`), sofort `deps.notify({subject:'Beschwerde oder rechtliche Drohung', text})` mit Betrieb, Auszug (≤ 600 Zeichen) und WOMS-Link `https://ticket.webklar.com/hustler` (Betreff ohne eigenes Präfix: P1s `notify` setzt `[Hustler] ` davor). `abwesend` ⇒ nichts. `unzustellbar` ⇒ bei `hardBounce` `suppressAddress(bounceEmail,'bounce','nora')` (nur die Adresse), `recordHardBounce(deps, {source:'dsn', messageDocId})` (H4, T14a: markiert die ausgehende Nachricht `unzustellbar`, vermerkt den Bounce im Tageszähler und prüft die Notbremse; `messageDocId` = Dokument-Id der ausgehenden Nachricht des Fadens mit `messageId === result.originalMessageId`, sonst `lastOutbound(deps, thread.$id)`, ohne ausgehende Nachricht leer), Interessent `verloren`; bei weichem Bounce (`hardBounce` falsch) nichts davon und nichts im Zähler.
- `expireOffersFor`: lädt `workersOffers` über `Query.equal('status', ['offen'])` (indiziert), filtert `prospectId` im Code und setzt nur `offen` auf `abgelaufen` (ein Angebot in `checkout` oder `bezahlt` bleibt unberührt).
- `blockProspect`: `suppress` für `thread.toEmail` und jede weitere Adresse aus `emails` (leere Adressen, etwa bei Plattform-Fäden, werden übersprungen; Firmen-Domain sperrt P1 automatisch, Freemail nie), Interessent `gesperrt`, Vorschau des Interessenten `verworfen` (nicht bei gesetztem `convertedProjectId`), `expireOffersFor`, Ereignis `gesperrt` (`nora`, Text ohne Namen/Adressen); ist der Interessent schon `deal`, kommt nur die Sperrliste (Status, Vorschau, Angebote bleiben).
@@ -486,7 +486,7 @@ WOMS `/home/webklar/apps/ticket.webklar.com/dev-workers/`: `src/lib/workersPult.
- Produces (Übersicht §12 H4):
- `recordHardBounce(deps, {messageDocId?, source}) → {counted:boolean, stopped:boolean}` (`source` ∈ `dsn`, `smtp`). Ablauf: (1) mit `messageDocId` das Dokument laden; hat es schon `classification 'unzustellbar'` ⇒ `{counted:false, stopped:false}` ohne Zählen (Wiederholung nach einem Absturz zählt nicht doppelt); (2) den ISO-Zeitpunkt (`isoNow(deps)`) an `counters.__bounce` (Array) des Berliner Tagesdokuments anhängen, über `updateDaily`, höchstens die letzten 50 pro Tag; (3) mit `messageDocId` und `direction 'out'` die ausgehende Nachricht auf `classification:'unzustellbar'` setzen (Zählen vor Markieren: ein Absturz dazwischen zählt höchstens einen Bounce doppelt, nie zu wenig); (4) `evaluateBounces(deps)`. Ohne `messageDocId` (SMTP lehnt sofort ab, die Reservierung ist schon gelöscht) entfallen (1) und (3).
- `evaluateBounces(deps) → {stopped:boolean, reason?:string}`. Harte Bounces = die Zeitpunkte in `counters.__bounce` der Tagesdokumente der letzten 8 Berliner Tage (`getDaily` je Tag). **Regel 1:** Ausgehende Nachrichten über `deps.db.list('workersMessages', [Query.orderDesc('$createdAt'), Query.limit(100)])` laden (`$createdAt` ist ein Systemfeld und braucht keinen Index), im Code auf `direction 'out'` mit gesetztem `sentAt` filtern und nach `sentAt` absteigend ordnen; T10 sei `sentAt` der 10. jüngsten (gibt es weniger als 10, die älteste); stoppen, wenn mindestens 2 harte Bounces einen Zeitpunkt ≥ T10 haben (Grund `Zwei harte Bounces bei den letzten 10 Mails`). **Regel 2:** harte Bounces der letzten 7 Tage geteilt durch `max(Σ(mailsCold + mailsReply) der 7 Berliner Tage, 20)` ist **größer** als 0,05 (Grund `Bounce-Quote über 5 % in 7 Tagen (<b> von <n> Mails)`; der Mindestnenner 20 verhindert, dass ein einzelner Bounce bei wenigen Mails sofort stoppt). Weiche Bounces stehen nie in `__bounce` und zählen nicht. Ist `settings.sendingHalted` schon wahr, bleibt es dabei (`{stopped:true}`, keine zweite Wirkung).
- `tripKillSwitch(deps, reason) → {tripped:boolean}` (idempotent, serialisiert über `onceFor('killswitch', …)`): ist `settings.sendingHalted` schon wahr ⇒ `{tripped:false}` ohne jede Wirkung; sonst `updateSettings(deps, {sendingHalted:true, sendingHaltedReason: reason (≤ 300 Zeichen)}, 'system')`, Ereignis `fehler` (`workerId 'system'`, Text `Versand gestoppt: Bounce-Notbremse`, `meta {reason:'bounce_stop'}`) und `deps.notify({subject:'Workers: Versand gestoppt (Bounces)', text})` (Grund plus Hinweis, dass ein Admin den Versand in WOMS unter `/workers`, Einstellungen, freigibt; geht an `settings.reportEmail`; ein Fehler beim Mailen wird nur geloggt) – Ereignis und Mail je genau einmal.
- `tripKillSwitch(deps, reason) → {tripped:boolean}` (idempotent, serialisiert über `onceFor('killswitch', …)`): ist `settings.sendingHalted` schon wahr ⇒ `{tripped:false}` ohne jede Wirkung; sonst `updateSettings(deps, {sendingHalted:true, sendingHaltedReason: reason (≤ 300 Zeichen)}, 'system')`, Ereignis `fehler` (`workerId 'system'`, Text `Versand gestoppt: Bounce-Notbremse`, `meta {reason:'bounce_stop'}`) und `deps.notify({subject:'Versand gestoppt (Bounces)', text})` (P1s `notify` setzt `[Hustler] ` davor; Text: Grund plus Hinweis, dass ein Admin den Versand in WOMS unter `/hustler`, Einstellungen, freigibt; geht an `settings.reportEmail`; ein Fehler beim Mailen wird nur geloggt) – Ereignis und Mail je genau einmal.
- [ ] **Step 1: Failing test** `test/mail-killswitch.test.js` (`makeP4Deps`, `seedProspect`, `seedThread`; Hilfen: `sentMails(n)` legt n ausgehende Nachrichten mit `sentAt` im Minutenabstand an und rückt die Uhr jeweils um 1 Minute vor, `dayCounts(day, n)` setzt `mailsCold` des Tages per `updateDaily`; `deps.notify.calls` ist der Spion aus T01):
- `2 harte Bounces in den letzten 10 Mails`: 10 Mails, der erste `recordHardBounce` (mit `messageDocId` einer ausgehenden Nachricht) ⇒ `{stopped:false}`; der zweite ⇒ `{stopped:true}`, `getSettings().sendingHalted === true`, `sendingHaltedReason` nicht leer und ≤ 300 Zeichen, `updatedBy 'system'`; genau ein Ereignis `fehler` mit `meta.reason === 'bounce_stop'` und genau ein `deps.notify`-Aufruf; ein dritter Bounce ändert daran nichts (weiterhin ein Ereignis, eine Mail).
@@ -835,12 +835,12 @@ WOMS `/home/webklar/apps/ticket.webklar.com/dev-workers/`: `src/lib/workersPult.
**Interfaces:**
- Consumes: `sendMail` (T08), `ensureThread`, `lastInbound`, `onInbound` (T06), `enqueue`, `PultError` (P3-T11), `onceFor`, `getDoc`, `isoNow`.
- Produces: `REPLY_KINDS = ['mail_antwort','unsicher','preisfrage','mensch_gewuenscht']` · `replyToThread(deps, {pultId, text, user:{id, name}}) → {item}` · `approvePreviewItem(deps, {pultId, user}) → {item, previewUrl}` · `reasonText(reason) → string` (deutsche Meldungen: `pause` „Workers ist pausiert.“, `versand_gestoppt` „Der Versand ist wegen Bounces gestoppt; ein Admin muss ihn freigeben.“, `platzhalter` „Im Text steht noch ein Platzhalter.“, `adresse_ungueltig` „Die Adresse passt nicht zum Gespräch.“, `link_anzahl` „Der Text enthält zu viele Links.“, `gesperrt` „Dieser Kontakt ist gesperrt.“, `datenschutz` „Der Datenschutz-Abschnitt ist noch nicht freigegeben.“, `antwortlimit` „Das Tageslimit für Antworten ist erreicht.“, `smtp_gesperrt`/`smtp_auth` „Der Mail-Versand ist für heute gestoppt.“, `nicht_konfiguriert` „Der Mail-Versand ist nicht eingerichtet.“, sonst `Senden nicht möglich (<Grund>).`) · `gespraecheRoutes() → [{method, pattern, handler}]` in der Form von P3-E6/P3-T15 (Pfade relativ zu `/api/workers`, Team-Recht prüft P1s Kern): `{method:'POST', pattern:/^\/pult\/([^/]+)\/reply$/, handler}` (Body `{text}`) und `{method:'POST', pattern:/^\/pult\/([^/]+)\/approve-preview$/, handler}`; `handler(deps, ctx)` liest `ctx.params[0]` (Eintrags-Id), `ctx.body`, `ctx.user = {id, name, labels}` und liefert `{status, json}`; `PultError` ⇒ `{status: e.status, json:{error: e.message}}`, Erfolg `{status:200, json:{item}}` bzw. `{item, previewUrl}`; ungültige Id (nicht `^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$`) ⇒ 404.
- Produces: `REPLY_KINDS = ['mail_antwort','unsicher','preisfrage','mensch_gewuenscht']` · `replyToThread(deps, {pultId, text, user:{id, name}}) → {item}` · `approvePreviewItem(deps, {pultId, user}) → {item, previewUrl}` · `reasonText(reason) → string` (deutsche Meldungen: `pause` „Hustler ist pausiert.“, `versand_gestoppt` „Der Versand ist wegen Bounces gestoppt; ein Admin muss ihn freigeben.“, `platzhalter` „Im Text steht noch ein Platzhalter.“, `adresse_ungueltig` „Die Adresse passt nicht zum Gespräch.“, `link_anzahl` „Der Text enthält zu viele Links.“, `gesperrt` „Dieser Kontakt ist gesperrt.“, `datenschutz` „Der Datenschutz-Abschnitt ist noch nicht freigegeben.“, `antwortlimit` „Das Tageslimit für Antworten ist erreicht.“, `smtp_gesperrt`/`smtp_auth` „Der Mail-Versand ist für heute gestoppt.“, `nicht_konfiguriert` „Der Mail-Versand ist nicht eingerichtet.“, sonst `Senden nicht möglich (<Grund>).`) · `gespraecheRoutes() → [{method, pattern, handler}]` in der Form von P3-E6/P3-T15 (Pfade relativ zu `/api/workers`, Team-Recht prüft P1s Kern): `{method:'POST', pattern:/^\/pult\/([^/]+)\/reply$/, handler}` (Body `{text}`) und `{method:'POST', pattern:/^\/pult\/([^/]+)\/approve-preview$/, handler}`; `handler(deps, ctx)` liest `ctx.params[0]` (Eintrags-Id), `ctx.body`, `ctx.user = {id, name, labels}` und liefert `{status, json}`; `PultError` ⇒ `{status: e.status, json:{error: e.message}}`, Erfolg `{status:200, json:{item}}` bzw. `{item, previewUrl}`; ungültige Id (nicht `^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$`) ⇒ 404.
- `replyToThread` (in `onceFor('pult:' + pultId)`): Eintrag fehlt ⇒ 404 „Eintrag nicht gefunden.“ (wie P3); Art ∉ `REPLY_KINDS` ⇒ 409 „Dazu gibt es keine Antwort-Funktion.“; ohne `prospectId` (etwa „Mail ohne Faden“ aus T15) ⇒ 409 „Zu diesem Eintrag gibt es keinen Interessenten.“; Status ∉ `offen|in_arbeit|veraltet` ⇒ 409 „Schon erledigt.“; `in_arbeit` von anderer Person ⇒ 409 „Wird schon von einem Kollegen bearbeitet.“; Text getrimmt leer ⇒ 400, > 8000 ⇒ 400; Faden aus `item.threadId`/`prospect.threadId`, fehlt er und der Interessent hat eine E-Mail ⇒ `ensureThread` (Betreff `Re: <Name>`); Kanal ≠ `mail` ⇒ 409 „Nur für Mail-Fäden.“; `sendMail(deps, {prospectId, threadId, to: thread.toEmail, subject: 'Re: ' + Betreff ohne führendes „Re: “, text, cold:false, inReplyTo: lastInbound?.messageId, kind:'antwort', author:'mensch:<id>', idempotencyKey:'pult:<pultId>'})`; nicht gesendet ⇒ 409 mit `reasonText`; gesendet ⇒ Eintrag `gesendet`, `doneBy 'mensch:<id>'`, `doneAt`. Von der Bounce-Notbremse ist dieser Weg **nicht** ausgenommen (A19): `versand_gestoppt` blockiert wie `pause` jede Mail; für `mensch:` entfällt nur das Sendefenster, von den Inhaltsregeln (H3) gilt nur `platzhalter`.
- `approvePreviewItem` (in `onceFor`): Art ≠ `vorschau_freigabe` ⇒ 409; Status wie oben; Vorschau nicht `freigabe` ⇒ 409 „Die Vorschau ist nicht freigabebereit.“; Interessent gesperrt ⇒ 409 „Dieser Kontakt ist gesperrt.“; Job `vorschau_link` (`max`, `payload {previewId, text: item.draft}`); Eintrag `erledigt`, `doneBy`, `doneAt`; Antwort `previewUrl` = `sourceUrl`.
- [ ] **Step 1: Failing test** (Eintrag `unsicher` mit `threadId 't1'`, eingegangene Nachricht mit `messageId '<in1@firma.de>'`; Nutzer `U1 = {id:'u1', name:'Justin'}`):
- `reply`: genau eine Mail an `thread.toEmail` mit Betreff `Re: Ihre Gratis-Vorschau`, Header `In-Reply-To: <in1@firma.de>`, Text enthält `Ein Mitarbeiter von WEBklar`; gespeicherte Nachricht `author 'mensch:u1'`; Eintrag `gesendet`/`doneBy 'mensch:u1'`; am Samstag ebenso (Fenster gilt nicht); `settings.paused` ⇒ 409 „Workers ist pausiert.“, nichts gesendet; `settings.sendingHalted` ⇒ 409 „Der Versand ist wegen Bounces gestoppt; ein Admin muss ihn freigeben.“, nichts gesendet, Eintrag unverändert (A19); Text mit `{{name}}` ⇒ 409 „Im Text steht noch ein Platzhalter.“, ein Text mit `!` und `Sehr geehrte` wird dagegen gesendet (ein Mensch entscheidet über Ton, H3).
- `reply`: genau eine Mail an `thread.toEmail` mit Betreff `Re: Ihre Gratis-Vorschau`, Header `In-Reply-To: <in1@firma.de>`, Text enthält `Ein Mitarbeiter von WEBklar`; gespeicherte Nachricht `author 'mensch:u1'`; Eintrag `gesendet`/`doneBy 'mensch:u1'`; am Samstag ebenso (Fenster gilt nicht); `settings.paused` ⇒ 409 „Hustler ist pausiert.“, nichts gesendet; `settings.sendingHalted` ⇒ 409 „Der Versand ist wegen Bounces gestoppt; ein Admin muss ihn freigeben.“, nichts gesendet, Eintrag unverändert (A19); Text mit `{{name}}` ⇒ 409 „Im Text steht noch ein Platzhalter.“, ein Text mit `!` und `Sehr geehrte` wird dagegen gesendet (ein Mensch entscheidet über Ton, H3).
- Doppelklick (zwei parallele Aufrufe) ⇒ eine Mail, der zweite Aufruf 409 „Schon erledigt.“; anderer Nutzer bei `in_arbeit` von `u2` ⇒ 409; Art `beschwerde` ⇒ 409; Plattform-Faden ⇒ 409 „Nur für Mail-Fäden.“; gesperrter Interessent ⇒ 409 „Dieser Kontakt ist gesperrt.“, nichts gesendet; leerer Text ⇒ 400.
- `mail_antwort` ohne Faden (Clara-Entwurf, Interessent mit E-Mail) ⇒ Faden wird angelegt, die Mail enthält den vollen Art.-14-Block (`Art. 21 DSGVO`) – mit `privacyInteressentenLive=false` ⇒ 409 „Der Datenschutz-Abschnitt …“; Eintrag `unsicher` ohne `prospectId` ⇒ 409 „Zu diesem Eintrag gibt es keinen Interessenten.“
- `approve`: Eintrag `vorschau_freigabe` (Vorschau `freigabe`) ⇒ Job `vorschau_link` mit `payload.text === item.draft`, Eintrag `erledigt`, Antwort `previewUrl`; zweiter Klick ⇒ 409; Vorschau nicht `freigabe` ⇒ 409; gesperrter Interessent ⇒ 409; falsche Art ⇒ 409.
@@ -925,7 +925,7 @@ WOMS `/home/webklar/apps/ticket.webklar.com/dev-workers/`: `src/lib/workersPult.
**Interfaces:**
- Produces: Dockerfile-Zeilen (vor der Zeile `USER node`) `RUN apk add --no-cache chromium ttf-dejavu`, `RUN mkdir -p /data/vorschau && chown -R node:node /data` (kein `playwright install`) · `ops/traefik/workers-entwurf.yml` mit Router `entwurf-https` (`Host(\`entwurf.project.webklar.com\`)`, `entryPoints: [websecure]`, `priority: 200`, `tls.certResolver: letsencrypt`, Service `entwurf-workers` → `http://webklar-workers:8093`) und Router `entwurf-http` (`entryPoints: [web]`, Middleware `entwurf-https` = `redirectScheme https permanent`) · `ops/compose-p4.md` (nur die **neuen** Zeilen für den Compose-Dienst `workers`; `WORKERS_UNSUB_BASE`/`WORKERS_UNSUB_SECRET` und die SMTP-/IMAP-Umgebung stehen seit P1-T27 im Block und werden nicht doppelt eingetragen: `WORKERS_CHROMIUM_PATH=/usr/bin/chromium`, `WORKERS_AUTOMAIL=${WORKERS_AUTOMAIL:-nein}`, `HOME=/tmp`, `XDG_CACHE_HOME=/tmp/.cache` und die Ersetzung des bisherigen `tmpfs`-Eintrags `/tmp` durch `/tmp:size=256m`; ein Hinweis nennt, dass der Datenschutz-Schalter kein Env ist, sondern `settings.privacyInteressentenLive`) · `parseArgs(argv) → {cmd, to?, name?}` (wirft ohne `--ja-echt`: „Ohne --ja-echt wird nichts ausgeführt.“; Befehle `smtp` (braucht `--to`), `imap`, `screenshot`, `build`, `pilot` (braucht `--to` und `--name`); sonst „Unbekannter Befehl“).
- `scripts/p4-livecheck.mjs` (manuell, Kenso): `smtp` sendet **eine** Testmail über `config.smtp` (ein Versuch, mit denselben Headern wie das Tor, Betreff `Workers P4 Testmail`) und druckt die Message-ID; `imap` druckt über P3s Leser `peek('antworten')` `uidValidity/uidNext/exists`; `screenshot` startet `createShooter` gegen eine lokale Testseite und druckt Bildgrößen und Spitzen-Speicher; `build` macht **einen** echten Opus-Aufruf mit Beispielbetrieb „Testbetrieb Muster“ (über `ask` mit `stream: true`, wie `runBuild`), prüft die Ausgabe mit `validateBuildFiles`, schreibt nach `/tmp/p4-build-test/` und druckt Tokens und Kosten; `pilot` legt Interessent (`entrance 'anfrage'`, `room 'netz'`, `foundBy 'rita'`, `source 'pilot'`, `status 'bewertet'`, `name` = `contactName` = `--name`, `requestText 'Pilot-Test: einfache Firmenwebsite'`, E-Mail = `--to`, `emailSource 'Pilot-Test'`), Faden und Job `antwort` (`author 'clara'`, fester Testtext `PILOT_TEXT` mit „Antworten Sie mit „ja““, exportiert; er besteht die Regeln des Tors) an und druckt die Ids. Nie Passwort, Schlüssel oder Mailinhalt ausgeben.
- `scripts/p4-livecheck.mjs` (manuell, Kenso): `smtp` sendet **eine** Testmail über `config.smtp` (ein Versuch, mit denselben Headern wie das Tor, Betreff `Hustler P4 Testmail`) und druckt die Message-ID; `imap` druckt über P3s Leser `peek('antworten')` `uidValidity/uidNext/exists`; `screenshot` startet `createShooter` gegen eine lokale Testseite und druckt Bildgrößen und Spitzen-Speicher; `build` macht **einen** echten Opus-Aufruf mit Beispielbetrieb „Testbetrieb Muster“ (über `ask` mit `stream: true`, wie `runBuild`), prüft die Ausgabe mit `validateBuildFiles`, schreibt nach `/tmp/p4-build-test/` und druckt Tokens und Kosten; `pilot` legt Interessent (`entrance 'anfrage'`, `room 'netz'`, `foundBy 'rita'`, `source 'pilot'`, `status 'bewertet'`, `name` = `contactName` = `--name`, `requestText 'Pilot-Test: einfache Firmenwebsite'`, E-Mail = `--to`, `emailSource 'Pilot-Test'`), Faden und Job `antwort` (`author 'clara'`, fester Testtext `PILOT_TEXT` mit „Antworten Sie mit „ja““, exportiert; er besteht die Regeln des Tors) an und druckt die Ids. Nie Passwort, Schlüssel oder Mailinhalt ausgeben.
- [ ] **Step 1: Failing test:**
- `p4-ops`: `Dockerfile` enthält `apk add --no-cache chromium` und `ttf-dejavu`, enthält nicht `playwright install`; `ops/traefik/workers-entwurf.yml` enthält die Zeilen `Host(\`entwurf.project.webklar.com\`)`, `priority: 200`, `certResolver: letsencrypt`, `http://webklar-workers:8093`, `redirectScheme`; `ops/compose-p4.md` nennt die fünf neuen Einträge oben und **nicht** `WORKERS_UNSUB_`/`WORKERS_DATENSCHUTZ_OK`.
@@ -992,12 +992,12 @@ Voraussetzung: P1–P3 laufen (P3-Betriebsanleitung durchgeführt), die Absender
5. **Bauen und starten.** `free -m | sed -n 2p` (mindestens 1,5 GB verfügbar), `docker compose build workers`, dann `docker compose up -d --no-deps workers`. Prüfung: `docker logs --tail 30 webklar-workers` zeigt `workers listening on 8092` und `vorschau listening on 8093` ohne Fehlerzeile; `docker exec webklar-workers chromium --version` zeigt eine Version; `docker exec webklar-workers sh -c 'mkdir -p /data/vorschau && id -un && ls -ld /data/vorschau'` zeigt `node` und `node` als Besitzer und `drwxr-xr-x` (0755; in einem schon vorhandenen Volume fehlt der Ordner bisher; scheitert `mkdir`, meldet Claude den Fehler und ändert nichts am Volume). Der Dienst legt Vorschau-Verzeichnisse selbst mit 0755 und Dateien mit 0644 an, damit der schreibgeschützt eingehängte Portal-Container (P5) sie lesen kann (A14).
6. **Schema nachziehen** (jetzt, im laufenden Container): `docker exec webklar-workers node scripts/setup-collections.mjs --check; echo "Exit $?"` → erwartet `workersPreviews.idx_status FEHLT`, `workersSettings.sendingHalted FEHLT`, `workersSettings.sendingHaltedReason FEHLT` (Bounce-Notbremse, H4), `Exit 1`; dann derselbe Befehl ohne `--check` (Ende `Fertig.`), danach wieder mit `--check` → `Alles vorhanden.`, `Exit 0`.
7. **Traefik-Route.** `cp /home/webklar/apps/workers/ops/traefik/workers-entwurf.yml /home/webklar/apps/traefik/dynamic/workers-entwurf.yml` (Hot-Reload, kein Neustart). Nach etwa 1 Minute: `curl -sI https://entwurf.project.webklar.com/abc` zeigt `404` und `x-robots-tag: noindex, nofollow`; `curl -sv https://entwurf.project.webklar.com/abc 2>&1 | rg -a -i 'issuer'` zeigt Let's Encrypt. Bei Zertifikatsfehler 5 Minuten warten und `docker logs --tail 50 webklar-traefik | rg -a entwurf` lesen.
8. **WOMS ausliefern.** `git -C /home/webklar/apps/ticket.webklar.com/dev-workers push origin feature/workers:test`. Prüfung nach dem Deploy: `https://ticket.webklar.com/workers` → Reiter „Freigabe“ lädt ohne Fehler.
8. **WOMS ausliefern.** `git -C /home/webklar/apps/ticket.webklar.com/dev-workers push origin feature/workers:test`. Prüfung nach dem Deploy: `https://ticket.webklar.com/hustler` → Reiter „Freigabe“ lädt ohne Fehler.
9. **Live-Prüfungen (je einzeln, Kosten nur bei `build`).**
- `docker exec webklar-workers node scripts/p4-livecheck.mjs smtp --to kenso.gri@gmail.com --ja-echt` → Mail kommt an; in Gmail „Original anzeigen“: SPF, DKIM, DMARC `PASS`, `List-Unsubscribe` und `List-Unsubscribe-Post` vorhanden. Meldet das Skript einen Login-Fehler, **nicht** wiederholen: Passwort in `.env` prüfen, erst dann erneut.
- `docker exec webklar-workers node scripts/p4-livecheck.mjs imap --ja-echt` → druckt `uidValidity`, `uidNext`, `exists`.
- `docker exec webklar-workers node scripts/p4-livecheck.mjs screenshot --ja-echt` → druckt je Ansicht Bildgrößen (lange Kante ≤ 1456) und den Spitzen-Speicher; parallel `docker stats --no-stream webklar-workers` in einem zweiten Fenster. Liegt der Speicher nahe 768 MB oder der Container wird beendet: `mem_limit` und `memswap_limit` auf `1g` anheben (Kensos Entscheidung), `docker compose up -d --no-deps workers`.
- `docker exec webklar-workers node scripts/p4-livecheck.mjs build --ja-echt` → ein echter Opus-Aufruf über Streaming (etwa 1–3 $, Dauer einige Minuten); Ausgabe nennt Tokens, Kosten und `validateBuildFiles: ok`. Bricht der Aufruf mit Zeitüberschreitung oder 400er-Fehler ab, **nicht** wiederholen, sondern die Meldung an Claude geben.
10. **Pilot mit eigenem Postfach** (nur Mo–Fr 09–16 Uhr). Das Tor lässt die erste Nachricht in einem Faden nur zu, wenn der Datenschutz-Schalter an ist. Für den Test geht die Mail nur an die eigene Adresse; deshalb den Schalter kurz einlegen (P3-Skript): `docker exec webklar-workers node scripts/datenschutz-schalter.mjs --an` und **am Ende dieses Schritts** wieder `… --aus`, solange der Abschnitt „Interessenten“ nicht live ist (P6). Dann `docker exec webklar-workers node scripts/p4-livecheck.mjs pilot --to kenso.gri@gmail.com --name "Testbetrieb Muster" --ja-echt` → innerhalb von 5 Minuten kommt die Testmail. Antworte „ja, gern“. Erwartung in WOMS `/workers`: Ereignis „Antwort eingegangen“ (Nora), Bau-Auftrag, nach etwa 5–15 Minuten im Reiter „Freigabe“ ein Eintrag „Vorschau für Testbetrieb Muster freigeben“ mit Link. Link öffnen; Rechte der Vorschau-Dateien prüfen: `docker exec webklar-workers sh -c 'ls -ld /data/vorschau/* | head -3; ls -l /data/vorschau/*/ | head -8'` zeigt `drwxr-xr-x` für die Verzeichnisse und `-rw-r--r--` für die Dateien (A14, sonst kann der Portal-Container sie nicht lesen). Dann „Freigeben & senden“ → Mail mit Vorschau-Link kommt an (innerhalb von 5 Minuten, im Sendefenster). Abmelde-Link der Mail im Browser öffnen: es erscheint nur die Seite mit Knopf; erst „Abmelden“ sperrt. Danach erneut `pilot` mit derselben Adresse: die Ereignisse zeigen keinen Versand (Kontakt gesperrt). **Aufräumen:** Der Abmelde-Test hat `kenso.gri@gmail.com` für immer auf die Sperrliste gesetzt; ohne Aufräumen kämen künftige Tests und Mails an diese Adresse nie an. Dokument-Id berechnen: `printf 'mail:kenso.gri@gmail.com' | sha256sum | cut -c1-32`, dann in der Appwrite-Konsole (Datenbank `woms-database`, Sammlung `workersSuppression`) das Dokument mit dieser Id löschen. Danach `docker exec webklar-workers node scripts/datenschutz-schalter.mjs --aus` (siehe oben). Der Pilot-Interessent bleibt als Testeintrag in `workersProspects` (Quelle `pilot`). **Plattform-Folge-Antwort (T34, A20):** Solange noch kein echter Plattform-Eintrag existiert (P3-Pilot), entfällt dieser Test. Sonst im Reiter „Freigabe“ (Filter „Erledigt“) bei einem von dir gesendeten Eintrag „Antwort auf …“ auf „Antwort des Kunden einfügen“ klicken, einen Beispieltext einfügen („Wie lange dauert das?“) und absenden: Meldung „Eingefügt. Nora sortiert die Antwort ein.“; innerhalb von 2 Minuten Ereignis „Antwort von der Plattform eingegangen“ (Nora), kurz danach ein neuer Eintrag „Folge-Antwort für …“ mit Claras Entwurf (Art `plattform_antwort`); der Text „Bitte abmelden“ sperrt den Interessenten sofort. Der Entwurf wird wie jeder Plattform-Entwurf von Hand kopiert und gesendet; Max sendet auf Plattformen nie. **Plattform-Benachrichtigungen aus dem Alarm-Postfach** werden erst ausgewertet, wenn der Betreff-Ausdruck der jeweiligen Plattform in `REPLY_SUBJECTS` (`src/gespraeche/plattformMail.js`) steht: Sobald du auf Kleinanzeigen, freelancermap oder freelance.de die erste echte Antwort auf eine von dir gesendete Nachricht bekommst, leite die Benachrichtigungs-Mail an Claude weiter (ohne persönliche Daten, nur Betreff und Aufbau), der den Ausdruck einträgt und testet. Bis dahin ist nur das Einfügen aktiv; Antwort-Mails im Alarm-Postfach behandelt P3 dann wie jede andere Nicht-Alarm-Mail.
11. **Dauerbetrieb freigeben.** Erst nach Warm-up und live veröffentlichtem Datenschutz-Abschnitt „Interessenten“: Schalter mit P6s Skript bzw. `docker exec webklar-workers node scripts/datenschutz-schalter.mjs --an` setzen (Prüfung `… --status` → `an`); optional `WORKERS_AUTOMAIL=ja` in `.env` (Max sendet Claras Mail-Antworten selbst), dann `docker compose up -d --no-deps workers`. Prüfung: `docker exec webklar-workers printenv WORKERS_AUTOMAIL`. **Bounce-Notbremse (H4):** Stoppt der Dienst den Versand (Mail „Workers: Versand gestoppt (Bounces)“ an die Berichtsadresse, in WOMS die Warnzeile „Versand gestoppt: …“), zuerst die Ursache prüfen (Adressquellen, Postfach, DNS/DKIM); freigegeben wird ausschließlich im Einstellungs-Dialog („Versand wieder freigeben“, nur Admin) – bis dahin bleiben alle Mail-Aufträge offen und gehen nach der Freigabe raus.
10. **Pilot mit eigenem Postfach** (nur Mo–Fr 09–16 Uhr). Das Tor lässt die erste Nachricht in einem Faden nur zu, wenn der Datenschutz-Schalter an ist. Für den Test geht die Mail nur an die eigene Adresse; deshalb den Schalter kurz einlegen (P3-Skript): `docker exec webklar-workers node scripts/datenschutz-schalter.mjs --an` und **am Ende dieses Schritts** wieder `… --aus`, solange der Abschnitt „Interessenten“ nicht live ist (P6). Dann `docker exec webklar-workers node scripts/p4-livecheck.mjs pilot --to kenso.gri@gmail.com --name "Testbetrieb Muster" --ja-echt` → innerhalb von 5 Minuten kommt die Testmail. Antworte „ja, gern“. Erwartung in WOMS `/hustler`: Ereignis „Antwort eingegangen“ (Nora), Bau-Auftrag, nach etwa 5–15 Minuten im Reiter „Freigabe“ ein Eintrag „Vorschau für Testbetrieb Muster freigeben“ mit Link. Link öffnen; Rechte der Vorschau-Dateien prüfen: `docker exec webklar-workers sh -c 'ls -ld /data/vorschau/* | head -3; ls -l /data/vorschau/*/ | head -8'` zeigt `drwxr-xr-x` für die Verzeichnisse und `-rw-r--r--` für die Dateien (A14, sonst kann der Portal-Container sie nicht lesen). Dann „Freigeben & senden“ → Mail mit Vorschau-Link kommt an (innerhalb von 5 Minuten, im Sendefenster). Abmelde-Link der Mail im Browser öffnen: es erscheint nur die Seite mit Knopf; erst „Abmelden“ sperrt. Danach erneut `pilot` mit derselben Adresse: die Ereignisse zeigen keinen Versand (Kontakt gesperrt). **Aufräumen:** Der Abmelde-Test hat `kenso.gri@gmail.com` für immer auf die Sperrliste gesetzt; ohne Aufräumen kämen künftige Tests und Mails an diese Adresse nie an. Dokument-Id berechnen: `printf 'mail:kenso.gri@gmail.com' | sha256sum | cut -c1-32`, dann in der Appwrite-Konsole (Datenbank `woms-database`, Sammlung `workersSuppression`) das Dokument mit dieser Id löschen. Danach `docker exec webklar-workers node scripts/datenschutz-schalter.mjs --aus` (siehe oben). Der Pilot-Interessent bleibt als Testeintrag in `workersProspects` (Quelle `pilot`). **Plattform-Folge-Antwort (T34, A20):** Solange noch kein echter Plattform-Eintrag existiert (P3-Pilot), entfällt dieser Test. Sonst im Reiter „Freigabe“ (Filter „Erledigt“) bei einem von dir gesendeten Eintrag „Antwort auf …“ auf „Antwort des Kunden einfügen“ klicken, einen Beispieltext einfügen („Wie lange dauert das?“) und absenden: Meldung „Eingefügt. Nora sortiert die Antwort ein.“; innerhalb von 2 Minuten Ereignis „Antwort von der Plattform eingegangen“ (Nora), kurz danach ein neuer Eintrag „Folge-Antwort für …“ mit Claras Entwurf (Art `plattform_antwort`); der Text „Bitte abmelden“ sperrt den Interessenten sofort. Der Entwurf wird wie jeder Plattform-Entwurf von Hand kopiert und gesendet; Max sendet auf Plattformen nie. **Plattform-Benachrichtigungen aus dem Alarm-Postfach** werden erst ausgewertet, wenn der Betreff-Ausdruck der jeweiligen Plattform in `REPLY_SUBJECTS` (`src/gespraeche/plattformMail.js`) steht: Sobald du auf Kleinanzeigen, freelancermap oder freelance.de die erste echte Antwort auf eine von dir gesendete Nachricht bekommst, leite die Benachrichtigungs-Mail an Claude weiter (ohne persönliche Daten, nur Betreff und Aufbau), der den Ausdruck einträgt und testet. Bis dahin ist nur das Einfügen aktiv; Antwort-Mails im Alarm-Postfach behandelt P3 dann wie jede andere Nicht-Alarm-Mail.
11. **Dauerbetrieb freigeben.** Erst nach Warm-up und live veröffentlichtem Datenschutz-Abschnitt „Interessenten“: Schalter mit P6s Skript bzw. `docker exec webklar-workers node scripts/datenschutz-schalter.mjs --an` setzen (Prüfung `… --status` → `an`); optional `WORKERS_AUTOMAIL=ja` in `.env` (Max sendet Claras Mail-Antworten selbst), dann `docker compose up -d --no-deps workers`. Prüfung: `docker exec webklar-workers printenv WORKERS_AUTOMAIL`. **Bounce-Notbremse (H4):** Stoppt der Dienst den Versand (Mail „[Hustler] Versand gestoppt (Bounces)“ an die Berichtsadresse, in WOMS die Warnzeile „Versand gestoppt: …“), zuerst die Ursache prüfen (Adressquellen, Postfach, DNS/DKIM); freigegeben wird ausschließlich im Einstellungs-Dialog („Versand wieder freigeben“, nur Admin) – bis dahin bleiben alle Mail-Aufträge offen und gehen nach der Freigabe raus.
12. **Zurückrollen.** `mv /home/webklar/apps/traefik/dynamic/workers-entwurf.yml /home/webklar/apps/`; `cp .env.bak-<Datum>-workers-p4 .env`; `cp docker-compose.yml.bak-<Datum>-workers-p4 docker-compose.yml` (die Sicherungen aus Schritt 2); `docker compose up -d --no-deps workers`; in WOMS Pausenschalter setzen. Prüfung: `curl -sI https://entwurf.project.webklar.com/abc` liefert nicht mehr `x-robots-tag` vom Workers-Dienst.