# $Log: mime.py,v $
# Revision 1.51  2004/03/25 03:19:10  stuart
# Correctly defang rfc822 attachments when boundary specified with
# content-type message/rfc822.
#
# Revision 1.50  2003/10/15 22:01:00  stuart
# Test for and work around email bug with encoded filenames.
#
# Revision 1.49  2003/09/04 18:48:13  stuart
# Support python-2.2.3
#
# Revision 1.48  2003/09/02 00:27:27  stuart
# Should have full milter based dspam support working
#
# Revision 1.47  2003/08/26 06:08:18  stuart
# Use new python boolean since we now require 2.2.2
#
# Revision 1.46  2003/08/26 05:01:38  stuart
# Release 0.6.0
#
# Revision 1.45  2003/08/26 04:01:24  stuart
# Use new email module for parsing mail.  Still need mime module to
# provide various bug fixes to email module, and maintain some compatibility
# with old milter code.
#

# This module provides a "defang" function to replace naughty attachments
# with a warning message.

# Author: Stuart D. Gathman <stuart@bmsi.com>
# Copyright 2001 Business Management Systems, Inc.
# This code is under GPL.  See COPYING for details.

import StringIO
import socket
import Milter
import email
import email.Message
from email.Message import Message
from email.Generator import Generator
from email.Utils import quote
from email import Utils

from types import ListType,StringType

# Enhance email.Parser
# - Fix _parsebody to decode message attachments before parsing

from email.Parser import Parser
try: from email.Parser import NLCRE
except: from email.Parser import nlcre as NLCRE

from email import Errors

