import type { PayloadEmailAdapter, SendEmailOptions } from 'payload' import { getSiteIntegrations } from '../payload/index.js' /** * From/To settings the adapter reads from SiteIntegrations (panel). The Graph * CREDENTIALS themselves are NOT here — they're agency secrets in env vars * (this is *our* Exchange, shared across projects), read below from process.env. * The panel only controls the display-from and where submissions land. */ type GraphIntegrations = { /** * Display From — reused from the existing SMTP fields, because the sender * label is the same concept regardless of transport (SMTP or Graph). No new * panel field needed; whatever the editor set as the from-address applies. */ smtpFromAddress?: null | string smtpFromName?: null | string } export type GraphAdapterArgs = { fallbackFromAddress?: string fallbackFromName?: string } type GraphEnv = { clientId: string clientSecret: string sender: string tenantId: string } /** Reads + validates the agency Graph credentials from env. */ function readGraphEnv(): GraphEnv | null { const tenantId = process.env.GRAPH_TENANT_ID const clientId = process.env.GRAPH_CLIENT_ID const clientSecret = process.env.GRAPH_CLIENT_SECRET const sender = process.env.GRAPH_SENDER if (!tenantId || !clientId || !clientSecret || !sender) {return null} return { clientId, clientSecret, sender, tenantId } } /** * Fetches an app-only access token via the OAuth2 client-credentials flow. * Scope MUST be '.../.default' — passing 'Mail.Send' directly is rejected * (AADSTS1002012). Tokens last ~1h; we fetch per send for simplicity and to * avoid holding state in a possibly multi-instance deployment. If you send at * high volume, cache by expiry. */ async function getAccessToken(env: GraphEnv): Promise { const url = `https://login.microsoftonline.com/${env.tenantId}/oauth2/v2.0/token` const body = new URLSearchParams({ client_id: env.clientId, client_secret: env.clientSecret, grant_type: 'client_credentials', scope: 'https://graph.microsoft.com/.default', }) const res = await fetch(url, { body, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, method: 'POST', }) if (!res.ok) { const detail = await res.text() throw new Error(`Graph token request failed (${res.status}): ${detail}`) } const data = (await res.json()) as { access_token?: string } if (!data.access_token) {throw new Error('Graph token response had no access_token')} return data.access_token } /** Normalizes Payload's to/cc (string | string[] | Address[]) into Graph recipients. */ function toRecipients(value: SendEmailOptions['to']): { emailAddress: { address: string } }[] { if (!value) {return []} const list = Array.isArray(value) ? value : [value] return list .map((v) => (typeof v === 'string' ? v : (v as { address?: string }).address)) .filter((a): a is string => typeof a === 'string' && a.length > 0) .map((address) => ({ emailAddress: { address } })) } /** * Payload email adapter that sends through Microsoft Graph (our Exchange), * using app-only client-credentials auth. Drop-in alternative to * panelSmtpAdapter — same PayloadEmailAdapter contract, so payload.sendEmail * and the form-builder's submission emails work unchanged. * * Split of configuration (deliberate): * - Graph credentials (tenant/client/secret/sender) = AGENCY secrets, from env. * The client never sees or sets them — it's our Exchange, one mailbox * (GRAPH_SENDER, e.g. forms@intecion.pl) for every project. * - From-display + recipient = per-project, from the panel (SiteIntegrations), * so an editor controls how the mail is labelled and where it lands. * * Wiring: email: process.env.GRAPH_CLIENT_ID ? graphAdapter() : panelSmtpAdapter() * * Azure setup (one-time, our side): App registration → Mail.Send APPLICATION * permission → admin consent → in Exchange, grant the app "Send As" on the * shared mailbox GRAPH_SENDER. */ export const graphAdapter = (args: GraphAdapterArgs = {}): PayloadEmailAdapter => ({ payload }) => ({ name: 'ipal-graph', defaultFromAddress: args.fallbackFromAddress ?? 'noreply@localhost', defaultFromName: args.fallbackFromName ?? 'Website', sendEmail: async (message: SendEmailOptions) => { const env = readGraphEnv() if (!env) { payload.logger.error( '[ipal] Email not sent: Graph is not configured. Set GRAPH_TENANT_ID, GRAPH_CLIENT_ID, GRAPH_CLIENT_SECRET, GRAPH_SENDER.', ) return { error: 'Graph is not configured (missing env vars).', sent: false } } // Display name on the From, WITHOUT triggering Send-As. // // 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 senderName = panel.smtpFromName || undefined const to = toRecipients(message.to) if (to.length === 0) { payload.logger.error('[ipal] Email not sent: no valid recipient.') return { error: 'No valid recipient.', sent: false } } // Graph accepts either HTML or Text; Payload gives us html and/or text. const isHtml = typeof message.html === 'string' && message.html.length > 0 const content = isHtml ? String(message.html) : String(message.text ?? '') // Reply-To: prefer whatever the caller set; otherwise the panel address. const replyTo = message.replyTo ? toRecipients(message.replyTo as SendEmailOptions['to']) : replyToAddress ? [{ emailAddress: { address: replyToAddress } }] : [] const graphMessage: Record = { body: { content, contentType: isHtml ? 'HTML' : 'Text' }, subject: message.subject ?? '', toRecipients: to, ...(message.cc ? { ccRecipients: toRecipients(message.cc) } : {}), ...(message.bcc ? { bccRecipients: toRecipients(message.bcc) } : {}), // From 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 } : {}), } 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 } } }, })