diff --git a/config.py b/config.py index 1098b7a..e2380cc 100644 --- a/config.py +++ b/config.py @@ -6,13 +6,15 @@ License: MIT """ -from dynaconf import ( - Dynaconf, -) -import platform -import tempfile import pathlib +import platform +import smtplib import subprocess +import tempfile + +from dynaconf import Dynaconf + +from smtp_functions import create_connection_and_log_user config = Dynaconf( envvar_prefix=False, @@ -100,6 +102,46 @@ def validate_config(config): ): raise ConfigError("Config error: Ignored tables parameter should be a list") + if "notification" in config: + settings = [ + "smtp_server", + "email_sender", + "email_subject", + "email_recipients", + ] + + for setting in settings: + if setting not in config.notification: + raise ConfigError(f"Config error: `{setting}` is missing from `notification`.") + + if not isinstance(config.notification.email_recipients, list): + raise ConfigError("Config error: `email_recipients` should be a list of addresses.") + + if "use_ssl" in config.notification: + if not isinstance(config.notification.use_ssl, bool): + raise ConfigError("Config error: `use_ssl` must be set to either `true` or `false`.") + + if "use_tls" in config.notification: + if not isinstance(config.notification.use_tls, bool): + raise ConfigError("Config error: `use_tls` must be set to either `true` or `false`.") + + if "smtp_port" in config.notification: + if not isinstance(config.notification.smtp_port, int): + raise ConfigError("Config error: `smtp_port` must be set to an integer.") + + if "minimal_email_interval" in config.notification: + if not isinstance(config.notification.minimal_email_interval, (int, float)): + raise ConfigError("Config error: `minimal_email_interval` must be set to a number.") + + try: + smtp_conn = create_connection_and_log_user(config) + smtp_conn.quit() + except (OSError, smtplib.SMTPConnectError, smtplib.SMTPAuthenticationError) as e: + err = str(e) + if isinstance(e, (smtplib.SMTPConnectError, smtplib.SMTPAuthenticationError)): + err = str(e.smtp_error) + raise ConfigError(f"Config SMTP Error: {err}.") + def get_ignored_tables( connection, diff --git a/dbsync_daemon.py b/dbsync_daemon.py index 785d050..81111f8 100644 --- a/dbsync_daemon.py +++ b/dbsync_daemon.py @@ -3,27 +3,20 @@ # - pull # - push -import datetime -import sys -import time import argparse -import platform +import datetime import logging import os import pathlib +import platform +import sys +import time import dbsync -from version import ( - __version__, -) -from config import ( - config, - validate_config, - ConfigError, - update_config_path, -) - -from log_functions import setup_logger, handle_error_and_exit +from config import ConfigError, config, update_config_path, validate_config +from log_functions import handle_error_and_exit, setup_logger +from smtp_functions import can_send_email, send_email +from version import __version__ def is_pyinstaller() -> bool: @@ -98,6 +91,11 @@ def main(): default="DEBUG", help="Set level of logging into log-file.", ) + parser.add_argument( + "--test-notification-email", + action="store_true", + help="Send test notification email using the `notification` settings. Should be used to validate settings.", + ) args = parser.parse_args() @@ -120,6 +118,11 @@ def main(): except ConfigError as e: handle_error_and_exit(e) + if args.test_notification_email: + send_email("Mergin DB Sync test email.", config) + logging.debug("Email sent!") + sys.exit(0) + if args.force_init and args.skip_init: handle_error_and_exit("Cannot use `--force-init` with `--skip-init` Initialization is required. ") @@ -148,12 +151,16 @@ def main(): handle_error_and_exit(e) else: + error_msg = None + if not args.skip_init: try: dbsync.dbsync_init(mc) except dbsync.DbSyncError as e: handle_error_and_exit(e) + last_email_send = None + while True: print(datetime.datetime.now()) @@ -171,6 +178,9 @@ def main(): except dbsync.DbSyncError as e: logging.error(str(e)) + if can_send_email(config): + send_email(str(e), last_email_send, config) + last_email_send = datetime.datetime.now() logging.debug("Going to sleep") time.sleep(sleep_time) diff --git a/docs/using.md b/docs/using.md index 2edda0a..616cd1e 100644 --- a/docs/using.md +++ b/docs/using.md @@ -76,6 +76,7 @@ daemon: - `--log-verbosity` use `errors` or `messages` to specify what should be logged. Default is `messages`. +- `--test-notification-email` used to test send notification email (see below for details about sending emails in case of sync fails) ## Excluding tables from sync @@ -93,3 +94,36 @@ connections: - table1 - table2 ``` + +## Sending emails in case of synchronization fail + +User can be notified about sync fails via email by including `notification` with proper setting in the config file + +The command line option `--test-notification-email` can be used to only send the notification email without running the daemon. This should be useful to test the `notification` settings setup. + +```yaml +# optional setting, if you do not want notifications on sync errors to be send via email just delete this section +notification: + # first four options are required to be present + # address of stmp server to send emails + smtp_server: "xxx.xxx" + # email sender to be displayed in the message + email_sender: "dbsync@info.com" + # email subject + email_subject: "DB Sync Error" + # list of recipients of the email notification + email_recipients: ["recipient1@email.xxx", "recipient1@email.xxx"] + # the following configuration is optional and does not need to be specified + # smtp server user + smtp_username: "user@email.xxx" + # smtp user's password + smtp_password: "password" + # smtp port + smtp_port: 25 + # use ssl true/false + use_ssl: false + # use tls true/false + use_tls: false + # minimal interval for sending emails in hours (default value is 4) to avoid sending too many emails + minimal_email_interval: 4 +``` diff --git a/smtp_functions.py b/smtp_functions.py new file mode 100644 index 0000000..78df4be --- /dev/null +++ b/smtp_functions.py @@ -0,0 +1,73 @@ +import datetime +import smtplib +import typing +from email.message import EmailMessage + +from dynaconf import Dynaconf + + +def create_connection_and_log_user(config: Dynaconf) -> typing.Union[smtplib.SMTP_SSL, smtplib.SMTP]: + """Create connection and log user to the SMTP server using the configuration in config.""" + if "smtp_port" in config.notification: + port = config.notification.smtp_port + else: + port = 0 + + if "use_ssl" in config.notification and config.notification.use_ssl: + host = smtplib.SMTP_SSL(config.notification.smtp_server, port) + else: + host = smtplib.SMTP(config.notification.smtp_server, port) + + if "use_tls" in config.notification and config.notification.use_tls: + host.starttls() + if config.notification.smtp_username and config.notification.smtp_password: + host.login(config.notification.smtp_username, config.notification.smtp_password) + + return host + + +def should_send_another_email( + current_time: datetime.datetime, last_email_sent: typing.Optional[datetime.datetime], config: Dynaconf +) -> bool: + """Checks if another email should be sent. The time difference to last sent email needs to larger them + minimal interval specified in config or 4 hours if no value was specified.""" + if last_email_sent is None: + return True + min_time_diff = config.notification.minimal_email_interval if "minimal_email_interval" in config.notification else 4 + time_diff = current_time - last_email_sent + return time_diff.seconds > (min_time_diff * 3600) + + +def send_email(error: str, last_email_send: typing.Optional[datetime.datetime], config: Dynaconf) -> None: + """Sends email with provided error using the settings in config.""" + current_time = datetime.datetime.now() + + if should_send_another_email(current_time, last_email_send, config): + msg = EmailMessage() + msg["Subject"] = config.notification.email_subject + msg["From"] = config.notification.email_sender + msg["To"] = ", ".join(config.notification.email_recipients) + + date_time = datetime.datetime.now().strftime("%d/%m/%Y %H:%M:%S") + + error = f"{date_time}: {error}" + + msg.set_content(error) + + sender_email = config.notification.email_sender + if "smtp_username" in config.notification: + sender_email = config.notification.smtp_username + + try: + smtp_conn = create_connection_and_log_user(config) + smtp_conn.sendmail(sender_email, config.notification.email_recipients, msg.as_string()) + smtp_conn.quit() + except: + pass + + +def can_send_email(config: Dynaconf) -> bool: + """Checks if notification settings is in the config and emails can be send.""" + if "notification" in config: + return True + return False diff --git a/test/test_config.py b/test/test_config.py index c8badfc..cd6458c 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -7,16 +7,10 @@ """ import pytest -from config import ( - config, - ConfigError, - validate_config, - get_ignored_tables, -) +from config import ConfigError, config, get_ignored_tables, validate_config +from smtp_functions import can_send_email -from .conftest import ( - _reset_config, -) +from .conftest import _reset_config def test_config(): @@ -256,3 +250,103 @@ def test_get_ignored_tables(): validate_config(config) ignored_tables = get_ignored_tables(config.connections[0]) assert ignored_tables == ["table"] + + +def test_config_notification_setup(): + _reset_config() + + # no NOTIFICATIONS set should pass but cannot send email + validate_config(config) + assert can_send_email(config) is False + + # incomplete setting + config.update( + { + "NOTIFICATION": { + "smtp_server": "server", + "smtp_username": "user", + } + } + ) + + with pytest.raises(ConfigError, match="Config error: `email_sender`"): + validate_config(config) + + # another incomplete setting + _reset_config() + + config.update( + { + "NOTIFICATION": { + "smtp_server": "server", + "smtp_username": "user", + "smtp_password": "pass", + "email_sender": "dbsync@info.com", + } + } + ) + + with pytest.raises(ConfigError, match="Config error: `email_subject`"): + validate_config(config) + + # bool variable test + _reset_config() + + config.update( + { + "NOTIFICATION": { + "smtp_server": "server", + "smtp_username": "user", + "smtp_password": "pass", + "email_sender": "dbsync@info.com", + "email_subject": "DB Sync Error", + "email_recipients": ["recipient1@test.com", "recipient2@test.com"], + "use_ssl": "some_string", + } + } + ) + + with pytest.raises(ConfigError, match="Config error: `use_ssl` must be set to either `true` or `false`"): + validate_config(config) + + # int variable test + _reset_config() + + config.update( + { + "NOTIFICATION": { + "smtp_server": "server", + "smtp_username": "user", + "smtp_password": "pass", + "email_sender": "dbsync@info.com", + "email_subject": "DB Sync Error", + "email_recipients": ["recipient1@test.com", "recipient2@test.com"], + "smtp_port": "some_string", + } + } + ) + + with pytest.raises(ConfigError, match="Config error: `smtp_port` must be set an integer"): + validate_config(config) + # complete setting but does not work + config.update( + { + "NOTIFICATION": { + "smtp_server": "server", + "smtp_username": "user", + "smtp_password": "pass", + "email_sender": "dbsync@sync.com", + "email_subject": "DB Sync Error", + "email_recipients": ["recipient1@test.com", "recipient2@test.com"], + "smtp_port": 25, + "use_ssl": False, + "use_tls": True, + } + } + ) + + with pytest.raises(ConfigError, match="Config SMTP Error"): + validate_config(config) + + # notifications are set, emails can be send - but this config was not validated, as it would be in real run + assert can_send_email(config)