]> Frank Brehm's Git Trees - pixelpark/pp-admin-tools.git/commitdiff
Splitting lib/pp_admintools/postfix_chain.py in different modules.
authorFrank Brehm <frank.brehm@pixelpark.com>
Mon, 15 Apr 2024 10:45:47 +0000 (12:45 +0200)
committerFrank Brehm <frank.brehm@pixelpark.com>
Mon, 15 Apr 2024 10:45:47 +0000 (12:45 +0200)
lib/pp_admintools/common.py [new file with mode: 0644]
lib/pp_admintools/postfix_chain.py
lib/pp_admintools/postfix_data_pair.py [new file with mode: 0644]
lib/pp_admintools/postfix_deliver_action.py [new file with mode: 0644]

diff --git a/lib/pp_admintools/common.py b/lib/pp_admintools/common.py
new file mode 100644 (file)
index 0000000..5cc84c3
--- /dev/null
@@ -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<year>\d{4})-?(?P<month>[01]\d)-?(?P<day>[0-3]\d)'
+PATTERN_ISOTIME = (
+    r'(?P<hour>[0-2]\d):?(?P<min>[0-5]\d)(?::?(?P<sec>[0-5]\d)(\.(?P<nsec>\d+))?)?')
+PATTERN_ISOTIMEZONE = r'((?P<utc>Z)|(?P<tz_hours>[+-][01]\d)(?::?(?P<tz_mins>[0-5]\d))?)?'
+
+RE_ISODATETIME = re.compile(PATTERN_ISODATE + r'[T\s]' + PATTERN_ISOTIME + PATTERN_ISOTIMEZONE)
+
+PATTERN_DEFAULT_TZ = r'^\s*(?P<sign>[+-])\s*(?P<offset>.*)'
+RE_DEFAULT_TZ = re.compile(PATTERN_DEFAULT_TZ)
+
+RE_TZ = re.compile(r'^\s*(?P<tz_hours>[01]\d)(?::?(?P<tz_mins>[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
index 8adee525d87ca7f9d424f8820d5f39cdaa651969..26e9dc9f6ac32a2928c6bcb8e765aabb2f5cccdd 100644 (file)
@@ -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<year>\d{4})-?(?P<month>[01]\d)-?(?P<day>[0-3]\d)'
-PATTERN_ISOTIME = (
-    r'(?P<hour>[0-2]\d):?(?P<min>[0-5]\d)(?::?(?P<sec>[0-5]\d)(\.(?P<nsec>\d+))?)?')
-PATTERN_ISOTIMEZONE = r'((?P<utc>Z)|(?P<tz_hours>[+-][01]\d)(?::?(?P<tz_mins>[0-5]\d))?)?'
-
-RE_ISODATETIME = re.compile(PATTERN_ISODATE + r'[T\s]' + PATTERN_ISOTIME + PATTERN_ISOTIMEZONE)
-
-PATTERN_DEFAULT_TZ = r'^\s*(?P<sign>[+-])\s*(?P<offset>.*)'
-RE_DEFAULT_TZ = re.compile(PATTERN_DEFAULT_TZ)
-
-RE_TZ = re.compile(r'^\s*(?P<tz_hours>[01]\d)(?::?(?P<tz_mins>[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<val>\d+)(?:\s*/\s*(?P<total>\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<to>[^>]*)>(?:,\s+)', re.IGNORECASE)
-    re_orig_to_address = re.compile(r'orig_to=<(?P<orig>[^>]*)>(?:,\s+)', re.IGNORECASE)
-    re_relay = re.compile(r'relay=(?P<relay>\S+)(?:,\s)', re.IGNORECASE)
-    re_delay = re.compile(r'delay=(?P<delay>\d+(?:\.\d*)?)(?:,\s+)', re.IGNORECASE)
-
-    pat_delays = r'delays='
-    pat_delays += r'(?P<p1>\d+(?:\.\d*)?)/(?P<p2>\d+(?:\.\d*)?)'
-    pat_delays += r'/(?P<p3>\d+(?:\.\d*)?)/(?P<p4>\d+(?:\.\d*)?)'
-    pat_delays += r'(?:,\s+)'
-    re_delays = re.compile(pat_delays, re.IGNORECASE)
-
-    re_dsn = re.compile(r'dsn=(?P<dsn>\S+)(?:,\s+)', re.IGNORECASE)
-    re_status = re.compile(r'status=(?P<status>\S+)\s+', re.IGNORECASE)
-
-    pat_remote_id = r'(?:queued\s+as\s+(?P<pf_id>[0-9a-f]+))'
-    pat_remote_id += r'|(?:InternalId=(?P<jira_id>\d+))'
-    pat_remote_id += r'|(?:\sid=(?P<raw_id>\S+))'
-    pat_remote_id += r'|(?:Message\s+(?P<msg1_id>[0-9a-f]+)\s+accepted)'
-    pat_remote_id += r'|(?:\s(?P<msg2_id>\S+)\s+(?:Message|mail)\s+accepted)'
-    pat_remote_id += r'|(?:\sOK\s+\d+\s+(?P<gsmtp_id>S+)\s+-\s+gsmtp)'
-    pat_remote_id += r'|(?:\sok\s+\((?P<msg3_id>\S+)\))'
-    pat_remote_id += r'|(?:\sok\s+(?P<msg4_id>\S+))'
-
-    re_remote_id = re.compile(pat_remote_id, re.IGNORECASE)
-
-    re_relay_address = re.compile(r'\[(?P<addr>[^\]]+)\]')
-
-    # -------------------------------------------------------------------------
-    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 (file)
index 0000000..4f8d5de
--- /dev/null
@@ -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<val>\d+)(?:\s*/\s*(?P<total>\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 (file)
index 0000000..b1520f4
--- /dev/null
@@ -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<to>[^>]*)>(?:,\s+)', re.IGNORECASE)
+    re_orig_to_address = re.compile(r'orig_to=<(?P<orig>[^>]*)>(?:,\s+)', re.IGNORECASE)
+    re_relay = re.compile(r'relay=(?P<relay>\S+)(?:,\s)', re.IGNORECASE)
+    re_delay = re.compile(r'delay=(?P<delay>\d+(?:\.\d*)?)(?:,\s+)', re.IGNORECASE)
+
+    pat_delays = r'delays='
+    pat_delays += r'(?P<p1>\d+(?:\.\d*)?)/(?P<p2>\d+(?:\.\d*)?)'
+    pat_delays += r'/(?P<p3>\d+(?:\.\d*)?)/(?P<p4>\d+(?:\.\d*)?)'
+    pat_delays += r'(?:,\s+)'
+    re_delays = re.compile(pat_delays, re.IGNORECASE)
+
+    re_dsn = re.compile(r'dsn=(?P<dsn>\S+)(?:,\s+)', re.IGNORECASE)
+    re_status = re.compile(r'status=(?P<status>\S+)\s+', re.IGNORECASE)
+
+    pat_remote_id = r'(?:queued\s+as\s+(?P<pf_id>[0-9a-f]+))'
+    pat_remote_id += r'|(?:InternalId=(?P<jira_id>\d+))'
+    pat_remote_id += r'|(?:\sid=(?P<raw_id>\S+))'
+    pat_remote_id += r'|(?:Message\s+(?P<msg1_id>[0-9a-f]+)\s+accepted)'
+    pat_remote_id += r'|(?:\s(?P<msg2_id>\S+)\s+(?:Message|mail)\s+accepted)'
+    pat_remote_id += r'|(?:\sOK\s+\d+\s+(?P<gsmtp_id>S+)\s+-\s+gsmtp)'
+    pat_remote_id += r'|(?:\sok\s+\((?P<msg3_id>\S+)\))'
+    pat_remote_id += r'|(?:\sok\s+(?P<msg4_id>\S+))'
+
+    re_remote_id = re.compile(pat_remote_id, re.IGNORECASE)
+
+    re_relay_address = re.compile(r'\[(?P<addr>[^\]]+)\]')
+
+    # -------------------------------------------------------------------------
+    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