From ac48f1e9d1e911b2d6534b2e1aceec9a580a6838 Mon Sep 17 00:00:00 2001 From: rasm-its Date: Sat, 22 Aug 2026 17:55:43 +0200 Subject: [PATCH] graph + mail dispatcher, test-email endpoint, notifications global --- dist/exports/client.d.ts | 1 + dist/exports/client.js | 1 + dist/exports/client.js.map | 2 +- .../components/TestEmailButton.d.ts | 11 ++ .../components/TestEmailButton.js | 131 +++++++++++++ .../components/TestEmailButton.js.map | 1 + dist/globals/SiteIntegrations/fields/smtp.js | 31 +++ .../SiteIntegrations/fields/smtp.js.map | 2 +- dist/index.d.ts | 4 + dist/index.js | 2 + dist/index.js.map | 2 +- dist/modules/email/graphAdapter.d.ts | 25 +++ dist/modules/email/graphAdapter.js | 174 +++++++++++++++++ dist/modules/email/graphAdapter.js.map | 1 + dist/modules/email/index.d.ts | 4 + dist/modules/email/index.js | 2 + dist/modules/email/index.js.map | 2 +- dist/modules/email/mailAdapter.d.ts | 28 +++ dist/modules/email/mailAdapter.js | 58 ++++++ dist/modules/email/mailAdapter.js.map | 1 + .../modules/email/test/testEmailEndpoint.d.ts | 15 ++ dist/modules/email/test/testEmailEndpoint.js | 74 +++++++ .../email/test/testEmailEndpoint.js.map | 1 + dist/plugin.js | 13 +- dist/plugin.js.map | 2 +- docs/README.md | 4 +- docs/email.md | 84 +++++++- docs/forms.md | 57 +++++- docs/notifications.md | 69 +++++++ docs/secuirt.md | 64 ++++++ docs/slug.md | 7 + src/exports/client.ts | 1 + .../components/TestEmailButton.tsx | 85 ++++++++ src/globals/SiteIntegrations/fields/smtp.ts | 31 +++ src/index.ts | 4 + src/modules/email/graphAdapter.ts | 182 ++++++++++++++++++ src/modules/email/index.ts | 4 + src/modules/email/mailAdapter.ts | 88 +++++++++ src/modules/email/test/testEmailEndpoint.ts | 68 +++++++ src/plugin.ts | 13 +- 40 files changed, 1336 insertions(+), 13 deletions(-) create mode 100644 dist/globals/SiteIntegrations/components/TestEmailButton.d.ts create mode 100644 dist/globals/SiteIntegrations/components/TestEmailButton.js create mode 100644 dist/globals/SiteIntegrations/components/TestEmailButton.js.map create mode 100644 dist/modules/email/graphAdapter.d.ts create mode 100644 dist/modules/email/graphAdapter.js create mode 100644 dist/modules/email/graphAdapter.js.map create mode 100644 dist/modules/email/mailAdapter.d.ts create mode 100644 dist/modules/email/mailAdapter.js create mode 100644 dist/modules/email/mailAdapter.js.map create mode 100644 dist/modules/email/test/testEmailEndpoint.d.ts create mode 100644 dist/modules/email/test/testEmailEndpoint.js create mode 100644 dist/modules/email/test/testEmailEndpoint.js.map create mode 100644 docs/notifications.md create mode 100644 docs/secuirt.md create mode 100644 src/globals/SiteIntegrations/components/TestEmailButton.tsx create mode 100644 src/modules/email/graphAdapter.ts create mode 100644 src/modules/email/mailAdapter.ts create mode 100644 src/modules/email/test/testEmailEndpoint.ts diff --git a/dist/exports/client.d.ts b/dist/exports/client.d.ts index 777b699..7734126 100644 --- a/dist/exports/client.d.ts +++ b/dist/exports/client.d.ts @@ -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 diff --git a/dist/exports/client.js b/dist/exports/client.js index 650fbca..57e793b 100644 --- a/dist/exports/client.js +++ b/dist/exports/client.js @@ -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 diff --git a/dist/exports/client.js.map b/dist/exports/client.js.map index b3442a3..81f5c85 100644 --- a/dist/exports/client.js.map +++ b/dist/exports/client.js.map @@ -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"} \ No newline at end of file +{"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"],"names":["MaskedField","TestEmailButton","Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","Turnstile"],"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"} \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/components/TestEmailButton.d.ts b/dist/globals/SiteIntegrations/components/TestEmailButton.d.ts new file mode 100644 index 0000000..ceedc7e --- /dev/null +++ b/dist/globals/SiteIntegrations/components/TestEmailButton.d.ts @@ -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; diff --git a/dist/globals/SiteIntegrations/components/TestEmailButton.js b/dist/globals/SiteIntegrations/components/TestEmailButton.js new file mode 100644 index 0000000..7d7d47a --- /dev/null +++ b/dist/globals/SiteIntegrations/components/TestEmailButton.js @@ -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: "you@example.com", + 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 \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/components/TestEmailButton.js.map b/dist/globals/SiteIntegrations/components/TestEmailButton.js.map new file mode 100644 index 0000000..cbcccf7 --- /dev/null +++ b/dist/globals/SiteIntegrations/components/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
\n \n

\n Sends through the transport selected above. Save your changes first.\n

