import crypto from 'node:crypto' import getTokenHelper from "../../utils/getTokenHelper" import refreshTokenHelper from "../../utils/refreshTokenHelper" import errorHandlingHelper from "../../utils/errorHandlingHelper" import fetchHelper from "../../utils/fetchHelper" import { resolveMerchantPartner, resolveOwnUserId, readPartnerFieldCI, MERCHANT_PARTNER_FIELDS } from "../../utils/merchantPartner" import { EMAIL_FIELDS, isValidEmailSyntax, sendVerificationEmail } from "../../utils/emailVerification" import { createPendingChange, cancelActivePending, countRecentRequests } from "../../utils/emailVerifyDb" /** * Merchant self-service e-mail change — body { fieldKey, email }. * * fieldKey ∈ EMAIL_FIELDS: the whitelisted c_bpartner addresses plus the * caller's own ad_user e-mail (`userEmail`). Everything else is rejected. * The target records are resolved server-side from the caller's own * identity; the client cannot pick a partner or user id. * * A new NON-EMPTY address is never written directly: it is parked in the * pending-changes store and a confirmation link is mailed to the NEW * address (proof of control). Only the confirm endpoint applies the write — * until then the old address stays active and the UI shows the pending one. * * Two cases apply immediately (there is no new address to prove control of): * - clearing an optional field to '' (required fields cannot be cleared) * - re-entering the currently active address (reverting = cancels pending) */ const VERIFY_TTL_MS = 48 * 60 * 60 * 1000 const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000 const RATE_LIMIT_MAX = 10 const handleFunc = async (event: any, authToken: string | null = null) => { const config = useRuntimeConfig() const serviceToken = (config.api as any)?.idempieretoken const token = serviceToken || authToken || await getTokenHelper(event) const body = await readBody(event) const fieldKey = String(body?.fieldKey || '') const def = EMAIL_FIELDS[fieldKey] if (!def) return { status: 400, message: 'Unknown e-mail field' } const email = String(body?.email ?? '').trim() if (email !== '' && !isValidEmailSyntax(email)) { return { status: 422, message: 'Invalid email address' } } if (email === '' && def.required) { return { status: 422, message: 'This address cannot be empty' } } const userId = resolveOwnUserId(event) if (!userId) return { status: 401, message: 'No user session' } // Resolve target record + its currently active value. let targetId: number let currentEmail = '' if (def.kind === 'aduser') { targetId = userId const userRes: any = await fetchHelper(event, `models/ad_user/${userId}?$select=EMail`, 'GET', token, null) currentEmail = String(userRes?.EMail || '').trim() } else { const partner = await resolveMerchantPartner(event, token) if (!partner) return { status: 404, message: 'No business partner is linked to this account' } targetId = partner.id currentEmail = String(readPartnerFieldCI(partner.record, MERCHANT_PARTNER_FIELDS[fieldKey].column) || '').trim() } // Re-entering the active address = revert: drop any pending change, done. if (email !== '' && email.toLowerCase() === currentEmail.toLowerCase()) { cancelActivePending(def.kind, targetId, fieldKey) return { status: 200, applied: true } } // Clearing an optional field: nothing to verify — apply immediately. if (email === '') { const payload: Record = { [def.writeKey]: '' } if (def.kind === 'aduser') payload.tableName = 'AD_User' await fetchHelper(event, `models/${def.kind === 'aduser' ? 'ad_user' : 'c_bpartner'}/${targetId}`, 'PUT', token, payload) cancelActivePending(def.kind, targetId, fieldKey) return { status: 200, applied: true } } // New address → verification flow. if (countRecentRequests(userId, RATE_LIMIT_WINDOW_MS) >= RATE_LIMIT_MAX) { return { status: 429, message: 'Too many change requests — please try again later' } } const language = getCookie(event, 'logship_language') || 'de_DE' const verifyToken = crypto.randomBytes(32).toString('hex') const pending = createPendingChange({ token: verifyToken, kind: def.kind, fieldKey, targetId, requestedBy: userId, oldEmail: currentEmail, newEmail: email, language, ttlMs: VERIFY_TTL_MS }) if (!pending) { return { status: 500, message: 'Verification store unavailable — please contact support' } } try { await sendVerificationEmail({ fieldKey, oldEmail: currentEmail, newEmail: email, token: verifyToken, language }) } catch (e: any) { // Undo the pending row — a confirmation that can never arrive must not // block the field in the UI. cancelActivePending(def.kind, targetId, fieldKey) console.error('[EmailVerification] send failed:', e?.message) return { status: 502, message: 'Confirmation e-mail could not be sent — please try again later' } } return { status: 200, applied: false, verificationSentTo: email } } export default defineEventHandler(async (event) => { let data: any = {} try { data = await handleFunc(event) } catch (err: any) { try { const authToken: any = await refreshTokenHelper(event) data = await handleFunc(event, authToken) } catch (error: any) { data = errorHandlingHelper(err?.data ?? err, error?.data ?? error) } } return data })