prefix . 'accessible_form_notifications'; } public static function install() { global $wpdb; $table = self::table(); $collate = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE {$table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, entry_id BIGINT UNSIGNED NOT NULL, job_key CHAR(64) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0, next_at DATETIME NOT NULL, locked_at DATETIME NULL, config LONGTEXT NOT NULL, history LONGTEXT NULL, PRIMARY KEY (id), UNIQUE KEY job_key (job_key), KEY due_jobs (status, next_at), KEY entry_id (entry_id) ) {$collate};" ); } public static function queue( $entry_id, $manual = false, $use_current = false ) { global $wpdb; $entry = Entries::get( $entry_id ); if ( ! $entry || 'received' !== $entry['status'] ) { return new \WP_Error( 'unavailable', __( 'Accept this submission before sending a notification.', 'accessible-forms-by-accessible-org' ) ); } if ( $manual && ! Capabilities::entry( $entry, 'send_accessible_form_notifications' ) ) { return new \WP_Error( 'forbidden', __( 'You cannot send notifications for this form.', 'accessible-forms-by-accessible-org' ) ); } $schema = $use_current ? Schema::get_live( $entry['form_id'] ) : $entry['snapshot']['schema'] ?? null; if ( ! $schema ) { return new \WP_Error( 'legacy', __( 'This older entry has no saved notification configuration. Choose the current recipients to resend it.', 'accessible-forms-by-accessible-org' ) ); } if ( $manual ) { $active = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE entry_id = %d AND status IN ('pending','sending') LIMIT 1", self::table(), $entry_id ) ); if ( $active || ! Locks::limit( 'resend:' . $entry_id, 1, MINUTE_IN_SECONDS ) ) { return new \WP_Error( 'rate', __( 'A notification is pending, or was requested in the last minute. Wait before trying again.', 'accessible-forms-by-accessible-org' ) ); } } $configs = self::configurations( $schema, $entry, ! $manual ); $batch = wp_generate_uuid4(); foreach ( $configs as $index => $config ) { $config['batch'] = $batch; $config['queued_at'] = gmdate( 'Y-m-d H:i:s' ); $key = hash( 'sha256', $entry_id . ':' . $index . ':' . ( $manual ? wp_generate_uuid4() : 'initial' ) ); $ok = $wpdb->insert( self::table(), array( 'entry_id' => $entry_id, 'job_key' => $key, 'next_at' => gmdate( 'Y-m-d H:i:s' ), 'config' => wp_json_encode( $config ), ), array( '%d', '%s', '%s', '%s' ) ); if ( ! $ok && ! $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE job_key = %s', self::table(), $key ) ) ) { Entries::set_mail_status( $entry_id, 'failed', __( 'The notification could not be queued. Please retry from the entry.', 'accessible-forms-by-accessible-org' ) ); return new \WP_Error( 'storage', __( 'The notification could not be queued.', 'accessible-forms-by-accessible-org' ) ); } } Entries::set_mail_status( $entry_id, $configs ? 'queued' : 'skipped' ); if ( $manual ) { Audit::record( $use_current ? 'resend_current_recipients' : 'resend_original_recipients', $entry['form_id'], $entry_id ); } self::prune( $entry_id ); self::schedule(); return true; } /** * The notifications for an entry: a plain-text email to the form's * recipients, plus whatever add-ons add. * * @param array $schema Schema the entry was submitted with. * @param array $entry Hydrated entry. * @param bool $initial True for the first delivery; false for a resend or a preview. * @return array[] */ public static function configurations( array $schema, array $entry, $initial = true ) { $configs = array( array( 'name' => __( 'Admin notification', 'accessible-forms-by-accessible-org' ), 'recipients' => $schema['recipients'], 'cc' => $schema['cc'] ?? array(), 'subject' => $schema['subject_template'], 'merge_subject' => ! empty( $schema['merge_subject'] ), 'body_template' => $schema['body_template'] ?? '', 'format' => 'plain', 'reply_to' => $schema['reply_to'] ?? '', ), ); // Add-ons add other notifications, such as receipts, and other channels. $filtered = Extensions::filter( 'accessible_forms_notification_configs', $configs, $schema, $entry, (bool) $initial ); return is_array( $filtered ) ? array_values( array_filter( $filtered, 'is_array' ) ) : $configs; } /** * How a notification is delivered: `email`, or an add-on's channel. * * @param array $config Notification. * @return string */ public static function channel( array $config ) { if ( isset( $config['channel'] ) && is_string( $config['channel'] ) ) { return sanitize_key( $config['channel'] ); } // Jobs queued before 2.0 name their channel only through the format. return 'webhook' === ( $config['format'] ?? '' ) ? 'webhook' : 'email'; } /** Queue an add-on channel independently of email. The key is stable for one entry and route. */ public static function queue_addon( $entry_id, $identity, array $config ) { global $wpdb; $json = wp_json_encode( $config ); $channel = self::channel( $config ); if ( $entry_id < 1 || '' === $channel || 'email' === $channel || ! $json || strlen( $json ) > 8192 ) { return false; } $key = hash( 'sha256', 'addon:' . (int) $entry_id . ':' . $channel . ':' . $identity ); $stored = $wpdb->query( $wpdb->prepare( "INSERT INTO %i (entry_id, job_key, next_at, config) VALUES (%d, %s, %s, %s) ON DUPLICATE KEY UPDATE job_key = job_key", self::table(), $entry_id, $key, gmdate( 'Y-m-d H:i:s' ), $json ) ); if ( false !== $stored ) { self::schedule(); } return false !== $stored; } public static function schedule() { if ( ! wp_next_scheduled( 'accessible_forms_notifications' ) ) { wp_schedule_single_event( time() + 5, 'accessible_forms_notifications' ); } } public static function run() { global $wpdb; // A process that vanished during transport leaves delivery uncertain, never silently resent. $stalled = $wpdb->get_results( $wpdb->prepare( "SELECT id, entry_id, history FROM %i WHERE status = 'sending' AND locked_at < %s LIMIT 50", self::table(), gmdate( 'Y-m-d H:i:s', time() - 5 * MINUTE_IN_SECONDS ) ), ARRAY_A ); foreach ( $stalled ?: array() as $job ) { $history = json_decode( $job['history'] ?? '', true ) ?: array(); $history[] = array( 'at' => gmdate( 'Y-m-d H:i:s' ), 'status' => 'uncertain', 'reason' => 'interrupted', ); $wpdb->update( self::table(), array( 'status' => 'uncertain', 'history' => wp_json_encode( array_slice( $history, -3 ) ), ), array( 'id' => $job['id'], 'status' => 'sending', ), array( '%s', '%s' ), array( '%d', '%s' ) ); self::refresh_status( $job['entry_id'] ); } // First attempts go before retries: after a mail outage, new submissions // must not wait behind a backlog of failing older jobs. $jobs = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE status = 'pending' AND next_at <= %s ORDER BY attempts, next_at, id LIMIT 5", self::table(), gmdate( 'Y-m-d H:i:s' ) ), ARRAY_A ); foreach ( $jobs ?: array() as $job ) { $claimed = $wpdb->query( $wpdb->prepare( "UPDATE %i SET status = 'sending', locked_at = %s WHERE id = %d AND status = 'pending'", self::table(), gmdate( 'Y-m-d H:i:s' ), $job['id'] ) ); if ( 1 !== (int) $claimed ) { continue; } $entry = Entries::get( $job['entry_id'] ); if ( ! $entry || 'received' !== $entry['status'] ) { $wpdb->update( self::table(), array( 'status' => 'cancelled' ), array( 'id' => $job['id'] ), array( '%s' ), array( '%d' ) ); continue; } $config = json_decode( $job['config'], true ); $config = is_array( $config ) ? $config : array(); try { $result = self::deliver( $entry, $config, $job['job_key'] ); } catch ( \Throwable $error ) { $result = array( 'sent' => false, 'error' => 'timeout: interrupted transport', ); } $attempts = (int) $job['attempts'] + 1; $uncertain = ! $result['sent'] && ( array_key_exists( 'uncertain', $result ) ? ! empty( $result['uncertain'] ) : (bool) preg_match( '/timed?\s*out|timeout/i', (string) $result['error'] ) ); $status = $result['sent'] ? 'accepted' : ( $uncertain ? 'uncertain' : ( $attempts < 3 && ! str_starts_with( (string) $result['error'], 'permanent:' ) ? 'pending' : 'failed' ) ); $history = json_decode( (string) $job['history'], true ) ?: array(); $attempt = array( 'at' => current_time( 'mysql', true ), 'status' => $result['sent'] ? 'accepted' : ( $uncertain ? 'uncertain' : 'failed' ), 'reason' => $result['sent'] ? '' : self::reason_code( $result, $config['format'] ?? '' ), ); if ( ! empty( $result['code'] ) ) { $attempt['code'] = (int) $result['code']; } if ( ! $result['sent'] ) { $attempt['detail'] = self::short_detail( (string) ( $result['detail'] ?? $result['error'] ) ); } if ( ! empty( $result['routes'] ) ) { $attempt['routes'] = self::mail_routes( $result['routes'] ); } $history[] = $attempt; $wpdb->update( self::table(), array( 'status' => $status, 'attempts' => $attempts, 'next_at' => gmdate( 'Y-m-d H:i:s', time() + max( $attempts * 5 * MINUTE_IN_SECONDS, min( DAY_IN_SECONDS, (int) ( $result['retry_after'] ?? 0 ) ) ) ), 'history' => wp_json_encode( array_slice( $history, -3 ) ), 'locked_at' => null, ), array( 'id' => $job['id'] ), array( '%s', '%d', '%s', '%s', '%s' ), array( '%d' ) ); self::refresh_status( $entry['id'] ); Extensions::notify( 'accessible_forms_notification_result', $entry['id'], $status ); } $next = $wpdb->get_var( $wpdb->prepare( "SELECT MIN(next_at) FROM %i WHERE status = 'pending'", self::table() ) ); if ( $next && ! wp_next_scheduled( 'accessible_forms_notifications' ) ) { wp_schedule_single_event( max( time() + 30, strtotime( $next . ' UTC' ) ), 'accessible_forms_notifications' ); } } public static function refresh_status( $entry_id ) { $statuses = array(); $latest_batch = null; foreach ( self::history( $entry_id ) as $job ) { $config = json_decode( $job['config'], true ) ?: array(); if ( 'email' !== self::channel( $config ) ) { continue; } $batch = $config['batch'] ?? 'legacy'; if ( null === $latest_batch ) { $latest_batch = $batch; } if ( $batch === $latest_batch && 'cancelled' !== $job['status'] ) { $statuses[] = $job['status']; } } $status = $statuses ? 'sent' : 'skipped'; foreach ( array( 'uncertain' => 'uncertain', 'failed' => 'failed', 'sending' => 'queued', 'pending' => 'queued', ) as $job => $entry ) { if ( in_array( $job, $statuses, true ) ) { $status = $entry; break; } } Entries::set_mail_status( $entry_id, $status ); } /** * A stable code for why an attempt failed, from the transport's error. * * @param array $result Result with 'error' and, for webhooks, 'code'. * @param string $format Notification format. * @return string */ public static function reason_code( array $result, $format = '' ) { $error = (string) ( $result['error'] ?? '' ); if ( ! empty( $result['reason'] ) ) { return sanitize_key( $result['reason'] ); } // Add-ons explain failures on their own channels. $reason = Extensions::filter( 'accessible_forms_notification_reason', '', $result, (string) $format ); if ( is_string( $reason ) && '' !== $reason ) { return sanitize_key( $reason ); } if ( preg_match( '/timed?\s*out|timeout/i', $error ) ) { return 'timeout'; } if ( false !== stripos( $error, 'Test recipient' ) ) { return 'test_recipient'; } if ( preg_match( '/authenticat|username|password|535/i', $error ) ) { return 'smtp_auth'; } if ( preg_match( '/connect|connection|getaddrinfo|resolve|host/i', $error ) ) { return 'smtp_connect'; } if ( preg_match( '/recipient|address|mailbox/i', $error ) ) { return 'recipient'; } if ( preg_match( '/instantiate mail|mail function|without an error/i', $error ) ) { return 'mail_function'; } return str_starts_with( $error, 'permanent:' ) ? 'configuration' : 'transport'; } /** * A short, single-line copy of an error for the attempt history. * * @param string $error Error text. * @return string */ private static function short_detail( $error ) { $error = trim( preg_replace( '/^permanent:\s*/', '', sanitize_text_field( $error ) ) ); return function_exists( 'mb_substr' ) ? mb_substr( $error, 0, 200, 'UTF-8' ) : substr( $error, 0, 200 ); } /** Bounded, content-free mail route history, shared by test results and queued notifications. */ public static function mail_routes( array $routes ) { $history = array(); foreach ( array_slice( $routes, 0, 2 ) as $route ) { if ( ! is_array( $route ) ) { continue; } $history[] = array( 'transport' => self::short_detail( (string) ( $route['transport'] ?? '' ) ), 'status' => ! empty( $route['sent'] ) ? 'accepted' : ( ! empty( $route['uncertain'] ) ? 'uncertain' : 'failed' ), 'reason' => ! empty( $route['sent'] ) ? '' : self::reason_code( $route ), ); if ( ! empty( $route['reference'] ) ) { $history[ count( $history ) - 1 ]['reference'] = self::short_detail( (string) $route['reference'] ); } } return $history; } /** * Plain-language explanation of a failure reason and what to do next. * * @param string $reason Reason code recorded in the attempt history. * @return array{summary: string, next: string} */ public static function explain( $reason ) { $email = __( 'Check Settings → Email delivery and send a test email, then queue the notification again.', 'accessible-forms-by-accessible-org' ); $map = array( 'interrupted' => array( __( 'The sending process stopped part-way, so it is not known whether the message went out.', 'accessible-forms-by-accessible-org' ), __( 'Check the recipient’s inbox before sending again, to avoid a duplicate.', 'accessible-forms-by-accessible-org' ), ), 'timeout' => array( __( 'The server did not answer in time, so it is not known whether the message went out.', 'accessible-forms-by-accessible-org' ), __( 'Check with the recipient before sending again. If this keeps happening, the mail server or destination may be slow or unreachable.', 'accessible-forms-by-accessible-org' ), ), 'smtp_auth' => array( __( 'The mail server refused the username or password.', 'accessible-forms-by-accessible-org' ), $email, ), 'smtp_connect' => array( __( 'The mail server refused the connection or could not be reached.', 'accessible-forms-by-accessible-org' ), __( 'Check the SMTP host, port and encryption in Settings → Email delivery, then send a test email.', 'accessible-forms-by-accessible-org' ), ), 'recipient' => array( __( 'The mail server did not accept a recipient address.', 'accessible-forms-by-accessible-org' ), __( 'Check the notification recipients in the form’s settings.', 'accessible-forms-by-accessible-org' ), ), 'mail_function' => array( __( 'WordPress could not hand the message to a mail service.', 'accessible-forms-by-accessible-org' ), __( 'Your host may not send email directly. Set up SMTP in Settings → Email delivery, or use a mail plugin, then send a test email.', 'accessible-forms-by-accessible-org' ), ), 'test_recipient' => array( __( 'Test delivery is on, but no test inbox is set.', 'accessible-forms-by-accessible-org' ), __( 'Add a test inbox or turn test delivery off, then queue the notification again.', 'accessible-forms-by-accessible-org' ), ), 'configuration' => array( __( 'The notification settings stopped this message from being sent.', 'accessible-forms-by-accessible-org' ), __( 'Review the form’s notification settings, then queue the notification again.', 'accessible-forms-by-accessible-org' ), ), 'transport' => array( __( 'The mail service reported an error.', 'accessible-forms-by-accessible-org' ), $email, ), ); $map = (array) Extensions::filter( 'accessible_forms_notification_explanations', $map ); $found = isset( $map[ $reason ][1] ) ? $map[ $reason ] : $map['transport']; return array( 'summary' => $found[0], 'next' => $found[1], ); } /** * Full address of the page a submission came from. * * @param array $entry Hydrated entry. * @return string Empty when no page was recorded. */ public static function page_link( array $entry ) { $path = (string) ( $entry['page_url'] ?? '' ); if ( '' === $path || '/' !== $path[0] ) { return ''; } $home = wp_parse_url( home_url() ); $origin = ( $home['scheme'] ?? 'https' ) . '://' . ( $home['host'] ?? '' ) . ( isset( $home['port'] ) ? ':' . $home['port'] : '' ); return esc_url_raw( $origin . $path ); } /** * When a submission was received, in the site's time zone. * * @param array $entry Hydrated entry. * @return string */ public static function received_time( array $entry ) { $format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) . ' T'; // An email preview has no stored event; actual entries use the same UTC // provenance and uncertainty label as the entry screen. return empty( $entry['created_at'] ) ? wp_date( $format ) : EntryTime::display( $entry, 'created_at', $format ); } /** * Deliver one queued notification. * * @param array $entry Hydrated entry. * @param array $config Notification. * @param string $job_key Unique job key. * @return array{sent: bool, error: string} */ private static function deliver( array $entry, array $config, $job_key ) { $unavailable = array( 'sent' => false, 'error' => 'permanent: The add-on that sends this notification is not active.', ); if ( 'email' !== self::channel( $config ) ) { $result = Extensions::filter( 'accessible_forms_deliver', null, $entry, $config, (string) $job_key ); return is_array( $result ) && isset( $result['sent'] ) ? array_merge( array( 'error' => '' ), $result ) : $unavailable; } $mail = self::message( $entry, $config ); return $mail ? Mailer::send( $mail['to'], $mail['subject'], $mail['message'], $mail['headers'], array( 'entry_id' => (int) $entry['id'], 'job_key' => (string) $job_key, 'purpose' => 'receipt' === ( $config['format'] ?? '' ) ? 'receipt' : 'notification', ) ) : $unavailable; } /** * The email for a notification. * * @param array $entry Hydrated entry. * @param array $config Notification. * @return array|null To, subject, message and headers; null when no active * plugin can write this kind of email. */ public static function message( array $entry, array $config ) { $schema = $entry['snapshot']['schema'] ?? array(); $values = Entries::display_values( $entry ); $labels = Entries::labels( $entry ); $to = array_values( array_filter( (array) ( $config['recipients'] ?? array() ), array( Mailer::class, 'valid_header_address' ) ) ); $rows = array(); $included = array(); foreach ( $values as $name => $value ) { if ( ! empty( $config['include'] ) && ! in_array( $name, (array) $config['include'], true ) ) { continue; } $included[ $name ] = $value; $rows[] = array( (string) ( $labels[ $name ] ?? $name ), is_array( $value ) ? implode( ', ', $value ) : (string) $value ); } $lines = array(); foreach ( $rows as $row ) { $lines[] = $row[0] . ': ' . $row[1]; } $format = (string) ( $config['format'] ?? 'plain' ); $modern = ! empty( $config['merge_subject'] ); $subject_answers = $modern ? ( 'link' === $format ? array() : $included ) : $values; $subject = Mailer::header_text( MergeTags::expand( $config['subject'] ?? '', $entry, $subject_answers, 'link' === $format ? array() : $lines, $modern, 500 ) ); $details = array( array( __( 'Received', 'accessible-forms-by-accessible-org' ), self::received_time( $entry ) ), ); $page = self::page_link( $entry ); if ( $page ) { $details[] = array( __( 'Submitted from', 'accessible-forms-by-accessible-org' ), $page ); } $view_url = Admin\EntryView::url( $entry['id'] ); $view_text = __( 'View this response in WordPress:', 'accessible-forms-by-accessible-org' ); $headers = array(); $reply = $values[ $config['reply_to'] ?? '' ] ?? ''; if ( ! $reply ) { foreach ( $schema['fields'] ?? array() as $field ) { if ( 'email' === $field['type'] && is_email( $values[ $field['name'] ] ?? '' ) ) { $reply = $values[ $field['name'] ]; break; } } } if ( Mailer::valid_header_address( $reply ) ) { $headers[] = 'Reply-To: ' . $reply; } $cc = array_unique( array_filter( (array) ( $config['cc'] ?? array() ), array( Mailer::class, 'valid_header_address' ) ) ); foreach ( $cc as $address ) { if ( ! in_array( strtolower( $address ), array_map( 'strtolower', $to ), true ) ) { $headers[] = 'Cc: ' . $address; } } if ( ! empty( $config['private'] ) ) { foreach ( array_slice( $to, 1 ) as $email ) { $headers[] = 'Bcc: ' . $email; } $to = array_slice( $to, 0, 1 ); } $body = implode( "\n\n", $lines ); foreach ( $details as $detail ) { $body .= "\n\n" . $detail[0] . ': ' . $detail[1]; } $body .= "\n\n" . $view_text . "\n" . $view_url; $template = (string) ( $config['body_template'] ?? '' ); if ( '' !== $template && in_array( $format, array( '', 'plain', 'html' ), true ) ) { $body = MergeTags::expand( $template, $entry, $included, $lines, true ); } $mail = array( 'to' => $to, 'subject' => MergeTags::cut( $subject, 500 ), 'message' => $body, 'headers' => $headers, ); if ( in_array( $format, array( '', 'plain' ), true ) ) { return $mail; } // Other formats belong to add-ons. Without one, nothing is sent: a format // can be meant for someone who must not see the answers. $mail = Extensions::filter( 'accessible_forms_notification_message', null, $entry, $config, array( 'mail' => $mail, 'rows' => $rows, 'details' => $details, 'view_text' => $view_text, 'view_url' => $view_url, ) ); return is_array( $mail ) && isset( $mail['to'], $mail['subject'], $mail['message'], $mail['headers'] ) ? $mail : null; } public static function history( $entry_id ) { global $wpdb; return $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE entry_id = %d ORDER BY id DESC LIMIT 60', self::table(), $entry_id ), ARRAY_A ) ?: array(); } public static function cancel( $entry_id ) { global $wpdb; $wpdb->query( $wpdb->prepare( "UPDATE %i SET status = 'cancelled' WHERE entry_id = %d AND status = 'pending'", self::table(), $entry_id ) ); self::refresh_status( $entry_id ); } private static function prune( $entry_id ) { global $wpdb; $ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM %i WHERE entry_id = %d AND status NOT IN ('pending','sending') ORDER BY id DESC LIMIT 100 OFFSET 60", self::table(), $entry_id ) ); foreach ( $ids as $id ) { $wpdb->delete( self::table(), array( 'id' => $id ), array( '%d' ) ); } } public static function forget( $entry_id ) { global $wpdb; return false !== $wpdb->delete( self::table(), array( 'entry_id' => $entry_id ), array( '%d' ) ); } }