#!/usr/bin/env python

########################
# Delivery (server) mode
########################

"""Filter incoming messages on standard input.

Usage:  %(program)s [-c <file>] [-d] [-A <file>] [-R <file>] [-h]

Where:
	-c <file>
	--config-file <file>
	   Specify a different configuration file other than ~/.tmdarc.
	   
	-d
	--discard
	   Discard message if address is invalid instead of bouncing it.

        -A
	--confirm-accept-template <file>
	   Full pathname to a custom template for confirmation acceptance notices.

        -R
	--confirm-request-template <file>
	   Full pathname to a custom template for confirmation requests.
           
	--help
	-h
	   Print this help message and exit.
"""

import cStringIO
import getopt
import fileinput
import os
import popen2
import rfc822
import string
import sys
import time
import re


discard = None
program = sys.argv[0]

def usage(code, msg=''):
    print __doc__ % globals()
    if msg:
        print msg
    sys.exit(code)
    
try:
    opts, args = getopt.getopt(sys.argv[1:],
                               'c:dA:R:h',['config-file=',
                                           'discard',
                                           'confirm-accept-template='
                                           'confirm-request-template='
                                           'help'])
except getopt.error, msg:
    usage(1, msg)

for opt, arg in opts:
    if opt in ('-h', '--help'):
        usage(0)
    elif opt in ('-R', '--confirm-request-template'):
        os.environ['TMDA_CONFIRM_REQUEST_TEMPLATE'] = arg
    elif opt in ('-A', '--confirm-accept-template'):
        os.environ['TMDA_CONFIRM_ACCEPT_TEMPLATE'] = arg
    elif opt in ('-d', '--discard'):
	discard = 1
    elif opt in ('-c', '--config-file'):
        os.environ['TMDARC'] = arg


try:
    import paths
except ImportError:
    pass

from TMDA import Cookie
from TMDA import Defaults
from TMDA import MTA
from TMDA import Util


# We use this MTA instance to control the fate of the message.
mta = MTA.init()

# Read sys.stdin into a temporary variable for later access.
stdin = cStringIO.StringIO(sys.stdin.read())

# Collect the message headers.
message_headers = rfc822.Message(stdin)
# Collect the message body.
message_body = stdin.read()

# Collect the entire message.
message = stdin.getvalue()
# Calculate the message size.
message_size = str(len(message_body))

# $SENDER is the envelope sender address.
envelope_sender = os.environ.get('SENDER')

# $RECIPIENT is the envelope recipient address.
# Use the X-Originally-To header if it exists.
envelope_recipient = (message_headers.getheader('x-originally-to')
                      or os.environ.get('RECIPIENT'))

# recipient_address is the original address the message was sent to,
# not qmail-send's rewritten interpretation.  This will be the same as
# envelope_recipient if we are not running under a qmail virtualdomain.
recipient_address = envelope_recipient
if Defaults.USEVIRTUALDOMAINS and os.path.exists(Defaults.VIRTUALDOMAINS):
    # Parse the virtualdomains control file; see qmail-send(8) for
    # syntax rules.  All this because qmail doesn't store the original
    # envelope recipient in the environment.
    (ousername, odomain) = string.split(envelope_recipient,'@')
    for line in fileinput.input(Defaults.VIRTUALDOMAINS):
        vdomain_match = 0
        line = string.lower(string.strip(line))
        # Comment or blank line?
        if line == '' or line[0] in '#':
            continue
        else:
            (vdomain, prepend) = string.split(line,':')
            # domain:prepend
            if vdomain == string.lower(odomain):
                vdomain_match = 1
            # .domain:prepend (wildcard)
            elif not string.split(vdomain,'.',1)[0]:
                if string.find(string.lower(odomain), vdomain) != -1:
                    vdomain_match = 1
            # user@domain:prepend
            else:
                try:
                    if string.split(vdomain,'@')[1] == string.lower(odomain):
                        vdomain_match = 1
                except IndexError:
                    pass
            if vdomain_match:
                # strip off the prepend
                if prepend:
                    nusername = string.replace(ousername,prepend + '-','')
                    recipient_address = nusername + '@' + odomain
                    fileinput.close()
                    break

