#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
#  king_phisher/client/mailer.py
#
#  Redistribution and use in source and binary forms, with or without
#  modification, are permitted provided that the following conditions are
#  met:
#
#  * Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
#  * Redistributions in binary form must reproduce the above
#    copyright notice, this list of conditions and the following disclaimer
#    in the documentation and/or other materials provided with the
#    distribution.
#  * Neither the name of the project nor the names of its
#    contributors may be used to endorse or promote products derived from
#    this software without specific prior written permission.
#
#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
#  "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
#  LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
#  A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
#  OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
#  SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
#  LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
#  DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
#  THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
#  (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
#  OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#

# disable this warning for the email.mime.* modules that have to be imported
# pylint: disable=unused-import

import codecs
import collections
import csv
import datetime
import email.encoders as encoders
import email.mime as mime
import email.mime.base
import email.mime.image
import email.mime.multipart
import email.mime.text
import email.utils
import logging
import mimetypes
import os
import smtplib
import socket
import threading
import time
import urllib.parse

from king_phisher import errors
from king_phisher import ics
from king_phisher import ipaddress
from king_phisher import its
from king_phisher import templates
from king_phisher import utilities
from king_phisher.client import gui_utilities
from king_phisher.client.dialogs import ssh_host_key
from king_phisher.constants import ConnectionErrorReason
from king_phisher.ssh_forward import SSHTCPForwarder

from gi.repository import GLib
import paramiko
import smoke_zephyr.utilities

__all__ = (
	'guess_smtp_server_address',
	'MailSenderThread',
	'render_message_template'
)

template_environment = templates.MessageTemplateEnvironment()

MessageAttachments = collections.namedtuple('MessageAttachments', ('files', 'images'))
"""
A named tuple for holding both image and file attachments for a message.

.. py:attribute:: files

	A tuple of :py:class:`~.mime.MIMEBase` instances representing the messsages
	attachments.

.. py:attribute:: images

	A tuple of :py:class:`~.mime.MIMEImage` instances representing the images in
	the message.
"""

MIME_TEXT_PLAIN = 'This message requires an HTML aware email agent to be properly viewed.\r\n\r\n'
"""The static string to place in MIME message as a text/plain part. This is shown by email clients that do not support HTML."""

def _iterate_targets_file(target_file, config=None):
	target_file_h = open(target_file, 'rU')
	csv_reader = csv.DictReader(target_file_h, ('first_name', 'last_name', 'email_address', 'department'))
	uid_charset = None if config is None else config['mailer.message_uid.charset']
	for line_no, raw_target in enumerate(csv_reader, 1):
		if None in raw_target:
			# remove the additional fields
			del raw_target[None]
		if its.py_v2:
			# this will intentionally cause a UnicodeDecodeError to be raised as is the behaviour in python 3.x
			# when csv.DictReader is initialized
			raw_target = dict((k, (v if v is None else v.decode('utf-8'))) for k, v in raw_target.items())
		if uid_charset is not None:
			raw_target['uid'] = utilities.make_message_uid(
				upper=uid_charset['upper'],
				lower=uid_charset['lower'],
				digits=uid_charset['digits']
			)
		target = MessageTarget(line=line_no, **raw_target)
		# the caller needs to catch and process the missing fields appropriately
		yield target
	target_file_h.close()

def count_targets_file(target_file):
	"""
	Count the number of valid targets that the specified file contains. This
	skips lines which are missing fields or where the email address is invalid.

	:param str target_file: The path the the target CSV file on disk.
	:return: The number of valid targets.
	:rtype: int
	"""
	count = 0
	for target in _iterate_targets_file(target_file):
		if target.missing_fields:
			continue
		if not utilities.is_valid_email_address(target.email_address):
			continue
		count += 1
	return count

def get_invite_start_from_config(config):
	"""
	Get the start time for an invite from the configuration. This takes into
	account whether the invite is for all day or starts at a specific time.

	:param dict config: The King Phisher client configuration.
	:return: The timestamp of when the invite is to start.
	:rtype: :py:class:`datetime.datetime`
	"""
	if config['mailer.calendar_invite_all_day']:
		start_time = datetime.datetime.combine(
			config['mailer.calendar_invite_date'],
			datetime.time(0, 0)
		)
	else:
		start_time = datetime.datetime.combine(
			config['mailer.calendar_invite_date'],
			datetime.time(
				int(config['mailer.calendar_invite_start_hour']),
				int(config['mailer.calendar_invite_start_minute'])
			)
		)
	return start_time

