1# Copyright 2013 The Distro Tracker Developers 

2# See the COPYRIGHT file at the top-level directory of this distribution and 

3# at https://deb.li/DTAuthors 

4# 

5# This file is part of Distro Tracker. It is subject to the license terms 

6# in the LICENSE file found in the top-level directory of this 

7# distribution and at https://deb.li/DTLicense. No part of Distro Tracker, 

8# including this file, may be copied, modified, propagated, or distributed 

9# except according to the terms contained in the LICENSE file. 

10""" 

11Module implementing the processing of email control messages. 

12""" 

13import logging 

14import re 

15from email.iterators import typed_subpart_iterator 

16 

17from django.conf import settings 

18from django.core.mail import EmailMessage 

19from django.template.loader import render_to_string 

20 

21from distro_tracker.core.utils import distro_tracker_render_to_string 

22from distro_tracker.core.utils import extract_email_address_from_header 

23from distro_tracker.core.utils import get_decoded_message_payload 

24from distro_tracker.core.utils.email_messages import decode_header 

25from distro_tracker.core.utils.email_messages import unfold_header 

26from distro_tracker.mail.control.commands import CommandFactory 

27from distro_tracker.mail.control.commands import CommandProcessor 

28from distro_tracker.mail.models import CommandConfirmation 

29 

30 

31DISTRO_TRACKER_CONTACT_EMAIL = settings.DISTRO_TRACKER_CONTACT_EMAIL 

32DISTRO_TRACKER_BOUNCES_EMAIL = settings.DISTRO_TRACKER_BOUNCES_EMAIL 

33DISTRO_TRACKER_CONTROL_EMAIL = settings.DISTRO_TRACKER_CONTROL_EMAIL 

34 

35logger = logging.getLogger(__name__) 

36 

37 

38def send_response(original_message, message_text, recipient_email, cc=None): 

39 """ 

40 Helper function which sends an email message in response to a control 

41 message. 

42 

43 :param original_message: The received control message. 

44 :type original_message: :py:class:`email.message.Message` or an object with 

45 an equivalent interface 

46 :param message_text: The text which should be included in the body of the 

47 response. 

48 :param cc: A list of emails which should receive a CC of the response. 

49 """ 

50 subject = unfold_header(decode_header(original_message.get('Subject', ''))) 

51 if not subject: 

52 subject = 'Your mail' 

53 message_id = unfold_header(original_message.get('Message-ID', '')) 

54 references = unfold_header(original_message.get('References', '')) 

55 if references: 

56 references += ' ' 

57 references += message_id 

58 message = EmailMessage( 

59 subject='Re: ' + subject, 

60 to=[unfold_header(original_message['From'])], 

61 cc=cc, 

62 from_email=DISTRO_TRACKER_BOUNCES_EMAIL, 

63 headers={ 

64 'From': DISTRO_TRACKER_CONTACT_EMAIL, 

65 'X-Loop': DISTRO_TRACKER_CONTROL_EMAIL, 

66 'References': references, 

67 'In-Reply-To': message_id, 

68 }, 

69 body=message_text, 

70 ) 

71 

72 logger.info("control => %(to)s %(cc)s", { 

73 'to': recipient_email, 

74 'cc': " ".join(cc) if cc else "", 

75 }) 

76 message.send() 

77 

78 

79def send_plain_text_warning(original_message, logdata): 

80 """ 

81 Sends an email warning the user that the control message could not 

82 be decoded due to not being a text/plain message. 

83 

84 :param original_message: The received control message. 

85 :type original_message: :py:class:`email.message.Message` or an object with 

86 an equivalent interface 

87 """ 

88 warning_message = render_to_string('control/email-plaintext-warning.txt') 

89 send_response(original_message, warning_message, 

90 recipient_email=logdata['from']) 

91 logger.info("control :: no plain text found in %(msgid)s", logdata) 

92 

93 

94class ConfirmationSet(object): 

95 """ 

96 A class which keeps track of all confirmations which are required during a 

97 single control process run. This is necessary in order to send the emails 

98 asking for confirmation only when all commands are processed. 

99 """ 

100 def __init__(self): 

101 self.commands = {} 

102 self.confirmation_messages = {} 

103 

104 def add_command(self, email, command_text, confirmation_message): 

