X-Git-Url: https://git.tokkee.org/?a=blobdiff_plain;f=roundup%2Fmailgw.py;h=7818d21b8652e940080963638ccd113d20d750c9;hb=9976de4f1761b47dc459f10c4b28c311de84e103;hp=7a71d6a7017dc9e90705ec7b0c0335d600342696;hpb=840af79b7d2c41ed0dfd70b496b6b5f3ffefc510;p=roundup.git diff --git a/roundup/mailgw.py b/roundup/mailgw.py index 7a71d6a..7818d21 100644 --- a/roundup/mailgw.py +++ b/roundup/mailgw.py @@ -92,7 +92,7 @@ from roundup.i18n import _ from roundup.hyperdb import iter_roles try: - import pyme, pyme.core, pyme.gpgme + import pyme, pyme.core, pyme.constants, pyme.constants.sigsum except ImportError: pyme = None @@ -156,39 +156,33 @@ def gpgh_key_getall(key, attr): ''' return list of given attribute for all uids in a key ''' - u = key.uids - while u: + for u in key.uids: yield getattr(u, attr) - u = u.next -def gpgh_sigs(sig): - ''' more pythonic iteration over GPG signatures ''' - while sig: - yield sig - sig = sig.next - -def check_pgp_sigs(sig, gpgctx, author): +def check_pgp_sigs(sigs, gpgctx, author, may_be_unsigned=False): ''' Theoretically a PGP message can have several signatures. GPGME - returns status on all signatures in a linked list. Walk that - linked list looking for the author's signature + returns status on all signatures in a list. Walk that list + looking for the author's signature. Note that even if incoming + signatures are not required, the processing fails if there is an + invalid signature. ''' - for sig in gpgh_sigs(sig): + for sig in sigs: key = gpgctx.get_key(sig.fpr, False) # we really only care about the signature of the user who # submitted the email if key and (author in gpgh_key_getall(key, 'email')): - if sig.summary & pyme.gpgme.GPGME_SIGSUM_VALID: + if sig.summary & pyme.constants.sigsum.VALID: return True else: # try to narrow down the actual problem to give a more useful # message in our bounce - if sig.summary & pyme.gpgme.GPGME_SIGSUM_KEY_MISSING: + if sig.summary & pyme.constants.sigsum.KEY_MISSING: raise MailUsageError, \ _("Message signed with unknown key: %s") % sig.fpr - elif sig.summary & pyme.gpgme.GPGME_SIGSUM_KEY_EXPIRED: + elif sig.summary & pyme.constants.sigsum.KEY_EXPIRED: raise MailUsageError, \ _("Message signed with an expired key: %s") % sig.fpr - elif sig.summary & pyme.gpgme.GPGME_SIGSUM_KEY_REVOKED: + elif sig.summary & pyme.constants.sigsum.KEY_REVOKED: raise MailUsageError, \ _("Message signed with a revoked key: %s") % sig.fpr else: @@ -196,7 +190,10 @@ def check_pgp_sigs(sig, gpgctx, author): _("Invalid PGP signature detected.") # we couldn't find a key belonging to the author of the email - raise MailUsageError, _("Message signed with unknown key: %s") % sig.fpr + if sigs: + raise MailUsageError, _("Message signed with unknown key: %s") % sig.fpr + elif not may_be_unsigned: + raise MailUsageError, _("Unsigned Message") class Message(mimetools.Message): ''' subclass mimetools.Message so we can retrieve the parts of the @@ -460,16 +457,18 @@ class Message(mimetools.Message): return self.gettype() == 'multipart/encrypted' \ and self.typeheader.find('protocol="application/pgp-encrypted"') != -1 - def decrypt(self, author): + def decrypt(self, author, may_be_unsigned=False): ''' decrypt an OpenPGP MIME message - This message must be signed as well as encrypted using the "combined" - method. The decrypted contents are returned as a new message. + This message must be signed as well as encrypted using the + "combined" method if incoming signatures are configured. + The decrypted contents are returned as a new message. ''' (hdr, msg) = self.getparts() # According to the RFC 3156 encrypted mail must have exactly two parts. # The first part contains the control information. Let's verify that # the message meets the RFC before we try to decrypt it. - if hdr.getbody() != 'Version: 1' or hdr.gettype() != 'application/pgp-encrypted': + if hdr.getbody().strip() != 'Version: 1' \ + or hdr.gettype() != 'application/pgp-encrypted': raise MailUsageError, \ _("Unknown multipart/encrypted version.") @@ -486,7 +485,8 @@ class Message(mimetools.Message): # key to send it to us. now check the signatures to see if it # was signed by someone we trust result = context.op_verify_result() - check_pgp_sigs(result.signatures, context, author) + check_pgp_sigs(result.signatures, context, author, + may_be_unsigned = may_be_unsigned) plaintext.seek(0,0) # pyme.core.Data implements a seek method with a different signature @@ -511,7 +511,6 @@ class Message(mimetools.Message): raise MailUsageError, \ _("No PGP signature found in message.") - context = pyme.core.Context() # msg.getbody() is skipping over some headers that are # required to be present for verification to succeed so # we'll do this by hand @@ -526,6 +525,7 @@ class Message(mimetools.Message): msg_data = pyme.core.Data(canonical_msg) sig_data = pyme.core.Data(sig.getbody()) + context = pyme.core.Context() context.op_verify(sig_data, msg_data, None) # check all signatures for validity @@ -558,6 +558,7 @@ class parsedMessage: self.props = None self.content = None self.attachments = None + self.crypt = False def handle_ignore(self): ''' Check to see if message can be safely ignored: @@ -862,7 +863,7 @@ Unknown address: %(from_address)s 'You are not permitted to access this tracker.') self.author = author - def check_node_permissions(self): + def check_permissions(self): ''' Check if the author has permission to edit or create this class of node ''' @@ -995,26 +996,37 @@ Subject was: "%(subject)s" """ if self.config.PGP_ROLES: return self.db.user.has_role(self.author, - iter_roles(self.config.PGP_ROLES)) + *iter_roles(self.config.PGP_ROLES)) else: return True - if self.config.PGP_ENABLE and pgp_role(): + if self.config.PGP_ENABLE: + if pgp_role() and self.config.PGP_ENCRYPT: + self.crypt = True assert pyme, 'pyme is not installed' # signed/encrypted mail must come from the primary address author_address = self.db.user.get(self.author, 'address') if self.config.PGP_HOMEDIR: os.environ['GNUPGHOME'] = self.config.PGP_HOMEDIR + if self.config.PGP_REQUIRE_INCOMING in ('encrypted', 'both') \ + and pgp_role() and not self.message.pgp_encrypted(): + raise MailUsageError, _( + "This tracker has been configured to require all email " + "be PGP encrypted.") if self.message.pgp_signed(): self.message.verify_signature(author_address) elif self.message.pgp_encrypted(): - # replace message with the contents of the decrypted + # Replace message with the contents of the decrypted # message for content extraction - # TODO: encrypted message handling is far from perfect - # bounces probably include the decrypted message, for - # instance :( - self.message = self.message.decrypt(author_address) - else: + # Note: the bounce-handling code now makes sure that + # either the encrypted mail received is sent back or + # that the error message is encrypted if needed. + encr_only = self.config.PGP_REQUIRE_INCOMING == 'encrypted' + encr_only = encr_only or not pgp_role() + self.crypt = True + self.message = self.message.decrypt(author_address, + may_be_unsigned = encr_only) + elif pgp_role(): raise MailUsageError, _(""" This tracker has been configured to require all email be PGP signed or encrypted.""") @@ -1155,6 +1167,72 @@ There was a problem with the message you sent: return self.nodeid + # XXX Don't enable. This doesn't work yet. +# "[^A-z.]tracker\+(?P[^\d\s]+)(?P\d+)\@some.dom.ain[^A-z.]" + # handle delivery to addresses like:tracker+issue25@some.dom.ain + # use the embedded issue number as our issue +# issue_re = config['MAILGW_ISSUE_ADDRESS_RE'] +# if issue_re: +# for header in ['to', 'cc', 'bcc']: +# addresses = message.getheader(header, '') +# if addresses: +# # FIXME, this only finds the first match in the addresses. +# issue = re.search(issue_re, addresses, 'i') +# if issue: +# classname = issue.group('classname') +# nodeid = issue.group('nodeid') +# break + + # Default sequence of methods to be called on message. Use this for + # easier override of the default message processing + # list consists of tuples (method, return_if_true), the parsing + # returns if the return_if_true flag is set for a method *and* the + # method returns something that evaluates to True. + method_list = [ + # Filter out messages to ignore + ("handle_ignore", False), + # Check for usage/help requests + ("handle_help", False), + # Check if the subject line is valid + ("check_subject", False), + # get importants parts from subject + ("parse_subject", False), + # check for registration OTK + ("rego_confirm", True), + # get the classname + ("get_classname", False), + # get the optional nodeid: + ("get_nodeid", False), + # Determine who the author is: + ("get_author_id", False), + # allowed to edit or create this class? + ("check_permissions", False), + # author may have been created: + # commit author to database and re-open as author + ("commit_and_reopen_as_author", False), + # Get the recipients list + ("get_recipients", False), + # get the new/updated node props + ("get_props", False), + # Handle PGP signed or encrypted messages + ("get_pgp_message", False), + # extract content and attachments from message body: + ("get_content_and_attachments", False), + # put attachments into files linked to the issue: + ("create_files", False), + # create the message if there's a message body (content): + ("create_msg", False), + ] + + + def parse (self): + for methodname, flag in self.method_list: + method = getattr (self, methodname) + ret = method() + if flag and ret: + return + # perform the node change / create: + return self.create_node() class MailGW: @@ -1370,6 +1448,7 @@ class MailGW: # in some rare cases, a particularly stuffed-up e-mail will make # its way into here... try to handle it gracefully + self.parsed_message = None sendto = message.getaddrlist('resent-from') if not sendto: sendto = message.getaddrlist('from') @@ -1391,6 +1470,12 @@ class MailGW: return self.handle_message(message) # no, we want to trap exceptions + # Note: by default we return the message received not the + # internal state of the parsedMessage -- except for + # MailUsageError, Unauthorized and for unknown exceptions. For + # the latter cases we make sure the error message is encrypted + # if needed (if it either was received encrypted or pgp + # processing is turned on for the user). try: return self.handle_message(message) except MailUsageHelp: @@ -1408,12 +1493,18 @@ class MailGW: m.append(str(value)) m.append('\n\nMail Gateway Help\n=================') m.append(fulldoc) - self.mailer.bounce_message(message, [sendto[0][1]], m) + if self.parsed_message: + message = self.parsed_message.message + crypt = self.parsed_message.crypt + self.mailer.bounce_message(message, [sendto[0][1]], m, crypt=crypt) except Unauthorized, value: # just inform the user that he is not authorized m = [''] m.append(str(value)) - self.mailer.bounce_message(message, [sendto[0][1]], m) + if self.parsed_message: + message = self.parsed_message.message + crypt = self.parsed_message.crypt + self.mailer.bounce_message(message, [sendto[0][1]], m, crypt=crypt) except IgnoreMessage: # do not take any action # this exception is thrown when email should be ignored @@ -1434,7 +1525,10 @@ class MailGW: m.append('An unexpected error occurred during the processing') m.append('of your message. The tracker administrator is being') m.append('notified.\n') - self.mailer.bounce_message(message, [sendto[0][1]], m) + if self.parsed_message: + message = self.parsed_message.message + crypt = self.parsed_message.crypt + self.mailer.bounce_message(message, [sendto[0][1]], m, crypt=crypt) m.append('----------------') m.append(traceback.format_exc()) @@ -1459,81 +1553,13 @@ class MailGW: The following code expects an opened database and a try/finally that closes the database. ''' - parsed_message = self.parsed_message_class(self, message) - - # Filter out messages to ignore - parsed_message.handle_ignore() - - # Check for usage/help requests - parsed_message.handle_help() - - # Check if the subject line is valid - parsed_message.check_subject() - - # XXX Don't enable. This doesn't work yet. - # XXX once this works it should be moved to parsedMessage class -# "[^A-z.]tracker\+(?P[^\d\s]+)(?P\d+)\@some.dom.ain[^A-z.]" - # handle delivery to addresses like:tracker+issue25@some.dom.ain - # use the embedded issue number as our issue -# issue_re = config['MAILGW_ISSUE_ADDRESS_RE'] -# if issue_re: -# for header in ['to', 'cc', 'bcc']: -# addresses = message.getheader(header, '') -# if addresses: -# # FIXME, this only finds the first match in the addresses. -# issue = re.search(issue_re, addresses, 'i') -# if issue: -# classname = issue.group('classname') -# nodeid = issue.group('nodeid') -# break - - # Parse the subject line to get the importants parts - parsed_message.parse_subject() - - # check for registration OTK - if parsed_message.rego_confirm(): - return - - # get the classname - parsed_message.get_classname() - - # get the optional nodeid - parsed_message.get_nodeid() - - # Determine who the author is - parsed_message.get_author_id() - - # make sure they're allowed to edit or create this class - parsed_message.check_node_permissions() - - # author may have been created: - # commit author to database and re-open as author - parsed_message.commit_and_reopen_as_author() - - # Get the recipients list - parsed_message.get_recipients() - - # get the new/updated node props - parsed_message.get_props() - - # Handle PGP signed or encrypted messages - parsed_message.get_pgp_message() - - # extract content and attachments from message body - parsed_message.get_content_and_attachments() - - # put attachments into files linked to the issue - parsed_message.create_files() - - # create the message if there's a message body (content) - parsed_message.create_msg() - - # perform the node change / create - nodeid = parsed_message.create_node() + self.parsed_message = self.parsed_message_class(self, message) + nodeid = self.parsed_message.parse () # commit the changes to the DB self.db.commit() + self.parsed_message = None return nodeid def get_class_arguments(self, class_type, classname=None):