# Collect the message's Subject: for later use.
subject = message_headers.getheader('subject', 'None')

# Collect the message's Precedence: header.
precedence = message_headers.getheader('precedence', None)
# If its value is "bulk", "junk", or "list" we should not generate any
# auto-replies.
auto_reply = 1
if precedence:
    precedence = string.lower(precedence)
    if precedence in ('bulk','junk','list'):
        auto_reply = 0


def logit(action_msg,date):
    """Write delivery statistics to the LOGFILE if enabled."""
    if Defaults.LOGFILE and recipient_address:
        try:
            logfile = open(Defaults.LOGFILE, 'a') # append to the file
            Date = time.asctime(time.localtime(date))
            From = message_headers.getheader('from')
            EnvelopeSender = envelope_sender
            To = recipient_address
            Subject = subject
            Action = action_msg
            actionstr = 'Actn: ' + Action
            sizestr = '(' + message_size + ')'
            wsbuf = 78 - len(actionstr) - len(sizestr)
            # Write the log entry and then close the log.
            logfile.write('Date: ' + Date + '\n')
            if (EnvelopeSender
                and message_headers.getaddr('from')[1] != EnvelopeSender):
                logfile.write('Sndr: ' + EnvelopeSender + '\n')
            if From:
                logfile.write('From: ' + From + '\n')
            logfile.write('  To: ' + To + '\n')
            logfile.write('Subj: ' + Subject + '\n')
            logfile.write(actionstr + ' '*wsbuf + sizestr + '\n')
            logfile.write('\n')
            logfile.close()
        except IOError, error_msg:
            print error_msg
            mta.defer()


def send_bounce(bounce_message, **vars):
    """Send a confirmation message back to the sender."""
    if auto_reply:
        bounce_message = cStringIO.StringIO(bounce_message)
        message_headers = rfc822.Message(bounce_message)
        # Add some headers.
        if not vars.has_key('already_confirmed'):
            message_headers['Reply-To'] = vars['confirm_accept_address']
        message_headers['To'] = envelope_sender
        message_headers['Precedence'] = 'bulk'
        message_headers['X-Delivery-Agent'] = Defaults.DELIVERY_AGENT
        message_body = bounce_message.read()
        inject = []
        inject.append(Defaults.SENDMAIL)
        inject.append('-f')
        inject.append(Defaults.BOUNCE_ENV_SENDER)
        inject.append(envelope_sender)
        try:
            pipeline = popen2.popen2(inject)[1]
            pipeline.write(str(message_headers))
            pipeline.write('\n')
            pipeline.write(message_body)
            pipeline.close()
        except IOError, error_msg:
            print error_msg
            mta.defer()


def bounce_cc(address):
    """Send a 'carbon copy' of the bounced message to address."""
    inject = []
    inject.append(Defaults.SENDMAIL)
    inject.append(address)
    try:
        pipeline = popen2.popen2(inject)[1]
        pipeline.write(message)
        pipeline.close()
    except IOError, error_msg:
        print error_msg
        mta.defer()
    logit(string.join(inject),time.time())


def inject_pending(pathname,timestamp,pid):
    """Reinject then unlink a sucessfully confirmed message."""
    (username, hostname) = string.split(recipient_address,'@')
    # Strip off the '-confirm-accept.TIMESTAMP.PID.HMAC' from username.
    base_username = string.join(
        (string.split(username,Defaults.RECIPIENT_DELIMITER)[:-2]),
        Defaults.RECIPIENT_DELIMITER)
    base_recipient = base_username + '@' + hostname
    # Create the `confirm-done' address
    confirm_done_address = Cookie.make_confirm_address(base_recipient,
                                                       timestamp,
                                                       pid,
                                                       'done')
    try:
        fileobj = open(pathname,'r')
        message_headers = rfc822.Message(fileobj)
        message_body = fileobj.read()
        fileobj.close()
        # Add the date when confirmed in a header.
        message_headers['X-TMDA-Confirmed'] = (
            time.asctime(time.localtime(time.time())))
        # Collect the envelope sender to pass to sendmail.
        return_path = message_headers.getaddr('return-path')[1]
        inject = []
        inject.append(Defaults.SENDMAIL)
        inject.append('-f')
        inject.append(return_path)
        inject.append(confirm_done_address)
        pipeline = popen2.popen2(inject)[1]
        pipeline.write(str(message_headers))
        pipeline.write('\n')
        pipeline.write(message_body)
        pipeline.close()
    except IOError, error_msg:
        print error_msg
        mta.defer()
    os.unlink(pathname)
    mta.stop()


