PageRenderTime 46ms CodeModel.GetById 22ms RepoModel.GetById 0ms app.codeStats 0ms

/src/mailman/runners/command.py

https://gitlab.com/yamhill/mailman
Python | 234 lines | 184 code | 13 blank | 37 comment | 13 complexity | cbc099f3917623551ec7fe714067890c MD5 | raw file
  1. # Copyright (C) 1998-2016 by the Free Software Foundation, Inc.
  2. #
  3. # This file is part of GNU Mailman.
  4. #
  5. # GNU Mailman is free software: you can redistribute it and/or modify it under
  6. # the terms of the GNU General Public License as published by the Free
  7. # Software Foundation, either version 3 of the License, or (at your option)
  8. # any later version.
  9. #
  10. # GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
  11. # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
  12. # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
  13. # more details.
  14. #
  15. # You should have received a copy of the GNU General Public License along with
  16. # GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
  17. """-request robot command runner."""
  18. # See the delivery diagram in IncomingRunner.py. This module handles all
  19. # email destined for mylist-request, -join, and -leave. It no longer handles
  20. # bounce messages (i.e. -admin or -bounces), nor does it handle mail to
  21. # -owner.
  22. import re
  23. import logging
  24. from contextlib import suppress
  25. from email.errors import HeaderParseError
  26. from email.header import decode_header, make_header
  27. from email.iterators import typed_subpart_iterator
  28. from io import StringIO
  29. from mailman import public
  30. from mailman.config import config
  31. from mailman.core.i18n import _
  32. from mailman.core.runner import Runner
  33. from mailman.email.message import UserNotification
  34. from mailman.interfaces.command import ContinueProcessing, IEmailResults
  35. from mailman.interfaces.languages import ILanguageManager
  36. from zope.component import getUtility
  37. from zope.interface import implementer
  38. NL = '\n'
  39. log = logging.getLogger('mailman.vette')
  40. class CommandFinder:
  41. """Generate commands from the content of a message."""
  42. def __init__(self, msg, msgdata, results):
  43. self.command_lines = []
  44. self.ignored_lines = []
  45. self.processed_lines = []
  46. # Depending on where the message was destined to, add some implicit
  47. # commands. For example, if this was sent to the -join or -leave
  48. # addresses, it's the same as if 'join' or 'leave' commands were sent
  49. # to the -request address.
  50. subaddress = msgdata.get('subaddress')
  51. if subaddress == 'join':
  52. self.command_lines.append('join')
  53. elif subaddress == 'leave':
  54. self.command_lines.append('leave')
  55. elif subaddress == 'confirm':
  56. mo = re.match(config.mta.verp_confirm_regexp, msg.get('to', ''))
  57. if mo:
  58. self.command_lines.append('confirm ' + mo.group('cookie'))
  59. # Extract the subject header and do RFC 2047 decoding.
  60. raw_subject = msg.get('subject', '')
  61. try:
  62. subject = str(make_header(decode_header(raw_subject)))
  63. # Mail commands must be ASCII.
  64. self.command_lines.append(subject.encode('us-ascii'))
  65. except (HeaderParseError, UnicodeError, LookupError):
  66. # The Subject header was unparseable or not ASCII. If the raw
  67. # subject is a unicode object, convert it to ASCII ignoring all
  68. # bogus characters. Otherwise, there's nothing in the subject
  69. # that we can use.
  70. if isinstance(raw_subject, str):
  71. safe_subject = raw_subject.encode('us-ascii', 'ignore')
  72. self.command_lines.append(safe_subject)
  73. # Find the first text/plain part of the message.
  74. part = None
  75. for part in typed_subpart_iterator(msg, 'text', 'plain'):
  76. break
  77. if part is None or part is not msg:
  78. # Either there was no text/plain part or we ignored some
  79. # non-text/plain parts.
  80. print(_('Ignoring non-text/plain MIME parts'), file=results)
  81. if part is None:
  82. # There was no text/plain part to be found.
  83. return
  84. body = part.get_payload()
  85. # text/plain parts better have string payloads.
  86. assert isinstance(body, (bytes, str)), 'Non-string decoded payload'
  87. lines = body.splitlines()
  88. # Use no more lines than specified
  89. max_lines = int(config.mailman.email_commands_max_lines)
  90. self.command_lines.extend(lines[:max_lines])
  91. self.ignored_lines.extend(lines[max_lines:])
  92. def __iter__(self):
  93. """Return each command line, split into space separated arguments."""
  94. while self.command_lines:
  95. line = self.command_lines.pop(0)
  96. self.processed_lines.append(line)
  97. parts = line.strip().split()
  98. if len(parts) == 0:
  99. continue
  100. # Ensure that all the parts are unicodes. Since we only accept
  101. # ASCII commands and arguments, ignore anything else.
  102. parts = [(part
  103. if isinstance(part, str)
  104. else part.decode('ascii', 'ignore'))
  105. for part in parts]
  106. yield parts
  107. @public
  108. @implementer(IEmailResults)
  109. class Results:
  110. """The email command results."""
  111. def __init__(self, charset='us-ascii'):
  112. self._output = StringIO()
  113. self.charset = charset
  114. print(_("""\
  115. The results of your email command are provided below.
  116. """), file=self._output)
  117. def write(self, text):
  118. if isinstance(text, bytes):
  119. text = text.decode(self.charset, 'ignore')
  120. self._output.write(text)
  121. def __str__(self):
  122. value = self._output.getvalue()
  123. assert isinstance(value, str), 'Not a string: %r' % value
  124. return value
  125. @public
  126. class CommandRunner(Runner):
  127. """The email command runner."""
  128. def _dispose(self, mlist, msg, msgdata):
  129. message_id = msg.get('message-id', 'n/a')
  130. # The policy here is similar to the Replybot policy. If a message has
  131. # "Precedence: bulk|junk|list" and no "X-Ack: yes" header, we discard
  132. # the command message.
  133. precedence = msg.get('precedence', '').lower()
  134. ack = msg.get('x-ack', '').lower()
  135. if ack != 'yes' and precedence in ('bulk', 'junk', 'list'):
  136. log.info('%s Precedence: %s message discarded by: %s',
  137. message_id, precedence, mlist.request_address)
  138. return False
  139. # Do replybot for commands.
  140. replybot = config.handlers['replybot']
  141. replybot.process(mlist, msg, msgdata)
  142. if mlist.autorespond_requests == 1:
  143. # Respond and discard.
  144. log.info('%s -request message replied and discard', message_id)
  145. return False
  146. # Now craft the response and process the command lines.
  147. charset = msg.get_param('charset')
  148. if charset is None:
  149. charset = 'us-ascii'
  150. results = Results(charset)
  151. # Include just a few key pieces of information from the original: the
  152. # sender, date, and message id.
  153. print(_('- Original message details:'), file=results)
  154. subject = msg.get('subject', 'n/a') # noqa: F841
  155. date = msg.get('date', 'n/a') # noqa: F841
  156. from_ = msg.get('from', 'n/a') # noqa: F841
  157. print(_(' From: $from_'), file=results)
  158. print(_(' Subject: $subject'), file=results)
  159. print(_(' Date: $date'), file=results)
  160. print(_(' Message-ID: $message_id'), file=results)
  161. print(_('\n- Results:'), file=results)
  162. finder = CommandFinder(msg, msgdata, results)
  163. for parts in finder:
  164. command = None
  165. # Try to find a command on this line. There may be a Re: prefix
  166. # (possibly internationalized) so try with the first and second
  167. # words on the line.
  168. if len(parts) > 0:
  169. command_name = parts.pop(0)
  170. command = config.commands.get(command_name)
  171. if command is None and len(parts) > 0:
  172. command_name = parts.pop(0)
  173. command = config.commands.get(command_name)
  174. if command is None:
  175. print(_('No such command: $command_name'), file=results)
  176. else:
  177. status = command.process(
  178. mlist, msg, msgdata, parts, results)
  179. assert status in ContinueProcessing, (
  180. 'Invalid status: %s' % status)
  181. if status == ContinueProcessing.no:
  182. break
  183. # All done. Strip blank lines and send the response.
  184. lines = [line.strip() for line in finder.command_lines if line]
  185. if len(lines) > 0:
  186. print(_('\n- Unprocessed:'), file=results)
  187. for line in lines:
  188. print(line, file=results)
  189. lines = [line.strip() for line in finder.ignored_lines if line]
  190. if len(lines) > 0:
  191. print(_('\n- Ignored:'), file=results)
  192. for line in lines:
  193. print(line, file=results)
  194. print(_('\n- Done.'), file=results)
  195. # Send a reply, but do not attach the original message. This is a
  196. # compromise because the original message is often helpful in tracking
  197. # down problems, but it's also a vector for backscatter spam.
  198. language = getUtility(ILanguageManager)[msgdata['lang']]
  199. reply = UserNotification(msg.sender, mlist.bounces_address,
  200. _('The results of your email commands'),
  201. lang=language)
  202. cte = msg.get('content-transfer-encoding')
  203. if cte is not None:
  204. reply['Content-Transfer-Encoding'] = cte
  205. # Find a charset for the response body. Try the original message's
  206. # charset first, then ascii, then latin-1 and finally falling back to
  207. # utf-8.
  208. reply_body = str(results)
  209. for charset in (results.charset, 'us-ascii', 'latin-1'):
  210. with suppress(UnicodeError):
  211. reply_body.encode(charset)
  212. break
  213. else:
  214. charset = 'utf-8'
  215. reply.set_payload(reply_body, charset=charset)
  216. reply.send(mlist)