Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d745a764fe | ||
|
|
6c273118a2 | ||
|
|
a010a7368a | ||
|
|
08bd00f01f | ||
|
|
cef7f46718 | ||
|
|
ac48f1e9d1 |
Vendored
+3
@@ -1,4 +1,5 @@
|
||||
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
|
||||
@@ -11,3 +12,5 @@ export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentCont
|
||||
export type { CookieBannerClassNames } from '../modules/consent/client.js';
|
||||
export { Turnstile } from '../modules/turnstile/client.js';
|
||||
export type { TurnstileProps } from '../modules/turnstile/client.js';
|
||||
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js';
|
||||
export type { FormNotificationTexts } from '../modules/notifications/types.js';
|
||||
|
||||
Vendored
+2
@@ -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
|
||||
@@ -9,5 +10,6 @@ export { Analytics } from '../modules/analytics/client.js';
|
||||
* client-only code.
|
||||
*/ export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentContext } from '../modules/consent/client.js';
|
||||
export { Turnstile } from '../modules/turnstile/client.js';
|
||||
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js';
|
||||
|
||||
//# sourceMappingURL=client.js.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["'use client'\nexport { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'\nexport { Analytics } from '../modules/analytics/client.js'\n/**\n * Entry point: ipal-kit/client\n *\n * Client-side ('use client') exports — React hooks, providers, and UI\n * components. Kept separate from the main entry so server bundles don't pull in\n * client-only code.\n */\nexport {\n ConsentProvider,\n CookieBanner,\n CookieButton,\n useConsent,\n useConsentContext,\n} from '../modules/consent/client.js'\nexport type { CookieBannerClassNames } from '../modules/consent/client.js'\nexport { Turnstile } from '../modules/turnstile/client.js'\nexport type { TurnstileProps } from '../modules/turnstile/client.js'\n"],"names":["MaskedField","Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","Turnstile"],"mappings":"AAAA;AACA,SAASA,WAAW,QAAQ,wDAAuD;AACnF,SAASC,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAErC,SAASC,SAAS,QAAQ,iCAAgC"}
|
||||
{"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["'use client'\nexport { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'\nexport { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js'\nexport { Analytics } from '../modules/analytics/client.js'\n/**\n * Entry point: ipal-kit/client\n *\n * Client-side ('use client') exports — React hooks, providers, and UI\n * components. Kept separate from the main entry so server bundles don't pull in\n * client-only code.\n */\nexport {\n ConsentProvider,\n CookieBanner,\n CookieButton,\n useConsent,\n useConsentContext,\n} from '../modules/consent/client.js'\nexport type { CookieBannerClassNames } from '../modules/consent/client.js'\nexport { Turnstile } from '../modules/turnstile/client.js'\nexport type { TurnstileProps } from '../modules/turnstile/client.js'\n\nexport { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js'\nexport type { FormNotificationTexts } from '../modules/notifications/types.js'\n"],"names":["MaskedField","TestEmailButton","Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","Turnstile","resolveFormMessage"],"mappings":"AAAA;AACA,SAASA,WAAW,QAAQ,wDAAuD;AACnF,SAASC,eAAe,QAAQ,4DAA2D;AAC3F,SAASC,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAErC,SAASC,SAAS,QAAQ,iCAAgC;AAG1D,SAASC,kBAAkB,QAAQ,iDAAgD"}
|
||||
+17
-8
@@ -1,17 +1,26 @@
|
||||
'use client';
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useField } from '@payloadcms/ui';
|
||||
import { useState } from 'react';
|
||||
export const MaskedField = ({ field, path })=>{
|
||||
const { setValue, value } = useField({
|
||||
path
|
||||
});
|
||||
export const MaskedField = (props)=>{
|
||||
const { field, path, value: propValue, setValue: propSetValue, onChange: propOnChange } = props || {};
|
||||
const [internalValue, setInternalValue] = useState(propValue ?? '');
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const label = typeof field?.label === 'string' ? field.label : field?.name ?? path;
|
||||
const handleChange = (e)=>{
|
||||
const newVal = e.target.value;
|
||||
setInternalValue(newVal);
|
||||
if (typeof propSetValue === 'function') {
|
||||
propSetValue(newVal);
|
||||
}
|
||||
if (typeof propOnChange === 'function') {
|
||||
propOnChange(e);
|
||||
}
|
||||
};
|
||||
const currentValue = propValue !== undefined ? propValue : internalValue;
|
||||
return /*#__PURE__*/ _jsxs("div", {
|
||||
className: "field-type text",
|
||||
children: [
|
||||
/*#__PURE__*/ _jsx("label", {
|
||||
label && /*#__PURE__*/ _jsx("label", {
|
||||
className: "field-label",
|
||||
children: label
|
||||
}),
|
||||
@@ -23,12 +32,12 @@ export const MaskedField = ({ field, path })=>{
|
||||
children: [
|
||||
/*#__PURE__*/ _jsx("input", {
|
||||
autoComplete: "off",
|
||||
onChange: (e)=>setValue(e.target.value),
|
||||
onChange: handleChange,
|
||||
style: {
|
||||
flex: 1
|
||||
},
|
||||
type: revealed ? 'text' : 'password',
|
||||
value: value ?? ''
|
||||
value: currentValue ?? ''
|
||||
}),
|
||||
/*#__PURE__*/ _jsx("button", {
|
||||
onClick: ()=>setRevealed((r)=>!r),
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/components/MaskedField.tsx"],"sourcesContent":["'use client'\nimport type { TextFieldClientComponent } from 'payload'\n\nimport { useField } from '@payloadcms/ui'\nimport { useState } from 'react'\n\nexport const MaskedField: TextFieldClientComponent = ({ field, path }) => {\n const { setValue, value } = useField<string>({ path })\n const [revealed, setRevealed] = useState(false)\n const label = typeof field?.label === 'string' ? field.label : (field?.name ?? path)\n\n return (\n <div className=\"field-type text\">\n <label className=\"field-label\">{label}</label>\n <div style={{ display: 'flex', gap: '.5rem' }}>\n <input\n autoComplete=\"off\"\n onChange={(e) => setValue(e.target.value)}\n style={{ flex: 1 }}\n type={revealed ? 'text' : 'password'}\n value={value ?? ''}\n />\n <button onClick={() => setRevealed((r) => !r)} type=\"button\">\n {revealed ? 'Hide' : 'Reveal'}\n </button>\n </div>\n </div>\n )\n}\nexport default MaskedField\n"],"names":["useField","useState","MaskedField","field","path","setValue","value","revealed","setRevealed","label","name","div","className","style","display","gap","input","autoComplete","onChange","e","target","flex","type","button","onClick","r"],"mappings":"AAAA;;AAGA,SAASA,QAAQ,QAAQ,iBAAgB;AACzC,SAASC,QAAQ,QAAQ,QAAO;AAEhC,OAAO,MAAMC,cAAwC,CAAC,EAAEC,KAAK,EAAEC,IAAI,EAAE;IACnE,MAAM,EAAEC,QAAQ,EAAEC,KAAK,EAAE,GAAGN,SAAiB;QAAEI;IAAK;IACpD,MAAM,CAACG,UAAUC,YAAY,GAAGP,SAAS;IACzC,MAAMQ,QAAQ,OAAON,OAAOM,UAAU,WAAWN,MAAMM,KAAK,GAAIN,OAAOO,QAAQN;IAE/E,qBACE,MAACO;QAAIC,WAAU;;0BACb,KAACH;gBAAMG,WAAU;0BAAeH;;0BAChC,MAACE;gBAAIE,OAAO;oBAAEC,SAAS;oBAAQC,KAAK;gBAAQ;;kCAC1C,KAACC;wBACCC,cAAa;wBACbC,UAAU,CAACC,IAAMd,SAASc,EAAEC,MAAM,CAACd,KAAK;wBACxCO,OAAO;4BAAEQ,MAAM;wBAAE;wBACjBC,MAAMf,WAAW,SAAS;wBAC1BD,OAAOA,SAAS;;kCAElB,KAACiB;wBAAOC,SAAS,IAAMhB,YAAY,CAACiB,IAAM,CAACA;wBAAIH,MAAK;kCACjDf,WAAW,SAAS;;;;;;AAK/B,EAAC;AACD,eAAeL,YAAW"}
|
||||
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/components/MaskedField.tsx"],"sourcesContent":["'use client'\nimport type { TextFieldClientComponent } from 'payload'\nimport { useState } from 'react'\n\nexport const MaskedField: TextFieldClientComponent = (props: any) => {\n const { field, path, value: propValue, setValue: propSetValue, onChange: propOnChange } = props || {}\n const [internalValue, setInternalValue] = useState(propValue ?? '')\n const [revealed, setRevealed] = useState(false)\n const label = typeof field?.label === 'string' ? field.label : (field?.name ?? path)\n\n const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n const newVal = e.target.value\n setInternalValue(newVal)\n if (typeof propSetValue === 'function') {\n propSetValue(newVal)\n }\n if (typeof propOnChange === 'function') {\n propOnChange(e)\n }\n }\n\n const currentValue = propValue !== undefined ? propValue : internalValue\n\n return (\n <div className=\"field-type text\">\n {label && <label className=\"field-label\">{label}</label>}\n <div style={{ display: 'flex', gap: '.5rem' }}>\n <input\n autoComplete=\"off\"\n onChange={handleChange}\n style={{ flex: 1 }}\n type={revealed ? 'text' : 'password'}\n value={currentValue ?? ''}\n />\n <button onClick={() => setRevealed((r) => !r)} type=\"button\">\n {revealed ? 'Hide' : 'Reveal'}\n </button>\n </div>\n </div>\n )\n}\n\nexport default MaskedField\n"],"names":["useState","MaskedField","props","field","path","value","propValue","setValue","propSetValue","onChange","propOnChange","internalValue","setInternalValue","revealed","setRevealed","label","name","handleChange","e","newVal","target","currentValue","undefined","div","className","style","display","gap","input","autoComplete","flex","type","button","onClick","r"],"mappings":"AAAA;;AAEA,SAASA,QAAQ,QAAQ,QAAO;AAEhC,OAAO,MAAMC,cAAwC,CAACC;IACpD,MAAM,EAAEC,KAAK,EAAEC,IAAI,EAAEC,OAAOC,SAAS,EAAEC,UAAUC,YAAY,EAAEC,UAAUC,YAAY,EAAE,GAAGR,SAAS,CAAC;IACpG,MAAM,CAACS,eAAeC,iBAAiB,GAAGZ,SAASM,aAAa;IAChE,MAAM,CAACO,UAAUC,YAAY,GAAGd,SAAS;IACzC,MAAMe,QAAQ,OAAOZ,OAAOY,UAAU,WAAWZ,MAAMY,KAAK,GAAIZ,OAAOa,QAAQZ;IAE/E,MAAMa,eAAe,CAACC;QACpB,MAAMC,SAASD,EAAEE,MAAM,CAACf,KAAK;QAC7BO,iBAAiBO;QACjB,IAAI,OAAOX,iBAAiB,YAAY;YACtCA,aAAaW;QACf;QACA,IAAI,OAAOT,iBAAiB,YAAY;YACtCA,aAAaQ;QACf;IACF;IAEA,MAAMG,eAAef,cAAcgB,YAAYhB,YAAYK;IAE3D,qBACE,MAACY;QAAIC,WAAU;;YACZT,uBAAS,KAACA;gBAAMS,WAAU;0BAAeT;;0BAC1C,MAACQ;gBAAIE,OAAO;oBAAEC,SAAS;oBAAQC,KAAK;gBAAQ;;kCAC1C,KAACC;wBACCC,cAAa;wBACbpB,UAAUQ;wBACVQ,OAAO;4BAAEK,MAAM;wBAAE;wBACjBC,MAAMlB,WAAW,SAAS;wBAC1BR,OAAOgB,gBAAgB;;kCAEzB,KAACW;wBAAOC,SAAS,IAAMnB,YAAY,CAACoB,IAAM,CAACA;wBAAIH,MAAK;kCACjDlB,WAAW,SAAS;;;;;;AAK/B,EAAC;AAED,eAAeZ,YAAW"}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 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 declare const TestEmailButton: () => import("react/jsx-runtime").JSX.Element;
|
||||
export default TestEmailButton;
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
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: '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 /*#__PURE__*/ _jsxs("div", {
|
||||
className: "field-type",
|
||||
style: {
|
||||
marginTop: '1rem'
|
||||
},
|
||||
children: [
|
||||
/*#__PURE__*/ _jsx("label", {
|
||||
className: "field-label",
|
||||
children: "Send a test email"
|
||||
}),
|
||||
/*#__PURE__*/ _jsx("p", {
|
||||
style: {
|
||||
fontSize: '.85rem',
|
||||
marginTop: 0,
|
||||
opacity: 0.7
|
||||
},
|
||||
children: "Sends through the transport selected above. Save your changes first."
|
||||
}),
|
||||
/*#__PURE__*/ _jsxs("div", {
|
||||
style: {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '.5rem'
|
||||
},
|
||||
children: [
|
||||
/*#__PURE__*/ _jsx("input", {
|
||||
onChange: (e)=>setTo(e.target.value),
|
||||
placeholder: "[email protected]",
|
||||
style: {
|
||||
flex: 1,
|
||||
minWidth: '220px'
|
||||
},
|
||||
type: "email",
|
||||
value: to
|
||||
}),
|
||||
/*#__PURE__*/ _jsx("button", {
|
||||
className: "btn btn--style-secondary",
|
||||
disabled: status.kind === 'sending',
|
||||
onClick: send,
|
||||
style: {
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
type: "button",
|
||||
children: status.kind === 'sending' ? 'Sending…' : 'Send test'
|
||||
})
|
||||
]
|
||||
}),
|
||||
status.kind === 'ok' && /*#__PURE__*/ _jsxs("p", {
|
||||
style: {
|
||||
color: 'var(--theme-success-500, green)',
|
||||
marginTop: '.5rem'
|
||||
},
|
||||
children: [
|
||||
"✓ ",
|
||||
status.msg
|
||||
]
|
||||
}),
|
||||
status.kind === 'error' && /*#__PURE__*/ _jsxs("p", {
|
||||
style: {
|
||||
color: 'var(--theme-error-500, crimson)',
|
||||
marginTop: '.5rem'
|
||||
},
|
||||
children: [
|
||||
"✗ ",
|
||||
status.msg
|
||||
]
|
||||
})
|
||||
]
|
||||
});
|
||||
};
|
||||
export default TestEmailButton;
|
||||
|
||||
//# sourceMappingURL=TestEmailButton.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/components/TestEmailButton.tsx"],"sourcesContent":["'use client'\n\nimport { useState } from 'react'\n\n/**\n * Admin UI: a small \"send test email\" tool for the SiteIntegrations email tab.\n * Enter an address, click Send, and it POSTs to /api/ipal/test-email, which\n * sends through the currently-selected transport (SMTP or Graph). Shows the\n * result inline so you can confirm delivery — or read the exact error — without\n * leaving the panel.\n *\n * Assigned via a `ui` field's admin.components.Field.\n */\nexport const TestEmailButton = () => {\n const [to, setTo] = useState('')\n const [status, setStatus] = useState<\n | { kind: 'error'; msg: string }\n | { kind: 'idle' }\n | { kind: 'ok'; msg: string }\n | { kind: 'sending' }\n >({ kind: 'idle' })\n\n const send = async () => {\n if (!to.trim()) {\n setStatus({ kind: 'error', msg: 'Enter a recipient address.' })\n return\n }\n setStatus({ kind: 'sending' })\n try {\n const res = await fetch('/api/ipal/test-email', {\n body: JSON.stringify({ to: to.trim() }),\n credentials: 'include',\n headers: { 'Content-Type': 'application/json' },\n method: 'POST',\n })\n const data = await res.json()\n if (data.ok) {\n setStatus({ kind: 'ok', msg: data.message ?? 'Test email sent.' })\n } else {\n setStatus({ kind: 'error', msg: data.error ?? 'Send failed.' })\n }\n } catch {\n setStatus({ kind: 'error', msg: 'Request failed. Is the server running?' })\n }\n }\n\n return (\n <div className=\"field-type\" style={{ marginTop: '1rem' }}>\n <label className=\"field-label\">Send a test email</label>\n <p style={{ fontSize: '.85rem', marginTop: 0, opacity: 0.7 }}>\n Sends through the transport selected above. Save your changes first.\n </p>\n <div style={{ alignItems: 'center', display: 'flex', flexWrap: 'wrap', gap: '.5rem' }}>\n <input\n onChange={(e) => setTo(e.target.value)}\n placeholder=\"[email protected]\"\n style={{ flex: 1, minWidth: '220px' }}\n type=\"email\"\n value={to}\n />\n <button\n className=\"btn btn--style-secondary\"\n disabled={status.kind === 'sending'}\n onClick={send}\n style={{ whiteSpace: 'nowrap' }}\n type=\"button\"\n >\n {status.kind === 'sending' ? 'Sending…' : 'Send test'}\n </button>\n </div>\n {status.kind === 'ok' && (\n <p style={{ color: 'var(--theme-success-500, green)', marginTop: '.5rem' }}>\n ✓ {status.msg}\n </p>\n )}\n {status.kind === 'error' && (\n <p style={{ color: 'var(--theme-error-500, crimson)', marginTop: '.5rem' }}>\n ✗ {status.msg}\n </p>\n )}\n </div>\n )\n}\n\nexport default TestEmailButton\n"],"names":["useState","TestEmailButton","to","setTo","status","setStatus","kind","send","trim","msg","res","fetch","body","JSON","stringify","credentials","headers","method","data","json","ok","message","error","div","className","style","marginTop","label","p","fontSize","opacity","alignItems","display","flexWrap","gap","input","onChange","e","target","value","placeholder","flex","minWidth","type","button","disabled","onClick","whiteSpace","color"],"mappings":"AAAA;;AAEA,SAASA,QAAQ,QAAQ,QAAO;AAEhC;;;;;;;;CAQC,GACD,OAAO,MAAMC,kBAAkB;IAC7B,MAAM,CAACC,IAAIC,MAAM,GAAGH,SAAS;IAC7B,MAAM,CAACI,QAAQC,UAAU,GAAGL,SAK1B;QAAEM,MAAM;IAAO;IAEjB,MAAMC,OAAO;QACX,IAAI,CAACL,GAAGM,IAAI,IAAI;YACdH,UAAU;gBAAEC,MAAM;gBAASG,KAAK;YAA6B;YAC7D;QACF;QACAJ,UAAU;YAAEC,MAAM;QAAU;QAC5B,IAAI;YACF,MAAMI,MAAM,MAAMC,MAAM,wBAAwB;gBAC9CC,MAAMC,KAAKC,SAAS,CAAC;oBAAEZ,IAAIA,GAAGM,IAAI;gBAAG;gBACrCO,aAAa;gBACbC,SAAS;oBAAE,gBAAgB;gBAAmB;gBAC9CC,QAAQ;YACV;YACA,MAAMC,OAAO,MAAMR,IAAIS,IAAI;YAC3B,IAAID,KAAKE,EAAE,EAAE;gBACXf,UAAU;oBAAEC,MAAM;oBAAMG,KAAKS,KAAKG,OAAO,IAAI;gBAAmB;YAClE,OAAO;gBACLhB,UAAU;oBAAEC,MAAM;oBAASG,KAAKS,KAAKI,KAAK,IAAI;gBAAe;YAC/D;QACF,EAAE,OAAM;YACNjB,UAAU;gBAAEC,MAAM;gBAASG,KAAK;YAAyC;QAC3E;IACF;IAEA,qBACE,MAACc;QAAIC,WAAU;QAAaC,OAAO;YAAEC,WAAW;QAAO;;0BACrD,KAACC;gBAAMH,WAAU;0BAAc;;0BAC/B,KAACI;gBAAEH,OAAO;oBAAEI,UAAU;oBAAUH,WAAW;oBAAGI,SAAS;gBAAI;0BAAG;;0BAG9D,MAACP;gBAAIE,OAAO;oBAAEM,YAAY;oBAAUC,SAAS;oBAAQC,UAAU;oBAAQC,KAAK;gBAAQ;;kCAClF,KAACC;wBACCC,UAAU,CAACC,IAAMlC,MAAMkC,EAAEC,MAAM,CAACC,KAAK;wBACrCC,aAAY;wBACZf,OAAO;4BAAEgB,MAAM;4BAAGC,UAAU;wBAAQ;wBACpCC,MAAK;wBACLJ,OAAOrC;;kCAET,KAAC0C;wBACCpB,WAAU;wBACVqB,UAAUzC,OAAOE,IAAI,KAAK;wBAC1BwC,SAASvC;wBACTkB,OAAO;4BAAEsB,YAAY;wBAAS;wBAC9BJ,MAAK;kCAEJvC,OAAOE,IAAI,KAAK,YAAY,aAAa;;;;YAG7CF,OAAOE,IAAI,KAAK,sBACf,MAACsB;gBAAEH,OAAO;oBAAEuB,OAAO;oBAAmCtB,WAAW;gBAAQ;;oBAAG;oBACvEtB,OAAOK,GAAG;;;YAGhBL,OAAOE,IAAI,KAAK,yBACf,MAACsB;gBAAEH,OAAO;oBAAEuB,OAAO;oBAAmCtB,WAAW;gBAAQ;;oBAAG;oBACvEtB,OAAOK,GAAG;;;;;AAKvB,EAAC;AAED,eAAeR,gBAAe"}
|
||||
+31
@@ -5,8 +5,30 @@
|
||||
* user), so all fields — including the password — stay editable in the admin
|
||||
* panel while remaining inaccessible to anonymous API requests.
|
||||
*/ export const smtpFields = [
|
||||
{
|
||||
name: 'emailTransport',
|
||||
type: 'select',
|
||||
admin: {
|
||||
description: 'How outbound email is sent. "Microsoft Graph" is only available when configured by the administrator (Intecion).'
|
||||
},
|
||||
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',
|
||||
@@ -57,6 +79,15 @@
|
||||
admin: {
|
||||
description: 'Default "from" display name.'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'emailTest',
|
||||
type: 'ui',
|
||||
admin: {
|
||||
components: {
|
||||
Field: '@intecion/ipal-kit/client#TestEmailButton'
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/smtp.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * SMTP transport settings for outbound email.\n *\n * Protected at the global level (SiteIntegrations requires an authenticated\n * user), so all fields — including the password — stay editable in the admin\n * panel while remaining inaccessible to anonymous API requests.\n */\nexport const smtpFields: Field[] = [\n {\n type: 'row',\n fields: [\n {\n name: 'smtpHost',\n type: 'text',\n admin: { placeholder: 'smtp.example.com', width: '70%' },\n },\n {\n name: 'smtpPort',\n type: 'number',\n admin: { width: '30%' },\n defaultValue: 587,\n },\n ],\n },\n {\n name: 'smtpUser',\n type: 'text',\n admin: {\n description: 'SMTP account username.',\n },\n },\n {\n name: 'smtpPassword',\n type: 'text',\n admin: {\n description: 'SMTP account password.',\n // Masked in the UI (••••) — stored plaintext, readable for SMTP auth.\n components: {\n Field: '@intecion/ipal-kit/client#MaskedField',\n },\n },\n },\n {\n name: 'smtpFromAddress',\n type: 'email',\n admin: {\n description: 'Default \"from\" address for outgoing mail.',\n },\n },\n {\n name: 'smtpFromName',\n type: 'text',\n admin: {\n description: 'Default \"from\" display name.',\n },\n },\n]\n"],"names":["smtpFields","type","fields","name","admin","placeholder","width","defaultValue","description","components","Field"],"mappings":"AAEA;;;;;;CAMC,GACD,OAAO,MAAMA,aAAsB;IACjC;QACEC,MAAM;QACNC,QAAQ;YACN;gBACEC,MAAM;gBACNF,MAAM;gBACNG,OAAO;oBAAEC,aAAa;oBAAoBC,OAAO;gBAAM;YACzD;YACA;gBACEH,MAAM;gBACNF,MAAM;gBACNG,OAAO;oBAAEE,OAAO;gBAAM;gBACtBC,cAAc;YAChB;SACD;IACH;IACA;QACEJ,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;IACA;QACEL,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;YACb,sEAAsE;YACtEC,YAAY;gBACVC,OAAO;YACT;QACF;IACF;IACA;QACEP,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;IACA;QACEL,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;CACD,CAAA"}
|
||||
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/smtp.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * SMTP transport settings for outbound email.\n *\n * Protected at the global level (SiteIntegrations requires an authenticated\n * user), so all fields — including the password — stay editable in the admin\n * panel while remaining inaccessible to anonymous API requests.\n */\nexport const smtpFields: Field[] = [\n {\n name: 'emailTransport',\n type: 'select',\n admin: {\n description:\n 'How outbound email is sent. \"Microsoft Graph\" is only available when configured by the administrator (Intecion).',\n // The Graph option only makes sense when agency credentials exist in env.\n // We can't read process.env in the admin UI directly, so a client project\n // that hasn't set up Graph should filter this option via integrationsFields\n // override, or simply leave it on 'smtp'. The adapter enforces the real\n // availability at send time regardless of what's selected here.\n },\n defaultValue: 'smtp',\n options: [\n { label: 'SMTP', value: 'smtp' },\n { label: 'Microsoft Graph (Exchange)', value: 'graph' },\n ],\n },\n {\n type: 'row',\n admin: {\n // Hide SMTP fields when Graph is selected — they're not used then.\n condition: (_, siblingData) => siblingData?.emailTransport !== 'graph',\n },\n fields: [\n {\n name: 'smtpHost',\n type: 'text',\n admin: { placeholder: 'smtp.example.com', width: '70%' },\n },\n {\n name: 'smtpPort',\n type: 'number',\n admin: { width: '30%' },\n defaultValue: 587,\n },\n ],\n },\n {\n name: 'smtpUser',\n type: 'text',\n admin: {\n description: 'SMTP account username.',\n },\n },\n {\n name: 'smtpPassword',\n type: 'text',\n admin: {\n description: 'SMTP account password.',\n // Masked in the UI (••••) — stored plaintext, readable for SMTP auth.\n components: {\n Field: '@intecion/ipal-kit/client#MaskedField',\n },\n },\n },\n {\n name: 'smtpFromAddress',\n type: 'email',\n admin: {\n description: 'Default \"from\" address for outgoing mail.',\n },\n },\n {\n name: 'smtpFromName',\n type: 'text',\n admin: {\n description: 'Default \"from\" display name.',\n },\n },\n {\n name: 'emailTest',\n type: 'ui',\n admin: {\n components: {\n Field: '@intecion/ipal-kit/client#TestEmailButton',\n },\n },\n },\n]\n"],"names":["smtpFields","name","type","admin","description","defaultValue","options","label","value","condition","_","siblingData","emailTransport","fields","placeholder","width","components","Field"],"mappings":"AAEA;;;;;;CAMC,GACD,OAAO,MAAMA,aAAsB;IACjC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QAMJ;QACAC,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAQC,OAAO;YAAO;YAC/B;gBAAED,OAAO;gBAA8BC,OAAO;YAAQ;SACvD;IACH;IACA;QACEN,MAAM;QACNC,OAAO;YACL,mEAAmE;YACnEM,WAAW,CAACC,GAAGC,cAAgBA,aAAaC,mBAAmB;QACjE;QACAC,QAAQ;YACN;gBACEZ,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEW,aAAa;oBAAoBC,OAAO;gBAAM;YACzD;YACA;gBACEd,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEY,OAAO;gBAAM;gBACtBV,cAAc;YAChB;SACD;IACH;IACA;QACEJ,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;YACb,sEAAsE;YACtEY,YAAY;gBACVC,OAAO;YACT;QACF;IACF;IACA;QACEhB,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLa,YAAY;gBACVC,OAAO;YACT;QACF;IACF;CACD,CAAA"}
|
||||
Vendored
+6
@@ -7,6 +7,10 @@ export type { ConsentCategory, ConsentState, ConsentTexts } from './modules/cons
|
||||
export type { ContentCollectionOption, ContentOption, ResolvedRoute, } from './modules/content/index.js';
|
||||
export { archiveFieldName, buildArchivePath, buildEntryPath, getArchiveEntries, parsePageParam, 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';
|
||||
export { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js';
|
||||
export type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js';
|
||||
export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
|
||||
@@ -27,5 +31,7 @@ export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/
|
||||
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js';
|
||||
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta, } from './modules/seo/index.js';
|
||||
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
||||
export { NOTIFICATION_FALLBACK, getNotificationTexts, resolveFormMessage, } from './modules/notifications/index.js';
|
||||
export type { FormNotificationTexts, NotificationsData, NotificationTexts, } from './modules/notifications/index.js';
|
||||
export { ipalKit } from './plugin.js';
|
||||
export type { IpalOptions } from './types.js';
|
||||
|
||||
Vendored
+3
@@ -2,6 +2,8 @@ export { adminOnly, adminOnlyField, adminOrEditor, adminOrEditorField, adminOrSe
|
||||
export { getAnalyticsConfig } from './modules/analytics/index.js';
|
||||
export { ACCEPT_ALL_CONSENT, CONSENT_CATEGORIES, CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, DEFAULT_CONSENT, getConsentTexts, parseConsent, REJECT_ALL_CONSENT, serializeConsent, setDefaultConsent, updateConsent } from './modules/consent/index.js';
|
||||
export { archiveFieldName, buildArchivePath, buildEntryPath, getArchiveEntries, parsePageParam, resolveRoute } from './modules/content/index.js';
|
||||
export { graphAdapter } from './modules/email/graphAdapter.js';
|
||||
export { mailAdapter } 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.
|
||||
@@ -16,6 +18,7 @@ export { buildSecurityHeaders } from './modules/security/index.js';
|
||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
||||
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta } from './modules/seo/index.js';
|
||||
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
||||
export { NOTIFICATION_FALLBACK, getNotificationTexts, resolveFormMessage } from './modules/notifications/index.js';
|
||||
export { ipalKit } from './plugin.js';
|
||||
|
||||
//# sourceMappingURL=index.js.map
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
import type { PayloadEmailAdapter } from 'payload';
|
||||
export type GraphAdapterArgs = {
|
||||
fallbackFromAddress?: string;
|
||||
fallbackFromName?: string;
|
||||
};
|
||||
/**
|
||||
* 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 declare const graphAdapter: (args?: GraphAdapterArgs) => PayloadEmailAdapter;
|
||||
Vendored
+181
@@ -0,0 +1,181 @@
|
||||
import { getSiteIntegrations } from '../payload/index.js';
|
||||
/** Reads + validates the agency Graph credentials from env. */ function readGraphEnv() {
|
||||
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) {
|
||||
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();
|
||||
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) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
const list = Array.isArray(value) ? value : [
|
||||
value
|
||||
];
|
||||
return list.map((v)=>typeof v === 'string' ? v : v.address).filter((a)=>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 = {})=>({ payload })=>({
|
||||
name: 'ipal-graph',
|
||||
defaultFromAddress: args.fallbackFromAddress ?? 'noreply@localhost',
|
||||
defaultFromName: args.fallbackFromName ?? 'Website',
|
||||
sendEmail: async (message)=>{
|
||||
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
|
||||
};
|
||||
}
|
||||
// The panel's from-address is used as Reply-To, NOT as the message From.
|
||||
//
|
||||
// Why: app-only Graph sends from GRAPH_SENDER's mailbox. If we also set a
|
||||
// `from` that differs from that mailbox, Exchange demands "Send As"
|
||||
// permission on it and rejects with ErrorSendAsDenied otherwise. So we
|
||||
// never override `from` — Graph stamps the mail as GRAPH_SENDER (the
|
||||
// mailbox we legitimately own) — and route replies to the panel address
|
||||
// via Reply-To. Recipients see the mail from forms@… but replying reaches
|
||||
// the real destination. No Send-As needed.
|
||||
const panel = await getSiteIntegrations(payload);
|
||||
const replyToAddress = panel.smtpFromAddress || undefined;
|
||||
const replyToName = 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 ?? '');
|
||||
// Reply-To: prefer whatever the caller set; otherwise the panel address.
|
||||
const replyTo = message.replyTo ? toRecipients(message.replyTo) : replyToAddress ? [
|
||||
{
|
||||
emailAddress: {
|
||||
address: replyToAddress,
|
||||
...replyToName ? {
|
||||
name: replyToName
|
||||
} : {}
|
||||
}
|
||||
}
|
||||
] : [];
|
||||
const graphMessage = {
|
||||
body: {
|
||||
content,
|
||||
contentType: isHtml ? 'HTML' : 'Text'
|
||||
},
|
||||
subject: message.subject ?? '',
|
||||
toRecipients: to,
|
||||
...message.cc ? {
|
||||
ccRecipients: toRecipients(message.cc)
|
||||
} : {},
|
||||
...message.bcc ? {
|
||||
bccRecipients: toRecipients(message.bcc)
|
||||
} : {},
|
||||
// NO `from` — Graph uses GRAPH_SENDER's own mailbox, so no Send-As.
|
||||
...replyTo.length > 0 ? {
|
||||
replyTo
|
||||
} : {}
|
||||
};
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
//# sourceMappingURL=graphAdapter.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+4
@@ -1,2 +1,6 @@
|
||||
export { graphAdapter } from './graphAdapter.js';
|
||||
export type { GraphAdapterArgs } from './graphAdapter.js';
|
||||
export { mailAdapter } from './mailAdapter.js';
|
||||
export type { MailAdapterArgs } from './mailAdapter.js';
|
||||
export { sendEmail } from './sendEmail.js';
|
||||
export type { SendEmailArgs, SendEmailResult } from './sendEmail.js';
|
||||
|
||||
Vendored
+2
@@ -1,3 +1,5 @@
|
||||
export { graphAdapter } from './graphAdapter.js';
|
||||
export { mailAdapter } 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';
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/email/index.ts"],"sourcesContent":["// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer)\n// so this must never be imported from a client component.\nexport { sendEmail } from './sendEmail.js'\nexport type { SendEmailArgs, SendEmailResult } from './sendEmail.js'\n"],"names":["sendEmail"],"mappings":"AAAA,mFAAmF;AACnF,0DAA0D;AAC1D,SAASA,SAAS,QAAQ,iBAAgB"}
|
||||
{"version":3,"sources":["../../../src/modules/email/index.ts"],"sourcesContent":["export { graphAdapter } from './graphAdapter.js'\nexport type { GraphAdapterArgs } from './graphAdapter.js'\nexport { mailAdapter } from './mailAdapter.js'\nexport type { MailAdapterArgs } from './mailAdapter.js'\n// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer)\n// so this must never be imported from a client component.\nexport { sendEmail } from './sendEmail.js'\nexport type { SendEmailArgs, SendEmailResult } from './sendEmail.js'\n"],"names":["graphAdapter","mailAdapter","sendEmail"],"mappings":"AAAA,SAASA,YAAY,QAAQ,oBAAmB;AAEhD,SAASC,WAAW,QAAQ,mBAAkB;AAE9C,mFAAmF;AACnF,0DAA0D;AAC1D,SAASC,SAAS,QAAQ,iBAAgB"}
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
import type { PayloadEmailAdapter } from 'payload';
|
||||
import { type GraphAdapterArgs } from './graphAdapter.js';
|
||||
import { type PanelSmtpAdapterArgs } from './panelSmtpAdapter.js';
|
||||
export type MailAdapterArgs = {
|
||||
fallbackFromAddress?: string;
|
||||
fallbackFromName?: string;
|
||||
graph?: GraphAdapterArgs;
|
||||
smtp?: PanelSmtpAdapterArgs;
|
||||
};
|
||||
/**
|
||||
* 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 declare const mailAdapter: (args?: MailAdapterArgs) => PayloadEmailAdapter;
|
||||
Vendored
+58
@@ -0,0 +1,58 @@
|
||||
import { getSiteIntegrations } from '../payload/index.js';
|
||||
import { graphAdapter } from './graphAdapter.js';
|
||||
import { panelSmtpAdapter } from './panelSmtpAdapter.js';
|
||||
/** True when the agency Graph credentials are present in the environment. */ function graphAvailable() {
|
||||
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 = {})=>(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)=>{
|
||||
const settings = await getSiteIntegrations(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);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
//# sourceMappingURL=mailAdapter.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/email/mailAdapter.ts"],"sourcesContent":["import type { PayloadEmailAdapter, SendEmailOptions } from 'payload'\n\nimport { getSiteIntegrations } from '../payload/index.js'\nimport { graphAdapter, type GraphAdapterArgs } from './graphAdapter.js'\nimport { panelSmtpAdapter, type PanelSmtpAdapterArgs } from './panelSmtpAdapter.js'\n\ntype TransportIntegrations = {\n /** 'smtp' | 'graph' — chosen by the editor in SiteIntegrations. */\n emailTransport?: 'graph' | 'smtp' | null\n}\n\nexport type MailAdapterArgs = {\n fallbackFromAddress?: string\n fallbackFromName?: string\n graph?: GraphAdapterArgs\n smtp?: PanelSmtpAdapterArgs\n}\n\n/** True when the agency Graph credentials are present in the environment. */\nfunction graphAvailable(): boolean {\n return Boolean(\n process.env.GRAPH_TENANT_ID &&\n process.env.GRAPH_CLIENT_ID &&\n process.env.GRAPH_CLIENT_SECRET &&\n process.env.GRAPH_SENDER,\n )\n}\n\n/**\n * Dispatcher email adapter: wired into the config ONCE, but picks the transport\n * (SMTP or Graph) per send by reading `emailTransport` from SiteIntegrations.\n * This is what makes the choice switchable in the panel — Payload builds the\n * email adapter at boot and can't swap it at runtime, so instead of choosing\n * between two adapters at boot we install one that delegates on every send.\n *\n * Availability guard: Graph only runs if its agency credentials exist in env\n * (this is *our* Exchange). If the panel says 'graph' but env isn't set up,\n * we DON'T silently fail — we log clearly and fall back to SMTP, so a client\n * flipping the switch without the backing config still gets mail out (over SMTP)\n * rather than silent nothing. If neither is usable, the send reports an error.\n *\n * @example\n * // payload.config.ts\n * import { mailAdapter } from '@intecion/ipal-kit'\n * email: mailAdapter()\n */\nexport const mailAdapter =\n (args: MailAdapterArgs = {}): PayloadEmailAdapter =>\n (deps) => {\n // Build both delegates once; each still resolves its own config per send.\n const smtp = panelSmtpAdapter({\n fallbackFromAddress: args.fallbackFromAddress,\n fallbackFromName: args.fallbackFromName,\n ...args.smtp,\n })(deps)\n const graph = graphAdapter({\n fallbackFromAddress: args.fallbackFromAddress,\n fallbackFromName: args.fallbackFromName,\n ...args.graph,\n })(deps)\n\n const { payload } = deps\n\n return {\n name: 'ipal-mail-dispatcher',\n defaultFromAddress: smtp.defaultFromAddress,\n defaultFromName: smtp.defaultFromName,\n\n sendEmail: async (message: SendEmailOptions) => {\n const settings = await getSiteIntegrations<TransportIntegrations>(payload)\n const choice = settings.emailTransport ?? 'smtp'\n\n if (choice === 'graph') {\n if (graphAvailable()) {\n return graph.sendEmail(message)\n }\n // Panel asked for Graph but the agency creds aren't configured for\n // this project. Fall back to SMTP rather than silently dropping mail.\n payload.logger.warn(\n '[ipal] Transport set to Graph but GRAPH_* env vars are missing; falling back to SMTP.',\n )\n return smtp.sendEmail(message)\n }\n\n return smtp.sendEmail(message)\n },\n }\n }\n"],"names":["getSiteIntegrations","graphAdapter","panelSmtpAdapter","graphAvailable","Boolean","process","env","GRAPH_TENANT_ID","GRAPH_CLIENT_ID","GRAPH_CLIENT_SECRET","GRAPH_SENDER","mailAdapter","args","deps","smtp","fallbackFromAddress","fallbackFromName","graph","payload","name","defaultFromAddress","defaultFromName","sendEmail","message","settings","choice","emailTransport","logger","warn"],"mappings":"AAEA,SAASA,mBAAmB,QAAQ,sBAAqB;AACzD,SAASC,YAAY,QAA+B,oBAAmB;AACvE,SAASC,gBAAgB,QAAmC,wBAAuB;AAcnF,2EAA2E,GAC3E,SAASC;IACP,OAAOC,QACLC,QAAQC,GAAG,CAACC,eAAe,IAC3BF,QAAQC,GAAG,CAACE,eAAe,IAC3BH,QAAQC,GAAG,CAACG,mBAAmB,IAC/BJ,QAAQC,GAAG,CAACI,YAAY;AAE5B;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,MAAMC,cACX,CAACC,OAAwB,CAAC,CAAC,GAC3B,CAACC;QACC,0EAA0E;QAC1E,MAAMC,OAAOZ,iBAAiB;YAC5Ba,qBAAqBH,KAAKG,mBAAmB;YAC7CC,kBAAkBJ,KAAKI,gBAAgB;YACvC,GAAGJ,KAAKE,IAAI;QACd,GAAGD;QACH,MAAMI,QAAQhB,aAAa;YACzBc,qBAAqBH,KAAKG,mBAAmB;YAC7CC,kBAAkBJ,KAAKI,gBAAgB;YACvC,GAAGJ,KAAKK,KAAK;QACf,GAAGJ;QAEH,MAAM,EAAEK,OAAO,EAAE,GAAGL;QAEpB,OAAO;YACLM,MAAM;YACNC,oBAAoBN,KAAKM,kBAAkB;YAC3CC,iBAAiBP,KAAKO,eAAe;YAErCC,WAAW,OAAOC;gBAChB,MAAMC,WAAW,MAAMxB,oBAA2CkB;gBAClE,MAAMO,SAASD,SAASE,cAAc,IAAI;gBAE1C,IAAID,WAAW,SAAS;oBACtB,IAAItB,kBAAkB;wBACpB,OAAOc,MAAMK,SAAS,CAACC;oBACzB;oBACA,mEAAmE;oBACnE,sEAAsE;oBACtEL,QAAQS,MAAM,CAACC,IAAI,CACjB;oBAEF,OAAOd,KAAKQ,SAAS,CAACC;gBACxB;gBAEA,OAAOT,KAAKQ,SAAS,CAACC;YACxB;QACF;IACF,EAAC"}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import type { Endpoint } 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 declare const testEmailEndpoint: Endpoint;
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
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 = {
|
||||
handler: async (req)=>{
|
||||
// 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?.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.sent !== false : true;
|
||||
if (!sent) {
|
||||
const error = info?.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'
|
||||
};
|
||||
|
||||
//# sourceMappingURL=testEmailEndpoint.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../../src/modules/email/test/testEmailEndpoint.ts"],"sourcesContent":["import type { Endpoint, PayloadRequest } from 'payload'\n\nimport { addDataAndFileToRequest } from 'payload'\n\n/**\n * Custom endpoint: send a test email to a given address through whatever\n * transport is currently active (SMTP or Graph — mailAdapter reads the panel\n * setting per send, so the test exercises the REAL path a form email would\n * take). Mounted at POST /api/ipal/test-email.\n *\n * Admin-only: uses payload.sendEmail (server-side), and requires an\n * authenticated admin user — a test-send button must never be open to the\n * public (it would be an open relay / spam vector).\n *\n * Returns the adapter's own result so the panel can show exactly what happened,\n * including the transport-specific error (SMTP auth failure, Graph 401, etc.).\n */\nexport const testEmailEndpoint: Endpoint = {\n handler: async (req: PayloadRequest) => {\n // Auth: only signed-in admins may trigger a send.\n if (!req.user) {\n return Response.json({ error: 'Unauthorized', ok: false }, { status: 401 })\n }\n\n await addDataAndFileToRequest(req)\n const to = (req.data?.to as string | undefined)?.trim()\n\n if (!to || !/^[^@\\s]+@[^\\s@][^\\s.@]*\\.[^\\s@]+$/.test(to)) {\n return Response.json(\n { error: 'Provide a valid recipient address.', ok: false },\n { status: 400 },\n )\n }\n\n try {\n const info = await req.payload.sendEmail({\n html: '<p>This is a test message from <strong>ipal-kit</strong>. If you received it, outbound email is configured correctly.</p>',\n subject: 'ipal-kit — test email',\n text: 'This is a test message from ipal-kit. If you received it, outbound email is configured correctly.',\n to,\n })\n\n // Payload's sendEmail resolves with the adapter's result. Our adapters\n // return { sent: boolean, error?: string }; nodemailer returns info with\n // messageId. Normalize to a simple ok/message for the panel.\n const sent =\n info && typeof info === 'object' && 'sent' in info\n ? (info as { sent?: boolean }).sent !== false\n : true\n\n if (!sent) {\n const error = (info as { error?: string })?.error ?? 'Send failed (see server logs).'\n return Response.json({ error, ok: false }, { status: 502 })\n }\n\n return Response.json({ message: `Test email sent to ${to}.`, ok: true })\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err)\n req.payload.logger.error(`[ipal] Test email failed: ${message}`)\n return Response.json(\n { error: 'Send failed. Check transport settings and server logs.', ok: false },\n { status: 502 },\n )\n }\n },\n method: 'post',\n path: '/ipal/test-email',\n}\n"],"names":["addDataAndFileToRequest","testEmailEndpoint","handler","req","user","Response","json","error","ok","status","to","data","trim","test","info","payload","sendEmail","html","subject","text","sent","message","err","Error","String","logger","method","path"],"mappings":"AAEA,SAASA,uBAAuB,QAAQ,UAAS;AAEjD;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,oBAA8B;IACzCC,SAAS,OAAOC;QACd,kDAAkD;QAClD,IAAI,CAACA,IAAIC,IAAI,EAAE;YACb,OAAOC,SAASC,IAAI,CAAC;gBAAEC,OAAO;gBAAgBC,IAAI;YAAM,GAAG;gBAAEC,QAAQ;YAAI;QAC3E;QAEA,MAAMT,wBAAwBG;QAC9B,MAAMO,KAAMP,IAAIQ,IAAI,EAAED,IAA2BE;QAEjD,IAAI,CAACF,MAAM,CAAC,oCAAoCG,IAAI,CAACH,KAAK;YACxD,OAAOL,SAASC,IAAI,CAClB;gBAAEC,OAAO;gBAAsCC,IAAI;YAAM,GACzD;gBAAEC,QAAQ;YAAI;QAElB;QAEA,IAAI;YACF,MAAMK,OAAO,MAAMX,IAAIY,OAAO,CAACC,SAAS,CAAC;gBACvCC,MAAM;gBACNC,SAAS;gBACTC,MAAM;gBACNT;YACF;YAEA,uEAAuE;YACvE,yEAAyE;YACzE,6DAA6D;YAC7D,MAAMU,OACJN,QAAQ,OAAOA,SAAS,YAAY,UAAUA,OAC1C,AAACA,KAA4BM,IAAI,KAAK,QACtC;YAEN,IAAI,CAACA,MAAM;gBACT,MAAMb,QAAQ,AAACO,MAA6BP,SAAS;gBACrD,OAAOF,SAASC,IAAI,CAAC;oBAAEC;oBAAOC,IAAI;gBAAM,GAAG;oBAAEC,QAAQ;gBAAI;YAC3D;YAEA,OAAOJ,SAASC,IAAI,CAAC;gBAAEe,SAAS,CAAC,mBAAmB,EAAEX,GAAG,CAAC,CAAC;gBAAEF,IAAI;YAAK;QACxE,EAAE,OAAOc,KAAK;YACZ,MAAMD,UAAUC,eAAeC,QAAQD,IAAID,OAAO,GAAGG,OAAOF;YAC5DnB,IAAIY,OAAO,CAACU,MAAM,CAAClB,KAAK,CAAC,CAAC,0BAA0B,EAAEc,SAAS;YAC/D,OAAOhB,SAASC,IAAI,CAClB;gBAAEC,OAAO;gBAA0DC,IAAI;YAAM,GAC7E;gBAAEC,QAAQ;YAAI;QAElB;IACF;IACAiB,QAAQ;IACRC,MAAM;AACR,EAAC"}
|
||||
Vendored
+12
-1
@@ -1,8 +1,10 @@
|
||||
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';
|
||||
@@ -83,7 +85,16 @@ import { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo
|
||||
buildSiteIntegrations({
|
||||
additionalFields: options.integrationsFields
|
||||
}),
|
||||
buildCookieSettings()
|
||||
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;
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+3
-1
@@ -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)
|
||||
|
||||
+79
-3
@@ -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ść
|
||||
|
||||
@@ -74,3 +76,77 @@ 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: 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.
|
||||
@@ -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' }
|
||||
```
|
||||
|
||||
@@ -163,3 +164,57 @@ function errorMessage(r) {
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
+16
-13
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@intecion/ipal-kit",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.12",
|
||||
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -65,22 +65,25 @@
|
||||
"slugify": "^1.6.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@payloadcms/plugin-form-builder": "^3.84.1",
|
||||
"@payloadcms/plugin-seo": "^3.84.1",
|
||||
"@payloadcms/next": "^3.88.0",
|
||||
"@payloadcms/plugin-form-builder": "^3.88.0",
|
||||
"@payloadcms/plugin-seo": "^3.88.0",
|
||||
"@payloadcms/ui": "^3.88.0",
|
||||
"next": ">=15",
|
||||
"payload": "^3.84.1",
|
||||
"react": "^19.0.0"
|
||||
"payload": "^3.88.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@payloadcms/db-postgres": "3.84.1",
|
||||
"@payloadcms/db-sqlite": "3.84.1",
|
||||
"@payloadcms/db-postgres": "3.88.0",
|
||||
"@payloadcms/db-sqlite": "3.88.0",
|
||||
"@payloadcms/eslint-config": "3.28.0",
|
||||
"@payloadcms/next": "3.84.1",
|
||||
"@payloadcms/plugin-form-builder": "3.84.1",
|
||||
"@payloadcms/plugin-seo": "3.84.1",
|
||||
"@payloadcms/richtext-lexical": "3.84.1",
|
||||
"@payloadcms/ui": "3.84.1",
|
||||
"@payloadcms/next": "3.88.0",
|
||||
"@payloadcms/plugin-form-builder": "3.88.0",
|
||||
"@payloadcms/plugin-seo": "3.88.0",
|
||||
"@payloadcms/richtext-lexical": "3.88.0",
|
||||
"@payloadcms/ui": "3.88.0",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@swc-node/register": "1.10.9",
|
||||
"@swc/cli": "0.6.0",
|
||||
@@ -96,7 +99,7 @@
|
||||
"mongodb-memory-server": "10.1.4",
|
||||
"next": "16.2.6",
|
||||
"open": "^10.1.0",
|
||||
"payload": "3.84.1",
|
||||
"payload": "3.88.0",
|
||||
"prettier": "^3.4.2",
|
||||
"qs-esm": "8.0.1",
|
||||
"react": "19.2.6",
|
||||
|
||||
Generated
+122
-416
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -18,3 +19,6 @@ export {
|
||||
export type { CookieBannerClassNames } from '../modules/consent/client.js'
|
||||
export { Turnstile } from '../modules/turnstile/client.js'
|
||||
export type { TurnstileProps } from '../modules/turnstile/client.js'
|
||||
|
||||
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js'
|
||||
export type { FormNotificationTexts } from '../modules/notifications/types.js'
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
'use client'
|
||||
import type { TextFieldClientComponent } from 'payload'
|
||||
|
||||
import { useField } from '@payloadcms/ui'
|
||||
import { useState } from 'react'
|
||||
|
||||
export const MaskedField: TextFieldClientComponent = ({ field, path }) => {
|
||||
const { setValue, value } = useField<string>({ path })
|
||||
export const MaskedField: TextFieldClientComponent = (props: any) => {
|
||||
const { field, path, value: propValue, setValue: propSetValue, onChange: propOnChange } = props || {}
|
||||
const [internalValue, setInternalValue] = useState(propValue ?? '')
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const label = typeof field?.label === 'string' ? field.label : (field?.name ?? path)
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newVal = e.target.value
|
||||
setInternalValue(newVal)
|
||||
if (typeof propSetValue === 'function') {
|
||||
propSetValue(newVal)
|
||||
}
|
||||
if (typeof propOnChange === 'function') {
|
||||
propOnChange(e)
|
||||
}
|
||||
}
|
||||
|
||||
const currentValue = propValue !== undefined ? propValue : internalValue
|
||||
|
||||
return (
|
||||
<div className="field-type text">
|
||||
<label className="field-label">{label}</label>
|
||||
{label && <label className="field-label">{label}</label>}
|
||||
<div style={{ display: 'flex', gap: '.5rem' }}>
|
||||
<input
|
||||
autoComplete="off"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onChange={handleChange}
|
||||
style={{ flex: 1 }}
|
||||
type={revealed ? 'text' : 'password'}
|
||||
value={value ?? ''}
|
||||
value={currentValue ?? ''}
|
||||
/>
|
||||
<button onClick={() => setRevealed((r) => !r)} type="button">
|
||||
{revealed ? 'Hide' : 'Reveal'}
|
||||
@@ -27,4 +39,5 @@ export const MaskedField: TextFieldClientComponent = ({ field, path }) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MaskedField
|
||||
|
||||
@@ -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.
|
||||
@@ -95,6 +99,16 @@ export {
|
||||
injectAutoFillMeta,
|
||||
} from './modules/seo/index.js'
|
||||
export { buildSlugField, toSlug } from './modules/slug/index.js'
|
||||
export {
|
||||
NOTIFICATION_FALLBACK,
|
||||
getNotificationTexts,
|
||||
resolveFormMessage,
|
||||
} from './modules/notifications/index.js'
|
||||
export type {
|
||||
FormNotificationTexts,
|
||||
NotificationsData,
|
||||
NotificationTexts,
|
||||
} from './modules/notifications/index.js'
|
||||
|
||||
export { ipalKit } from './plugin.js'
|
||||
export type { IpalOptions } from './types.js'
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
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 }
|
||||
}
|
||||
|
||||
// The panel's from-address is used as Reply-To, NOT as the message From.
|
||||
//
|
||||
// Why: app-only Graph sends from GRAPH_SENDER's mailbox. If we also set a
|
||||
// `from` that differs from that mailbox, Exchange demands "Send As"
|
||||
// permission on it and rejects with ErrorSendAsDenied otherwise. So we
|
||||
// never override `from` — Graph stamps the mail as GRAPH_SENDER (the
|
||||
// mailbox we legitimately own) — and route replies to the panel address
|
||||
// via Reply-To. Recipients see the mail from forms@… but replying reaches
|
||||
// the real destination. No Send-As needed.
|
||||
const panel = await getSiteIntegrations<GraphIntegrations>(payload)
|
||||
const replyToAddress = panel.smtpFromAddress || undefined
|
||||
const replyToName = 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 ?? '')
|
||||
|
||||
// Reply-To: prefer whatever the caller set; otherwise the panel address.
|
||||
const replyTo = message.replyTo
|
||||
? toRecipients(message.replyTo as SendEmailOptions['to'])
|
||||
: replyToAddress
|
||||
? [
|
||||
{
|
||||
emailAddress: {
|
||||
address: replyToAddress,
|
||||
...(replyToName ? { name: replyToName } : {}),
|
||||
},
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
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) } : {}),
|
||||
// NO `from` — Graph uses GRAPH_SENDER's own mailbox, so no Send-As.
|
||||
...(replyTo.length > 0 ? { replyTo } : {}),
|
||||
}
|
||||
|
||||
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