123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201 |
- #!/usr/bin/python3
- """
- Dispatch an email to the right mailbox
- """
- from __future__ import print_function
- from __future__ import absolute_import
- from __future__ import division
- from __future__ import unicode_literals
- import sys
- import re
- import shutil
- import os
- import os.path
- from email.parser import BytesHeaderParser
- from email.utils import getaddresses
- # TODO: once nm.debian.org is python3, move most of this code to process/ and
- # make it unit-tested
- VERSION="0.2"
- class umask_override:
- """
- Context manager that temporarily overrides the umask during its lifetime
- """
- def __init__(self, umask):
- self.new_umask = umask
- self.old_umask = None
- def __enter__(self):
- # Set umask
- self.old_umask = os.umask(self.new_umask)
- return self
- def __exit__(self, exc_type, exc_val, exc_tb):
- # Restore umask
- os.umask(self.old_umask)
- return False
- def open_db(sqlite=False):
- """
- Connect to the NM database and return the db connection.
- Returns the db connection and a function used to process query strings
- before sending them to the database. It is a noop for postgresql and a
- replacement of "%s" with "?" on sqlite.
- That's what you get when some smart one decides to make a DB-independent
- db_api that supports only a DB-specific syntax for SQL query arguments.
- """
- if sqlite:
- import sqlite3
- db = sqlite3.connect("data/db-used-for-development.sqlite")
- return db, lambda s: s.replace("%s", "?").replace("true", "1")
- else:
- import psycopg2
- return psycopg2.connect("service=nm user=nm"), lambda x: x
- class IncomingMessage:
- re_dest = re.compile("^archive-(?P<key>.+)@nm.debian.org$")
- def __init__(self, infd):
- self.infd = infd
- # Parse the header only, leave the body in the input pipe
- self.msg = BytesHeaderParser().parse(self.infd)
- # History of lookup attempts
- self.lookup_attempts = []
- def log_lookup(self, msg):
- self.lookup_attempts.append(msg)
- self.msg.add_header("NM-Archive-Lookup-History", msg)
- def log_exception(self, exc):
- self.msg.add_header("NM-Archive-Lookup-History", "exception: {}: {}".format(exc.__class__.__name__, str(exc)))
- def deliver_to_mailbox(self, pathname):
- with umask_override(0o037) as uo:
- with open(pathname, "ab") as out:
- out.write(self.msg.as_string(True).encode("utf-8"))
- out.write(b"\n")
- shutil.copyfileobj(self.infd, out)
- def get_dest_key(self):
- """
- Lookup the archive-(?P<key>.+) destination key in the Delivered-To mail
- header, extract the key and return it.
- Returns None if no parsable Delivered-To header is found.
- """
- dests = self.msg.get_all("Delivered-To")
- if dests is None:
- self.log_lookup("No Delivered-To header found")
- return None
- for dest in dests:
- if dest == "archive@nm.debian.org":
- self.log_lookup("ignoring {} as destination".format(dest))
- continue
- mo = self.re_dest.match(dest)
- if mo is None:
- self.log_lookup("delivered-to '{}' does not match any known format".format(dest))
- continue
- return mo.group("key")
- self.log_lookup("No valid Delivered-To headers found")
- return None
- def lookup_mailbox_filename(self, key, sqlite=False):
- db, Q = open_db(sqlite)
- cur = db.cursor()
- query = """
- SELECT pr.archive_key
- FROM person p
- JOIN process pr ON pr.person_id = p.id
- WHERE pr.is_active
- """
- if '=' in key:
- # Lookup email
- email = key.replace("=", "@")
- self.log_lookup("lookup by email '%s'" % email)
- cur.execute(Q(query + "AND p.email=%s"), (email,))
- else:
- # Lookup uid
- self.log_lookup("lookup by uid '%s'" % key)
- cur.execute(Q(query + "AND p.uid=%s"), (key,))
- basename = None
- for i, in cur:
- basename = i
- if basename is None:
- return None
- else:
- return basename + ".mbox"
- def get_dest_pathname(msg, sqlite=False):
- """
- Return a couple (destdir, filename) with the default directory and mailbox
- file name where msg should be delivered
- """
- try:
- key = msg.get_dest_key()
- if key is None:
- # Key not found in the message
- return "/srv/nm.debian.org/mbox/", "archive-failsafe.mbox"
- elif key.isdigit():
- # New-style processes
- return "/srv/nm.debian.org/mbox/processes", "process-{}.mbox".format(key)
- else:
- # Old-style processes, need a DB lookup
- fname = msg.lookup_mailbox_filename(key, sqlite)
- if fname is None:
- msg.log_lookup("Key {} not found in the database".format(repr(key)))
- return "/srv/nm.debian.org/mbox/", "archive-failsafe.mbox"
- else:
- return "/srv/nm.debian.org/mbox/applicants", fname
- except Exception as e:
- msg.log_exception(e)
- return "/srv/nm.debian.org/mbox/", "archive-failsafe.mbox"
- def main():
- import argparse
- parser = argparse.ArgumentParser(description="Dispatch NM mails Cc-ed to the archive address")
- parser.add_argument("--version", action="version", version="%(prog)s " + VERSION)
- parser.add_argument("--dest", action="store", default=None, help="override destination directory (default: hardcoded depending on archive address type)")
- parser.add_argument("--dry-run", action="store_true", help="print destinations instead of delivering mails")
- parser.add_argument("--sqlite", action="store_true", help="use the SQLite database on the development deployment instead of the production PostgreSQL")
- args = parser.parse_args()
- msg = IncomingMessage(sys.stdin.buffer)
- destdir, filename = get_dest_pathname(msg, args.sqlite)
- # Override destdir if requested
- if args.dest: destdir = args.dest
- # Deliver
- pathname = os.path.join(destdir, filename)
- if args.dry_run:
- for warn in msg.msg.get_all("NM-Archive-Lookup-History", []):
- print(warn)
- print("Delivering to mailbox", pathname)
- else:
- msg.deliver_to_mailbox(pathname)
- if __name__ == "__main__":
- sys.exit(main())
|