graph + mail dispatcher, test-email endpoint, notifications global
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'
|
||||
export { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js'
|
||||
export { Analytics } from '../modules/analytics/client.js'
|
||||
/**
|
||||
* Entry point: ipal-kit/client
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
/**
|
||||
* Admin UI: a small "send test email" tool for the SiteIntegrations email tab.
|
||||
* Enter an address, click Send, and it POSTs to /api/ipal/test-email, which
|
||||
* sends through the currently-selected transport (SMTP or Graph). Shows the
|
||||
* result inline so you can confirm delivery — or read the exact error — without
|
||||
* leaving the panel.
|
||||
*
|
||||
* Assigned via a `ui` field's admin.components.Field.
|
||||
*/
|
||||
export const TestEmailButton = () => {
|
||||
const [to, setTo] = useState('')
|
||||
const [status, setStatus] = useState<
|
||||
| { kind: 'error'; msg: string }
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'ok'; msg: string }
|
||||
| { kind: 'sending' }
|
||||
>({ kind: 'idle' })
|
||||
|
||||
const send = async () => {
|
||||
if (!to.trim()) {
|
||||
setStatus({ kind: 'error', msg: 'Enter a recipient address.' })
|
||||
return
|
||||
}
|
||||
setStatus({ kind: 'sending' })
|
||||
try {
|
||||
const res = await fetch('/api/ipal/test-email', {
|
||||
body: JSON.stringify({ to: to.trim() }),
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.ok) {
|
||||
setStatus({ kind: 'ok', msg: data.message ?? 'Test email sent.' })
|
||||
} else {
|
||||
setStatus({ kind: 'error', msg: data.error ?? 'Send failed.' })
|
||||
}
|
||||
} catch {
|
||||
setStatus({ kind: 'error', msg: 'Request failed. Is the server running?' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="field-type" style={{ marginTop: '1rem' }}>
|
||||
<label className="field-label">Send a test email</label>
|
||||
<p style={{ fontSize: '.85rem', marginTop: 0, opacity: 0.7 }}>
|
||||
Sends through the transport selected above. Save your changes first.
|
||||
</p>
|
||||
<div style={{ alignItems: 'center', display: 'flex', flexWrap: 'wrap', gap: '.5rem' }}>
|
||||
<input
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
style={{ flex: 1, minWidth: '220px' }}
|
||||
type="email"
|
||||
value={to}
|
||||
/>
|
||||
<button
|
||||
className="btn btn--style-secondary"
|
||||
disabled={status.kind === 'sending'}
|
||||
onClick={send}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
type="button"
|
||||
>
|
||||
{status.kind === 'sending' ? 'Sending…' : 'Send test'}
|
||||
</button>
|
||||
</div>
|
||||
{status.kind === 'ok' && (
|
||||
<p style={{ color: 'var(--theme-success-500, green)', marginTop: '.5rem' }}>
|
||||
✓ {status.msg}
|
||||
</p>
|
||||
)}
|
||||
{status.kind === 'error' && (
|
||||
<p style={{ color: 'var(--theme-error-500, crimson)', marginTop: '.5rem' }}>
|
||||
✗ {status.msg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TestEmailButton
|
||||
@@ -8,8 +8,30 @@ import type { Field } from 'payload'
|
||||
* panel while remaining inaccessible to anonymous API requests.
|
||||
*/
|
||||
export const smtpFields: Field[] = [
|
||||
{
|
||||
name: 'emailTransport',
|
||||
type: 'select',
|
||||
admin: {
|
||||
description:
|
||||
'How outbound email is sent. "Microsoft Graph" is only available when configured by the administrator (Intecion).',
|
||||
// The Graph option only makes sense when agency credentials exist in env.
|
||||
// We can't read process.env in the admin UI directly, so a client project
|
||||
// that hasn't set up Graph should filter this option via integrationsFields
|
||||
// override, or simply leave it on 'smtp'. The adapter enforces the real
|
||||
// availability at send time regardless of what's selected here.
|
||||
},
|
||||
defaultValue: 'smtp',
|
||||
options: [
|
||||
{ label: 'SMTP', value: 'smtp' },
|
||||
{ label: 'Microsoft Graph (Exchange)', value: 'graph' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
admin: {
|
||||
// Hide SMTP fields when Graph is selected — they're not used then.
|
||||
condition: (_, siblingData) => siblingData?.emailTransport !== 'graph',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'smtpHost',
|
||||
@@ -56,4 +78,13 @@ export const smtpFields: Field[] = [
|
||||
description: 'Default "from" display name.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'emailTest',
|
||||
type: 'ui',
|
||||
admin: {
|
||||
components: {
|
||||
Field: '@intecion/ipal-kit/client#TestEmailButton',
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -44,6 +44,10 @@ export {
|
||||
resolveRoute,
|
||||
} from './modules/content/index.js'
|
||||
export type { ArchiveEntries } from './modules/content/index.js'
|
||||
export { graphAdapter } from './modules/email/graphAdapter.js'
|
||||
export type { GraphAdapterArgs } from './modules/email/graphAdapter.js'
|
||||
export { mailAdapter } from './modules/email/mailAdapter.js'
|
||||
export type { MailAdapterArgs } from './modules/email/mailAdapter.js'
|
||||
// Imported straight from the file, NOT from ./modules/email/index.js — that
|
||||
// barrel re-exports sendEmail, which imports 'server-only' and would crash when
|
||||
// Payload loads the config (or runs generate:importmap) as a plain Node script.
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
+12
-1
@@ -3,10 +3,12 @@ import type { Config, Plugin } from 'payload'
|
||||
import type { IpalOptions } from './types.js'
|
||||
|
||||
import { buildCookieSettings } from './globals/CookieSettings/index.js'
|
||||
import { buildNotifications } from './globals/Notifications/index.js'
|
||||
import { buildSiteIntegrations } from './globals/SiteIntegrations/index.js'
|
||||
import { buildSiteSettings } from './globals/SiteSettings/index.js'
|
||||
import { injectRoles } from './modules/access/index.js'
|
||||
import { buildArchiveFields } from './modules/content/index.js'
|
||||
import { testEmailEndpoint } from './modules/email/test/testEmailEndpoint.js'
|
||||
import { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'
|
||||
import { buildLocalizationConfig, validateI18nConfig } from './modules/i18n/index.js'
|
||||
import { buildSystemPagesFields } from './modules/pages/index.js'
|
||||
@@ -94,12 +96,21 @@ export const ipalKit = (options: IpalOptions): Plugin => {
|
||||
}),
|
||||
buildSiteIntegrations({ additionalFields: options.integrationsFields }),
|
||||
buildCookieSettings(),
|
||||
buildNotifications(),
|
||||
]
|
||||
|
||||
// --- endpoints ---
|
||||
// Test-email endpoint (admin-only): POST /api/ipal/test-email sends a probe
|
||||
// message through the currently selected transport, so the panel's "send
|
||||
// test" button can confirm delivery without leaving the admin UI.
|
||||
config.endpoints = [...(config.endpoints ?? []), testEmailEndpoint]
|
||||
|
||||
// --- hooks: onInit ---
|
||||
const incomingOnInit = config.onInit
|
||||
config.onInit = async (payload) => {
|
||||
if (incomingOnInit) {await incomingOnInit(payload)}
|
||||
if (incomingOnInit) {
|
||||
await incomingOnInit(payload)
|
||||
}
|
||||
payload.logger.info('[ipal] Plugin initialized.')
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user