jenkins-bot has submitted this change and it was merged. ( https://gerrit.wikimedia.org/r/387516 )
Change subject: Maintenance script for sending bulk emails ...................................................................... Maintenance script for sending bulk emails Send a bulk email message to a list of wiki account holders using User::sendMail. Features: * Read message body from a file * Read recipients from a file, one per line * Only send to accounts with confirmed addresses * Only send to accounts that have not opted out of email contact * Optional: check users against an opt-out list maintained on-wiki * Optional: set a From: address * Optional: set a Reply-To: address Bug: T148783 Depends-On: I75699008638f7e99b11210c7bb9e2e131fca7c9e Change-Id: I89b2894607b86d683d8cd75a327e6ee61587fcf6 --- A sendBulkEmails.php 1 file changed, 401 insertions(+), 0 deletions(-) Approvals: Legoktm: Looks good to me, approved jenkins-bot: Verified Anomie: Looks good to me, but someone else must approve diff --git a/sendBulkEmails.php b/sendBulkEmails.php new file mode 100644 index 0000000..d34c067 --- /dev/null +++ b/sendBulkEmails.php @@ -0,0 +1,401 @@ +<?php +/** + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @ingroup Maintenance + * @ingroup Wikimedia + */ + +require_once __DIR__ . '/WikimediaMaintenance.php'; + +/** + * Send a bulk email message to a list of wiki account holders using + * User::sendMail. + * + * Features: + * - Read message body from a file + * - Read recipients from a file, one per line + * - Only send to accounts with confirmed addresses + * - Only send to accounts that have not opted out of email contact + * - Optional: check users against an opt-out list maintained on-wiki + * - Optional: set a From: address + * - Optional: set a Reply-To: address + * + * @copyright © 2017 Wikimedia Foundation and contributors. + */ +class SendBulkEmails extends Maintenance { + /** + * @var string $DEFAULT_START Opt-out list start marker + */ + const DEFAULT_START = '<!-- BEGIN OPT-OUT LIST -->'; + + /** + * @var string $DEFAULT_END Opt-out list end marker + */ + const DEFAULT_END = '<!-- END OPT-OUT LIST -->'; + + /** + * @var int $DEFAULT_DELAY Email send delay (seconds) + */ + const DEFAULT_DELAY = 5; + + /** + * @var int $start Unix epoch time + */ + private $start = 0; + + /** + * @var int $missing Count of users not found in database + */ + private $missing = 0; + + /** + * @var int $noreceive Count of users who can not receive email + */ + private $noreceive = 0; + + /** + * @var int $optedout Count of users listed on the opt-out page + */ + private $optedout = 0; + + /** + * @var int $failed Count of User::sendMail() failures + */ + private $failed = 0; + + /** + * @var int $ok Count of User::sendMail() successes + */ + private $ok = 0; + + /** + * @var int $total Count of users processed + */ + private $total = 0; + + /** + * @var string $subject Email subject + */ + private $subject = ''; + + /** + * @var string $body Email body + */ + private $body = ''; + + /** + * @var User|null $from Email From: user + */ + private $from = null; + + /** + * @var MailAddress|null $replyto Email Reoly-To: address + */ + private $replyto = null; + + /** + * @var string $optoutStart Opt-out list start marker + */ + private $optoutStart = self::DEFAULT_START; + + /** + * @var string $optoutEnd Opt-out list end marker + */ + private $optoutEnd = self::DEFAULT_END; + + /** + * @var string[] $optout List of opt-out usernames + */ + private $optout = []; + + /** + * @var string|null $optoutUrl Full URL to opt-out page + */ + private $optoutUrl = null; + + /** + * @var int $delpy Number of seconds to delay between email sends + */ + private $delay = self::DEFAULT_DELAY; + + /** + * @var bool $dryRun Dry run (no email send) guard + */ + private $dryRun = false; + + public function __construct() { + parent::__construct(); + $this->start = microtime( true ); + $this->mDescription = + 'Send bulk email to a list of wiki account holders'; + $this->addOption( 'subject', 'Email subject (string)', true, true ); + $this->addOption( 'body', 'Email body (file)', true, true ); + $this->addOption( 'to', + 'List of users to email, one per line (file)', true, true ); + $this->addOption( 'from', 'Email sender (username)', false, true ); + $this->addOption( 'reply-to', + 'Reply-To address (username)', false, true ); + $this->addOption( 'optout', + 'Wikipage containing list of users to exclude from contact (title)', + false, true ); + $this->addOption( 'optout-start', + 'Opt-out list start marker', false, true ); + $this->addOption( 'optout-end', + 'Opt-out list end marker', false, true ); + $this->addOption( 'delay', + 'Time to wait between emails (seconds)', false, true ); + $this->addOption( 'dry-run', 'Do not send emails' ); + } + + public function execute() { + $this->subject = $this->getOption( 'subject' ); + $this->body = $this->getFileContents( 'body' ); + $this->from = $this->getSender(); + $this->replyto = $this->getReplyTo(); + $this->optoutStart = $this->getOption( + 'optout-start', self::DEFAULT_START ); + $this->optoutEnd = $this->getOption( 'optout-end', self::DEFAULT_END ); + $this->optout = $this->getOptOutList(); + $this->delay = $this->getOption( 'delay', self::DEFAULT_DELAY ); + $this->dryRun = $this->hasOption( 'dry-run' ); + + Hooks::register( + 'UserMailerTransformMessage', + [ $this, 'onUserMailerTransformMessage' ] + ); + + $to = $this->getFileHandle( 'to' ); + for ( + $username = trim( fgets( $to ) ); + strlen( $username ); + $username = trim( fgets( $to ) ) + ) { + if ( $this->processUser( $username ) && $this->delay ) { + sleep( $this->delay ); + } + } + fclose( $to ); + + $this->report(); + $this->output( "done.\n" ); + } + + /** + * @param string $username + * @return bool True if mail was sent (or attempted); false otherwise + */ + private function processUser( $username ) { + $this->total++; + $user = User::newFromName( $username ); + if ( !$user || !$user->getId() ) { + $this->missing++; + $this->output( "ERROR - Unknown user {$username}\n" ); + return false; + } + if ( !$user->canReceiveEmail() ) { + $this->noreceive++; + $this->output( "WARNING - User {$username} can't receive mail\n" ); + return false; + } + if ( in_array( $user->getName(), $this->optout, true ) ) { + $this->optedout++; + $this->output( "WARNING - User {$username} on opt-out list\n" ); + return false; + } + + $this->output( "INFO - Emailing {$username} <{$user->getEmail()}>\n" ); + $status = $this->dryRun ? + Status::newGood() : + $user->sendMail( + $this->subject, $this->body, $this->from, $this->replyto ); + if ( $status->isGood() ) { + $this->ok++; + } else { + $this->failed++; + $this->output( "ERROR - Send failed: {$status->getMessage()}\n" ); + } + return true; + } + + /** + * Hook handler for the UserMailerTransformMessage hook. + * + * @param MailAddress[] $to List of mail recipients + * @param MailAddress $from Mail sender + * @param string &$subject Message subject + * @param array &$headers Email headers + * @param string|array &$body Message body + * @param Message|string &$error Explanation of any error encountered + * @return bool True if mail should be sent; flase otherwise + */ + public function onUserMailerTransformMessage( + $to, $from, &$subject, &$headers, &$body, &$error + ) { + $headers['Precedence'] = 'bulk'; + if ( $this->optoutUrl ) { + $headers['List-Unsubscribe'] = "<{$this->optoutUrl}>"; + } + return true; + } + + private function reportPcnt( $val ) { + if ( $this->total > 0 ) { + return $val / $this->total * 100.0; + } + return 0; + } + + private function report() { + $delta = microtime( true ) - $this->start; + $format = '[%s]' . + ' processed: %d (%.1f/sec);' . + ' ok: %d (%.1f%%);' . + ' failed: %d (%.1f%%);' . + ' missing: %d (%.1f%%);' . + ' noreceive: %d (%.1f%%);' . + ' optedout: %d (%.1f%%);' . + "\n"; + $this->output( sprintf( $format, + wfTimestamp( TS_DB ), + $this->total, $this->total / $delta, + $this->ok, $this->reportPcnt( $this->ok ), + $this->failed, $this->reportPcnt( $this->failed ), + $this->missing, $this->reportPcnt( $this->missing ), + $this->noreceive, $this->reportPcnt( $this->noreceive ), + $this->optedout, $this->reportPcnt( $this->optedout ) + ) ); + } + + /** + * @return User|null Sender + */ + private function getSender() { + if ( $this->hasOption( 'from' ) ) { + $uname = $this->getOption( 'from' ); + $from = User::newFromName( $uname ); + if ( !$from || !$from->getId() ) { + $this->fatalError( "ERROR - Unknown user {$uname}" ); + } + return $from; + } + return null; + } + + /** + * @return MailAddress|null reply-to + */ + private function getReplyTo() { + if ( $this->hasOption( 'reply-to' ) ) { + $uname = $this->getOption( 'reply-to' ); + $rt = User::newFromName( $uname ); + if ( !$rt || !$rt->getId() ) { + $this->fatalError( "ERROR - Unknown user {$uname}" ); + } + return MailAddress::newFromUser( $rt ); + } + return null; + } + + /** + * Get the filehandle pointed to by a parameter's value. + * + * @param string $param Parameter name + * @return resource Open file handle + */ + private function getFileHandle( $param ) { + $fname = $this->getOption( $param ); + if ( !is_file( $fname ) ) { + $this->fatalError( "ERROR - File not found: {$fname}" ); + } + $fh = fopen( $fname, 'r' ); + if ( $fh === false ) { + $this->fatalError( "ERROR - Could not open file: {$fname}" ); + } + return $fh; + } + + /** + * Get the contents of a file pointed to by a parameter's value. + * + * @param string $param Parameter name + * @return string File contents + */ + private function getFileContents( $param ) { + $fname = $this->getOption( $param ); + if ( !is_file( $fname ) ) { + $this->fatalError( "ERROR - File not found: {$fname}" ); + } + $contents = file_get_contents( $fname ); + if ( $contents === false ) { + $this->fatalError( "ERROR - Could not read file: {$fname}" ); + } + return $contents; + } + + /** + * Read an opt-out list from a wiki page. + * + * The page is expected to be a normal wiki page and to have list start + * and end markers in the wikitext source that surround the list. The list + * itself is expected to be one username per line in canonical form. + * + * Lots of assumptions for sure, but hey this is maintenance script. :) + * + * Also sets $this->optoutUrl as a side effect. + * + * @return string[] List of usernames + */ + private function getOptOutList() { + $list = []; + if ( $this->hasOption( 'optout' ) ) { + $title = Title::newFromText( $this->getOption( 'optout' ) ); + if ( !$title->exists() ) { + $this->fatalError( "ERROR - Opt-out page '{$title}' not found." ); + } + $this->optoutUrl = $title->getFullURL( + '', false, PROTO_CANONICAL ); + $rev = Revision::newFromTitle( $title ); + $content = ContentHandler::getContentText( $rev->getContent() ); + $inList = false; + foreach ( explode( "\n", $content ) as $line ) { + if ( !$inList ) { + if ( $line == $this->optoutStart ) { + $inList = true; + } + } else { + if ( $line == $this->optoutEnd ) { + break; + } + $list[] = trim( $line ); + } + } + if ( !$inList ) { + $this->fatalError( + "ERROR - List marker '{$this->optoutStart}' not found." ); + } + } + return $list; + } + + public function getDbType() { + return self::DB_NONE; + } +} + +$maintClass = 'SendBulkEmails'; +require_once RUN_MAINTENANCE_IF_MAIN; -- To view, visit https://gerrit.wikimedia.org/r/387516 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: I89b2894607b86d683d8cd75a327e6ee61587fcf6 Gerrit-PatchSet: 4 Gerrit-Project: mediawiki/extensions/WikimediaMaintenance Gerrit-Branch: master Gerrit-Owner: BryanDavis <[email protected]> Gerrit-Reviewer: Anomie <[email protected]> Gerrit-Reviewer: BryanDavis <[email protected]> Gerrit-Reviewer: Faidon Liambotis <[email protected]> Gerrit-Reviewer: Herron <[email protected]> Gerrit-Reviewer: Legoktm <[email protected]> Gerrit-Reviewer: Reedy <[email protected]> Gerrit-Reviewer: jenkins-bot <> _______________________________________________ MediaWiki-commits mailing list [email protected] https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits
