403 lines
10 KiB
Python
403 lines
10 KiB
Python
# -*- test-case-name: twisted.mail.test.test_mailmail -*-
|
|
# Copyright (c) Twisted Matrix Laboratories.
|
|
# See LICENSE for details.
|
|
|
|
"""
|
|
Implementation module for the I{mailmail} command.
|
|
"""
|
|
|
|
from __future__ import print_function
|
|
|
|
import email.utils
|
|
import os
|
|
import sys
|
|
import getpass
|
|
try:
|
|
# Python 3
|
|
from configparser import ConfigParser
|
|
except ImportError:
|
|
# Python 2
|
|
from ConfigParser import ConfigParser
|
|
|
|
from twisted.copyright import version
|
|
from twisted.internet import reactor
|
|
from twisted.logger import Logger, textFileLogObserver
|
|
from twisted.mail import smtp
|
|
from twisted.python.compat import NativeStringIO
|
|
|
|
GLOBAL_CFG = "/etc/mailmail"
|
|
LOCAL_CFG = os.path.expanduser("~/.twisted/mailmail")
|
|
SMARTHOST = '127.0.0.1'
|
|
|
|
ERROR_FMT = """\
|
|
Subject: Failed Message Delivery
|
|
|
|
Message delivery failed. The following occurred:
|
|
|
|
%s
|
|
--
|
|
The Twisted sendmail application.
|
|
"""
|
|
|
|
_logObserver = textFileLogObserver(sys.stderr)
|
|
_log = Logger(observer=_logObserver)
|
|
|
|
|
|
|
|
class Options:
|
|
"""
|
|
Store the values of the parsed command-line options to the I{mailmail}
|
|
script.
|
|
|
|
@type to: L{list} of L{str}
|
|
@ivar to: The addresses to which to deliver this message.
|
|
|
|
@type sender: L{str}
|
|
@ivar sender: The address from which this message is being sent.
|
|
|
|
@type body: C{file}
|
|
@ivar body: The object from which the message is to be read.
|
|
"""
|
|
|
|
|
|
|
|
def getlogin():
|
|
try:
|
|
return os.getlogin()
|
|
except:
|
|
return getpass.getuser()
|
|
|
|
|
|
_unsupportedOption = SystemExit("Unsupported option.")
|
|
|
|
|
|
|
|
def parseOptions(argv):
|
|
o = Options()
|
|
o.to = [e for e in argv if not e.startswith('-')]
|
|
o.sender = getlogin()
|
|
|
|
# Just be very stupid
|
|
|
|
# Skip -bm -- it is the default
|
|
|
|
# Add a non-standard option for querying the version of this tool.
|
|
if '--version' in argv:
|
|
print('mailmail version:', version)
|
|
raise SystemExit()
|
|
|
|
# -bp lists queue information. Screw that.
|
|
if '-bp' in argv:
|
|
raise _unsupportedOption
|
|
|
|
# -bs makes sendmail use stdin/stdout as its transport. Screw that.
|
|
if '-bs' in argv:
|
|
raise _unsupportedOption
|
|
|
|
# -F sets who the mail is from, but is overridable by the From header
|
|
if '-F' in argv:
|
|
o.sender = argv[argv.index('-F') + 1]
|
|
o.to.remove(o.sender)
|
|
|
|
# -i and -oi makes us ignore lone "."
|
|
if ('-i' in argv) or ('-oi' in argv):
|
|
raise _unsupportedOption
|
|
|
|
# -odb is background delivery
|
|
if '-odb' in argv:
|
|
o.background = True
|
|
else:
|
|
o.background = False
|
|
|
|
# -odf is foreground delivery
|
|
if '-odf' in argv:
|
|
o.background = False
|
|
else:
|
|
o.background = True
|
|
|
|
# -oem and -em cause errors to be mailed back to the sender.
|
|
# It is also the default.
|
|
|
|
# -oep and -ep cause errors to be printed to stderr
|
|
if ('-oep' in argv) or ('-ep' in argv):
|
|
o.printErrors = True
|
|
else:
|
|
o.printErrors = False
|
|
|
|
# -om causes a copy of the message to be sent to the sender if the sender
|
|
# appears in an alias expansion. We do not support aliases.
|
|
if '-om' in argv:
|
|
raise _unsupportedOption
|
|
|
|
# -t causes us to pick the recipients of the message from
|
|
# the To, Cc, and Bcc headers, and to remove the Bcc header
|
|
# if present.
|
|
if '-t' in argv:
|
|
o.recipientsFromHeaders = True
|
|
o.excludeAddresses = o.to
|
|
o.to = []
|
|
else:
|
|
o.recipientsFromHeaders = False
|
|
o.exludeAddresses = []
|
|
|
|
requiredHeaders = {
|
|
'from': [],
|
|
'to': [],
|
|
'cc': [],
|
|
'bcc': [],
|
|
'date': [],
|
|
}
|
|
|
|
buffer = NativeStringIO()
|
|
while 1:
|
|
write = 1
|
|
line = sys.stdin.readline()
|
|
if not line.strip():
|
|
break
|
|
|
|
hdrs = line.split(': ', 1)
|
|
|
|
hdr = hdrs[0].lower()
|
|
if o.recipientsFromHeaders and hdr in ('to', 'cc', 'bcc'):
|
|
o.to.extend([
|
|
email.utils.parseaddr(hdrs[1])[1]
|
|
])
|
|
if hdr == 'bcc':
|
|
write = 0
|
|
elif hdr == 'from':
|
|
o.sender = email.utils.parseaddr(hdrs[1])[1]
|
|
|
|
if hdr in requiredHeaders:
|
|
requiredHeaders[hdr].append(hdrs[1])
|
|
|
|
if write:
|
|
buffer.write(line)
|
|
|
|
if not requiredHeaders['from']:
|
|
buffer.write('From: {}\r\n'.format(o.sender))
|
|
if not requiredHeaders['to']:
|
|
if not o.to:
|
|
raise SystemExit("No recipients specified.")
|
|
buffer.write('To: {}\r\n'.format(', '.join(o.to)))
|
|
if not requiredHeaders['date']:
|
|
buffer.write('Date: {}\r\n'.format(smtp.rfc822date()))
|
|
|
|
buffer.write(line)
|
|
|
|
if o.recipientsFromHeaders:
|
|
for a in o.excludeAddresses:
|
|
try:
|
|
o.to.remove(a)
|
|
except:
|
|
pass
|
|
|
|
buffer.seek(0, 0)
|
|
o.body = NativeStringIO(buffer.getvalue() + sys.stdin.read())
|
|
return o
|
|
|
|
|
|
|
|
class Configuration:
|
|
"""
|
|
|
|
@ivar allowUIDs: A list of UIDs which are allowed to send mail.
|
|
@ivar allowGIDs: A list of GIDs which are allowed to send mail.
|
|
@ivar denyUIDs: A list of UIDs which are not allowed to send mail.
|
|
@ivar denyGIDs: A list of GIDs which are not allowed to send mail.
|
|
|
|
@type defaultAccess: L{bool}
|
|
@ivar defaultAccess: L{True} if access will be allowed when no other access
|
|
control rule matches or L{False} if it will be denied in that case.
|
|
|
|
@ivar useraccess: Either C{'allow'} to check C{allowUID} first
|
|
or C{'deny'} to check C{denyUID} first.
|
|
|
|
@ivar groupaccess: Either C{'allow'} to check C{allowGID} first or
|
|
C{'deny'} to check C{denyGID} first.
|
|
|
|
@ivar identities: A L{dict} mapping hostnames to credentials to use when
|
|
sending mail to that host.
|
|
|
|
@ivar smarthost: L{None} or a hostname through which all outgoing mail will
|
|
be sent.
|
|
|
|
@ivar domain: L{None} or the hostname with which to identify ourselves when
|
|
connecting to an MTA.
|
|
"""
|
|
def __init__(self):
|
|
self.allowUIDs = []
|
|
self.denyUIDs = []
|
|
self.allowGIDs = []
|
|
self.denyGIDs = []
|
|
self.useraccess = 'deny'
|
|
self.groupaccess = 'deny'
|
|
|
|
self.identities = {}
|
|
self.smarthost = None
|
|
self.domain = None
|
|
|
|
self.defaultAccess = True
|
|
|
|
|
|
|
|
def loadConfig(path):
|
|
# [useraccess]
|
|
# allow=uid1,uid2,...
|
|
# deny=uid1,uid2,...
|
|
# order=allow,deny
|
|
# [groupaccess]
|
|
# allow=gid1,gid2,...
|
|
# deny=gid1,gid2,...
|
|
# order=deny,allow
|
|
# [identity]
|
|
# host1=username:password
|
|
# host2=username:password
|
|
# [addresses]
|
|
# smarthost=a.b.c.d
|
|
# default_domain=x.y.z
|
|
|
|
c = Configuration()
|
|
|
|
if not os.access(path, os.R_OK):
|
|
return c
|
|
|
|
p = ConfigParser()
|
|
p.read(path)
|
|
|
|
au = c.allowUIDs
|
|
du = c.denyUIDs
|
|
ag = c.allowGIDs
|
|
dg = c.denyGIDs
|
|
for (section, a, d) in (('useraccess', au, du), ('groupaccess', ag, dg)):
|
|
if p.has_section(section):
|
|
for (mode, L) in (('allow', a), ('deny', d)):
|
|
if p.has_option(section, mode) and p.get(section, mode):
|
|
for sectionID in p.get(section, mode).split(','):
|
|
try:
|
|
sectionID = int(sectionID)
|
|
except ValueError:
|
|
_log.error(
|
|
"Illegal {prefix}ID in "
|
|
"[{section}] section: {sectionID}",
|
|
prefix=section[0].upper(),
|
|
section=section, sectionID=sectionID)
|
|
else:
|
|
L.append(sectionID)
|
|
order = p.get(section, 'order')
|
|
order = [s.split()
|
|
for s in [s.lower()
|
|
for s in order.split(',')]]
|
|
if order[0] == 'allow':
|
|
setattr(c, section, 'allow')
|
|
else:
|
|
setattr(c, section, 'deny')
|
|
|
|
if p.has_section('identity'):
|
|
for (host, up) in p.items('identity'):
|
|
parts = up.split(':', 1)
|
|
if len(parts) != 2:
|
|
_log.error("Illegal entry in [identity] section: {section}",
|
|
section=up)
|
|
continue
|
|
c.identities[host] = parts
|
|
|
|
if p.has_section('addresses'):
|
|
if p.has_option('addresses', 'smarthost'):
|
|
c.smarthost = p.get('addresses', 'smarthost')
|
|
if p.has_option('addresses', 'default_domain'):
|
|
c.domain = p.get('addresses', 'default_domain')
|
|
|
|
return c
|
|
|
|
|
|
|
|
def success(result):
|
|
reactor.stop()
|
|
|
|
|
|
|
|
failed = None
|
|
def failure(f):
|
|
global failed
|
|
reactor.stop()
|
|
failed = f
|
|
|
|
|
|
|
|
def sendmail(host, options, ident):
|
|
d = smtp.sendmail(host, options.sender, options.to, options.body)
|
|
d.addCallbacks(success, failure)
|
|
reactor.run()
|
|
|
|
|
|
|
|
def senderror(failure, options):
|
|
recipient = [options.sender]
|
|
sender = '"Internally Generated Message ({})"<postmaster@{}>'.format(
|
|
sys.argv[0], smtp.DNSNAME.decode("ascii"))
|
|
error = NativeStringIO()
|
|
failure.printTraceback(file=error)
|
|
body = NativeStringIO(ERROR_FMT % error.getvalue())
|
|
d = smtp.sendmail('localhost', sender, recipient, body)
|
|
d.addBoth(lambda _: reactor.stop())
|
|
|
|
|
|
|
|
def deny(conf):
|
|
uid = os.getuid()
|
|
gid = os.getgid()
|
|
|
|
if conf.useraccess == 'deny':
|
|
if uid in conf.denyUIDs:
|
|
return True
|
|
if uid in conf.allowUIDs:
|
|
return False
|
|
else:
|
|
if uid in conf.allowUIDs:
|
|
return False
|
|
if uid in conf.denyUIDs:
|
|
return True
|
|
|
|
if conf.groupaccess == 'deny':
|
|
if gid in conf.denyGIDs:
|
|
return True
|
|
if gid in conf.allowGIDs:
|
|
return False
|
|
else:
|
|
if gid in conf.allowGIDs:
|
|
return False
|
|
if gid in conf.denyGIDs:
|
|
return True
|
|
|
|
return not conf.defaultAccess
|
|
|
|
|
|
|
|
def run():
|
|
o = parseOptions(sys.argv[1:])
|
|
gConf = loadConfig(GLOBAL_CFG)
|
|
lConf = loadConfig(LOCAL_CFG)
|
|
|
|
if deny(gConf) or deny(lConf):
|
|
_log.error("Permission denied")
|
|
return
|
|
|
|
host = lConf.smarthost or gConf.smarthost or SMARTHOST
|
|
|
|
ident = gConf.identities.copy()
|
|
ident.update(lConf.identities)
|
|
|
|
if lConf.domain:
|
|
smtp.DNSNAME = lConf.domain
|
|
elif gConf.domain:
|
|
smtp.DNSNAME = gConf.domain
|
|
|
|
sendmail(host, o, ident)
|
|
|
|
if failed:
|
|
if o.printErrors:
|
|
failed.printTraceback(file=sys.stderr)
|
|
raise SystemExit(1)
|
|
else:
|
|
senderror(failed, o)
|