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
17from django.conf import settings
18from django.core.mail import EmailMessage
19from django.template.loader import render_to_string
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
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
35logger = logging.getLogger(__name__)
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.
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 )
72 logger.info("control => %(to)s %(cc)s", {
73 'to': recipient_email,
74 'cc': " ".join(cc) if cc else "",
75 })
76 message.send()
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.
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)
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 = {}
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.
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, [])
118 self.commands[email].append(command_text)
119 self.confirmation_messages[email].append(confirmation_message)
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
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)
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])
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()
165def process(msg):
166 """
167 The function which actually processes a received command email message.
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
189 # Decode the plain text into a unicode string
190 text = get_decoded_message_payload(plain_text_part)
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)
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)
209def extract_command_from_subject(message):
210 """
211 Returns a command found in the subject of the email.
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]