From 67347f1e34200a592272f5545e6101fd5f18e706 Mon Sep 17 00:00:00 2001 From: rasm-its Date: Sat, 22 Aug 2026 23:05:05 +0200 Subject: [PATCH] graph: sender display name from panel --- dist/modules/email/graphAdapter.js | 36 ++++++++++++++++---------- dist/modules/email/graphAdapter.js.map | 2 +- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/dist/modules/email/graphAdapter.js b/dist/modules/email/graphAdapter.js index 6932988..4daf366 100644 --- a/dist/modules/email/graphAdapter.js +++ b/dist/modules/email/graphAdapter.js @@ -89,18 +89,19 @@ import { getSiteIntegrations } from '../payload/index.js'; sent: false }; } - // The panel's from-address is used as Reply-To, NOT as the message From. + // Display name on the From, WITHOUT triggering Send-As. // - // Why: app-only Graph sends from GRAPH_SENDER's mailbox. If we also set a - // `from` that differs from that mailbox, Exchange demands "Send As" - // permission on it and rejects with ErrorSendAsDenied otherwise. So we - // never override `from` — Graph stamps the mail as GRAPH_SENDER (the - // mailbox we legitimately own) — and route replies to the panel address - // via Reply-To. Recipients see the mail from forms@… but replying reaches - // the real destination. No Send-As needed. + // The trick: we may set a `from` as long as its ADDRESS stays the sender + // mailbox (GRAPH_SENDER) — only the display NAME changes. Exchange only + // demands Send-As when the from ADDRESS differs from the mailbox, so a + // same-address / custom-name From is allowed and gives each project its + // own sender label (e.g. "Kancelaria Kędzierski") over the shared mailbox. + // + // The panel's from-address becomes Reply-To (so replies reach the client), + // and the panel's from-name becomes the sender display name. const panel = await getSiteIntegrations(payload); const replyToAddress = panel.smtpFromAddress || undefined; - const replyToName = panel.smtpFromName || undefined; + const senderName = panel.smtpFromName || undefined; const to = toRecipients(message.to); if (to.length === 0) { payload.logger.error('[ipal] Email not sent: no valid recipient.'); @@ -116,10 +117,7 @@ import { getSiteIntegrations } from '../payload/index.js'; const replyTo = message.replyTo ? toRecipients(message.replyTo) : replyToAddress ? [ { emailAddress: { - address: replyToAddress, - ...replyToName ? { - name: replyToName - } : {} + address: replyToAddress } } ] : []; @@ -136,7 +134,17 @@ import { getSiteIntegrations } from '../payload/index.js'; ...message.bcc ? { bccRecipients: toRecipients(message.bcc) } : {}, - // NO `from` — Graph uses GRAPH_SENDER's own mailbox, so no Send-As. + // From with the sender's OWN address (no Send-As) plus an optional + // display name from the panel. Omit entirely when no name is set — + // Graph then uses the mailbox's default name. + ...senderName ? { + from: { + emailAddress: { + name: senderName, + address: env.sender + } + } + } : {}, ...replyTo.length > 0 ? { replyTo } : {} diff --git a/dist/modules/email/graphAdapter.js.map b/dist/modules/email/graphAdapter.js.map index 17f5ba4..e0a3ed6 100644 --- a/dist/modules/email/graphAdapter.js.map +++ b/dist/modules/email/graphAdapter.js.map @@ -1 +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 // The panel's from-address is used as Reply-To, NOT as the message From.\n //\n // Why: app-only Graph sends from GRAPH_SENDER's mailbox. If we also set a\n // `from` that differs from that mailbox, Exchange demands \"Send As\"\n // permission on it and rejects with ErrorSendAsDenied otherwise. So we\n // never override `from` — Graph stamps the mail as GRAPH_SENDER (the\n // mailbox we legitimately own) — and route replies to the panel address\n // via Reply-To. Recipients see the mail from forms@… but replying reaches\n // the real destination. No Send-As needed.\n const panel = await getSiteIntegrations(payload)\n const replyToAddress = panel.smtpFromAddress || undefined\n const replyToName = 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 // Reply-To: prefer whatever the caller set; otherwise the panel address.\n const replyTo = message.replyTo\n ? toRecipients(message.replyTo as SendEmailOptions['to'])\n : replyToAddress\n ? [\n {\n emailAddress: {\n address: replyToAddress,\n ...(replyToName ? { name: replyToName } : {}),\n },\n },\n ]\n : []\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 // NO `from` — Graph uses GRAPH_SENDER's own mailbox, so no Send-As.\n ...(replyTo.length > 0 ? { replyTo } : {}),\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","replyToAddress","smtpFromAddress","undefined","replyToName","smtpFromName","to","isHtml","html","content","String","replyTo","graphMessage","contentType","subject","cc","ccRecipients","bcc","bccRecipients","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,yEAAyE;gBACzE,EAAE;gBACF,0EAA0E;gBAC1E,oEAAoE;gBACpE,uEAAuE;gBACvE,qEAAqE;gBACrE,wEAAwE;gBACxE,0EAA0E;gBAC1E,2CAA2C;gBAC3C,MAAMC,QAAQ,MAAMzD,oBAAuC8C;gBAC3D,MAAMY,iBAAiBD,MAAME,eAAe,IAAIC;gBAChD,MAAMC,cAAcJ,MAAMK,YAAY,IAAIF;gBAE1C,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,yEAAyE;gBACzE,MAAM0C,UAAUf,QAAQe,OAAO,GAC3BpC,aAAaqB,QAAQe,OAAO,IAC5BV,iBACE;oBACE;wBACEf,cAAc;4BACZJ,SAASmB;4BACT,GAAIG,cAAc;gCAAEd,MAAMc;4BAAY,IAAI,CAAC,CAAC;wBAC9C;oBACF;iBACD,GACD,EAAE;gBAER,MAAMQ,eAAwC;oBAC5CvD,MAAM;wBAAEoD;wBAASI,aAAaN,SAAS,SAAS;oBAAO;oBACvDO,SAASlB,QAAQkB,OAAO,IAAI;oBAC5BvC,cAAc+B;oBACd,GAAIV,QAAQmB,EAAE,GAAG;wBAAEC,cAAczC,aAAaqB,QAAQmB,EAAE;oBAAE,IAAI,CAAC,CAAC;oBAChE,GAAInB,QAAQqB,GAAG,GAAG;wBAAEC,eAAe3C,aAAaqB,QAAQqB,GAAG;oBAAE,IAAI,CAAC,CAAC;oBACnE,oEAAoE;oBACpE,GAAIN,QAAQ1B,MAAM,GAAG,IAAI;wBAAE0B;oBAAQ,IAAI,CAAC,CAAC;gBAC3C;gBAEA,IAAI;oBACF,MAAMQ,QAAQ,MAAMhE,eAAeR;oBACnC,oDAAoD;oBACpD,MAAMgB,MAAM,MAAMC,MAChB,CAAC,uCAAuC,EAAEwD,mBAAmBzE,IAAIM,MAAM,EAAE,SAAS,CAAC,EACnF;wBACEI,MAAMgE,KAAKC,SAAS,CAAC;4BAAE1B,SAASgB;4BAAcW,iBAAiB;wBAAM;wBACrE1D,SAAS;4BACP2D,eAAe,CAAC,OAAO,EAAEL,OAAO;4BAChC,gBAAgB;wBAClB;wBACArD,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,OAAO0B,KAAK;oBACZ,MAAMC,MAAMD,eAAevD,QAAQuD,IAAI7B,OAAO,GAAGc,OAAOe;oBACxDpC,QAAQQ,MAAM,CAACC,KAAK,CAAC,CAAC,yBAAyB,EAAE4B,KAAK;oBACtD,OAAO;wBAAE5B,OAAO;wBAAqBC,MAAM;oBAAM;gBACnD;YACF;QACF,CAAA,EAAE"} \ No newline at end of file +{"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 // Display name on the From, WITHOUT triggering Send-As.\n //\n // The trick: we may set a `from` as long as its ADDRESS stays the sender\n // mailbox (GRAPH_SENDER) — only the display NAME changes. Exchange only\n // demands Send-As when the from ADDRESS differs from the mailbox, so a\n // same-address / custom-name From is allowed and gives each project its\n // own sender label (e.g. \"Kancelaria Kędzierski\") over the shared mailbox.\n //\n // The panel's from-address becomes Reply-To (so replies reach the client),\n // and the panel's from-name becomes the sender display name.\n const panel = await getSiteIntegrations(payload)\n const replyToAddress = panel.smtpFromAddress || undefined\n const senderName = 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 // Reply-To: prefer whatever the caller set; otherwise the panel address.\n const replyTo = message.replyTo\n ? toRecipients(message.replyTo as SendEmailOptions['to'])\n : replyToAddress\n ? [{ emailAddress: { address: replyToAddress } }]\n : []\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 with the sender's OWN address (no Send-As) plus an optional\n // display name from the panel. Omit entirely when no name is set —\n // Graph then uses the mailbox's default name.\n ...(senderName\n ? { from: { emailAddress: { name: senderName, address: env.sender } } }\n : {}),\n ...(replyTo.length > 0 ? { replyTo } : {}),\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","replyToAddress","smtpFromAddress","undefined","senderName","smtpFromName","to","isHtml","html","content","String","replyTo","graphMessage","contentType","subject","cc","ccRecipients","bcc","bccRecipients","from","token","encodeURIComponent","JSON","stringify","saveToSentItems","Authorization","err","msg"],"mappings":"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"} \ No newline at end of file