@smoke_zephyr.utilities.Cache('3m')
def guess_smtp_server_address(host, forward_host=None):
	"""
	Guess the IP address of the SMTP server that will be connected to given the
	SMTP host information and an optional SSH forwarding host. If a hostname is
	in use it will be resolved to an IP address, either IPv4 or IPv6 and in that
	order. If a hostname resolves to multiple IP addresses, None will be
	returned. This function is intended to guess the SMTP servers IP address
	given the client configuration so it can be used for SPF record checks.

	:param str host: The SMTP server that is being connected to.
	:param str forward_host: An optional host that is being used to tunnel the connection.
	:return: The IP address of the SMTP server.
	:rtype: None, :py:class:`ipaddress.IPv4Address`, :py:class:`ipaddress.IPv6Address`
	"""
	host = host.rsplit(':', 1)[0]
	if ipaddress.is_valid(host):
		ip = ipaddress.ip_address(host)
		if not ip.is_loopback:
			return ip
	else:
		info = None
		for family in (socket.AF_INET, socket.AF_INET6):
			try:
				info = socket.getaddrinfo(host, 1, family)
			except socket.gaierror:
				continue
			info = set(list([r[4][0] for r in info]))
			if len(info) != 1:
				return
			break
		if info:
			ip = ipaddress.ip_address(info.pop())
			if not ip.is_loopback:
				return ip
	if forward_host:
		return guess_smtp_server_address(forward_host)
	return

def render_message_template(template, config, target=None, analyze=False):
	"""
	Take a message from a template and format it to be sent by replacing
	variables and processing other template directives. If the *target*
	parameter is not set, a placeholder will be created and the message will be
	formatted to be previewed.

	:param str template: The message template.
	:param dict config: The King Phisher client configuration.
	:param target: The messages intended target information.
	:type target: :py:class:`.MessageTarget`
	:param bool analyze: Set the template environment to analyze mode.
	:return: The formatted message.
	:rtype: str
	"""
	if target is None:
		target = MessageTargetPlaceholder(uid=config['server_config'].get('server.secret_id'))
		template_environment.set_mode(template_environment.MODE_PREVIEW)

	if analyze:
		template_environment.set_mode(template_environment.MODE_ANALYZE)

	template = template_environment.from_string(template)
	template_vars = {}
	template_vars['campaign'] = dict(
		id=str(config['campaign_id']),
		name=config['campaign_name']
	)
	template_vars['client'] = dict(
		first_name=target.first_name,
		last_name=target.last_name,
		email_address=target.email_address,
		department=target.department,
		company_name=config.get('mailer.company_name'),
		message_id=target.uid
	)

	template_vars['sender'] = dict(
		email=config.get('mailer.source_email'),
		friendly_alias=config.get('mailer.source_email_alias'),
		reply_to=config.get('mailer.reply_to_email')
	)
	template_vars['uid'] = target.uid

	message_type = config.get('mailer.message_type', 'email')
	template_vars['message_type'] = message_type
	if message_type == 'calendar_invite':
		template_vars['calendar_invite'] = dict(
			all_day=config.get('mailer.calendar_invite_all_day'),
			location=config.get('mailer.calendar_invite_location'),
			start=get_invite_start_from_config(config),
			summary=config.get('mailer.calendar_invite_summary')
		)

	template_vars['message'] = dict(
		attachment=config.get('mailer.attachment_file'),
		importance=config.get('mailer.importance'),
		recipient=dict(
			field=config.get('mailer.target_field', 'to'),
			to=(target.email_address if config.get('mailer.target_field') == 'to' else config.get('mailer.recipient_email_to', '')),
			cc=(target.email_address if config.get('mailer.target_field') == 'cc' else config.get('mailer.recipient_email_cc', '')),
			bcc=(target.email_address if config.get('mailer.target_field') == 'bcc' else '')
		),
		sensitivity=config.get('mailer.sensitivity'),
		subject=config.get('mailer.subject'),
		template=config.get('mailer.html_file'),
		type=message_type
	)

	webserver_url = config.get('mailer.webserver_url', '')
	webserver_url = urllib.parse.urlparse(webserver_url)
	tracking_image = config['server_config']['server.tracking_image']
	template_vars['webserver'] = webserver_url.netloc
	tracking_url = urllib.parse.urlunparse((webserver_url.scheme, webserver_url.netloc, tracking_image, '', 'id=' + target.uid, ''))
	webserver_url = urllib.parse.urlunparse((webserver_url.scheme, webserver_url.netloc, webserver_url.path, '', '', ''))
	template_vars['tracking_dot_image_tag'] = "<img src=\"{0}\" style=\"display:none\" />".format(tracking_url)

	template_vars_url = {}
	template_vars_url['rickroll'] = 'http://www.youtube.com/watch?v=oHg5SJYRHA0'
	template_vars_url['webserver'] = webserver_url + '?id=' + target.uid
	template_vars_url['webserver_raw'] = webserver_url
	template_vars_url['tracking_dot'] = tracking_url
	template_vars['url'] = template_vars_url
	template_vars.update(template_environment.standard_variables)
	return template.render(template_vars)

