\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.
+