# Copyright (C) 2001-2018 by the Free Software Foundation, Inc.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
# USA.

"""Creation/deletion hooks for the Postfix MTA."""

import os
import pwd
import grp
import time
import errno
from stat import *

from Mailman import mm_cfg
from Mailman import Utils
from Mailman import LockFile
from Mailman.i18n import C_
from Mailman.MailList import MailList
from Mailman.MTA.Utils import makealiases
from Mailman.Logging.Syslog import syslog

LOCKFILE = os.path.join(mm_cfg.LOCK_DIR, 'creator')
ALIASFILE = os.path.join(mm_cfg.DATA_DIR, 'aliases')
VIRTFILE = os.path.join(mm_cfg.DATA_DIR, 'virtual-mailman')
# Desired mode for aliases(.db) and virtual-mailman(.db) for both creation
# and check_perms.
targetmode = S_IFREG | S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH

try:
    True, False
except NameError:
    True = 1
    False = 0



def _update_maps():
    # Helper function to fix owner and mode.
    def fixom(file):
        # It's not necessary for the non-db file to be S_IROTH, but for
        # simplicity and compatibility with check_perms, we set it.
        stat = os.stat(file)
        if (stat[ST_MODE] & targetmode) <> targetmode:
            os.chmod(file, stat[ST_MODE] | targetmode)
        dbfile = file + '.db'
        try:
            stat = os.stat(dbfile)
        except OSError, e:
            if e.errno <> errno.ENOENT:
                raise
            return
        if (stat[ST_MODE] & targetmode) <> targetmode:
            os.chmod(dbfile, stat[ST_MODE] | targetmode)
        user = mm_cfg.MAILMAN_USER
        if stat[ST_UID] != pwd.getpwnam(user)[2]:
            uid = pwd.getpwnam(user)[2]
            gid = grp.getgrnam(mm_cfg.MAILMAN_GROUP)[2]
            os.chown(dbfile, uid, gid)
    msg = 'command failed: %s (status: %s, %s)'
    acmd = mm_cfg.POSTFIX_ALIAS_CMD + ' ' + ALIASFILE
    status = (os.system(acmd) >> 8) & 0xff
    if status:
        errstr = os.strerror(status)
        syslog('error', msg, acmd, status, errstr)
        raise RuntimeError, msg % (acmd, status, errstr)
    # Fix owner and mode of .db if needed.
    fixom(ALIASFILE)
    if os.path.exists(VIRTFILE):
        vcmd = mm_cfg.POSTFIX_MAP_CMD + ' ' + VIRTFILE
        status = (os.system(vcmd) >> 8) & 0xff
        if status:
            errstr = os.strerror(status)
            syslog('error', msg, vcmd, status, errstr)
            raise RuntimeError, msg % (vcmd, status, errstr)
        # Fix owner and mode of .db if needed.
        fixom(VIRTFILE)



def makelock():
    return LockFile.LockFile(LOCKFILE)


def _zapfile(filename):
    # Truncate the file w/o messing with the file permissions, but only if it
    # already exists.
    if os.path.exists(filename):
        fp = open(filename, 'w')
        fp.close()


def clear():
    _zapfile(ALIASFILE)
    _zapfile(VIRTFILE)



def _addlist(mlist, fp):
    # Set up the mailman-loop address
    loopaddr = Utils.ParseEmail(Utils.get_site_email(extra='loop'))[0]
    loopmbox = os.path.join(mm_cfg.DATA_DIR, 'owner-bounces.mbox')
    # Seek to the end of the text file, but if it's empty write the standard
    # disclaimer, and the loop catch address.
    fp.seek(0, 2)
    if not fp.tell():
        print >> fp, """\
# This file is generated by Mailman, and is kept in sync with the
# binary hash file aliases.db.  YOU SHOULD NOT MANUALLY EDIT THIS FILE
# unless you know what you're doing, and can keep the two files properly
# in sync.  If you screw it up, you're on your own.
"""
        print >> fp, '# The ultimate loop stopper address'
        print >> fp, '%s: %s' % (loopaddr, loopmbox)
        print >> fp
    # Bootstrapping.  bin/genaliases must be run before any lists are created,
    # but if no lists exist yet then mlist is None.  The whole point of the
    # exercise is to get the minimal aliases.db file into existance.
    if mlist is None:
        return
    listname = mlist.internal_name()
    fieldsz = len(listname) + len('-unsubscribe')
    # The text file entries get a little extra info
    print >> fp, '# STANZA START:', listname
    print >> fp, '# CREATED:', time.ctime(time.time())
    # Now add all the standard alias entries
    for k, v in makealiases(listname):
        # Format the text file nicely
        print >> fp, k + ':', ((fieldsz - len(k)) * ' ') + v
    # Finish the text file stanza
    print >> fp, '# STANZA END:', listname
    print >> fp