def rfc2282_timestamp(dt=None, utc=False):
	"""
	Convert a :py:class:`datetime.datetime` instance into an :rfc:`2282`
	compliant timestamp suitable for use in MIME-encoded messages.

	:param dt: A time to use for the timestamp otherwise the current time is used.
	:type dt: :py:class:`datetime.datetime`
	:param utc: Whether to return the timestamp as a UTC offset or from the local timezone.
	:return: The timestamp.
	:rtype: str
	"""
	dt = dt or datetime.datetime.utcnow()
	# email.utils.formatdate wants the time to be in the local timezone
	dt = utilities.datetime_utc_to_local(dt)
	return email.utils.formatdate(time.mktime(dt.timetuple()), not utc)

class MessageTarget(object):
	"""
	A simple class for holding information regarding a messages intended
	recipient.
	"""
	required_fields = ('first_name', 'last_name', 'email_address')
	__slots__ = 'department', 'email_address', 'first_name', 'last_name', 'line', 'uid'
	def __init__(self, first_name, last_name, email_address, uid=None, department=None, line=None):
		self.first_name = first_name
		"""The target recipient's first name."""
		self.last_name = last_name
		"""The target recipient's last name."""
		self.email_address = utilities.nonempty_string(email_address)
		"""The target recipient's email address."""
		self.uid = uid
		"""The unique identifier that is going to be used for this target."""
		if self.uid is None:
			self.uid = utilities.make_message_uid()
		self.department = utilities.nonempty_string(department)
		"""The target recipient's department name."""
		self.line = line
		"""The line number in the file from which this target was loaded."""

	def __repr__(self):
		return "<{0} first_name={1!r} last_name={2!r} email_address={3!r} >".format(self.__class__.__name__, self.first_name, self.last_name, self.email_address)

	@property
	def missing_fields(self):
		return tuple(field for field in self.required_fields if getattr(self, field) is None)

class MessageTargetPlaceholder(MessageTarget):
	"""
	A default :py:class:`~.MessageTarget` for use as a placeholder value while
	rendering, performing tests, etc.
	"""
	def __init__(self, uid=None):
		super(MessageTargetPlaceholder, self).__init__('Alice', 'Liddle', 'aliddle@wonderland.com', uid=uid, department='Visitors')

class TopMIMEMultipart(mime.multipart.MIMEMultipart):
	"""
	A :py:class:`.mime.multipart.MIMEMultipart` subclass for representing the
	top / outer most part of a MIME multipart message. This adds additional
	default headers to the message.
	"""
	def __init__(self, mime_type, config, target):
		"""
		:param str mime_type: The type of this part such as related or alternative.
		:param dict config: The King Phisher client configuration.
		:param target: The target information for the messages intended recipient.
		:type target: :py:class:`.MessageTarget`
		"""
		mime.multipart.MIMEMultipart.__init__(self, mime_type, charset='utf-8')
		self['Subject'] = render_message_template(config['mailer.subject'], config, target)
		if config.get('mailer.reply_to_email'):
			self.add_header('reply-to', config['mailer.reply_to_email'])
		if config.get('mailer.source_email_alias'):
			self['From'] = "\"{0}\" <{1}>".format(config['mailer.source_email_alias'], config['mailer.source_email'])
		else:
			self['From'] = config['mailer.source_email']
		self['Date'] = rfc2282_timestamp()
		self.preamble = 'This is a multi-part message in MIME format.'

