299 lines
10 KiB
PHP
299 lines
10 KiB
PHP
|
|
<?php
|
||
|
|
/**
|
||
|
|
* The in-app inbox that catches mail with nowhere to go.
|
||
|
|
*
|
||
|
|
* Sirius Press does not disable `wp_mail()`. That would break Contact Form 7,
|
||
|
|
* WooCommerce receipts, newsletters — every legitimate reason a site has to
|
||
|
|
* send a human an email — and those are not what the fork objects to. What it
|
||
|
|
* objects to is *identity* depending on a mailbox.
|
||
|
|
*
|
||
|
|
* So the rule is narrow and mechanical: messages addressed to one of this
|
||
|
|
* site's own `.invalid` placeholder addresses never leave the server. They
|
||
|
|
* land here, in an inbox the recipient reads while signed in. That is where
|
||
|
|
* password resets, comment-moderation pings, update nags and "your plugin
|
||
|
|
* needs attention" notices end up — everything core and plugins send to an
|
||
|
|
* account rather than to a person's real address.
|
||
|
|
*
|
||
|
|
* Mail addressed to a real domain is passed straight through to whatever the
|
||
|
|
* site owner configured. If they set up SMTP, it sends. If they did not, it
|
||
|
|
* fails exactly the way stock WordPress fails, which is the honest outcome.
|
||
|
|
*
|
||
|
|
* @package SiriusPress
|
||
|
|
*/
|
||
|
|
|
||
|
|
defined( 'ABSPATH' ) || exit;
|
||
|
|
|
||
|
|
final class SP_Inbox {
|
||
|
|
|
||
|
|
const TABLE = 'sirius_notices';
|
||
|
|
const VERSION = 1;
|
||
|
|
|
||
|
|
public static function table() {
|
||
|
|
global $wpdb;
|
||
|
|
return $wpdb->prefix . self::TABLE;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Create the table. Called on activation and on a version bump. */
|
||
|
|
public static function install() {
|
||
|
|
global $wpdb;
|
||
|
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||
|
|
$table = self::table();
|
||
|
|
$collate = $wpdb->get_charset_collate();
|
||
|
|
dbDelta(
|
||
|
|
"CREATE TABLE {$table} (
|
||
|
|
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||
|
|
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
|
||
|
|
created_at datetime NOT NULL,
|
||
|
|
read_at datetime DEFAULT NULL,
|
||
|
|
subject varchar(255) NOT NULL DEFAULT '',
|
||
|
|
body longtext NOT NULL,
|
||
|
|
source varchar(60) NOT NULL DEFAULT '',
|
||
|
|
PRIMARY KEY (id),
|
||
|
|
KEY user_unread (user_id, read_at),
|
||
|
|
KEY created (created_at)
|
||
|
|
) {$collate};"
|
||
|
|
);
|
||
|
|
update_option( 'sirius_press_inbox_version', self::VERSION, false );
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function hooks() {
|
||
|
|
add_filter( 'pre_wp_mail', array( __CLASS__, 'intercept_mail' ), 10, 2 );
|
||
|
|
add_action( 'admin_menu', array( __CLASS__, 'menu' ) );
|
||
|
|
add_action( 'admin_bar_menu', array( __CLASS__, 'admin_bar' ), 80 );
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Deliver a message to a user's inbox.
|
||
|
|
*
|
||
|
|
* @param int $user_id 0 addresses every administrator.
|
||
|
|
* @param string $subject
|
||
|
|
* @param string $body HTML allowed; rendered with wp_kses_post.
|
||
|
|
* @param string $source Free-form tag, e.g. 'wp_mail' or 'export'.
|
||
|
|
* @return int Rows written.
|
||
|
|
*/
|
||
|
|
public static function add( $user_id, $subject, $body, $source = '' ) {
|
||
|
|
global $wpdb;
|
||
|
|
$targets = array();
|
||
|
|
if ( (int) $user_id > 0 ) {
|
||
|
|
$targets[] = (int) $user_id;
|
||
|
|
} else {
|
||
|
|
foreach ( get_users( array( 'role' => 'administrator', 'fields' => 'ID' ) ) as $id ) {
|
||
|
|
$targets[] = (int) $id;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
$written = 0;
|
||
|
|
foreach ( array_unique( $targets ) as $id ) {
|
||
|
|
$ok = $wpdb->insert(
|
||
|
|
self::table(),
|
||
|
|
array(
|
||
|
|
'user_id' => $id,
|
||
|
|
'created_at' => current_time( 'mysql', true ),
|
||
|
|
'subject' => mb_substr( wp_strip_all_tags( (string) $subject ), 0, 250 ),
|
||
|
|
'body' => (string) $body,
|
||
|
|
'source' => mb_substr( (string) $source, 0, 60 ),
|
||
|
|
),
|
||
|
|
array( '%d', '%s', '%s', '%s', '%s' )
|
||
|
|
);
|
||
|
|
$written += $ok ? 1 : 0;
|
||
|
|
}
|
||
|
|
return $written;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Short-circuit wp_mail for placeholder recipients.
|
||
|
|
*
|
||
|
|
* Returning a non-null value from `pre_wp_mail` tells WordPress the send
|
||
|
|
* is handled. Returning `true` specifically means "delivered", which is
|
||
|
|
* the truth here — it was delivered, to the inbox.
|
||
|
|
*
|
||
|
|
* @param null|bool $short Whatever an earlier filter decided.
|
||
|
|
* @param array $atts wp_mail()'s arguments.
|
||
|
|
* @return null|bool
|
||
|
|
*/
|
||
|
|
public static function intercept_mail( $short, $atts ) {
|
||
|
|
if ( null !== $short ) {
|
||
|
|
return $short; // Somebody else already claimed this send.
|
||
|
|
}
|
||
|
|
$to = isset( $atts['to'] ) ? $atts['to'] : array();
|
||
|
|
if ( ! is_array( $to ) ) {
|
||
|
|
$to = explode( ',', (string) $to );
|
||
|
|
}
|
||
|
|
$stub_domain = strtolower( SP_Settings::stub_email_domain() );
|
||
|
|
|
||
|
|
$captured = array();
|
||
|
|
$passing = array();
|
||
|
|
foreach ( $to as $recipient ) {
|
||
|
|
$addr = strtolower( trim( self::bare_address( $recipient ) ) );
|
||
|
|
if ( '' === $addr ) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
// Any .invalid recipient is ours by definition — RFC 2606 says the
|
||
|
|
// TLD never resolves, so passing it to a mailer only produces a
|
||
|
|
// bounce or a hard error in the log.
|
||
|
|
if ( substr( $addr, -strlen( $stub_domain ) - 1 ) === '@' . $stub_domain
|
||
|
|
|| preg_match( '/@[^@]*\.invalid$/', $addr ) ) {
|
||
|
|
$captured[] = $addr;
|
||
|
|
} else {
|
||
|
|
$passing[] = $recipient;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if ( ! $captured ) {
|
||
|
|
return null; // Nothing of ours — let the normal mailer have it.
|
||
|
|
}
|
||
|
|
|
||
|
|
$subject = isset( $atts['subject'] ) ? (string) $atts['subject'] : __( '(no subject)', 'sirius-press' );
|
||
|
|
$body = isset( $atts['message'] ) ? (string) $atts['message'] : '';
|
||
|
|
foreach ( $captured as $addr ) {
|
||
|
|
$user = get_user_by( 'email', $addr );
|
||
|
|
self::add( $user ? (int) $user->ID : 0, $subject, self::to_html( $body ), 'wp_mail' );
|
||
|
|
}
|
||
|
|
|
||
|
|
if ( $passing ) {
|
||
|
|
// A mixed send: the real recipients still deserve their copy, so
|
||
|
|
// re-enter wp_mail with only those. `pre_wp_mail` is not re-entered
|
||
|
|
// for them because their addresses are not placeholders.
|
||
|
|
$rest = $atts;
|
||
|
|
$rest['to'] = $passing;
|
||
|
|
wp_mail(
|
||
|
|
$rest['to'],
|
||
|
|
$subject,
|
||
|
|
$body,
|
||
|
|
isset( $rest['headers'] ) ? $rest['headers'] : '',
|
||
|
|
isset( $rest['attachments'] ) ? $rest['attachments'] : array()
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Pull `foo@bar` out of `Name <foo@bar>`. */
|
||
|
|
private static function bare_address( $recipient ) {
|
||
|
|
$recipient = (string) $recipient;
|
||
|
|
if ( preg_match( '/<([^>]+)>/', $recipient, $m ) ) {
|
||
|
|
return $m[1];
|
||
|
|
}
|
||
|
|
return $recipient;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Plain-text mail bodies are the common case; keep their line breaks. */
|
||
|
|
private static function to_html( $body ) {
|
||
|
|
if ( preg_match( '/<(a|p|br|div|table|html)\b/i', $body ) ) {
|
||
|
|
return $body;
|
||
|
|
}
|
||
|
|
return wpautop( make_clickable( esc_html( $body ) ) );
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------- reading it
|
||
|
|
|
||
|
|
public static function unread_count( $user_id ) {
|
||
|
|
global $wpdb;
|
||
|
|
$table = self::table();
|
||
|
|
return (int) $wpdb->get_var(
|
||
|
|
$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE user_id = %d AND read_at IS NULL", (int) $user_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function recent( $user_id, $limit = 50, $offset = 0 ) {
|
||
|
|
global $wpdb;
|
||
|
|
$table = self::table();
|
||
|
|
return (array) $wpdb->get_results(
|
||
|
|
$wpdb->prepare( "SELECT * FROM {$table} WHERE user_id = %d ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d", (int) $user_id, (int) $limit, (int) $offset ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function mark_all_read( $user_id ) {
|
||
|
|
global $wpdb;
|
||
|
|
$table = self::table();
|
||
|
|
return (int) $wpdb->query(
|
||
|
|
$wpdb->prepare( "UPDATE {$table} SET read_at = %s WHERE user_id = %d AND read_at IS NULL", current_time( 'mysql', true ), (int) $user_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function delete_all( $user_id ) {
|
||
|
|
global $wpdb;
|
||
|
|
return (int) $wpdb->delete( self::table(), array( 'user_id' => (int) $user_id ), array( '%d' ) );
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------- UI
|
||
|
|
|
||
|
|
public static function menu() {
|
||
|
|
$count = self::unread_count( get_current_user_id() );
|
||
|
|
$label = __( 'Inbox', 'sirius-press' );
|
||
|
|
if ( $count > 0 ) {
|
||
|
|
$label .= sprintf( ' <span class="awaiting-mod"><span class="pending-count">%d</span></span>', $count );
|
||
|
|
}
|
||
|
|
add_menu_page(
|
||
|
|
__( 'Inbox', 'sirius-press' ),
|
||
|
|
$label,
|
||
|
|
'read',
|
||
|
|
'sirius-inbox',
|
||
|
|
array( __CLASS__, 'render_page' ),
|
||
|
|
'dashicons-email-alt',
|
||
|
|
72
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function admin_bar( $bar ) {
|
||
|
|
if ( ! is_user_logged_in() ) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
$count = self::unread_count( get_current_user_id() );
|
||
|
|
$bar->add_node(
|
||
|
|
array(
|
||
|
|
'id' => 'sirius-inbox',
|
||
|
|
'title' => '<span class="ab-icon dashicons dashicons-email-alt" style="top:2px"></span>'
|
||
|
|
. ( $count > 0 ? '<span class="ab-label">' . (int) $count . '</span>' : '' ),
|
||
|
|
'href' => admin_url( 'admin.php?page=sirius-inbox' ),
|
||
|
|
'meta' => array( 'title' => __( 'Sirius Press inbox', 'sirius-press' ) ),
|
||
|
|
)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function render_page() {
|
||
|
|
$user_id = get_current_user_id();
|
||
|
|
|
||
|
|
if ( isset( $_POST['sirius_inbox_action'] ) && check_admin_referer( 'sirius_inbox' ) ) {
|
||
|
|
$action = sanitize_key( wp_unslash( $_POST['sirius_inbox_action'] ) );
|
||
|
|
if ( 'read_all' === $action ) {
|
||
|
|
self::mark_all_read( $user_id );
|
||
|
|
} elseif ( 'delete_all' === $action ) {
|
||
|
|
self::delete_all( $user_id );
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$items = self::recent( $user_id, 100 );
|
||
|
|
echo '<div class="wrap"><h1>' . esc_html__( 'Inbox', 'sirius-press' ) . '</h1>';
|
||
|
|
echo '<p class="description">' . esc_html__( 'Messages this site would have emailed to your account. Nothing here was sent anywhere.', 'sirius-press' ) . '</p>';
|
||
|
|
|
||
|
|
echo '<form method="post" style="margin:12px 0">';
|
||
|
|
wp_nonce_field( 'sirius_inbox' );
|
||
|
|
echo '<button class="button" name="sirius_inbox_action" value="read_all">' . esc_html__( 'Mark all read', 'sirius-press' ) . '</button> ';
|
||
|
|
echo '<button class="button" name="sirius_inbox_action" value="delete_all" onclick="return confirm(' . esc_attr( wp_json_encode( __( 'Delete every message in your inbox?', 'sirius-press' ) ) ) . ')">' . esc_html__( 'Delete all', 'sirius-press' ) . '</button>';
|
||
|
|
echo '</form>';
|
||
|
|
|
||
|
|
if ( ! $items ) {
|
||
|
|
echo '<p>' . esc_html__( 'Nothing here yet.', 'sirius-press' ) . '</p></div>';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
echo '<table class="widefat striped"><thead><tr>';
|
||
|
|
echo '<th style="width:160px">' . esc_html__( 'When', 'sirius-press' ) . '</th>';
|
||
|
|
echo '<th>' . esc_html__( 'Message', 'sirius-press' ) . '</th>';
|
||
|
|
echo '</tr></thead><tbody>';
|
||
|
|
foreach ( $items as $item ) {
|
||
|
|
$unread = empty( $item->read_at );
|
||
|
|
printf(
|
||
|
|
'<tr%s><td>%s<br><small>%s</small></td><td><strong>%s</strong><div style="margin-top:6px">%s</div></td></tr>',
|
||
|
|
$unread ? ' style="font-weight:600;background:#fff8e5"' : '',
|
||
|
|
esc_html( mysql2date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $item->created_at ) ),
|
||
|
|
esc_html( $item->source ),
|
||
|
|
esc_html( $item->subject ),
|
||
|
|
wp_kses_post( $item->body )
|
||
|
|
);
|
||
|
|
}
|
||
|
|
echo '</tbody></table></div>';
|
||
|
|
|
||
|
|
self::mark_all_read( $user_id );
|
||
|
|
}
|
||
|
|
}
|