Skip to content

Commit bd30153

Browse files
committed
emails: add wm api backend
Bug: T412427
1 parent cab85e3 commit bd30153

File tree

2 files changed

+263
-0
lines changed

2 files changed

+263
-0
lines changed

TWLight/emails/backends/__init__.py

Whitespace-only changes.
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
"""
2+
Email backend that POSTs messages to the MediaWiki Emailuser endpoint.
3+
see: https://www.mediawiki.org/wiki/API:Emailuser
4+
"""
5+
import logging
6+
from requests import Session
7+
from requests.exceptions import ConnectionError
8+
from requests.structures import CaseInsensitiveDict
9+
from threading import RLock
10+
from time import sleep
11+
12+
from django.conf import settings
13+
from django.core.mail.backends.base import BaseEmailBackend
14+
15+
from TWLight.users.models import Editor
16+
17+
logger = logging.getLogger(__name__)
18+
19+
20+
def retry_conn():
21+
"""A decorator that handles connetion retries."""
22+
retry_delay = 0
23+
retry_on_connection_error = 10
24+
retry_after_conn = 5
25+
26+
def wrapper(func):
27+
def conn(*args, **kwargs):
28+
try_count = 0
29+
try_count_conn = 0
30+
while True:
31+
try_count += 1
32+
try_count_conn += 1
33+
34+
if retry_delay:
35+
sleep(retry_delay)
36+
try:
37+
return func(*args, **kwargs)
38+
except ConnectionError as e:
39+
no_retry_conn = 0 <= retry_on_connection_error < try_count_conn
40+
if no_retry_conn:
41+
logger.warning("ConnectionError exhausted retries")
42+
raise e
43+
logger.warning(
44+
"ConnectionError, retrying in {self.retry_after_conn}s"
45+
)
46+
sleep(retry_after_conn)
47+
continue
48+
49+
return conn
50+
51+
return wrapper
52+
53+
54+
def _handle_maxlag(response):
55+
"""A helper method that handles maxlag retries."""
56+
data = response.json()
57+
try:
58+
if data["error"]["code"] != "maxlag":
59+
return data
60+
except KeyError:
61+
return data
62+
63+
retry_after = float(response.headers.get("Retry-After", 5))
64+
retry_on_lag_error = 50
65+
no_retry = 0 <= retry_on_lag_error < try_count
66+
67+
message = "Server exceeded maxlag"
68+
if not no_retry:
69+
message += f", retrying in {retry_after}s"
70+
if "lag" in data["error"]:
71+
message += f", lag={data['error']['lag']}"
72+
message += f", API={self.url}"
73+
74+
log = logger.warning if no_retry else logger.info
75+
log(
76+
message,
77+
{
78+
"code": "maxlag-retry",
79+
"retry-after": retry_after,
80+
"lag": data["error"]["lag"] if "lag" in data["error"] else None,
81+
"x-database-lag": response.headers.get("X-Database-Lag", 5),
82+
},
83+
)
84+
85+
if no_retry:
86+
raise Exception(message)
87+
88+
sleep(retry_after)
89+
90+
91+
class EmailBackend(BaseEmailBackend):
92+
def __init__(
93+
self,
94+
url=None,
95+
timeout=None,
96+
delay=None,
97+
retry_delay=None,
98+
maxlag=None,
99+
username=None,
100+
password=None,
101+
fail_silently=False,
102+
**kwargs,
103+
):
104+
super().__init__(fail_silently=fail_silently)
105+
self.url = settings.MW_API_URL if url is None else url
106+
self.headers = CaseInsensitiveDict()
107+
self.headers["User-Agent"] = "{}/0.0.1".format(__name__)
108+
self.url = settings.MW_API_URL if url is None else url
109+
self.timeout = settings.MW_API_REQUEST_TIMEOUT if timeout is None else timeout
110+
self.delay = settings.MW_API_REQUEST_DELAY if delay is None else delay
111+
self.retry_delay = (
112+
settings.MW_API_REQUEST_RETRY_DELAY if retry_delay is None else retry_delay
113+
)
114+
self.maxlag = settings.MW_API_MAXLAG if maxlag is None else maxlag
115+
self.username = settings.MW_API_EMAIL_USER if username is None else username
116+
self.password = settings.MW_API_EMAIL_PASSWORD if password is None else password
117+
self.email_token = None
118+
self.session = None
119+
self._lock = RLock()
120+
121+
def open(self):
122+
"""
123+
Ensure an open session to the API server. Return whether or not a
124+
new session was required (True or False) or None if an exception
125+
passed silently.
126+
"""
127+
if self.session:
128+
# Nothing to do if the session exists
129+
return False
130+
131+
try:
132+
# GET request to fetch login token
133+
login_token_params = {
134+
"action": "query",
135+
"meta": "tokens",
136+
"type": "login",
137+
"format": "json",
138+
}
139+
logger.info("Getting login token...")
140+
session = Session()
141+
response_login_token = session.get(url=self.url, params=login_token_params)
142+
if response_login_token.status_code != 200:
143+
raise Exception(
144+
"There was an error in the request for obtaining the login token."
145+
)
146+
login_token_data = response_login_token.json()
147+
login_token = login_token_data["query"]["tokens"]["logintoken"]
148+
if not login_token:
149+
raise Exception("There was an error obtaining the login token.")
150+
151+
# POST request to log in. Use of main account for login is not
152+
# supported. Obtain credentials via Special:BotPasswords
153+
# (https://www.mediawiki.org/wiki/Special:BotPasswords) for lgname & lgpassword
154+
login_params = {
155+
"action": "login",
156+
"lgname": self.username,
157+
"lgpassword": self.password,
158+
"lgtoken": login_token,
159+
"format": "json",
160+
}
161+
logger.info("Signing in...")
162+
login_response = session.post(url=self.url, data=login_params)
163+
if login_response.status_code != 200:
164+
raise Exception("There was an error in the request for the login.")
165+
166+
# GET request to fetch Email token
167+
email_token_params = {"action": "query", "meta": "tokens", "format": "json"}
168+
169+
logger.info("Getting email token...")
170+
email_token_response = session.get(url=self.url, params=email_token_params)
171+
email_token_data = email_token_response.json()
172+
173+
# Assign the session and email token
174+
self.email_token = email_token_data["query"]["tokens"]["csrftoken"]
175+
self.session = session
176+
return True
177+
except:
178+
if not self.fail_silently:
179+
raise
180+
181+
def close(self):
182+
"""Unset the session."""
183+
self.email_token = None
184+
self.session = None
185+
186+
def send_messages(self, email_messages):
187+
"""
188+
Send one or more EmailMessage objects and return the number of email
189+
messages sent.
190+
"""
191+
if not email_messages:
192+
return 0
193+
with self._lock:
194+
new_session_created = self.open()
195+
if not self.session or new_session_created is None:
196+
# We failed silently on open().
197+
# Trying to send would be pointless.
198+
return 0
199+
num_sent = 0
200+
for message in email_messages:
201+
sent = self._send(message)
202+
if sent:
203+
num_sent += 1
204+
if new_session_created:
205+
self.close()
206+
return num_sent
207+
208+
@retry_conn()
209+
def _send(self, email_message):
210+
"""A helper method that does the actual sending."""
211+
if not email_message.recipients():
212+
return False
213+
214+
try:
215+
for recipient in email_message.recipients():
216+
# lookup the target editor from the email address
217+
target = Editor.objects.values_list("wp_username", flat=True).get(
218+
user__email=recipient
219+
)
220+
221+
# GET request to check if user is emailable
222+
emailable_params = {
223+
"action": "query",
224+
"list": "users",
225+
"ususers": target,
226+
"usprop": "emailable",
227+
"maxlag": self.maxlag,
228+
"format": "json",
229+
}
230+
231+
logger.info("Checking if user is emailable...")
232+
emailable_response = self.session.post(
233+
url=self.url, data=emailable_params
234+
)
235+
logger.info(emailable_response.text)
236+
emailable_data = emailable_response.json()
237+
emailable = "emailable" in emailable_data["query"]["users"][0]
238+
if not emailable:
239+
continue
240+
241+
# POST request to send an email
242+
email_params = {
243+
"action": "emailuser",
244+
"target": target,
245+
"subject": email_message.subject,
246+
"text": email_message.body,
247+
"token": self.email_token,
248+
"maxlag": self.maxlag,
249+
"format": "json",
250+
}
251+
252+
logger.info("Sending email...")
253+
emailuser_data = _handle_maxlag(
254+
self.session.post(url=self.url, data=email_params)
255+
)
256+
logger.info(emailuser_data)
257+
if emailuser_data["emailuser"]["result"] != "Success":
258+
raise Exception("There was an error when trying to send the email.")
259+
except:
260+
if not self.fail_silently:
261+
raise
262+
return False
263+
return True

0 commit comments

Comments
 (0)