class MimeParser(Parser):

    # This is a copy of _parsebody from email.Parser, with a fix
    # for message attachments.  I couldn't find a smaller way to patch it
    # in a subclass.

    def _parsebody(self, container, fp, firstbodyline=None):
        # Parse the body, but first split the payload on the content-type
        # boundary if present.
        boundary = container.get_boundary()
        isdigest = (container.get_content_type() == 'multipart/digest')
        # If there's a boundary, split the payload text into its constituent
        # parts and parse each separately.  Otherwise, just parse the rest of
        # the body as a single message.  Note: any exceptions raised in the
        # recursive parse need to have their line numbers coerced.
        if boundary:
            preamble = epilogue = None
            # Split into subparts.  The first boundary we're looking for won't
            # always have a leading newline since we're at the start of the
            # body text, and there's not always a preamble before the first
            # boundary.
            separator = '--' + boundary
            payload = fp.read()
            if firstbodyline is not None:
                payload = firstbodyline + '\n' + payload
            # We use an RE here because boundaries can have trailing
            # whitespace.
            mo = re.search(
                r'(?P<sep>' + re.escape(separator) + r')(?P<ws>[ \t]*)',
                payload)
            if not mo:
                if self._strict:
                    raise Errors.BoundaryError(
                        "Couldn't find starting boundary: %s" % boundary)
                container.set_payload(payload)
                return
            start = mo.start()
            if start > 0:
                # there's some pre-MIME boundary preamble
                preamble = payload[0:start]
            # Find out what kind of line endings we're using
            start += len(mo.group('sep')) + len(mo.group('ws'))
            mo = NLCRE.search(payload, start)
            if mo:
                start += len(mo.group(0))
            # We create a compiled regexp first because we need to be able to
            # specify the start position, and the module function doesn't
            # support this signature. :(
            cre = re.compile('(?P<sep>\r\n|\r|\n)' +
                             re.escape(separator) + '--')
            mo = cre.search(payload, start)
            if mo:
                terminator = mo.start()
                linesep = mo.group('sep')
                if mo.end() < len(payload):
                    # There's some post-MIME boundary epilogue
                    epilogue = payload[mo.end():]
            elif self._strict:
                raise Errors.BoundaryError(
                        "Couldn't find terminating boundary: %s" % boundary)
            else:
                # Handle the case of no trailing boundary.  Check that it ends
                # in a blank line.  Some cases (spamspamspam) don't even have
                # that!
                mo = re.search('(?P<sep>\r\n|\r|\n){2}$', payload)
                if not mo:
                    mo = re.search('(?P<sep>\r\n|\r|\n)$', payload)
                    if not mo:
                        raise Errors.BoundaryError(
                          'No terminating boundary and no trailing empty line')
                linesep = mo.group('sep')
                terminator = len(payload)
            # We split the textual payload on the boundary separator, which
            # includes the trailing newline. If the container is a
            # multipart/digest then the subparts are by default message/rfc822
            # instead of text/plain.  In that case, they'll have a optional
            # block of MIME headers, then an empty line followed by the
            # message headers.
            parts = re.split(
                linesep + re.escape(separator) + r'[ \t]*' + linesep,
                payload[start:terminator])
            for part in parts:
                if isdigest:
                    if part.startswith(linesep):
                        # There's no header block so create an empty message
                        # object as the container, and lop off the newline so
                        # we can parse the sub-subobject
                        msgobj = self._class()
                        part = part[len(linesep):]
                    else:
                        parthdrs, part = part.split(linesep+linesep, 1)
                        # msgobj in this case is the "message/rfc822" container
                        msgobj = self.parsestr(parthdrs, headersonly=1)
                    # while submsgobj is the message itself
                    msgobj.set_default_type('message/rfc822')
                    maintype = msgobj.get_content_maintype()
                    if maintype in ('message', 'multipart'):
                        submsgobj = self.parsestr(part)
                        msgobj.attach(submsgobj)
                    else:
                        msgobj.set_payload(part)
                else:
                    msgobj = self.parsestr(part)
                container.preamble = preamble
                container.epilogue = epilogue
                container.attach(msgobj)
        elif container.get_main_type() == 'multipart':
            # Very bad.  A message is a multipart with no boundary!
            raise Errors.BoundaryError(
                'multipart message with no defined boundary')
        elif container.get_type() == 'message/delivery-status':
            # This special kind of type contains blocks of headers separated
            # by a blank line.  We'll represent each header block as a
            # separate Message object
            blocks = []
            while True:
                blockmsg = self._class()
                self._parseheaders(blockmsg, fp)
                if not len(blockmsg):
                    # No more header blocks left
                    break
                blocks.append(blockmsg)
            container.set_payload(blocks)
        elif container.get_main_type() == 'message':
            # Create a container for the payload, but watch out for there not
            # being any headers left
            container.set_payload(fp.read())
	    fp = StringIO.StringIO(container.get_payload(decode=True))
            try:
                msg = self.parse(fp)
            except Errors.HeaderParseError:
                msg = self._class()
                self._parsebody(msg, fp)
            container.set_payload([msg])
        else:
            text = fp.read()
            if firstbodyline is not None:
                text = firstbodyline + '\n' + text
            container.set_payload(text)

def unquote(str):
    """Remove quotes from a string."""
    if len(str) > 1:
        if str.startswith('"'):
	  if str.endswith('"'):
            str = str[1:-1]
	  else: # remove garbage after trailing quote
	    try: str = str[1:str[1:].index('"')+1]
	    except: return str
	  return str.replace('\\\\', '\\').replace('\\"', '"')
        if str.startswith('<') and str.endswith('>'):
            return str[1:-1]
    return str

from types import TupleType

def _unquotevalue(value):
  if isinstance(value, TupleType):
      return value[0], value[1], unquote(value[2])
  else:
      return unquote(value)

email.Message._unquotevalue = _unquotevalue

def _parseparam(str):
    plist = []
    while str[:1] == ';':
	str = str[1:]
	end = str.find(';')
	while end > 0 and (str.count('"',0,end) & 1):
	  end = str.find(';',end + 1)
	if end < 0: end = len(str)
	f = str[:end]
	if '=' in f:
	    i = f.index('=')
	    f = f[:i].strip().lower() + \
		    '=' + f[i+1:].strip()
	plist.append(f.strip())
	str = str[end:]
    return plist

