> */ public function fields() { return array( array( 'id' => 'recipients', 'type' => 'email_recipients', 'label' => __( 'Extra recipients', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Separate addresses with commas. Everyone in To and Cc sees each other’s address, including the customer on customer emails; use Bcc to keep addresses private.', 'admin-powerups-for-woocommerce' ), 'default' => array(), ), array( 'id' => 'attachments', 'type' => 'email_attachments', 'label' => __( 'Attachments', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Files from the Media Library, added to the emails you pick. Leave products and categories empty to always attach.', 'admin-powerups-for-woocommerce' ), 'default' => array(), 'warning' => __( 'Large files make emails slow to send and may be rejected by inboxes. Keep attachments small (under 5 MB in total).', 'admin-powerups-for-woocommerce' ), 'warning_icon' => 'circle-alert', ), ); } /** * {@inheritDoc} */ public function boot() { add_action( 'woocommerce_email', array( $this, 'hook_emails' ) ); } /** * {@inheritDoc} The two custom fields and the Media Library, on or off. * * @param string $tab Tab on screen. */ public function admin_screen( $tab ) { add_action( 'apwc_render_field_email_recipients', array( $this, 'render_recipients' ), 10, 3 ); add_action( 'apwc_render_field_email_attachments', array( $this, 'render_attachments' ), 10, 3 ); $this->enqueue( $this ); } /* ------------------------------------------------------------------ settings */ /** * Clean a list of addresses. * * @since 1.0.0 * @param string|string[] $items Addresses. * @return string[] */ public static function addresses( $items ) { $items = is_array( $items ) ? $items : explode( ',', (string) $items ); $out = array(); foreach ( $items as $address ) { $address = sanitize_email( trim( (string) $address ) ); if ( is_email( $address ) ) { $out[] = $address; } } return array_values( array_unique( $out ) ); } /** * {@inheritDoc} The two custom fields arrive as JSON from their hidden inputs; JSON that can't * be read keeps the saved value. * * @param array $field Field schema. * @param mixed $value Raw value. */ protected function sanitize_field( array $field, $value ) { if ( ! in_array( $field['type'], array( 'email_recipients', 'email_attachments' ), true ) ) { return parent::sanitize_field( $field, $value ); } $current = $this->setting( $field['id'] ); $value = Json::decode_setting( $value, is_array( $current ) ? $current : array() ); $known = array_keys( WooEmails::all() ); if ( 'email_recipients' === $field['type'] ) { $clean = array(); foreach ( $value as $email_id => $row ) { $email_id = sanitize_key( (string) $email_id ); if ( ! is_array( $row ) || ( $known && ! in_array( $email_id, $known, true ) ) || WooEmails::is_account_email( $email_id ) ) { continue; } $row = array( 'to' => self::addresses( $row['to'] ?? array() ), 'cc' => self::addresses( $row['cc'] ?? array() ), 'bcc' => self::addresses( $row['bcc'] ?? array() ), ); if ( $row['to'] || $row['cc'] || $row['bcc'] ) { $clean[ $email_id ] = $row; } } return $clean; } // Only files the person saving may read (never protected downloads). The rule remembers who // saved it, and the same check runs again, for that person, whenever the email is sent. $user = get_current_user_id(); $rules = array(); foreach ( array_slice( $value, 0, 50 ) as $rule ) { if ( ! is_array( $rule ) ) { continue; } $ids = static fn( $items ) => array_values( array_unique( array_filter( array_map( 'absint', array_filter( (array) $items, 'is_scalar' ) ) ) ) ); $files = array_values( array_filter( $ids( $rule['files'] ?? array() ), static fn( $id ) => '' !== self::attachable( $id, $user ) ) ); $mails = array_values( array_filter( array_map( 'sanitize_key', array_filter( (array) ( $rule['emails'] ?? array() ), 'is_scalar' ) ), static fn( $id ) => ! $known || in_array( $id, $known, true ) ) ); if ( $files && $mails ) { $rules[] = array( 'files' => $files, 'emails' => $mails, 'products' => $ids( $rule['products'] ?? array() ), 'categories' => $ids( $rule['categories'] ?? array() ), 'user' => $user, ); } } return $rules; } /* ------------------------------------------------------------------ sending */ /** * Hook the filters once WooCommerce's mailer loads (only on requests that send email). * * @since 1.0.0 * @param \WC_Emails|null $mailer Mailer being built. * @return void */ public function hook_emails( $mailer = null ) { // Settings are read only inside the callbacks, i.e. when an email is actually built; the mailer // also loads on requests that send nothing (e.g. some Store API calls). Use the mailer passed in: // this runs inside WC_Emails' constructor, so calling WC()->mailer() here would build another // mailer and recurse forever. $emails = $mailer instanceof \WC_Emails ? (array) $mailer->get_emails() : array(); foreach ( $emails as $email ) { if ( $email instanceof WC_Email ) { add_filter( 'woocommerce_email_recipient_' . $email->id, array( $this, 'add_to' ), 20, 3 ); } } add_filter( 'woocommerce_email_headers', array( $this, 'add_copies' ), 20, 4 ); add_filter( 'woocommerce_email_attachments', array( $this, 'add_files' ), 20, 4 ); } /** * Extra To addresses. Skipped when WooCommerce has no recipient (so a disabled or * recipient-less email stays unsent). * * @since 1.0.0 * @param string $recipient Recipients. * @param mixed $wc_object Object. * @param WC_Email|null $email Email. * @return string */ public function add_to( $recipient, $wc_object = null, $email = null ) { if ( ! $email instanceof WC_Email || '' === trim( (string) $recipient ) ) { return $recipient; } if ( WooEmails::is_account_email( (string) $email->id ) ) { return $recipient; // Password links go to the account holder only. } $extra = $this->recipients( (string) $email->id )['to']; return $extra ? implode( ',', array_unique( array_merge( array_map( 'trim', explode( ',', (string) $recipient ) ), $extra ) ) ) : $recipient; } /** * Cc and Bcc headers. * * @since 1.0.0 * @param string $headers Headers. * @param string $email_id Email ID. * @param mixed $wc_object Object. * @param WC_Email|null $email Email. * @return string */ public function add_copies( $headers, $email_id = '', $wc_object = null, $email = null ) { if ( WooEmails::is_account_email( (string) $email_id ) ) { return $headers; } $row = $this->recipients( (string) $email_id ); foreach ( array( 'cc' => 'Cc', 'bcc' => 'Bcc', ) as $key => $name ) { if ( ! empty( $row[ $key ] ) ) { $headers .= $name . ': ' . implode( ', ', $row[ $key ] ) . "\r\n"; } } return $headers; } /** * Attach files whose rule matches this email (and order). * * @since 1.0.0 * @param string[] $attachments Paths. * @param string $email_id Email ID. * @param mixed $wc_object Object. * @param WC_Email|null $email Email. * @return string[] */ public function add_files( $attachments, $email_id = '', $wc_object = null, $email = null ) { $attachments = (array) $attachments; foreach ( self::rules( $this->setting( 'attachments' ) ) as $rule ) { if ( ! in_array( (string) $email_id, $rule['emails'], true ) || ! $this->order_matches( $rule, $wc_object ) ) { continue; } foreach ( $rule['files'] as $file_id ) { // Checked again at send time, for the person who saved the rule: files can change // owner, status or place after the rule was saved. $path = self::attachable( $file_id, $rule['user'] ); if ( '' !== $path && is_readable( $path ) && ! in_array( $path, $attachments, true ) ) { $attachments[] = $path; } } } return $attachments; } /** * The file of a Media Library attachment that may be sent, or '' when it may not: it must be * an attachment this user can read, stored inside the uploads folder and outside WooCommerce's * protected downloads folder (`woocommerce_uploads`, files customers pay for). * * @since 1.0.0 * @param int $file_id Attachment ID. * @param int $user_id User who saved the rule (0: nobody, refused). * @return string Real path, or ''. */ public static function attachable( $file_id, $user_id ) { $file_id = (int) $file_id; $user_id = (int) $user_id; if ( $file_id < 1 || $user_id < 1 || 'attachment' !== get_post_type( $file_id ) || ! user_can( $user_id, 'read_post', $file_id ) ) { return ''; } $file = get_attached_file( $file_id ); $real = is_string( $file ) && '' !== $file ? realpath( $file ) : false; if ( false === $real ) { return ''; } $uploads = wp_upload_dir( null, false ); $base = (string) ( $uploads['basedir'] ?? '' ); if ( '' === $base || ! self::inside( $real, $base ) || self::protected_path( $real, $base ) ) { return ''; } /** * Filter whether a Media Library file may be attached by an attachment rule. Runs only for * files that passed the built-in checks (readable by the person who saved the rule, inside * the uploads folder, not in a `woocommerce_uploads`, `edd` or `dlm_uploads` folder); * return false to refuse one, e.g. from another plugin's protected downloads folder. * * @since 1.0.0 * @param bool $allowed Whether the file may be attached (true). * @param int $file_id Attachment ID. * @param string $path Real path of the file. * @param int $user_id User who saved the rule. */ return true === apply_filters( 'apwc_email_attachment_allowed', true, $file_id, $real, $user_id ) ? $real : ''; } /** * Whether a file inside uploads sits in a folder that holds files customers pay for: * WooCommerce's `woocommerce_uploads` at any depth (e.g. `sites/2/woocommerce_uploads` on a * network), and the Easy Digital Downloads (`edd`) and Download Monitor (`dlm_uploads`) * folders. Other plugins' protected folders: use `apwc_email_attachment_allowed`. * * @since 1.0.0 * @param string $path Real path. * @param string $base Uploads folder. * @return bool */ private static function protected_path( $path, $base ) { $resolved = realpath( $base ); $base = untrailingslashit( wp_normalize_path( false !== $resolved ? $resolved : $base ) ); $relative = strtolower( substr( wp_normalize_path( $path ), strlen( $base ) + 1 ) ); $folders = array_slice( explode( '/', $relative ), 0, -1 ); return (bool) array_intersect( $folders, array( 'woocommerce_uploads', 'edd', 'dlm_uploads' ) ); } /** * Whether a real path is inside a folder (the folder resolved too; case ignored, so a * case-insensitive file system can't dodge the check). * * @param string $path Real path. * @param string $folder Folder. * @return bool */ private static function inside( $path, $folder ) { $resolved = realpath( $folder ); $folder = strtolower( untrailingslashit( wp_normalize_path( false !== $resolved ? $resolved : $folder ) ) ); return str_starts_with( strtolower( wp_normalize_path( $path ) ), $folder . '/' ); } /** * Whether an order has one of the rule's products or categories (or the rule has none). * * @since 1.0.0 * @param array $rule Rule. * @param mixed $wc_object Email object. * @return bool */ public function order_matches( array $rule, $wc_object ) { $products = (array) ( $rule['products'] ?? array() ); $categories = (array) ( $rule['categories'] ?? array() ); if ( ! $products && ! $categories ) { return true; } if ( ! $wc_object instanceof WC_Order ) { return false; } foreach ( $wc_object->get_items() as $item ) { if ( ! $item instanceof \WC_Order_Item_Product ) { continue; } $product_id = (int) $item->get_product_id(); $variation_id = (int) $item->get_variation_id(); if ( array_intersect( array( $product_id, $variation_id ), $products ) ) { return true; } if ( $categories && $product_id && array_intersect( wc_get_product_term_ids( $product_id, 'product_cat' ), $categories ) ) { return true; } } return false; } /** * One email's saved extra addresses, whatever shape the stored setting has. * * @since 1.0.0 * @param string $email_id Email ID. * @return array{to: string[], cc: string[], bcc: string[]} */ private function recipients( $email_id ) { $all = $this->setting( 'recipients' ); $row = is_array( $all ) && isset( $all[ $email_id ] ) && is_array( $all[ $email_id ] ) ? $all[ $email_id ] : array(); return array( 'to' => self::strings( $row['to'] ?? array() ), 'cc' => self::strings( $row['cc'] ?? array() ), 'bcc' => self::strings( $row['bcc'] ?? array() ), ); } /** * Saved attachment rules brought to their shape (no lookups): stored settings may come from an * older version, another plugin or a manual edit. * * @since 1.0.0 * @param mixed $value Stored rules. * @return array * `user`: who saved the rule (0 when unknown: its files are never attached). */ public static function rules( $value ) { $ids = static fn( $items ) => array_values( array_filter( array_map( 'absint', array_filter( is_array( $items ) ? $items : array(), 'is_scalar' ) ) ) ); $rules = array(); foreach ( is_array( $value ) ? $value : array() as $rule ) { if ( ! is_array( $rule ) ) { continue; } $rules[] = array( 'files' => $ids( $rule['files'] ?? array() ), 'emails' => self::strings( $rule['emails'] ?? array() ), 'products' => $ids( $rule['products'] ?? array() ), 'categories' => $ids( $rule['categories'] ?? array() ), 'user' => isset( $rule['user'] ) && is_scalar( $rule['user'] ) ? absint( $rule['user'] ) : 0, ); } return $rules; } /** * Strings from a stored list (anything else dropped). * * @param mixed $items Stored list. * @return string[] */ private static function strings( $items ) { return array_values( Str::scalars( is_array( $items ) ? $items : array() ) ); } /* ------------------------------------------------------------------ settings UI */ /** * Media Library and the field script on this power-up's settings. * * @since 1.0.0 * @param AbstractModule|null $module Module on screen. * @return void */ public function enqueue( $module ) { if ( $module !== $this || ! did_action( 'admin_enqueue_scripts' ) ) { return; } wp_enqueue_media(); wp_enqueue_script( 'apwc-email-routing', Assets::url( 'assets/js/email-routing.js' ), array( 'apwc-admin', 'apwc-combobox', 'wp-i18n' ), Assets::ver( 'assets/js/email-routing.js' ), true ); wp_set_script_translations( 'apwc-email-routing', 'admin-powerups-for-woocommerce' ); } /** * Recipients table. * * @since 1.0.0 * @param array $field Field. * @param mixed $value Value. * @param string $id Element ID. * @return void */ public function render_recipients( $field, $value, $id ) { $value = array_filter( is_array( $value ) ? $value : array(), 'is_array' ); $value = array_map( static fn( $row ) => array( 'to' => self::strings( $row['to'] ?? array() ), 'cc' => self::strings( $row['cc'] ?? array() ), 'bcc' => self::strings( $row['bcc'] ?? array() ), ), $value ); $emails = array_diff_key( WooEmails::all(), array_flip( WooEmails::account_emails() ) ); // Never copy password links to others. include __DIR__ . '/views/recipients.php'; } /** * Attachment rules. * * @since 1.0.0 * @param array $field Field. * @param mixed $value Value. * @param string $id Element ID. * @return void */ public function render_attachments( $field, $value, $id ) { $rules = self::rules( $value ); $emails = WooEmails::titles( true ); include __DIR__ . '/views/attachments.php'; } }