193 lines
7.9 KiB
TypeScript
193 lines
7.9 KiB
TypeScript
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<string> {
|
|
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. [email protected]) 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<GraphIntegrations>(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<string, unknown> = {
|
|
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 }
|
|
}
|
|
},
|
|
})
|