add a newline at the end of replies
[edward.git] / edward
diff --git a/edward b/edward
index 1dad2e1c8062dc2aab55e7c658f9304598c2e838..673e4dc74f8ac18414b5a61d1cdebe24458d5653 100755 (executable)
--- a/edward
+++ b/edward
@@ -31,17 +31,20 @@ Code sourced from these projects:
   * http://www.physics.drexel.edu/~wking/code/python/send_pgp_mime
 """
 
-import sys
-import gpgme
 import re
 import io
 import os
+import sys
+import enum
+import gpgme
 import importlib
+import subprocess
 
 import email.parser
 import email.message
 import email.encoders
 
+from email.mime.text            import MIMEText
 from email.mime.multipart       import MIMEMultipart
 from email.mime.application     import MIMEApplication
 from email.mime.nonmultipart    import MIMENonMultipart
@@ -53,12 +56,19 @@ langs = ["de", "el", "en", "fr", "ja", "pt-br", "ro", "ru", "tr"]
 """This list contains the abbreviated names of reply languages available to
 edward."""
 
+class TxtType (enum.Enum):
+    text        = 0
+    message     = 1
+    pubkey      = 2
+    detachedsig = 3
+    signature   = 4
+
 
-match_types =  [('message',
+match_pairs =  [(TxtType.message,
                 '-----BEGIN PGP MESSAGE-----.*?-----END PGP MESSAGE-----'),
-                ('pubkey',
+                (TxtType.pubkey,
                 '-----BEGIN PGP PUBLIC KEY BLOCK-----.*?-----END PGP PUBLIC KEY BLOCK-----'),
-                ('detachedsig',
+                (TxtType.detachedsig,
                 '-----BEGIN PGP SIGNATURE-----.*?-----END PGP SIGNATURE-----')]
 
 """This list of tuples matches query names with re.search() queries used
@@ -104,11 +114,11 @@ class PayloadPiece (object):
     Instances of this class are often strung together within one or more arrays
     pointed to by each instance of the EddyMsg class.
 
-    'piece_type' refers to a string whose value describes the content of
-    'string'.  Examples include "pubkey", for public keys, and "message", for
-    encrypted data (or armored signatures until they are known to be such.) The
-    names derive from the header and footer of each of these ascii-encoded gpg
-    blocks.
+    'piece_type' refers to an enum whose value describes the content of
+    'string'.  Examples include TxtType.pubkey, for public keys, and
+    TxtType.message, for encrypted data (or armored signatures until they are
+    known to be such.) Some of the names derive from the header and footer of
+    each of these ascii-encoded gpg blocks.
 
     'string' contains some string of text, such as non-GPG text, an encrypted
     block of text, a signature, or a public key.
@@ -232,7 +242,7 @@ def main ():
         implied by the To: address in the original email.
     """
 
-    handle_args()
+    print_reply_only = handle_args()
 
     gpgme_ctx = get_gpg_context(edward_config.gnupghome,
                               edward_config.sign_with_key)
@@ -241,7 +251,7 @@ def main ():
     email_struct = parse_pgp_mime(email_text, gpgme_ctx)
 
     email_to, email_from, email_subject = email_to_from_subject(email_text)
-    lang = import_lang(email_to)
+    lang, reply_from = import_lang_pick_address(email_to, edward_config.hostname)
 
     replyinfo_obj = ReplyInfo()
     replyinfo_obj.replies = lang.replies
@@ -254,7 +264,10 @@ def main ():
                                          email_subject, encrypt_to_key,
                                          gpgme_ctx)
 
-    print(reply_mime)
+    if print_reply_only == True:
+        print(reply_mime)
+    else:
+        send_reply(reply_mime, email_subject, email_from, reply_from)
 
 
 def get_gpg_context (gnupghome, sign_with_key_fp):
@@ -349,7 +362,7 @@ def parse_mime(msg_struct):
     return eddymsg_obj
 
 
-def scan_and_split (payload_piece, match_type, pattern):
+def scan_and_split (payload_piece, match_name, pattern):
     """This splits the payloads of an EddyMsg object into GPG and text parts.
 
     An EddyMsg object's payload_pieces starts off as a list containing a single
@@ -359,7 +372,7 @@ def scan_and_split (payload_piece, match_type, pattern):
 
     Args:
         payload_piece: a single payload or a split part of a payload
