view mailer.py @ 23:1670a33e3e6d

from markup
author Henry Thompson <ht@markup.co.uk>
date Sat, 29 May 2021 11:07:34 +0100
parents 2d7c91f89f6b
children 1cef0e5f5851 b1ec44d254c6
line wrap: on
line source

#!/usr/bin/python
'''Attempt at flexible mailout functionality
Usage: mailer.py [-n] [-s] [-C cc string] [-c COLSPEC[,COLSPEC]*] [-B bcc string] [-b COLSPEC[,COLSPEC]*] [-S col[,col]*] [-a COLSPEC[,COLSPEC]*] [-p COLPAT]* [-SA file[,file]*] [-R reply-to] COLSPEC[,COLSPEC]* subject {addr-file|-} body-file

Sends the body as a message from me with subject to destinations per
lines in the addr-file selected by COLSPECs (to:) or -c/-b COLSPECs (Cc:/Bcc:)

-n for dry run, prints to stdout
-c for Cc column(s)
-C for static Cc
-b for Bcc columns(s)
-B for static Bcc
-a for attachment file column(s)
-A for attachment file pattern column(s)
-SA for static attachment files
-u Use unicode for attachments
-s for substitute into body
-S for columns to substitute as such
-p for augmentation pattern for a column
-R for replyTo address

COLSPEC is of the form a[:n[:f[:g]]] selects from addr-file, which must be tsv
  a gives the column for an email address
  n (optional) gives column for a name
  f gives format for the name: FS, SF or S.F for
      forenames surname (fornames space separated)
      surname forenames (space separated)
      surname, forenames (space separated)
    default is FS
    _ will be replaced by space in surnames
  g gives column for gender (for pronouns), m or f
COLPAT takes the form i:template, where i selects an address column
and template is a string containing exactly 1 "%s", which is replaced with
the column value to give the string which will be used for COLSPEC
references to that column, e.g. 1:S%s@sms.ed.ac.uk
if column 1 contains bare student numbers

-s enables body substitution.  body may contain
  %(fi)s  first forename of column i
  %(si)s  surname
  %(fsi)s all forenames
  %(i)s   the undivided original and/or -S col value
  if there is a supplied gender
    %(pni)s 'he'/'she'
    %(pai)s 'him'/'her'
    %(pgi)s 'his/her'

All column indices are 1-origin, as for cut'''

import smtplib, sys, re, os.path, codecs
from email.mime.text import MIMEText

addrPat=re.compile("<([^>]*)>")

def usage(hint=None):
  if hint is None:
    print __doc__
    exit()
  else:
    print >>sys.stderr,"Trouble with your commandline at %s\n  %s"%(hint,
                                                                    __doc__)
    exit(1)

def parseCols(specs,where):
  return [Column(s,where) for s in specs.split(',')]

def parsePat(spec):
  (c,t)=spec.split(':')
  c=int(c)
  found=False
  for colTab in (ccCols,bccCols,toCols,attCols):
    if c in colTab:
      colTab[c].addTemplate(t)
      found=True
  if not found:
    print >>sys.stderr, "Warning, template supplied for column %s, but no use of the column found!"%c

def addrList(addrFields,cols,att=False):
  global someExpand
  if att and someExpand:
    # There were some file patterns
    return itertools.chain(*(c.fullAddr(addrFields,True) for c in cols.values()))
  else:
    return [c.fullAddr(addrFields) for c in cols.values()]

def addrLine(hdr,addrFields,cols):
  return "%s: %s"%(hdr,", ".join(addrList(addrFields,cols)))

def subDict(addrFields):
  res={}
  for c in names.values():
    c.subDo(addrFields,res)
  for c in subs.values():
    if c not in names:
      c.subDo(addrFields,res)
  return res

bccCols={}
ccCols={}
attCols={}
toCols={}
names={}
subs={}
CC=[]
BCC=[]
rawCols={}

