6.8 KiB
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ść
"dependencies": { "nodemailer": "^6.9.0" }
(+ @types/nodemailer w devDependencies)
Config
Brak opcji — SMTP (host, port, user, password, from) jest w SiteIntegrations (tab SMTP). Edytor konfiguruje w panelu.
Front — sendEmail (server)
import { sendEmail } from '@intecion/ipal-kit/server'
const result = await sendEmail({
payload,
to: '[email protected]',
subject: 'Nowa wiadomość',
html: '<h1>Cześć</h1><p>Treść…</p>', // gotowy HTML (Twój wygląd)
// text: 'wersja plain', from: '...', replyTo: '...'
})
if (result.sent) {
// result.messageId
} else {
// result.error — np. "SMTP is not configured..."
}
- HTML to Twój argument — plugin nie ma templatek, wysyła to co podasz. Wygląd maila składasz na froncie.
- Zwraca
{ sent: true, messageId }albo{ sent: false, error }— nie rzuca wyjątku, nie wycieka detali SMTP do klienta. - Port 465 → implicit TLS, inne → STARTTLS.
server-only— hasło SMTP nigdy w bundlu przeglądarki.
Uwaga: maile systemowe Payloada
Reset hasła / weryfikacja email idą przez wbudowany mechanizm Payload
(config.email), którego ten moduł nie konfiguruje (celowo — wymagałby
SMTP w env). Jeśli ich potrzebujesz, to osobna konfiguracja adaptera przy
buildConfig.
Adapter — SMTP z panelu dla całego Payloada
sendEmail wysyła własnym transportem. Osobno plugin daje adapter, który
podłącza tę samą skrzynkę pod payload.sendEmail:
// payload.config.ts
import { panelSmtpAdapter } from '@intecion/ipal-kit'
export default buildConfig({
email: panelSmtpAdapter(),
// panelSmtpAdapter({ fallbackFromAddress, fallbackFromName }) — używane tylko
// zanim panel zostanie wypełniony (Payload wymaga adresu synchronicznie przy
// starcie, zanim można odczytać globala)
})
Po wpięciu wszystko, co w Payloadzie wysyła maile, idzie przez SMTP z Site Integrations — w tym wbudowane maile form-buildera (Forms → Emails), maile 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ą.
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: isAdminna 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):
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
// 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)
- App registration w Azure AD →
tenantId,clientId - Client secret →
clientSecret - API Permissions → Microsoft Graph → Application →
Mail.Send→ Grant admin consent (bez tego:Insufficient privileges) - 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(NIEMail.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.
Przełącznik transportu — mailAdapter
mailAdapter() to dyspozytor: jeden adapter wpięty w config, wybiera transport
(SMTP/Graph) przy KAŻDEJ wysyłce, czytając ustawienie z panelu. Dzięki temu
przełącznik działa w panelu (Payload buduje adapter raz przy starcie, więc nie
da się podmieniać osobnych adapterów w runtime — dyspozytor deleguje wewnątrz).
// payload.config.ts — JEDEN adapter, wybór wewnątrz
import { mailAdapter } from '@intecion/ipal-kit'
email: mailAdapter()
Panel → Site Integrations → SMTP → Email Transport (SMTP / Microsoft Graph). Dyspozytor czyta ten wybór per wysyłka. Guard: jeśli wybrano Graph, ale brak sekretów w .env → log + fallback na SMTP (nie cicha awaria).
Test wysyłki — przycisk w panelu
W tabie SMTP jest przycisk Send test: podaj adres, kliknij, wyślij testowy
mail przez AKTUALNY transport. Pokazuje wynik (✓/✗ z błędem). Endpoint
POST /api/ipal/test-email (admin-only). Zapisz zmiany przed testem — endpoint
czyta z bazy, nie z pola na ekranie.
Bezcenne przy diagnozie Graph — od razu widzisz
ErrorSendAsDenied,Insufficient privilegesitp. zamiast zgadywać.