<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[12760] sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php: Plugin Directory: Add a bin script to process email bounces in HelpScout.</title>
</head>
<body>
<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; }
#msg dl a { font-weight: bold}
#msg dl a:link { color:#fc3; }
#msg dl a:active { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { white-space: pre-line; overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta" style="font-size: 105%">
<dt style="float: left; width: 6em; font-weight: bold">Revision</dt> <dd><a style="font-weight: bold" href="http://meta.trac.wordpress.org/changeset/12760">12760</a><script type="application/ld+json">{"@context":"http://schema.org","@type":"EmailMessage","description":"Review this Commit","action":{"@type":"ViewAction","url":"http://meta.trac.wordpress.org/changeset/12760","name":"Review Commit"}}</script></dd>
<dt style="float: left; width: 6em; font-weight: bold">Author</dt> <dd>dd32</dd>
<dt style="float: left; width: 6em; font-weight: bold">Date</dt> <dd>2023-07-26 05:54:44 +0000 (Wed, 26 Jul 2023)</dd>
</dl>
<pre style='padding-left: 1em; margin: 2em 0; border-left: 2px solid #ccc; line-height: 1.25; font-size: 105%; font-family: sans-serif'>Plugin Directory: Add a bin script to process email bounces in HelpScout.
This bin script can be run after bulk emails to automatically revoke commit or close plugins as required when the authors emails are bouncing.
Data is logged to allow plugin reviewers to see why an action was taken, and the Author Note (visible to plugin committers) explains the next steps if their plugin was closed.
See <a href="http://meta.trac.wordpress.org/ticket/6920">#6920</a>.</pre>
<h3>Added Paths</h3>
<ul>
<li><a href="#sitestrunkwordpressorgpublic_htmlwpcontentpluginsplugindirectorybinbouncedemailsphp">sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php</a></li>
</ul>
</div>
<div id="patch">
<h3>Diff</h3>
<a id="sitestrunkwordpressorgpublic_htmlwpcontentpluginsplugindirectorybinbouncedemailsphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php (rev 0)
+++ sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php 2023-07-26 05:54:44 UTC (rev 12760)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,397 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+namespace WordPressdotorg\Plugin_Directory\Bin\BouncedEmails;
+use WordPressdotorg\Plugin_Directory\Plugin_Directory;
+use WordPressdotorg\Plugin_Directory\Tools;
+use WordPressdotorg\Plugin_Directory\Admin\Metabox\Author_Notice;
+
+use WordPressdotorg\MU_Plugins\Utilities\HelpScout; // NOTE: NOT the same as the HelpScout class in this plugin.
+
+use function WordPressdotorg\API\HelpScout\{ get_user_email_for_email };
+
+/**
+ * This script will process all the bounced emails in HelpScout and take the appropriate action.
+ *
+ * It will:
+ * - Fetch plugins tagged 'auto-bounce'
+ * - Determine who the bounce is for
+ * - Then either:
+ * a. Revoke commit for the bouncing user, IF there are other committers AND the owner is not bouncing.
+ * b. Close the plugin if all committers are bouncing OR the owner is bouncing.
+ * - In both cases, an Audit log entry is added referencing this script.
+ * - In the case of revoke, the audit log entry contains the bounce message.
+ * - In the case of a plugin closure, the Author notice is set to the next steps for the user AND a copy of the bounce is included.
+ * - If an action is taken, the HS ticket is closed. (This action will show as 'Systems').
+ */
+
+// This script should only be called in a CLI environment.
+if ( 'cli' != php_sapi_name() ) {
+ die();
+}
+
+// Comment out once you're sure you know what you're doing :) Remove the sleep( 10 ) below if you want.
+die( "Please check the source, and validate this will work as you anticipate." );
+
+$opts = getopt( '', array( 'url:', 'abspath:', 'doit' ) );
+
+if ( empty( $opts['url'] ) ) {
+ $opts['url'] = 'https://wordpress.org/plugins/';
+}
+if ( empty( $opts['abspath'] ) && false !== strpos( __DIR__, 'wp-content' ) ) {
+ $opts['abspath'] = substr( __DIR__, 0, strpos( __DIR__, 'wp-content' ) );
+}
+
+// Dry-run or live mode?
+define( __NAMESPACE__ . '\OPERATION_MODE', isset( $opts['doit'] ) ? 'live' : 'dry-run' );
+
+// Bootstrap WordPress
+$_SERVER['HTTP_HOST'] = parse_url( $opts['url'], PHP_URL_HOST );
+$_SERVER['REQUEST_URI'] = parse_url( $opts['url'], PHP_URL_PATH );
+
+require rtrim( $opts['abspath'], '/' ) . '/wp-load.php';
+
+if ( ! class_exists( '\WordPressdotorg\Plugin_Directory\Plugin_Directory' ) ) {
+ fwrite( STDERR, "Error! This site doesn't have the Plugin Directory plugin enabled.\n" );
+ if ( defined( 'WPORG_PLUGIN_DIRECTORY_BLOGID' ) ) {
+ fwrite( STDERR, "Run the following command instead:\n" );
+ fwrite( STDERR, "\tphp " . implode( ' ', $argv ) . ' --url ' . get_site_url( WPORG_PLUGIN_DIRECTORY_BLOGID, '/' ) . "\n" );
+ }
+ die();
+}
+
+if ( 'live' == OPERATION_MODE ) {
+ echo "Running in live mode. Changes will be made.\nProceeding in 10s...\n";
+} else {
+ echo "Running in dry-run mode. No changes will be made. Pass --doit parameter to make changes.\nProceeding in 10s...\n";
+}
+sleep( 10 );
+
+// Load the HelpScout API helper methods.
+require_once API_WPORGPATH . '/dotorg/helpscout/common.php';
+
+// Set a user to run as.
+wp_set_current_user( get_user_by( 'slug', 'wordpressdotorg' )->ID );
+
+/**
+ * Fetch the bounce email threads.
+ */
+function get_bounces() {
+ $api = HelpScout::instance();
+
+ return $api->get_paged(
+ '/v2/conversations',
+ [
+ 'mailbox' => $api->get_mailbox_id( 'plugins' ),
+ 'embed' => 'threads',
+ 'status' => 'pending',
+ 'tag' => 'auto-bounce',
+ ]
+ );
+}
+
+/**
+ * Determine the bounce message from the MTA.
+ *
+ * @param object $email
+ * @return string|false
+ */
+function extract_bounce_message( $email ) {
+ $body = '';
+ $message = '';
+
+ foreach ( $email->_embedded->threads as $reply ) {
+ if ( 'customer' != $reply->type ) {
+ continue;
+ }
+
+ $body = $reply->body;
+ break;
+ }
+
+ $body = preg_replace( '#<br/?>#i', "\n", $body );
+ $body = str_replace( "\r", '', $body );
+
+ // Trim the body to header-like lines.
+ $body = explode( "\n", $body );
+ $last_was_header = false;
+ $lines_matched = 0;
+ foreach ( $body as $i => $line ) {
+ if ( ! preg_match( '/^[\w-]+:/', $line ) && ! $last_was_header ) {
+ $last_was_header = false;
+ continue;
+ }
+
+ $lines_matched++;
+ $last_was_header = true;
+ $message .= $line . "\n";
+ }
+
+ /*
+ * If lots of lines matched, it probably contained the full message.. Outlook does this.
+ * See if we can match a smaller specific message.
+ */
+ if (
+ $lines_matched > 10 &&
+ preg_match( '/(^|\n)Reporting-MTA:.+(\n[\w-]+:.+(\n[ \t]+.+)*|\n){2,10}/i', $message, $m )
+ ) {
+ $message = $m[0];
+ }
+
+ // Unfurl the message if needed. [\d-.] strips some MTA's codes from the indent.
+ $message = preg_replace( '/\n[ \t]+/', ' ', $message );
+
+ // Remove the headers we don't care about.
+ if ( str_contains( $message, 'Reporting-MTA' ) ) {
+ $lines = explode( "\n", $message );
+ $lines = array_filter( $lines, function( $line ) {
+ // Removes things like Reporting-MTA, X-PostFix-ID, etc.
+ return ! preg_match( '/^(Reporting-MTA|Received-From-MTA|X-)/i', $line );
+ } );
+
+ $lines = array_filter( $lines );
+
+ $message = implode( "\n", $lines );
+ }
+
+ return trim( $message ) ?: false;
+}
+
+$stats = [
+ 'processed' => 0,
+ 'error' => 0,
+ 'closed' => 0,
+ 'revoked-commit' => 0,
+];
+
+$actions_to_take = [
+/* plugin-id => [
+ 'id' => [ user-123 ],
+ 'users' => [
+ 'username' => 'bounce message',
+ 'user-2' => 'bounce message',
+ ],
+ 'action' => 'close|revoke'
+ ],
+*/
+];
+
+foreach ( get_bounces()->_embedded->conversations as $bounce ) {
+ $helpscout_url = "https://secure.helpscout.net/conversation/{$bounce->id}/{$bounce->number}";
+
+ $stats['processed']++;
+
+ echo "Processing {$helpscout_url}\n";
+
+ $email = get_user_email_for_email( $bounce );
+ $user = get_user_by( 'email', $email );
+
+ $slugs = Tools::get_users_write_access_plugins( $user ) ?: [];
+ $plugins = array_map(
+ function( $slug ) {
+ return Plugin_Directory::get_plugin_post( $slug );
+ },
+ $slugs
+ );
+
+ $plugins = array_filter(
+ $plugins,
+ function( $plugin ) {
+ return 'publish' === get_post_status( $plugin );
+ }
+ );
+
+ $single_committer_plugins = array_filter(
+ $plugins,
+ function( $plugin ) use( $user ) {
+ return (
+ $user->ID == $plugin->post_author ||
+ 1 === count( Tools::get_plugin_committers( $plugin ) )
+ );
+ }
+ );
+ $multiple_committer_plugins = array_filter(
+ $plugins,
+ function( $plugin ) use( $user ) {
+ return (
+ count( Tools::get_plugin_committers( $plugin ) ) > 1 &&
+ $user->ID != $plugin->post_author
+ );
+ }
+ );
+
+ if ( ! $email || ! $user || ! $slugs || ! $plugins ) {
+ echo "\tNo user or plugins found.\n";
+ $stats['error']++;
+ continue;
+ }
+
+ $bounce_message = extract_bounce_message( $bounce );
+
+ echo "\tEmail: $email\n";
+ echo "\tUser: {$user->user_login}\n";
+ echo "\tSingular committer (or owns): " . implode( ', ', wp_list_pluck( $single_committer_plugins, 'post_name' ) ) . "\n";
+ echo "\tJust a committer: " . implode( ', ', wp_list_pluck( $multiple_committer_plugins, 'post_name' ) ) . "\n";
+
+ echo "\tBounce Message:\n\t\t> " . ( str_replace( "\n", "\n\t\t> ", $bounce_message ) ?: 'Unable to determine bounce reason' ) . "\n";
+
+ foreach ( $plugins as $plugin ) {
+ $actions_to_take[ $plugin->ID ] ??= [ 'id' => [], 'users' => [], 'action' => '' ];
+
+ $actions_to_take[ $plugin->ID ]['id'][] = $bounce->id;
+ $actions_to_take[ $plugin->ID ]['users'][ $user->ID ] = $bounce_message;
+ }
+
+ echo "\n";
+}
+
+// Determine the action to take..
+foreach ( $actions_to_take as $post_id => $data ) {
+ $plugin = get_post( $post_id );
+ $committer_count = count( Tools::get_plugin_committers( $plugin ) );
+ $bouncing_users = count( $data['users'] );
+ $owner_bouncing = in_array( $plugin->post_author, array_keys( $data['users'] ) );
+
+ $action = 'revoke'; // revoke, close. Default to revoke.
+ if ( $bouncing_users >= $committer_count || $owner_bouncing ) {
+ $action = 'close';
+ }
+
+ $actions_to_take[ $post_id ]['action'] = $action;
+}
+
+// Perform Commit Revoke.
+foreach ( $actions_to_take as $post_id => $data ) {
+ if ( 'revoke' !== $data['action'] ) {
+ continue;
+ }
+
+ $plugin = get_post( $post_id );
+
+ $helpscout_url = '';
+ foreach ( $data['id'] as $hs_id ) {
+ $helpscout_url .= " https://secure.helpscout.net/conversation/{$hs_id}";
+ }
+ $helpscout_url = trim( $helpscout_url );
+
+ echo "Plugin: {$plugin->post_name}\n";
+ echo "\t" . get_permalink( $plugin ) . "\n";
+
+ foreach ( $data['users'] as $user_id => $bounce_message ) {
+ $user = get_user_by( 'id', $user_id );
+
+ $bounce_message = $bounce_message ? "<pre>{$bounce_message}</pre>\n" : '';
+
+ echo "\tRemoved Commit for {$user->user_login}\n";
+
+ if ( 'live' != OPERATION_MODE ) {
+ continue;
+ }
+
+ Tools::audit_log(
+ "Removing {$user->user_login} as a committer due to Email bounce. See {$helpscout_url}.\n{$bounce_message}<em>Automated by <code>bin/bounced-emails.php</code></em>",
+ $plugin
+ );
+ Tools::revoke_plugin_committer( $plugin, $user );
+
+ $stats['revoked-commit']++;
+ }
+
+ // Close those HS tickets.
+ if ( 'live' == OPERATION_MODE ) {
+ foreach ( $data['id'] as $hs_id ) {
+ HelpScout::instance()->api(
+ '/v2/conversations/' . $hs_id,
+ [
+ 'op' => 'replace',
+ 'path' => '/status',
+ 'value' => 'closed',
+ ],
+ 'PATCH'
+ );
+ }
+ }
+
+ echo "\n";
+}
+
+// Perform plugin closures.
+foreach ( $actions_to_take as $post_id => $data ) {
+ if ( 'close' !== $data['action'] ) {
+ continue;
+ }
+
+ $plugin = get_post( $post_id );
+
+ $helpscout_url = '';
+ foreach ( $data['id'] as $hs_id ) {
+ $helpscout_url .= " https://secure.helpscout.net/conversation/{$hs_id}";
+ }
+ $helpscout_url = trim( $helpscout_url );
+
+ echo "Plugin: {$plugin->post_name}\n";
+ echo "\t" . get_permalink( $plugin ) . "\n";
+ echo "\tClosing due to bounce of all committers, or owner.\n";
+
+ $bounce_message = implode( "\n\n", array_filter( $data['users'] ) );
+ $bounce_message = $bounce_message ? '<pre>' . esc_html( $bounce_message ) . "</pre>\n" : '';
+
+ if ( 'live' != OPERATION_MODE ) {
+ echo "\n";
+ continue;
+ }
+
+ Tools::audit_log(
+ "Closing due to Email bounce. See {$helpscout_url}.\n{$bounce_message}<em>Automated by <code>bin/bounced-emails.php</code></em>",
+ $plugin
+ );
+
+ // Add author notice.
+ Author_Notice::set(
+ $plugin,
+ '<p>' .
+ '<strong>Your plugin has been closed due to a <a href="/plugins/developers/">guideline violation</a>:</strong> Your email address is currently bouncing.<br>' .
+ 'The good news is that in most cases, we can restore your plugin(s). To do that, we need you to do the following:' .
+ '<ol>' .
+ '<li><a href="https://wordpress.org/support/users/profile/edit/">Make sure the email on the user account is valid</a>.</li>' .
+ '<li>If the email is a group mail or mailing list, make sure it can receive email from external domains or non-members.</li>' .
+ "<li>If the email forwards, check all addresses to make sure they're valid and do not forward bounces, some email forwarders break DMARC signatures in which case you will need to change your forwarding configutation.</li>" .
+ '<li>If the ownership of the plugin is in doubt, let us know what accounts are supposed to have access and be the official owners so we can transfer them appropriately.</li>' .
+ '<li>You must update the plugin readme to confirm it is compatible with the current release of WordPress. This is to ensure people can actually find your plugin.</li>' .
+ '<li>Perform a full security and guideline check of your own work. Look for sanitization, remote loading of content, and any other minor bugs.</li>' .
+ '<li>Update all the code and upload it to SVN.</li>' .
+ "<li>Contact <a href='mailto:plugins@wordpress.org'>plugins@wordpress.org</a> to begin the review and re-open process.</li>" .
+ '</ol>' .
+ '</p>' .
+ $bounce_message,
+ 'error'
+ );
+
+ // Record why it's closed
+ update_post_meta( $plugin->ID, '_close_reason', 'guideline-violation' );
+ update_post_meta( $plugin->ID, 'plugin_closed_date', current_time( 'mysql' ) );
+
+ // Change status.
+ wp_update_post( [
+ 'ID' => $plugin->ID,
+ 'post_status' => 'closed',
+ ] );
+
+ // Close those HS tickets.
+ foreach ( $data['id'] as $hs_id ) {
+ HelpScout::instance()->api(
+ '/v2/conversations/' . $hs_id,
+ [
+ 'op' => 'replace',
+ 'path' => '/status',
+ 'value' => 'closed',
+ ],
+ 'PATCH'
+ );
+ }
+
+ $stats['closed']++;
+
+ echo "\n";
+}
+
+echo "\nAll Done! Stats:\n";
+var_dump( $stats );
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of file
</span><span class="cx" style="display: block; padding: 0 10px">Property changes on: sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span></div>
</body>
</html>