class Column:
  _expand=False
  def __init__(self,spec,where):
    global names, subs
    parts=spec.split(':')
    if (len(parts)<1 or len(parts)>4):
      print >>sys.stderr, "col spec. must have 1--4 :-separated parts: %s"%parts
      usage('colspec')
    self.a=int(parts[0])
    if len(parts)>1:
      self.n=int(parts[1])
      if len(parts)>2:
        self.f=parts[2]
      else:
        self.f='FS'
      if len(parts)>3:
        self.g=int(parts[3])
      else:
        self.g=None
    else:
      self.n=None
    if self.a<=0:
      print >>sys.stderr, "addr column index %s not allowed -- 1-origin indexing"%self.a
      exit(2)
    if self.a in where:
      print >>sys.stderr, "duplicate column %s"%self.a
      exit(2)
    if self.n is not None:
      if self.n<=0:
        print >>sys.stderr, "name column index %s not allowed -- 1-origin indexing"%self.n
        exit(3)
      if self.n in where:
        print >>sys.stderr, "can't use column %s as both name and address"%self.n
        exit(3)
      if self.n in names:
        print >>sys.stderr, "attempt to redefine %s from \"%s\" to \"%s\""%(self.n,names[self.n],self)
        exit(3)
      if self.f not in ('FS','SF','S.F'):
        print >>sys.stderr, "name format %s not recognised"%self.f
        exit(4)
    where[self.a]=self
    if self.n is not None:
      if isinstance(self,RawColumn):
        subs[self.n]=self
      else:
        names[self.n]=self

  def __str__(self):
    if self.n is None:
      return str(self.a)
    else:
      return "%s:%s"%(self.a,self.n)

  def __repr__(self):
    return str(self)

  def addTemplate(self,template):
    try:
      print >>sys.stderr,"Attempt to overwrite existing template \"%s\" for %s with \"%s\""%(self.template,
                                                                                             self.a,
                                                                                             template)
    except AttributeError:
      self.template=template
    
  def name(self):
    return self.n

  def expAddr(self,fields):
    addr=fields[self.a-1]
    try:
      return self.template%addr
    except AttributeError:
      return addr

  def fullAddr(self,fields,att=False):
    global someExpand
    if self.n is None:
      res=self.expAddr(fields)
      if att and someExpand:
        if self._expand:
          return glob.iglob(res)
        else:
          return [res]
      else:
        return res
    else:
      return '"%s" <%s>'%(fields[self.n-1].replace('_',' '),self.expAddr(fields))    

  def subDo(self,addrFields,dict):
    f=addrFields[self.n-1]
    dict[str(self.n)]=f
    nparts=f.split(' ')
    if self.f=='FS':
      sur=nparts.pop()
    elif self.f=='SF':
      sur=nparts.pop(0)
    elif self.f=='S.F':
      sur=nparts.pop(0)[:-1]
    fores=nparts
    dict['fs%s'%self.n]=' '.join(fores)
    dict['f%s'%self.n]=fores[0]
    dict['s%s'%self.n]=sur.replace('_',' ')
    if self.g is not None:
      gg=addrFields[self.g-1]
      if gg=='m':
        dict['pn%s'%self.n]='he'
        dict['pa%s'%self.n]='him'
        dict['pg%s'%self.n]='his'
      elif gg=='f':
        dict['pn%s'%self.n]='she'
        dict['pa%s'%self.n]='her'
        dict['pg%s'%self.n]='her'
      else:
        print >>sys.stderr,"Warning, unrecognised gender in column %s: %s"%(self.n,gg)

  def setExpand(self):
    self._expand=True

class RawColumn(Column):
  '''Not for person names, just raw text'''
  
  def subDo(self,addrFields,dict):
    f=addrFields[self.n-1]
    dict[str(self.n)]=f

def doAtt(msg,att,codec):
  (mt,enc)=mimetypes.guess_type(att)
  (tp,subtp)=mt.split('/',2)
  if tp=='text':
    attf=codecs.open(att,'r',codec)
    atm=MIMEText(attf.read(),subtp,codec)
  elif tp=='application':
    from email.mime.application import MIMEApplication
    attf=open(att,'r')
    atm=MIMEApplication(attf.read(),subtp)
  else:
    print >>sys.stderr, "Help: Media type %s (for attachment %s) not supported"%(mt,att)
    exit(5)
  atm.add_header('Content-Disposition','attachment',
                 filename=os.path.basename(att))
  msg.attach(atm)

dryrun=False
replyTo=None

sys.argv.pop(0)
doSub=False

