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
+182
View File
@@ -0,0 +1,182 @@
import type { PayloadEmailAdapter, SendEmailOptions } from 'payload'
import { getSiteIntegrations } from '../payload/index.js'
/**
* From/To settings the adapter reads from SiteIntegrations (panel). The Graph
* CREDENTIALS themselves are NOT here — they're agency secrets in env vars
* (this is *our* Exchange, shared across projects), read below from process.env.
* The panel only controls the display-from and where submissions land.
*/
type GraphIntegrations = {
/**
* Display From — reused from the existing SMTP fields, because the sender
* label is the same concept regardless of transport (SMTP or Graph). No new
* panel field needed; whatever the editor set as the from-address applies.
*/
smtpFromAddress?: null | string
smtpFromName?: null | string
}
export type GraphAdapterArgs = {
fallbackFromAddress?: string
fallbackFromName?: string
}
type GraphEnv = {
clientId: string
clientSecret: string
sender: string
tenantId: string
}
/** Reads + validates the agency Graph credentials from env. */
function readGraphEnv(): GraphEnv | null {
const tenantId = process.env.GRAPH_TENANT_ID
const clientId = process.env.GRAPH_CLIENT_ID
const clientSecret = process.env.GRAPH_CLIENT_SECRET
const sender = process.env.GRAPH_SENDER
if (!tenantId || !clientId || !clientSecret || !sender) {return null}
return { clientId, clientSecret, sender, tenantId }
}
/**
* Fetches an app-only access token via the OAuth2 client-credentials flow.
* Scope MUST be '.../.default' — passing 'Mail.Send' directly is rejected
* (AADSTS1002012). Tokens last ~1h; we fetch per send for simplicity and to
* avoid holding state in a possibly multi-instance deployment. If you send at
* high volume, cache by expiry.
*/
async function getAccessToken(env: GraphEnv): Promise<string> {
const url = `https://login.microsoftonline.com/${env.tenantId}/oauth2/v2.0/token`
const body = new URLSearchParams({
client_id: env.clientId,
client_secret: env.clientSecret,
grant_type: 'client_credentials',
scope: 'https://graph.microsoft.com/.default',
})
const res = await fetch(url, {
body,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
method: 'POST',
})
if (!res.ok) {
const detail = await res.text()
throw new Error(`Graph token request failed (${res.status}): ${detail}`)
}
const data = (await res.json()) as { access_token?: string }
if (!data.access_token) {throw new Error('Graph token response had no access_token')}
return data.access_token
}
/** Normalizes Payload's to/cc (string | string[] | Address[]) into Graph recipients. */
function toRecipients(value: SendEmailOptions['to']): { emailAddress: { address: string } }[] {
if (!value) {return []}
const list = Array.isArray(value) ? value : [value]
return list
.map((v) => (typeof v === 'string' ? v : (v as { address?: string }).address))
.filter((a): a is string => typeof a === 'string' && a.length > 0)
.map((address) => ({ emailAddress: { address } }))
}
/**
* Payload email adapter that sends through Microsoft Graph (our Exchange),
* using app-only client-credentials auth. Drop-in alternative to
* panelSmtpAdapter — same PayloadEmailAdapter contract, so payload.sendEmail
* and the form-builder's submission emails work unchanged.
*
* Split of configuration (deliberate):
* - Graph credentials (tenant/client/secret/sender) = AGENCY secrets, from env.
* The client never sees or sets them — it's our Exchange, one mailbox
* (GRAPH_SENDER, e.g. [email protected]) for every project.
* - From-display + recipient = per-project, from the panel (SiteIntegrations),
* so an editor controls how the mail is labelled and where it lands.
*
* Wiring: email: process.env.GRAPH_CLIENT_ID ? graphAdapter() : panelSmtpAdapter()
*
* Azure setup (one-time, our side): App registration → Mail.Send APPLICATION
* permission → admin consent → in Exchange, grant the app "Send As" on the
* shared mailbox GRAPH_SENDER.
*/
export const graphAdapter =
(args: GraphAdapterArgs = {}): PayloadEmailAdapter =>
({ payload }) => ({
name: 'ipal-graph',
defaultFromAddress: args.fallbackFromAddress ?? 'noreply@localhost',
defaultFromName: args.fallbackFromName ?? 'Website',
sendEmail: async (message: SendEmailOptions) => {
const env = readGraphEnv()
if (!env) {
payload.logger.error(
'[ipal] Email not sent: Graph is not configured. Set GRAPH_TENANT_ID, GRAPH_CLIENT_ID, GRAPH_CLIENT_SECRET, GRAPH_SENDER.',
)
return { error: 'Graph is not configured (missing env vars).', sent: false }
}
// From-display comes from the panel; falls back to the caller's from.
const panel = await getSiteIntegrations<GraphIntegrations>(payload)
const fromAddress = panel.smtpFromAddress || undefined
const fromName = panel.smtpFromName || undefined
const to = toRecipients(message.to)
if (to.length === 0) {
payload.logger.error('[ipal] Email not sent: no valid recipient.')
return { error: 'No valid recipient.', sent: false }
}
// Graph accepts either HTML or Text; Payload gives us html and/or text.
const isHtml = typeof message.html === 'string' && message.html.length > 0
const content = isHtml ? String(message.html) : String(message.text ?? '')
const graphMessage: Record<string, unknown> = {
body: { content, contentType: isHtml ? 'HTML' : 'Text' },
subject: message.subject ?? '',
toRecipients: to,
...(message.cc ? { ccRecipients: toRecipients(message.cc) } : {}),
...(message.bcc ? { bccRecipients: toRecipients(message.bcc) } : {}),
// from is only honoured if the app has Send-As for that address; when
// it's the shared mailbox itself, omit it and Graph uses the sender.
...(fromAddress
? {
from: {
emailAddress: { address: fromAddress, ...(fromName ? { name: fromName } : {}) },
},
}
: {}),
// replyTo lets the recipient reply to the real submitter if the caller set it.
...(message.replyTo
? { replyTo: toRecipients(message.replyTo as SendEmailOptions['to']) }
: {}),
}
try {
const token = await getAccessToken(env)
// App-only: MUST target /users/{sender}, never /me.
const res = await fetch(
`https://graph.microsoft.com/v1.0/users/${encodeURIComponent(env.sender)}/sendMail`,
{
body: JSON.stringify({ message: graphMessage, saveToSentItems: false }),
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
method: 'POST',
},
)
// sendMail returns 202 Accepted with an empty body on success.
if (res.status === 202) {
return { sent: true }
}
const detail = await res.text()
payload.logger.error(`[ipal] Graph sendMail failed (${res.status}): ${detail}`)
return { error: `Graph sendMail failed (${res.status}).`, sent: false }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
payload.logger.error(`[ipal] Graph send error: ${msg}`)
return { error: 'Graph send error.', sent: false }
}
},
})
+4
View File
@@ -1,3 +1,7 @@
export { graphAdapter } from './graphAdapter.js'
export type { GraphAdapterArgs } from './graphAdapter.js'
export { mailAdapter } from './mailAdapter.js'
export type { MailAdapterArgs } from './mailAdapter.js'
// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer)
// so this must never be imported from a client component.
export { sendEmail } from './sendEmail.js'
+88
View File
@@ -0,0 +1,88 @@
import type { PayloadEmailAdapter, SendEmailOptions } from 'payload'
import { getSiteIntegrations } from '../payload/index.js'
import { graphAdapter, type GraphAdapterArgs } from './graphAdapter.js'
import { panelSmtpAdapter, type PanelSmtpAdapterArgs } from './panelSmtpAdapter.js'
type TransportIntegrations = {
/** 'smtp' | 'graph' — chosen by the editor in SiteIntegrations. */
emailTransport?: 'graph' | 'smtp' | null
}
export type MailAdapterArgs = {
fallbackFromAddress?: string
fallbackFromName?: string
graph?: GraphAdapterArgs
smtp?: PanelSmtpAdapterArgs
}
/** True when the agency Graph credentials are present in the environment. */
function graphAvailable(): boolean {
return Boolean(
process.env.GRAPH_TENANT_ID &&
process.env.GRAPH_CLIENT_ID &&
process.env.GRAPH_CLIENT_SECRET &&
process.env.GRAPH_SENDER,
)
}
/**
* Dispatcher email adapter: wired into the config ONCE, but picks the transport
* (SMTP or Graph) per send by reading `emailTransport` from SiteIntegrations.
* This is what makes the choice switchable in the panel — Payload builds the
* email adapter at boot and can't swap it at runtime, so instead of choosing
* between two adapters at boot we install one that delegates on every send.
*
* Availability guard: Graph only runs if its agency credentials exist in env
* (this is *our* Exchange). If the panel says 'graph' but env isn't set up,
* we DON'T silently fail — we log clearly and fall back to SMTP, so a client
* flipping the switch without the backing config still gets mail out (over SMTP)
* rather than silent nothing. If neither is usable, the send reports an error.
*
* @example
* // payload.config.ts
* import { mailAdapter } from '@intecion/ipal-kit'
* email: mailAdapter()
*/
export const mailAdapter =
(args: MailAdapterArgs = {}): PayloadEmailAdapter =>
(deps) => {
// Build both delegates once; each still resolves its own config per send.
const smtp = panelSmtpAdapter({
fallbackFromAddress: args.fallbackFromAddress,
fallbackFromName: args.fallbackFromName,
...args.smtp,
})(deps)
const graph = graphAdapter({
fallbackFromAddress: args.fallbackFromAddress,
fallbackFromName: args.fallbackFromName,
...args.graph,
})(deps)
const { payload } = deps
return {
name: 'ipal-mail-dispatcher',
defaultFromAddress: smtp.defaultFromAddress,
defaultFromName: smtp.defaultFromName,
sendEmail: async (message: SendEmailOptions) => {
const settings = await getSiteIntegrations<TransportIntegrations>(payload)
const choice = settings.emailTransport ?? 'smtp'
if (choice === 'graph') {
if (graphAvailable()) {
return graph.sendEmail(message)
}
// Panel asked for Graph but the agency creds aren't configured for
// this project. Fall back to SMTP rather than silently dropping mail.
payload.logger.warn(
'[ipal] Transport set to Graph but GRAPH_* env vars are missing; falling back to SMTP.',
)
return smtp.sendEmail(message)
}
return smtp.sendEmail(message)
},
}
}
@@ -0,0 +1,68 @@
import type { Endpoint, PayloadRequest } from 'payload'
import { addDataAndFileToRequest } from 'payload'
/**
* Custom endpoint: send a test email to a given address through whatever
* transport is currently active (SMTP or Graph — mailAdapter reads the panel
* setting per send, so the test exercises the REAL path a form email would
* take). Mounted at POST /api/ipal/test-email.
*
* Admin-only: uses payload.sendEmail (server-side), and requires an
* authenticated admin user — a test-send button must never be open to the
* public (it would be an open relay / spam vector).
*
* Returns the adapter's own result so the panel can show exactly what happened,
* including the transport-specific error (SMTP auth failure, Graph 401, etc.).
*/
export const testEmailEndpoint: Endpoint = {
handler: async (req: PayloadRequest) => {
// Auth: only signed-in admins may trigger a send.
if (!req.user) {
return Response.json({ error: 'Unauthorized', ok: false }, { status: 401 })
}
await addDataAndFileToRequest(req)
const to = (req.data?.to as string | undefined)?.trim()
if (!to || !/^[^@\s]+@[^\s@][^\s.@]*\.[^\s@]+$/.test(to)) {
return Response.json(
{ error: 'Provide a valid recipient address.', ok: false },
{ status: 400 },
)
}
try {
const info = await req.payload.sendEmail({
html: '<p>This is a test message from <strong>ipal-kit</strong>. If you received it, outbound email is configured correctly.</p>',
subject: 'ipal-kit — test email',
text: 'This is a test message from ipal-kit. If you received it, outbound email is configured correctly.',
to,
})
// Payload's sendEmail resolves with the adapter's result. Our adapters
// return { sent: boolean, error?: string }; nodemailer returns info with
// messageId. Normalize to a simple ok/message for the panel.
const sent =
info && typeof info === 'object' && 'sent' in info
? (info as { sent?: boolean }).sent !== false
: true
if (!sent) {
const error = (info as { error?: string })?.error ?? 'Send failed (see server logs).'
return Response.json({ error, ok: false }, { status: 502 })
}
return Response.json({ message: `Test email sent to ${to}.`, ok: true })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
req.payload.logger.error(`[ipal] Test email failed: ${message}`)
return Response.json(
{ error: 'Send failed. Check transport settings and server logs.', ok: false },
{ status: 502 },
)
}
},
method: 'post',
path: '/ipal/test-email',
}