#!/usr/bin/env python # -*- coding: utf-8 -*- # # putmail.py Send mail read from standard input. # # Copyright 2007 Ricardo Garcia Gonzalez: http://sourceforge.net/users/rg3/ # # This tiny script is distributed under the X Consortium License. See # LICENSE file for more details. # # Version: 1.4 import ConfigParser import smtplib import email import os import sys import socket import datetime ################################## # General program initialization # ################################## ### Some constants ### CONFIG_DIRECTORY = '.putmail' CONFIG_NAME = 'putmailrc' LOG_FILE = 'putmail.log' DEFAULT_PORT = 25 HIGHEST_PORT = 65535 FIRST_ERROR_CODE = 400 CONFIG_SECTION = 'config' OPTION_SERVER = 'server' OPTION_EMAIL = 'email' OPTION_TLS = 'tls' OPTION_LOGIN = 'username' OPTION_PASSWORD = 'password' OPTION_PORT = 'port' OPTION_QUIET = 'quiet' FROM_HEADER = 'From' TO_HEADER = 'To' CC_HEADER = 'Cc' BCC_HEADER = 'Bcc' HOME_EV = 'HOME' HOME_DIR = '/opt/zimbra' DEFAULT_CONFIG = """[config] server = your.smtp.server.example.com email = your.email@example.com """ # Internal errors ERROR_HOME_UNSET = 'Error: %s environment variable not set' % HOME_EV ERROR_CONFIG_NONEXISTANT = 'Error: config file %s not present' ERROR_CONFIG_UNREADABLE = 'Error: config file present but not readable' ERROR_CONFIG_CREATE = 'Error: unable to create sample config file' ERROR_CONFIG_PARSE = 'Error: parse error reading config file' ERROR_READ_MAIL = 'Error: parse error reading the mail message' ERROR_NO_RECIPIENTS = 'Error: no recipients for the message' ERROR_TLS = 'Error: malformed option "%s"' % OPTION_TLS ERROR_PORT = 'Error: malformed option "%s"' % OPTION_PORT ERROR_QUIET = 'Error: malformed option "%s"' % OPTION_QUIET ERROR_O_OPTION = 'Error: missing option for -o' ERROR_OPTION_ARGS = 'Error: missing arguments for last option' ERROR_UNKNOWN_OPTION = 'Error: unknown option "%s"' # SMTP and network errors ERROR_REFUSED = 'Error: all recipients rejected by server' ERROR_HELO = 'Error: server did not properly reply to HELO' ERROR_SENDER_REFUSED = 'Error: the server rejected the envelope address' ERROR_DISCONNECTED = 'Error: the server disconnected unexpectedly' ERROR_DATA = 'Error: the server refused to accept the message data' ERROR_CONNECT = 'Error: could not connect to server' ERROR_AUTH = 'Error: server did not accept login/password' ERROR_OTHER = 'Error: server replied: %s %s' ERROR_NETWORK = 'Error: problem contacting server %s: %s' ERROR_UNKNOWN = 'Error: unknown problem happened' # Warnings WARNING_REJECTED = 'Warning: the following recipients were rejected:' WARNING_QUIT = 'Warning: problem disconnecting but message probably sent' WARNING_LOGFILE = 'Warning: unable to write to log file' WARNING_CONFIG_UNREADABLE = 'Warning: config file for %s present but no read access, falling back to default config file' WARNING_SAMPLE_CONFIG = 'Warning: trying to create sample config file (read manpage and check permissions' # Codes EXIT_FAILURE = 1 ### Some key variables in the program ### theEMailAddress = None # Envelope From address theSMTPServer = None # SMTP server to use thePort = None # The SMTP port in the server theMessage = None # The E-Mail message theRecipients = [] # The recipients of the E-Mail message theConfigFilename = None # The configuration file name theTLSFlag = False # Use TLS or not theAuthenticateFlag = False # Use SMTP authentication or not theSMTPLogin = None # The login name to use with the server theSMTPPassword = None # The corresponding password theRcpsFromMailFlag = False # Take recipients from message or not theQuietFlag = False # Supress program output or not theLogFile = None # The log file name ### A few auxiliary functions ### def handle_error(str, stderr_output, exit_program): fullstr = str + "\n" try: file(theLogFile, "a").write("%s: %s" % (datetime.datetime.ctime(datetime.datetime.now()), fullstr)) except (IOError, OSError): sys.stderr.write(WARNING_LOGFILE + "\n") if stderr_output: sys.stderr.write(fullstr) if exit_program: sys.exit(EXIT_FAILURE) def exit_forcing_print(str): handle_error(str, True, True) def exit_conditional_print(str): handle_error(str, not theQuietFlag, True) def conditional_print(str): handle_error(str, not theQuietFlag, False) def force_print(str): handle_error(str, True, False) def check_status((code, message)): if code >= FIRST_ERROR_CODE: exit_conditional_print(ERROR_OTHER % (code, message)) ### Check for HOME present (needed later, checking now saves a lot of work) ### #if not os.environ.has_key(HOME_EV): # Note: I still can't use exit_forcing_print() at this point, the log # filename is not set. # sys.exit(ERROR_HOME_UNSET + "\n") ### Build the log filename now ### theLogFile = os.path.join(HOME_DIR, CONFIG_DIRECTORY, LOG_FILE) ############################################### # First step: Parse the command line options. # ############################################### ### This is the new manual option parser to fully comply with ### ### the braindead traditional sendmail options format >:( ### try: program_args = sys.argv[1:] # Program arguments direct_exit = False # Exit directly? ### The lists below contain IGNORED OPTIONS ONLY, see the loop. ### ### Option groups ending with _exit make the program exit ### ### directly at the end of the parsing loop ### single_options = ['-Ac', '-Am', '-ba', '-bm', '-bs', '-bt', '-bv', '-G', '-i', '-n', '-v'] single_options_exit = ['-bd', '-bD', '-bh', '-bH', '-bp', '-bP', '-qf', '-bi' ] one_argument_options = [ '-B', '-C', '-D', '-d', '-F', '-h', '-L', '-N', '-O', '-p', '-R', '-r', '-V', '-X' ] one_argument_options_exit = ['-qG', '-qI', '-qQ', '-qR', '-qS', '-q!I', '-q!Q', '-q!R', '-q!S' ] optional_extra_chars_options_exit = [ '-q', '-qp', '-Q' ] # Eat arguments until there's none left while (len(program_args) > 0): if program_args[0] == '--': # Remaining options are recipients theRecipients.extend(program_args[1:]) break elif program_args[0] == '-t': # Take recipients from message theRcpsFromMailFlag = True del program_args[0] elif program_args[0] in single_options: del program_args[0] elif program_args[0] in single_options_exit: del program_args[0] direct_exit = True elif program_args[0] == '-f': # Address in next argument theEMailAddress = program_args[1] del program_args[0] del program_args[0] elif program_args[0][0:2] == '-f': # Address in this argument theEMailAddress = program_args[0][2:] del program_args[0] elif program_args[0] in one_argument_options: del program_args[0] del program_args[0] elif program_args[0][0:2] in one_argument_options: del program_args[0] elif program_args[0] in one_argument_options_exit: del program_args[0] del program_args[0] direct_exit = True elif (sum([program_args[0].startswith(x) # First chars match for x in one_argument_options_exit]) > 0): del program_args[0] direct_exit = True elif (sum([program_args[0].startswith(x) # First chars match for x in optional_extra_chars_options_exit]) > 0): del program_args[0] direct_exit = True elif program_args[0].startswith('-o'): # Weird case if program_args[0] == '-o': exit_forcing_print(ERROR_O_OPTION) del program_args[0] del program_args[0] else: if program_args[0].startswith('-'): exit_forcing_print(ERROR_UNKNOWN_OPTION % program_args[0]) theRecipients.append(program_args[0]) del program_args[0] # End of parsing loop # Problem in some option argument except IndexError: exit_forcing_print(ERROR_OPTION_ARGS) # Options indicated direct exit if direct_exit: sys.exit() # No addresses found? if len(theRecipients) == 0 and not theRcpsFromMailFlag: exit_forcing_print(ERROR_NO_RECIPIENTS) ###################################################### # Second step: Read the message from standard input. # ###################################################### try: theMessage = email.message_from_file(sys.stdin) except email.Errors.MessageError: exit_forcing_print(ERROR_READ_MAIL) ############################################ # Third step: Read the configuration file. # ############################################ ### Try to find the apropiate configuration file or ### ### fall back to CONFIG_NAME. ### configPath = os.path.join(HOME_DIR, CONFIG_DIRECTORY) # temporally theConfigFilename = CONFIG_NAME if theMessage.has_key(FROM_HEADER): try: fromaddr = email.Utils.getaddresses( theMessage.get_all(FROM_HEADER))[-1][1] tmpcfgpath = os.path.join(configPath, fromaddr) if os.path.isfile(tmpcfgpath): if os.access(tmpcfgpath, os.R_OK): theConfigFilename = fromaddr else: force_print(WARNING_CONFIG_UNREADABLE %fromaddr) except IndexError: pass configPath = os.path.join(configPath, theConfigFilename) # finally if not os.path.exists(configPath): # Config file not present, try to create one and exit force_print(ERROR_CONFIG_NONEXISTANT % configPath) force_print(WARNING_SAMPLE_CONFIG) try: dirname = os.path.dirname(configPath) if not os.path.isdir(dirname): os.makedirs(dirname) file(configPath, "w").write(DEFAULT_CONFIG) except: exit_forcing_print(ERROR_CONFIG_CREATE) sys.exit(EXIT_FAILURE) # Last check. If we cannot read this we cannot proceed. if not os.access(configPath, os.R_OK): exit_forcing_print(ERROR_CONFIG_UNREADABLE) ### Read the file ### config = ConfigParser.ConfigParser() try: config.read([configPath]) except: exit_forcing_print(ERROR_CONFIG_PARSE) ### Check conditions for bad configurations ### if (not config.has_section(CONFIG_SECTION) or not config.has_option(CONFIG_SECTION, OPTION_SERVER) or not config.has_option(CONFIG_SECTION, OPTION_EMAIL) or (config.has_option(CONFIG_SECTION, OPTION_LOGIN) and not config.has_option(CONFIG_SECTION, OPTION_PASSWORD)) or (config.has_option(CONFIG_SECTION, OPTION_PASSWORD) and not config.has_option(CONFIG_SECTION, OPTION_LOGIN))): exit_forcing_print(ERROR_CONFIG_PARSE) ### Extract the necessary configuration parameters ## theSMTPServer = config.get(CONFIG_SECTION, OPTION_SERVER) if theEMailAddress is None: # "Envelope from" if -f was not present theEMailAddress = config.get(CONFIG_SECTION, OPTION_EMAIL) try: # TLS if (config.has_option(CONFIG_SECTION, OPTION_TLS) and config.getboolean(CONFIG_SECTION, OPTION_TLS)): theTLSFlag = True except ValueError: exit_forcing_print(ERROR_TLS) try: # Quiet if (config.has_option(CONFIG_SECTION, OPTION_QUIET) and config.getboolean(CONFIG_SECTION, OPTION_QUIET)): theQuietFlag = True except ValueError: exit_forcing_print(ERROR_QUIET) if config.has_option(CONFIG_SECTION, OPTION_LOGIN): # Login/password theSMTPLogin = config.get(CONFIG_SECTION, OPTION_LOGIN) theSMTPPassword = config.get(CONFIG_SECTION, OPTION_PASSWORD) theAuthenticateFlag = True try: # Port if config.has_option(CONFIG_SECTION, OPTION_PORT): thePort = config.getint(CONFIG_SECTION, OPTION_PORT) if thePort < 0 or thePort > HIGHEST_PORT: raise ValueError else: thePort = DEFAULT_PORT except ValueError: exit_forcing_print(ERROR_PORT) ########################################################################## # Fourth step: Extract information from important headers (like To) and # # remove the Bcc header from the message. # ########################################################################## # If we are told to take the addresses from the message itself... if theRcpsFromMailFlag: if theMessage.has_key(TO_HEADER): theRecipients.extend( [x[1] for x in email.Utils.getaddresses( theMessage.get_all(TO_HEADER))] ) if theMessage.has_key(CC_HEADER): theRecipients.extend( [x[1] for x in email.Utils.getaddresses( theMessage.get_all(CC_HEADER))] ) if theMessage.has_key(BCC_HEADER): theRecipients.extend( [x[1] for x in email.Utils.getaddresses( theMessage.get_all(BCC_HEADER))] ) # Delete Bcc header if it exists if theMessage.has_key(BCC_HEADER): del theMessage[BCC_HEADER] # Still no addresses found? if len(theRecipients) == 0: exit_forcing_print(ERROR_NO_RECIPIENTS) #################################################### # Fifth step: Send the damned message finally \o/. # #################################################### try: server = smtplib.SMTP() #server.set_debuglevel(True) check_status(server.connect(theSMTPServer, thePort)) check_status(server.ehlo()) if theTLSFlag: check_status(server.starttls()) check_status(server.ehlo()) # Repeat EHLO after starting TLS if theAuthenticateFlag: check_status(server.login(theSMTPLogin, theSMTPPassword)) rejected = server.sendmail(theEMailAddress, theRecipients, theMessage.as_string()) except smtplib.SMTPServerDisconnected: exit_conditional_print(ERROR_DISCONNECTED) except smtplib.SMTPSenderRefused: exit_conditional_print(ERROR_SENDER_REFUSED) except smtplib.SMTPRecipientsRefused: exit_conditional_print(ERROR_REFUSED) except smtplib.SMTPDataError: exit_conditional_print(ERROR_DATA) except smtplib.SMTPConnectError: exit_conditional_print(ERROR_CONNECT) except smtplib.SMTPHeloError: exit_conditional_print(ERROR_HELO) except smtplib.SMTPAuthenticationError: exit_conditional_print(ERROR_AUTH) except smtplib.SMTPResponseException, err: exit_conditional_print(ERROR_OTHER % (err.smtp_code, err.smtp_error)) except (socket.error, socket.herror, socket.gaierror), err: exit_conditional_print(ERROR_NETWORK % (theSMTPServer, err[1])) except socket.timeout, err: exit_conditional_print(ERROR_NETWORK % (theSMTPServer, err)) except smtplib.SMTPException: exit_conditional_print(ERROR_UNKNOWN) try: server.quit() except: conditional_print(WARNING_QUIT) # Check for rejected recipients if len(rejected) > 0: conditional_print(WARNING_REJECTED) conditional_print('\n'.join(['\t' + email for email in rejected])) # Good enough sys.exit()