def locate_pending(timestamp,pid):
    """Locate the message in the pending queue."""
    pendingdir = Defaults.DATADIR + 'pending'
    pending_message = timestamp + '.' + pid + '.msg'
    pending_message_pathname = pendingdir + '/' + pending_message
    if os.path.exists(pending_message_pathname):
        logit("CONFIRM accept " + pending_message, time.time())
        # Optionally append the envelope sender to the whitelist.
        if Defaults.WHITELIST_AUTO_APPEND:
            if Util.append_to_file(envelope_sender, Defaults.WHITELIST) != 0:
                logit("APPEND whitelist " + envelope_sender, time.time())
        # Optionally generate the confirmation acceptance notice.
        if Defaults.CONFIRM_ACCEPT_NOTIFY:
            bouncegen('accept')
        # Reinject the original (now confirmed) message.
        inject_pending(pending_message_pathname,timestamp,pid)
    else:
        logit("BOUNCE nonexistent_pending_message",time.time())
        print "Sorry, your original message could not be located, please resend."
        mta.bounce()


def verify_confirm_cookie(confirm_cookie):
    """Verify a confirmation cookie."""
    # Save some time if the cookie is bogus.
    try:
        (confirm_action, confirm_timestamp,
         confirm_pid, confirm_hmac) = string.split(confirm_cookie,'.')
    except ValueError:
        bouncegen('request')
    # pre-confirmation
    if confirm_action == 'accept':
        new_confirm_hmac = Cookie.confirmationmac(confirm_timestamp,
                                                  confirm_pid,'accept')
        # Accept the message only if the HMAC can be verified.
        if (confirm_hmac == new_confirm_hmac):
            locate_pending(confirm_timestamp,confirm_pid)
        else:
            logit("BOUNCE invalid_confirmation_address",time.time())
            print("Sorry, this confirmation address is invalid.")
            mta.bounce()
    # post-confirmation
    elif confirm_action == 'done':
        new_confirm_hmac = Cookie.confirmationmac(confirm_timestamp,
                                                  confirm_pid,'done')
        # Accept the message only if the HMAC can be verified.
        if (confirm_hmac == new_confirm_hmac):
            logit("OK good_confirm_cookie", time.time())
            mta.deliver(message)
        else:
            logit("BOUNCE invalid_confirmation_address",time.time())
            print("Sorry, this confirmation address is invalid.")
            mta.bounce()


def verify_dated_cookie(dated_cookie):
    """Verify a dated cookie."""
    # Save some time if the cookie is bogus.
    dated_cookie_split = string.split(dated_cookie,'.')
    if len(dated_cookie_split) != 2:
        bouncegen('request')
    cookie_date = dated_cookie_split[0]
    datemac = dated_cookie_split[1]
    newdatemac = Cookie.datemac(cookie_date)
    # Accept the message only if the address has not expired *and* the HMAC
    # can be verified.
    now = time.time()
    if ((int(cookie_date) >= int('%d' % now)) and (datemac == newdatemac)):
        logit("OK good_dated_cookie",now)
        mta.deliver(message)
    else:
        bouncegen('request')


def verify_sender_cookie(sender_address,sender_cookie):
    """Verify a sender cookie."""
    sender_address_cookie = Cookie.make_sender_cookie(sender_address)
    # Accept the message only if the HMAC can be verified.
    if (sender_cookie == sender_address_cookie):
        logit("OK good_sender_cookie", time.time())
        mta.deliver(message)
    else:
        bouncegen('request')


