Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 105
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProcessBounceEmails
0.00% covered (danger)
0.00%
0 / 105
0.00% covered (danger)
0.00%
0 / 8
702
0.00% covered (danger)
0.00%
0 / 1
 handleBounce
n/a
0 / 0
n/a
0 / 0
0
 getProcessor
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 processEmail
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 processBounceHeaders
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
56
 getUserDetails
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
42
 getOriginalEmail
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 checkPermanentFailure
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 handleUnrecognizedBounces
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getBounceRecordDB
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace MediaWiki\Extension\BounceHandler;
4
5use MediaWiki\MediaWikiServices;
6use Wikimedia\Rdbms\IDatabase;
7
8/**
9 * Class ProcessBounceEmails
10 *
11 * Methods to process a bounce email
12 *
13 * @file
14 * @ingroup Extensions
15 * @author Tony Thomas, Kunal Mehta, Jeff Green
16 * @license GPL-2.0-or-later
17 */
18abstract class ProcessBounceEmails {
19    /**
20     * Receive an email from the job queue and process it
21     *
22     * @param string $email
23     */
24    abstract public function handleBounce( $email );
25
26    /**
27     * Generates bounce email processor
28     *
29     * @return ProcessBounceWithRegex
30     */
31    public static function getProcessor() {
32        return new ProcessBounceWithRegex();
33    }
34
35    /**
36     * Process bounce email
37     *
38     * @param array $emailHeaders
39     * @param string $emailRaw
40     *
41     * @return bool
42     */
43    public function processEmail( $emailHeaders, $emailRaw ) {
44        // The bounceHandler needs to respond only to permanent failures.
45        $isPermanentFailure = $this->checkPermanentFailure( $emailHeaders );
46        if ( $isPermanentFailure ) {
47            return $this->processBounceHeaders( $emailHeaders, $emailRaw );
48        }
49
50        return false;
51    }
52
53    /**
54     * Process received bounce emails from Job Queue
55     *
56     * @param array $emailHeaders
57     * @param string $emailRaw
58     *
59     * @return bool
60     */
61    public function processBounceHeaders( $emailHeaders, $emailRaw ) {
62        global $wgBounceRecordPeriod, $wgBounceRecordLimit,
63            $wgBounceHandlerUnconfirmUsers, $wgBounceRecordMaxAge;
64
65        $to = $emailHeaders['to'];
66        $subject = $emailHeaders['subject'];
67
68        // Get original failed user email and wiki details
69        $failedUser = $to ? $this->getUserDetails( $to ) : false;
70        if ( is_array( $failedUser ) && isset( $failedUser['wikiId'] )
71            && isset( $failedUser['rawEmail'] ) && isset( $failedUser[ 'bounceTime' ] )
72        ) {
73            $wikiId = $failedUser['wikiId'];
74            $originalEmail = $failedUser['rawEmail'];
75            $bounceTimestamp = $failedUser['bounceTime'];
76            $dbw = self::getBounceRecordDB( DB_PRIMARY, $wikiId );
77
78            $rowData = [
79                'br_user_email' => $originalEmail,
80                'br_timestamp' => $dbw->timestamp( $bounceTimestamp ),
81                'br_reason' => $subject
82            ];
83            $dbw->insert( 'bounce_records', $rowData, __METHOD__ );
84            \MediaWiki\MediaWikiServices::getInstance()
85                ->getStatsdDataFactory()->increment( 'bouncehandler.bounces' );
86
87            if ( $wgBounceRecordMaxAge ) {
88                $pruneOldRecords = new PruneOldBounceRecords( $wgBounceRecordMaxAge );
89                $pruneOldRecords->pruneOldRecords( $wikiId );
90            }
91
92            $takeBounceActions = new BounceHandlerActions(
93                $wikiId,
94                $wgBounceRecordPeriod,
95                $wgBounceRecordLimit,
96                $wgBounceHandlerUnconfirmUsers,
97                $emailRaw
98            );
99            $takeBounceActions->handleFailingRecipient( $failedUser, $emailHeaders );
100            return true;
101        } else {
102            wfDebugLog( 'BounceHandler',
103                "Error: Failed to extract user details from verp address $to"
104            );
105            return false;
106        }
107    }
108
109    /**
110     * Validate and extract user info from a given VERP address and
111     *
112     * return the failed user details, if hashes match
113     * @param string $hashedEmail The original hashed Email from bounce email
114     * @return array $failedUser The failed user details
115     */
116    public function getUserDetails( $hashedEmail ) {
117        global $wgVERPalgorithm, $wgVERPsecret, $wgVERPAcceptTime;
118
119        $failedUser = [];
120
121        $currentTime = (int)wfTimestamp();
122        preg_match( '~(.*?)@~', $hashedEmail, $hashedPart );
123        if ( !isset( $hashedPart[1] ) ) {
124            wfDebugLog( 'BounceHandler',
125                "Error: The received address: $hashedEmail does not match the VERP pattern."
126            );
127            return [];
128        }
129        $hashedVERPPart = explode( '-', $hashedPart[1] );
130        // This would ensure that indexes 0 - 4 in $hashedVERPPart is set
131        if ( isset( $hashedVERPPart[4] ) ) {
132            $hashedData = $hashedVERPPart[0] . '-' . $hashedVERPPart[1] .
133                '-' . $hashedVERPPart[2] . '-' . $hashedVERPPart[3];
134        } else {
135            wfDebugLog(
136                'BounceHandler',
137                "Error: Received malformed VERP address: $hashedPart[1], cannot extract details."
138            );
139            return [];
140        }
141        $bounceTime = (int)base_convert( $hashedVERPPart[3], 36, 10 );
142        // Check if the VERP hash is valid
143        if ( base64_encode(
144                substr( hash_hmac( $wgVERPalgorithm, $hashedData, $wgVERPsecret, true ), 0, 12 )
145            ) === $hashedVERPPart[4]
146            && $currentTime - $bounceTime < $wgVERPAcceptTime
147        ) {
148            $failedUser['wikiId'] = str_replace( '.', '-', $hashedVERPPart[1] );
149            $failedUser['rawUserId'] = base_convert( $hashedVERPPart[2], 36, 10 );
150            $failedEmail = $this->getOriginalEmail( $failedUser );
151            $failedUser['rawEmail'] = $failedEmail ? : null;
152            $failedUser['bounceTime'] = wfTimestamp( TS_MW, $bounceTime );
153        } else {
154            wfDebugLog( 'BounceHandler',
155                "Error: Hash validation failed. Expected hash of $hashedData, got $hashedVERPPart[4]."
156            );
157        }
158
159        return $failedUser;
160    }
161
162    /**
163     * Generate Original Email Id from a hashed emailId
164     *
165     * @param array $failedUser The failed user details
166     * @return string|false $rawEmail The emailId of the failing recipient
167     */
168    public function getOriginalEmail( $failedUser ) {
169        // In multiple wiki deployed case, the $wikiId can help correctly
170        // identify the user after looking up in the required database.
171        $wikiId = $failedUser['wikiId'];
172        $rawUserId = $failedUser['rawUserId'];
173        $lb = MediaWikiServices::getInstance()->getDBLoadBalancerFactory()->getMainLB( $wikiId );
174        $dbr = $lb->getConnection( DB_REPLICA, [], $wikiId );
175
176        $res = $dbr->selectRow(
177            'user',
178            [ 'user_email' ],
179            [
180                'user_id' => $rawUserId,
181            ],
182            __METHOD__
183        );
184        if ( $res !== false ) {
185            return $res->user_email;
186        }
187
188        wfDebugLog( 'BounceHandler',
189            "Error fetching email_id of user_id $rawUserId from Database $wikiId."
190        );
191        return false;
192    }
193
194    /**
195     * Check for a permanent failure
196     *
197     * @param array $emailHeaders
198     * @return bool
199     */
200    protected function checkPermanentFailure( $emailHeaders ) {
201        if ( isset( $emailHeaders['status'] ) ) {
202            $status = explode( '.', $emailHeaders['status'] );
203            // According to RFC1893 status codes starting with 5 mean Permanent Failures
204            return $status[0] == 5;
205        } elseif ( isset( $emailHeaders['smtp-code'] ) ) {
206            return $emailHeaders['smtp-code'] >= 500;
207        } elseif ( isset( $emailHeaders['x-failed-recipients'] ) ) {
208            // If not status code was found, let's presume that the presence of
209            // X-Failed-Recipients means permanent failure
210            return true;
211        } else {
212            return false;
213        }
214    }
215
216    /**
217     * Handle unrecognized bounces by notifying wiki admins with the full email
218     *
219     * @param string $email
220     * @param string $to
221     */
222    public function handleUnrecognizedBounces( $email, $to ) {
223        global $wgUnrecognizedBounceNotify, $wgPasswordSender;
224
225        wfDebugLog( 'BounceHandler', "Received temporary bounce from $to" );
226        $handleUnIdentifiedBounce = new ProcessUnRecognizedBounces(
227            $wgUnrecognizedBounceNotify, $wgPasswordSender );
228        $handleUnIdentifiedBounce->processUnRecognizedBounces( $email );
229    }
230
231    /**
232     * Get a lazy connection to the bounce table
233     *
234     * @param int $index DB_PRIMARY/DB_REPLICA
235     * @param string $wiki The DB that the bounced email was sent from
236     * @return IDatabase
237     */
238    public static function getBounceRecordDB( $index, $wiki ) {
239        global $wgBounceHandlerCluster, $wgBounceHandlerSharedDB;
240
241        $wiki = $wgBounceHandlerSharedDB ?: $wiki;
242
243        $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
244        $lb = $wgBounceHandlerCluster
245            ? $lbFactory->getExternalLB( $wgBounceHandlerCluster )
246            : $lbFactory->getMainLB( $wiki );
247
248        return $lb->getConnection( $index, [], $wiki );
249    }
250}