def _isvirtual(mlist):
    return (mlist and mlist.host_name.lower() in
            [d.lower() for d in mm_cfg.POSTFIX_STYLE_VIRTUAL_DOMAINS])

def _addvirtual(mlist, fp):
    listname = mlist.internal_name()
    fieldsz = len(listname) + len('-unsubscribe')
    hostname = mlist.host_name
    # Set up the mailman-loop address
    loopaddr = Utils.get_site_email(mlist.host_name, extra='loop')
    loopdest = Utils.ParseEmail(loopaddr)[0]
    # And the site list posting address.
    siteaddr = Utils.get_site_email(mlist.host_name)
    sitedest = Utils.ParseEmail(siteaddr)[0]
    # And the site list -owner address.
    siteowneraddr = Utils.get_site_email(mlist.host_name, extra='owner')
    siteownerdest = Utils.ParseEmail(siteowneraddr)[0]
    if mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN:
        loopdest += '@' + mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN
        sitedest += '@' + mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN
        siteownerdest += '@' + mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN
    # If the site list's host_name is a virtual domain, adding the list and
    # owner addresses to the SITE ADDRESSES will duplicate the entries in the
    # stanza for the list.  Postfix doesn't like dups so we try to comment them
    # here, but only for the actual site list domain.
    if (MailList(mm_cfg.MAILMAN_SITE_LIST, lock=False).host_name.lower() ==
            hostname.lower()):
        siteaddr = '#' + siteaddr
        siteowneraddr = '#' + siteowneraddr
    # Seek to the end of the text file, but if it's empty write the standard
    # disclaimer, and the loop catch address and site address.
    fp.seek(0, 2)
    if not fp.tell():
        print >> fp, """\
# This file is generated by Mailman, and is kept in sync with the binary hash
# file virtual-mailman.db.  YOU SHOULD NOT MANUALLY EDIT THIS FILE unless you
# know what you're doing, and can keep the two files properly in sync.  If you
# screw it up, you're on your own.
#
# Note that you should already have this virtual domain set up properly in
# your Postfix installation.  See README.POSTFIX for details.

# LOOP ADDRESSES START
%s\t%s
# LOOP ADDRESSES END

# We also add the site list address in each virtual domain as that address
# is exposed on admin and listinfo overviews, and we add the site list-owner
# address as it is exposed in the list created email notice.

# SITE ADDRESSES START
%s\t%s
%s\t%s
# SITE ADDRESSES END
""" % (loopaddr, loopdest, siteaddr, sitedest, siteowneraddr, siteownerdest)
    # The text file entries get a little extra info
    print >> fp, '# STANZA START:', listname
    print >> fp, '# CREATED:', time.ctime(time.time())
    # Now add all the standard alias entries
    for k, v in makealiases(listname):
        fqdnaddr = '%s@%s' % (k, hostname)
        if mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN:
            localaddr = '%s@%s' % (k, mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN)
        else:
            localaddr = k
        # Format the text file nicely
        print >> fp, fqdnaddr, ((fieldsz - len(k)) * ' '), localaddr
    # Finish the text file stanza
    print >> fp, '# STANZA END:', listname
    print >> fp



