PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.1
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.1
2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.16 2.1.2 All 57 releases
easy-invoice / includes / Services / EmailManager.php

EmailManager.php in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.4.1, at includes/Services/EmailManager.php

2,154 lines 90.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Email Manager Service
4 *
5 * Handles all email functionality for Easy Invoice
6 *
7 * @package EasyInvoice
8 * @subpackage Services
9 * @since 1.0.0
10 */
11
12 namespace EasyInvoice\Services;
13
14 use EasyInvoice\Models\Invoice;
15 use EasyInvoice\Models\Quote;
16 use EasyInvoice\Models\Client;
17
18 if (!defined('ABSPATH')) {
19 exit;
20 }
21
22 /**
23 * EmailManager Class
24 *
25 * Centralized email management for Easy Invoice
26 */
27 class EmailManager extends BaseService {
28
29 /**
30 * Singleton instance
31 *
32 * @var EmailManager|null
33 */
34 private static $instance = null;
35
36 /**
37 * Service name
38 *
39 * @var string
40 */
41 protected $service_name = 'email_manager';
42
43 /**
44 * Email templates
45 *
46 * @var array
47 */
48 private $templates = [];
49
50 /**
51 * Email settings
52 *
53 * @var array
54 */
55 private $settings = [];
56
57 /**
58 * Get singleton instance
59 *
60 * @return EmailManager
61 */
62 public static function getInstance(): EmailManager {
63 if (self::$instance === null) {
64 self::$instance = new self();
65 }
66 return self::$instance;
67 }
68
69 /**
70 * Constructor
71 */
72 public function __construct() {
73 parent::__construct();
74 $this->loadSettings();
75 $this->loadTemplates();
76 $this->initHooks();
77 }
78
79 /**
80 * Initialize hooks
81 */
82 private function initHooks(): void {
83 // Invoice/quote send-email AJAX is handled exclusively by EasyInvoice\Admin\EasyInvoiceAjax
84 // (published-document checks, single handler) to avoid duplicate nopriv callbacks.
85
86 // Add email settings to admin
87 add_action('admin_init', [$this, 'registerEmailSettings']);
88 add_action('admin_init', [__CLASS__, 'refreshStockTemplates']);
89
90 // Refresh settings when they're updated
91 add_action('update_option_easy_invoice_email_from_name', [$this, 'refreshSettings']);
92 add_action('update_option_easy_invoice_email_from_address', [$this, 'refreshSettings']);
93 add_action('update_option_easy_invoice_email_reply_to', [$this, 'refreshSettings']);
94 add_action('update_option_easy_invoice_email_reply_to_name', [$this, 'refreshSettings']);
95 add_action('update_option_easy_invoice_enable_email_styling', [$this, 'refreshSettings']);
96 add_action('update_option_easy_invoice_email_logo', [$this, 'refreshSettings']);
97 add_action('update_option_easy_invoice_email_footer_text', [$this, 'refreshSettings']);
98 add_action('update_option_easy_invoice_bcc_admin', [$this, 'refreshSettings']);
99 add_action('update_option_easy_invoice_admin_email', [$this, 'refreshSettings']);
100 add_action('update_option_easy_invoice_email_subject', [$this, 'refreshSettings']);
101 add_action('update_option_easy_invoice_email_body', [$this, 'refreshSettings']);
102 add_action('update_option_easy_invoice_quote_subject', [$this, 'refreshSettings']);
103 add_action('update_option_easy_invoice_quote_body', [$this, 'refreshSettings']);
104
105 // Add email logs
106 add_action('easy_invoice_email_sent', [$this, 'logEmailSent'], 10, 3);
107 add_action('easy_invoice_email_failed', [$this, 'logEmailFailed'], 10, 3);
108
109 // Listen for payment completion to send admin notifications
110 add_action('easy_invoice_payment_completed', [$this, 'handlePaymentCompleted'], 10, 3);
111 // An instalment gets a receipt as well; the invoice just is not settled yet.
112 add_action('easy_invoice_payment_received', [$this, 'handlePaymentCompleted'], 10, 3);
113
114 // A client has submitted a manual payment (bank transfer, cheque,
115 // cash, with or without proof) that now waits for verification. The
116 // only listener used to live in an admin class nothing instantiates,
117 // so the admin was never told.
118 add_action('easy_invoice_manual_payment_submitted', [$this, 'handleManualPaymentSubmitted'], 10, 2);
119 }
120
121 /**
122 * Load email settings
123 */
124 private function loadSettings(): void {
125 $this->settings = [
126 'from_name' => get_option('easy_invoice_email_from_name', get_bloginfo('name')),
127 'from_email' => get_option('easy_invoice_email_from_address', get_bloginfo('admin_email')),
128 'reply_to_email' => get_option('easy_invoice_email_reply_to', ''),
129 'reply_to_name' => get_option('easy_invoice_email_reply_to_name', ''),
130 'enable_html' => get_option('easy_invoice_enable_email_styling', 'yes'),
131 'email_logo' => get_option('easy_invoice_email_logo', ''),
132 'footer_text' => get_option('easy_invoice_email_footer_text', ''),
133 'bcc_admin' => get_option('easy_invoice_bcc_admin', 'no'),
134 'admin_email' => get_option('easy_invoice_admin_email', get_option('admin_email')),
135 ];
136 }
137
138 /**
139 * Load email templates
140 */
141 private function loadTemplates(): void {
142 $this->templates = [
143 'invoice_new' => [
144 'enabled' => get_option('easy_invoice_invoice_email_enabled', 'yes') === 'yes',
145 'subject' => get_option('easy_invoice_invoice_email_subject', __('Your Invoice #{{invoice_number}} from {{company_name}}', 'easy-invoice')),
146 'body' => get_option('easy_invoice_invoice_email_body', $this->getDefaultInvoiceTemplate()),
147 'type' => 'invoice'
148 ],
149 'invoice_reminder' => [
150 'enabled' => get_option('easy_invoice_invoice_email_enabled', 'yes') === 'yes',
151 'subject' => get_option('easy_invoice_reminder_subject', 'Payment Reminder - Invoice #{{invoice_number}}'),
152 'body' => get_option('easy_invoice_reminder_body', $this->getDefaultReminderTemplate()),
153 'type' => 'invoice'
154 ],
155 'invoice_paid' => [
156 'enabled' => get_option('easy_invoice_payment_email_enabled', 'yes') === 'yes',
157 'subject' => get_option('easy_invoice_payment_email_subject', __('Payment Received - Invoice #{{invoice_number}}', 'easy-invoice')),
158 'body' => get_option('easy_invoice_payment_email_body', $this->getDefaultPaymentTemplate()),
159 'type' => 'invoice'
160 ],
161 'quote_new' => [
162 'enabled' => get_option('easy_invoice_quote_email_enabled', 'yes') === 'yes',
163 'subject' => get_option('easy_invoice_quote_email_subject', __('Your Quote #{{quote_number}} from {{company_name}}', 'easy-invoice')),
164 'body' => get_option('easy_invoice_quote_email_body', $this->getDefaultQuoteTemplate()),
165 'type' => 'quote'
166 ],
167 'quote_accepted' => [
168 'enabled' => get_option('easy_invoice_quote_email_enabled', 'yes') === 'yes',
169 'subject' => get_option('easy_invoice_quote_accepted_subject', 'Quote Accepted - #{{quote_number}}'),
170 'body' => get_option('easy_invoice_quote_accepted_body', $this->getDefaultQuoteAcceptedTemplate()),
171 'type' => 'quote'
172 ],
173 'quote_declined' => [
174 'enabled' => get_option('easy_invoice_quote_email_enabled', 'yes') === 'yes',
175 'subject' => get_option('easy_invoice_quote_declined_subject', 'Quote Declined - #{{quote_number}}'),
176 'body' => get_option('easy_invoice_quote_declined_body', $this->getDefaultQuoteDeclinedTemplate()),
177 'type' => 'quote'
178 ]
179 ];
180 }
181
182 /**
183 * Send invoice email
184 *
185 * @param Invoice $invoice The invoice
186 * @param string $template_type Template type (new, reminder, paid)
187 * @param array $additional_data Additional data for template
188 * @return array Result array with success status and message
189 */
190 public function sendInvoiceEmail(Invoice $invoice, string $template_type = 'new', array $additional_data = []): array {
191 try {
192 // Validate invoice
193 if (!$invoice || !$invoice->getId()) {
194 return ['success' => false, 'message' => __('Invalid invoice', 'easy-invoice')];
195 }
196
197 // Get client email
198 $client_email = $invoice->getCustomerEmail();
199 if (empty($client_email)) {
200 return ['success' => false, 'message' => __('Client email is missing', 'easy-invoice')];
201 }
202
203 // Get template
204 $template_key = 'invoice_' . $template_type;
205 if (!isset($this->templates[$template_key])) {
206 return ['success' => false, 'message' => __('Email template not found', 'easy-invoice')];
207 }
208
209 $template = $this->templates[$template_key];
210
211 /**
212 * Filter an email template before subject and body are built.
213 *
214 * Runs for invoice, quote and payment emails, so a listener can
215 * switch locale for the client or swap the template wholesale.
216 * `easy_invoice_email_finished` fires once the send is over.
217 *
218 * @param array $template subject, body, enabled.
219 * @param string $template_key invoice_new, quote_reminder, invoice_paid…
220 * @param object $document Invoice or Quote model.
221 */
222 $template = (array) apply_filters('easy_invoice_email_template_data', $template, $template_key, $invoice);
223
224 // Check if email is enabled
225 if (!$template['enabled']) {
226 return ['success' => false, 'message' => __('Invoice available email is disabled', 'easy-invoice')];
227 }
228
229 // Prepare email data
230 $email_data = $this->prepareInvoiceEmailData($invoice, $template, $additional_data);
231
232 // Send email
233 // Attach the invoice as a PDF, when the site has asked for it.
234 //
235 // This is the capability the browser-based renderer could never provide:
236 // wp_mail() needs a file on disk, and until PdfRenderer existed the server
237 // never held the document. Off by default so an upgrade does not silently
238 // change what customers receive.
239 $attachments = [];
240 $attached_path = '';
241 if ($this->shouldAttachInvoicePdf()) {
242 $rendered = \EasyInvoice\Services\PdfRenderer::renderToFile($invoice, 'invoice');
243 if (is_wp_error($rendered)) {
244 // A failed attachment must never stop the invoice being sent.
245 error_log('Easy Invoice: could not attach invoice PDF — ' . $rendered->get_error_message());
246 } else {
247 $attached_path = $rendered;
248 $attachments[] = $rendered;
249 }
250 }
251
252 /**
253 * Filter the files sent with an invoice email.
254 *
255 * @param array $attachments Paths.
256 * @param object $invoice Invoice model.
257 * @param string $type 'invoice'.
258 */
259 $attachments = (array) apply_filters( 'easy_invoice_email_attachments', $attachments, $invoice, 'invoice' );
260 $email_data['message'] = $this->attachmentWording($email_data['message'], !empty($attachments));
261
262 $sent = $this->sendEmail(
263 $email_data['to'],
264 $email_data['subject'],
265 $email_data['message'],
266 $email_data['headers'],
267 $attachments
268 );
269
270 // The rendered PDF lives in the system temp directory; remove it once
271 // wp_mail() has handed it to the transport.
272 if ($attached_path !== '' && file_exists($attached_path)) {
273 wp_delete_file($attached_path);
274 }
275
276 /**
277 * Fires once an email send has finished, whether or not it went out.
278 *
279 * @param object $document Invoice or Quote model.
280 * @param string $template_key Template key.
281 * @param bool $sent Whether wp_mail() accepted it.
282 */
283 do_action('easy_invoice_email_finished', $invoice, $template_key, (bool) $sent);
284
285 if ($sent) {
286 // Log success
287 do_action('easy_invoice_email_sent', $invoice, $client_email, $template_type);
288
289 // Emailing a draft issues it: from here on it is a document
290 // the client holds, so it reads Available, is chased by
291 // reminders and is corrected by credit note, not by editing.
292 if ('new' === $template_type && 'draft' === strtolower((string) $invoice->getStatus())) {
293 $invoice->setStatus('available');
294 $invoice->save();
295 }
296
297 return [
298 'success' => true,
299 'message' => __('Email sent successfully', 'easy-invoice'),
300 'email_data' => $email_data
301 ];
302 } else {
303 // Log failure
304 do_action('easy_invoice_email_failed', $invoice, $client_email, $template_type);
305
306 return ['success' => false, 'message' => __('Failed to send email', 'easy-invoice')];
307 }
308
309 } catch (\Exception $e) {
310 $this->log('Email sending error: ' . $e->getMessage(), 'error');
311 return ['success' => false, 'message' => __('Error sending email: ', 'easy-invoice') . $e->getMessage()];
312 }
313 }
314
315 /**
316 * Send quote email
317 *
318 * @param Quote $quote The quote
319 * @param string $template_type Template type (new, accepted, declined)
320 * @param array $additional_data Additional data for template
321 * @return array Result array with success status and message
322 */
323 public function sendQuoteEmail(Quote $quote, string $template_type = 'new', array $additional_data = []): array {
324 try {
325 // Validate quote
326 if (!$quote || !$quote->getId()) {
327 return ['success' => false, 'message' => __('Invalid quote', 'easy-invoice')];
328 }
329
330 // Get client email
331 $client_email = $quote->getCustomerEmail();
332 if (empty($client_email)) {
333 return ['success' => false, 'message' => __('Client email is missing', 'easy-invoice')];
334 }
335
336 // Get template
337 $template_key = 'quote_' . $template_type;
338 if (!isset($this->templates[$template_key])) {
339 return ['success' => false, 'message' => __('Email template not found', 'easy-invoice')];
340 }
341
342 $template = $this->templates[$template_key];
343 /** This filter is documented above in sendInvoiceEmail(). */
344 $template = (array) apply_filters('easy_invoice_email_template_data', $template, $template_key, $quote);
345
346 // Check if email is enabled
347 if (!$template['enabled']) {
348 return ['success' => false, 'message' => __('Quote available email is disabled', 'easy-invoice')];
349 }
350
351 // Prepare email data
352 $email_data = $this->prepareQuoteEmailData($quote, $template, $additional_data);
353
354 // Same setting as invoices: a PDF copy goes with the quote when asked for.
355 $attachments = [];
356 $attached_path = '';
357 if ($this->shouldAttachInvoicePdf()) {
358 $rendered = \EasyInvoice\Services\PdfRenderer::renderToFile($quote, 'quote');
359 if (is_wp_error($rendered)) {
360 error_log('Easy Invoice: could not attach quote PDF — ' . $rendered->get_error_message());
361 } else {
362 $attached_path = $rendered;
363 $attachments[] = $rendered;
364 }
365 }
366 $attachments = (array) apply_filters( 'easy_invoice_email_attachments', $attachments, $quote, 'quote' );
367 $email_data['message'] = $this->attachmentWording($email_data['message'], !empty($attachments));
368
369 // Send email
370 $sent = $this->sendEmail(
371 $email_data['to'],
372 $email_data['subject'],
373 $email_data['message'],
374 $email_data['headers'],
375 $attachments
376 );
377 if ($attached_path !== '' && file_exists($attached_path)) {
378 wp_delete_file($attached_path);
379 }
380
381 /**
382 * Fires once an email send has finished, whether or not it went out.
383 *
384 * @param object $document Invoice or Quote model.
385 * @param string $template_key Template key.
386 * @param bool $sent Whether wp_mail() accepted it.
387 */
388 do_action('easy_invoice_email_finished', $quote, $template_key, (bool) $sent);
389
390 if ($sent) {
391 // Log success
392 do_action('easy_invoice_quote_email_sent', $quote, $client_email, $template_type);
393
394 // A quote that has been emailed is "sent".
395 if ('new' === $template_type && in_array(strtolower((string) $quote->getStatus()), ['draft', 'available'], true)) {
396 $quote->setStatus('sent');
397 $quote->save();
398 }
399
400 return [
401 'success' => true,
402 'message' => __('Quote email sent successfully', 'easy-invoice'),
403 'email_data' => $email_data
404 ];
405 } else {
406 // Log failure
407 do_action('easy_invoice_quote_email_failed', $quote, $client_email, $template_type);
408
409 return ['success' => false, 'message' => __('Failed to send quote email', 'easy-invoice')];
410 }
411
412 } catch (\Exception $e) {
413 $this->log('Quote email sending error: ' . $e->getMessage(), 'error');
414 return ['success' => false, 'message' => __('Error sending quote email: ', 'easy-invoice') . $e->getMessage()];
415 }
416 }
417
418 /**
419 * Prepare invoice email data
420 *
421 * @param Invoice $invoice The invoice
422 * @param array $template The email template
423 * @param array $additional_data Additional data
424 * @return array Email data
425 */
426 private function prepareInvoiceEmailData(Invoice $invoice, array $template, array $additional_data = []): array {
427 // Get replacements — the receipt needs the payment placeholders too.
428 $replacements = !empty($additional_data['payment_receipt'])
429 ? $this->getPaymentReplacements($invoice, $additional_data)
430 : $this->getInvoiceReplacements($invoice, $additional_data);
431
432 // Process template
433 $subject = $this->processTemplate($template['subject'], $replacements);
434 $message = $this->processTemplate($template['body'], $replacements);
435 $message = do_shortcode($message); // Render shortcodes like [easy_invoice_url ...]
436
437 // Add HTML wrapper if enabled
438 if ($this->settings['enable_html'] === 'yes') {
439 $message = $this->wrapInHtmlTemplate($message);
440 }
441
442 // Prepare headers
443 $headers = $this->prepareEmailHeaders('invoice', $invoice);
444
445 // Add BCC to admin if enabled
446 if ($this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) {
447 $headers[] = 'Bcc: ' . $this->settings['admin_email'];
448 }
449
450 return [
451 'to' => $invoice->getCustomerEmail(),
452 'subject' => $subject,
453 'message' => $message,
454 'headers' => $headers
455 ];
456 }
457
458 /**
459 * Prepare quote email data
460 *
461 * @param Quote $quote The quote
462 * @param array $template The email template
463 * @param array $additional_data Additional data
464 * @return array Email data
465 */
466 private function prepareQuoteEmailData(Quote $quote, array $template, array $additional_data = []): array {
467 // Get replacements
468 $replacements = $this->getQuoteReplacements($quote, $additional_data);
469
470 // Process template
471 $subject = $this->processTemplate($template['subject'], $replacements);
472 $message = $this->processTemplate($template['body'], $replacements);
473
474 // Add HTML wrapper if enabled
475 if ($this->settings['enable_html'] === 'yes') {
476 $message = $this->wrapInHtmlTemplate($message);
477 }
478
479 // Prepare headers
480 $headers = $this->prepareEmailHeaders('quote', $quote);
481
482 // Add BCC to admin if enabled
483 if ($this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) {
484 $headers[] = 'Bcc: ' . $this->settings['admin_email'];
485 }
486
487 return [
488 'to' => $quote->getCustomerEmail(),
489 'subject' => $subject,
490 'message' => $message,
491 'headers' => $headers
492 ];
493 }
494
495 /**
496 * Prepare payment email data
497 *
498 * @param Invoice $invoice The invoice
499 * @param array $template The email template
500 * @param array $payment_data Payment data
501 * @return array Email data
502 */
503 private function preparePaymentEmailData(Invoice $invoice, array $template, array $payment_data = []): array {
504 // Get replacements
505 $replacements = $this->getPaymentReplacements($invoice, $payment_data);
506
507 // Process template
508 $subject = $this->processTemplate($template['subject'], $replacements);
509 $message = $this->processTemplate($template['body'], $replacements);
510
511 // Add HTML wrapper if enabled
512 if ($this->settings['enable_html'] === 'yes') {
513 $message = $this->wrapInHtmlTemplate($message);
514 }
515
516 // Prepare headers
517 $headers = $this->prepareEmailHeaders('receipt', $invoice);
518
519 // Add BCC to admin if enabled
520 if ($this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) {
521 $headers[] = 'Bcc: ' . $this->settings['admin_email'];
522 }
523
524 return [
525 'to' => $invoice->getCustomerEmail(),
526 'subject' => $subject,
527 'message' => $message,
528 'headers' => $headers
529 ];
530 }
531
532 /**
533 * Get invoice replacements
534 *
535 * @param Invoice $invoice The invoice
536 * @param array $additional_data Additional data
537 * @return array Replacements
538 */
539 private function getInvoiceReplacements(Invoice $invoice, array $additional_data = []): array {
540 $currency_symbol = get_option('easy_invoice_currency_symbol', '$');
541
542 // Secure link support
543 $invoice_url = get_permalink($invoice->getId());
544 $secure_links_enabled = get_option('easy_invoice_pro_enable_secure_links', 'no') === 'yes';
545 if ($secure_links_enabled && class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
546 $secure_url = \EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController::getInvoiceSecureLinkUrl($invoice->getId());
547 if ($secure_url) {
548 $invoice_url = $secure_url;
549 }
550 }
551
552 // SECURITY: attach a per-invoice access token to the outbound URL
553 // so the legitimate email recipient can submit manual payments
554 // without needing to log in. The token is verified server-side in
555 // PaymentController::submitManualPayment via
556 // InvoiceController::canSubmitPaymentForInvoice. Empty-token
557 // guard so a CSPRNG failure doesn't produce malformed `?ik=` URLs.
558 $invoice_access_token = \EasyInvoice\Controllers\InvoiceController::invoiceAccessToken((int) $invoice->getId());
559 if ($invoice_access_token !== '' && $invoice_url) {
560 $invoice_url = add_query_arg('ik', $invoice_access_token, $invoice_url);
561 // The recipient now holds a keyed link; the bare one may close (TemplateLoader::isLegacyOpenDocument).
562 \EasyInvoice\TemplateLoader::markKeyedLinkSent((int) $invoice->getId());
563 }
564
565 // Get client data for additional fields
566 $client = null;
567 if ($invoice->getClientId()) {
568 $client_repository = new \EasyInvoice\Repositories\ClientRepository();
569 $client = $client_repository->find($invoice->getClientId());
570 }
571
572 return array_merge([
573 '{{invoice_number}}' => $invoice->getNumber(),
574 '{{invoice_title}}' => $invoice->getTitle(),
575 '{{client_name}}' => $invoice->getCustomerName(),
576 '{{client_email}}' => $invoice->getCustomerEmail(),
577 '{{client_address}}' => $invoice->getCustomerAddress(),
578 '{{client_first_name}}' => $client ? $client->getFirstName() : '',
579 '{{client_last_name}}' => $client ? $client->getLastName() : '',
580 '{{company_name}}' => get_option('easy_invoice_company_name') ?: get_bloginfo('name'),
581 '{{company_email}}' => $this->settings['from_email'],
582 '{{company_phone}}' => get_option('easy_invoice_company_phone', ''),
583 '{{company_address}}' => get_option('easy_invoice_company_address', ''),
584 '{{company_website}}' => get_option('easy_invoice_company_website', ''),
585 '{{total_amount}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getTotal()),
586 '{{amount_due}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format(\EasyInvoice\Services\InvoiceBalance::due($invoice)),
587 '{{subtotal}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getSubtotal()),
588 '{{tax_amount}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getTaxAmount()),
589 '{{discount_amount}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getDiscountAmount()),
590 '{{due_date}}' => gmdate('F j, Y', strtotime($invoice->getDueDate())),
591 '{{issue_date}}' => gmdate('F j, Y', strtotime($invoice->getIssueDate())),
592 '{{invoice_url}}' => $invoice_url, // Use correct (possibly secure) link
593 '{{payment_url}}' => add_query_arg('payment', '1', get_permalink($invoice->getId())),
594 '{{site_url}}' => get_site_url(),
595 '{{admin_url}}' => admin_url(),
596 '{{payment_terms}}' => get_option('easy_invoice_payment_terms', __('Due on receipt', 'easy-invoice')),
597 ], self::placeholderKeysOnly($additional_data));
598 }
599
600 /**
601 * Keep only entries shaped like placeholders. Callers pass raw payment
602 * data ('amount', 'date', 'payment_method') alongside; merged as-is those
603 * became replacements of the bare words, turning "{{payment_amount}}"
604 * into "{{payment_40}}" and every "date" in the text into a date.
605 *
606 * @param array $data Mixed data.
607 * @return array<string,string>
608 */
609 private static function placeholderKeysOnly(array $data): array {
610 $out = [];
611 foreach ($data as $key => $value) {
612 if (is_string($key) && 0 === strpos($key, '{{') && is_scalar($value)) {
613 $out[$key] = (string) $value;
614 }
615 }
616 return $out;
617 }
618
619 /**
620 * Get quote replacements
621 *
622 * @param Quote $quote The quote
623 * @param array $additional_data Additional data
624 * @return array Replacements
625 */
626 private function getQuoteReplacements(Quote $quote, array $additional_data = []): array {
627 $currency_symbol = get_option('easy_invoice_currency_symbol', '$');
628
629 $quote_url = get_permalink($quote->getId());
630 $secure_links_enabled = get_option('easy_invoice_pro_enable_secure_links', 'no') === 'yes';
631 if ($secure_links_enabled && class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
632 $secure_url = \EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController::getQuoteSecureLinkUrl($quote->getId());
633 if ($secure_url) {
634 $quote_url = $secure_url;
635 }
636 }
637
638 // SECURITY (CVE-2026-9021): attach the per-quote access token so
639 // the emailed recipient lands on a page that renders the
640 // Accept/Decline UI and can submit either action without
641 // authenticating. Without the token the public single-quote page
642 // is read-only (no buttons, no nonce in DOM). Lazily generates
643 // the token on first send. The query parameter name is
644 // intentionally short ('qk') and opaque — leaking it via referer
645 // headers is no worse than leaking the secure-link signature.
646 $quote_access_token = \EasyInvoice\Controllers\QuoteController::quoteAccessToken((int) $quote->getId());
647 if ($quote_access_token !== '' && $quote_url) {
648 $quote_url = add_query_arg('qk', $quote_access_token, $quote_url);
649 \EasyInvoice\TemplateLoader::markKeyedLinkSent((int) $quote->getId());
650 }
651
652 // Get client data for additional fields
653 $client = null;
654 if ($quote->getClientId()) {
655 $client_repository = new \EasyInvoice\Repositories\ClientRepository();
656 $client = $client_repository->find($quote->getClientId());
657 }
658
659 return array_merge([
660 '{{quote_number}}' => $quote->getNumber(),
661 '{{quote_title}}' => $quote->getTitle(),
662 '{{client_name}}' => $quote->getCustomerName(),
663 '{{client_email}}' => $quote->getCustomerEmail(),
664 '{{client_address}}' => $quote->getCustomerAddress(),
665 '{{client_first_name}}' => $client ? $client->getFirstName() : '',
666 '{{client_last_name}}' => $client ? $client->getLastName() : '',
667 '{{company_name}}' => get_option('easy_invoice_company_name') ?: get_bloginfo('name'),
668 '{{company_email}}' => $this->settings['from_email'],
669 '{{company_phone}}' => get_option('easy_invoice_company_phone', ''),
670 '{{company_address}}' => get_option('easy_invoice_company_address', ''),
671 '{{company_website}}' => get_option('easy_invoice_company_website', ''),
672 '{{total_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getTotal()),
673 '{{subtotal}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getSubtotal()),
674 '{{tax_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getTaxAmount()),
675 '{{discount_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getDiscountAmount()),
676 '{{expiry_date}}' => gmdate('F j, Y', strtotime($quote->getExpiryDate())),
677 '{{issue_date}}' => gmdate('F j, Y', strtotime($quote->getIssueDate())),
678 '{{quote_url}}' => $quote_url,
679 '{{site_url}}' => get_site_url(),
680 '{{admin_url}}' => admin_url(),
681 '{{payment_terms}}' => get_option('easy_invoice_payment_terms', __('Due on receipt', 'easy-invoice')),
682 ], self::placeholderKeysOnly($additional_data));
683 }
684
685 /**
686 * Get payment replacements
687 *
688 * @param Invoice $invoice The invoice
689 * @param array $payment_data Payment data
690 * @return array Replacements
691 */
692 private function getPaymentReplacements(Invoice $invoice, array $payment_data = []): array {
693 $replacements = $this->getInvoiceReplacements($invoice, $payment_data);
694
695 // Add payment-specific replacements
696 $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($invoice);
697 $replacements['{{payment_amount}}'] = isset($payment_data['amount']) ? $formatter->format((float) $payment_data['amount']) : $formatter->format($invoice->getTotal());
698 $paid_on = !empty($payment_data['date']) ? strtotime((string) $payment_data['date']) : false;
699 $replacements['{{payment_date}}'] = date_i18n(get_option('date_format'), $paid_on ?: current_time('timestamp'));
700 // Callers pass the gateway id as payment_method (some as method); show its label.
701 $method_key = (string) ($payment_data['payment_method'] ?? $payment_data['method'] ?? '');
702 $replacements['{{payment_method}}'] = '' !== $method_key ? $this->getPaymentMethodLabel($method_key) : __('Online Payment', 'easy-invoice');
703 $replacements['{{transaction_id}}'] = !empty($payment_data['transaction_id']) ? (string) $payment_data['transaction_id'] : __('N/A', 'easy-invoice');
704 $replacements['{{acceptance_date}}'] = isset($payment_data['acceptance_date']) ? $payment_data['acceptance_date'] : current_time('Y-m-d');
705 $replacements['{{response_date}}'] = isset($payment_data['response_date']) ? $payment_data['response_date'] : current_time('Y-m-d');
706 $replacements['{{decline_reason}}'] = isset($payment_data['decline_reason']) ? $payment_data['decline_reason'] : __('No specific reason provided', 'easy-invoice');
707
708 return $replacements;
709 }
710
711 /**
712 * Process template with replacements
713 *
714 * @param string $template The template
715 * @param array $replacements The replacements
716 * @return string Processed template
717 */
718 private function processTemplate(string $template, array $replacements): string {
719 return easy_invoice_str_replace(array_keys($replacements), array_values($replacements), $template);
720 }
721
722 /**
723 * Prepare email headers
724 *
725 * @return array Headers
726 */
727 /**
728 * Should outgoing invoice emails carry a PDF copy?
729 *
730 * Defaults to off. Attaching a document changes what every customer receives and
731 * makes messages substantially larger, which some SMTP relays limit — that is the
732 * site owner's decision, not something an update should impose.
733 *
734 * @return bool
735 */
736 private function shouldAttachInvoicePdf(): bool {
737 if (!\EasyInvoice\Services\PdfRenderer::isAvailable()) {
738 return false;
739 }
740
741 $enabled = get_option('easy_invoice_attach_pdf_to_email', 'no') === 'yes';
742
743 /**
744 * Filter whether to attach a PDF to invoice emails.
745 *
746 * @param bool $enabled Current setting.
747 */
748 return (bool) apply_filters('easy_invoice_attach_pdf_to_email', $enabled);
749 }
750
751 private function prepareEmailHeaders(string $template_name = '', $document = null): array {
752 $headers = [
753 'Content-Type: text/html; charset=UTF-8',
754 'From: ' . $this->settings['from_name'] . ' <' . $this->settings['from_email'] . '>',
755 ];
756
757 // Add Reply-To if set
758 if (!empty($this->settings['reply_to_email'])) {
759 $reply_to_name = !empty($this->settings['reply_to_name']) ? $this->settings['reply_to_name'] : $this->settings['from_name'];
760 $headers[] = 'Reply-To: ' . $reply_to_name . ' <' . $this->settings['reply_to_email'] . '>';
761 }
762
763 /**
764 * Filter the headers of an outgoing Easy Invoice email.
765 *
766 * This is the extension point Easy Invoice Pro's Email Enhancements addon uses
767 * to set a per-document-type Reply-To. The addon has always registered against
768 * it, but nothing here ever applied it, so that half of the addon did nothing
769 * at all — the Reply-To customers saw came only from the free plugin's own
770 * Email settings above.
771 *
772 * @param array $headers Headers assembled so far.
773 * @param string $template_name Which email this is: invoice, quote, receipt,
774 * reminder, and so on. Empty when the caller has
775 * no template context.
776 * @param mixed $document The Invoice or Quote the email concerns, or null.
777 */
778 return (array) apply_filters('easy_invoice_email_headers', $headers, $template_name, $document);
779 }
780
781 /**
782 * Wrap message in HTML template
783 *
784 * @param string $message The message
785 * @return string HTML wrapped message
786 */
787 /**
788 * Wrap a message body in the plugin's HTML email layout (logo, styles,
789 * footer) — for anything outside this class that sends a branded email.
790 *
791 * @param string $message Body HTML.
792 * @return string
793 */
794 /**
795 * The stock templates mention an attached copy. When nothing is attached
796 * (the setting is off by default) that sentence would be untrue, so the
797 * exact stock phrases are reworded; a merchant's own text is left alone.
798 *
799 * @param string $message Rendered email body.
800 * @param bool $attached Whether a file goes with it.
801 * @return string
802 */
803 private function attachmentWording(string $message, bool $attached): string {
804 if ($attached) {
805 return $message;
806 }
807 return str_replace(
808 [
809 __('The invoice is attached and can also be viewed and paid online:', 'easy-invoice'),
810 __('It is attached, and you can review, accept or decline it online:', 'easy-invoice'),
811 ],
812 [
813 __('You can view and pay it online:', 'easy-invoice'),
814 __('You can review, accept or decline it online:', 'easy-invoice'),
815 ],
816 $message
817 );
818 }
819
820 public function wrapMessage(string $message): string {
821 return $this->wrapInHtmlTemplate($message);
822 }
823
824 /**
825 * Headers for an email sent by something other than this class (Pro's
826 * reminders, addons): From and Reply-To from Settings → Email, then the
827 * `easy_invoice_email_headers` filter with the template name.
828 *
829 * @param string $template_name invoice, quote, receipt, reminder…
830 * @param mixed $document The Invoice or Quote concerned, or null.
831 * @return array<int,string>
832 */
833 public function headers(string $template_name = '', $document = null): array {
834 return $this->prepareEmailHeaders($template_name, $document);
835 }
836
837 /**
838 * Placeholder replacements for an invoice, for a template sent by
839 * something other than this class (Pro's reminders, addons).
840 *
841 * @param object $invoice Invoice model.
842 * @return array<string,string>
843 */
844 public function invoicePlaceholders($invoice): array {
845 return $this->getInvoiceReplacements($invoice);
846 }
847
848 private function wrapInHtmlTemplate(string $message): string {
849 $logo_html = '';
850 if (!empty($this->settings['email_logo'])) {
851 $logo_html = '<div style="text-align: center; margin-bottom: 40px;"><img src="' . esc_url($this->settings['email_logo']) . '" alt="' . esc_attr($this->settings['from_name']) . '" style="max-width: 200px; height: auto; border-radius: 8px;"></div>';
852 }
853
854 $footer_html = '';
855 if (!empty($this->settings['footer_text'])) {
856 $footer_html = '<div style="margin-top: 50px; padding-top: 25px; border-top: 2px solid #f3f4f6; font-size: 14px; color: #6b7280; text-align: center;">' . wpautop($this->settings['footer_text']) . '</div>';
857 }
858
859 /**
860 * Filter the footer block of every Easy Invoice email.
861 *
862 * @param string $footer_html The footer markup ('' when no footer text is set).
863 * @param array $settings Email settings.
864 */
865 $footer_html = (string) apply_filters('easy_invoice_email_footer_html', $footer_html, $this->settings);
866
867 /**
868 * Replace the whole email layout.
869 *
870 * Return a full HTML document to use it instead of the stock layout.
871 * Pro's Email Enhancements addon uses this for a custom branded
872 * layout; the placeholders it offers are resolved before this fires.
873 *
874 * @param string $html '' — return non-empty markup to take over.
875 * @param string $message The email body (placeholders already replaced), unwrapped.
876 * @param string $logo_html Logo block from Settings → Email, or ''.
877 * @param string $footer_html Footer block, after the filter above.
878 * @param array $settings Email settings.
879 */
880 $custom = (string) apply_filters('easy_invoice_email_html', '', $message, $logo_html, $footer_html, $this->settings);
881 if ('' !== trim($custom)) {
882 return $custom;
883 }
884
885 return '
886 <!DOCTYPE html>
887 <html>
888 <head>
889 <meta charset="UTF-8">
890 <meta name="viewport" content="width=device-width, initial-scale=1.0">
891 <title>' . esc_html($this->settings['from_name']) . '</title>
892 <style>
893 body {
894 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
895 line-height: 1.6;
896 color: #374151;
897 margin: 0;
898 padding: 0;
899 background-color: #f9fafb;
900 }
901 .email-container {
902 max-width: 600px;
903 margin: 0 auto;
904 background-color: #ffffff;
905 border-radius: 12px;
906 box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
907 overflow: hidden;
908 }
909 .email-header {
910 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
911 padding: 50px 30px;
912 text-align: center;
913 position: relative;
914 }
915 .email-header::before {
916 content: "";
917 position: absolute;
918 top: 0;
919 left: 0;
920 right: 0;
921 bottom: 0;
922 background: url("data:image/svg+xml,%3Csvg width="60" height="60" viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%23ffffff" fill-opacity="0.1"%3E%3Ccircle cx="30" cy="30" r="2"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
923 opacity: 0.3;
924 }
925 .email-header h1 {
926 color: #ffffff;
927 margin: 0;
928 font-size: 28px;
929 font-weight: 700;
930 position: relative;
931 z-index: 1;
932 }
933 .email-content {
934 padding: 50px 40px;
935 background: #ffffff;
936 }
937 .email-content p {
938 margin: 0 0 20px 0;
939 color: #374151;
940 line-height: 1.7;
941 }
942 .email-content h2 {
943 color: #1f2937;
944 font-size: 28px;
945 font-weight: 700;
946 margin: 0 0 30px 0;
947 text-align: center;
948 }
949 .email-content h3 {
950 color: #374151;
951 font-size: 20px;
952 font-weight: 600;
953 margin: 0 0 16px 0;
954 }
955 .email-footer {
956 background-color: #f9fafb;
957 padding: 40px 30px;
958 text-align: center;
959 border-top: 1px solid #e5e7eb;
960 }
961 .email-footer p {
962 margin: 0;
963 color: #6b7280;
964 font-size: 14px;
965 }
966 .highlight-box {
967 background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
968 border-left: 4px solid #3b82f6;
969 padding: 30px;
970 margin: 30px 0;
971 border-radius: 0 12px 12px 0;
972 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
973 }
974 .highlight-box p {
975 margin: 0;
976 font-size: 16px;
977 line-height: 1.6;
978 }
979 .highlight-box strong {
980 color: #1f2937;
981 font-weight: 600;
982 }
983 .info-box {
984 background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
985 border: 1px solid #93c5fd;
986 border-radius: 12px;
987 padding: 25px;
988 margin: 30px 0;
989 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
990 }
991 .info-box p {
992 margin: 0;
993 color: #1e40af;
994 font-size: 15px;
995 line-height: 1.7;
996 }
997 .info-box strong {
998 color: #1e3a8a;
999 font-weight: 600;
1000 }
1001 .success-box {
1002 background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
1003 border: 1px solid #6ee7b7;
1004 border-radius: 12px;
1005 padding: 25px;
1006 margin: 30px 0;
1007 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
1008 }
1009 .success-box p {
1010 margin: 0;
1011 color: #065f46;
1012 font-size: 15px;
1013 line-height: 1.7;
1014 }
1015 .success-box strong {
1016 color: #047857;
1017 font-weight: 600;
1018 }
1019 .warning-box {
1020 background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
1021 border: 1px solid #f59e0b;
1022 border-radius: 12px;
1023 padding: 25px;
1024 margin: 30px 0;
1025 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
1026 }
1027 .warning-box p {
1028 margin: 0;
1029 color: #92400e;
1030 font-size: 15px;
1031 line-height: 1.7;
1032 }
1033 .warning-box strong {
1034 color: #78350f;
1035 font-weight: 600;
1036 }
1037 .button {
1038 display: inline-block;
1039 background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
1040 color: #ffffff;
1041 padding: 14px 28px;
1042 text-decoration: none;
1043 border-radius: 8px;
1044 font-weight: 600;
1045 margin: 20px 0;
1046 box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
1047 transition: all 0.2s ease;
1048 }
1049 .button:hover {
1050 background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
1051 transform: translateY(-1px);
1052 box-shadow: 0 6px 8px -1px rgba(59, 130, 246, 0.4);
1053 }
1054 .divider {
1055 height: 1px;
1056 background: linear-gradient(90deg, transparent 0%, #e5e7eb 50%, transparent 100%);
1057 margin: 40px 0;
1058 }
1059 .amount-highlight {
1060 font-size: 28px;
1061 font-weight: 700;
1062 color: #059669;
1063 text-align: center;
1064 margin: 25px 0;
1065 display: block;
1066 }
1067 .status-badge {
1068 display: inline-block;
1069 padding: 6px 12px;
1070 border-radius: 20px;
1071 font-size: 12px;
1072 font-weight: 600;
1073 text-transform: uppercase;
1074 letter-spacing: 0.5px;
1075 }
1076 .status-paid {
1077 background: #d1fae5;
1078 color: #065f46;
1079 }
1080 .status-pending {
1081 background: #fef3c7;
1082 color: #92400e;
1083 }
1084 .status-overdue {
1085 background: #fee2e2;
1086 color: #991b1b;
1087 }
1088 @media only screen and (max-width: 600px) {
1089 .email-content { padding: 25px 20px; }
1090 .email-header { padding: 35px 20px; }
1091 .email-header h1 { font-size: 24px; }
1092 .email-content h2 { font-size: 22px; }
1093 .highlight-box, .info-box, .success-box, .warning-box { padding: 20px; }
1094 .amount-highlight { font-size: 24px; }
1095 }
1096 </style>
1097 </head>
1098 <body>
1099 <div class="email-container">
1100 ' . $logo_html . '
1101 <div class="email-content">
1102 ' . wpautop($message) . '
1103 </div>
1104 ' . $footer_html . '
1105 </div>
1106 </body>
1107 </html>';
1108 }
1109
1110 /**
1111 * Register email settings
1112 */
1113 public function registerEmailSettings(): void {
1114 // Email settings section
1115 add_settings_section(
1116 'easy_invoice_email_settings',
1117 __('Email Configuration', 'easy-invoice'),
1118 [$this, 'emailSettingsSectionCallback'],
1119 'easy_invoice_settings'
1120 );
1121
1122 // Register settings
1123 $yes_no = static function ($value) {
1124 return in_array((string) $value, ['yes', '1', 'on', 'true'], true) ? 'yes' : 'no';
1125 };
1126 register_setting('easy_invoice_settings', 'easy_invoice_email_from_name', ['sanitize_callback' => 'sanitize_text_field']);
1127 register_setting('easy_invoice_settings', 'easy_invoice_email_from_address', ['sanitize_callback' => 'sanitize_email']);
1128 register_setting('easy_invoice_settings', 'easy_invoice_email_reply_to', ['sanitize_callback' => 'sanitize_email']);
1129 register_setting('easy_invoice_settings', 'easy_invoice_email_reply_to_name', ['sanitize_callback' => 'sanitize_text_field']);
1130 register_setting('easy_invoice_settings', 'easy_invoice_enable_email_styling', ['sanitize_callback' => $yes_no]);
1131 register_setting('easy_invoice_settings', 'easy_invoice_email_logo', ['sanitize_callback' => 'esc_url_raw']);
1132 register_setting('easy_invoice_settings', 'easy_invoice_email_footer_text', ['sanitize_callback' => 'wp_kses_post']);
1133 register_setting('easy_invoice_settings', 'easy_invoice_bcc_admin', ['sanitize_callback' => $yes_no]);
1134 register_setting('easy_invoice_settings', 'easy_invoice_admin_email', ['sanitize_callback' => 'sanitize_email']);
1135
1136 // Add settings fields
1137 add_settings_field(
1138 'easy_invoice_email_from_name',
1139 __('From Name', 'easy-invoice'),
1140 [$this, 'textFieldCallback'],
1141 'easy_invoice_settings',
1142 'easy_invoice_email_settings',
1143 ['label_for' => 'easy_invoice_email_from_name']
1144 );
1145
1146 add_settings_field(
1147 'easy_invoice_email_from_address',
1148 __('From Email Address', 'easy-invoice'),
1149 [$this, 'emailFieldCallback'],
1150 'easy_invoice_settings',
1151 'easy_invoice_email_settings',
1152 ['label_for' => 'easy_invoice_email_from_address']
1153 );
1154
1155 add_settings_field(
1156 'easy_invoice_email_reply_to',
1157 __('Reply-To Email', 'easy-invoice'),
1158 [$this, 'emailFieldCallback'],
1159 'easy_invoice_settings',
1160 'easy_invoice_email_settings',
1161 ['label_for' => 'easy_invoice_email_reply_to']
1162 );
1163
1164 add_settings_field(
1165 'easy_invoice_enable_email_styling',
1166 __('Enable HTML Emails', 'easy-invoice'),
1167 [$this, 'checkboxFieldCallback'],
1168 'easy_invoice_settings',
1169 'easy_invoice_email_settings',
1170 ['label_for' => 'easy_invoice_enable_email_styling']
1171 );
1172
1173 add_settings_field(
1174 'easy_invoice_bcc_admin',
1175 __('BCC Admin on All Emails', 'easy-invoice'),
1176 [$this, 'checkboxFieldCallback'],
1177 'easy_invoice_settings',
1178 'easy_invoice_email_settings',
1179 ['label_for' => 'easy_invoice_bcc_admin']
1180 );
1181 }
1182
1183
1184 /**
1185 * Email settings section callback
1186 */
1187 public function emailSettingsSectionCallback(): void {
1188 echo '<p>' . esc_html__('Configure how emails are sent from Easy Invoice.', 'easy-invoice') . '</p>';
1189 }
1190
1191 /**
1192 * Text field callback
1193 *
1194 * @param array $args Field arguments
1195 */
1196 public function textFieldCallback(array $args): void {
1197 $field_id = $args['label_for'];
1198 $value = get_option($field_id, '');
1199 echo '<input type="text" id="' . esc_attr($field_id) . '" name="' . esc_attr($field_id) . '" value="' . esc_attr($value) . '" class="regular-text">';
1200 }
1201
1202 /**
1203 * Email field callback
1204 *
1205 * @param array $args Field arguments
1206 */
1207 public function emailFieldCallback(array $args): void {
1208 $field_id = $args['label_for'];
1209 $value = get_option($field_id, '');
1210 echo '<input type="email" id="' . esc_attr($field_id) . '" name="' . esc_attr($field_id) . '" value="' . esc_attr($value) . '" class="regular-text">';
1211 }
1212
1213 /**
1214 * Checkbox field callback
1215 *
1216 * @param array $args Field arguments
1217 */
1218 public function checkboxFieldCallback(array $args): void {
1219 $field_id = $args['label_for'];
1220 $value = get_option($field_id, '');
1221 echo '<input type="checkbox" id="' . esc_attr($field_id) . '" name="' . esc_attr($field_id) . '" value="yes"' . checked($value, 'yes', false) . '>';
1222 echo '<span class="description">' . esc_html__('Enable this option', 'easy-invoice') . '</span>';
1223 }
1224
1225 /**
1226 * Log email sent
1227 *
1228 * @param Invoice $invoice The invoice
1229 * @param string $email The email address
1230 * @param string $type The email type
1231 */
1232 public function logEmailSent($invoice, string $email, string $type): void {
1233 $this->log(sprintf('Email sent to %s for invoice #%s (%s)', $email, $invoice->getNumber(), $type), 'info');
1234 }
1235
1236 /**
1237 * Log email failed
1238 *
1239 * @param Invoice $invoice The invoice
1240 * @param string $email The email address
1241 * @param string $type The email type
1242 */
1243 public function logEmailFailed($invoice, string $email, string $type): void {
1244 $this->log(sprintf('Email failed to %s for invoice #%s (%s)', $email, $invoice->getNumber(), $type), 'error');
1245 }
1246
1247 /**
1248 * Get default invoice template
1249 *
1250 * @return string Template
1251 */
1252 /**
1253 * Replace the 2.3.x stock email bodies with the 2.4.0 ones — once, and
1254 * only where the saved body is still the stock text (compared by its
1255 * words, since the editor re-wraps markup on save). A body the site
1256 * edited is left alone.
1257 */
1258 public static function refreshStockTemplates(): void {
1259 if ( get_option( 'easy_invoice_email_stock_v240' ) ) {
1260 return;
1261 }
1262 $old = [
1263 'invoice' => [ '83846ae6875ad4335976cc07140e56bc', 'b40d303fa4194c0da4257495fa9e138f' ],
1264 'quote' => [ '7f31d11b8368fce31daddb7cbace9fb1', 'f9b9c4ad911ac729a04e52e35d056968' ],
1265 'payment' => [ '52c9e647eac3d10ea39cf641e2bfc2b0' ],
1266 ];
1267 foreach ( $old as $kind => $fingerprints ) {
1268 $key = 'easy_invoice_' . $kind . '_email_body';
1269 $stored = get_option( $key, null );
1270 if ( null === $stored || '' === $stored ) {
1271 continue;
1272 }
1273 $words = preg_replace( '/[^A-Za-z0-9{}]/u', '', html_entity_decode( wp_strip_all_tags( stripslashes( (string) $stored ) ) ) );
1274 if ( in_array( md5( (string) $words ), $fingerprints, true ) ) {
1275 update_option( $key, self::defaultTemplate( $kind ) );
1276 }
1277 }
1278 update_option( 'easy_invoice_email_stock_v240', 1, false );
1279 }
1280
1281 /**
1282 * The stock body for one of the emails, used wherever a default is needed
1283 * (settings screen, activation seeding, sending when nothing is saved).
1284 *
1285 * @param string $kind invoice | reminder | payment | quote | quote_accepted | quote_declined
1286 * @return string
1287 */
1288 public static function defaultTemplate( string $kind ): string {
1289 switch ( $kind ) {
1290 case 'reminder': return self::getDefaultReminderTemplate();
1291 case 'payment': return self::getDefaultPaymentTemplate();
1292 case 'quote': return self::getDefaultQuoteTemplate();
1293 case 'quote_accepted': return self::getDefaultQuoteAcceptedTemplate();
1294 case 'quote_declined': return self::getDefaultQuoteDeclinedTemplate();
1295 default: return self::getDefaultInvoiceTemplate();
1296 }
1297 }
1298
1299 private static function getDefaultInvoiceTemplate(): string {
1300 return '<h2>Invoice {{invoice_number}}</h2>
1301
1302 <p>Dear {{client_name}},</p>
1303
1304 <p>Please find invoice {{invoice_number}} for <strong>{{total_amount}}</strong>, due on <strong>{{due_date}}</strong>. The invoice is attached and can also be viewed and paid online:</p>
1305
1306 <p style="text-align:center;margin:28px 0;"><a class="button" href="{{invoice_url}}">View and pay invoice</a></p>
1307 <p style="font-size:13px;color:#6b7280;word-break:break-all;">{{invoice_url}}</p>
1308
1309 <div class="info-box">
1310 <p>Invoice number: {{invoice_number}}<br>
1311 Amount due: {{amount_due}}<br>
1312 Due date: {{due_date}}</p>
1313 </div>
1314
1315 <p>If you have any questions about this invoice, just reply to this email.</p>
1316
1317 <div class="divider"></div>
1318
1319 <p>Thank you for your business.</p>
1320
1321 <p>{{company_name}}<br>
1322 {{company_email}}</p>';
1323 }
1324
1325 private static function getDefaultReminderTemplate(): string {
1326 return '<h2>Payment reminder — invoice {{invoice_number}}</h2>
1327
1328 <p>Dear {{client_name}},</p>
1329
1330 <p>A reminder that invoice {{invoice_number}} was due on <strong>{{due_date}}</strong>; <strong>{{amount_due}}</strong> is still outstanding. If you have already paid, please disregard this message.</p>
1331
1332 <p style="text-align:center;margin:28px 0;"><a class="button" href="{{invoice_url}}">View and pay invoice</a></p>
1333 <p style="font-size:13px;color:#6b7280;word-break:break-all;">{{invoice_url}}</p>
1334
1335 <p>If you have a question about the invoice or need to arrange payment, reply to this email and we will sort it out.</p>
1336
1337 <div class="divider"></div>
1338
1339 <p>Thank you.</p>
1340
1341 <p>{{company_name}}<br>
1342 {{company_email}}</p>';
1343 }
1344
1345 private static function getDefaultPaymentTemplate(): string {
1346 return '<h2>Payment received — thank you</h2>
1347
1348 <p>Dear {{client_name}},</p>
1349
1350 <p>We have received your payment of <strong>{{payment_amount}}</strong> against invoice {{invoice_number}}.</p>
1351
1352 <div class="info-box">
1353 <p>Invoice: {{invoice_number}}<br>
1354 Amount paid: {{payment_amount}}<br>
1355 Date: {{payment_date}}<br>
1356 Method: {{payment_method}}<br>
1357 Reference: {{transaction_id}}<br>
1358 Balance remaining: {{amount_due}}</p>
1359 </div>
1360
1361 <p>Keep this email as your receipt. If you need anything else, reply to this message.</p>
1362
1363 <div class="divider"></div>
1364
1365 <p>Thank you for your business.</p>
1366
1367 <p>{{company_name}}<br>
1368 {{company_email}}</p>';
1369 }
1370
1371 private static function getDefaultQuoteTemplate(): string {
1372 return '<h2>Quote {{quote_number}}</h2>
1373
1374 <p>Dear {{client_name}},</p>
1375
1376 <p>Please find our quote {{quote_number}} for <strong>{{total_amount}}</strong>, valid until <strong>{{expiry_date}}</strong>. It is attached, and you can review, accept or decline it online:</p>
1377
1378 <p style="text-align:center;margin:28px 0;"><a class="button" href="{{quote_url}}">View quote</a></p>
1379 <p style="font-size:13px;color:#6b7280;word-break:break-all;">{{quote_url}}</p>
1380
1381 <div class="info-box">
1382 <p>Quote number: {{quote_number}}<br>
1383 Amount: {{total_amount}}<br>
1384 Valid until: {{expiry_date}}</p>
1385 </div>
1386
1387 <p>If you would like to discuss any part of it, just reply to this email.</p>
1388
1389 <div class="divider"></div>
1390
1391 <p>We look forward to working with you.</p>
1392
1393 <p>{{company_name}}<br>
1394 {{company_email}}</p>';
1395 }
1396
1397 private static function getDefaultQuoteAcceptedTemplate(): string {
1398 return '<h2>Quote {{quote_number}} accepted</h2>
1399
1400 <p>Dear {{client_name}},</p>
1401
1402 <p>Thank you for accepting quote {{quote_number}} for <strong>{{total_amount}}</strong> on {{acceptance_date}}.</p>
1403
1404 <p>We will send the invoice and any next steps shortly. If you have questions in the meantime, reply to this email.</p>
1405
1406 <div class="divider"></div>
1407
1408 <p>Thank you for choosing us.</p>
1409
1410 <p>{{company_name}}<br>
1411 {{company_email}}</p>';
1412 }
1413
1414 private static function getDefaultQuoteDeclinedTemplate(): string {
1415 return '<h2>Quote {{quote_number}}</h2>
1416
1417 <p>Dear {{client_name}},</p>
1418
1419 <p>Thank you for letting us know that quote {{quote_number}} is not going ahead ({{response_date}}).</p>
1420
1421 <div class="info-box">
1422 <p>Reason given: {{decline_reason}}</p>
1423 </div>
1424
1425 <p>If your requirements change, or there is something we could adjust, we would be glad to prepare a revised quote — just reply to this email.</p>
1426
1427 <div class="divider"></div>
1428
1429 <p>{{company_name}}<br>
1430 {{company_email}}</p>';
1431 }
1432
1433 /**
1434 * Refresh settings and templates
1435 * Call this method when settings are updated
1436 */
1437 public function refreshSettings(): void {
1438 $this->loadSettings();
1439 $this->loadTemplates();
1440 }
1441
1442 /**
1443 * Get email templates
1444 *
1445 * @return array Templates
1446 */
1447 public function getTemplates(): array {
1448 return $this->templates;
1449 }
1450
1451 /**
1452 * Get email settings
1453 *
1454 * @return array Settings
1455 */
1456 public function getSettings(): array {
1457 return $this->settings;
1458 }
1459
1460 /**
1461 * Test email functionality
1462 *
1463 * @param string $to_email Email to send test to
1464 * @return array Result
1465 */
1466 public function testEmail(string $to_email): array {
1467 $subject = 'Easy Invoice - Email Configuration Test';
1468 $message = '<h2>🧪 Email Configuration Test</h2>
1469
1470 <p>Hello!</p>
1471
1472 <div class="success-box">
1473 <p><strong>�
1474 Test Email Successfully Sent</strong><br>
1475 Date: <strong>' . current_time('Y-m-d H:i:s') . '</strong><br>
1476 To: <strong>' . esc_html($to_email) . '</strong></p>
1477 </div>
1478
1479 <p>This is a test email to verify that your Easy Invoice email configuration is working correctly.</p>
1480
1481 <div class="info-box">
1482 <p><strong>⚙️ Email Settings Verified:</strong><br>
1483 • From Name: ' . esc_html($this->settings['from_name']) . '<br>
1484 • From Email: ' . esc_html($this->settings['from_email']) . '<br>
1485 • Reply-To: ' . esc_html($this->settings['reply_to_email'] ?: 'Not set') . '<br>
1486 • HTML Emails: ' . ($this->settings['enable_html'] === 'yes' ? 'Enabled' : 'Disabled') . '</p>
1487 </div>
1488
1489 <div class="highlight-box">
1490 <p><strong>🎉 Congratulations!</strong> If you received this email, your email configuration is working properly and you can now send invoices, quotes, and payment confirmations to your clients.</p>
1491 </div>
1492
1493 <div class="divider"></div>
1494
1495 <p>Thank you for using Easy Invoice!</p>
1496
1497 <p>Best regards,<br>
1498 <strong>' . esc_html($this->settings['from_name']) . '</strong></p>';
1499
1500 if ($this->settings['enable_html'] === 'yes') {
1501 $message = $this->wrapInHtmlTemplate($message);
1502 }
1503
1504 $headers = $this->prepareEmailHeaders();
1505
1506 $sent = $this->sendEmail($to_email, $subject, $message, $headers);
1507
1508 if ($sent) {
1509 return ['success' => true, 'message' => __('Test email sent successfully', 'easy-invoice')];
1510 } else {
1511 return ['success' => false, 'message' => __('Failed to send test email', 'easy-invoice')];
1512 }
1513 }
1514
1515 /**
1516 * Send test template email with custom subject and body
1517 *
1518 * @param string $to_email Email to send test to
1519 * @param string $subject Email subject
1520 * @param string $body Email body
1521 * @return array Result
1522 */
1523 public function sendTestTemplateEmail(string $to_email, string $subject, string $body): array {
1524 if ($this->settings['enable_html'] === 'yes') {
1525 $body = $this->wrapInHtmlTemplate($body);
1526 }
1527
1528 $headers = $this->prepareEmailHeaders();
1529
1530 $sent = $this->sendEmail($to_email, $subject, $body, $headers);
1531
1532 if ($sent) {
1533 return ['success' => true, 'message' => __('Template test email sent successfully', 'easy-invoice')];
1534 } else {
1535 return ['success' => false, 'message' => __('Failed to send template test email', 'easy-invoice')];
1536 }
1537 }
1538
1539 /**
1540 * Send payment received email
1541 *
1542 * @param Invoice $invoice The invoice
1543 * @param array $payment_data Payment data
1544 * @return array Result array with success status and message
1545 */
1546 public function sendPaymentEmail(Invoice $invoice, array $payment_data = []): array {
1547 try {
1548 // Validate invoice
1549 if (!$invoice || !$invoice->getId()) {
1550 return ['success' => false, 'message' => __('Invalid invoice', 'easy-invoice')];
1551 }
1552
1553 // Get client email
1554 $client_email = $invoice->getCustomerEmail();
1555 if (empty($client_email)) {
1556 return ['success' => false, 'message' => __('Client email is missing', 'easy-invoice')];
1557 }
1558
1559 // Get template
1560 $template_key = 'invoice_paid';
1561 if (!isset($this->templates[$template_key])) {
1562 return ['success' => false, 'message' => __('Payment email template not found', 'easy-invoice')];
1563 }
1564
1565 $template = $this->templates[$template_key];
1566 /** This filter is documented above in sendInvoiceEmail(). */
1567 $template = (array) apply_filters('easy_invoice_email_template_data', $template, $template_key, $invoice);
1568
1569 // Check if email is enabled
1570 if (!$template['enabled']) {
1571 return ['success' => false, 'message' => __('Payment received email is disabled', 'easy-invoice')];
1572 }
1573
1574 // Prepare email data
1575 $email_data = $this->preparePaymentEmailData($invoice, $template, $payment_data);
1576
1577 // Send email
1578 $sent = $this->sendEmail(
1579 $email_data['to'],
1580 $email_data['subject'],
1581 $email_data['message'],
1582 $email_data['headers']
1583 );
1584
1585 /**
1586 * Fires once an email send has finished, whether or not it went out.
1587 *
1588 * @param object $document Invoice or Quote model.
1589 * @param string $template_key Template key.
1590 * @param bool $sent Whether wp_mail() accepted it.
1591 */
1592 do_action('easy_invoice_email_finished', $invoice, $template_key, (bool) $sent);
1593
1594 if ($sent) {
1595 // Log success
1596 do_action('easy_invoice_payment_email_sent', $invoice, $client_email, $payment_data);
1597
1598 return [
1599 'success' => true,
1600 'message' => __('Payment email sent successfully', 'easy-invoice'),
1601 'email_data' => $email_data
1602 ];
1603 } else {
1604 // Log failure
1605 do_action('easy_invoice_payment_email_failed', $invoice, $client_email, $payment_data);
1606
1607 return ['success' => false, 'message' => __('Failed to send payment email', 'easy-invoice')];
1608 }
1609
1610 } catch (\Exception $e) {
1611 $this->log('Payment email sending error: ' . $e->getMessage(), 'error');
1612 return ['success' => false, 'message' => __('Error sending payment email: ', 'easy-invoice') . $e->getMessage()];
1613 }
1614 }
1615
1616 /**
1617 * Send admin notification when payment is received
1618 *
1619 * @param Invoice $invoice The invoice
1620 * @param array $payment_data Payment data (method, amount, etc.)
1621 * @return array Result array with success status and message
1622 */
1623 public function sendAdminPaymentNotification(Invoice $invoice, array $payment_data = []): array {
1624 try {
1625 // Validate invoice
1626 if (!$invoice || !$invoice->getId()) {
1627 return ['success' => false, 'message' => __('Invalid invoice', 'easy-invoice')];
1628 }
1629
1630 // Get admin email
1631 $admin_email = $this->settings['admin_email'] ?? get_option('admin_email');
1632 if (empty($admin_email)) {
1633 return ['success' => false, 'message' => __('Admin email is missing', 'easy-invoice')];
1634 }
1635
1636 // Get payment method
1637 $payment_method = $payment_data['payment_method'] ?? $payment_data['method'] ?? 'online';
1638 $payment_method_label = $this->getPaymentMethodLabel($payment_method);
1639
1640 // Format amount
1641 $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($invoice);
1642 // The payment that came in, not the invoice's face value.
1643 $amount = $formatter->format(isset($payment_data['amount']) && (float) $payment_data['amount'] > 0 ? (float) $payment_data['amount'] : $invoice->getTotal());
1644
1645 // Prepare email subject
1646 $pending = !empty($payment_data['pending']);
1647 $subject = sprintf(
1648 /* translators: %s: document number. */
1649 $pending ? __('Payment awaiting verification - Invoice #%s', 'easy-invoice') : __('New Payment Received - Invoice #%s', 'easy-invoice'),
1650 $invoice->getNumber()
1651 );
1652
1653 // Prepare email message
1654 $message = $this->prepareAdminPaymentNotificationMessage($invoice, $payment_method_label, $amount, $payment_data);
1655
1656 // Add HTML wrapper if enabled
1657 if ($this->settings['enable_html'] === 'yes') {
1658 $message = $this->wrapInHtmlTemplate($message);
1659 }
1660
1661 // Prepare headers
1662 $headers = $this->prepareEmailHeaders();
1663
1664 // Send email
1665 $sent = $this->sendEmail($admin_email, $subject, $message, $headers);
1666
1667 if ($sent) {
1668 do_action('easy_invoice_admin_payment_notification_sent', $invoice, $admin_email, $payment_data);
1669 return [
1670 'success' => true,
1671 'message' => __('Admin notification sent successfully', 'easy-invoice')
1672 ];
1673 } else {
1674 do_action('easy_invoice_admin_payment_notification_failed', $invoice, $admin_email, $payment_data);
1675 return ['success' => false, 'message' => __('Failed to send admin notification', 'easy-invoice')];
1676 }
1677
1678 } catch (\Exception $e) {
1679 $this->log('Admin payment notification error: ' . $e->getMessage(), 'error');
1680 return ['success' => false, 'message' => __('Error sending admin notification: ', 'easy-invoice') . $e->getMessage()];
1681 }
1682 }
1683
1684 /**
1685 * Tell the admin a manual payment is waiting for verification.
1686 *
1687 * @param int $invoice_id The invoice paid.
1688 * @param string $payment_method Gateway or payment type submitted.
1689 */
1690 public function handleManualPaymentSubmitted($invoice_id, $payment_method = 'manual'): void {
1691 $post = get_post((int) $invoice_id);
1692 if (!$post) {
1693 return;
1694 }
1695 $invoice = new Invoice($post);
1696 if (!$invoice->getId()) {
1697 return;
1698 }
1699 $payment_data = [
1700 'payment_method' => (string) $payment_method,
1701 'pending' => true,
1702 ];
1703 $notes = get_post_meta($invoice->getId(), '_manual_payment_notes', true);
1704 if ($notes) {
1705 $payment_data['notes'] = $notes;
1706 }
1707 $this->sendAdminPaymentNotification($invoice, $payment_data);
1708 }
1709
1710 /**
1711 * Send payment confirmation email to customer
1712 *
1713 * @param Invoice $invoice The invoice
1714 * @param array $payment_data Payment data
1715 * @return array Result array with success status and message
1716 */
1717 public function sendPaymentConfirmationEmail(Invoice $invoice, array $payment_data = []): array {
1718 try {
1719 // Validate invoice
1720 if (!$invoice || !$invoice->getId()) {
1721 return ['success' => false, 'message' => __('Invalid invoice', 'easy-invoice')];
1722 }
1723
1724 // Get customer email
1725 $customer_email = $invoice->getCustomerEmail();
1726 if (empty($customer_email)) {
1727 return ['success' => false, 'message' => __('Customer email is missing', 'easy-invoice')];
1728 }
1729
1730 // Get currency settings
1731 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
1732 $settings = $settings_controller->getSettings();
1733 $currency_code = $settings['easy_invoice_currency_code'] ?? 'USD';
1734 $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
1735
1736 // Format amount
1737 $amount = $invoice->getTotal();
1738 $formatted_amount = $currency_symbol . number_format($amount, 2);
1739
1740 // Prepare email subject
1741 $site_name = get_bloginfo('name');
1742 $subject = sprintf(
1743 /* translators: %1$s: site name; %2$s: document number. */
1744 __('[%1$s] Payment Confirmed - Invoice #%2$s', 'easy-invoice'),
1745 $site_name,
1746 $invoice->getNumber()
1747 );
1748
1749 // Prepare email message
1750 $message = $this->preparePaymentConfirmationMessage($invoice, $formatted_amount);
1751
1752 // Add HTML wrapper if enabled
1753 if ($this->settings['enable_html'] === 'yes') {
1754 $message = $this->wrapInHtmlTemplate($message);
1755 }
1756
1757 // Prepare headers
1758 $headers = $this->prepareEmailHeaders();
1759
1760 // Add BCC to admin if enabled (but skip if this is from payment completion hook to avoid duplicate)
1761 // The payment completion hook already sends a dedicated admin notification
1762 $skip_bcc = isset($payment_data['skip_bcc']) && $payment_data['skip_bcc'] === true;
1763 if (!$skip_bcc && $this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) {
1764 $headers[] = 'Bcc: ' . $this->settings['admin_email'];
1765 }
1766
1767 // Send email
1768 $sent = $this->sendEmail($customer_email, $subject, $message, $headers);
1769
1770 if ($sent) {
1771 do_action('easy_invoice_payment_confirmation_sent', $invoice, $customer_email, $payment_data);
1772 return [
1773 'success' => true,
1774 'message' => __('Payment confirmation email sent successfully', 'easy-invoice')
1775 ];
1776 } else {
1777 do_action('easy_invoice_payment_confirmation_failed', $invoice, $customer_email, $payment_data);
1778 return ['success' => false, 'message' => __('Failed to send payment confirmation email', 'easy-invoice')];
1779 }
1780
1781 } catch (\Exception $e) {
1782 $this->log('Payment confirmation email error: ' . $e->getMessage(), 'error');
1783 return ['success' => false, 'message' => __('Error sending payment confirmation: ', 'easy-invoice') . $e->getMessage()];
1784 }
1785 }
1786
1787 /**
1788 * Send payment rejection email to customer
1789 *
1790 * @param Invoice $invoice The invoice
1791 * @param string $reason Rejection reason
1792 * @return array Result array with success status and message
1793 */
1794 public function sendPaymentRejectionEmail(Invoice $invoice, string $reason = ''): array {
1795 try {
1796 // Validate invoice
1797 if (!$invoice || !$invoice->getId()) {
1798 return ['success' => false, 'message' => __('Invalid invoice', 'easy-invoice')];
1799 }
1800
1801 // Get customer email
1802 $customer_email = $invoice->getCustomerEmail();
1803 if (empty($customer_email)) {
1804 return ['success' => false, 'message' => __('Customer email is missing', 'easy-invoice')];
1805 }
1806
1807 // Prepare email subject
1808 $site_name = get_bloginfo('name');
1809 $subject = sprintf(
1810 /* translators: %1$s: site name; %2$s: document number. */
1811 __('[%1$s] Payment Rejected - Invoice #%2$s', 'easy-invoice'),
1812 $site_name,
1813 $invoice->getNumber()
1814 );
1815
1816 // Prepare email message
1817 $message = $this->preparePaymentRejectionMessage($invoice, $reason);
1818
1819 // Add HTML wrapper if enabled
1820 if ($this->settings['enable_html'] === 'yes') {
1821 $message = $this->wrapInHtmlTemplate($message);
1822 }
1823
1824 // Prepare headers
1825 $headers = $this->prepareEmailHeaders();
1826
1827 // Add BCC to admin if enabled
1828 if ($this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) {
1829 $headers[] = 'Bcc: ' . $this->settings['admin_email'];
1830 }
1831
1832 // Send email
1833 $sent = $this->sendEmail($customer_email, $subject, $message, $headers);
1834
1835 if ($sent) {
1836 do_action('easy_invoice_payment_rejection_sent', $invoice, $customer_email, $reason);
1837 return [
1838 'success' => true,
1839 'message' => __('Payment rejection email sent successfully', 'easy-invoice')
1840 ];
1841 } else {
1842 do_action('easy_invoice_payment_rejection_failed', $invoice, $customer_email, $reason);
1843 return ['success' => false, 'message' => __('Failed to send payment rejection email', 'easy-invoice')];
1844 }
1845
1846 } catch (\Exception $e) {
1847 $this->log('Payment rejection email error: ' . $e->getMessage(), 'error');
1848 return ['success' => false, 'message' => __('Error sending payment rejection: ', 'easy-invoice') . $e->getMessage()];
1849 }
1850 }
1851
1852 /**
1853 * Prepare admin payment notification message
1854 *
1855 * @param Invoice $invoice The invoice
1856 * @param string $payment_method_label Payment method label
1857 * @param string $amount Formatted amount
1858 * @param array $payment_data Payment data
1859 * @return string Email message
1860 */
1861 private function prepareAdminPaymentNotificationMessage(Invoice $invoice, string $payment_method_label, string $amount, array $payment_data = []): string {
1862 $invoice_number = $invoice->getNumber();
1863 $customer_name = $invoice->getCustomerName();
1864 $customer_email = $invoice->getCustomerEmail();
1865 $invoice_id = $invoice->getId();
1866
1867 $pending = !empty($payment_data['pending']);
1868 $message = sprintf(
1869 /* translators: %1$s: payment method; %2$s: invoice number. */
1870 $pending ? __('A %1$s payment has been submitted for invoice #%2$s and is waiting for your verification.', 'easy-invoice') : __('A new %1$s payment has been received for invoice #%2$s.', 'easy-invoice'),
1871 $payment_method_label,
1872 $invoice_number
1873 );
1874 $message .= "\n\n";
1875 /* translators: . */
1876 $message .= __('Invoice Details:', 'easy-invoice');
1877 $message .= "\n";
1878 /* translators: %s: amount. */
1879 $message .= sprintf($pending ? __('- Amount submitted: %s', 'easy-invoice') : __('- Amount received: %s', 'easy-invoice'), $amount);
1880 $message .= "\n";
1881 $ei_due = \EasyInvoice\Services\InvoiceBalance::due($invoice);
1882 /* translators: %s: amount. */
1883 $message .= sprintf(__('- Still owed: %s', 'easy-invoice'), (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($ei_due));
1884 $message .= "\n";
1885 /* translators: %s: customer name. */
1886 $message .= sprintf(__('- Customer: %s', 'easy-invoice'), $customer_name);
1887 $message .= "\n";
1888 /* translators: %s: customer email address. */
1889 $message .= sprintf(__('- Email: %s', 'easy-invoice'), $customer_email);
1890
1891 if (!empty($payment_data['notes'])) {
1892 $message .= "\n";
1893 /* translators: %s: note left by the client. */
1894 $message .= sprintf(__('- Client note: %s', 'easy-invoice'), $payment_data['notes']);
1895 }
1896
1897 // Add transaction ID if available
1898 if (!empty($payment_data['transaction_id'])) {
1899 $message .= "\n";
1900 /* translators: %s: transaction id. */
1901 $message .= sprintf(__('- Transaction ID: %s', 'easy-invoice'), $payment_data['transaction_id']);
1902 }
1903
1904 $message .= "\n\n";
1905 $message .= __('Please review this payment in the admin dashboard:', 'easy-invoice');
1906 $message .= "\n";
1907 $message .= admin_url('admin.php?page=easy-invoice-payments&action=verify&invoice_id=' . $invoice_id);
1908 $message .= "\n\n";
1909 $message .= __('This is an automated message from Easy Invoice.', 'easy-invoice');
1910
1911 return $message;
1912 }
1913
1914 /**
1915 * Prepare payment confirmation message
1916 *
1917 * @param Invoice $invoice The invoice
1918 * @param string $formatted_amount Formatted amount
1919 * @return string Email message
1920 */
1921 private function preparePaymentConfirmationMessage(Invoice $invoice, string $formatted_amount): string {
1922 $customer_name = $invoice->getCustomerName();
1923 $invoice_number = $invoice->getNumber();
1924 $site_name = get_bloginfo('name');
1925 $company_name = get_option('easy_invoice_company_name', $site_name);
1926
1927 /* translators: %s: customer name. */
1928 /* translators: %s: customer name. */
1929 $message = sprintf(__('Dear %s,', 'easy-invoice'), $customer_name);
1930 $message .= "\n\n";
1931 $message .= sprintf(
1932 /* translators: %1$s: amount paid; %2$s: invoice number. */
1933 __('We are pleased to confirm that your payment of %1$s for Invoice #%2$s has been received and processed successfully.', 'easy-invoice'),
1934 $formatted_amount,
1935 $invoice_number
1936 );
1937 $message .= "\n\n";
1938 $message .= __('Thank you for your business.', 'easy-invoice');
1939 $message .= "\n\n";
1940 $message .= __('Regards,', 'easy-invoice');
1941 $message .= "\n";
1942 $message .= $company_name;
1943
1944 return $message;
1945 }
1946
1947 /**
1948 * Prepare payment rejection message
1949 *
1950 * @param Invoice $invoice The invoice
1951 * @param string $reason Rejection reason
1952 * @return string Email message
1953 */
1954 private function preparePaymentRejectionMessage(Invoice $invoice, string $reason = ''): string {
1955 $customer_name = $invoice->getCustomerName();
1956 $invoice_number = $invoice->getNumber();
1957 $site_name = get_bloginfo('name');
1958 $company_name = get_option('easy_invoice_company_name', $site_name);
1959
1960 /* translators: %s: customer name. */
1961 /* translators: %s: customer name. */
1962 $message = sprintf(__('Dear %s,', 'easy-invoice'), $customer_name);
1963 $message .= "\n\n";
1964 $message .= sprintf(
1965 /* translators: %s: invoice number. */
1966 __('We regret to inform you that your payment for Invoice #%s has been rejected.', 'easy-invoice'),
1967 $invoice_number
1968 );
1969
1970 if (!empty($reason)) {
1971 $message .= "\n\n";
1972 $message .= __('Reason:', 'easy-invoice');
1973 $message .= "\n";
1974 $message .= $reason;
1975 }
1976
1977 $message .= "\n\n";
1978 $message .= __('Please contact us if you have any questions or concerns.', 'easy-invoice');
1979 $message .= "\n\n";
1980 $message .= __('Regards,', 'easy-invoice');
1981 $message .= "\n";
1982 $message .= $company_name;
1983
1984 return $message;
1985 }
1986
1987 /**
1988 * Send admin notification for quote acceptance/decline
1989 *
1990 * @param Quote $quote The quote
1991 * @param string $action Action type ('accepted' or 'declined')
1992 * @return array Result array with success status and message
1993 */
1994 public function sendAdminQuoteNotification(Quote $quote, string $action = 'accepted'): array {
1995 try {
1996 // Validate quote
1997 if (!$quote || !$quote->getId()) {
1998 return ['success' => false, 'message' => __('Invalid quote', 'easy-invoice')];
1999 }
2000
2001 // Get admin email
2002 $admin_email = $this->settings['admin_email'] ?? get_option('admin_email');
2003 if (empty($admin_email)) {
2004 return ['success' => false, 'message' => __('Admin email is missing', 'easy-invoice')];
2005 }
2006
2007 // Prepare email subject
2008 $subject = sprintf(
2009 /* translators: %1$s: document number; %2$s: value. */
2010 __('Quote %1$s has been %2$s', 'easy-invoice'),
2011 $quote->getNumber(),
2012 $action === 'accepted' ? __('accepted', 'easy-invoice') : __('declined', 'easy-invoice')
2013 );
2014
2015 // Prepare email message
2016 $message = $this->prepareAdminQuoteNotificationMessage($quote, $action);
2017
2018 // Add HTML wrapper if enabled
2019 if ($this->settings['enable_html'] === 'yes') {
2020 $message = $this->wrapInHtmlTemplate($message);
2021 }
2022
2023 // Prepare headers
2024 $headers = $this->prepareEmailHeaders();
2025
2026 // Send email
2027 $sent = $this->sendEmail($admin_email, $subject, $message, $headers);
2028
2029 if ($sent) {
2030 do_action('easy_invoice_admin_quote_notification_sent', $quote, $admin_email, $action);
2031 return [
2032 'success' => true,
2033 'message' => __('Admin notification sent successfully', 'easy-invoice')
2034 ];
2035 } else {
2036 do_action('easy_invoice_admin_quote_notification_failed', $quote, $admin_email, $action);
2037 return ['success' => false, 'message' => __('Failed to send admin notification', 'easy-invoice')];
2038 }
2039
2040 } catch (\Exception $e) {
2041 $this->log('Admin quote notification error: ' . $e->getMessage(), 'error');
2042 return ['success' => false, 'message' => __('Error sending admin notification: ', 'easy-invoice') . $e->getMessage()];
2043 }
2044 }
2045
2046 /**
2047 * Prepare admin quote notification message
2048 *
2049 * @param Quote $quote The quote
2050 * @param string $action Action type ('accepted' or 'declined')
2051 * @return string Email message
2052 */
2053 private function prepareAdminQuoteNotificationMessage(Quote $quote, string $action): string {
2054 $site_name = get_bloginfo('name');
2055 $quote_number = $quote->getNumber();
2056 $customer_name = $quote->getCustomerName();
2057
2058 // Format amount
2059 $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($quote);
2060 $formatted_amount = $formatter->format($quote->getTotal());
2061
2062 $action_label = $action === 'accepted' ? __('accepted', 'easy-invoice') : __('declined', 'easy-invoice');
2063 $date_label = $action === 'accepted' ? __('Accepted Date', 'easy-invoice') : __('Declined Date', 'easy-invoice');
2064
2065 /* translators: . */
2066 $message = __('Hello,', 'easy-invoice');
2067 $message .= "\n\n";
2068 $message .= sprintf(
2069 /* translators: %1$s: quote number; %2$s: quote title; %3$s: accepted or declined. */
2070 __('The quote %1$s for %2$s has been %3$s by the client.', 'easy-invoice'),
2071 $quote_number,
2072 $customer_name,
2073 $action_label
2074 );
2075 $message .= "\n\n";
2076 /* translators: . */
2077 $message .= __('Quote Details:', 'easy-invoice');
2078 $message .= "\n";
2079 /* translators: %s: quote number. */
2080 $message .= sprintf(__('- Quote Number: %s', 'easy-invoice'), $quote_number);
2081 $message .= "\n";
2082 /* translators: %s: customer name. */
2083 $message .= sprintf(__('- Client: %s', 'easy-invoice'), $customer_name);
2084 $message .= "\n";
2085 /* translators: %s: amount. */
2086 $message .= sprintf(__('- Total Amount: %s', 'easy-invoice'), $formatted_amount);
2087 $message .= "\n";
2088 /* translators: %1$s: label such as "Accepted on"; %2$s: date and time. */
2089 $message .= sprintf(__('- %1$s: %2$s', 'easy-invoice'), $date_label, date_i18n(get_option('date_format') . ' ' . get_option('time_format')));
2090 $message .= "\n\n";
2091 $message .= __('You can view the quote at:', 'easy-invoice');
2092 $message .= "\n";
2093 $message .= get_permalink($quote->getId());
2094 $message .= "\n\n";
2095 $message .= __('Best regards,', 'easy-invoice');
2096 $message .= "\n";
2097 $message .= $site_name;
2098
2099 return $message;
2100 }
2101
2102 /**
2103 * Handle payment completed hook
2104 * Sends admin notification and customer confirmation when payment is completed
2105 *
2106 * @param int $invoice_id Invoice ID
2107 * @param \EasyInvoice\Models\Invoice $invoice Invoice object
2108 * @param array $payment_data Payment data (method, gateway, transaction_id, amount)
2109 * @return void
2110 */
2111 public function handlePaymentCompleted(int $invoice_id, $invoice, array $payment_data = []): void {
2112 if (!$invoice || !$invoice->getId()) {
2113 return;
2114 }
2115
2116 // Send admin notification
2117 $this->sendAdminPaymentNotification($invoice, $payment_data);
2118
2119 // Send customer confirmation email using proper template system
2120 // Check if payment email is enabled first
2121 if (isset($this->templates['invoice_paid']) && $this->templates['invoice_paid']['enabled']) {
2122 $this->sendInvoiceEmail($invoice, 'paid', array_merge($payment_data, ['skip_bcc' => true, 'payment_receipt' => true]));
2123 }
2124 }
2125
2126 /**
2127 * Get payment method label
2128 *
2129 * @param string $method Payment method
2130 * @return string Payment method label
2131 */
2132 private function getPaymentMethodLabel(string $method): string {
2133 $labels = [
2134 'bank' => __('Bank Transfer', 'easy-invoice'),
2135 'bank_transfer' => __('Bank Transfer', 'easy-invoice'),
2136 'cash' => __('Cash', 'easy-invoice'),
2137 'check' => __('Cheque', 'easy-invoice'),
2138 'cheque' => __('Cheque', 'easy-invoice'),
2139 'paystack' => __('Paystack', 'easy-invoice'),
2140 'moneris' => __('Moneris', 'easy-invoice'),
2141 'other' => __('Other', 'easy-invoice'),
2142 'paypal' => __('PayPal', 'easy-invoice'),
2143 'stripe' => __('Stripe', 'easy-invoice'),
2144 'square' => __('Square', 'easy-invoice'),
2145 'mollie' => __('Mollie', 'easy-invoice'),
2146 'authorizenet' => __('Authorize.Net', 'easy-invoice'),
2147 'manual' => __('Manual Payment', 'easy-invoice'),
2148 'online' => __('Online Payment', 'easy-invoice'),
2149 ];
2150
2151 return $labels[$method] ?? ucfirst($method);
2152 }
2153
2154 }