pats=[]
someExpand=False
codec='iso-8859-1'
staticAtts=[]
while sys.argv:
  if sys.argv[0]=='-n':
    dryrun=True
    sys.argv.pop(0)
  elif sys.argv[0]=='-c' and ccCols=={}:
    sys.argv.pop(0)
    if sys.argv:
      parseCols(sys.argv.pop(0),ccCols)
    else:
      usage('cc')
  elif sys.argv[0]=='-C' and CC==[]:
    sys.argv.pop(0)
    if sys.argv:
      CC=sys.argv.pop(0).split(',')
    else:
      usage('CC')
  elif sys.argv[0]=='-b' and bccCols=={}:
    sys.argv.pop(0)
    if sys.argv:
      parseCols(sys.argv.pop(0),bccCols)
    else:
      usage('bcc')
  elif sys.argv[0]=='-B' and BCC==[]:
    sys.argv.pop(0)
    if sys.argv:
      BCC=sys.argv.pop(0).split(',')
    else:
      usage('BCC')
  elif sys.argv[0] in ('-a','-A','-SA'): # and attCols=={}
    expand=sys.argv[0]=='-A'
    static=sys.argv[0]=='-SA'
    sys.argv.pop(0)
    if sys.argv:
      if static:
        staticAtts=sys.argv.pop(0).split(',')
      else:
        pc=parseCols(sys.argv.pop(0),attCols)
        if expand:
          import itertools, glob
          someExpand=True
          for c in pc:
            c.setExpand()
      from email.mime.multipart import MIMEMultipart
      import mimetypes
    else:
      usage('attachment')
  elif sys.argv[0]=='-u':
    sys.argv.pop(0)
    codec='utf-8'
  elif sys.argv[0]=='-s':
    sys.argv.pop(0)
    doSub=True
  elif sys.argv[0]=='-S' and rawCols=={}:
    sys.argv.pop(0)
    if sys.argv:
      for c in sys.argv.pop(0).split(','):
        RawColumn("%s:%s"%(c,c),rawCols)
    else:
      usage('raw subs')
  elif sys.argv[0]=='-p':
    sys.argv.pop(0)
    if sys.argv:
      pats.append(sys.argv.pop(0))
    else:
      usage('pat')
  elif sys.argv[0]=='-R':
    sys.argv.pop(0)
    if sys.argv:
      replyTo=sys.argv.pop(0)
    else:
      usage('reply to')
  elif sys.argv[0][0]=='-':
    print sys.argv
    usage()
  else:
    break

if sys.argv:
  parseCols(sys.argv.pop(0),toCols)
else:
  usage('to')

pats=[parsePat(p) for p in pats]

if sys.argv:
  subj=sys.argv.pop(0)
else:
  usage('subj')

if sys.argv:
  af=sys.argv.pop(0)
  if af=='-':
    addrFile=sys.stdin
  else:
    try:
      addrFile=open(af,'r')
    except:
      usage('addr: %s'%sys.exc_value)
else:
  usage('addr')

if sys.argv:
  bf=sys.argv.pop(0)
  try:
    bodyFile=open(bf,'r')
  except:
    usage('body: %s'%sys.exc_value)
else:
  usage('body')

try:
  sig=open("/home/ht/.signature","r")
  signature=sig.read().rstrip()
except:
  signature=None

CS=', '
body=bodyFile.read().rstrip()
if not dryrun:
  mailer=smtplib.SMTP()
  mailer.connect()
for l in addrFile:
  addrFields=l.rstrip().split('\t')
  if doSub:
    bodyPlus=body%subDict(addrFields)
  else:
    bodyPlus=body
  if signature is not None:
    bodyPlus+="\n--\n"
    bodyPlus+=signature
  if attCols or staticAtts:
    msg=MIMEMultipart()
    msg.attach(MIMEText(bodyPlus))
  else:
    msg=MIMEText(bodyPlus)
  #to=addrLine("To",addrFields,toCols)
  to=addrList(addrFields,toCols)
  #msg=to
  #recips=addrPat.findall(to)
  msg['To']=CS.join(to)
  recips=[]+list(to)
  cc=CC
  if ccCols:
    cc+=addrList(addrFields,ccCols)
  if cc!=[]:
    msg["Cc"]=CS.join(cc)
    recips+=list(cc)
  bcc=BCC
  if bccCols:
    bcc+=addrList(addrFields,bccCols)
  if bcc!=[]:
    msg["Bcc"]=CS.join(bcc)
    recips+=list(bcc)
  msg["Subject"]=subj
  for att in staticAtts:
    doAtt(msg,att,codec)
  if attCols:
    for att in addrList(addrFields,attCols,True):
      doAtt(msg,att,codec)
  if replyTo is not None:
    msg["Reply-To"]=replyTo
  if dryrun:
    print recips
    print msg.keys()
    print msg.as_string()
    exit()
  print "mailing to %s"%recips
  mailer.sendmail("ht@inf.ed.ac.uk",recips,msg.as_string())
mailer.quit()