174 lines
7.6 KiB
JavaScript
174 lines
7.6 KiB
JavaScript
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. [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 = {})=>({ 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
|