# Enhance email.Message 
# - Fix getparam to parse attributes IE style
# - Provide a headerchange event for integration with Milter
#   Headerchange attribute can be assigned a function to be called when
#   changing headers.  The signature is:
#	headerchange(msg,name,value) -> None
# - Track modifications to headers of body or any part independently

class MimeMessage(Message):
  """Version of email.Message.Message compatible with old mime module
  """
  def __init__(self,fp=None,seekable=1):
    self.headerchange = None
    self.submsg = None
    Message.__init__(self)
    self.fp = fp
    if fp:
      parser = MimeParser(MimeMessage)
      self.startofheaders = fp.tell()
      parser._parseheaders(self,fp)
      self.startofbody = fp.tell()
      parser._parsebody(self,fp)
    for part in self.walk():
      part.modified = False

  def rewindbody(self):
    return self.fp.seek(self.startofbody)

  # override param parsing to handle quotes
  def _get_params_preserve(self,failobj=None,header='content-type'):
    "Return all parameter names and values. Use parser that handles quotes."
    missing = []
    value = self.get(header, missing)
    if value is missing:
	return failobj
    params = []
    for p in _parseparam(';' + value):
	  try:
	      name, val = p.split('=', 1)
	      name = name.strip()
	      val = val.strip()
	  except ValueError:
	      # Must have been a bare attribute
	      name = p.strip()
	      val = ''
	  params.append((name, val))
    params = Utils.decode_params(params)
    return params

  def get_filename(self, failobj=None):
      """Return the filename associated with the payload if present.

      The filename is extracted from the Content-Disposition header's
      `filename' parameter, and it is unquoted.
      """
      missing = []
      filename = self.get_param('filename', missing, 'content-disposition')
      if filename is missing:
	  return failobj
      if isinstance(filename, TupleType):
	  # It's an RFC 2231 encoded parameter
	  newvalue = _unquotevalue(filename)
	  if newvalue[0]:
	    return unicode(newvalue[2], newvalue[0])
	  return unicode(newvalue[2])
      else:
	  newvalue = _unquotevalue(filename.strip())
	  return newvalue

  getfilename = get_filename
  ismultipart = Message.is_multipart
  getheaders = Message.get_all
  gettype = Message.get_content_type
  getparam = Message.get_param

  def getparams(self): return self.get_params([])

  def getname(self):
    return self.get_param('name')

  def getnames(self):
    """Return a list of (attr,name) pairs of attributes that IE might
       interpret as a name - and hence decide to execute this message."""
    names = []
    for attr,val in self.get_params([]):
      if isinstance(val, TupleType):
	  # It's an RFC 2231 encoded parameter
	  newvalue = _unquotevalue(val)
	  if val[0]:
	    val =  unicode(newvalue[2], newvalue[0])
	  else:
	    val = unicode(newvalue[2])
      else:
	  val = _unquotevalue(val.strip())
      names.append((attr,val))
    return names + [("filename",self.get_filename())]

  def ismodified(self):
    "True if this message or a subpart has been modified."
    if not self.is_multipart():
      if self.submsg:
        return self.submsg.ismodified()
      return self.modified
    if self.modified: return True
    for i in self.get_payload():
      if i.ismodified(): return True
    return False

  def dump(self,file,unixfrom=False):
    "Write this message (and all subparts) to a file"
    g = Generator(file)
    g.flatten(self,unixfrom=unixfrom)

  def getencoding(self):
    return self.get('content-transfer-encoding',None)

  # Decode body to stream according to transfer encoding, return encoding name
  def decode(self,filter):
    try:
      filter.write(self.get_payload(decode=True))
    except:
      pass
    return self.getencoding()

  def get_payload_decoded(self):
    return self.get_payload(decode=True)

  def __setitem__(self, name, value):
    rc = Message.__setitem__(self,name,value)
    self.modified = True
    if self.headerchange: self.headerchange(self,name,value)
    return rc

  def __delitem__(self, name):
    if self.headerchange: self.headerchange(self,name,None)
    rc = Message.__delitem__(self,name)
    self.modified = True
    return rc

  def get_payload(self,i=None,decode=False):
    msg = self.submsg
    if msg and msg.ismodified():
      self.set_payload([msg])
    return Message.get_payload(self,i,decode)

  def set_payload(self, val, charset=None):
    self.modified = True
    try:
      val.seek(0)
      val = val.read()
    except: pass
    Message.set_payload(self,val,charset)
    self.submsg = None

  def get_submsg(self):
    if self.get_content_type().lower() == 'message/rfc822':
      if not self.submsg:
        txt = self.get_payload()
	if type(txt) == str:
	  txt = self.get_payload(decode=True)
	  parser = MimeParser(MimeMessage)
	  self.submsg = parser.parsestr(txt)
	else:
	  self.submsg = txt[0]
      return self.submsg
    return None

