sendMailToERP5 6.52 KB
Newer Older
Julien Muchembled's avatar
Julien Muchembled committed
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
#!/usr/bin/python
import os, subprocess, sys, textwrap, traceback, urllib, urlparse

# Example of configuration of postfix to deliver to ERP5:
# - Add the following lines to master.cf:
#   erp5      unix  -       n       n       -       -       pipe
#     flags=FR user=erp5 argv=/path_to/sendMailToERP5 --ingestion_map=...
#                                                     recipient=${recipient}
# - Tell smtpd service to use the new filter, by adding:
#   -o content_filter=erp5:

class HTTPError(IOError):

  def __init__(self, errcode, errmsg, result):
    self.__dict__.update(errcode=errcode, errmsg=errmsg, result=result)

  def __str__(self):
    return '%s %s' % (self.errcode, self.errmsg)


class urlopen(urllib.FancyURLopener, object):
  """Open a network object denoted by a URL for reading

  Raise a HTTPError exception if HTTP error code is not 200.
  """
  def __new__(cls, *args, **kw):
    self = object.__new__(cls)
    self.__init__()
    return self.open(*args, **kw)

  def http_error(self, url, fp, errcode, errmsg, headers, data=None):
    raise HTTPError(errcode, errmsg,
      self.http_error_default(url, fp, errcode, errmsg, headers))


class Message(object):

  def __init__(self, *args):
    for arg in args:
      k, v = arg.split('=', 1)
      if not k.islower():
        raise ValueError
      old = getattr(self, k, None)
      if old is not None:
        if type(old) is list:
          old.append(v)
          continue
        v = [old, v]
      setattr(self, k, v)
    recipient_list = self.__dict__.pop('recipient', [])
    if isinstance(recipient_list, basestring):
      recipient_list = [recipient_list]
    self.recipient_list = recipient_list

  def __call__(self, portal=None, **kw):
56 57 58 59 60
    # A filter should not deliver to more than one place, otherwise we can't
    # avoid duplicate (or lost) mails in case of failure.
    # So this method must not be modified to allow delivery to several
    # destinations. Additional deliveries (even if locally), must be done
    # by the ERP5 instance itself, by activity.
61 62 63
    if portal == 'UNAVAILABLE':
      print 'Message rejected'
      sys.exit(os.EX_UNAVAILABLE)
64 65 66 67
    if portal == 'SENDMAIL':
      print 'Deliver message locally ...'
      os.execl('/usr/sbin/sendmail', 'sendmail', '-G', '-i',
               *self.recipient_list)
Julien Muchembled's avatar
Julien Muchembled committed
68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92
    if portal is not None:
      scheme, netloc, path, query, fragment = urlparse.urlsplit(portal)
      if query or fragment:
        raise ValueError
      user, host = urllib.splituser(netloc)
      if user is None:
        password = None
      else:
        user, password = urllib.splitpasswd(user)
      user = kw.pop('user', user)
      if user is not None:
        password = kw.pop('password', password)
        if password is not None:
          user = '%s:%s' % (user, password)
        host = '%s@%s' % (user, host)
      url = urlparse.urlunsplit((scheme, host, path.rstrip('/'), '', '')) + \
        '/portal_contributions/newContent'
      kw['data'] = sys.stdin.read()
      try:
        result = urlopen(url, urllib.urlencode(kw))
      except HTTPError, e:
        if e.errcode >= 300:
          raise
        result = e.result
      result.read() # ERP5 does not return useful information
93 94 95
      print 'Message ingested'
    else:
      print 'Message dropped'
Julien Muchembled's avatar
Julien Muchembled committed
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132


class SimpleIngestionMap(object):
  """Simple implementation of ingestion map, using a Python file as database

  This class maps recipients to parameters for portal_contributions/newContent
  """
  def __init__(self, ingestion_map_filename):
    fd = file(ingestion_map_filename)
    g = {}
    try:
      exec fd in g
    finally:
      fd.close()
    self._map = g['ingestion_map']

  def __call__(self, message, **kw):
    for recipient in message.recipient_list:
      recipient = self._map.get(recipient)
      if recipient:
        kw.update(recipient)
        break
    return message(**kw)


def getOptionParser():
  from optparse import IndentedHelpFormatter, OptionGroup, OptionParser
  class Formatter(IndentedHelpFormatter):
    """Subclass IndentedHelpFormatter to preserve line breaks in description"""
    def format_description(self, description):
      return ''.join(IndentedHelpFormatter.format_description(self, x)
                     for x in description.split('\n'))
  parser = OptionParser(usage="%prog [options] [<key>=<value>]...",
                        formatter=Formatter(), description="""Positional \
arguments defines variables that are used by ingestion maps to determine \
options to send to ERP5. Currently, only 'recipient' key is used.
This tool can be used directly to deliver mails from postfix to ERP5, \
133
by using it as a filter (cf documentation of /etc/postfix/master.cf).""")
Julien Muchembled's avatar
Julien Muchembled committed
134
  _ = parser.add_option
135 136 137
  _("--portal", help="URL of ERP5 instance to connect to, or one of the"
                     " following special values: 'UNAVAILABLE' returns the mail"
                     " to the sender; 'SENDMAIL' injects it back into MTA")
Julien Muchembled's avatar
Julien Muchembled committed
138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185
  _("--user", help="use this user to connect to ERP5")
  _("--password", help="use this password to connect to ERP5")
  _("--file_name", help="ERP5 requires a file name to guess content type")
  _("--container_path", help="define where to contribute the content"
                             " (by default, it is guessed by ERP5)")
  #_("--portal_type", default="Mail Message")
  group = OptionGroup(parser, "Ingestion map", """Above options can be \
overridden according to recipients, using a Python module as ingestion map \
database. The module must define an 'ingestion_map' variable implementing \
'get(recipient) -> option_dict'. Example:
  ingestion_map = {
    'foo@bar.com': dict(user='foo', password='12345'),
    'patches@prj1.org': dict(file_name='prj1.patch'),
    'spam@example.invalid': dict(portal=None), # drop
  }""")
  group.add_option("--ingestion_map", help="get options from this file,"
                                           " according to recipients")
  parser.add_option_group(group)
  _ = group.add_option
  parser.set_defaults(file_name="unnamed.eml")
  return parser


def main():
  parser = getOptionParser()
  options, args = parser.parse_args()
  message = Message(*args)
  default = {}
  for option in parser.option_list:
    dest = option.dest
    if dest not in (None, 'ingestion_map'):
      value = getattr(options, dest)
      if value is not None:
        default[dest] = value
  if options.ingestion_map:
    SimpleIngestionMap(options.ingestion_map)(message, **default)
  else:
    message(**default)


if __name__ == '__main__':
  try:
    main()
  except SystemExit:
    raise
  except:
    traceback.print_exc()
    sys.exit(os.EX_TEMPFAIL)