def verify_keyword_cookie(keyword_cookie):
    """Verify a keyword cookie."""
    # Save some time if the cookie is bogus.
    parts = string.split(keyword_cookie, '.')
    if len(parts) != 2:
        bouncegen('request')
    keyword = parts[0]
    mac = parts[1]
    newmac = Cookie.make_keywordmac(keyword)
    # Accept the message only if the HMAC can be verified.
    if mac == newmac:
        logit("OK good_keyword_cookie \"" + keyword + "\"", time.time())
        mta.deliver(message)
    else:
        bouncegen('request')


def bouncegen(mode):
    """Bounce a message back to sender."""
    # Stop right away if --discard was specified.
    if discard:
        mta.stop()
    # Common variables.
    now = time.time()
    recipient_address = globals().get('recipient_address')
    envelope_sender = globals().get('envelope_sender')
    subject = globals().get('subject')
    original_message_headers = globals().get('message_headers')

    if (Defaults.CONFIRM_MAX_MESSAGE_SIZE and
        int(Defaults.CONFIRM_MAX_MESSAGE_SIZE) < int(globals().get('message_size'))):
        original_message = str(original_message_headers) + "\n" + \
                           "[ Message body suppressed (exceeded " + \
                           str(Defaults.CONFIRM_MAX_MESSAGE_SIZE) + " bytes) ]"
    else:
        original_message = globals().get('message')

    pkg_template_dir = '/etc/tmda/'    # Debian
    if not os.path.exists(pkg_template_dir):
        pkg_template_dir = sys.prefix + '/share/tmda/' # Redhat
    # Optional 'dated' address variables.
    if Defaults.DATED_TEMPLATE_VARS:
        dated_timeout = Util.format_timeout(Defaults.TIMEOUT)
        dated_expire_date = time.asctime(
            time.gmtime(now + Util.seconds(Defaults.TIMEOUT)))
        dated_cookie_address = Cookie.make_dated_address(
            Defaults.USERNAME + '@' + Defaults.HOSTNAME)
    # Optional 'sender' address variables.
    if Defaults.SENDER_TEMPLATE_VARS:
        sender_cookie_address = Cookie.make_sender_address(
            Defaults.USERNAME + '@' + Defaults.HOSTNAME,
            envelope_sender)
    if mode == 'accept':                # confirmation acceptance notices
        env_template = os.environ.get('TMDA_CONFIRM_ACCEPT_TEMPLATE')
        def_template = Defaults.CONFIRM_ACCEPT_TEMPLATE
        pkg_template = pkg_template_dir + 'confirm_accept.txt'
    if mode == 'request':               # request confirmations
        env_template = os.environ.get('TMDA_CONFIRM_REQUEST_TEMPLATE')
        def_template = Defaults.CONFIRM_REQUEST_TEMPLATE
        pkg_template = pkg_template_dir + 'confirm_request.txt'
        timestamp = str('%d' %now)
        pid = str(os.getpid())
        confirm_accept_address = Cookie.make_confirm_address(recipient_address,
                                                             timestamp,
                                                             pid,
                                                             'accept')
        pendingdir = Defaults.DATADIR + 'pending'
        pending_message = timestamp + '.' + pid + '.msg'
        # Create ~/.tmda/ and friends if necessary.
        if not os.path.exists(pendingdir):
            try:
                os.makedirs(pendingdir,0700) # stores the unconfirmed messages
            except IOError, error_msg:
                print error_msg
                mta.defer()
        # Write ~/.tmda/pending/TIMESTAMP.PID.msg
        message_headers['Return-Path'] = '<' + envelope_sender + '>'
        pending_contents = str(message_headers) + '\n' + message_body
        Util.writefile(pending_contents, pendingdir + '/' + pending_message)
        logit("CONFIRM pending " + pending_message, time.time())
    # Find the right template.
    if env_template and os.path.exists(env_template):
        right_template = env_template
    elif def_template and os.path.exists(def_template):
        right_template = def_template
    else:
        # must be installed from a package
        right_template = pkg_template
    # Create the message and the send it.
    bounce_message = Util.maketext(right_template,vars())
    if mode == 'accept':
        send_bounce(bounce_message,already_confirmed=1)
    if mode == 'request':
        if Defaults.BOUNCE_CONFIRM_CC:
            bounce_cc(Defaults.BOUNCE_CONFIRM_CC)
        send_bounce(bounce_message,
                    confirm_accept_address = confirm_accept_address)
        mta.stop()  