extlist = ''.join("""
ade,adp,asd,asx,asp,bas,bat,chm,cmd,com,cpl,crt,dll,exe,hlp,hta,inf,ins,isp,js,
jse,lnk,mdb,mde,msc,msi,msp,mst,ocx,pcd,pif,reg,scr,sct,shs,url,vb,vbe,vbs,wsc,
wsf,wsh 
""".split())
bad_extensions = map(lambda x:'.' + x,extlist.split(','))

def check_ext(name):
  "Check a name for dangerous Winblows extensions."
  if not name: return name
  lname = name.lower()
  for ext in bad_extensions:
    if lname.endswith(ext): return name
  return None

virus_msg = """This message appeared to contain a virus.
It was originally named '%s', and has been removed.
A copy of your original message was saved as '%s:%s'.
See your administrator.
"""

def check_name(msg,savname=None,ckname=check_ext):
  "Replace attachment with a warning if its name is suspicious."
  for (key,name) in msg.getnames():
    badname = ckname(name)
    if badname:
      hostname = socket.gethostname()
      msg.set_payload(virus_msg % (badname,hostname,savname))
      del msg["content-type"]
      del msg["content-disposition"]
      del msg["content-transfer-encoding"]
      name = "WARNING.TXT"
      msg["Content-Type"] = "text/plain; name="+name
      break
  return Milter.CONTINUE

import email.Iterators

def check_attachments(msg,check):
  """Scan attachments.
msg	MimeMessage
check	function(MimeMessage): int
	Return CONTINUE, REJECT, ACCEPT
  """
  if msg.ismultipart() and not msg.get_content_type() == 'message/rfc822':
    for i in msg.get_payload():
      rc = check_attachments(i,check)
      if rc != Milter.CONTINUE: return rc
    return Milter.CONTINUE
  return check(msg)

# save call context for Python without nested_scopes
class _defang:
  def __init__(self,savname,check):
    self._savname = savname
    self._check = check
    self.scan_rfc822 = True
    self.scan_html = True
  def _chk_name(self,msg):
    rc = check_name(msg,self._savname,self._check)
    if self.scan_html:
      check_html(msg,self._savname)	# remove scripts from HTML
    if self.scan_rfc822:
      msg = msg.get_submsg()
      if msg: return check_attachments(msg,self._chk_name)
    return rc

# emulate old defang function
def defang(msg,savname=None,check=check_ext):
  """Compatible entry point.
Replace all attachments with dangerous names."""
  check_attachments(msg,_defang(savname,check)._chk_name)
  if msg.ismodified():
    return 1;
  return 0

import sgmllib

import re
declname = re.compile(r'[a-zA-Z][-_.a-zA-Z0-9]*\s*')
declstringlit = re.compile(r'(\'[^\']*\'|"[^"]*")\s*')