# Blech.
def _check_for_virtual_loopaddr(mlist, filename):
    loopaddr = Utils.get_site_email(mlist.host_name, extra='loop')
    loopdest = Utils.ParseEmail(loopaddr)[0]
    siteaddr = Utils.get_site_email(mlist.host_name)
    sitedest = Utils.ParseEmail(siteaddr)[0]
    siteowneraddr = Utils.get_site_email(mlist.host_name, extra='owner')
    siteownerdest = Utils.ParseEmail(siteowneraddr)[0]
    if mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN:
        loopdest += '@' + mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN
        sitedest += '@' + mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN
        siteownerdest += '@' + mm_cfg.VIRTUAL_MAILMAN_LOCAL_DOMAIN
    # If the site list's host_name is a virtual domain, adding the list and
    # owner addresses to the SITE ADDRESSES will duplicate the entries in the
    # stanza for the list.  Postfix doesn't like dups so we try to comment them
    # here, but only for the actual site list domain.
    if (MailList(mm_cfg.MAILMAN_SITE_LIST, lock=False).host_name.lower() ==
            mlist.host_name.lower()):
        siteaddr = '#' + siteaddr
        siteowneraddr = '#' + siteowneraddr
    infp = open(filename)
    omask = os.umask(007)
    try:
        outfp = open(filename + '.tmp', 'w')
    finally:
        os.umask(omask)
    try:
        # Find the start of the loop address block
        while True:
            line = infp.readline()
            if not line:
                break
            outfp.write(line)
            if line.startswith('# LOOP ADDRESSES START'):
                break
        # Now see if our domain has already been written
        while True:
            line = infp.readline()
            if not line:
                break
            if line.startswith('# LOOP ADDRESSES END'):
                # It hasn't
                print >> outfp, '%s\t%s' % (loopaddr, loopdest)
                outfp.write(line)
                break
            elif line.startswith(loopaddr):
                # We just found it
                outfp.write(line)
                break
            else:
                # This isn't our loop address, so spit it out and continue
                outfp.write(line)
        # Now do it all again for the site list address. It must follow the
        # loop addresses.
        while True:
            line = infp.readline()
            if not line:
                break
            outfp.write(line)
            if line.startswith('# SITE ADDRESSES START'):
                break
        # Now see if our domain has already been written
        while True:
            line = infp.readline()
            if not line:
                break
            if line.startswith('# SITE ADDRESSES END'):
                # It hasn't
                print >> outfp, '%s\t%s' % (siteaddr, sitedest)
                print >> outfp, '%s\t%s' % (siteowneraddr, siteownerdest)
                outfp.write(line)
                break
            elif line.startswith(siteaddr) or line.startswith('#' + siteaddr):
                # We just found it
                outfp.write(line)
                break
            else:
                # This isn't our loop address, so spit it out and continue
                outfp.write(line)
        outfp.writelines(infp.readlines())
    finally:
        infp.close()
        outfp.close()
    os.rename(filename + '.tmp', filename)



def _do_create(mlist, textfile, func):
    # Crack open the plain text file
    try:
        fp = open(textfile, 'r+')
    except IOError, e:
        if e.errno <> errno.ENOENT: raise
        omask = os.umask(007)
        try:
            fp = open(textfile, 'w+')
        finally:
            os.umask(omask)
    try:
        func(mlist, fp)
    finally:
        fp.close()
    # Now double check the virtual plain text file
    if func is _addvirtual:
        _check_for_virtual_loopaddr(mlist, textfile)


def create(mlist, cgi=False, nolock=False, quiet=False):
    # Acquire the global list database lock.  quiet flag is ignored.
    lock = None
    if not nolock:
        lock = makelock()
        lock.lock()
    # Do the aliases file, which need to be done in any case
    try:
        _do_create(mlist, ALIASFILE, _addlist)
        if _isvirtual(mlist):
            _do_create(mlist, VIRTFILE, _addvirtual)
        # bin/genaliases is the only one that calls create with nolock = True.
        # Use that to only update the maps at the end of genaliases.
        if not nolock:
            _update_maps()
    finally:
        if lock:
            lock.unlock(unconditionally=True)