\n
\n setTo(e.target.value)}\n placeholder=\"you@example.com\"\n style={{ flex: 1, minWidth: '220px' }}\n type=\"email\"\n value={to}\n />\n \n {status.kind === 'sending' ? 'Sending…' : 'Send test'}\n \n
\n {status.kind === 'ok' && (\n

\n ✓ {status.msg}\n

\n )}\n {status.kind === 'error' && (\n

\n ✗ {status.msg}\n

\n )}\n
\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"} \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/fields/smtp.js b/dist/globals/SiteIntegrations/fields/smtp.js index d05d5fb..91051db 100644 --- a/dist/globals/SiteIntegrations/fields/smtp.js +++ b/dist/globals/SiteIntegrations/fields/smtp.js @@ -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' + } + } } ]; diff --git a/dist/globals/SiteIntegrations/fields/smtp.js.map b/dist/globals/SiteIntegrations/fields/smtp.js.map index 0ba2f09..8f94911 100644 --- a/dist/globals/SiteIntegrations/fields/smtp.js.map +++ b/dist/globals/SiteIntegrations/fields/smtp.js.map @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/dist/index.d.ts b/dist/index.d.ts index d0152c2..a2ce705 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -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'; diff --git a/dist/index.js b/dist/index.js index 2a7c0de..5c62ff9 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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. diff --git a/dist/index.js.map b/dist/index.js.map index 53c528f..3de6f31 100644 --- a/dist/index.js.map +++ b/dist/index.js.map @@ -1 +1 @@ -{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export type { AccessOption, Role } from './modules/access/index.js'\nexport {\n adminOnly,\n adminOnlyField,\n adminOrEditor,\n adminOrEditorField,\n adminOrSelf,\n authenticated,\n hasMinimumRole,\n isAdmin,\n isEditor,\n requireRole,\n requireRoleField,\n ROLE_HIERARCHY,\n} from './modules/access/index.js'\nexport type { AnalyticsConfig } from './modules/analytics/index.js'\nexport { getAnalyticsConfig } from './modules/analytics/index.js'\nexport {\n ACCEPT_ALL_CONSENT,\n CONSENT_CATEGORIES,\n CONSENT_COOKIE,\n CONSENT_MAX_AGE,\n CONSENT_VERSION,\n DEFAULT_CONSENT,\n getConsentTexts,\n parseConsent,\n REJECT_ALL_CONSENT,\n serializeConsent,\n setDefaultConsent,\n updateConsent,\n} from './modules/consent/index.js'\nexport type { ConsentCategory, ConsentState, ConsentTexts } from './modules/consent/index.js'\nexport type {\n ContentCollectionOption,\n ContentOption,\n ResolvedRoute,\n} from './modules/content/index.js'\nexport {\n archiveFieldName,\n buildArchivePath,\n buildEntryPath,\n getArchiveEntries,\n parsePageParam,\n resolveRoute,\n} from './modules/content/index.js'\nexport type { ArchiveEntries } from './modules/content/index.js'\n// Imported straight from the file, NOT from ./modules/email/index.js — that\n// barrel re-exports sendEmail, which imports 'server-only' and would crash when\n// Payload loads the config (or runs generate:importmap) as a plain Node script.\nexport { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js'\nexport type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js'\nexport { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'\nexport type {\n FormsCollectionOverrides,\n FormsFieldsOverride,\n FormsOption,\n} from './modules/forms/types.js'\nexport { createContentHelpers } from './modules/frontend/index.js'\nexport type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18n/index.js'\nexport {\n buildLocalizedPath,\n getDefaultLocale,\n getLocaleCodes,\n getLocaleDefinition,\n getLocalizedSlugs,\n isValidLocale,\n LOCALE_COOKIE_NAME,\n matchAcceptLanguage,\n negotiateLocale,\n switchLocalePath,\n} from './modules/i18n/index.js'\nexport type { LocaleMiddlewareResult } from './modules/i18n/index.js'\nexport { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'\nexport type { PagesOption, SystemPageRole } from './modules/pages/index.js'\nexport { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'\nexport type { GlobalQueryOptions } from './modules/payload/index.js'\nexport {\n getGlobal,\n getSiteIntegrations,\n getSiteSettings,\n SITE_INTEGRATIONS_SLUG,\n SITE_SETTINGS_SLUG,\n} from './modules/payload/index.js'\nexport { buildSecurityHeaders } from './modules/security/index.js'\nexport type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'\nexport type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'\nexport { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'\nexport type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'\nexport {\n buildAutoFillMetaHook,\n buildRobots,\n buildSitemapEntries,\n createMetadataGenerator,\n createPageMetadata,\n injectAutoFillMeta,\n} from './modules/seo/index.js'\nexport { buildSlugField, toSlug } from './modules/slug/index.js'\n\nexport { ipalKit } from './plugin.js'\nexport type { IpalOptions } from './types.js'\n\n\n"],"names":["adminOnly","adminOnlyField","adminOrEditor","adminOrEditorField","adminOrSelf","authenticated","hasMinimumRole","isAdmin","isEditor","requireRole","requireRoleField","ROLE_HIERARCHY","getAnalyticsConfig","ACCEPT_ALL_CONSENT","CONSENT_CATEGORIES","CONSENT_COOKIE","CONSENT_MAX_AGE","CONSENT_VERSION","DEFAULT_CONSENT","getConsentTexts","parseConsent","REJECT_ALL_CONSENT","serializeConsent","setDefaultConsent","updateConsent","archiveFieldName","buildArchivePath","buildEntryPath","getArchiveEntries","parsePageParam","resolveRoute","panelSmtpAdapter","buildFormsPlugin","createContentHelpers","buildLocalizedPath","getDefaultLocale","getLocaleCodes","getLocaleDefinition","getLocalizedSlugs","isValidLocale","LOCALE_COOKIE_NAME","matchAcceptLanguage","negotiateLocale","switchLocalePath","createLocaleMiddleware","DEFAULT_MIDDLEWARE_MATCHER","ALL_SYSTEM_PAGE_ROLES","getSystemPagePath","getGlobal","getSiteIntegrations","getSiteSettings","SITE_INTEGRATIONS_SLUG","SITE_SETTINGS_SLUG","buildSecurityHeaders","buildHreflangAlternates","buildMetadata","composeTitle","buildAutoFillMetaHook","buildRobots","buildSitemapEntries","createMetadataGenerator","createPageMetadata","injectAutoFillMeta","buildSlugField","toSlug","ipalKit"],"mappings":"AACA,SACEA,SAAS,EACTC,cAAc,EACdC,aAAa,EACbC,kBAAkB,EAClBC,WAAW,EACXC,aAAa,EACbC,cAAc,EACdC,OAAO,EACPC,QAAQ,EACRC,WAAW,EACXC,gBAAgB,EAChBC,cAAc,QACT,4BAA2B;AAElC,SAASC,kBAAkB,QAAQ,+BAA8B;AACjE,SACEC,kBAAkB,EAClBC,kBAAkB,EAClBC,cAAc,EACdC,eAAe,EACfC,eAAe,EACfC,eAAe,EACfC,eAAe,EACfC,YAAY,EACZC,kBAAkB,EAClBC,gBAAgB,EAChBC,iBAAiB,EACjBC,aAAa,QACR,6BAA4B;AAOnC,SACEC,gBAAgB,EAChBC,gBAAgB,EAChBC,cAAc,EACdC,iBAAiB,EACjBC,cAAc,EACdC,YAAY,QACP,6BAA4B;AAEnC,4EAA4E;AAC5E,gFAAgF;AAChF,gFAAgF;AAChF,SAASC,gBAAgB,QAAQ,sCAAqC;AAEtE,SAASC,gBAAgB,QAAQ,uCAAsC;AAMvE,SAASC,oBAAoB,QAAQ,8BAA6B;AAElE,SACEC,kBAAkB,EAClBC,gBAAgB,EAChBC,cAAc,EACdC,mBAAmB,EACnBC,iBAAiB,EACjBC,aAAa,EACbC,kBAAkB,EAClBC,mBAAmB,EACnBC,eAAe,EACfC,gBAAgB,QACX,0BAAyB;AAEhC,SAASC,sBAAsB,EAAEC,0BAA0B,QAAQ,0BAAyB;AAE5F,SAASC,qBAAqB,EAAEC,iBAAiB,QAAQ,2BAA0B;AAEnF,SACEC,SAAS,EACTC,mBAAmB,EACnBC,eAAe,EACfC,sBAAsB,EACtBC,kBAAkB,QACb,6BAA4B;AACnC,SAASC,oBAAoB,QAAQ,8BAA6B;AAGlE,SAASC,uBAAuB,EAAEC,aAAa,EAAEC,YAAY,QAAQ,yBAAwB;AAE7F,SACEC,qBAAqB,EACrBC,WAAW,EACXC,mBAAmB,EACnBC,uBAAuB,EACvBC,kBAAkB,EAClBC,kBAAkB,QACb,yBAAwB;AAC/B,SAASC,cAAc,EAAEC,MAAM,QAAQ,0BAAyB;AAEhE,SAASC,OAAO,QAAQ,cAAa"} \ No newline at end of file +{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export type { AccessOption, Role } from './modules/access/index.js'\nexport {\n adminOnly,\n adminOnlyField,\n adminOrEditor,\n adminOrEditorField,\n adminOrSelf,\n authenticated,\n hasMinimumRole,\n isAdmin,\n isEditor,\n requireRole,\n requireRoleField,\n ROLE_HIERARCHY,\n} from './modules/access/index.js'\nexport type { AnalyticsConfig } from './modules/analytics/index.js'\nexport { getAnalyticsConfig } from './modules/analytics/index.js'\nexport {\n ACCEPT_ALL_CONSENT,\n CONSENT_CATEGORIES,\n CONSENT_COOKIE,\n CONSENT_MAX_AGE,\n CONSENT_VERSION,\n DEFAULT_CONSENT,\n getConsentTexts,\n parseConsent,\n REJECT_ALL_CONSENT,\n serializeConsent,\n setDefaultConsent,\n updateConsent,\n} from './modules/consent/index.js'\nexport type { ConsentCategory, ConsentState, ConsentTexts } from './modules/consent/index.js'\nexport type {\n ContentCollectionOption,\n ContentOption,\n ResolvedRoute,\n} from './modules/content/index.js'\nexport {\n archiveFieldName,\n buildArchivePath,\n buildEntryPath,\n getArchiveEntries,\n parsePageParam,\n resolveRoute,\n} from './modules/content/index.js'\nexport type { ArchiveEntries } from './modules/content/index.js'\nexport { graphAdapter } from './modules/email/graphAdapter.js'\nexport type { GraphAdapterArgs } from './modules/email/graphAdapter.js'\nexport { mailAdapter } from './modules/email/mailAdapter.js'\nexport type { MailAdapterArgs } from './modules/email/mailAdapter.js'\n// Imported straight from the file, NOT from ./modules/email/index.js — that\n// barrel re-exports sendEmail, which imports 'server-only' and would crash when\n// Payload loads the config (or runs generate:importmap) as a plain Node script.\nexport { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js'\nexport type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js'\nexport { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'\nexport type {\n FormsCollectionOverrides,\n FormsFieldsOverride,\n FormsOption,\n} from './modules/forms/types.js'\nexport { createContentHelpers } from './modules/frontend/index.js'\nexport type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18n/index.js'\nexport {\n buildLocalizedPath,\n getDefaultLocale,\n getLocaleCodes,\n getLocaleDefinition,\n getLocalizedSlugs,\n isValidLocale,\n LOCALE_COOKIE_NAME,\n matchAcceptLanguage,\n negotiateLocale,\n switchLocalePath,\n} from './modules/i18n/index.js'\nexport type { LocaleMiddlewareResult } from './modules/i18n/index.js'\nexport { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'\nexport type { PagesOption, SystemPageRole } from './modules/pages/index.js'\nexport { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'\nexport type { GlobalQueryOptions } from './modules/payload/index.js'\nexport {\n getGlobal,\n getSiteIntegrations,\n getSiteSettings,\n SITE_INTEGRATIONS_SLUG,\n SITE_SETTINGS_SLUG,\n} from './modules/payload/index.js'\nexport { buildSecurityHeaders } from './modules/security/index.js'\nexport type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'\nexport type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'\nexport { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'\nexport type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'\nexport {\n buildAutoFillMetaHook,\n buildRobots,\n buildSitemapEntries,\n createMetadataGenerator,\n createPageMetadata,\n injectAutoFillMeta,\n} from './modules/seo/index.js'\nexport { buildSlugField, toSlug } from './modules/slug/index.js'\n\nexport { ipalKit } from './plugin.js'\nexport type { IpalOptions } from './types.js'\n\n\n"],"names":["adminOnly","adminOnlyField","adminOrEditor","adminOrEditorField","adminOrSelf","authenticated","hasMinimumRole","isAdmin","isEditor","requireRole","requireRoleField","ROLE_HIERARCHY","getAnalyticsConfig","ACCEPT_ALL_CONSENT","CONSENT_CATEGORIES","CONSENT_COOKIE","CONSENT_MAX_AGE","CONSENT_VERSION","DEFAULT_CONSENT","getConsentTexts","parseConsent","REJECT_ALL_CONSENT","serializeConsent","setDefaultConsent","updateConsent","archiveFieldName","buildArchivePath","buildEntryPath","getArchiveEntries","parsePageParam","resolveRoute","graphAdapter","mailAdapter","panelSmtpAdapter","buildFormsPlugin","createContentHelpers","buildLocalizedPath","getDefaultLocale","getLocaleCodes","getLocaleDefinition","getLocalizedSlugs","isValidLocale","LOCALE_COOKIE_NAME","matchAcceptLanguage","negotiateLocale","switchLocalePath","createLocaleMiddleware","DEFAULT_MIDDLEWARE_MATCHER","ALL_SYSTEM_PAGE_ROLES","getSystemPagePath","getGlobal","getSiteIntegrations","getSiteSettings","SITE_INTEGRATIONS_SLUG","SITE_SETTINGS_SLUG","buildSecurityHeaders","buildHreflangAlternates","buildMetadata","composeTitle","buildAutoFillMetaHook","buildRobots","buildSitemapEntries","createMetadataGenerator","createPageMetadata","injectAutoFillMeta","buildSlugField","toSlug","ipalKit"],"mappings":"AACA,SACEA,SAAS,EACTC,cAAc,EACdC,aAAa,EACbC,kBAAkB,EAClBC,WAAW,EACXC,aAAa,EACbC,cAAc,EACdC,OAAO,EACPC,QAAQ,EACRC,WAAW,EACXC,gBAAgB,EAChBC,cAAc,QACT,4BAA2B;AAElC,SAASC,kBAAkB,QAAQ,+BAA8B;AACjE,SACEC,kBAAkB,EAClBC,kBAAkB,EAClBC,cAAc,EACdC,eAAe,EACfC,eAAe,EACfC,eAAe,EACfC,eAAe,EACfC,YAAY,EACZC,kBAAkB,EAClBC,gBAAgB,EAChBC,iBAAiB,EACjBC,aAAa,QACR,6BAA4B;AAOnC,SACEC,gBAAgB,EAChBC,gBAAgB,EAChBC,cAAc,EACdC,iBAAiB,EACjBC,cAAc,EACdC,YAAY,QACP,6BAA4B;AAEnC,SAASC,YAAY,QAAQ,kCAAiC;AAE9D,SAASC,WAAW,QAAQ,iCAAgC;AAE5D,4EAA4E;AAC5E,gFAAgF;AAChF,gFAAgF;AAChF,SAASC,gBAAgB,QAAQ,sCAAqC;AAEtE,SAASC,gBAAgB,QAAQ,uCAAsC;AAMvE,SAASC,oBAAoB,QAAQ,8BAA6B;AAElE,SACEC,kBAAkB,EAClBC,gBAAgB,EAChBC,cAAc,EACdC,mBAAmB,EACnBC,iBAAiB,EACjBC,aAAa,EACbC,kBAAkB,EAClBC,mBAAmB,EACnBC,eAAe,EACfC,gBAAgB,QACX,0BAAyB;AAEhC,SAASC,sBAAsB,EAAEC,0BAA0B,QAAQ,0BAAyB;AAE5F,SAASC,qBAAqB,EAAEC,iBAAiB,QAAQ,2BAA0B;AAEnF,SACEC,SAAS,EACTC,mBAAmB,EACnBC,eAAe,EACfC,sBAAsB,EACtBC,kBAAkB,QACb,6BAA4B;AACnC,SAASC,oBAAoB,QAAQ,8BAA6B;AAGlE,SAASC,uBAAuB,EAAEC,aAAa,EAAEC,YAAY,QAAQ,yBAAwB;AAE7F,SACEC,qBAAqB,EACrBC,WAAW,EACXC,mBAAmB,EACnBC,uBAAuB,EACvBC,kBAAkB,EAClBC,kBAAkB,QACb,yBAAwB;AAC/B,SAASC,cAAc,EAAEC,MAAM,QAAQ,0BAAyB;AAEhE,SAASC,OAAO,QAAQ,cAAa"} \ No newline at end of file diff --git a/dist/modules/email/graphAdapter.d.ts b/dist/modules/email/graphAdapter.d.ts new file mode 100644 index 0000000..2e91242 --- /dev/null +++ b/dist/modules/email/graphAdapter.d.ts @@ -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. forms@intecion.pl) 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; diff --git a/dist/modules/email/graphAdapter.js b/dist/modules/email/graphAdapter.js new file mode 100644 index 0000000..dc9b3ea --- /dev/null +++ b/dist/modules/email/graphAdapter.js @@ -0,0 +1,174 @@ +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. forms@intecion.pl) 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 + }; + } + // From-display comes from the panel; falls back to the caller's from. + const panel = await getSiteIntegrations(payload); + const fromAddress = panel.smtpFromAddress || undefined; + const fromName = panel.smtpFromName || undefined; + const to = toRecipients(message.to); + if (to.length === 0) { + payload.logger.error('[ipal] Email not sent: no valid recipient.'); + return { + error: 'No valid recipient.', + sent: false + }; + } + // Graph accepts either HTML or Text; Payload gives us html and/or text. + const isHtml = typeof message.html === 'string' && message.html.length > 0; + const content = isHtml ? String(message.html) : String(message.text ?? ''); + const graphMessage = { + body: { + content, + contentType: isHtml ? 'HTML' : 'Text' + }, + subject: message.subject ?? '', + toRecipients: to, + ...message.cc ? { + ccRecipients: toRecipients(message.cc) + } : {}, + ...message.bcc ? { + bccRecipients: toRecipients(message.bcc) + } : {}, + // from is only honoured if the app has Send-As for that address; when + // it's the shared mailbox itself, omit it and Graph uses the sender. + ...fromAddress ? { + from: { + emailAddress: { + address: fromAddress, + ...fromName ? { + name: fromName + } : {} + } + } + } : {}, + // replyTo lets the recipient reply to the real submitter if the caller set it. + ...message.replyTo ? { + replyTo: toRecipients(message.replyTo) + } : {} + }; + 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 \ No newline at end of file diff --git a/dist/modules/email/graphAdapter.js.map b/dist/modules/email/graphAdapter.js.map new file mode 100644 index 0000000..c584f44 --- /dev/null +++ b/dist/modules/email/graphAdapter.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/email/graphAdapter.ts"],"sourcesContent":["import type { PayloadEmailAdapter, SendEmailOptions } from 'payload'\n\nimport { getSiteIntegrations } from '../payload/index.js'\n\n/**\n * From/To settings the adapter reads from SiteIntegrations (panel). The Graph\n * CREDENTIALS themselves are NOT here — they're agency secrets in env vars\n * (this is *our* Exchange, shared across projects), read below from process.env.\n * The panel only controls the display-from and where submissions land.\n */\ntype GraphIntegrations = {\n /**\n * Display From — reused from the existing SMTP fields, because the sender\n * label is the same concept regardless of transport (SMTP or Graph). No new\n * panel field needed; whatever the editor set as the from-address applies.\n */\n smtpFromAddress?: null | string\n smtpFromName?: null | string\n}\n\nexport type GraphAdapterArgs = {\n fallbackFromAddress?: string\n fallbackFromName?: string\n}\n\ntype GraphEnv = {\n clientId: string\n clientSecret: string\n sender: string\n tenantId: string\n}\n\n/** Reads + validates the agency Graph credentials from env. */\nfunction readGraphEnv(): GraphEnv | null {\n const tenantId = process.env.GRAPH_TENANT_ID\n const clientId = process.env.GRAPH_CLIENT_ID\n const clientSecret = process.env.GRAPH_CLIENT_SECRET\n const sender = process.env.GRAPH_SENDER\n if (!tenantId || !clientId || !clientSecret || !sender) {return null}\n return { clientId, clientSecret, sender, tenantId }\n}\n\n/**\n * Fetches an app-only access token via the OAuth2 client-credentials flow.\n * Scope MUST be '.../.default' — passing 'Mail.Send' directly is rejected\n * (AADSTS1002012). Tokens last ~1h; we fetch per send for simplicity and to\n * avoid holding state in a possibly multi-instance deployment. If you send at\n * high volume, cache by expiry.\n */\nasync function getAccessToken(env: GraphEnv): Promise {\n const url = `https://login.microsoftonline.com/${env.tenantId}/oauth2/v2.0/token`\n const body = new URLSearchParams({\n client_id: env.clientId,\n client_secret: env.clientSecret,\n grant_type: 'client_credentials',\n scope: 'https://graph.microsoft.com/.default',\n })\n\n const res = await fetch(url, {\n body,\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n method: 'POST',\n })\n if (!res.ok) {\n const detail = await res.text()\n throw new Error(`Graph token request failed (${res.status}): ${detail}`)\n }\n const data = (await res.json()) as { access_token?: string }\n if (!data.access_token) {throw new Error('Graph token response had no access_token')}\n return data.access_token\n}\n\n/** Normalizes Payload's to/cc (string | string[] | Address[]) into Graph recipients. */\nfunction toRecipients(value: SendEmailOptions['to']): { emailAddress: { address: string } }[] {\n if (!value) {return []}\n const list = Array.isArray(value) ? value : [value]\n return list\n .map((v) => (typeof v === 'string' ? v : (v as { address?: string }).address))\n .filter((a): a is string => typeof a === 'string' && a.length > 0)\n .map((address) => ({ emailAddress: { address } }))\n}\n\n/**\n * Payload email adapter that sends through Microsoft Graph (our Exchange),\n * using app-only client-credentials auth. Drop-in alternative to\n * panelSmtpAdapter — same PayloadEmailAdapter contract, so payload.sendEmail\n * and the form-builder's submission emails work unchanged.\n *\n * Split of configuration (deliberate):\n * - Graph credentials (tenant/client/secret/sender) = AGENCY secrets, from env.\n * The client never sees or sets them — it's our Exchange, one mailbox\n * (GRAPH_SENDER, e.g. forms@intecion.pl) for every project.\n * - From-display + recipient = per-project, from the panel (SiteIntegrations),\n * so an editor controls how the mail is labelled and where it lands.\n *\n * Wiring: email: process.env.GRAPH_CLIENT_ID ? graphAdapter() : panelSmtpAdapter()\n *\n * Azure setup (one-time, our side): App registration → Mail.Send APPLICATION\n * permission → admin consent → in Exchange, grant the app \"Send As\" on the\n * shared mailbox GRAPH_SENDER.\n */\nexport const graphAdapter =\n (args: GraphAdapterArgs = {}): PayloadEmailAdapter =>\n ({ payload }) => ({\n name: 'ipal-graph',\n defaultFromAddress: args.fallbackFromAddress ?? 'noreply@localhost',\n defaultFromName: args.fallbackFromName ?? 'Website',\n\n sendEmail: async (message: SendEmailOptions) => {\n const env = readGraphEnv()\n if (!env) {\n payload.logger.error(\n '[ipal] Email not sent: Graph is not configured. Set GRAPH_TENANT_ID, GRAPH_CLIENT_ID, GRAPH_CLIENT_SECRET, GRAPH_SENDER.',\n )\n return { error: 'Graph is not configured (missing env vars).', sent: false }\n }\n\n // From-display comes from the panel; falls back to the caller's from.\n const panel = await getSiteIntegrations(payload)\n const fromAddress = panel.smtpFromAddress || undefined\n const fromName = panel.smtpFromName || undefined\n\n const to = toRecipients(message.to)\n if (to.length === 0) {\n payload.logger.error('[ipal] Email not sent: no valid recipient.')\n return { error: 'No valid recipient.', sent: false }\n }\n\n // Graph accepts either HTML or Text; Payload gives us html and/or text.\n const isHtml = typeof message.html === 'string' && message.html.length > 0\n const content = isHtml ? String(message.html) : String(message.text ?? '')\n\n const graphMessage: Record = {\n body: { content, contentType: isHtml ? 'HTML' : 'Text' },\n subject: message.subject ?? '',\n toRecipients: to,\n ...(message.cc ? { ccRecipients: toRecipients(message.cc) } : {}),\n ...(message.bcc ? { bccRecipients: toRecipients(message.bcc) } : {}),\n // from is only honoured if the app has Send-As for that address; when\n // it's the shared mailbox itself, omit it and Graph uses the sender.\n ...(fromAddress\n ? {\n from: {\n emailAddress: { address: fromAddress, ...(fromName ? { name: fromName } : {}) },\n },\n }\n : {}),\n // replyTo lets the recipient reply to the real submitter if the caller set it.\n ...(message.replyTo\n ? { replyTo: toRecipients(message.replyTo as SendEmailOptions['to']) }\n : {}),\n }\n\n try {\n const token = await getAccessToken(env)\n // App-only: MUST target /users/{sender}, never /me.\n const res = await fetch(\n `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(env.sender)}/sendMail`,\n {\n body: JSON.stringify({ message: graphMessage, saveToSentItems: false }),\n headers: {\n Authorization: `Bearer ${token}`,\n 'Content-Type': 'application/json',\n },\n method: 'POST',\n },\n )\n\n // sendMail returns 202 Accepted with an empty body on success.\n if (res.status === 202) {\n return { sent: true }\n }\n const detail = await res.text()\n payload.logger.error(`[ipal] Graph sendMail failed (${res.status}): ${detail}`)\n return { error: `Graph sendMail failed (${res.status}).`, sent: false }\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err)\n payload.logger.error(`[ipal] Graph send error: ${msg}`)\n return { error: 'Graph send error.', sent: false }\n }\n },\n })\n"],"names":["getSiteIntegrations","readGraphEnv","tenantId","process","env","GRAPH_TENANT_ID","clientId","GRAPH_CLIENT_ID","clientSecret","GRAPH_CLIENT_SECRET","sender","GRAPH_SENDER","getAccessToken","url","body","URLSearchParams","client_id","client_secret","grant_type","scope","res","fetch","headers","method","ok","detail","text","Error","status","data","json","access_token","toRecipients","value","list","Array","isArray","map","v","address","filter","a","length","emailAddress","graphAdapter","args","payload","name","defaultFromAddress","fallbackFromAddress","defaultFromName","fallbackFromName","sendEmail","message","logger","error","sent","panel","fromAddress","smtpFromAddress","undefined","fromName","smtpFromName","to","isHtml","html","content","String","graphMessage","contentType","subject","cc","ccRecipients","bcc","bccRecipients","from","replyTo","token","encodeURIComponent","JSON","stringify","saveToSentItems","Authorization","err","msg"],"mappings":"AAEA,SAASA,mBAAmB,QAAQ,sBAAqB;AA8BzD,6DAA6D,GAC7D,SAASC;IACP,MAAMC,WAAWC,QAAQC,GAAG,CAACC,eAAe;IAC5C,MAAMC,WAAWH,QAAQC,GAAG,CAACG,eAAe;IAC5C,MAAMC,eAAeL,QAAQC,GAAG,CAACK,mBAAmB;IACpD,MAAMC,SAASP,QAAQC,GAAG,CAACO,YAAY;IACvC,IAAI,CAACT,YAAY,CAACI,YAAY,CAACE,gBAAgB,CAACE,QAAQ;QAAC,OAAO;IAAI;IACpE,OAAO;QAAEJ;QAAUE;QAAcE;QAAQR;IAAS;AACpD;AAEA;;;;;;CAMC,GACD,eAAeU,eAAeR,GAAa;IACzC,MAAMS,MAAM,CAAC,kCAAkC,EAAET,IAAIF,QAAQ,CAAC,kBAAkB,CAAC;IACjF,MAAMY,OAAO,IAAIC,gBAAgB;QAC/BC,WAAWZ,IAAIE,QAAQ;QACvBW,eAAeb,IAAII,YAAY;QAC/BU,YAAY;QACZC,OAAO;IACT;IAEA,MAAMC,MAAM,MAAMC,MAAMR,KAAK;QAC3BC;QACAQ,SAAS;YAAE,gBAAgB;QAAoC;QAC/DC,QAAQ;IACV;IACA,IAAI,CAACH,IAAII,EAAE,EAAE;QACX,MAAMC,SAAS,MAAML,IAAIM,IAAI;QAC7B,MAAM,IAAIC,MAAM,CAAC,4BAA4B,EAAEP,IAAIQ,MAAM,CAAC,GAAG,EAAEH,QAAQ;IACzE;IACA,MAAMI,OAAQ,MAAMT,IAAIU,IAAI;IAC5B,IAAI,CAACD,KAAKE,YAAY,EAAE;QAAC,MAAM,IAAIJ,MAAM;IAA2C;IACpF,OAAOE,KAAKE,YAAY;AAC1B;AAEA,sFAAsF,GACtF,SAASC,aAAaC,KAA6B;IACjD,IAAI,CAACA,OAAO;QAAC,OAAO,EAAE;IAAA;IACtB,MAAMC,OAAOC,MAAMC,OAAO,CAACH,SAASA,QAAQ;QAACA;KAAM;IACnD,OAAOC,KACJG,GAAG,CAAC,CAACC,IAAO,OAAOA,MAAM,WAAWA,IAAI,AAACA,EAA2BC,OAAO,EAC3EC,MAAM,CAAC,CAACC,IAAmB,OAAOA,MAAM,YAAYA,EAAEC,MAAM,GAAG,GAC/DL,GAAG,CAAC,CAACE,UAAa,CAAA;YAAEI,cAAc;gBAAEJ;YAAQ;QAAE,CAAA;AACnD;AAEA;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,MAAMK,eACX,CAACC,OAAyB,CAAC,CAAC,GAC5B,CAAC,EAAEC,OAAO,EAAE,GAAM,CAAA;YAChBC,MAAM;YACNC,oBAAoBH,KAAKI,mBAAmB,IAAI;YAChDC,iBAAiBL,KAAKM,gBAAgB,IAAI;YAE1CC,WAAW,OAAOC;gBAChB,MAAMjD,MAAMH;gBACZ,IAAI,CAACG,KAAK;oBACR0C,QAAQQ,MAAM,CAACC,KAAK,CAClB;oBAEF,OAAO;wBAAEA,OAAO;wBAA+CC,MAAM;oBAAM;gBAC7E;gBAEA,sEAAsE;gBACtE,MAAMC,QAAQ,MAAMzD,oBAAuC8C;gBAC3D,MAAMY,cAAcD,MAAME,eAAe,IAAIC;gBAC7C,MAAMC,WAAWJ,MAAMK,YAAY,IAAIF;gBAEvC,MAAMG,KAAK/B,aAAaqB,QAAQU,EAAE;gBAClC,IAAIA,GAAGrB,MAAM,KAAK,GAAG;oBACnBI,QAAQQ,MAAM,CAACC,KAAK,CAAC;oBACrB,OAAO;wBAAEA,OAAO;wBAAuBC,MAAM;oBAAM;gBACrD;gBAEA,wEAAwE;gBACxE,MAAMQ,SAAS,OAAOX,QAAQY,IAAI,KAAK,YAAYZ,QAAQY,IAAI,CAACvB,MAAM,GAAG;gBACzE,MAAMwB,UAAUF,SAASG,OAAOd,QAAQY,IAAI,IAAIE,OAAOd,QAAQ3B,IAAI,IAAI;gBAEvE,MAAM0C,eAAwC;oBAC5CtD,MAAM;wBAAEoD;wBAASG,aAAaL,SAAS,SAAS;oBAAO;oBACvDM,SAASjB,QAAQiB,OAAO,IAAI;oBAC5BtC,cAAc+B;oBACd,GAAIV,QAAQkB,EAAE,GAAG;wBAAEC,cAAcxC,aAAaqB,QAAQkB,EAAE;oBAAE,IAAI,CAAC,CAAC;oBAChE,GAAIlB,QAAQoB,GAAG,GAAG;wBAAEC,eAAe1C,aAAaqB,QAAQoB,GAAG;oBAAE,IAAI,CAAC,CAAC;oBACnE,sEAAsE;oBACtE,qEAAqE;oBACrE,GAAIf,cACA;wBACEiB,MAAM;4BACJhC,cAAc;gCAAEJ,SAASmB;gCAAa,GAAIG,WAAW;oCAAEd,MAAMc;gCAAS,IAAI,CAAC,CAAC;4BAAE;wBAChF;oBACF,IACA,CAAC,CAAC;oBACN,+EAA+E;oBAC/E,GAAIR,QAAQuB,OAAO,GACf;wBAAEA,SAAS5C,aAAaqB,QAAQuB,OAAO;oBAA4B,IACnE,CAAC,CAAC;gBACR;gBAEA,IAAI;oBACF,MAAMC,QAAQ,MAAMjE,eAAeR;oBACnC,oDAAoD;oBACpD,MAAMgB,MAAM,MAAMC,MAChB,CAAC,uCAAuC,EAAEyD,mBAAmB1E,IAAIM,MAAM,EAAE,SAAS,CAAC,EACnF;wBACEI,MAAMiE,KAAKC,SAAS,CAAC;4BAAE3B,SAASe;4BAAca,iBAAiB;wBAAM;wBACrE3D,SAAS;4BACP4D,eAAe,CAAC,OAAO,EAAEL,OAAO;4BAChC,gBAAgB;wBAClB;wBACAtD,QAAQ;oBACV;oBAGF,+DAA+D;oBAC/D,IAAIH,IAAIQ,MAAM,KAAK,KAAK;wBACtB,OAAO;4BAAE4B,MAAM;wBAAK;oBACtB;oBACA,MAAM/B,SAAS,MAAML,IAAIM,IAAI;oBAC7BoB,QAAQQ,MAAM,CAACC,KAAK,CAAC,CAAC,8BAA8B,EAAEnC,IAAIQ,MAAM,CAAC,GAAG,EAAEH,QAAQ;oBAC9E,OAAO;wBAAE8B,OAAO,CAAC,uBAAuB,EAAEnC,IAAIQ,MAAM,CAAC,EAAE,CAAC;wBAAE4B,MAAM;oBAAM;gBACxE,EAAE,OAAO2B,KAAK;oBACZ,MAAMC,MAAMD,eAAexD,QAAQwD,IAAI9B,OAAO,GAAGc,OAAOgB;oBACxDrC,QAAQQ,MAAM,CAACC,KAAK,CAAC,CAAC,yBAAyB,EAAE6B,KAAK;oBACtD,OAAO;wBAAE7B,OAAO;wBAAqBC,MAAM;oBAAM;gBACnD;YACF;QACF,CAAA,EAAE"} \ No newline at end of file diff --git a/dist/modules/email/index.d.ts b/dist/modules/email/index.d.ts index 225c347..1e32c7f 100644 --- a/dist/modules/email/index.d.ts +++ b/dist/modules/email/index.d.ts @@ -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'; diff --git a/dist/modules/email/index.js b/dist/modules/email/index.js index 385c3b2..c0b399f 100644 --- a/dist/modules/email/index.js +++ b/dist/modules/email/index.js @@ -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'; diff --git a/dist/modules/email/index.js.map b/dist/modules/email/index.js.map index d62dbdf..7636713 100644 --- a/dist/modules/email/index.js.map +++ b/dist/modules/email/index.js.map @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/dist/modules/email/mailAdapter.d.ts b/dist/modules/email/mailAdapter.d.ts new file mode 100644 index 0000000..2483bc0 --- /dev/null +++ b/dist/modules/email/mailAdapter.d.ts @@ -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; diff --git a/dist/modules/email/mailAdapter.js b/dist/modules/email/mailAdapter.js new file mode 100644 index 0000000..8e90916 --- /dev/null +++ b/dist/modules/email/mailAdapter.js @@ -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 \ No newline at end of file diff --git a/dist/modules/email/mailAdapter.js.map b/dist/modules/email/mailAdapter.js.map new file mode 100644 index 0000000..b514135 --- /dev/null +++ b/dist/modules/email/mailAdapter.js.map @@ -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(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"} \ No newline at end of file diff --git a/dist/modules/email/test/testEmailEndpoint.d.ts b/dist/modules/email/test/testEmailEndpoint.d.ts new file mode 100644 index 0000000..2bc99d3 --- /dev/null +++ b/dist/modules/email/test/testEmailEndpoint.d.ts @@ -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; diff --git a/dist/modules/email/test/testEmailEndpoint.js b/dist/modules/email/test/testEmailEndpoint.js new file mode 100644 index 0000000..7127c6f --- /dev/null +++ b/dist/modules/email/test/testEmailEndpoint.js @@ -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: '

This is a test message from ipal-kit. If you received it, outbound email is configured correctly.

', + 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 \ No newline at end of file diff --git a/dist/modules/email/test/testEmailEndpoint.js.map b/dist/modules/email/test/testEmailEndpoint.js.map new file mode 100644 index 0000000..4b9563e --- /dev/null +++ b/dist/modules/email/test/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: '

This is a test message from ipal-kit. If you received it, outbound email is configured correctly.

',\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"} \ No newline at end of file diff --git a/dist/plugin.js b/dist/plugin.js index fa81ed0..3222581 100644 --- a/dist/plugin.js +++ b/dist/plugin.js @@ -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; diff --git a/dist/plugin.js.map b/dist/plugin.js.map index 915cbe6..a5c8ada 100644 --- a/dist/plugin.js.map +++ b/dist/plugin.js.map @@ -1 +1 @@ -{"version":3,"sources":["../src/plugin.ts"],"sourcesContent":["import type { Config, Plugin } from 'payload'\n\nimport type { IpalOptions } from './types.js'\n\nimport { buildCookieSettings } from './globals/CookieSettings/index.js'\nimport { buildSiteIntegrations } from './globals/SiteIntegrations/index.js'\nimport { buildSiteSettings } from './globals/SiteSettings/index.js'\nimport { injectRoles } from './modules/access/index.js'\nimport { buildArchiveFields } from './modules/content/index.js'\nimport { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'\nimport { buildLocalizationConfig, validateI18nConfig } from './modules/i18n/index.js'\nimport { buildSystemPagesFields } from './modules/pages/index.js'\nimport { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo/index.js'\n\n/**\n * IPAL (Intecion Payload Advanced Library) plugin for Payload CMS 3.\n *\n * @example\n * ```ts\n * import { ipalKit } from 'ipal-kit'\n *\n * export default buildConfig({\n * plugins: [\n * ipalKit({\n * i18n: {\n * locales: [\n * { code: 'pl', label: 'Polski' },\n * { code: 'en', label: 'English' },\n * ],\n * defaultLocale: 'pl',\n * },\n * access: { authCollection: 'users' },\n * }),\n * ],\n * })\n * ```\n */\nexport const ipalKit = (options: IpalOptions): Plugin => {\n // Validate eagerly — fail fast before Payload boots\n validateI18nConfig(options.i18n)\n\n return async (incomingConfig: Config): Promise => {\n // Early return when disabled — schema stays, behavior off\n if (options.enabled === false) {\n return incomingConfig\n }\n\n let config = { ...incomingConfig }\n\n // --- i18n ---\n config.localization = buildLocalizationConfig(options.i18n)\n\n // --- access: inject roles into the client's auth collection ---\n if (options.access) {\n config = injectRoles(config, options.access)\n }\n\n // --- seo: apply @payloadcms/plugin-seo directly ---\n // NOTE: apply the plugin function to the config immediately rather than\n // pushing it onto config.plugins. Payload has already iterated the plugins\n // array by the time IPAL runs, so nested plugins added to that list are\n // never executed. Calling the plugin as (config) => config applies its\n // transform now.\n if (options.seo) {\n config = await buildSeoPlugin({ seo: options.seo })(config)\n // Auto-fill empty meta from document content on save\n config = injectAutoFillMeta(config, options.seo)\n // Wrap fields into Content + SEO tabs (replaces plugin-seo's tabbedUI,\n // which breaks when other fields already exist in the collection)\n config = injectSeoTabs(config, options.seo)\n }\n\n // --- forms: apply @payloadcms/plugin-form-builder directly ---\n if (options.forms) {\n config = await buildFormsPlugin(options.forms)(config)\n }\n\n // --- globals ---\n // The System Pages tab collects every \"which page plays this role\"\n // assignment. Composing it here keeps SiteSettings unaware of which modules\n // are enabled — it just renders the fields it's given.\n const systemPageFields = [\n ...(options.pages ? buildSystemPagesFields(options.pages) : []),\n ...(options.content && options.pages\n ? buildArchiveFields(options.content, options.pages.slug)\n : []),\n ]\n\n config.globals = [\n ...(config.globals ?? []),\n buildSiteSettings({\n additionalFields: options.siteSettingsFields,\n systemPageFields,\n }),\n buildSiteIntegrations({ additionalFields: options.integrationsFields }),\n buildCookieSettings(),\n ]\n\n // --- hooks: onInit ---\n const incomingOnInit = config.onInit\n config.onInit = async (payload) => {\n if (incomingOnInit) {await incomingOnInit(payload)}\n payload.logger.info('[ipal] Plugin initialized.')\n }\n\n return config\n }\n}\n"],"names":["buildCookieSettings","buildSiteIntegrations","buildSiteSettings","injectRoles","buildArchiveFields","buildFormsPlugin","buildLocalizationConfig","validateI18nConfig","buildSystemPagesFields","buildSeoPlugin","injectAutoFillMeta","injectSeoTabs","ipalKit","options","i18n","incomingConfig","enabled","config","localization","access","seo","forms","systemPageFields","pages","content","slug","globals","additionalFields","siteSettingsFields","integrationsFields","incomingOnInit","onInit","payload","logger","info"],"mappings":"AAIA,SAASA,mBAAmB,QAAQ,oCAAmC;AACvE,SAASC,qBAAqB,QAAQ,sCAAqC;AAC3E,SAASC,iBAAiB,QAAQ,kCAAiC;AACnE,SAASC,WAAW,QAAQ,4BAA2B;AACvD,SAASC,kBAAkB,QAAQ,6BAA4B;AAC/D,SAASC,gBAAgB,QAAQ,uCAAsC;AACvE,SAASC,uBAAuB,EAAEC,kBAAkB,QAAQ,0BAAyB;AACrF,SAASC,sBAAsB,QAAQ,2BAA0B;AACjE,SAASC,cAAc,EAAEC,kBAAkB,EAAEC,aAAa,QAAQ,yBAAwB;AAE1F;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,MAAMC,UAAU,CAACC;IACtB,oDAAoD;IACpDN,mBAAmBM,QAAQC,IAAI;IAE/B,OAAO,OAAOC;QACZ,0DAA0D;QAC1D,IAAIF,QAAQG,OAAO,KAAK,OAAO;YAC7B,OAAOD;QACT;QAEA,IAAIE,SAAS;YAAE,GAAGF,cAAc;QAAC;QAEjC,eAAe;QACfE,OAAOC,YAAY,GAAGZ,wBAAwBO,QAAQC,IAAI;QAE1D,iEAAiE;QACjE,IAAID,QAAQM,MAAM,EAAE;YAClBF,SAASd,YAAYc,QAAQJ,QAAQM,MAAM;QAC7C;QAEA,qDAAqD;QACrD,wEAAwE;QACxE,2EAA2E;QAC3E,wEAAwE;QACxE,uEAAuE;QACvE,iBAAiB;QACjB,IAAIN,QAAQO,GAAG,EAAE;YACfH,SAAS,MAAMR,eAAe;gBAAEW,KAAKP,QAAQO,GAAG;YAAC,GAAGH;YACpD,qDAAqD;YACrDA,SAASP,mBAAmBO,QAAQJ,QAAQO,GAAG;YAC/C,uEAAuE;YACvE,kEAAkE;YAClEH,SAASN,cAAcM,QAAQJ,QAAQO,GAAG;QAC5C;QAEA,gEAAgE;QAChE,IAAIP,QAAQQ,KAAK,EAAE;YACjBJ,SAAS,MAAMZ,iBAAiBQ,QAAQQ,KAAK,EAAEJ;QACjD;QAEA,kBAAkB;QAClB,mEAAmE;QACnE,4EAA4E;QAC5E,uDAAuD;QACvD,MAAMK,mBAAmB;eACnBT,QAAQU,KAAK,GAAGf,uBAAuBK,QAAQU,KAAK,IAAI,EAAE;eAC1DV,QAAQW,OAAO,IAAIX,QAAQU,KAAK,GAChCnB,mBAAmBS,QAAQW,OAAO,EAAEX,QAAQU,KAAK,CAACE,IAAI,IACtD,EAAE;SACP;QAEDR,OAAOS,OAAO,GAAG;eACXT,OAAOS,OAAO,IAAI,EAAE;YACxBxB,kBAAkB;gBAChByB,kBAAkBd,QAAQe,kBAAkB;gBAC5CN;YACF;YACArB,sBAAsB;gBAAE0B,kBAAkBd,QAAQgB,kBAAkB;YAAC;YACrE7B;SACD;QAED,wBAAwB;QACxB,MAAM8B,iBAAiBb,OAAOc,MAAM;QACpCd,OAAOc,MAAM,GAAG,OAAOC;YACrB,IAAIF,gBAAgB;gBAAC,MAAMA,eAAeE;YAAQ;YAClDA,QAAQC,MAAM,CAACC,IAAI,CAAC;QACtB;QAEA,OAAOjB;IACT;AACF,EAAC"} \ No newline at end of file +{"version":3,"sources":["../src/plugin.ts"],"sourcesContent":["import type { Config, Plugin } from 'payload'\n\nimport type { IpalOptions } from './types.js'\n\nimport { buildCookieSettings } from './globals/CookieSettings/index.js'\nimport { buildNotifications } from './globals/Notifications/index.js'\nimport { buildSiteIntegrations } from './globals/SiteIntegrations/index.js'\nimport { buildSiteSettings } from './globals/SiteSettings/index.js'\nimport { injectRoles } from './modules/access/index.js'\nimport { buildArchiveFields } from './modules/content/index.js'\nimport { testEmailEndpoint } from './modules/email/test/testEmailEndpoint.js'\nimport { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'\nimport { buildLocalizationConfig, validateI18nConfig } from './modules/i18n/index.js'\nimport { buildSystemPagesFields } from './modules/pages/index.js'\nimport { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo/index.js'\n\n/**\n * IPAL (Intecion Payload Advanced Library) plugin for Payload CMS 3.\n *\n * @example\n * ```ts\n * import { ipalKit } from 'ipal-kit'\n *\n * export default buildConfig({\n * plugins: [\n * ipalKit({\n * i18n: {\n * locales: [\n * { code: 'pl', label: 'Polski' },\n * { code: 'en', label: 'English' },\n * ],\n * defaultLocale: 'pl',\n * },\n * access: { authCollection: 'users' },\n * }),\n * ],\n * })\n * ```\n */\nexport const ipalKit = (options: IpalOptions): Plugin => {\n // Validate eagerly — fail fast before Payload boots\n validateI18nConfig(options.i18n)\n\n return async (incomingConfig: Config): Promise => {\n // Early return when disabled — schema stays, behavior off\n if (options.enabled === false) {\n return incomingConfig\n }\n\n let config = { ...incomingConfig }\n\n // --- i18n ---\n config.localization = buildLocalizationConfig(options.i18n)\n\n // --- access: inject roles into the client's auth collection ---\n if (options.access) {\n config = injectRoles(config, options.access)\n }\n\n // --- seo: apply @payloadcms/plugin-seo directly ---\n // NOTE: apply the plugin function to the config immediately rather than\n // pushing it onto config.plugins. Payload has already iterated the plugins\n // array by the time IPAL runs, so nested plugins added to that list are\n // never executed. Calling the plugin as (config) => config applies its\n // transform now.\n if (options.seo) {\n config = await buildSeoPlugin({ seo: options.seo })(config)\n // Auto-fill empty meta from document content on save\n config = injectAutoFillMeta(config, options.seo)\n // Wrap fields into Content + SEO tabs (replaces plugin-seo's tabbedUI,\n // which breaks when other fields already exist in the collection)\n config = injectSeoTabs(config, options.seo)\n }\n\n // --- forms: apply @payloadcms/plugin-form-builder directly ---\n if (options.forms) {\n config = await buildFormsPlugin(options.forms)(config)\n }\n\n // --- globals ---\n // The System Pages tab collects every \"which page plays this role\"\n // assignment. Composing it here keeps SiteSettings unaware of which modules\n // are enabled — it just renders the fields it's given.\n const systemPageFields = [\n ...(options.pages ? buildSystemPagesFields(options.pages) : []),\n ...(options.content && options.pages\n ? buildArchiveFields(options.content, options.pages.slug)\n : []),\n ]\n\n config.globals = [\n ...(config.globals ?? []),\n buildSiteSettings({\n additionalFields: options.siteSettingsFields,\n systemPageFields,\n }),\n buildSiteIntegrations({ additionalFields: options.integrationsFields }),\n buildCookieSettings(),\n buildNotifications(),\n ]\n\n // --- endpoints ---\n // Test-email endpoint (admin-only): POST /api/ipal/test-email sends a probe\n // message through the currently selected transport, so the panel's \"send\n // test\" button can confirm delivery without leaving the admin UI.\n config.endpoints = [...(config.endpoints ?? []), testEmailEndpoint]\n\n // --- hooks: onInit ---\n const incomingOnInit = config.onInit\n config.onInit = async (payload) => {\n if (incomingOnInit) {\n await incomingOnInit(payload)\n }\n payload.logger.info('[ipal] Plugin initialized.')\n }\n\n return config\n }\n}\n"],"names":["buildCookieSettings","buildNotifications","buildSiteIntegrations","buildSiteSettings","injectRoles","buildArchiveFields","testEmailEndpoint","buildFormsPlugin","buildLocalizationConfig","validateI18nConfig","buildSystemPagesFields","buildSeoPlugin","injectAutoFillMeta","injectSeoTabs","ipalKit","options","i18n","incomingConfig","enabled","config","localization","access","seo","forms","systemPageFields","pages","content","slug","globals","additionalFields","siteSettingsFields","integrationsFields","endpoints","incomingOnInit","onInit","payload","logger","info"],"mappings":"AAIA,SAASA,mBAAmB,QAAQ,oCAAmC;AACvE,SAASC,kBAAkB,QAAQ,mCAAkC;AACrE,SAASC,qBAAqB,QAAQ,sCAAqC;AAC3E,SAASC,iBAAiB,QAAQ,kCAAiC;AACnE,SAASC,WAAW,QAAQ,4BAA2B;AACvD,SAASC,kBAAkB,QAAQ,6BAA4B;AAC/D,SAASC,iBAAiB,QAAQ,4CAA2C;AAC7E,SAASC,gBAAgB,QAAQ,uCAAsC;AACvE,SAASC,uBAAuB,EAAEC,kBAAkB,QAAQ,0BAAyB;AACrF,SAASC,sBAAsB,QAAQ,2BAA0B;AACjE,SAASC,cAAc,EAAEC,kBAAkB,EAAEC,aAAa,QAAQ,yBAAwB;AAE1F;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,MAAMC,UAAU,CAACC;IACtB,oDAAoD;IACpDN,mBAAmBM,QAAQC,IAAI;IAE/B,OAAO,OAAOC;QACZ,0DAA0D;QAC1D,IAAIF,QAAQG,OAAO,KAAK,OAAO;YAC7B,OAAOD;QACT;QAEA,IAAIE,SAAS;YAAE,GAAGF,cAAc;QAAC;QAEjC,eAAe;QACfE,OAAOC,YAAY,GAAGZ,wBAAwBO,QAAQC,IAAI;QAE1D,iEAAiE;QACjE,IAAID,QAAQM,MAAM,EAAE;YAClBF,SAASf,YAAYe,QAAQJ,QAAQM,MAAM;QAC7C;QAEA,qDAAqD;QACrD,wEAAwE;QACxE,2EAA2E;QAC3E,wEAAwE;QACxE,uEAAuE;QACvE,iBAAiB;QACjB,IAAIN,QAAQO,GAAG,EAAE;YACfH,SAAS,MAAMR,eAAe;gBAAEW,KAAKP,QAAQO,GAAG;YAAC,GAAGH;YACpD,qDAAqD;YACrDA,SAASP,mBAAmBO,QAAQJ,QAAQO,GAAG;YAC/C,uEAAuE;YACvE,kEAAkE;YAClEH,SAASN,cAAcM,QAAQJ,QAAQO,GAAG;QAC5C;QAEA,gEAAgE;QAChE,IAAIP,QAAQQ,KAAK,EAAE;YACjBJ,SAAS,MAAMZ,iBAAiBQ,QAAQQ,KAAK,EAAEJ;QACjD;QAEA,kBAAkB;QAClB,mEAAmE;QACnE,4EAA4E;QAC5E,uDAAuD;QACvD,MAAMK,mBAAmB;eACnBT,QAAQU,KAAK,GAAGf,uBAAuBK,QAAQU,KAAK,IAAI,EAAE;eAC1DV,QAAQW,OAAO,IAAIX,QAAQU,KAAK,GAChCpB,mBAAmBU,QAAQW,OAAO,EAAEX,QAAQU,KAAK,CAACE,IAAI,IACtD,EAAE;SACP;QAEDR,OAAOS,OAAO,GAAG;eACXT,OAAOS,OAAO,IAAI,EAAE;YACxBzB,kBAAkB;gBAChB0B,kBAAkBd,QAAQe,kBAAkB;gBAC5CN;YACF;YACAtB,sBAAsB;gBAAE2B,kBAAkBd,QAAQgB,kBAAkB;YAAC;YACrE/B;YACAC;SACD;QAED,oBAAoB;QACpB,4EAA4E;QAC5E,yEAAyE;QACzE,kEAAkE;QAClEkB,OAAOa,SAAS,GAAG;eAAKb,OAAOa,SAAS,IAAI,EAAE;YAAG1B;SAAkB;QAEnE,wBAAwB;QACxB,MAAM2B,iBAAiBd,OAAOe,MAAM;QACpCf,OAAOe,MAAM,GAAG,OAAOC;YACrB,IAAIF,gBAAgB;gBAClB,MAAMA,eAAeE;YACvB;YACAA,QAAQC,MAAM,CAACC,IAAI,CAAC;QACtB;QAEA,OAAOlB;IACT;AACF,EAAC"} \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index c7d9920..a7c4909 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/email.md b/docs/email.md index a894b1f..521c0d7 100644 --- a/docs/email.md +++ b/docs/email.md @@ -1,8 +1,10 @@ # email -Wysyłka maili przez SMTP z SiteIntegrations, w runtime (bez Payload email -adaptera). Edytor zmienia SMTP w panelu — następny mail idzie z nowymi -ustawieniami, bez restartu. +Wysyłka maili z dwoma transportami do wyboru: **SMTP z panelu** +(`panelSmtpAdapter`, uniwersalny) albo **Microsoft Graph** (`graphAdapter`, +przez Exchange/M365). Oba implementują ten sam interfejs `PayloadEmailAdapter`, +więc `payload.sendEmail` i maile z formularzy działają niezależnie od wyboru. +Klient/projekt wybiera transport w configu. ## Zależność @@ -73,4 +75,78 @@ resetu hasła i weryfikacji konta. Adapter czyta konfigurację przy każdym wysłaniu, więc zmiana skrzynki w panelu działa bez restartu. Bez adaptera Payload używa mocka, który tylko loguje do konsoli — maile -form-buildera nie wyjdą. \ No newline at end of file +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. `forms@intecion.pl`). + +### Podział konfiguracji (celowy) + +**Sekrety w `.env`** (agencyjne — Wasz Exchange, klient nie widzi): + +```bash +GRAPH_TENANT_ID=... +GRAPH_CLIENT_ID=... +GRAPH_CLIENT_SECRET=... +GRAPH_SENDER=forms@intecion.pl # 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 `forms@intecion.pl` → 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. `noreply@klient.pl`), a sender = +`forms@intecion.pl` — 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. \ No newline at end of file diff --git a/docs/forms.md b/docs/forms.md index 5406003..c1a5058 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -146,6 +146,7 @@ type SubmitFormResult = | { success: false; reason: 'turnstile' } | { success: false; reason: 'validation'; field?: string; kind?: 'required' | 'too_long' | 'unknown_fields' } | { success: false; reason: 'not_found' } + | { success: false; reason: 'consent'; field?: string } | { success: false; reason: 'error' } ``` @@ -162,4 +163,58 @@ function errorMessage(r) { } ``` -Ten sam wzorzec co consent: plugin nie zaszywa języka, oddaje dane. \ No newline at end of file +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. \ No newline at end of file diff --git a/docs/notifications.md b/docs/notifications.md new file mode 100644 index 0000000..9f0a434 --- /dev/null +++ b/docs/notifications.md @@ -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). \ No newline at end of file diff --git a/docs/secuirt.md b/docs/secuirt.md new file mode 100644 index 0000000..858f3d7 --- /dev/null +++ b/docs/secuirt.md @@ -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. \ No newline at end of file diff --git a/docs/slug.md b/docs/slug.md index c8f4189..e80b11b 100644 --- a/docs/slug.md +++ b/docs/slug.md @@ -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 diff --git a/src/exports/client.ts b/src/exports/client.ts index 3e0bffa..d413ae6 100644 --- a/src/exports/client.ts +++ b/src/exports/client.ts @@ -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 diff --git a/src/globals/SiteIntegrations/components/TestEmailButton.tsx b/src/globals/SiteIntegrations/components/TestEmailButton.tsx new file mode 100644 index 0000000..8ccfc1d --- /dev/null +++ b/src/globals/SiteIntegrations/components/TestEmailButton.tsx @@ -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 ( +
+ +

+ Sends through the transport selected above. Save your changes first. +

+
+ setTo(e.target.value)} + placeholder="you@example.com" + style={{ flex: 1, minWidth: '220px' }} + type="email" + value={to} + /> + +
+ {status.kind === 'ok' && ( +

+ ✓ {status.msg} +

+ )} + {status.kind === 'error' && ( +

+ ✗ {status.msg} +

+ )} +
+ ) +} + +export default TestEmailButton diff --git a/src/globals/SiteIntegrations/fields/smtp.ts b/src/globals/SiteIntegrations/fields/smtp.ts index 12b1b9a..6285640 100644 --- a/src/globals/SiteIntegrations/fields/smtp.ts +++ b/src/globals/SiteIntegrations/fields/smtp.ts @@ -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', + }, + }, + }, ] diff --git a/src/index.ts b/src/index.ts index ecfec09..49bf710 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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. diff --git a/src/modules/email/graphAdapter.ts b/src/modules/email/graphAdapter.ts new file mode 100644 index 0000000..aef1b79 --- /dev/null +++ b/src/modules/email/graphAdapter.ts @@ -0,0 +1,182 @@ +import type { PayloadEmailAdapter, SendEmailOptions } from 'payload' + +import { getSiteIntegrations } from '../payload/index.js' + +/** + * From/To settings the adapter reads from SiteIntegrations (panel). The Graph + * CREDENTIALS themselves are NOT here — they're agency secrets in env vars + * (this is *our* Exchange, shared across projects), read below from process.env. + * The panel only controls the display-from and where submissions land. + */ +type GraphIntegrations = { + /** + * Display From — reused from the existing SMTP fields, because the sender + * label is the same concept regardless of transport (SMTP or Graph). No new + * panel field needed; whatever the editor set as the from-address applies. + */ + smtpFromAddress?: null | string + smtpFromName?: null | string +} + +export type GraphAdapterArgs = { + fallbackFromAddress?: string + fallbackFromName?: string +} + +type GraphEnv = { + clientId: string + clientSecret: string + sender: string + tenantId: string +} + +/** Reads + validates the agency Graph credentials from env. */ +function readGraphEnv(): GraphEnv | null { + const tenantId = process.env.GRAPH_TENANT_ID + const clientId = process.env.GRAPH_CLIENT_ID + const clientSecret = process.env.GRAPH_CLIENT_SECRET + const sender = process.env.GRAPH_SENDER + if (!tenantId || !clientId || !clientSecret || !sender) {return null} + return { clientId, clientSecret, sender, tenantId } +} + +/** + * Fetches an app-only access token via the OAuth2 client-credentials flow. + * Scope MUST be '.../.default' — passing 'Mail.Send' directly is rejected + * (AADSTS1002012). Tokens last ~1h; we fetch per send for simplicity and to + * avoid holding state in a possibly multi-instance deployment. If you send at + * high volume, cache by expiry. + */ +async function getAccessToken(env: GraphEnv): Promise { + 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. forms@intecion.pl) for every project. + * - From-display + recipient = per-project, from the panel (SiteIntegrations), + * so an editor controls how the mail is labelled and where it lands. + * + * Wiring: email: process.env.GRAPH_CLIENT_ID ? graphAdapter() : panelSmtpAdapter() + * + * Azure setup (one-time, our side): App registration → Mail.Send APPLICATION + * permission → admin consent → in Exchange, grant the app "Send As" on the + * shared mailbox GRAPH_SENDER. + */ +export const graphAdapter = + (args: GraphAdapterArgs = {}): PayloadEmailAdapter => + ({ payload }) => ({ + name: 'ipal-graph', + defaultFromAddress: args.fallbackFromAddress ?? 'noreply@localhost', + defaultFromName: args.fallbackFromName ?? 'Website', + + sendEmail: async (message: SendEmailOptions) => { + const env = readGraphEnv() + if (!env) { + payload.logger.error( + '[ipal] Email not sent: Graph is not configured. Set GRAPH_TENANT_ID, GRAPH_CLIENT_ID, GRAPH_CLIENT_SECRET, GRAPH_SENDER.', + ) + return { error: 'Graph is not configured (missing env vars).', sent: false } + } + + // From-display comes from the panel; falls back to the caller's from. + const panel = await getSiteIntegrations(payload) + const fromAddress = panel.smtpFromAddress || undefined + const fromName = panel.smtpFromName || undefined + + const to = toRecipients(message.to) + if (to.length === 0) { + payload.logger.error('[ipal] Email not sent: no valid recipient.') + return { error: 'No valid recipient.', sent: false } + } + + // Graph accepts either HTML or Text; Payload gives us html and/or text. + const isHtml = typeof message.html === 'string' && message.html.length > 0 + const content = isHtml ? String(message.html) : String(message.text ?? '') + + const graphMessage: Record = { + body: { content, contentType: isHtml ? 'HTML' : 'Text' }, + subject: message.subject ?? '', + toRecipients: to, + ...(message.cc ? { ccRecipients: toRecipients(message.cc) } : {}), + ...(message.bcc ? { bccRecipients: toRecipients(message.bcc) } : {}), + // from is only honoured if the app has Send-As for that address; when + // it's the shared mailbox itself, omit it and Graph uses the sender. + ...(fromAddress + ? { + from: { + emailAddress: { address: fromAddress, ...(fromName ? { name: fromName } : {}) }, + }, + } + : {}), + // replyTo lets the recipient reply to the real submitter if the caller set it. + ...(message.replyTo + ? { replyTo: toRecipients(message.replyTo as SendEmailOptions['to']) } + : {}), + } + + try { + const token = await getAccessToken(env) + // App-only: MUST target /users/{sender}, never /me. + const res = await fetch( + `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(env.sender)}/sendMail`, + { + body: JSON.stringify({ message: graphMessage, saveToSentItems: false }), + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + method: 'POST', + }, + ) + + // sendMail returns 202 Accepted with an empty body on success. + if (res.status === 202) { + return { sent: true } + } + const detail = await res.text() + payload.logger.error(`[ipal] Graph sendMail failed (${res.status}): ${detail}`) + return { error: `Graph sendMail failed (${res.status}).`, sent: false } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + payload.logger.error(`[ipal] Graph send error: ${msg}`) + return { error: 'Graph send error.', sent: false } + } + }, + }) diff --git a/src/modules/email/index.ts b/src/modules/email/index.ts index 03d4236..bd929e0 100644 --- a/src/modules/email/index.ts +++ b/src/modules/email/index.ts @@ -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' diff --git a/src/modules/email/mailAdapter.ts b/src/modules/email/mailAdapter.ts new file mode 100644 index 0000000..41e3b1e --- /dev/null +++ b/src/modules/email/mailAdapter.ts @@ -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(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) + }, + } + } diff --git a/src/modules/email/test/testEmailEndpoint.ts b/src/modules/email/test/testEmailEndpoint.ts new file mode 100644 index 0000000..26b298c --- /dev/null +++ b/src/modules/email/test/testEmailEndpoint.ts @@ -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: '

This is a test message from ipal-kit. If you received it, outbound email is configured correctly.

', + 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', +} diff --git a/src/plugin.ts b/src/plugin.ts index 95f1f22..67676ed 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -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.') }