Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.38% covered (warning)
77.38%
65 / 84
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
BounceHandlerActions
77.38% covered (warning)
77.38%
65 / 84
50.00% covered (danger)
50.00%
3 / 6
17.60
0.00% covered (danger)
0.00%
0 / 1
 __construct
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 handleFailingRecipient
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 createEchoNotification
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 notifyGlobalUser
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 unSubscribeUser
86.21% covered (warning)
86.21%
25 / 29
0.00% covered (danger)
0.00%
0 / 1
6.09
 formatHeaders
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\BounceHandler;
4
5use InvalidArgumentException;
6use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
7use MediaWiki\Extension\Notifications\Model\Event;
8use MediaWiki\Logger\LoggerFactory;
9use MediaWiki\MediaWikiServices;
10use MediaWiki\Registration\ExtensionRegistry;
11use MediaWiki\Title\Title;
12use MediaWiki\WikiMap\WikiMap;
13
14/**
15 * Actions to perform after we receive an email bounce
16 *
17 * @file
18 * @ingroup Extensions
19 * @author Tony Thomas, Kunal Mehta, Jeff Green
20 * @license GPL-2.0-or-later
21 */
22class BounceHandlerActions {
23
24    /**
25     * @var string
26     */
27    protected $wikiId;
28
29    /**
30     * @var int
31     */
32    protected $bounceRecordPeriod;
33
34    /**
35     * @var int
36     */
37    protected $bounceRecordLimit;
38
39    /**
40     * @var bool
41     */
42    protected $bounceHandlerUnconfirmUsers;
43
44    /**
45     * @var string
46     */
47    protected $emailRaw;
48
49    /**
50     * @param string $wikiId The database id of the failing recipient
51     * @param int $bounceRecordPeriod Time period for which bounce activities are considered
52     *  before un-subscribing
53     * @param int $bounceRecordLimit The number of bounce allowed in the bounceRecordPeriod.
54     * @param bool $bounceHandlerUnconfirmUsers Enable/Disable user un-subscribe action
55     * @param string $emailRaw The raw bounce Email
56     */
57    public function __construct(
58        $wikiId, $bounceRecordPeriod, $bounceRecordLimit, $bounceHandlerUnconfirmUsers, $emailRaw
59    ) {
60        if ( $wikiId !== WikiMap::getCurrentWikiId() ) {
61            // We want to use the User class methods, which make no sense on the wrong wiki
62            throw new InvalidArgumentException( "BounceHandlerActions constructed for a foreign wiki." );
63        }
64
65        $this->wikiId = $wikiId;
66        $this->bounceRecordPeriod = $bounceRecordPeriod;
67        $this->bounceRecordLimit = $bounceRecordLimit;
68        $this->bounceHandlerUnconfirmUsers = $bounceHandlerUnconfirmUsers;
69        $this->emailRaw = $emailRaw;
70    }
71
72    /**
73     * Perform actions on users who failed to receive emails in a given period
74     *
75     * @param array $failedUser The details of the failing user
76     * @param array $emailHeaders Email headers
77     * @return bool
78     */
79    public function handleFailingRecipient( array $failedUser, $emailHeaders ) {
80        if ( $this->bounceHandlerUnconfirmUsers ) {
81            $originalEmail = $failedUser['rawEmail'];
82            $bounceValidPeriod = time() - $this->bounceRecordPeriod; // Unix
83
84            $dbr = ProcessBounceEmails::getBounceRecordDB( DB_REPLICA, $this->wikiId );
85
86            $totalBounces = $dbr->newSelectQueryBuilder()
87                ->select( '*' )
88                ->from( 'bounce_records' )
89                ->where( [
90                    'br_user_email' => $originalEmail,
91                    $dbr->expr( 'br_timestamp', '>=', $dbr->timestamp( $bounceValidPeriod ) )
92                ] )
93                ->limit( $this->bounceRecordLimit )
94                ->caller( __METHOD__ )->fetchRowCount();
95
96            if ( $totalBounces >= $this->bounceRecordLimit ) {
97                $this->unSubscribeUser( $failedUser, $emailHeaders );
98            }
99        }
100
101        return true;
102    }
103
104    /**
105     * Function to trigger Echo notifications
106     *
107     * @param int $userId ID of user to be notified
108     * @param string $email un-subscribed email address used in notification
109     */
110    public function createEchoNotification( $userId, $email ) {
111        if ( ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) {
112            Event::create( [
113                'type' => 'unsubscribe-bouncehandler',
114                'extra' => [
115                    'failed-user-id' => $userId,
116                    'failed-email' => $email,
117                ],
118            ] );
119        }
120    }
121
122    /**
123     * Function to inject Echo notification to the last source of bounce for an
124     * unsubscribed Global user
125     *
126     * @param int $bounceUserId
127     * @param string $originalEmail
128     */
129    public function notifyGlobalUser( $bounceUserId, $originalEmail ) {
130        $params = [
131            'failed-user-id' => $bounceUserId,
132            'failed-email' => $originalEmail,
133            'wikiId' => $this->wikiId,
134            'bounceRecordPeriod' => $this->bounceRecordPeriod,
135            'bounceRecordLimit' => $this->bounceRecordLimit,
136            'bounceHandlerUnconfirmUsers' => $this->bounceHandlerUnconfirmUsers,
137            'emailRaw' => $this->emailRaw,
138        ];
139        $title = Title::newFromText( 'BounceHandler Global user notification Job' );
140        $job = new BounceHandlerNotificationJob( $title, $params );
141        MediaWikiServices::getInstance()->getJobQueueGroupFactory()
142            ->makeJobQueueGroup( $this->wikiId )
143            ->push( $job );
144    }
145
146    /**
147     * Un-subscribe a failing recipient
148     *
149     * @param array $failedUser The details of the failing user
150     * @param array $emailHeaders Email headers
151     */
152    public function unSubscribeUser( array $failedUser, $emailHeaders ) {
153        $originalEmail = $failedUser['rawEmail'];
154        $bounceUserId = $failedUser['rawUserId'];
155
156        $user = MediaWikiServices::getInstance()->getUserFactory()->newFromId( $bounceUserId );
157        $caUser = null;
158        if ( ExtensionRegistry::getInstance()->isLoaded( 'CentralAuth' ) ) {
159            $instance = CentralAuthUser::getPrimaryInstance( $user );
160            if ( $instance->isAttached() ) {
161                $caUser = $instance;
162            }
163        }
164
165        if ( $caUser ) {
166            // Invalidate gu_email_authenticated in the CentralAuth database
167            $caUser->setEmailAuthenticationTimestamp( null );
168            $caUser->saveSettings();
169            $this->notifyGlobalUser( $bounceUserId, $originalEmail );
170        } else {
171            // Invalidate user_email_authenticated in the local user table
172            $user->setEmailAuthenticationTimestamp( null );
173            $user->saveSettings();
174            $this->createEchoNotification( $bounceUserId, $originalEmail );
175        }
176
177        $logger = LoggerFactory::getInstance( 'BounceHandler' );
178        $logger->info( 'Un-subscribed {name} <{email}> for exceeding bounce limit {limit}', [
179            'name' => $caUser ? $caUser->getName() : $user->getName(),
180            'email' => $originalEmail,
181            'limit' => $this->bounceRecordLimit,
182            'emailHeaders' => $this->formatHeaders( $emailHeaders ),
183            'emailRaw' => $this->emailRaw,
184        ] );
185
186        $from = $caUser ? 'global' : 'local';
187        MediaWikiServices::getInstance()->getStatsFactory()
188            ->getCounter( 'BounceHandler_unsubscribed_total' )
189            ->setLabel( 'from', $from )
190            ->copyToStatsdAt( "bouncehandler.unsub.$from" )
191            ->increment();
192    }
193
194    /**
195     * Turns a keyed array into "Key: Value" newline split string
196     *
197     * @param array $emailHeaders
198     * @return string
199     */
200    private function formatHeaders( $emailHeaders ) {
201        return implode(
202            "\n",
203            array_map(
204                static function ( $v, $k ) {
205                    return "$k$v";
206                },
207                $emailHeaders,
208                array_keys( $emailHeaders )
209            )
210        );
211    }
212
213}