' . esc_html(sprintf(
/* translators: %s: folder/file of the other, older copy, e.g. quote-pro/wpquotepro.php */
__('Another copy of GresQuote is active (%s), so this copy from WordPress.org does not run. Deactivate and delete the other copy to use this one; your settings and quotes stay.', 'gresquote'),
$wpquotepro_ander
)) . '
';
});
unset($wpquotepro_ander);
} elseif (defined('WPQUOTEPRO_VERSION')) {
unset($wpquotepro_ander);
$wpquotepro_tweede = plugin_basename(__FILE__);
add_action('admin_notices', function () use ($wpquotepro_tweede) {
if (! current_user_can('activate_plugins')) {
return;
}
echo '' . esc_html(sprintf(
/* translators: %s: folder/file of the second copy, e.g. quote-pro/wpquotepro.php */
__('GresQuote is installed twice. Only one copy runs; deactivate and delete the other one (%s).', 'gresquote'),
$wpquotepro_tweede
)) . '
';
});
unset($wpquotepro_tweede);
} else {
unset($wpquotepro_ander);
// Engelse brontekst, Nederlands via languages/ (Engeland, fase 1). Op
// WordPress.org laadt WordPress de vertaling van translate.wordpress.org zelf;
// deze regel is voor de meegeleverde nl_NL en voor de uitgave van de eigen winkel.
function wpquotepro_laad_vertaling() {
if (! is_textdomain_loaded('gresquote')) {
load_plugin_textdomain('gresquote', false, dirname(plugin_basename(__FILE__)) . '/languages');
}
}
add_action('init', 'wpquotepro_laad_vertaling', 1);
// Versie wordt uit de header hierboven gelezen (niet meer los hardcoded) — anders
// raakt deze constante losgezongen van de echte "Version:"-regel, waardoor de
// updater, telemetrie en de DB-schemaversie een verkeerd versienummer gebruiken.
$wpquotepro_file_headers = get_file_data(__FILE__, ['Version' => 'Version']);
define('WPQUOTEPRO_VERSION', ! empty($wpquotepro_file_headers['Version']) ? $wpquotepro_file_headers['Version'] : '1.6.42');
unset($wpquotepro_file_headers);
// Backend-koppeling. De klant hoeft dit NIET zelf in te vullen: de plugin gebruikt
// standaard de productie-backend. Te overschrijven door beheerders via:
// 1. WPQUOTEPRO_BACKEND_URL constant in wp-config.php (highest priority)
// 2. wpquotepro_backend_base_url optie uit database
// 3. Environment-based auto-detection (local ngrok dev vs production)
// 4. Production default: https://app.wpquotepro.nl
// Voor LOCAL DEVELOPMENT: Zet ngrok URL hier
// Voorbeeld: define('WPQUOTEPRO_DEV_BACKEND_URL', 'https://abc123.ngrok-free.dev');
define('WPQUOTEPRO_DEV_BACKEND_URL', ''); // Zet hier je ngrok URL als je lokaal wil testen
if (defined('WPQUOTEPRO_BACKEND_URL')) {
$wpquotepro_backend_base = (string) WPQUOTEPRO_BACKEND_URL;
} else {
$wpquotepro_backend_base = trim((string) get_option('wpquotepro_backend_base_url', ''));
// Fallback: Als er een dev backend URL is en we zijn lokaal, gebruik die
if ('' === $wpquotepro_backend_base && defined('WPQUOTEPRO_DEV_BACKEND_URL') && '' !== WPQUOTEPRO_DEV_BACKEND_URL) {
$wpquotepro_backend_base = (string) WPQUOTEPRO_DEV_BACKEND_URL;
}
if ('' === $wpquotepro_backend_base) {
// Productie-default. Pas dit aan als de QuoteEngine op een ander domein draait.
$wpquotepro_backend_base = 'https://app.wpquotepro.nl';
}
}
$wpquotepro_backend_base = apply_filters('wpquotepro_backend_url', $wpquotepro_backend_base);
$wpquotepro_backend_base = '' !== $wpquotepro_backend_base ? rtrim($wpquotepro_backend_base, '/') : '';
// TP-14: de API-sleutel gaat in elke aanvraag mee, dus nooit over gewone http.
// Een optie of filter die op http:// uitkomt wordt https://, behalve voor een
// lokale ontwikkelhost waar geen certificaat is.
if (0 === stripos($wpquotepro_backend_base, 'http://')) {
$wpquotepro_backend_host = strtolower((string) wp_parse_url($wpquotepro_backend_base, PHP_URL_HOST));
$wpquotepro_backend_lokaal = in_array($wpquotepro_backend_host, ['localhost', '127.0.0.1', '::1'], true)
|| (bool) preg_match('/\.(test|local|localhost)$/', $wpquotepro_backend_host);
if (! $wpquotepro_backend_lokaal) {
$wpquotepro_backend_base = 'https://' . substr($wpquotepro_backend_base, 7);
}
}
define('WPQUOTEPRO_REGISTER_ENDPOINT', '' !== $wpquotepro_backend_base ? $wpquotepro_backend_base . '/api/v1/tenant/register' : '');
define('WPQUOTEPRO_TENANT_STATUS_ENDPOINT', '' !== $wpquotepro_backend_base ? $wpquotepro_backend_base . '/api/v1/tenant/status' : '');
define('WPQUOTEPRO_TENANT_SETTINGS_ENDPOINT', '' !== $wpquotepro_backend_base ? $wpquotepro_backend_base . '/api/v1/tenant/settings' : '');
// Zelf-service cache van je eigen API-sleutel, zodat "Mijn account" op wpquotepro.nl
// 'm ook kan tonen voor trial-tenants zonder WooCommerce-bestelling (zie
// class-wpquotepro-admin.php: maybe_cache_api_key_for_account_page()).
define('WPQUOTEPRO_API_KEY_CACHE_ENDPOINT', '' !== $wpquotepro_backend_base ? $wpquotepro_backend_base . '/api/v1/api-key' : '');
// Form/wizard-templates (branche-flows) uit de backend.
define('WPQUOTEPRO_FORM_TEMPLATES_ENDPOINT', '' !== $wpquotepro_backend_base ? $wpquotepro_backend_base . '/api/v1/form-templates' : '');
define('WPQUOTEPRO_FORM_TEMPLATES_INSTALLED_ENDPOINT', '' !== $wpquotepro_backend_base ? $wpquotepro_backend_base . '/api/v1/form-templates/installed' : '');
// Voorbeeldberekening van een template: de Live demo stuurt de ingevulde waarden
// hierheen en krijgt de uitkomst terug, zodat de prijslogica van een betaalde
// template niet in de browser hoeft te staan (BE-23).
define('WPQUOTEPRO_FORM_TEMPLATES_PREVIEW_ENDPOINT', '' !== $wpquotepro_backend_base ? $wpquotepro_backend_base . '/api/v1/form-templates/preview' : '');
define('WPQUOTEPRO_FORM_TEMPLATES_USE_ENDPOINT', '' !== $wpquotepro_backend_base ? $wpquotepro_backend_base . '/api/v1/form-templates/use' : '');
// Betaalde templates worden centraal gekocht in de WooCommerce-webshop op wpquotepro.nl.
// Instelbaar via filter 'wpquotepro_shop_url' indien de webshop elders draait.
define('WPQUOTEPRO_SHOP_URL', apply_filters('wpquotepro_shop_url', 'https://www.wpquotepro.nl'));
// Update-manifest voor zelf-gehoste plugin-updates (geserveerd door WPQuotePro Connect).
define('WPQUOTEPRO_UPDATE_MANIFEST', apply_filters('wpquotepro_update_manifest_url', rtrim(WPQUOTEPRO_SHOP_URL, '/') . '/wp-json/wpquotepro/v1/latest-plugin'));
define('WPQUOTEPRO_API_ENDPOINT', '' !== $wpquotepro_backend_base ? $wpquotepro_backend_base . '/api/v1/offerte-aanvraag' : '');
define('WPQUOTEPRO_CONNECT_ENDPOINT', '' !== $wpquotepro_backend_base ? $wpquotepro_backend_base . '/api/v1/plugin/connect' : '');
define('WPQUOTEPRO_ERROR_REPORT_ENDPOINT', '' !== $wpquotepro_backend_base ? $wpquotepro_backend_base . '/api/v1/plugin/report-error' : '');
if (!defined('WPQUOTEPRO_BACKEND_BASE')) {
define('WPQUOTEPRO_BACKEND_BASE', $wpquotepro_backend_base);
}
unset($wpquotepro_backend_base);
define('WPQUOTEPRO_PLUGIN_FILE', __FILE__);
define('WPQUOTEPRO_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('WPQUOTEPRO_PLUGIN_URL', plugin_dir_url(__FILE__));
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/class-wpquotepro-db.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/class-wpquotepro-error-handler.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/class-wpquotepro-health-check.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/class-wpquotepro-regio.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/adapters/interface-wpquotepro-form-adapter.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/class-wpquotepro-woocommerce.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/adapters/class-wpquotepro-adapter-internal.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/adapters/class-wpquotepro-adapter-wpforms.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/adapters/class-wpquotepro-adapter-cf7.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/adapters/class-wpquotepro-adapter-gravityforms.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/adapters/class-wpquotepro-adapter-ninjaforms.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/class-wpquotepro-groups.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/class-wpquotepro-enrichment.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/class-wpquotepro-form-registry.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/class-wpquotepro-bijlagen.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/class-wpquotepro-submission-bridge.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/class-wpquotepro-acceptance-shortcode.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/class-wpquotepro-acceptance-api.php';
// class-wpquotepro-acceptance-webhook.php is bewust verwijderd (TP-04): die
// registreerde dezelfde route wpquotepro/v1/quotation-accepted als
// class-wpquotepro-submission-bridge.php, maar met een fail-open
// signature-check (geen else-tak) en een hardcoded fallback-secret — precies
// het lek dat in v1.6.5 al gedicht was. Welke van de twee handlers won hing af
// van de WordPress-registratievolgorde. De gehardende versie in de
// submission-bridge is nu de enige.
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/wpquotepro-log.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/class-wpquotepro-activation.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/class-wpquotepro-admin.php';
// De updater zit alleen in de uitgave van wpquotepro.nl; de zip voor
// WordPress.org laat hem weg (richtlijn 8, build-plugin-wporg-zip.ps1).
if (file_exists(WPQUOTEPRO_PLUGIN_DIR . 'includes/class-wpquotepro-updater.php')) {
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/class-wpquotepro-updater.php';
}
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/class-wpquotepro-form-preview.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/class-wpquotepro-plugin.php';
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/class-wpquotepro-site-proof.php';
WPQUOTEPRO_Site_Proof::init();
require_once WPQUOTEPRO_PLUGIN_DIR . 'includes/class-wpquotepro-verzendfout.php';
WPQUOTEPRO_Verzendfout::init();
register_activation_hook(__FILE__, ['WPQUOTEPRO_Activation', 'activate']);
register_deactivation_hook(__FILE__, ['WPQUOTEPRO_Activation', 'deactivate']);
register_uninstall_hook(__FILE__, ['WPQUOTEPRO_Activation', 'uninstall']);
register_activation_hook(__FILE__, ['WPQUOTEPRO_DB', 'activate']);
// Dagelijkse health check. Tot 1.0.15 heette de hook
// wp_scheduled_event_wpquotepro_health_check; een naam met wp_ vooraan hoort bij
// WordPress zelf. Een site die bijwerkt zonder opnieuw te activeren, zet de
// planning hieronder één keer om.
register_activation_hook(__FILE__, function () {
if (!wp_next_scheduled('wpquotepro_daily_health_check')) {
wp_schedule_event(time(), 'daily', 'wpquotepro_daily_health_check');
}
});
add_action('init', function () {
if (wp_next_scheduled('wp_scheduled_event_wpquotepro_health_check')) {
wp_clear_scheduled_hook('wp_scheduled_event_wpquotepro_health_check');
if (!wp_next_scheduled('wpquotepro_daily_health_check')) {
wp_schedule_event(time(), 'daily', 'wpquotepro_daily_health_check');
}
}
});
// Cleanup on deactivation
register_deactivation_hook(__FILE__, function () {
wp_clear_scheduled_hook('wpquotepro_daily_health_check');
});
// Zelf-gehoste plugin-updates: controleer wpquotepro.nl op nieuwere versies.
// Niet in de uitgave voor WordPress.org: daar komen updates van WordPress.org zelf.
if (WPQUOTEPRO_UPDATE_MANIFEST && class_exists('WPQuotePro_Updater')) {
(new WPQuotePro_Updater(WPQUOTEPRO_PLUGIN_FILE, WPQUOTEPRO_VERSION, WPQUOTEPRO_UPDATE_MANIFEST))->init();
}
// ─── Auto-update limit info van backend bij instellingspagina en formulieren ────
// Wanneer de instellingspagina geladen wordt, haalt de plugin automatisch de
// huidige limit/usage info van de backend op zodat de admin altijd actuele
// gegevens ziet (zonder API key nodig te zijn).
// Ook bij frontend pagina's met formulieren wordt dit gedaan.
add_action('load-toplevel_page_wpquotepro-settings', 'wpquotepro_refresh_usage_limits');
add_action('wp_loaded', 'wpquotepro_refresh_usage_limits_frontend');
function wpquotepro_refresh_usage_limits() {
if (!function_exists('get_current_screen')) {
return;
}
$screen = get_current_screen();
if (!$screen || $screen->id !== 'toplevel_page_wpquotepro-settings') {
return;
}
wpquotepro_fetch_and_update_usage_limits();
}
function wpquotepro_refresh_usage_limits_frontend() {
// Refresh op frontend elke 5 minuten (check transient)
if (get_transient('wpquotepro_usage_refresh_' . home_url())) {
return; // Nog niet 5 minuten voorbij
}
wpquotepro_fetch_and_update_usage_limits();
set_transient('wpquotepro_usage_refresh_' . home_url(), 1, 5 * MINUTE_IN_SECONDS);
}
/**
* Kent de backend deze API-sleutel? (1.0.0)
*
* 'bekend', 'onbekend' of 'onzeker'. Alleen een stellig "geen abonnement voor
* deze sleutel" (404) telt als onbekend; een time-out, een storing of een
* onverwacht antwoord is onzeker en verandert niets.
*/
function wpquotepro_controleer_sleutel($api_key) {
if ('' === (string) $api_key || !defined('WPQUOTEPRO_TENANT_STATUS_ENDPOINT') || !WPQUOTEPRO_TENANT_STATUS_ENDPOINT) {
return 'onzeker';
}
$response = wp_remote_get(WPQUOTEPRO_TENANT_STATUS_ENDPOINT, array(
'timeout' => 10,
'headers' => array(
'Accept' => 'application/json',
'Apikey' => $api_key,
'Authorization' => 'Bearer ' . $api_key,
),
));
if (is_wp_error($response)) {
return 'onzeker';
}
return wpquotepro_sleutel_oordeel((int) wp_remote_retrieve_response_code($response));
}
/** Het oordeel bij een statuscode van de sleutelcontrole. Puur, getest in tests/run-tests.php. */
function wpquotepro_sleutel_oordeel($code) {
$code = (int) $code;
if ($code >= 200 && $code < 300) {
return 'bekend';
}
return 404 === $code ? 'onbekend' : 'onzeker';
}
/**
* Legt vast dat de koppeling niet (meer) werkt, of juist weer wel.
*
* De sleutel zelf blijft staan. Zou onze backend door een storing ooit ten
* onrechte "onbekend" antwoorden, dan raakt niemand zijn sleutel kwijt: zodra
* de backend hem weer kent, verdwijnt de melding vanzelf.
*/
function wpquotepro_verwerk_sleutelcontrole($oordeel) {
if ('bekend' === $oordeel) {
delete_option('wpquotepro_koppeling_onbekend');
return;
}
if ('onbekend' !== $oordeel) {
return;
}
update_option('wpquotepro_koppeling_onbekend', current_time('mysql'), false);
// Het plan van het verdwenen account mag niet blijven staan: het scherm
// toonde anders "Actief, Starter" voor iets wat niet meer bestaat.
delete_option('wpquotepro_tenant_id');
delete_option('wpquotepro_plan');
delete_option('wpquotepro_license_status');
}
function wpquotepro_fetch_and_update_usage_limits() {
$api_key = trim((string) get_option('wpquotepro_api_key', ''));
$site_url = home_url('/');
$backend_url = WPQUOTEPRO_BACKEND_BASE;
if (empty($backend_url)) {
return;
}
// 1.0.6 (richtlijn 7 van WordPress.org): geen contact met de backend zolang
// de site niet gekoppeld is. Pas met een sleutel (proef gestart of account
// gekoppeld) heeft de beheerder voor de dienst gekozen. Zonder sleutel hoort
// er ook geen plan van een eerdere koppeling te blijven staan; dat deed
// hiervoor de 404 van de backend (1.9.32).
if ('' === $api_key) {
delete_option('wpquotepro_tenant_id');
delete_option('wpquotepro_plan');
delete_option('wpquotepro_license_status');
return;
}
// Via Next.js proxy endpoint (works for both dev and production)
$endpoint = rtrim($backend_url, '/') . '/api/v1/plugin/usage-by-site?site_url=' . urlencode($site_url);
$headers = array('Authorization' => 'Bearer ' . $api_key);
$response = wp_remote_get($endpoint, array(
'timeout' => 10,
'headers' => $headers,
));
if (is_wp_error($response)) {
return;
}
$code = wp_remote_retrieve_response_code($response);
// 1.0.6: zonder sleutel komen we hier niet meer (zie boven). Dat de backend
// de site al kent (gekocht vóór de plugin erop stond) blijkt nu pas bij
// "Gratis proef starten": dan antwoordt de backend 409 en wijst de plugin
// naar Verbinden met het e-mailadres.
// 1.0.0: de backend kent deze website niet, maar er staat wel een sleutel.
// Dat kan twee dingen zijn: het account is weg (dan werkt er niets meer en
// moet de ondernemer dat zien), of de website staat bij ons onder een andere
// schrijfwijze. Alleen de sleutel zelf navragen geeft uitsluitsel.
if (404 === $code) {
wpquotepro_verwerk_sleutelcontrole(wpquotepro_controleer_sleutel($api_key));
return;
}
if ($code !== 200) {
return;
}
$data = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($data) || empty($data['success'])) {
return;
}
// De backend kent de website: een eerdere "koppeling werkt niet" vervalt.
delete_option('wpquotepro_koppeling_onbekend');
// Sla de limit info op in WP options
update_option('wpquotepro_tenant_id', sanitize_text_field($data['tenant_id'] ?? ''));
update_option('wpquotepro_plan', sanitize_text_field($data['plan'] ?? ''));
update_option('wpquotepro_license_status', sanitize_text_field($data['status'] ?? ''));
update_option('wpquotepro_trial_quote_limit', (int) ($data['limit'] ?? 25));
update_option('wpquotepro_trial_quote_used', (int) ($data['used'] ?? 0));
// Sinds 1.8.4 (prijsronde 01-10-2026) betekent limit_reached: geblokkeerd. Dat
// is de proef op of verlopen, of 2x de maandlimiet. Boven de gewone limiet
// gaat het door als meerverbruik (EUR 0,40 per offerte).
update_option('wpquotepro_limit_reached', ! empty($data['limit_reached']) ? '1' : '0');
update_option('wpquotepro_overage', (int) ($data['overage'] ?? 0));
update_option('wpquotepro_overage_cents', (int) ($data['overage_cents'] ?? 0));
update_option('wpquotepro_hard_stop_at', isset($data['hard_stop_at']) ? (int) $data['hard_stop_at'] : 0);
update_option('wpquotepro_trial_ends_at', sanitize_text_field((string) ($data['trial_ends_at'] ?? '')));
}
// ─── REST API endpoint voor PDF webhook (offerte email verzenden) ─────────────────
// Backend stuurt de offerte PDF hier naartoe, plugin verstuurt email via WordPress
add_action( 'rest_api_init', 'wpquotepro_register_pdf_webhook_endpoint' );
// Tot 1.0.15 stonden hier ook wp-quote-pro/v1/send-email en /upload-pdf. Die
// vroegen WordPress-inloggegevens van de klant, die geen klant had; PDF en mail
// lopen via store-pdf (submission-bridge) en pdf-webhook (HMAC).
// wpquotepro/v1/store-pdf is NIET hier geregistreerd — de gehardende versie
// (Apikey-verplicht, %PDF-magic-byte-check) zit in
// class-wpquotepro-submission-bridge.php. Deze file registreerde vroeger een
// tweede, ongeauthenticeerde kopie op dezelfde route (TP-03 in de bug bak) —
// verwijderd i.p.v. gepatcht, zelfde aanpak als de TP-04-fix.
/**
* Verifieert de gedeelde HMAC-SHA256-signature die de backend meestuurt.
* Zelfde schema als class-wpquotepro-submission-bridge.php::verify_webhook_signature()
* en apps/web/app/api/v1/quotations/[id]/accept/route.ts (WORDPRESS_WEBHOOK_SECRET).
*/
function wpquotepro_verify_shared_webhook_signature( $request ) {
$signature_header = $request->get_header( 'X-Webhook-Signature' );
if ( empty( $signature_header ) || ! preg_match( '/^sha256=(.+)$/', $signature_header, $matches ) ) {
return false;
}
$webhook_secret = defined( 'WPQUOTEPRO_WEBHOOK_SECRET' )
? WPQUOTEPRO_WEBHOOK_SECRET
: get_option( 'wpquotepro_webhook_secret', '' );
if ( empty( $webhook_secret ) ) {
wpquotepro_log( '[WPQuotePro] Webhook secret not configured' );
return false;
}
$body = file_get_contents( 'php://input' );
if ( empty( $body ) ) {
return false;
}
$expected_signature = hash_hmac( 'sha256', $body, $webhook_secret );
return hash_equals( $expected_signature, $matches[1] );
}
function wpquotepro_register_pdf_webhook_endpoint() {
register_rest_route( 'wpquotepro/v1', '/pdf-webhook', array(
'methods' => 'POST',
'permission_callback' => 'wpquotepro_verify_shared_webhook_signature',
'callback' => 'wpquotepro_handle_pdf_webhook',
) );
}
function wpquotepro_handle_pdf_webhook( $request ) {
$params = $request->get_json_params();
$customer_name = isset($params['customer_name']) ? sanitize_text_field($params['customer_name']) : '';
$customer_email = isset($params['customer_email']) ? sanitize_email($params['customer_email']) : '';
$tenant_name = isset($params['tenant_name']) ? sanitize_text_field($params['tenant_name']) : '';
$tenant_email = isset($params['tenant_email']) ? sanitize_email($params['tenant_email']) : '';
$quotation_number = isset($params['quotation_number']) ? sanitize_text_field($params['quotation_number']) : '';
$pdf_url = isset($params['pdf_url']) ? esc_url($params['pdf_url']) : '';
$email_html = isset($params['email_html']) ? $params['email_html'] : '';
$email_sender = isset($params['email_sender']) ? sanitize_email($params['email_sender']) : '';
// 1.9.3: de platte-tekstversie die de backend meestuurt, als tweede deel van
// dezelfde mail. Een mail met alleen HTML scoort slechter bij spamfilters.
$email_text = isset($params['email_text']) ? sanitize_textarea_field($params['email_text']) : '';
if ( !$customer_email || !$tenant_email ) {
return new WP_REST_Response( array( 'error' => 'Missing required fields' ), 400 );
}
// Verstuur email via WordPress wp_mail.
//
// De backend mag een eigen onderwerp meesturen: een herinnering die "Offerte
// | Bedrijf" heet, ziet de klant aan voor de mail die hij al had. Zonder dat
// veld blijft het onderwerp precies wat het altijd was.
$subject = isset($params['subject']) ? sanitize_text_field($params['subject']) : '';
if ( '' === $subject ) {
/* translators: %s: company name */
$subject = sprintf( __('Quote | %s', 'gresquote'), $tenant_name );
}
// Use React Email HTML if available, fallback to plain text
if ( !empty($email_html) ) {
$message = $email_html;
$headers = array( 'Content-Type: text/html; charset=UTF-8' );
} else {
$message =
/* translators: %s: customer name */
sprintf(__('Hello %s,', 'gresquote'), $customer_name) . "\n\n"
/* translators: %s: quote number */
. sprintf(__('Please find your quote enclosed: %s', 'gresquote'), $quotation_number) . "\n\n"
. __('Download your quote via this link:', 'gresquote') . "\n" . $pdf_url . "\n\n"
. __('Kind regards,', 'gresquote') . "\n" . $tenant_name;
$headers = array( 'Content-Type: text/plain; charset=UTF-8' );
}
// Use email_sender if provided, otherwise fall back to tenant_email
$from_email = $email_sender ? $email_sender : $tenant_email;
if ( $from_email ) {
$headers[] = 'From: ' . $tenant_name . ' <' . $from_email . '>';
}
$alt = null;
if ( !empty($email_html) && '' !== $email_text ) {
$alt = function ( $phpmailer ) use ( $email_text ) {
$phpmailer->AltBody = $email_text;
};
add_action( 'phpmailer_init', $alt );
}
$sent = wp_mail( $customer_email, $subject, $message, $headers );
if ( $alt ) {
remove_action( 'phpmailer_init', $alt );
}
if ( $sent ) {
do_action( 'wpquotepro_quotation_email_sent', $customer_email, $quotation_number );
return new WP_REST_Response( array( 'success' => true, 'message' => 'Email sent' ), 200 );
} else {
wpquotepro_log( '[WPQuotePro] Failed to send quotation email to ' . $customer_email );
return new WP_REST_Response( array( 'error' => 'Failed to send email' ), 500 );
}
}
function wpquotepro_boot_plugin() {
$plugin = new WPQUOTEPRO_Plugin();
$plugin->boot();
}
wpquotepro_boot_plugin();
} // einde: alleen de eerste kopie (zie bovenaan)