graph + mail dispatcher, test-email endpoint, notifications global

This commit is contained in:
2026-08-22 17:55:43 +02:00
parent 3d8bc9019f
commit ac48f1e9d1
40 changed files with 1336 additions and 13 deletions
+3 -1
View File
@@ -109,10 +109,12 @@ export default buildConfig({
| blocks | RenderBlocks — silnik renderowania bloków | [blocks.md](./blocks.md) |
| consent | Banner cookies GDPR, Google Consent Mode | [consent.md](./consent.md) |
| turnstile | Cloudflare Turnstile (widget + verify) | [turnstile.md](./turnstile.md) |
| email | SMTP z panelu: adapter Payloada + sendEmail | [email.md](./email.md) |
| email | Wysyłka: SMTP z panelu lub Microsoft Graph (M365) | [email.md](./email.md) |
| forms | Form-builder + submitForm (Turnstile + zapis) | [forms.md](./forms.md) |
| analytics | GA4 / GTM spięte z Consent Mode | [analytics.md](./analytics.md) |
| slug | Auto-slug z tytułu, per locale | [slug.md](./slug.md) |
| notifications | Teksty wyników akcji (formularz) per język | [notifications.md](./notifications.md) |
| security | Nagłówki bezpieczeństwa HTTP (HSTS, X-Frame...) | [security.md](./security.md) |
| content | Blog/archiwa: kolekcje pod stroną-archiwum, listing, paginacja | [content.md](./content.md) |
Nowy projekt krok po kroku: [getting-started.md](./getting-started.md)
+80 -4
View File
@@ -1,8 +1,10 @@
# email
Wysyłka maili przez SMTP z SiteIntegrations, w runtime (bez Payload email
adaptera). Edytor zmienia SMTP w panelu — następny mail idzie z nowymi
ustawieniami, bez restartu.
Wysyłka maili z dwoma transportami do wyboru: **SMTP z panelu**
(`panelSmtpAdapter`, uniwersalny) albo **Microsoft Graph** (`graphAdapter`,
przez Exchange/M365). Oba implementują ten sam interfejs `PayloadEmailAdapter`,
więc `payload.sendEmail` i maile z formularzy działają niezależnie od wyboru.
Klient/projekt wybiera transport w configu.
## Zależność
@@ -73,4 +75,78 @@ resetu hasła i weryfikacji konta. Adapter czyta konfigurację przy każdym
wysłaniu, więc zmiana skrzynki w panelu działa bez restartu.
Bez adaptera Payload używa mocka, który tylko loguje do konsoli — maile
form-buildera nie wyjdą.
form-buildera nie wyjdą.
## Maskowanie sekretów w panelu (MaskedField)
Wrażliwe pola w Site Integrations (smtpPassword, r2SecretAccessKey,
turnstileSecretKey) są maskowane w UI — pokazują `••••` zamiast plaintextu, z
przyciskiem Reveal/Hide. To maskowanie UI, NIE hashowanie ani szyfrowanie:
wartość w bazie jest plaintext (musi być odzyskiwalna do autentykacji SMTP/R2).
Chroni przed patrzeniem przez ramię i przypadkowym pokazaniem panelu.
Podpięte przez `admin.components.Field: '@intecion/ipal-kit/client#MaskedField'`.
Działa na dowolnym polu `text`. Po wpięciu w projekcie może być konieczne
`payload generate:importmap`, żeby panel rozpoznał komponent.
> Główną ochroną sekretów pozostaje `read: isAdmin` na globalu SiteIntegrations
> (anonim nie dostaje). Maskowanie to warstwa dodatkowa (shoulder-surfing), nie
> ochrona bazy — przy wycieku DB sekrety są czytelne.
## Adapter — Microsoft Graph (Exchange / M365)
Alternatywa dla SMTP: wysyłka przez Microsoft Graph API, przez skrzynkę w
Waszym (agencyjnym) tenancie M365. Wszystkie maile z formularzy wszystkich
projektów idą przez JEDNĄ skrzynkę nadawczą (np. `[email protected]`).
### Podział konfiguracji (celowy)
**Sekrety w `.env`** (agencyjne — Wasz Exchange, klient nie widzi):
```bash
GRAPH_TENANT_ID=...
GRAPH_CLIENT_ID=...
GRAPH_CLIENT_SECRET=...
GRAPH_SENDER=[email protected] # jedna skrzynka dla wszystkich projektów
```
**From-display w panelu** (per projekt): czyta istniejące `smtpFromAddress` /
`smtpFromName` z SiteIntegrations — bo „from" to ten sam koncept niezależnie od
transportu. Nie trzeba nowego pola.
### Wpięcie — wybór transportu
```ts
// payload.config.ts
import { panelSmtpAdapter, graphAdapter } from '@intecion/ipal-kit'
email: process.env.GRAPH_CLIENT_ID
? graphAdapter() // Graph, gdy sekrety w .env
: panelSmtpAdapter(), // SMTP z panelu (fallback)
```
### Setup Azure / Exchange (jednorazowo, Wasza strona, POZA kodem)
1. **App registration** w Azure AD → `tenantId`, `clientId`
2. **Client secret** → `clientSecret`
3. **API Permissions** → Microsoft Graph → **Application** → `Mail.Send` →
**Grant admin consent** (bez tego: `Insufficient privileges`)
4. **Exchange Admin Center** → skrzynka `[email protected]` → Mailbox
Delegation → aplikacja do **"Send As"** (bez tego: `ErrorAccessDenied`)
### Szczegóły techniczne
- Auth: client credentials flow, scope `https://graph.microsoft.com/.default`
(NIE `Mail.Send` — Azure odrzuca, AADSTS1002012)
- Wysyłka: `POST /users/{sender}/sendMail` (NIE `/me` — app-only nie ma „me")
- Sukces: HTTP 202 (pusty body)
- Czysty REST (fetch), zero bibliotek Microsoft, zero nowych zależności
### PUŁAPKA — from vs Send-As
Jeśli `from` w panelu = cudza domena (np. `[email protected]`), a sender =
`[email protected]` — Exchange zablokuje, chyba że aplikacja ma Send-As na tę
domenę. Najbezpieczniej: `from` = `GRAPH_SENDER` (Wasza skrzynka), a adres
klienta w `replyTo` (odpowiedzi trafią do klienta). Wtedy Send-As na cudze
domeny nie jest potrzebny.
+56 -1
View File
@@ -146,6 +146,7 @@ type SubmitFormResult =
| { success: false; reason: 'turnstile' }
| { success: false; reason: 'validation'; field?: string; kind?: 'required' | 'too_long' | 'unknown_fields' }
| { success: false; reason: 'not_found' }
| { success: false; reason: 'consent'; field?: string }
| { success: false; reason: 'error' }
```
@@ -162,4 +163,58 @@ function errorMessage(r) {
}
```
Ten sam wzorzec co consent: plugin nie zaszywa języka, oddaje dane.
Ten sam wzorzec co consent: plugin nie zaszywa języka, oddaje dane.
## Zgoda RODO (consent field)
Pole zgody RODO to checkbox o nazwie `consent` (konfigurowalna przez
`FormsOption.consentFieldName`). Plugin WYMUSZA jego zaznaczenie SERVER-SIDE —
niezależnie od tego, jak redaktor ustawił pole w panelu.
### Dlaczego server-side
Zgoda egzekwowana jest w `validateSubmission`, nie flagą w panelu. To jedyne
miejsce, którego redaktor nie osłabi (zapominając `required`) ani nie naruszy
(ustawiając `defaultValue: true` — pre-zaznaczenie, którego RODO zabrania), a
front nie obejdzie. Jeśli formularz ma pole `consent`, MUSI być zaznaczone,
inaczej `submitForm` zwraca `reason: 'consent'`.
### Jak użyć
1. Redaktor dodaje w panelu checkbox o nazwie `consent`, label „Akceptuję
politykę prywatności [link]" (link do polityki wpisuje w label — treść zgody
należy do panelu).
2. Plugin wymusza zaznaczenie. Niezaznaczony → `reason: 'consent'`.
3. Komunikat z modułu notifications (`notifications.form.consent`, per język).
Zmiana nazwy pola:
```ts
ipalKit({ forms: { consentFieldName: 'zgoda' } })
```
## Komunikaty — resolveFormMessage (zalecane)
Zamiast ręcznego switcha po `reason`, użyj `resolveFormMessage` z modułu
notifications — mapuje kod na tekst z panelu, per język, z interpolacją `{field}`:
```tsx
import { resolveFormMessage, getNotificationTexts } from '@intecion/ipal-kit'
const notifications = await getNotificationTexts({ payload, locale })
// w FormRenderer:
if (!result.success) {
setError(resolveFormMessage(result, notifications.form))
}
```
To obsługuje WSZYSTKIE kody (w tym `consent`, `rate_limited`, `validation` z
`{field}`) tekstami z panelu. Ręczny switch (wyżej) zostaw tylko, jeśli nie
używasz modułu notifications. Szczegóły: [notifications.md](./notifications.md).
## PUŁAPKA — pola captchy
Turnstile wstrzykuje ukryte pole `cf-turnstile-response`. Plugin je toleruje
(nie odrzuca jako `unknown_fields`) — bo sam obsługuje Turnstile. Nie musisz go
filtrować w kliencie.
+69
View File
@@ -0,0 +1,69 @@
# notifications
Teksty powiadomień (wyniki akcji) konfigurowane w panelu, per język, z
fallbackiem. Na dziś obsługuje komunikaty wyników formularza (`submitForm`),
z miejscem na przyszłe konteksty. Global **Notifications**, budowany zawsze.
## Zasada
Plugin daje KOD wyniku (`submitForm` zwraca `reason`), nie tekst. Ten moduł
mapuje kod → tekst z panelu (localized), z fallbackiem angielskim per pole.
Front dostaje gotowy string i styluje go jak chce (toast, inline, banner).
Dzięki temu żaden komunikat nie jest zaszyty w kodzie — wszystko przez panel.
## Config
Brak opcji — global **Notifications** jest zawsze budowany. Edytor zarządza
tekstami w panelu (karta Notifications), grupowane per kontekst. Grupa `form`:
`success`, `error`, `rateLimited`, `turnstile`, `validation`, `consent`,
`notFound`. Każde pole puste → fallback (NOTIFICATION_FALLBACK).
## Helper — getNotificationTexts
Pobiera teksty z globala per język, fallback per pole. Analog `getConsentTexts`:
```ts
import { getNotificationTexts } from '@intecion/ipal-kit'
const notifications = await getNotificationTexts({ payload, locale })
// notifications.form.error, notifications.form.success, ...
```
## Mapowanie wyniku — resolveFormMessage
Most między `submitForm` a UI: bierze wynik i teksty, zwraca jeden komunikat.
Interpoluje `{field}` w walidacji. NIGDY nie pokazuje surowego wyjątku
(`error` → generyczny tekst, nie treść błędu backendu).
```ts
import { resolveFormMessage } from '@intecion/ipal-kit'
const result = await submitFormAction(...)
if (!result.success) {
setError(resolveFormMessage(result, notifications.form))
}
```
To zastępuje sztywne `Błąd: ${result.reason}` — teraz przyjazny tekst z panelu,
per język.
## Interpolacja {field}
Tekst `validation` może zawierać `{field}` — podstawia się nazwa pola z błędem:
```
Panel: "Sprawdź pole {field} i spróbuj ponownie."
Wynik: "Sprawdź pole email i spróbuj ponownie."
```
## Rozszerzanie o nowe konteksty
Grupa `form` to pierwszy kontekst. Kolejne (`newsletter`, `system`) dodaje się
tak samo — nowa grupa w `globals/Notifications/fields.ts` + pole w typach +
fallback. `getNotificationTexts` resolwuje, co istnieje.
## Dostęp
Global ma `read: () => true` — teksty są publiczne (pokazywane użytkownikom
końcowym), więc front czyta je bez sesji. Inaczej niż SiteIntegrations
(`read: isAdmin` — tam sekrety).
+64
View File
@@ -0,0 +1,64 @@
# security
Generyczne nagłówki bezpieczeństwa HTTP (HSTS, X-Frame-Options, nosniff,
Referrer-Policy, Permissions-Policy) jako funkcja do `next.config`. CSP CELOWO
pominięte — zależy od domen projektu, zostaje w projekcie.
## Zasada
Nagłówki, które są IDENTYCZNE między projektami, plugin dostarcza raz. CSP
(Content-Security-Policy) wymaga znajomości domen konkretnego projektu (skąd
ładują się skrypty, obrazy, fonty, analytics), więc nie może być generyczne —
zostaje w projekcie, dodawane przez `additional`.
## Użycie — next.config.ts
Nagłówki wpina się w `next.config`, NIE w proxy — bo muszą pokryć CAŁĄ
aplikację (też `/admin`, statyki), a proxy pomija te trasy.
```ts
// next.config.ts
import { withPayload } from '@payloadcms/next/withPayload'
import type { NextConfig } from 'next'
import { buildSecurityHeaders } from '@intecion/ipal-kit'
const securityHeaders = buildSecurityHeaders({
hsts: process.env.NODE_ENV === 'production', // WAŻNE: off w dev (http)
additional: [
// CSP projektu — zna swoje domeny:
// { key: 'Content-Security-Policy', value: "default-src 'self'; ..." },
],
})
const nextConfig: NextConfig = {
async headers() {
return [{ source: '/:path*', headers: securityHeaders }]
},
}
export default withPayload(nextConfig)
```
## Opcje
| Opcja | Domyślnie | Rola |
|---|---|---|
| `hsts` | `true` | Strict-Transport-Security (wymuś HTTPS) |
| `hstsMaxAge` | `63072000` (2 lata) | max-age HSTS w sekundach |
| `hstsIncludeSubDomains` | `true` | HSTS na subdomeny |
| `hstsPreload` | `false` | preload (tylko jeśli zgłaszasz do listy) |
| `frameOptions` | `'DENY'` | X-Frame-Options (anty-clickjacking) |
| `referrerPolicy` | `'strict-origin-when-cross-origin'` | Referrer-Policy |
| `permissionsPolicy` | blokuje camera/mic/geolocation | Permissions-Policy |
| `additional` | `[]` | dodatkowe nagłówki (np. CSP); same-key nadpisuje |
## PUŁAPKA — HSTS w dev
HSTS nad HTTP na localhost może zablokować przeglądarkę na HTTPS dla localhost.
ZAWSZE wyłączaj w dev: `hsts: process.env.NODE_ENV === 'production'`.
## Nadpisywanie i CSP
`additional` z tym samym kluczem NADPISUJE domyślny (np. zmień X-Frame-Options
na SAMEORIGIN). Nowy klucz (jak CSP) dodaje. CSP zawsze przez `additional` —
plugin go nie generuje, bo zależy od projektu.
+7
View File
@@ -2,6 +2,13 @@
Pole slug generowane automatycznie z tytułu, per locale.
> **Plugin JUŻ to ma — nie pisz własnego auto-sluga.** Częsty błąd: projekt
> dodaje ręczne pole `{ name: 'slug', type: 'text' }` i każe redaktorowi wpisywać
> slug, albo pisze własny hook normalizujący. Nie trzeba — `buildSlugField`
> robi to lepiej: auto-generacja gdy puste, nie nadpisuje ręcznego, per-locale,
> diakrytyki PL→ASCII. Jeśli w kolekcji masz ręczny slug, ZAMIEŃ go na
> `buildSlugField({ from: 'title' })`.
## Użycie (kolekcja klienta)
```ts