105 """ 

106 Adds a command to the list of all commands which need to be confirmed. 

107 

108 :param email: The email of the user the command references. 

109 :param command_text: The text of the command which needs to be 

110 confirmed. 

111 :param confirmation_message: An extra message to be included in the 

112 email when asking for confirmation of this command. This is usually 

113 an explanation of what the effect of the command is. 

114 """ 

115 self.commands.setdefault(email, []) 

116 self.confirmation_messages.setdefault(email, []) 

117 

118 self.commands[email].append(command_text) 

119 self.confirmation_messages[email].append(confirmation_message) 

120 

121 def _ask_confirmation(self, email, commands, messages): 

122 """ 

123 Sends a confirmation mail to a single user. Includes all commands that 

124 the user needs to confirm. 

125 """ 

126 command_confirmation = CommandConfirmation.objects.create_for_commands( 

127 commands=commands) 

128 message = distro_tracker_render_to_string( 

129 'control/email-confirmation-required.txt', { 

130 'command_confirmation': command_confirmation, 

131 'confirmation_messages': self.confirmation_messages[email], 

132 } 

133 ) 

134 subject = 'CONFIRM ' + command_confirmation.confirmation_key 

135 

136 EmailMessage( 

137 subject=subject, 

138 to=[email], 

139 from_email=DISTRO_TRACKER_BOUNCES_EMAIL, 

140 headers={ 

141 'From': DISTRO_TRACKER_CONTROL_EMAIL, 

142 }, 

143 body=message, 

144 ).send() 

145 logger.info("control => confirmation token sent to %s", email) 

146 

147 def ask_confirmation_all(self): 

148 """ 

149 Sends a confirmation mail to all users which have been registered by 

150 using :py:meth:`add_command`. 

151 """ 

152 for email, commands in self.commands.items(): 

153 self._ask_confirmation( 

154 email, commands, self.confirmation_messages[email]) 

155 

156 def get_emails(self): 

157 """ 

158 :returns: A unique list of emails which will receive a confirmation 

159 mail since there exists at least one command which references 

160 this user's email. 

161 """ 

162 return self.commands.keys() 

163 

164 

165def process(msg): 

166 """ 

167 The function which actually processes a received command email message. 

168 

169 :param msg: The received command email message. 

170 :type msg: ``email.message.Message`` 

171 """ 

172 email = extract_email_address_from_header(msg.get('From', '')) 

173 logdata = { 

174 'from': email, 

175 'msgid': msg.get('Message-ID', 'no-msgid-present@localhost'), 

176 } 

177 logger.info("control <= %(from)s %(msgid)s", logdata) 

178 if 'X-Loop' in msg and \ 

179 DISTRO_TRACKER_CONTROL_EMAIL in msg.get_all('X-Loop'): 

180 logger.info("control :: discarded %(msgid)s due to X-Loop", logdata) 

181 return 

182 # Get the first plain-text part of the message 

183 plain_text_part = next(typed_subpart_iterator(msg, 'text', 'plain'), None) 

184 if not plain_text_part: 

185 # There is no plain text in the email 

186 send_plain_text_warning(msg, logdata) 

187 return 

188 

189 # Decode the plain text into a unicode string 

190 text = get_decoded_message_payload(plain_text_part) 

191 

192 lines = extract_command_from_subject(msg) + text.splitlines() 

193 # Process the commands 

194 factory = CommandFactory({'email': email}) 

195 confirmation_set = ConfirmationSet() 

196 processor = CommandProcessor(factory) 

197 processor.confirmation_set = confirmation_set 

198 processor.process(lines) 

199 

200 confirmation_set.ask_confirmation_all() 

201 # Send a response only if there were some commands processed 

202 if processor.is_success(): 

203 send_response(msg, processor.get_output(), recipient_email=email, 

204 cc=set(confirmation_set.get_emails())) 

205 else: 

206 logger.info("control :: no command processed in %(msgid)s", logdata) 

207 

208 

209def extract_command_from_subject(message): 

210 """ 

211 Returns a command found in the subject of the email. 

212 

213 :param message: An email message. 

214 :type message: :py:class:`email.message.Message` or an object with 

215 an equivalent interface 

216 """ 

217 subject = decode_header(message.get('Subject')) 

218 if not subject: 

219 return [] 

220 match = re.match(r'(?:Re\s*:\s*)?(.*)$', subject, re.IGNORECASE) 

221 return ['# Message subject', match.group(1) if match else subject]