class SGMLFilter(sgmllib.SGMLParser):
  """Parse HTML and pass through all constructs unchanged.  It is intended for
     derived classes to implement exceptional processing for selected cases.
  """
  def __init__(self,out):
    sgmllib.SGMLParser.__init__(self)
    self.out = out

  def handle_comment(self,comment):
    self.out.write("<!--%s-->" % comment)

  def unknown_starttag(self,tag,attr):
    if hasattr(self,"get_starttag_text"):
      self.out.write(self.get_starttag_text())
    else:
      self.out.write("<%s" % tag)
      for (key,val) in attr:
        self.out.write(' %s="%s"' % (key,val))
      self.out.write('>')

  def handle_data(self,data):
    self.out.write(data)

  def handle_entityref(self,ref):
    self.out.write("&%s;" % ref)

  def handle_charref(self,ref):
    self.out.write("&#%s;" % ref)
      
  def unknown_endtag(self,tag):
    self.out.write("</%s>" % tag)

  def handle_special(self,data):
    self.out.write("<!%s>" % data)

  def write(self,buf):
    "Act like a writer.  Why doesn't SGMLParser do this by default?"
    self.feed(buf)

  # Python-2.1 sgmllib rejects illegal declarations.  Since various Microsoft
  # products accept and output them, we need to pass them through -
  # at least until we discover that MS will execute them.
  # sgmlop-1.1 will not use this method, but calls handle_special to
  # do what we want.
  def parse_declaration(self, i):
      rawdata = self.rawdata
      n = len(rawdata)
      j = i + 2
      while j < n:
	  c = rawdata[j]
	  if c == ">":
	      # end of declaration syntax
	      self.handle_special(rawdata[i+2:j])
	      return j + 1
	  if c in "\"'":
	      m = declstringlit.match(rawdata, j)
	      if not m:
		  # incomplete or an error?
		  return -1
	      j = m.end()
	  elif c in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ":
	      m = declname.match(rawdata, j)
	      if not m:
		  # incomplete or an error?
		  return -1
	      j = m.end()
	  else:
	      j += 1
      # end of buffer between tokens
      return -1

class HTMLScriptFilter(SGMLFilter):
  "Remove scripts from an HTML document."
  def __init__(self,out):
    SGMLFilter.__init__(self,out)
    self.ignoring = 0
    self.modified = False
    self.msg = "<!-- WARNING: embedded script removed -->"
  def start_script(self,unused):
    self.ignoring += 1
    self.modified = True
    self.out.write(self.msg)
  def end_script(self):
    self.ignoring -= 1
  def handle_data(self,data):
    if not self.ignoring: SGMLFilter.handle_data(self,data)
  def handle_comment(self,comment):
    if not self.ignoring: SGMLFilter.handle_comment(self,comment)


def check_html(msg,savname=None):
  "Remove scripts from HTML attachments."
  msgtype = msg.get_content_type().lower()
  # check for more MSIE braindamage
  if msgtype == 'application/octet-stream':
    for (attr,name) in msg.getnames():
      if name and name.lower().endswith(".htm"):
	msgtype = 'text/html'
  if msgtype == 'text/html':
    out = StringIO.StringIO()
    filter = HTMLScriptFilter(out)
    try:
      filter.write(msg.get_payload(decode=True))
      filter.close()
    #except sgmllib.SGMLParseError:
    except:
      #mimetools.copyliteral(msg.get_payload(),open('debug.out','w')
      filter.close()
      hostname = socket.gethostname()
      msg.set_payload(
  "An HTML attachment could not be parsed.  The original is saved as '%s:%s'"
      % (hostname,savname))
      del msg["content-type"]
      del msg["content-disposition"]
      del msg["content-transfer-encoding"]
      name = "WARNING.TXT"
      msg["Content-Type"] = "text/plain; name="+name
      return Milter.CONTINUE
    if filter.modified:
      msg.set_payload(out)	# remove embedded scripts
      del msg["content-transfer-encoding"]
      email.Encoders.encode_quopri(msg)
  return Milter.CONTINUE