-        match_type: the type of data to try to spit out from the payload piece
+        match_name: the type of data to try to spit out from the payload piece
         pattern: the search pattern to be used for finding that type of data
 
     Returns:
@@ -369,7 +382,7 @@ def scan_and_split (payload_piece, match_type, pattern):
     """
 
     # don't try to re-split pieces containing gpg data
-    if payload_piece.piece_type != "text":
+    if payload_piece.piece_type != TxtType.text:
         return [payload_piece]
 
     flags = re.DOTALL | re.MULTILINE
@@ -387,13 +400,13 @@ def scan_and_split (payload_piece, match_type, pattern):
 
         match                   = PayloadPiece()
         match.string            = matches.group('match')
-        match.piece_type        = match_type
+        match.piece_type        = match_name
 
         rest                    = PayloadPiece()
         rest.string             = matches.group('rest')
         rest.piece_type         = payload_piece.piece_type
 
-        more_pieces = scan_and_split(rest, match_type, pattern)
+        more_pieces = scan_and_split(rest, match_name, pattern)
         pieces = [beginning, match ] + more_pieces
 
     return pieces
@@ -430,7 +443,7 @@ def get_subpart_data (part):
         try:
             payload = PayloadPiece()
             payload.string = mime_decoded_bytes.decode(charset)
-            payload.piece_type = 'text'
+            payload.piece_type = TxtType.text
 
             obj.payload_pieces = [payload]
         except UnicodeDecodeError:
@@ -487,11 +500,11 @@ def split_payloads (eddymsg_obj):
         The EddyMsg object's payloads are all split into GPG and non-GPG parts.
     """
 
-    for match_type in match_types:
-        do_to_eddys_pieces(split_payload_pieces, eddymsg_obj, match_type)
+    for match_pair in match_pairs:
+        do_to_eddys_pieces(split_payload_pieces, eddymsg_obj, match_pair)
 
 
-def split_payload_pieces (eddymsg_obj, match_type):
+def split_payload_pieces (eddymsg_obj, match_pair):
     """A helper function for split_payloads(); works on PayloadPiece objects.
 
     This function splits up PayloadPiece objects into multipe PayloadPiece
@@ -500,7 +513,7 @@ def split_payload_pieces (eddymsg_obj, match_type):
 
     Args:
         eddymsg_obj: a single-part EddyMsg object.
-        match_type: a tuple from the match_types list, which specifies a match
+        match_pair: a tuple from the match_pairs list, which specifies a match
             name and a match pattern.
 
     Returns:
@@ -512,10 +525,10 @@ def split_payload_pieces (eddymsg_obj, match_type):
 
     Post:
         The EddyMsg object's payload piece(s) are split into a list of pieces
-        if matches of the match_type are found.
+        if matches of the match_pair are found.
     """
 
-    (match_name, pattern) = match_type
+    (match_name, pattern) = match_pair
 
     new_pieces_list = []
     for piece in eddymsg_obj.payload_pieces:
@@ -562,11 +575,11 @@ def gpg_on_payloads (eddymsg_obj, gpgme_ctx, prev_parts=[]):
 
     for piece in eddymsg_obj.payload_pieces:
 
-        if piece.piece_type == "text":
+        if piece.piece_type == TxtType.text:
             # don't transform the plaintext.
             pass
 
-        elif piece.piece_type == "message":
+        elif piece.piece_type == TxtType.message:
             (plaintext, sigs) = decrypt_block(piece.string, gpgme_ctx)
 
             if plaintext:
@@ -581,22 +594,22 @@ def gpg_on_payloads (eddymsg_obj, gpgme_ctx, prev_parts=[]):
             (plaintext, sigs) = verify_sig_message(piece.string, gpgme_ctx)
 
             if plaintext:
-                piece.piece_type = "signature"
+                piece.piece_type = TxtType.signature
                 piece.gpg_data = GPGData()
                 piece.gpg_data.sigs = sigs
                 # recurse!
                 piece.gpg_data.plainobj = parse_pgp_mime(plaintext, gpgme_ctx)
 
-        # FIXME: handle pubkeys first, so that signatures can be validated
-        # on freshly imported keys
-        elif piece.piece_type == "pubkey":
+        # FIXME: consider handling pubkeys first, so that signatures can be
+        # validated on freshly imported keys
+        elif piece.piece_type == TxtType.pubkey:
             key_fps = add_gpg_key(piece.string, gpgme_ctx)
 
             if key_fps != []:
                 piece.gpg_data = GPGData()
                 piece.gpg_data.keys = key_fps
 
