archive-process-email 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. #!/usr/bin/python3
  2. """
  3. Dispatch an email to the right mailbox
  4. """
  5. from __future__ import print_function
  6. from __future__ import absolute_import
  7. from __future__ import division
  8. from __future__ import unicode_literals
  9. import sys
  10. import re
  11. import shutil
  12. import os
  13. import os.path
  14. from email.parser import BytesHeaderParser
  15. from email.utils import getaddresses
  16. # TODO: once nm.debian.org is python3, move most of this code to process/ and
  17. # make it unit-tested
  18. VERSION="0.2"
  19. class umask_override:
  20. """
  21. Context manager that temporarily overrides the umask during its lifetime
  22. """
  23. def __init__(self, umask):
  24. self.new_umask = umask
  25. self.old_umask = None
  26. def __enter__(self):
  27. # Set umask
  28. self.old_umask = os.umask(self.new_umask)
  29. return self
  30. def __exit__(self, exc_type, exc_val, exc_tb):
  31. # Restore umask
  32. os.umask(self.old_umask)
  33. return False
  34. def open_db(sqlite=False):
  35. """
  36. Connect to the NM database and return the db connection.
  37. Returns the db connection and a function used to process query strings
  38. before sending them to the database. It is a noop for postgresql and a
  39. replacement of "%s" with "?" on sqlite.
  40. That's what you get when some smart one decides to make a DB-independent
  41. db_api that supports only a DB-specific syntax for SQL query arguments.
  42. """
  43. if sqlite:
  44. import sqlite3
  45. db = sqlite3.connect("data/db-used-for-development.sqlite")
  46. return db, lambda s: s.replace("%s", "?").replace("true", "1")
  47. else:
  48. import psycopg2
  49. return psycopg2.connect("service=nm user=nm"), lambda x: x
  50. class IncomingMessage:
  51. re_dest = re.compile("^archive-(?P<key>.+)@nm.debian.org$")
  52. def __init__(self, infd):
  53. self.infd = infd
  54. # Parse the header only, leave the body in the input pipe
  55. self.msg = BytesHeaderParser().parse(self.infd)
  56. # History of lookup attempts
  57. self.lookup_attempts = []
  58. def log_lookup(self, msg):
  59. self.lookup_attempts.append(msg)
  60. self.msg.add_header("NM-Archive-Lookup-History", msg)
  61. def log_exception(self, exc):
  62. self.msg.add_header("NM-Archive-Lookup-History", "exception: {}: {}".format(exc.__class__.__name__, str(exc)))
  63. def deliver_to_mailbox(self, pathname):
  64. with umask_override(0o037) as uo:
  65. with open(pathname, "ab") as out:
  66. out.write(self.msg.as_string(True).encode("utf-8"))
  67. out.write(b"\n")
  68. shutil.copyfileobj(self.infd, out)
  69. def get_dest_key(self):
  70. """
  71. Lookup the archive-(?P<key>.+) destination key in the Delivered-To mail
  72. header, extract the key and return it.
  73. Returns None if no parsable Delivered-To header is found.
  74. """
  75. dests = self.msg.get_all("Delivered-To")
  76. if dests is None:
  77. self.log_lookup("No Delivered-To header found")
  78. return None
  79. for dest in dests:
  80. if dest == "archive@nm.debian.org":
  81. self.log_lookup("ignoring {} as destination".format(dest))
  82. continue
  83. mo = self.re_dest.match(dest)
  84. if mo is None:
  85. self.log_lookup("delivered-to '{}' does not match any known format".format(dest))
  86. continue
  87. return mo.group("key")
  88. self.log_lookup("No valid Delivered-To headers found")
  89. return None
  90. def lookup_mailbox_filename(self, key, sqlite=False):
  91. db, Q = open_db(sqlite)
  92. cur = db.cursor()
  93. query = """
  94. SELECT pr.archive_key
  95. FROM person p
  96. JOIN process pr ON pr.person_id = p.id
  97. WHERE pr.is_active
  98. """
  99. if '=' in key:
  100. # Lookup email
  101. email = key.replace("=", "@")
  102. self.log_lookup("lookup by email '%s'" % email)
  103. cur.execute(Q(query + "AND p.email=%s"), (email,))
  104. else:
  105. # Lookup uid
  106. self.log_lookup("lookup by uid '%s'" % key)
  107. cur.execute(Q(query + "AND p.uid=%s"), (key,))
  108. basename = None
  109. for i, in cur:
  110. basename = i
  111. if basename is None:
  112. return None
  113. else:
  114. return basename + ".mbox"
  115. def get_dest_pathname(msg, sqlite=False):
  116. """
  117. Return a couple (destdir, filename) with the default directory and mailbox
  118. file name where msg should be delivered
  119. """
  120. try:
  121. key = msg.get_dest_key()
  122. if key is None:
  123. # Key not found in the message
  124. return "/srv/nm.debian.org/mbox/", "archive-failsafe.mbox"
  125. elif key.isdigit():
  126. # New-style processes
  127. return "/srv/nm.debian.org/mbox/processes", "process-{}.mbox".format(key)
  128. else:
  129. # Old-style processes, need a DB lookup
  130. fname = msg.lookup_mailbox_filename(key, sqlite)
  131. if fname is None:
  132. msg.log_lookup("Key {} not found in the database".format(repr(key)))
  133. return "/srv/nm.debian.org/mbox/", "archive-failsafe.mbox"
  134. else:
  135. return "/srv/nm.debian.org/mbox/applicants", fname
  136. except Exception as e:
  137. msg.log_exception(e)
  138. return "/srv/nm.debian.org/mbox/", "archive-failsafe.mbox"
  139. def main():
  140. import argparse
  141. parser = argparse.ArgumentParser(description="Dispatch NM mails Cc-ed to the archive address")
  142. parser.add_argument("--version", action="version", version="%(prog)s " + VERSION)
  143. parser.add_argument("--dest", action="store", default=None, help="override destination directory (default: hardcoded depending on archive address type)")
  144. parser.add_argument("--dry-run", action="store_true", help="print destinations instead of delivering mails")
  145. parser.add_argument("--sqlite", action="store_true", help="use the SQLite database on the development deployment instead of the production PostgreSQL")
  146. args = parser.parse_args()
  147. msg = IncomingMessage(sys.stdin.buffer)
  148. destdir, filename = get_dest_pathname(msg, args.sqlite)
  149. # Override destdir if requested
  150. if args.dest: destdir = args.dest
  151. # Deliver
  152. pathname = os.path.join(destdir, filename)
  153. if args.dry_run:
  154. for warn in msg.msg.get_all("NM-Archive-Lookup-History", []):
  155. print(warn)
  156. print("Delivering to mailbox", pathname)
  157. else:
  158. msg.deliver_to_mailbox(pathname)
  159. if __name__ == "__main__":
  160. sys.exit(main())