graph: use replyTo instead of from to avoid ErrorSendAsDenied

This commit is contained in:
2026-08-22 22:26:47 +02:00
parent cef7f46718
commit 08bd00f01f
16 changed files with 245 additions and 480 deletions
+3
View File
@@ -19,3 +19,6 @@ export {
export type { CookieBannerClassNames } from '../modules/consent/client.js'
export { Turnstile } from '../modules/turnstile/client.js'
export type { TurnstileProps } from '../modules/turnstile/client.js'
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js'
export type { FormNotificationTexts } from '../modules/notifications/types.js'
@@ -1,24 +1,36 @@
'use client'
import type { TextFieldClientComponent } from 'payload'
import { useField } from '@payloadcms/ui'
import { useState } from 'react'
export const MaskedField: TextFieldClientComponent = ({ field, path }) => {
const { setValue, value } = useField<string>({ path })
export const MaskedField: TextFieldClientComponent = (props: any) => {
const { field, path, value: propValue, setValue: propSetValue, onChange: propOnChange } = props || {}
const [internalValue, setInternalValue] = useState(propValue ?? '')
const [revealed, setRevealed] = useState(false)
const label = typeof field?.label === 'string' ? field.label : (field?.name ?? path)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVal = e.target.value
setInternalValue(newVal)
if (typeof propSetValue === 'function') {
propSetValue(newVal)
}
if (typeof propOnChange === 'function') {
propOnChange(e)
}
}
const currentValue = propValue !== undefined ? propValue : internalValue
return (
<div className="field-type text">
<label className="field-label">{label}</label>
{label && <label className="field-label">{label}</label>}
<div style={{ display: 'flex', gap: '.5rem' }}>
<input
autoComplete="off"
onChange={(e) => setValue(e.target.value)}
onChange={handleChange}
style={{ flex: 1 }}
type={revealed ? 'text' : 'password'}
value={value ?? ''}
value={currentValue ?? ''}
/>
<button onClick={() => setRevealed((r) => !r)} type="button">
{revealed ? 'Hide' : 'Reveal'}
@@ -27,4 +39,5 @@ export const MaskedField: TextFieldClientComponent = ({ field, path }) => {
</div>
)
}
export default MaskedField
+10
View File
@@ -99,6 +99,16 @@ export {
injectAutoFillMeta,
} from './modules/seo/index.js'
export { buildSlugField, toSlug } from './modules/slug/index.js'
export {
NOTIFICATION_FALLBACK,
getNotificationTexts,
resolveFormMessage,
} from './modules/notifications/index.js'
export type {
FormNotificationTexts,
NotificationsData,
NotificationTexts,
} from './modules/notifications/index.js'
export { ipalKit } from './plugin.js'
export type { IpalOptions } from './types.js'
+27 -16
View File
@@ -115,10 +115,18 @@ export const graphAdapter =
return { error: 'Graph is not configured (missing env vars).', sent: false }
}
// From-display comes from the panel; falls back to the caller's from.
// 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<GraphIntegrations>(payload)
const fromAddress = panel.smtpFromAddress || undefined
const fromName = panel.smtpFromName || undefined
const replyToAddress = panel.smtpFromAddress || undefined
const replyToName = panel.smtpFromName || undefined
const to = toRecipients(message.to)
if (to.length === 0) {
@@ -130,25 +138,28 @@ export const graphAdapter =
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,
...(replyToName ? { name: replyToName } : {}),
},
},
]
: []
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 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 as SendEmailOptions['to']) }
: {}),
// NO `from` — Graph uses GRAPH_SENDER's own mailbox, so no Send-As.
...(replyTo.length > 0 ? { replyTo } : {}),
}
try {