Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 106
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 / 106
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 / 37
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 / 15
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->newInsertQueryBuilder()
84                ->insertInto( 'bounce_records' )
85                ->row( $rowData )
86                ->caller( __METHOD__ )
87                ->execute();
88            \MediaWiki\MediaWikiServices::getInstance()
89                ->getStatsdDataFactory()->increment( 'bouncehandler.bounces' );
90
91            if ( $wgBounceRecordMaxAge ) {
92                $pruneOldRecords = new PruneOldBounceRecords( $wgBounceRecordMaxAge );
93                $pruneOldRecords->pruneOldRecords( $wikiId );
94            }
95
96            $takeBounceActions = new BounceHandlerActions(
97                $wikiId,
98                $wgBounceRecordPeriod,
99                $wgBounceRecordLimit,
100                $wgBounceHandlerUnconfirmUsers,
101                $emailRaw
102            );
103            $takeBounceActions->handleFailingRecipient( $failedUser, $emailHeaders );
104            return true;
105        } else {
106            wfDebugLog( 'BounceHandler',
107                "Error: Failed to extract user details from verp address $to"
108            );
109            return false;
110        }
111    }
112
113    /**
114     * Validate and extract user info from a given VERP address and
115     *
116     * return the failed user details, if hashes match
117     * @param string $hashedEmail The original hashed Email from bounce email
118     * @return array $failedUser The failed user details
119     */
120    public function getUserDetails( $hashedEmail ) {
121        global $wgVERPalgorithm, $wgVERPsecret, $wgVERPAcceptTime;
122
123        $failedUser = [];
124
125        $currentTime = (int)wfTimestamp();
126        preg_match( '~(.*?)@~', $hashedEmail, $hashedPart );
127        if ( !isset( $hashedPart[1] ) ) {
128            wfDebugLog( 'BounceHandler',
129                "Error: The received address: $hashedEmail does not match the VERP pattern."
130            );
131            return [];
132        }
133        $hashedVERPPart = explode( '-', $hashedPart[1] );
134        // This would ensure that indexes 0 - 4 in $hashedVERPPart is set
135        if ( isset( $hashedVERPPart[4] ) ) {
136            $hashedData = $hashedVERPPart[0] . '-' . $hashedVERPPart[1] .
137                '-' . $hashedVERPPart[2] . '-' . $hashedVERPPart[3];
138        } else {
139            wfDebugLog(
140                'BounceHandler',
141                "Error: Received malformed VERP address: $hashedPart[1], cannot extract details."
142            );
143            return [];
144        }
145        $bounceTime = (int)base_convert( $hashedVERPPart[3], 36, 10 );
146        // Check if the VERP hash is valid
147        if ( base64_encode(
148                substr( hash_hmac( $wgVERPalgorithm, $hashedData, $wgVERPsecret, true ), 0, 12 )
149            ) === $hashedVERPPart[4]
150            && $currentTime - $bounceTime < $wgVERPAcceptTime
151        ) {
152            $failedUser['wikiId'] = str_replace( '.', '-', $hashedVERPPart[1] );
153            $failedUser['rawUserId'] = base_convert( $hashedVERPPart[2], 36, 10 );
154            $failedEmail = $this->getOriginalEmail( $failedUser );
155            $failedUser['rawEmail'] = $failedEmail ? : null;
156            $failedUser['bounceTime'] = wfTimestamp( TS_MW, $bounceTime );
157        } else {
158            wfDebugLog( 'BounceHandler',
159                "Error: Hash validation failed. Expected hash of $hashedData, got $hashedVERPPart[4]."
160            );
161        }
162
163        return $failedUser;
164    }
165
166    /**
167     * Generate Original Email Id from a hashed emailId
168     *
169     * @param array $failedUser The failed user details
170     * @return string|false $rawEmail The emailId of the failing recipient
171     */
172    public function getOriginalEmail( $failedUser ) {
173        // In multiple wiki deployed case, the $wikiId can help correctly
174        // identify the user after looking up in the required database.
175        $wikiId = $failedUser['wikiId'];
176        $rawUserId = $failedUser['rawUserId'];
177        $lb = MediaWikiServices::getInstance()->getDBLoadBalancerFactory()->getMainLB( $wikiId );
178        $dbr = $lb->getConnection( DB_REPLICA, [], $wikiId );
179
180        $res = $dbr->newSelectQueryBuilder()
181            ->select( [ 'user_email' ] )
182            ->from( 'user' )
183            ->where( [ 'user_id' => $rawUserId, ] )
184            ->caller( __METHOD__ )->fetchRow();
185
186        if ( $res !== false ) {
187            return $res->user_email;
188        }
189
190        wfDebugLog( 'BounceHandler',
191            "Error fetching email_id of user_id $rawUserId from Database $wikiId."
192        );
193        return false;
194    }
195
196    /**
197     * Check for a permanent failure
198     *
199     * @param array $emailHeaders
200     * @return bool
201     */
202    protected function checkPermanentFailure( $emailHeaders ) {
203        if ( isset( $emailHeaders['status'] ) ) {
204            $status = explode( '.', $emailHeaders['status'] );
205            // According to RFC1893 status codes starting with 5 mean Permanent Failures
206            return $status[0] == 5;
207        } elseif ( isset( $emailHeaders['smtp-code'] ) ) {
208            return $emailHeaders['smtp-code'] >= 500;
209        } elseif ( isset( $emailHeaders['x-failed-recipients'] ) ) {
210            // If not status code was found, let's presume that the presence of
211            // X-Failed-Recipients means permanent failure
212            return true;
213        } else {
214            return false;
215        }
216    }
217
218    /**
219     * Handle unrecognized bounces by notifying wiki admins with the full email
220     *
221     * @param string $email
222     * @param string $to
223     */
224    public function handleUnrecognizedBounces( $email, $to ) {
225        global $wgUnrecognizedBounceNotify, $wgPasswordSender;
226
227        wfDebugLog( 'BounceHandler', "Received temporary bounce from $to" );
228        $handleUnIdentifiedBounce = new ProcessUnRecognizedBounces(
229            $wgUnrecognizedBounceNotify, $wgPasswordSender );
230        $handleUnIdentifiedBounce->processUnRecognizedBounces( $email );
231    }
232
233    /**
234     * Get a lazy connection to the bounce table
235     *
236     * @param int $index DB_PRIMARY/DB_REPLICA
237     * @param string $wiki The DB that the bounced email was sent from
238     * @return IDatabase
239     */
240    public static function getBounceRecordDB( $index, $wiki ) {
241        global $wgBounceHandlerCluster, $wgBounceHandlerSharedDB;
242
243        $wiki = $wgBounceHandlerSharedDB ?: $wiki;
244
245        $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
246        $lb = $wgBounceHandlerCluster
247            ? $lbFactory->getExternalLB( $wgBounceHandlerCluster )
248            : $lbFactory->getMainLB( $wiki );
249
250        return $lb->getConnection( $index, [], $wiki );
251    }
252}