class MIMEText(mime.text.MIMEText):
	def __init__(self, text, subtype, charset='utf-8'):
		super(MIMEText, self).__init__(text, subtype, charset)

	@property
	def payload_string(self):
		return self.get_payload_string()

	@payload_string.setter
	def payload_string(self, text):
		self.set_payload_string(text)

	def get_payload_string(self):
		payload = self.get_payload(decode=True)
		if payload:
			charset = self.get_charset()
			payload = payload.decode(charset.input_charset)
		return payload

	def set_payload_string(self, payload, charset=None):
		if 'Content-Transfer-Encoding' in self:
			del self['Content-Transfer-Encoding']
		return self.set_payload(payload, charset=charset or self.get_charset())

class MailSenderThread(threading.Thread):
	"""
	The King Phisher threaded email message sender. This object manages
	the sending of emails for campaigns and supports pausing the sending of
	messages which can later be resumed by unpausing. This object reports
	its information to the GUI through an optional
	:py:class:`.MailSenderSendTab` instance, these two objects
	are very interdependent.
	"""
	def __init__(self, application, target_file, rpc, tab=None):
		"""
		:param application: The GTK application that the thread is associated with.
		:type application: :py:class:`.KingPhisherClientApplication`
		:param str target_file: The CSV formatted file to read message targets from.
		:param tab: The GUI tab to report information to.
		:type tab: :py:class:`.MailSenderSendTab`
		:param rpc: The client's connected RPC instance.
		:type rpc: :py:class:`.KingPhisherRPCClient`
		"""
		super(MailSenderThread, self).__init__()
		self.daemon = True
		self.logger = logging.getLogger('KingPhisher.Client.' + self.__class__.__name__)
		self.application = application
		self.config = self.application.config
		self.target_file = target_file
		"""The name of the target file in CSV format."""
		self.tab = tab
		"""The optional :py:class:`.MailSenderSendTab` instance for reporting status messages to the GUI."""
		self.rpc = rpc
		self._ssh_forwarder = None
		self.smtp_connection = None
		"""The :py:class:`smtplib.SMTP` connection instance."""
		self.smtp_server = smoke_zephyr.utilities.parse_server(self.config['smtp_server'], 25)
		self.running = threading.Event()
		"""A :py:class:`threading.Event` object indicating if emails are being sent."""
		self.paused = threading.Event()
		"""A :py:class:`threading.Event` object indicating if the email sending operation is or should be paused."""
		self.should_stop = threading.Event()
		self.max_messages_per_minute = float(self.config.get('smtp_max_send_rate', 0.0))
		self.mail_options = []

	def tab_notify_sent(self, emails_done, emails_total):
		"""
		Notify the tab that messages have been sent.

		:param int emails_done: The number of emails that have been sent.
		:param int emails_total: The total number of emails that are going to be sent.
		"""
		if isinstance(self.tab, gui_utilities.GladeGObject):
			GLib.idle_add(lambda x: self.tab.notify_sent(*x), (emails_done, emails_total))

	def tab_notify_status(self, message):
		"""
		Handle a status message regarding the message sending operation.

		:param str message: The notification message.
		"""
		self.logger.info(message.lower())
		if isinstance(self.tab, gui_utilities.GladeGObject):
			GLib.idle_add(self.tab.notify_status, message + '\n')

	def tab_notify_stopped(self):
		"""
		Notify the tab that the message sending operation has stopped.
		"""
		if isinstance(self.tab, gui_utilities.GladeGObject):
			GLib.idle_add(self.tab.notify_stopped)

	def server_ssh_connect(self):
		"""
		Connect to the remote SMTP server over SSH and configure port forwarding
		with :py:class:`.SSHTCPForwarder` for tunneling SMTP traffic.

		:return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants.
		"""
		server = smoke_zephyr.utilities.parse_server(self.config['ssh_server'], 22)
		username = self.config['ssh_username']
		password = self.config['ssh_password']
		remote_server = smoke_zephyr.utilities.parse_server(self.config['smtp_server'], 25)
		try:
			self._ssh_forwarder = SSHTCPForwarder(
				server,
				username,
				password,
				remote_server,
				private_key=self.config.get('ssh_preferred_key'),
				missing_host_key_policy=ssh_host_key.MissingHostKeyPolicy(self.application)
			)
			self._ssh_forwarder.start()
		except errors.KingPhisherAbortError as error:
			self.logger.info("ssh connection aborted ({0})".format(error.message))
		except paramiko.AuthenticationException:
			self.logger.warning('failed to authenticate to the remote ssh server')
			return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED
		except paramiko.SSHException as error:
			self.logger.warning("failed with: {0!r}".format(error))
		except socket.timeout:
			self.logger.warning('the connection to the ssh server timed out')
		except Exception:
			self.logger.warning('failed to connect to the remote ssh server', exc_info=True)
		else:
			self.smtp_server = self._ssh_forwarder.local_server
			return ConnectionErrorReason.SUCCESS
		return ConnectionErrorReason.ERROR_UNKNOWN

	def server_smtp_connect(self):
		"""
		Connect and optionally authenticate to the configured SMTP server.

		:return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants.
		"""
		if self.config.get('smtp_ssl_enable', False):
			SmtpClass = smtplib.SMTP_SSL
		else:
			SmtpClass = smtplib.SMTP
		self.logger.debug('opening a new connection to the SMTP server')
		try:
			self.smtp_connection = SmtpClass(*self.smtp_server, timeout=15)
			self.smtp_connection.ehlo()
		except smtplib.SMTPException:
			self.logger.warning('received an SMTPException while connecting to the SMTP server', exc_info=True)
			return ConnectionErrorReason.ERROR_UNKNOWN
		except socket.error:
			self.logger.warning('received a socket.error while connecting to the SMTP server')
			return ConnectionErrorReason.ERROR_CONNECTION

		if not self.config.get('smtp_ssl_enable', False) and 'starttls' in self.smtp_connection.esmtp_features:
			self.logger.debug('target SMTP server supports the STARTTLS extension')
			try:
				self.smtp_connection.starttls()
				self.smtp_connection.ehlo()
			except smtplib.SMTPException:
				self.logger.warning('received an SMTPException while negotiating STARTTLS with the SMTP server', exc_info=True)
				return ConnectionErrorReason.ERROR_UNKNOWN
			except socket.error:
				self.logger.warning('received a socket.error while negotiating STARTTLS with the SMTP server')
				return ConnectionErrorReason.ERROR_CONNECTION

		username = self.config.get('smtp_username', '')
		if username:
			password = self.config.get('smtp_password', '')
			try:
				self.smtp_connection.login(username, password)
			except smtplib.SMTPException as error:
				self.logger.warning('received an {0} while authenticating to the SMTP server'.format(error.__class__.__name__))
				self.smtp_connection.quit()
				return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED

		if self.smtp_connection.has_extn('SMTPUTF8'):
			self.logger.debug('target SMTP server supports the SMTPUTF8 extension')
			self.mail_options.append('SMTPUTF8')
		return ConnectionErrorReason.SUCCESS

	def server_smtp_disconnect(self):
		"""Clean up and close the connection to the remote SMTP server."""
		if self.smtp_connection:
			self.logger.debug('closing the connection to the SMTP server')
			try:
				self.smtp_connection.quit()
			except smtplib.SMTPServerDisconnected:
				pass
			self.smtp_connection = None
			self.tab_notify_status('Disconnected from the SMTP server')

	def server_smtp_reconnect(self):
		"""
		Disconnect from the remote SMTP server and then attempt to open
		a new connection to it.

		:return: The reconnection status.
		:rtype: bool
		"""
		if self.smtp_connection:
			try:
				self.smtp_connection.quit()
			except smtplib.SMTPServerDisconnected:
				pass