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 }; } // The panel's from-address is used as Reply-To, NOT as the message From. // // Why: app-only Graph sends from GRAPH_SENDER's mailbox. If we also set a // `from` that differs from that mailbox, Exchange demands "Send As" // permission on it and rejects with ErrorSendAsDenied otherwise. So we // never override `from` — Graph stamps the mail as GRAPH_SENDER (the // mailbox we legitimately own) — and route replies to the panel address // via Reply-To. Recipients see the mail from forms@… but replying reaches // the real destination. No Send-As needed. const panel = await getSiteIntegrations(payload); const replyToAddress = panel.smtpFromAddress || undefined; const replyToName = panel.smtpFromName || undefined; const to = toRecipients(message.to); if (to.length === 0) { payload.logger.error('[ipal] Email not sent: no valid recipient.'); return { error: 'No valid recipient.', sent: false }; } // Graph accepts either HTML or Text; Payload gives us html and/or text. const isHtml = typeof message.html === 'string' && message.html.length > 0; const content = isHtml ? String(message.html) : String(message.text ?? ''); // Reply-To: prefer whatever the caller set; otherwise the panel address. const replyTo = message.replyTo ? toRecipients(message.replyTo) : replyToAddress ? [ { emailAddress: { address: replyToAddress, ...replyToName ? { name: replyToName } : {} } } ] : []; const graphMessage = { body: { content, contentType: isHtml ? 'HTML' : 'Text' }, subject: message.subject ?? '', toRecipients: to, ...message.cc ? { ccRecipients: toRecipients(message.cc) } : {}, ...message.bcc ? { bccRecipients: toRecipients(message.bcc) } : {}, // NO `from` — Graph uses GRAPH_SENDER's own mailbox, so no Send-As. ...replyTo.length > 0 ? { replyTo } : {} }; try { const token = await getAccessToken(env); // App-only: MUST target /users/{sender}, never /me. const res = await fetch(`https://graph.microsoft.com/v1.0/users/${encodeURIComponent(env.sender)}/sendMail`, { body: JSON.stringify({ message: graphMessage, saveToSentItems: false }), headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, method: 'POST' }); // sendMail returns 202 Accepted with an empty body on success. if (res.status === 202) { return { sent: true }; } const detail = await res.text(); payload.logger.error(`[ipal] Graph sendMail failed (${res.status}): ${detail}`); return { error: `Graph sendMail failed (${res.status}).`, sent: false }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); payload.logger.error(`[ipal] Graph send error: ${msg}`); return { error: 'Graph send error.', sent: false }; } } }); //# sourceMappingURL=graphAdapter.js.map