text = (string) $text; } public function __toString() { return $this->text; } } class Notifications { /** * Filter: the route an acknowledgement is written for. * ( string $route, array $payload ) — 'withdrawal' or 'return' from here. */ const ACK_ROUTE_FILTER = 'careback_ack_route'; /** * Filter: the settings and text keys an acknowledgement is built from. * ( array $template, string $route, array $payload, bool $is_fault ). */ const ACK_TEMPLATE_FILTER = 'careback_ack_template'; /** * Filter: lines printed after the item list of an acknowledgement. * ( array $lines, int $request_id, \WC_Order $order, array $items, * array $payload, Settings $settings ). */ const ACK_LINES_FILTER = 'careback_ack_lines'; private $settings; private $events; public function __construct( Settings $settings, ?Events_Repository $events = null ) { $this->settings = $settings; $this->events = $events; } private function record( $type, $context ) { if ( $this->events ) { $this->events->log( $type, '', (string) $context ); } } /** * Acknowledgement of receipt on a durable medium. * Must state the content of the declaration and the date and time it was * received; this is what makes the acknowledgement legally valid. * Sent to the order's email, which the lookup step already matched, as * WooCommerce's email "Return request received" (N-69): the shop's design, * its subject and heading when it set them, its reply-to address. * * @param int $request_id Request. * @param \WC_Order $order Order the request was filed on. * @param array $items Items of the request. * @param array $payload The request as filed; `received_at` (the * moment stored, UTC) and `receipt_code` when * the submission hands them over. * @return bool True when the email was handed to the mailer. */ public function acknowledge( $request_id, \WC_Order $order, array $items, array $payload ) { $to = $order->get_billing_email(); if ( ! is_email( $to ) ) { $this->record( 'ack_no_address', $request_id ); return false; } $email = Emails::get( 'Careback_Email_Request_Received' ); $sent = $email ? $email->trigger( $to, $order, function () use ( $request_id, $order, $items, $payload ) { return $this->ack_message( $request_id, $order, $items, $payload ); } ) : false; // Switched off by the shop in WooCommerce is a choice, not a failure. if ( false === $sent ) { $this->record( 'ack_failed', $request_id ); } return true === $sent; } /** * The acknowledgement of one request as the email sends it: the subject * (the shop's for the route, else the built-in one; WooCommerce's own field * goes over it when the shop filled it), the lines in HTML and plain text, * and the values of the placeholders {order}, {ref}, {name}. * * @param int $request_id Request. * @param \WC_Order $order Order. * @param array $items Items of the request. * @param array $payload The request as filed. * @return array */ public function ack_message( $request_id, \WC_Order $order, array $items, array $payload ) { $template = $this->ack_template( $payload ); $subject = trim( (string) $this->settings->get( $template['subject'], '' ) ); if ( '' === $subject ) { $subject = Texts::get( $template['subject_text'], $this->settings ); } // The legally required core (receipt time + item list) is built line by // line and escaped; a merchant intro/closing may carry HTML. $lines = $this->ack_lines( $request_id, $order, $items, $payload ); return array( 'subject' => $subject, 'html' => Emails::lines_html( $lines ), 'plain' => Emails::lines_plain( $lines ), 'placeholders' => array( 'order' => $order->get_order_number(), 'ref' => $request_id, 'name' => isset( $payload['name'] ) ? $payload['name'] : '', ), ); } /** * The route an acknowledgement is written for. A request recorded as a * statutory declaration reads as a withdrawal whatever its type field says, * because that is the right the row carries; every other one as a return. * An add-on answers for the routes it brings through `careback_ack_route`. * * @param array $payload Reads `type` and `original_type`, and whatever an * add-on's answer needs. * @return string 'withdrawal', 'return', or a route an add-on names. */ public static function template_route( array $payload ) { $route = Requests_Repository::is_statutory( $payload ) ? 'withdrawal' : 'return'; return (string) apply_filters( self::ACK_ROUTE_FILTER, $route, $payload ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_ack_route, the final prefix. } /** * The settings and text keys one acknowledgement is built from: * * subject setting holding the shop's subject * subject_text Texts key of the subject used while that setting is blank * intro setting holding the opening line; blank falls back to the * acknowledgement intro * received Texts key of the line carrying the moment of receipt (%s) * closing setting holding the sign-off; blank falls back to the * acknowledgement closing, then to the built-in line * * A withdrawal confirms the withdrawal; a return whose reason is marked as a * fault reads as a fault report; every other request as a return. An add-on * sets the keys of the routes it brings through `careback_ack_template`. * * @param array $payload The request as filed. * @return array */ private function ack_template( array $payload ) { $route = self::template_route( $payload ); $is_fault = Reasons::is_fault( isset( $payload['reason'] ) ? $payload['reason'] : '', $this->settings ); $template = array( 'subject' => 'email_ack_subject', 'subject_text' => 'email_subject_ack', 'intro' => 'email_ack_intro', 'received' => 'email_received', 'closing' => 'email_ack_closing', ); if ( 'withdrawal' === $route ) { $template['received'] = 'email_withdrawal_received'; } elseif ( $is_fault ) { $template['intro'] = 'email_defect_intro'; // Switched off (N-80), the line names no statutory right. $template['received'] = Routes::withdrawal_offered( $this->settings ) ? 'email_defect_received' : 'email_defect_neutral'; } $filtered = apply_filters( self::ACK_TEMPLATE_FILTER, $template, $route, $payload, $is_fault ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_ack_template, the final prefix. foreach ( array_keys( $template ) as $key ) { if ( is_array( $filtered ) && isset( $filtered[ $key ] ) && '' !== (string) $filtered[ $key ] ) { $template[ $key ] = (string) $filtered[ $key ]; } } return $template; } /** * The lines of the acknowledgement. The received time and the item list are * always present because they are what make it a valid acknowledgement; the * merchant can only add an intro and a closing line around that core, and an * add-on lines of its own after the item list. */ public function ack_lines( $request_id, \WC_Order $order, array $items, array $payload ) { // A stable, unambiguous 24-hour date+time in the site's timezone, with // that zone's offset from UTC (N-72). A statutory acknowledgement has to // carry the moment of receipt: the moment stored with the request when // the submission hands it over, else now. $received = Emails::moment( isset( $payload['received_at'] ) ? $payload['received_at'] : null ); $t = Texts::all( $this->settings ); // The route decides the intro and the receipt wording. The fault marker on // a reason adds a paragraph further down instead, except where the route // keeps its own fault wording. $template = $this->ack_template( $payload ); $is_fault = Reasons::is_fault( isset( $payload['reason'] ) ? $payload['reason'] : '', $this->settings ); $fault_note = $is_fault && ! in_array( $template['received'], array( 'email_defect_received', 'email_defect_neutral' ), true ); $lines = array(); $intro = trim( (string) $this->settings->get( $template['intro'], '' ) ); if ( '' === $intro ) { $intro = trim( (string) $this->settings->get( 'email_ack_intro', '' ) ); } if ( '' !== $intro ) { $lines[] = new Trusted_Line( $intro ); $lines[] = ''; } /* translators: %s: date and time the request was received. */ $lines[] = new Trusted_Line( sprintf( self::text_of( $t, $template['received'], $this->settings ), $received ) ); if ( $fault_note ) { $lines[] = ''; $lines[] = new Trusted_Line( $t['email_fault_note'] ); } // Who pays for sending the goods back (N-116), on a route that says it on // its check step. Not on a request reported as faulty: the shop decides // that cost when it replies. $cost = $is_fault ? '' : self::cost_key( self::template_route( $payload ) ); if ( '' !== $cost ) { $lines[] = ''; $lines[] = new Trusted_Line( self::text_of( $t, $cost, $this->settings ) ); } $lines[] = ''; $lines[] = new Trusted_Line( $t['email_details'] ); $lines[] = sprintf( '%s: %s', $t['email_order'], $order->get_order_number() ); $lines[] = sprintf( '%s: %s', $t['email_name'], $payload['name'] ); $lines[] = sprintf( '%s: %s', $t['email_reference'], $request_id ); // N-70: worked out from the request as stored and the moment it arrived, // so a later copy can be shown to match what the customer was sent. if ( ! empty( $payload['receipt_code'] ) ) { $lines[] = sprintf( '%s: %s', self::text_of( $t, 'email_receipt_code', $this->settings ), $payload['receipt_code'] ); } $lines[] = ''; $lines[] = new Trusted_Line( $t['email_items'] . ':' ); foreach ( $items as $item ) { $lines = array_merge( $lines, self::item_lines( $order, $item ) ); } // A plain string is escaped like customer input; only a Trusted_Line keeps // its markup. $added = apply_filters( self::ACK_LINES_FILTER, array(), $request_id, $order, $items, $payload, $this->settings ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_ack_lines, the final prefix. foreach ( (array) $added as $line ) { $lines[] = $line instanceof Trusted_Line ? $line : (string) $line; } if ( ! empty( $payload['reason'] ) ) { $lines[] = ''; $lines[] = sprintf( '%s: %s', $t['email_reason'], $this->reason_label( $payload['reason'] ) ); } if ( ! empty( $payload['comment'] ) ) { $lines[] = sprintf( '%s: %s', $t['email_comment'], $payload['comment'] ); } // N-12: the customer accepted the shop's returns policy on the form. if ( ! empty( $payload['policy'] ) ) { $lines[] = new Trusted_Line( esc_html( self::text_of( $t, 'email_policy_accepted', $this->settings ) ) ); } $lines[] = ''; $closing = trim( (string) $this->settings->get( $template['closing'], '' ) ); if ( '' === $closing ) { $closing = trim( (string) $this->settings->get( 'email_ack_closing', '' ) ); } $lines[] = new Trusted_Line( '' !== $closing ? $closing : $t['email_default_closing'] ); return $lines; } /** * The lines of one item of a request in an email: "- name xN", and under it * each detail of the variation on a line of its own (N-16). An item without * details is the one line it always was. * * @param \WC_Order $order The order. * @param array $item The item as filed: line_item_id, name, quantity. * @return array Strings and Trusted_Line objects. */ public static function item_lines( \WC_Order $order, array $item ) { $name = isset( $item['name'] ) ? (string) $item['name'] : ''; $quantity = isset( $item['quantity'] ) ? (int) $item['quantity'] : 1; $parts = Order_Lookup::line_parts( $order, isset( $item['line_item_id'] ) ? (int) $item['line_item_id'] : 0, $name ); if ( empty( $parts['details'] ) ) { return array( '- ' . $name . ' x' . $quantity ); } $lines = array( '- ' . $parts['title'] . ' x' . $quantity ); foreach ( $parts['details'] as $detail ) { $lines[] = new Trusted_Line( '' . esc_html( $detail ) . '' ); } return $lines; } /** * Texts key of the line on who pays for sending the goods back on a route * (its `cost_key`); '' for none. * * @param string $route Route key. * @return string */ private static function cost_key( $route ) { $routes = Routes::all(); return isset( $routes[ $route ]['cost_key'] ) ? (string) $routes[ $route ]['cost_key'] : ''; } /** One text out of a Texts::all() list, read on its own when the list lacks it. */ private static function text_of( array $texts, $key, Settings $settings ) { return isset( $texts[ $key ] ) ? (string) $texts[ $key ] : Texts::get( $key, $settings ); } /** Human label for a reason key; falls back to the key itself. */ private function reason_label( $reason ) { $reason = (string) $reason; if ( '' === $reason ) { return ''; } $all = Reasons::all( $this->settings ); return isset( $all[ $reason ] ) ? $all[ $reason ] : $reason; } /** * Replaces {token} placeholders; unknown tokens are left untouched. * * @param bool $escape When true, each substituted value is HTML-escaped, so a * value carrying markup cannot introduce it into an HTML * email. */ public static function render_template( $template, array $vars, $escape = false ) { $out = (string) $template; foreach ( $vars as $key => $value ) { $value = (string) $value; $out = str_replace( '{' . $key . '}', $escape ? esc_html( $value ) : $value, $out ); } return $out; } /** * Collapses a value to a single line for use in a mail header. Line breaks * and control characters are removed, so a value carrying them cannot append * further headers to the message. * * Public (API 1, api/admin.md §5): an add-on's own mails build their subject * with it. * * @param string $value Value for the header. * @return string */ public static function header_safe( $value ) { return trim( preg_replace( '/[\r\n\t\x00-\x1F\x7F]+/', ' ', (string) $value ) ); } /** * Keeps the customer informed as the request moves, which is what removes * "where is my return?" contacts. Sent as WooCommerce's email "Return * request status" for the statuses the shop switched on. * * @param int $request_id Request. * @param string $status The status it moved to. * @param array $request The request row. * @return bool True when the email was handed to the mailer. */ public function status_update( $request_id, $status, array $request ) { if ( ! Plugin::woocommerce_active() ) { return false; } // Per-status opt-in: a customer email is sent only for statuses the merchant // explicitly enabled. By default none are, so nothing is sent. $config = $this->status_email_config( $status ); if ( empty( $config['enabled'] ) ) { return false; } $order = wc_get_order( (int) $request['order_id'] ); if ( ! $order instanceof \WC_Order || ! is_email( $order->get_billing_email() ) ) { return false; } $email = Emails::get( 'Careback_Email_Request_Status' ); if ( ! $email ) { return false; } return true === $email->trigger( $order->get_billing_email(), $order, function () use ( $request_id, $status, $order ) { return $this->status_message( $request_id, $status, $order ); } ); } /** * The status email of one request as the email sends it: the subject (the * status's own, else the built-in one; WooCommerce's field goes over it when * the shop filled it), the body in HTML and plain text, and the values of * the placeholders {ref}, {status}, {order}. * * @param int $request_id Request. * @param string $status Status. * @param \WC_Order|null $order Order. * @return array */ public function status_message( $request_id, $status, $order ) { $config = $this->status_email_config( $status ); $labels = self::customer_status_labels( $this->settings ); $label = isset( $labels[ $status ] ) ? $labels[ $status ] : $status; $subject = trim( (string) $config['subject'] ); if ( '' === $subject ) { $subject = Texts::get( 'email_subject_status', $this->settings ); } $html = $this->status_body( $request_id, $label, $order, $config['body'] ); return array( 'subject' => $subject, 'html' => $html, 'plain' => Emails::html_to_plain( $html ) . "\n", 'placeholders' => array( 'ref' => $request_id, 'status' => $label, 'order' => $order instanceof \WC_Order ? $order->get_order_number() : '', ), ); } /** The per-status email settings (enabled / subject / body) with safe defaults. */ public function status_email_config( $status ) { $all = $this->settings->get( 'status_emails', array() ); $row = ( is_array( $all ) && isset( $all[ $status ] ) && is_array( $all[ $status ] ) ) ? $all[ $status ] : array(); return array( 'enabled' => ! empty( $row['enabled'] ), 'subject' => isset( $row['subject'] ) ? (string) $row['subject'] : '', 'body' => isset( $row['body'] ) ? (string) $row['body'] : '', ); } /** * HTML body of the status-update email. A per-status template (with {ref} * {status} {order} tokens) is used when set; otherwise a minimal default. */ public function status_body( $request_id, $status_label, $order = null, $template = '' ) { $order_number = ( $order instanceof \WC_Order ) ? $order->get_order_number() : ''; $vars = array( 'ref' => $request_id, 'status' => $status_label, 'order' => $order_number ); $tpl = trim( (string) $template ); if ( '' !== $tpl ) { return wp_kses_post( self::render_template( $tpl, $vars, true ) ); } return wp_kses_post( Texts::format( 'email_status_default_body', $this->settings, array( esc_html( (string) $request_id ), esc_html( (string) $status_label ), esc_html( (string) $order_number ), ) ) ); } /** * The same status names as a customer reads them, taken from the editable * texts under each status's text key (`status_`). A blank override * falls back to the built-in wording, so the portal and the status emails * carry the merchant's own wording while the admin screens keep the fixed * English literals above. * * @return array */ public static function customer_status_labels( Settings $settings ) { $out = array(); foreach ( Statuses::text_keys() as $status => $key ) { $out[ $status ] = Texts::get( $key, $settings ); } return $out; } /** * Tells the shop's team about a new request, as WooCommerce's email "New * return request": to the recipients of that email, else the plugin's * notification recipients, else the site's admin address. A reply goes to * the customer (N-71). * * @param int $request_id Request. * @param \WC_Order $order Order the request was filed on. * @param array $items Items of the request. * @param array $payload The request as filed. * @return bool True when the email was handed to the mailer. */ public function notify_admin( $request_id, \WC_Order $order, array $items, array $payload ) { $email = Emails::get( 'Careback_Email_New_Request' ); $sent = $email ? $email->trigger( $this->admin_recipients(), $order, function () use ( $request_id, $order, $items, $payload ) { return $this->admin_message( $request_id, $order, $items, $payload ); } ) : false; if ( false === $sent ) { $this->record( 'admin_notice_failed', $request_id ); } return true === $sent; } /** * The plugin's notification recipients, else the site's admin address. * * @return string[] */ public function admin_recipients() { $recipients = $this->settings->get( 'admin_recipients', '' ); $to = array_values( array_filter( array_map( 'trim', explode( ',', (string) $recipients ) ), 'is_email' ) ); return $to ? $to : array( (string) get_option( 'admin_email' ) ); } /** * The team email of one request as the email sends it: the subject (the * shop's, else the built-in one; WooCommerce's field goes over it when the * shop filled it), the lines in HTML and plain text, the values of the * placeholders {order}, {ref}, {name}, and who a reply goes to. * * @param int $request_id Request. * @param \WC_Order $order Order. * @param array $items Items of the request. * @param array $payload The request as filed. * @return array */ public function admin_message( $request_id, \WC_Order $order, array $items, array $payload ) { $name = isset( $payload['name'] ) ? trim( (string) $payload['name'] ) : ''; $name = '' !== $name ? $name : trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() ); $subject = trim( (string) $this->settings->get( 'email_admin_subject', '' ) ); if ( '' === $subject ) { $subject = __( 'New request', 'careback' ); } $lines = $this->admin_lines( $request_id, $order, $items, array_merge( $payload, array( 'name' => $name ) ) ); return array( 'subject' => $subject, 'html' => Emails::lines_html( $lines ), 'plain' => Emails::lines_plain( $lines ), 'placeholders' => array( 'order' => $order->get_order_number(), 'ref' => $request_id, 'name' => $name, ), 'reply_name' => $name, 'reply_address' => (string) $order->get_billing_email(), ); } /** * The lines of the team email. The shop's own text comes first when it * wrote one; the order number, the request number, the customer's name, the * moment it arrived and a link to the request follow always (X-07), then * the items, the reason, the comment and the customer's address. * * @param int $request_id Request. * @param \WC_Order $order Order. * @param array $items Items of the request. * @param array $payload The request as filed. * @return array Strings and Trusted_Line objects. */ public function admin_lines( $request_id, \WC_Order $order, array $items, array $payload ) { $lines = array(); $vars = array( 'order' => $order->get_order_number(), 'ref' => $request_id, 'name' => isset( $payload['name'] ) ? $payload['name'] : '', ); $body_tpl = trim( (string) $this->settings->get( 'email_admin_body', '' ) ); if ( '' !== $body_tpl ) { // A plain-text template rendered into an HTML email: line breaks become //
, and token values are escaped, so only the merchant's own markup // survives wp_kses_post. $lines[] = new Trusted_Line( nl2br( self::render_template( $body_tpl, $vars, true ), false ) ); $lines[] = ''; } /* translators: %s: order number. */ $lines[] = sprintf( __( 'Order: %s', 'careback' ), $vars['order'] ); /* translators: %s: request number. */ $lines[] = sprintf( __( 'Request: #%s', 'careback' ), $request_id ); /* translators: %s: customer name. */ $lines[] = sprintf( __( 'Customer: %s', 'careback' ), $vars['name'] ); /* translators: %s: date and time the request arrived, with its time zone. */ $lines[] = sprintf( __( 'Received: %s', 'careback' ), Emails::moment( isset( $payload['received_at'] ) ? $payload['received_at'] : null ) ); if ( ! empty( $payload['receipt_code'] ) ) { /* translators: %s: the receipt code of the request, e.g. 4F2A-9C1E-0B7D-33A1. */ $lines[] = sprintf( __( 'Receipt code: %s', 'careback' ), $payload['receipt_code'] ); } $lines[] = new Trusted_Line( '' . esc_html__( 'Open the request', 'careback' ) . '' ); $lines[] = ''; $lines[] = __( 'Items:', 'careback' ); foreach ( $items as $item ) { $lines = array_merge( $lines, self::item_lines( $order, $item ) ); } if ( ! empty( $payload['reason'] ) ) { /* translators: %s: reason the customer chose. */ $lines[] = sprintf( __( 'Reason: %s', 'careback' ), $this->reason_label( $payload['reason'] ) ); } if ( ! empty( $payload['comment'] ) ) { /* translators: %s: the customer's comment. */ $lines[] = sprintf( __( 'Comment: %s', 'careback' ), $payload['comment'] ); } // The customer email is included so the team can reply from the alert // (a reply goes there as well). The phone number is not: it stays on the order. $customer_email = (string) $order->get_billing_email(); if ( '' !== $customer_email ) { $lines[] = ''; /* translators: %s: the customer's email address. */ $lines[] = sprintf( __( 'Email: %s', 'careback' ), $customer_email ); } return $lines; } }