######
# Main
######

def main():

    # Get the cookie type and value by parsing the extension address.
    ext = (os.environ.get('EXT')           # qmail
           or os.environ.get('EXTENSION')) # Postfix
    if ext:
        ext = string.lower(ext)
        ext_split = string.split(ext, Defaults.RECIPIENT_DELIMITER)
        cookie_value = ext_split[-1]
        try:
            cookie_type = ext_split[-2]
        except IndexError:
            cookie_type = None
        if cookie_type not in ('confirm','dated','sender'):
            cookie_type = 'keyword'
            cookie_value = ext
    else:
        cookie_type = None
        cookie_value = None
    
    # Extract the e-mail address from From:, and Reply-To:
    EnvelopeSender = envelope_sender
    From = message_headers.getaddr('from')[1]
    ReplyTo = message_headers.getaddr('reply-to')[1]
    
    # Is the sender in the BLACKLIST?
    if os.path.exists(Defaults.BLACKLIST):
        blacklist_list = []
        blacklist_list = Util.file_to_list(Defaults.BLACKLIST,
                                           blacklist_list)
        if Util.findmatch(blacklist_list, EnvelopeSender,From,ReplyTo):
            logit("DROP blacklist_match", time.time())
            if Defaults.BOUNCE_BLACKLIST_CC:
                bounce_cc(Defaults.BOUNCE_BLACKLIST_CC)
            mta.stop()
    # Confirm tag?
    if cookie_type == 'confirm' and cookie_value:
            verify_confirm_cookie(cookie_value)
    # Is the sender in the WHITELIST?
    if os.path.exists(Defaults.WHITELIST):
        whitelist_list = []
        whitelist_list = Util.file_to_list(Defaults.WHITELIST,
                                           whitelist_list)
        if Util.findmatch(whitelist_list, EnvelopeSender,From,ReplyTo):
            logit("OK whitelist_match", time.time())
            mta.deliver(message)
    # Is the recipient in the REVOKED_FILE?
    if os.path.exists(Defaults.REVOKED_FILE):
        revoked_file_list = []
        revoked_file_list = Util.file_to_list(Defaults.REVOKED_FILE,
                                              revoked_file_list)
        if Util.findmatch(revoked_file_list, recipient_address):
            logit("BOUNCE revoked_file_match", time.time())
            if Defaults.BOUNCE_REVOKED_CC:
                bounce_cc(Defaults.BOUNCE_REVOKED_CC)
            print "Sorry, this address has been revoked."
            mta.bounce()
    # Is the message sacred?
    if os.path.exists(Defaults.SACRED_FILE):
        sacred_list = []
        sacred_list = Util.file_to_list(Defaults.SACRED_FILE,sacred_list)
        matches = 0
        for single_expression in sacred_list:
            if None != re.search(single_expression,message,(re.M|re.I)):
                matches = matches+1
        if matches > 0:
            logit("OK sacred_text_match", time.time())
            mta.deliver(message)
    # Dated tag?
    if cookie_type == 'dated' and cookie_value:
        verify_dated_cookie(cookie_value)
    # Sender tag?
    if cookie_type == 'sender' and cookie_value:
        sender_address = globals().get('envelope_sender')
        verify_sender_cookie(sender_address,cookie_value)
    # Keyword tag?
    if cookie_type == 'keyword' and cookie_value:
        verify_keyword_cookie(cookie_value)
    # If the message gets this far (i.e, was not sent to a tagged
    # address and it didn't match any list files), then we bounce it
    # for confirmation.
    bouncegen('request')


# This is the end my friend.
if __name__ == '__main__':
    main()