-        elif piece.piece_type == "detachedsig":
+        elif piece.piece_type == TxtType.detachedsig:
             for prev in prev_parts:
                 sig_fps = verify_detached_signature(piece.string, prev.payload_bytes, gpgme_ctx)
 
@@ -659,18 +672,18 @@ def prepare_for_reply_pieces (eddymsg_obj, replyinfo_obj):
     """
 
     for piece in eddymsg_obj.payload_pieces:
-        if piece.piece_type == "text":
+        if piece.piece_type == TxtType.text:
             # don't quote the plaintext part.
             pass
 
-        elif piece.piece_type == "message":
+        elif piece.piece_type == TxtType.message:
             prepare_for_reply_message(piece, replyinfo_obj)
 
-        elif piece.piece_type == "pubkey":
+        elif piece.piece_type == TxtType.pubkey:
             prepare_for_reply_pubkey(piece, replyinfo_obj)
 
-        elif (piece.piece_type == "detachedsig") \
-            or (piece.piece_type == "signature"):
+        elif (piece.piece_type == TxtType.detachedsig) \
+            or (piece.piece_type == TxtType.signature):
                     prepare_for_reply_sig(piece, replyinfo_obj)
 
 
@@ -678,7 +691,7 @@ def prepare_for_reply_message (piece, replyinfo_obj):
     """Helper function for prepare_for_reply()
 
     This function is called when the piece_type of a payload piece is
-    "message", or GPG Message block. This should be encrypted text. If the
+    TxtType.message, or GPG Message block. This should be encrypted text. If the
     encryted block is signed, a sig will be attached to .target_key unless
     there is already one there.
 
@@ -691,7 +704,7 @@ def prepare_for_reply_message (piece, replyinfo_obj):
         Nothing
 
     Pre:
-        the piece.payload_piece value should be "message".
+        the piece.payload_piece value should be TxtType.message.
 
     Post:
         replyinfo_obj gets updated with decryption status, signing status and a
@@ -731,7 +744,7 @@ def prepare_for_reply_pubkey (piece, replyinfo_obj):
         replyinfo_obj: a ReplyInfo object
 
     Pre:
-        piece.piece_type should be set to "pubkey".
+        piece.piece_type should be set to TxtType.pubkey .
 
     Post:
         replyinfo_obj has its fields updated.
@@ -742,8 +755,8 @@ def prepare_for_reply_pubkey (piece, replyinfo_obj):
     else:
         replyinfo_obj.public_key_received = True
 
-        if replyinfo_obj.fallback_target_key == None:
-            replyinfo_obj.fallback_target_key = piece.gpg_data.keys[0]
+        # prefer public key as a fallback for the encrypted reply
+        replyinfo_obj.fallback_target_key = piece.gpg_data.keys[0]
 
 
 def prepare_for_reply_sig (piece, replyinfo_obj):
@@ -756,7 +769,8 @@ def prepare_for_reply_sig (piece, replyinfo_obj):
         replyinfo_obj: a ReplyInfo object
 
     Pre:
-        piece.piece_type should be set to "signature", or "detachedsig".
+        piece.piece_type should be set to TxtType.signature, or
+        TxtType.detachedsig .
 
     Post:
         replyinfo_obj has its fields updated.
@@ -812,14 +826,14 @@ def flatten_decrypted_payloads (eddymsg_obj, replyinfo_obj, get_signed_part):
 
     for piece in eddymsg_obj.payload_pieces:
         if (get_signed_part):
-            if ((piece.piece_type == "detachedsig") \
-                    or (piece.piece_type == "signature")) \
+            if ((piece.piece_type == TxtType.detachedsig) \
+                    or (piece.piece_type == TxtType.signature)) \
                     and (piece.gpg_data != None):
                         flatten_decrypted_payloads(piece.gpg_data.plainobj, replyinfo_obj, False)
                         replyinfo_obj.target_key = piece.gpg_data.sigs[0]
                         break
         else:
-            if piece.piece_type == "text":
+            if piece.piece_type == TxtType.text:
                 replyinfo_obj.msg_to_quote += piece.string
 
 
@@ -919,6 +933,7 @@ def write_reply (replyinfo_obj):
 
     reply_plain += "\n\n"
     reply_plain += replyinfo_obj.replies['signature']
+    reply_plain += "\n"
 
     return reply_plain
 
