From 66f7b679ff2818e7898a887b4c02c7d04c8cdc8f Mon Sep 17 00:00:00 2001 From: Frank Brehm Date: Mon, 15 Apr 2024 12:45:47 +0200 Subject: [PATCH] Splitting lib/pp_admintools/postfix_chain.py in different modules. --- lib/pp_admintools/common.py | 154 ++++ lib/pp_admintools/postfix_chain.py | 820 +------------------- lib/pp_admintools/postfix_data_pair.py | 98 +++ lib/pp_admintools/postfix_deliver_action.py | 684 ++++++++++++++++ 4 files changed, 940 insertions(+), 816 deletions(-) create mode 100644 lib/pp_admintools/common.py create mode 100644 lib/pp_admintools/postfix_data_pair.py create mode 100644 lib/pp_admintools/postfix_deliver_action.py diff --git a/lib/pp_admintools/common.py b/lib/pp_admintools/common.py new file mode 100644 index 0000000..5cc84c3 --- /dev/null +++ b/lib/pp_admintools/common.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@summary: The module for common used functions and global variables. + +@author: Frank Brehm +@contact: frank.brehm@pixelpark.com +@copyright: © 2024 by Frank Brehm, Berlin +""" +from __future__ import absolute_import + +# Standard modules +import datetime +import logging +import re + +# Third party modules +from fb_tools.common import timeinterval2delta + +# Own modules +from . import DEFAULT_TZ_OFFSET +from .errors import DateFormatError +from .errors import WrongDateIsoformatError +from .xlate import XLATOR + +HAS_GET_LOCALZONE = False +try: + from .tzlocal import get_localzone + HAS_GET_LOCALZONE = True +except ImportError: + pass + +_ = XLATOR.gettext +ngettext = XLATOR.ngettext + +__version__ = '0.1.0' + +LOG = logging.getLogger(__name__) + +UTC = utc = datetime.timezone(datetime.timedelta(), 'UTC') + +PATTERN_ISODATE = r'(?P\d{4})-?(?P[01]\d)-?(?P[0-3]\d)' +PATTERN_ISOTIME = ( + r'(?P[0-2]\d):?(?P[0-5]\d)(?::?(?P[0-5]\d)(\.(?P\d+))?)?') +PATTERN_ISOTIMEZONE = r'((?PZ)|(?P[+-][01]\d)(?::?(?P[0-5]\d))?)?' + +RE_ISODATETIME = re.compile(PATTERN_ISODATE + r'[T\s]' + PATTERN_ISOTIME + PATTERN_ISOTIMEZONE) + +PATTERN_DEFAULT_TZ = r'^\s*(?P[+-])\s*(?P.*)' +RE_DEFAULT_TZ = re.compile(PATTERN_DEFAULT_TZ) + +RE_TZ = re.compile(r'^\s*(?P[01]\d)(?::?(?P[0-5]\d))?') + +DEFAULT_TZ = UTC + +# ============================================================================= +def get_default_tz_from_offset(offset=None): + """Return the given offset in seconds as a datetime.timezone object.""" + if offset is None: + offset = DEFAULT_TZ_OFFSET + + default_tz = datetime.timedelta() + + if not offset: + return default_tz + + if isinstance(offset, (int, float)): + return datetime.timezone(datetime.timedelta(seconds=float(offset))) + + offset = str(offset) + omatch = RE_DEFAULT_TZ.match(offset) + if not omatch: + msg = _('Could not interprete {!r} as a timezone offset.').format(offset) + raise ValueError(msg) + + sign = omatch['sign'] + offset_abs = omatch['offset'] + + tz_match = RE_TZ.match(offset_abs) + if tz_match: + res_offset = 0 + if tz_match['tz_mins']: + res_offset = int(tz_match['tz_mins']) * 60 + res_offset += int(tz_match['tz_hours']) * 3600 + if sign == '-': + res_offset *= -1 + return datetime.timezone(datetime.timedelta(seconds=res_offset)) + + delta = timeinterval2delta(offset_abs) + if sign == '-': + delta *= -1 + return datetime.timezone(delta) + + +# ============================================================================= +DEFAULT_TZ = get_default_tz_from_offset(DEFAULT_TZ_OFFSET) + + +# ============================================================================= +def fromisoformat(datestr): + """Try to convert a string with an ISO-formatted timestamp into a datetime object.""" + if hasattr(datetime.datetime, 'fromisoformat'): + try: + ret = datetime.datetime.fromisoformat(datestr) + return ret + except ValueError as e: + raise DateFormatError(str(e)) + + match_obj = RE_ISODATETIME.search(datestr) + if not match_obj: + raise WrongDateIsoformatError(datestr) + + params = { + 'year': int(match_obj['year']), + 'month': int(match_obj['month']), + 'day': int(match_obj['day']), + 'minute': int(match_obj['min']), + } + if match_obj['sec'] is not None: + params['second'] = int(match_obj['sec']) + + if match_obj['nsec'] is not None: + params['microsecond'] = int(round(float('0.' + match_obj['nsec']) * 1000000)) + + if match_obj['utc']: + params['tzinfo'] = UTC + elif match_obj['tz_hours'] is not None: + prefix = match_obj['tz_hours'][0] + offset = 0 + if match_obj['tz_mins'] is not None: + offset = int(match_obj['tz_mins']) * 60 + offset += int(match_obj['tz_hours'][1:]) * 3600 + if prefix == '-': + offset *= -1 + + params['tzinfo'] = datetime.timezone(datetime.timedelta(seconds=offset)) + else: + if HAS_GET_LOCALZONE: + params['tzinfo'] = get_localzone() + else: + params['tzinfo'] = DEFAULT_TZ + + return datetime.datetime(**params) + + +# ============================================================================= + +if __name__ == '__main__': + + pass + +# ============================================================================= + +# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 list diff --git a/lib/pp_admintools/postfix_chain.py b/lib/pp_admintools/postfix_chain.py index 8adee52..26e9dc9 100644 --- a/lib/pp_admintools/postfix_chain.py +++ b/lib/pp_admintools/postfix_chain.py @@ -13,842 +13,30 @@ import copy import datetime import ipaddress import logging -import re from collections.abc import Sequence # Third party modules from fb_tools.common import is_sequence from fb_tools.common import pp -from fb_tools.common import timeinterval2delta from fb_tools.common import to_bool from fb_tools.errors import InvalidMailAddressError from fb_tools.mailaddress import MailAddress from fb_tools.obj import FbGenericBaseObject # Own modules -from . import DEFAULT_TZ_OFFSET +from .common import fromisoformat from .errors import DateFormatError -from .errors import WrongDateIsoformatError +from .postfix_data_pair import DataPair +from .postfix_deliver_action import DeliverAction from .xlate import XLATOR -HAS_GET_LOCALZONE = False -try: - from .tzlocal import get_localzone - HAS_GET_LOCALZONE = True -except ImportError: - pass - _ = XLATOR.gettext ngettext = XLATOR.ngettext -__version__ = '0.9.0' +__version__ = '0.10.0' LOG = logging.getLogger(__name__) -UTC = utc = datetime.timezone(datetime.timedelta(), 'UTC') - -PATTERN_ISODATE = r'(?P\d{4})-?(?P[01]\d)-?(?P[0-3]\d)' -PATTERN_ISOTIME = ( - r'(?P[0-2]\d):?(?P[0-5]\d)(?::?(?P[0-5]\d)(\.(?P\d+))?)?') -PATTERN_ISOTIMEZONE = r'((?PZ)|(?P[+-][01]\d)(?::?(?P[0-5]\d))?)?' - -RE_ISODATETIME = re.compile(PATTERN_ISODATE + r'[T\s]' + PATTERN_ISOTIME + PATTERN_ISOTIMEZONE) - -PATTERN_DEFAULT_TZ = r'^\s*(?P[+-])\s*(?P.*)' -RE_DEFAULT_TZ = re.compile(PATTERN_DEFAULT_TZ) - -RE_TZ = re.compile(r'^\s*(?P[01]\d)(?::?(?P[0-5]\d))?') - -DEFAULT_TZ = UTC - -# ============================================================================= -def get_default_tz_from_offset(offset=None): - """Return the given offset in seconds as a datetime.timezone object.""" - if offset is None: - offset = DEFAULT_TZ_OFFSET - - default_tz = datetime.timedelta() - - if not offset: - return default_tz - - if isinstance(offset, (int, float)): - return datetime.timezone(datetime.timedelta(seconds=float(offset))) - - offset = str(offset) - omatch = RE_DEFAULT_TZ.match(offset) - if not omatch: - msg = _('Could not interprete {!r} as a timezone offset.').format(offset) - raise ValueError(msg) - - sign = omatch['sign'] - offset_abs = omatch['offset'] - - tz_match = RE_TZ.match(offset_abs) - if tz_match: - res_offset = 0 - if tz_match['tz_mins']: - res_offset = int(tz_match['tz_mins']) * 60 - res_offset += int(tz_match['tz_hours']) * 3600 - if sign == '-': - res_offset *= -1 - return datetime.timezone(datetime.timedelta(seconds=res_offset)) - - delta = timeinterval2delta(offset_abs) - if sign == '-': - delta *= -1 - return datetime.timezone(delta) - - -# ============================================================================= -DEFAULT_TZ = get_default_tz_from_offset(DEFAULT_TZ_OFFSET) - - -# ============================================================================= -def fromisoformat(datestr): - """Try to convert a string with an ISO-formatted timestamp into a datetime object.""" - if hasattr(datetime.datetime, 'fromisoformat'): - try: - ret = datetime.datetime.fromisoformat(datestr) - return ret - except ValueError as e: - raise DateFormatError(str(e)) - - match_obj = RE_ISODATETIME.search(datestr) - if not match_obj: - raise WrongDateIsoformatError(datestr) - - params = { - 'year': int(match_obj['year']), - 'month': int(match_obj['month']), - 'day': int(match_obj['day']), - 'minute': int(match_obj['min']), - } - if match_obj['sec'] is not None: - params['second'] = int(match_obj['sec']) - - if match_obj['nsec'] is not None: - params['microsecond'] = int(round(float('0.' + match_obj['nsec']) * 1000000)) - - if match_obj['utc']: - params['tzinfo'] = UTC - elif match_obj['tz_hours'] is not None: - prefix = match_obj['tz_hours'][0] - offset = 0 - if match_obj['tz_mins'] is not None: - offset = int(match_obj['tz_mins']) * 60 - offset += int(match_obj['tz_hours'][1:]) * 3600 - if prefix == '-': - offset *= -1 - - params['tzinfo'] = datetime.timezone(datetime.timedelta(seconds=offset)) - else: - if HAS_GET_LOCALZONE: - params['tzinfo'] = get_localzone() - else: - params['tzinfo'] = DEFAULT_TZ - - return datetime.datetime(**params) - - -# ============================================================================= -class DataPair(object): - """Encapsulates a pair of to integer values in the form of 'value of total'.""" - - re_data = re.compile(r'^\s*(?P\d+)(?:\s*/\s*(?P\d+))?\s*$') - - # ------------------------------------------------------------------------- - def __init__(self, value, total=None): - """Initialize this object.""" - self.value = int(value) - if self.value < 0: - msg = _( - 'The first value {v!r} of {c} must be greater than or equal to null.').format( - v=value, c=self.__class__.__name__) - raise ValueError(msg) - - self.total = None - if total is not None: - self.total = int(total) - if self.total < self.value: - msg = _( - 'The total value {t!r} must be greater than or equal to the ' - 'value {v}.').format(t=total, v=self.value) - raise ValueError(msg) - - # ------------------------------------------------------------------------- - @classmethod - def from_str(cls, value): - """Convert a string of format '2' or '0/2' into a DataPair object.""" - m = cls.re_data.match(value) - if not m: - msg = _('Invalid value {v!r} of a {c}.').format(v=value, c=__class__.__name__) - raise ValueError(msg) - pair = cls(value=m['val'], total=m['total']) - return pair - - # ------------------------------------------------------------------------- - def __str__(self): - """Typecast into a string object.""" - if self.total is None: - return str(self.value) - return '{}/{}'.format(self.value, self.total) - - # ------------------------------------------------------------------------- - def __repr__(self): - """Typecast into a string for reproduction.""" - out = '<%s(' % (self.__class__.__name__) - - fields = [] - fields.append('value={!r}'.format(self.value)) - if self.total is not None: - fields.append('total={!r}'.format(self.total)) - - out += ', '.join(fields) + ')>' - return out - - # ------------------------------------------------------------------------- - def __copy__(self): - """Copy the current data pair into a new object.""" - return self.__class__(self.value, total=self.total) - - -# ============================================================================== -class DeliverAction(FbGenericBaseObject): - """A class encapsulating the logged action of a Postix deliverer.""" - - warn_on_parse_error = False - - attributes = ( - 'command', 'to_address', 'origin_to', 'pid', 'date', 'relay', 'delay_total', - 'time_before_queue', 'time_in_queue', 'time_conn_setup', 'time_xmission', - 'dsn', 'status', 'message', 'remote_id', - ) - - re_to_address = re.compile(r'\bto=<(?P[^>]*)>(?:,\s+)', re.IGNORECASE) - re_orig_to_address = re.compile(r'orig_to=<(?P[^>]*)>(?:,\s+)', re.IGNORECASE) - re_relay = re.compile(r'relay=(?P\S+)(?:,\s)', re.IGNORECASE) - re_delay = re.compile(r'delay=(?P\d+(?:\.\d*)?)(?:,\s+)', re.IGNORECASE) - - pat_delays = r'delays=' - pat_delays += r'(?P\d+(?:\.\d*)?)/(?P\d+(?:\.\d*)?)' - pat_delays += r'/(?P\d+(?:\.\d*)?)/(?P\d+(?:\.\d*)?)' - pat_delays += r'(?:,\s+)' - re_delays = re.compile(pat_delays, re.IGNORECASE) - - re_dsn = re.compile(r'dsn=(?P\S+)(?:,\s+)', re.IGNORECASE) - re_status = re.compile(r'status=(?P\S+)\s+', re.IGNORECASE) - - pat_remote_id = r'(?:queued\s+as\s+(?P[0-9a-f]+))' - pat_remote_id += r'|(?:InternalId=(?P\d+))' - pat_remote_id += r'|(?:\sid=(?P\S+))' - pat_remote_id += r'|(?:Message\s+(?P[0-9a-f]+)\s+accepted)' - pat_remote_id += r'|(?:\s(?P\S+)\s+(?:Message|mail)\s+accepted)' - pat_remote_id += r'|(?:\sOK\s+\d+\s+(?PS+)\s+-\s+gsmtp)' - pat_remote_id += r'|(?:\sok\s+\((?P\S+)\))' - pat_remote_id += r'|(?:\sok\s+(?P\S+))' - - re_remote_id = re.compile(pat_remote_id, re.IGNORECASE) - - re_relay_address = re.compile(r'\[(?P[^\]]+)\]') - - # ------------------------------------------------------------------------- - def __init__(self, **kwargs): - """Initialize this object.""" - for attr in self.attributes: - priv_name = '_' + attr - setattr(self, priv_name, None) - - for attr in kwargs.keys(): - if attr not in self.attributes: - msg = _('Unknown parameter {p!r} on calling {c}.__init__().').format( - p=attr, c=self.__class__.__name__) - raise AttributeError(msg) - setattr(self, attr, kwargs[attr]) - - # ----------------------------------------------------------- - @property - def command(self): - """Return the command to deliver the mail.""" - return self._command - - @command.setter - def command(self, value): - if value is None: - self._command = None - return - - val = str(value).strip() - if val == '': - self._command = None - return - self._command = val - - # ----------------------------------------------------------- - @property - def date(self): - """Return the timestamp of the delivering action.""" - return self._date - - @date.setter - def date(self, value): - if value is None: - self._date = None - return - - if isinstance(value, datetime.datetime): - self._date = value - return - - val = str(value).strip() - if val == '': - self._date = None - return - - try: - date = fromisoformat(val) - except DateFormatError as e: - msg = _('Could not interprete date {!r}:').format(val) + ' ' + str(e) - if self.warn_on_parse_error: - LOG.warn(msg) - else: - LOG.debug(msg) - date = None - if date: - self._date = date - return - self._date = val - - # ---------------------------------------------------------- - @property - def delay_total(self): - """Return the total delay of the sended mail ion the current postfix.""" - return self._delay_total - - @delay_total.setter - def delay_total(self, value): - if value is None: - self._delay_total = None - return - - if isinstance(value, (float, int)): - self._delay_total = float(value) - return - val = str(value).strip() - if val == '': - self._delay_total = None - return - - try: - self._delay_total = float(val) - except ValueError as e: - msg = _('Could not interprete total delay {a!r}: {e}').format(a=value, e=e) - if self.warn_on_parse_error: - LOG.warn(msg) - else: - LOG.debug(msg) - self._delay_total = val - - # ----------------------------------------------------------- - @property - def dsn(self): - """Return the Delivery Status Notification (DSN) information.""" - return self._dsn - - @dsn.setter - def dsn(self, value): - if value is None: - self._dsn = None - return - - val = str(value).strip() - if val == '': - self._dsn = None - return - self._dsn = val - - # ----------------------------------------------------------- - @property - def message(self): - """Return the verbose message of the delivering transaction.""" - return self._message - - @message.setter - def message(self, value): - if value is None: - self._message = None - return - - val = str(value).strip() - if val == '': - self._message = None - return - self._message = val - - # ----------------------------------------------------------- - @property - def origin_to(self): - """Return the original RCPT TO address in the delivering dialogue envelope.""" - return self._origin_to - - @origin_to.setter - def origin_to(self, value): - if value is None: - self._origin_to = None - return - - if isinstance(value, MailAddress): - self._origin_to = copy.copy(value) - return - val = str(value).strip() - if val == '': - self._origin_to = None - return - - try: - self._origin_to = MailAddress(val) - except InvalidMailAddressError as e: - msg = _('Could not interprete to address {a!r}: {e}').format(a=val, e=e) - if self.warn_on_parse_error: - LOG.warn(msg) - else: - LOG.debug(msg) - self._origin_to = val - - # ----------------------------------------------------------- - @property - def relay(self): - """Return the socket address of the receiving relay MTA.""" - return self._relay - - @relay.setter - def relay(self, value): - if value is None: - self._relay = None - return - - val = str(value).strip() - if val == '': - self._relay = None - return - self._relay = val - - # ----------------------------------------------------------- - @property - def relay_address(self): - """Return the IP address of an existing relay for SMTP.""" - if not self.relay: - return None - - m = self.re_relay_address.search(self.relay) - if not m: - # Did not found IP address in SMTP relay. - return None - - try: - addr = ipaddress.ip_address(m['addr']) - except ValueError as e: - msg = _('Could not interprete relay address {a!r}: {e}').format(a=m['addr'], e=e) - if self.warn_on_parse_error: - LOG.warn(msg) - else: - LOG.debug(msg) - return None - - return addr - - # ----------------------------------------------------------- - @property - def remote_id(self): - """Return the the Mail ID of the remote (receiving) MTA.""" - return self._remote_id - - @remote_id.setter - def remote_id(self, value): - if value is None: - self._remote_id = None - return - - val = str(value).strip() - if val == '': - self._remote_id = None - return - self._remote_id = val - - # ---------------------------------------------------------- - @property - def pid(self): - """Return the process ID (PID) of the sending delivering process.""" - return self._pid - - @pid.setter - def pid(self, value): - if value is None: - self._pid = None - return - - if isinstance(value, (float, int)): - self._pid = int(value) - return - val = str(value).strip() - if val == '': - self._pid = None - return - - try: - self._pid = int(val) - except ValueError as e: - msg = _('Could not interprete PID of deliverer {a!r}: {e}').format(a=value, e=e) - if self.warn_on_parse_error: - LOG.warn(msg) - else: - LOG.debug(msg) - self._pid = val - - # ----------------------------------------------------------- - @property - def status(self): - """Return the final status of the deliverer transaction.""" - return self._status - - @status.setter - def status(self, value): - if value is None: - self._status = None - return - - val = str(value).strip() - if val == '': - self._status = None - return - self._status = val - - # ---------------------------------------------------------- - @property - def time_before_queue(self): - """Return the used time before the mail was queued.""" - return self._time_before_queue - - @time_before_queue.setter - def time_before_queue(self, value): - if value is None: - self._time_before_queue = None - return - - if isinstance(value, (float, int)): - self._time_before_queue = float(value) - return - val = str(value).strip() - if val == '': - self._time_before_queue = None - return - - try: - self._time_before_queue = float(val) - except ValueError as e: - msg = _('Could not interprete time before queueing {a!r}: {e}').format(a=value, e=e) - if self.warn_on_parse_error: - LOG.warn(msg) - else: - LOG.debug(msg) - self._time_before_queue = val - - # ---------------------------------------------------------- - @property - def time_conn_setup(self): - """ - Return the time the deliverer process needed to establish a SMTP connection. - - This is including DNS, HELO and TLS. - """ - return self._time_conn_setup - - @time_conn_setup.setter - def time_conn_setup(self, value): - if value is None: - self._time_conn_setup = None - return - - if isinstance(value, (float, int)): - self._time_conn_setup = float(value) - return - val = str(value).strip() - if val == '': - self._time_conn_setup = None - return - - try: - self._time_conn_setup = float(val) - except ValueError as e: - msg = _('Could not interprete smtp connection setup time {a!r}: {e}').format( - a=value, e=e) - if self.warn_on_parse_error: - LOG.warn(msg) - else: - LOG.debug(msg) - self._time_conn_setup = val - - # ---------------------------------------------------------- - @property - def time_in_queue(self): - """Return the time the mail was held in the queue.""" - return self._time_in_queue - - @time_in_queue.setter - def time_in_queue(self, value): - if value is None: - self._time_in_queue = None - return - - if isinstance(value, (float, int)): - self._time_in_queue = float(value) - return - val = str(value).strip() - if val == '': - self._time_in_queue = None - return - - try: - self._time_in_queue = float(val) - except ValueError as e: - msg = _('Could not interprete time in queue {a!r}: {e}').format(a=value, e=e) - if self.warn_on_parse_error: - LOG.warn(msg) - else: - LOG.debug(msg) - self._time_in_queue = val - - # ---------------------------------------------------------- - @property - def time_xmission(self): - """Return the time the smtp process needed to transmit the mail.""" - return self._time_xmission - - @time_xmission.setter - def time_xmission(self, value): - if value is None: - self._time_xmission = None - return - - if isinstance(value, (float, int)): - self._time_xmission = float(value) - return - val = str(value).strip() - if val == '': - self._time_xmission = None - return - - try: - self._time_xmission = float(val) - except ValueError as e: - msg = _('Could not interprete smtp transmission time {a!r}: {e}').format(a=value, e=e) - if self.warn_on_parse_error: - LOG.warn(msg) - else: - LOG.debug(msg) - self._time_xmission = val - - # ----------------------------------------------------------- - @property - def to_address(self): - """Return the RCPT TO address in the deliverer dialogue envelope.""" - return self._to_address - - @to_address.setter - def to_address(self, value): - if value is None: - self._to_address = None - return - - if isinstance(value, MailAddress): - self._to_address = copy.copy(value) - return - val = str(value).strip() - if val == '': - self._to_address = None - return - - try: - self._to_address = MailAddress(val) - except InvalidMailAddressError as e: - msg = _('Could not interprete to address {a!r}: {e}').format(a=val, e=e) - if self.warn_on_parse_error: - LOG.warn(msg) - else: - LOG.debug(msg) - self._to_address = val - - # ------------------------------------------------------------------------- - def __str__(self): - """Typecast into a string object. - - @return: structure as string - @rtype: str - """ - return pp(self.as_dict(exportable=True)) - - # ------------------------------------------------------------------------- - def __repr__(self): - """Typecast into a string for reproduction.""" - out = '<%s(' % (self.__class__.__name__) - - fields = [] - - attr_dict = self.as_dict(exportable=True) - for attr in attr_dict.keys(): - value = attr_dict[attr] - if value is None: - continue - fields.append(f'{attr}={value!r}') - - if fields: - out += ', '.join(fields) - - out += ')>' - return out - - # ------------------------------------------------------------------------- - def as_dict(self, short=True, exportable=False): - """ - Transform the elements of the object into a dict. - - @param short: don't include local properties in resulting dict. - @type short: bool - - @return: structure as dict - @rtype: dict - """ - if exportable: - res = {} - else: - res = super(DeliverAction, self).as_dict(short=short) - - for attrib in self.attributes: - if not hasattr(self, attrib): - continue - value = getattr(self, attrib, None) - if value is None: - res[attrib] = None - continue - if isinstance(value, ipaddress._BaseAddress): - res[attrib] = str(value) if exportable else value - continue - if isinstance(value, datetime.datetime): - res[attrib] = value.isoformat(' ') if exportable else value - continue - if isinstance(value, (DataPair, MailAddress)): - res[attrib] = str(value) if exportable else value - continue - - # Catch all - res[attrib] = value - - if not exportable: - res['relay_address'] = self.relay_address - - return res - - # ------------------------------------------------------------------------- - def __copy__(self): - """Copy the current chain data into a new object.""" - params = {} - - for attrib in self.attributes: - value = getattr(self, attrib, None) - if value is None: - continue - params[attrib] = copy.copy(value) - - return self.__class__(**params) - - # ------------------------------------------------------------------------- - @classmethod - def _get_remote_mailid(cls, message): - rmatch = cls.re_remote_id.search(message) - if not rmatch: - return None - - if rmatch['pf_id'] is not None: - return rmatch['pf_id'] - elif rmatch['jira_id'] is not None: - return rmatch['jira_id'] - elif rmatch['raw_id'] is not None: - return rmatch['raw_id'] - elif rmatch['msg1_id'] is not None: - return rmatch['msg1_id'] - elif rmatch['msg2_id'] is not None: - return rmatch['msg2_id'] - elif rmatch['gsmtp_id'] is not None: - return rmatch['gsmtp_id'] - elif rmatch['msg3_id'] is not None: - return rmatch['msg3_id'] - elif rmatch['msg4_id'] is not None: - return rmatch['msg4_id'] - - return None - - # ------------------------------------------------------------------------- - @classmethod - def from_log_entry(cls, timestamp, command, pid, message, verbose=0): - """Try to create a DeliverAction from the given Postfix log entry.""" - action = cls() - action.date = timestamp - action.command = command - action.pid = pid - cur_msg = message - - if verbose > 2: - LOG.debug(f'Parsing {command} delivering line: {message}') - - rmatch = cls.re_to_address.search(cur_msg) - if rmatch: - action.to_address = rmatch['to'] - cur_msg = cls.re_to_address.sub('', cur_msg) - - rmatch = cls.re_orig_to_address.search(cur_msg) - if rmatch: - action.origin_to = rmatch['orig'] - cur_msg = cls.re_orig_to_address.sub('', cur_msg) - - rmatch = cls.re_relay.search(cur_msg) - if rmatch: - action.relay = rmatch['relay'] - cur_msg = cls.re_relay.sub('', cur_msg) - - rmatch = cls.re_delay.search(cur_msg) - if rmatch: - action.delay_total = rmatch['delay'] - cur_msg = cls.re_delay.sub('', cur_msg) - - rmatch = cls.re_delays.search(cur_msg) - if rmatch: - action.time_before_queue = rmatch['p1'] - action.time_in_queue = rmatch['p2'] - action.time_conn_setup = rmatch['p3'] - action.time_xmission = rmatch['p4'] - cur_msg = cls.re_delays.sub('', cur_msg) - - rmatch = cls.re_dsn.search(cur_msg) - if rmatch: - action.dsn = rmatch['dsn'] - cur_msg = cls.re_dsn.sub('', cur_msg) - - rmatch = cls.re_status.search(cur_msg) - if rmatch: - action.status = rmatch['status'] - cur_msg = cls.re_status.sub('', cur_msg) - - cur_msg = cur_msg.strip() - if cur_msg: - if cur_msg.startswith('(') and cur_msg.endswith(')'): - cur_msg = cur_msg[1:-1] - if cur_msg: - action.message = cur_msg - remote_id = cls._get_remote_mailid(cur_msg) - if remote_id: - action.remote_id = remote_id - - return action # ============================================================================== class PostfixLogchainInfo(FbGenericBaseObject): diff --git a/lib/pp_admintools/postfix_data_pair.py b/lib/pp_admintools/postfix_data_pair.py new file mode 100644 index 0000000..4f8d5de --- /dev/null +++ b/lib/pp_admintools/postfix_data_pair.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +""" +@summary: Module for class DataPair, used in postfix loggings. + +@author: Frank Brehm +@contact: frank.brehm@pixelpark.com +@copyright: © 2024 by Frank Brehm, Berlin +""" +from __future__ import absolute_import + +# Standard modules +import logging +import re + +# Third party modules + +# Own modules +from .xlate import XLATOR + +_ = XLATOR.gettext +ngettext = XLATOR.ngettext + +__version__ = '0.1.0' + +LOG = logging.getLogger(__name__) + + +# ============================================================================= +class DataPair(object): + """Encapsulates a pair of to integer values in the form of 'value of total'.""" + + re_data = re.compile(r'^\s*(?P\d+)(?:\s*/\s*(?P\d+))?\s*$') + + # ------------------------------------------------------------------------- + def __init__(self, value, total=None): + """Initialize this object.""" + self.value = int(value) + if self.value < 0: + msg = _( + 'The first value {v!r} of {c} must be greater than or equal to null.').format( + v=value, c=self.__class__.__name__) + raise ValueError(msg) + + self.total = None + if total is not None: + self.total = int(total) + if self.total < self.value: + msg = _( + 'The total value {t!r} must be greater than or equal to the ' + 'value {v}.').format(t=total, v=self.value) + raise ValueError(msg) + + # ------------------------------------------------------------------------- + @classmethod + def from_str(cls, value): + """Convert a string of format '2' or '0/2' into a DataPair object.""" + m = cls.re_data.match(value) + if not m: + msg = _('Invalid value {v!r} of a {c}.').format(v=value, c=__class__.__name__) + raise ValueError(msg) + pair = cls(value=m['val'], total=m['total']) + return pair + + # ------------------------------------------------------------------------- + def __str__(self): + """Typecast into a string object.""" + if self.total is None: + return str(self.value) + return '{}/{}'.format(self.value, self.total) + + # ------------------------------------------------------------------------- + def __repr__(self): + """Typecast into a string for reproduction.""" + out = '<%s(' % (self.__class__.__name__) + + fields = [] + fields.append('value={!r}'.format(self.value)) + if self.total is not None: + fields.append('total={!r}'.format(self.total)) + + out += ', '.join(fields) + ')>' + return out + + # ------------------------------------------------------------------------- + def __copy__(self): + """Copy the current data pair into a new object.""" + return self.__class__(self.value, total=self.total) + + +# ==============# ============================================================================= + +if __name__ == '__main__': + + pass + +# ============================================================================= + +# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 list diff --git a/lib/pp_admintools/postfix_deliver_action.py b/lib/pp_admintools/postfix_deliver_action.py new file mode 100644 index 0000000..b1520f4 --- /dev/null +++ b/lib/pp_admintools/postfix_deliver_action.py @@ -0,0 +1,684 @@ +# -*- coding: utf-8 -*- +""" +@summary: A class for encapsulating a delivering action in a Postfix transaction chain. + +@author: Frank Brehm +@contact: frank.brehm@pixelpark.com +@copyright: © 2024 by Frank Brehm, Berlin +""" +from __future__ import absolute_import + +# Standard modules +import copy +import datetime +import ipaddress +import logging +import re + +# Third party modules +from fb_tools.common import pp +from fb_tools.errors import InvalidMailAddressError +from fb_tools.mailaddress import MailAddress +from fb_tools.obj import FbGenericBaseObject + +# Own modules +from .common import fromisoformat +from .errors import DateFormatError +from .postfix_data_pair import DataPair +from .xlate import XLATOR + +_ = XLATOR.gettext +ngettext = XLATOR.ngettext + +__version__ = '0.1.0' + +LOG = logging.getLogger(__name__) + + +# ============================================================================== +class DeliverAction(FbGenericBaseObject): + """A class encapsulating the logged action of a Postix deliverer.""" + + warn_on_parse_error = False + + attributes = ( + 'command', 'to_address', 'origin_to', 'pid', 'date', 'relay', 'delay_total', + 'time_before_queue', 'time_in_queue', 'time_conn_setup', 'time_xmission', + 'dsn', 'status', 'message', 'remote_id', + ) + + re_to_address = re.compile(r'\bto=<(?P[^>]*)>(?:,\s+)', re.IGNORECASE) + re_orig_to_address = re.compile(r'orig_to=<(?P[^>]*)>(?:,\s+)', re.IGNORECASE) + re_relay = re.compile(r'relay=(?P\S+)(?:,\s)', re.IGNORECASE) + re_delay = re.compile(r'delay=(?P\d+(?:\.\d*)?)(?:,\s+)', re.IGNORECASE) + + pat_delays = r'delays=' + pat_delays += r'(?P\d+(?:\.\d*)?)/(?P\d+(?:\.\d*)?)' + pat_delays += r'/(?P\d+(?:\.\d*)?)/(?P\d+(?:\.\d*)?)' + pat_delays += r'(?:,\s+)' + re_delays = re.compile(pat_delays, re.IGNORECASE) + + re_dsn = re.compile(r'dsn=(?P\S+)(?:,\s+)', re.IGNORECASE) + re_status = re.compile(r'status=(?P\S+)\s+', re.IGNORECASE) + + pat_remote_id = r'(?:queued\s+as\s+(?P[0-9a-f]+))' + pat_remote_id += r'|(?:InternalId=(?P\d+))' + pat_remote_id += r'|(?:\sid=(?P\S+))' + pat_remote_id += r'|(?:Message\s+(?P[0-9a-f]+)\s+accepted)' + pat_remote_id += r'|(?:\s(?P\S+)\s+(?:Message|mail)\s+accepted)' + pat_remote_id += r'|(?:\sOK\s+\d+\s+(?PS+)\s+-\s+gsmtp)' + pat_remote_id += r'|(?:\sok\s+\((?P\S+)\))' + pat_remote_id += r'|(?:\sok\s+(?P\S+))' + + re_remote_id = re.compile(pat_remote_id, re.IGNORECASE) + + re_relay_address = re.compile(r'\[(?P[^\]]+)\]') + + # ------------------------------------------------------------------------- + def __init__(self, **kwargs): + """Initialize this object.""" + for attr in self.attributes: + priv_name = '_' + attr + setattr(self, priv_name, None) + + for attr in kwargs.keys(): + if attr not in self.attributes: + msg = _('Unknown parameter {p!r} on calling {c}.__init__().').format( + p=attr, c=self.__class__.__name__) + raise AttributeError(msg) + setattr(self, attr, kwargs[attr]) + + # ----------------------------------------------------------- + @property + def command(self): + """Return the command to deliver the mail.""" + return self._command + + @command.setter + def command(self, value): + if value is None: + self._command = None + return + + val = str(value).strip() + if val == '': + self._command = None + return + self._command = val + + # ----------------------------------------------------------- + @property + def date(self): + """Return the timestamp of the delivering action.""" + return self._date + + @date.setter + def date(self, value): + if value is None: + self._date = None + return + + if isinstance(value, datetime.datetime): + self._date = value + return + + val = str(value).strip() + if val == '': + self._date = None + return + + try: + date = fromisoformat(val) + except DateFormatError as e: + msg = _('Could not interprete date {!r}:').format(val) + ' ' + str(e) + if self.warn_on_parse_error: + LOG.warn(msg) + else: + LOG.debug(msg) + date = None + if date: + self._date = date + return + self._date = val + + # ---------------------------------------------------------- + @property + def delay_total(self): + """Return the total delay of the sended mail ion the current postfix.""" + return self._delay_total + + @delay_total.setter + def delay_total(self, value): + if value is None: + self._delay_total = None + return + + if isinstance(value, (float, int)): + self._delay_total = float(value) + return + val = str(value).strip() + if val == '': + self._delay_total = None + return + + try: + self._delay_total = float(val) + except ValueError as e: + msg = _('Could not interprete total delay {a!r}: {e}').format(a=value, e=e) + if self.warn_on_parse_error: + LOG.warn(msg) + else: + LOG.debug(msg) + self._delay_total = val + + # ----------------------------------------------------------- + @property + def dsn(self): + """Return the Delivery Status Notification (DSN) information.""" + return self._dsn + + @dsn.setter + def dsn(self, value): + if value is None: + self._dsn = None + return + + val = str(value).strip() + if val == '': + self._dsn = None + return + self._dsn = val + + # ----------------------------------------------------------- + @property + def message(self): + """Return the verbose message of the delivering transaction.""" + return self._message + + @message.setter + def message(self, value): + if value is None: + self._message = None + return + + val = str(value).strip() + if val == '': + self._message = None + return + self._message = val + + # ----------------------------------------------------------- + @property + def origin_to(self): + """Return the original RCPT TO address in the delivering dialogue envelope.""" + return self._origin_to + + @origin_to.setter + def origin_to(self, value): + if value is None: + self._origin_to = None + return + + if isinstance(value, MailAddress): + self._origin_to = copy.copy(value) + return + val = str(value).strip() + if val == '': + self._origin_to = None + return + + try: + self._origin_to = MailAddress(val) + except InvalidMailAddressError as e: + msg = _('Could not interprete to address {a!r}: {e}').format(a=val, e=e) + if self.warn_on_parse_error: + LOG.warn(msg) + else: + LOG.debug(msg) + self._origin_to = val + + # ----------------------------------------------------------- + @property + def relay(self): + """Return the socket address of the receiving relay MTA.""" + return self._relay + + @relay.setter + def relay(self, value): + if value is None: + self._relay = None + return + + val = str(value).strip() + if val == '': + self._relay = None + return + self._relay = val + + # ----------------------------------------------------------- + @property + def relay_address(self): + """Return the IP address of an existing relay for SMTP.""" + if not self.relay: + return None + + m = self.re_relay_address.search(self.relay) + if not m: + # Did not found IP address in SMTP relay. + return None + + try: + addr = ipaddress.ip_address(m['addr']) + except ValueError as e: + msg = _('Could not interprete relay address {a!r}: {e}').format(a=m['addr'], e=e) + if self.warn_on_parse_error: + LOG.warn(msg) + else: + LOG.debug(msg) + return None + + return addr + + # ----------------------------------------------------------- + @property + def remote_id(self): + """Return the the Mail ID of the remote (receiving) MTA.""" + return self._remote_id + + @remote_id.setter + def remote_id(self, value): + if value is None: + self._remote_id = None + return + + val = str(value).strip() + if val == '': + self._remote_id = None + return + self._remote_id = val + + # ---------------------------------------------------------- + @property + def pid(self): + """Return the process ID (PID) of the sending delivering process.""" + return self._pid + + @pid.setter + def pid(self, value): + if value is None: + self._pid = None + return + + if isinstance(value, (float, int)): + self._pid = int(value) + return + val = str(value).strip() + if val == '': + self._pid = None + return + + try: + self._pid = int(val) + except ValueError as e: + msg = _('Could not interprete PID of deliverer {a!r}: {e}').format(a=value, e=e) + if self.warn_on_parse_error: + LOG.warn(msg) + else: + LOG.debug(msg) + self._pid = val + + # ----------------------------------------------------------- + @property + def status(self): + """Return the final status of the deliverer transaction.""" + return self._status + + @status.setter + def status(self, value): + if value is None: + self._status = None + return + + val = str(value).strip() + if val == '': + self._status = None + return + self._status = val + + # ---------------------------------------------------------- + @property + def time_before_queue(self): + """Return the used time before the mail was queued.""" + return self._time_before_queue + + @time_before_queue.setter + def time_before_queue(self, value): + if value is None: + self._time_before_queue = None + return + + if isinstance(value, (float, int)): + self._time_before_queue = float(value) + return + val = str(value).strip() + if val == '': + self._time_before_queue = None + return + + try: + self._time_before_queue = float(val) + except ValueError as e: + msg = _('Could not interprete time before queueing {a!r}: {e}').format(a=value, e=e) + if self.warn_on_parse_error: + LOG.warn(msg) + else: + LOG.debug(msg) + self._time_before_queue = val + + # ---------------------------------------------------------- + @property + def time_conn_setup(self): + """ + Return the time the deliverer process needed to establish a SMTP connection. + + This is including DNS, HELO and TLS. + """ + return self._time_conn_setup + + @time_conn_setup.setter + def time_conn_setup(self, value): + if value is None: + self._time_conn_setup = None + return + + if isinstance(value, (float, int)): + self._time_conn_setup = float(value) + return + val = str(value).strip() + if val == '': + self._time_conn_setup = None + return + + try: + self._time_conn_setup = float(val) + except ValueError as e: + msg = _('Could not interprete smtp connection setup time {a!r}: {e}').format( + a=value, e=e) + if self.warn_on_parse_error: + LOG.warn(msg) + else: + LOG.debug(msg) + self._time_conn_setup = val + + # ---------------------------------------------------------- + @property + def time_in_queue(self): + """Return the time the mail was held in the queue.""" + return self._time_in_queue + + @time_in_queue.setter + def time_in_queue(self, value): + if value is None: + self._time_in_queue = None + return + + if isinstance(value, (float, int)): + self._time_in_queue = float(value) + return + val = str(value).strip() + if val == '': + self._time_in_queue = None + return + + try: + self._time_in_queue = float(val) + except ValueError as e: + msg = _('Could not interprete time in queue {a!r}: {e}').format(a=value, e=e) + if self.warn_on_parse_error: + LOG.warn(msg) + else: + LOG.debug(msg) + self._time_in_queue = val + + # ---------------------------------------------------------- + @property + def time_xmission(self): + """Return the time the smtp process needed to transmit the mail.""" + return self._time_xmission + + @time_xmission.setter + def time_xmission(self, value): + if value is None: + self._time_xmission = None + return + + if isinstance(value, (float, int)): + self._time_xmission = float(value) + return + val = str(value).strip() + if val == '': + self._time_xmission = None + return + + try: + self._time_xmission = float(val) + except ValueError as e: + msg = _('Could not interprete smtp transmission time {a!r}: {e}').format(a=value, e=e) + if self.warn_on_parse_error: + LOG.warn(msg) + else: + LOG.debug(msg) + self._time_xmission = val + + # ----------------------------------------------------------- + @property + def to_address(self): + """Return the RCPT TO address in the deliverer dialogue envelope.""" + return self._to_address + + @to_address.setter + def to_address(self, value): + if value is None: + self._to_address = None + return + + if isinstance(value, MailAddress): + self._to_address = copy.copy(value) + return + val = str(value).strip() + if val == '': + self._to_address = None + return + + try: + self._to_address = MailAddress(val) + except InvalidMailAddressError as e: + msg = _('Could not interprete to address {a!r}: {e}').format(a=val, e=e) + if self.warn_on_parse_error: + LOG.warn(msg) + else: + LOG.debug(msg) + self._to_address = val + + # ------------------------------------------------------------------------- + def __str__(self): + """Typecast into a string object. + + @return: structure as string + @rtype: str + """ + return pp(self.as_dict(exportable=True)) + + # ------------------------------------------------------------------------- + def __repr__(self): + """Typecast into a string for reproduction.""" + out = '<%s(' % (self.__class__.__name__) + + fields = [] + + attr_dict = self.as_dict(exportable=True) + for attr in attr_dict.keys(): + value = attr_dict[attr] + if value is None: + continue + fields.append(f'{attr}={value!r}') + + if fields: + out += ', '.join(fields) + + out += ')>' + return out + + # ------------------------------------------------------------------------- + def as_dict(self, short=True, exportable=False): + """ + Transform the elements of the object into a dict. + + @param short: don't include local properties in resulting dict. + @type short: bool + + @return: structure as dict + @rtype: dict + """ + if exportable: + res = {} + else: + res = super(DeliverAction, self).as_dict(short=short) + + for attrib in self.attributes: + if not hasattr(self, attrib): + continue + value = getattr(self, attrib, None) + if value is None: + res[attrib] = None + continue + if isinstance(value, ipaddress._BaseAddress): + res[attrib] = str(value) if exportable else value + continue + if isinstance(value, datetime.datetime): + res[attrib] = value.isoformat(' ') if exportable else value + continue + if isinstance(value, (DataPair, MailAddress)): + res[attrib] = str(value) if exportable else value + continue + + # Catch all + res[attrib] = value + + if not exportable: + res['relay_address'] = self.relay_address + + return res + + # ------------------------------------------------------------------------- + def __copy__(self): + """Copy the current chain data into a new object.""" + params = {} + + for attrib in self.attributes: + value = getattr(self, attrib, None) + if value is None: + continue + params[attrib] = copy.copy(value) + + return self.__class__(**params) + + # ------------------------------------------------------------------------- + @classmethod + def _get_remote_mailid(cls, message): + rmatch = cls.re_remote_id.search(message) + if not rmatch: + return None + + if rmatch['pf_id'] is not None: + return rmatch['pf_id'] + elif rmatch['jira_id'] is not None: + return rmatch['jira_id'] + elif rmatch['raw_id'] is not None: + return rmatch['raw_id'] + elif rmatch['msg1_id'] is not None: + return rmatch['msg1_id'] + elif rmatch['msg2_id'] is not None: + return rmatch['msg2_id'] + elif rmatch['gsmtp_id'] is not None: + return rmatch['gsmtp_id'] + elif rmatch['msg3_id'] is not None: + return rmatch['msg3_id'] + elif rmatch['msg4_id'] is not None: + return rmatch['msg4_id'] + + return None + + # ------------------------------------------------------------------------- + @classmethod + def from_log_entry(cls, timestamp, command, pid, message, verbose=0): + """Try to create a DeliverAction from the given Postfix log entry.""" + action = cls() + action.date = timestamp + action.command = command + action.pid = pid + cur_msg = message + + if verbose > 2: + LOG.debug(f'Parsing {command} delivering line: {message}') + + rmatch = cls.re_to_address.search(cur_msg) + if rmatch: + action.to_address = rmatch['to'] + cur_msg = cls.re_to_address.sub('', cur_msg) + + rmatch = cls.re_orig_to_address.search(cur_msg) + if rmatch: + action.origin_to = rmatch['orig'] + cur_msg = cls.re_orig_to_address.sub('', cur_msg) + + rmatch = cls.re_relay.search(cur_msg) + if rmatch: + action.relay = rmatch['relay'] + cur_msg = cls.re_relay.sub('', cur_msg) + + rmatch = cls.re_delay.search(cur_msg) + if rmatch: + action.delay_total = rmatch['delay'] + cur_msg = cls.re_delay.sub('', cur_msg) + + rmatch = cls.re_delays.search(cur_msg) + if rmatch: + action.time_before_queue = rmatch['p1'] + action.time_in_queue = rmatch['p2'] + action.time_conn_setup = rmatch['p3'] + action.time_xmission = rmatch['p4'] + cur_msg = cls.re_delays.sub('', cur_msg) + + rmatch = cls.re_dsn.search(cur_msg) + if rmatch: + action.dsn = rmatch['dsn'] + cur_msg = cls.re_dsn.sub('', cur_msg) + + rmatch = cls.re_status.search(cur_msg) + if rmatch: + action.status = rmatch['status'] + cur_msg = cls.re_status.sub('', cur_msg) + + cur_msg = cur_msg.strip() + if cur_msg: + if cur_msg.startswith('(') and cur_msg.endswith(')'): + cur_msg = cur_msg[1:-1] + if cur_msg: + action.message = cur_msg + remote_id = cls._get_remote_mailid(cur_msg) + if remote_id: + action.remote_id = remote_id + + return action + + +# ============================================================================= + +if __name__ == '__main__': + + pass + +# ============================================================================= + +# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 list -- 2.39.5