def _do_remove(mlist, textfile, virtualp):
    listname = mlist.internal_name()
    # Now do our best to filter out the proper stanza from the text file.
    # The text file better exist!
    outfp = None
    try:
        infp = open(textfile)
    except IOError, e:
        if e.errno <> errno.ENOENT: raise
        # Otherwise, there's no text file to filter so we're done.
        return
    try:
        omask = os.umask(007)
        try:
            outfp = open(textfile + '.tmp', 'w')
        finally:
            os.umask(omask)
        filteroutp = False
        start = '# STANZA START: ' + listname
        end = '# STANZA END: ' + listname
        oops = '# STANZA START: '
        while 1:
            line = infp.readline()
            if not line:
                break
            # If we're filtering out a stanza, just look for the end marker and
            # filter out everything in between.  If we're not in the middle of
            # filtering out a stanza, we're just looking for the proper begin
            # marker.
            if filteroutp:
                if line.strip() == end:
                    filteroutp = False
                    # Discard the trailing blank line, but don't worry if
                    # we're at the end of the file.
                    infp.readline()
                elif line.startswith(oops):
                    # Stanza end must be missing - start writing from here.
                    filteroutp = False
                    outfp.write(line)
                # Otherwise, ignore the line
            else:
                if line.strip() == start:
                    # Filter out this stanza
                    filteroutp = True
                else:
                    outfp.write(line)
    # Close up shop, and rotate the files
    finally:
        infp.close()
        outfp.close()
    os.rename(textfile+'.tmp', textfile)


def remove(mlist, cgi=False):
    # Acquire the global list database lock
    lock = makelock()
    lock.lock()
    try:
        _do_remove(mlist, ALIASFILE, False)
        if _isvirtual(mlist):
            _do_remove(mlist, VIRTFILE, True)
        # Regenerate the alias and map files
        _update_maps()
    finally:
        lock.unlock(unconditionally=True)



def checkperms(state):
    for file in ALIASFILE, VIRTFILE:
        if state.VERBOSE:
            print C_('checking permissions on %(file)s')
        stat = None
        try:
            stat = os.stat(file)
        except OSError, e:
            if e.errno <> errno.ENOENT:
                raise
        if stat and (stat[ST_MODE] & targetmode) <> targetmode:
            state.ERRORS += 1
            octmode = oct(stat[ST_MODE])
            print C_('%(file)s permissions must be 0664 (got %(octmode)s)'),
            if state.FIX:
                print C_('(fixing)')
                os.chmod(file, stat[ST_MODE] | targetmode)
            else:
                print
        # Make sure the corresponding .db files are owned by the Mailman user.
        # We don't need to check the group ownership of the file, since
        # check_perms checks this itself.
        dbfile = file + '.db'
        stat = None
        try:
            stat = os.stat(dbfile)
        except OSError, e:
            if e.errno <> errno.ENOENT:
                raise
            continue
        if state.VERBOSE:
            print C_('checking ownership of %(dbfile)s')
        user = mm_cfg.MAILMAN_USER
        ownerok = stat[ST_UID] == pwd.getpwnam(user)[2]
        if not ownerok:
            try:
                owner = pwd.getpwuid(stat[ST_UID])[0]
            except KeyError:
                owner = 'uid %d' % stat[ST_UID]
            print C_(
                '%(dbfile)s owned by %(owner)s (must be owned by %(user)s'),
            state.ERRORS += 1
            if state.FIX:
                print C_('(fixing)')
                uid = pwd.getpwnam(user)[2]
                gid = grp.getgrnam(mm_cfg.MAILMAN_GROUP)[2]
                os.chown(dbfile, uid, gid)
            else:
                print
        if stat and (stat[ST_MODE] & targetmode) <> targetmode:
            state.ERRORS += 1
            octmode = oct(stat[ST_MODE])
            print C_('%(dbfile)s permissions must be 0664 (got %(octmode)s)'),
            if state.FIX:
                print C_('(fixing)')
                os.chmod(dbfile, stat[ST_MODE] | targetmode)
            else:
                print