@@ -939,8 +954,11 @@ def add_gpg_key (key_block, gpgme_ctx):
 
     fp = io.BytesIO(key_block.encode('ascii'))
 
-    result = gpgme_ctx.import_(fp)
-    imports = result.imports
+    try:
+        result = gpgme_ctx.import_(fp)
+        imports = result.imports
+    except gpgme.GpgmeError:
+        imports = []
 
     key_fingerprints = []
 
@@ -1070,32 +1088,41 @@ def email_to_from_subject (email_text):
     return email_to, email_from, email_subject
 
 
-def import_lang(email_to):
-    """Imports appropriate language file for basic i18n support
+def import_lang_pick_address(email_to, hostname):
+    """Imports language file for i18n support; makes reply from address
 
     The language imported depends on the To: address of the email received by
     edward. an -en ending implies the English language, whereas a -ja ending
     implies Japanese. The list of supported languages is listed in the 'langs'
-    list at the beginning of the program.
+    list at the beginning of the program. This function also chooses the
+    language-dependent address which can be used as the From address in the
+    reply email.
 
     Args:
         email_to: the string containing the email address that the mail was
-        sent to.
+            sent to.
+        hostname: the hostname part of the reply email's from address
 
     Returns:
         the reference to the imported language module. The only variable in
         this file is the 'replies' dictionary.
     """
 
+    # default
+    use_lang = "en"
+
     if email_to != None:
         for lang in langs:
             if "edward-" + lang in email_to:
-                lang = "lang." + re.sub('-', '_', lang)
-                language = importlib.import_module(lang)
+                use_lang = lang
+                break
 
-                return language
+    lang_mod_name = "lang." + re.sub('-', '_', use_lang)
+    lang_module = importlib.import_module(lang_mod_name)
 
-    return importlib.import_module("lang.en")
+    reply_from = "edward-" + use_lang + "@" + hostname
+
+    return lang_module, reply_from
 
 
 def generate_encrypted_mime (plaintext, email_to, email_subject, encrypt_to_key,
@@ -1117,45 +1144,42 @@ def generate_encrypted_mime (plaintext, email_to, email_subject, encrypt_to_key,
         A string version of the mime message, possibly encrypted and signed.
     """
 
-    # quoted printable encoding lets most ascii characters look normal
-    # before the mime message is decoded.
-    char_set = email.charset.Charset("utf-8")
-    char_set.body_encoding = email.charset.QP
+    if (encrypt_to_key != None):
 
-    # MIMEText doesn't allow setting the text encoding
-    # so we use MIMENonMultipart.
-    plaintext_mime = MIMENonMultipart('text', 'plain')
-    plaintext_mime.set_payload(plaintext, charset=char_set)
+        # quoted printable encoding lets most ascii characters look normal
+        # before the mime message is decoded.
+        char_set = email.charset.Charset("utf-8")
+        char_set.body_encoding = email.charset.QP
 
-    if (encrypt_to_key != None):
+        # MIMEText doesn't allow setting the text encoding
+        # so we use MIMENonMultipart.
+        plaintext_mime = MIMENonMultipart('text', 'plain')
+        plaintext_mime.set_payload(plaintext, charset=char_set)
 
         encrypted_text = encrypt_sign_message(plaintext_mime.as_string(),
                                               encrypt_to_key,
                                               gpgme_ctx)
-        gpg_payload = encrypted_text
-
-    else:
-        signed_text = sign_message(plaintext_mime.as_string(), gpgme_ctx)
-        gpg_payload = signed_text
 
-    control_mime = MIMEApplication("Version: 1",
-                                   _subtype='pgp-encrypted',
-                                   _encoder=email.encoders.encode_7or8bit)
-    control_mime['Content-Description'] = 'PGP/MIME version identification'
-    control_mime.set_charset('us-ascii')
+        control_mime = MIMEApplication("Version: 1",
+                                       _subtype='pgp-encrypted',
+                                       _encoder=email.encoders.encode_7or8bit)
+        control_mime['Content-Description'] = 'PGP/MIME version identification'
+        control_mime.set_charset('us-ascii')
 
