' . 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)