-    encoded_mime = MIMEApplication(gpg_payload,
-                                   _subtype='octet-stream; name="encrypted.asc"',
-                                   _encoder=email.encoders.encode_7or8bit)
-    encoded_mime['Content-Description'] = 'OpenPGP encrypted message'
-    encoded_mime['Content-Disposition'] = 'inline; filename="encrypted.asc"'
-    encoded_mime.set_charset('us-ascii')
+        encoded_mime = MIMEApplication(encrypted_text,
+                                       _subtype='octet-stream; name="encrypted.asc"',
+                                       _encoder=email.encoders.encode_7or8bit)
+        encoded_mime['Content-Description'] = 'OpenPGP encrypted message'
+        encoded_mime['Content-Disposition'] = 'inline; filename="encrypted.asc"'
+        encoded_mime.set_charset('us-ascii')
 
-    message_mime = MIMEMultipart(_subtype="encrypted", protocol="application/pgp-encrypted")
-    message_mime.attach(control_mime)
-    message_mime.attach(encoded_mime)
-    message_mime['Content-Disposition'] = 'inline'
+        message_mime = MIMEMultipart(_subtype="encrypted", protocol="application/pgp-encrypted")
+        message_mime.attach(control_mime)
+        message_mime.attach(encoded_mime)
+        message_mime['Content-Disposition'] = 'inline'
 
+    else:
+        message_mime = MIMEText(plaintext)
 
     message_mime['To'] = email_to
     message_mime['Subject'] = email_subject
@@ -1165,6 +1189,20 @@ def generate_encrypted_mime (plaintext, email_to, email_subject, encrypt_to_key,
     return reply
 
 
+def send_reply(email_txt, subject, reply_to, reply_from):
+
+    email_bytes = email_txt.encode('ascii')
+
+    p = subprocess.Popen(["/usr/sbin/sendmail", "-f", reply_from, "-F", "Edward, GPG Bot", "-i", reply_to], stdin=subprocess.PIPE)
+
+    (stdout, stderr) = p.communicate(email_bytes)
+
+    if stdout != None:
+        debug("sendmail stdout: " + str(stdout))
+    if stderr != None:
+        error("sendmail stderr: " + str(stderr))
+
+
 def email_quote_text (text):
     """Quotes input text by inserting "> "s
 
@@ -1208,29 +1246,6 @@ def encrypt_sign_message (plaintext, encrypt_to_key, gpgme_ctx):
     return encrypted_txt
 
 
-def sign_message (plaintext, gpgme_ctx):
-    """Signs plaintext
-
-    This signs a message.
-
-    Args:
-        plaintext: text to sign
-        gpgme_ctx: the gpgme context
-
-    Returns:
-        An armored signature as a string of text
-    """
-
-    # the plaintext should be mime encoded in an ascii-compatible form
-    plaintext_bytes = io.BytesIO(plaintext.encode('ascii'))
-    signed_bytes = io.BytesIO()
-
-    gpgme_ctx.sign(plaintext_bytes, signed_bytes, gpgme.SIG_MODE_NORMAL)
-
-    signed_txt = signed_bytes.getvalue().decode('ascii')
-    return signed_txt
-
-
 def error (error_msg):
     """Write an error message to stdout
 
@@ -1270,30 +1285,40 @@ def debug (debug_msg):
 
 
 def handle_args ():
-    """Sets the progname variable and complains about any arguments
+    """Sets the progname variable and processes optional argument
 
-    If there are any arguments, then edward complains and quits, because input
-    is read from stdin.
+    If there are more than two arguments then edward complains and quits. An
+    single "-p" argument sets the print_reply_only option, which makes edward
+    print email replies instead of mailing them.
 
     Args:
         None
 
     Returns:
-        None
+        True if edward should print arguments instead of mailing them,
+        otherwise it returns False.
 
     Post:
-        Exits with error 1 if there are arguments, otherwise returns to the
-        calling function, such as main().
+        Exits with error 1 if there are more than two arguments, otherwise
+        returns the print_reply_only option.
     """
 
     global progname
     progname = sys.argv[0]
 
-    if len(sys.argv) > 1:
-        print(progname + ": error, this program doesn't " \
-                "need any arguments.", file=sys.stderr)
+    print_reply_only = False
+
+    if len(sys.argv) > 2:
+        print(progname + " usage:  " + progname + " [-p]\n\n" \
+                + "        -p      print reply message to stdout, do not mail it\n", \
+                file=sys.stderr)
         exit(1)
 
+    elif (len(sys.argv) == 2) and (sys.argv[1] == "-p"):
+        print_reply_only = True
+
+    return print_reply_only
+
 
 if __name__ == "__main__":
     """Executes main if this file is not loaded interactively"""