From 99520d54a59a63ddf555229819e88baabf56847e Mon Sep 17 00:00:00 2001 From: Ozzie Isaacs Date: Sun, 28 Mar 2021 14:50:55 +0200 Subject: [PATCH] Added ability to send emails via gmail (#1905) Gmail email sending --- cps/admin.py | 19 +++-- cps/config_sql.py | 13 ++-- cps/gmail.py | 64 ---------------- cps/services/__init__.py | 6 ++ cps/services/gmail.py | 80 +++++++++++++++++++ cps/tasks/mail.py | 139 +++++++++++++++++++--------------- cps/templates/admin.html | 41 ++++++---- cps/templates/email_edit.html | 6 +- gmail.json | 1 + 9 files changed, 219 insertions(+), 150 deletions(-) delete mode 100644 cps/gmail.py create mode 100644 cps/services/gmail.py create mode 100644 gmail.json diff --git a/cps/admin.py b/cps/admin.py index e938f3a1..d6a26798 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -39,7 +39,7 @@ from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError from sqlalchemy.sql.expression import func, or_ -from . import constants, logger, helper, services, gmail +from . import constants, logger, helper, services from .cli import filepicker from . import db, calibre_db, ub, web_server, get_locale, config, updater_thread, babel, gdriveutils from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash @@ -58,7 +58,8 @@ feature_support = { 'ldap': bool(services.ldap), 'goodreads': bool(services.goodreads_support), 'kobo': bool(services.kobo), - 'updater': constants.UPDATER_AVAILABLE + 'updater': constants.UPDATER_AVAILABLE, + 'gmail': bool(services.gmail) } try: @@ -1311,7 +1312,7 @@ def new_user(): def edit_mailsettings(): content = config.get_mail_settings() return render_title_template("email_edit.html", content=content, title=_(u"Edit E-mail Server Settings"), - page="mailset") + page="mailset", feature_support=feature_support) @admi.route("/admin/mailsettings", methods=["POST"]) @@ -1320,15 +1321,21 @@ def edit_mailsettings(): def update_mailsettings(): to_save = request.form.to_dict() _config_int(to_save, "mail_server_type") - if to_save.get("invalidate_server"): + if to_save.get("invalidate"): config.mail_gmail_token = {} try: flag_modified(config, "mail_gmail_token") except AttributeError: pass elif to_save.get("gmail"): - config.mail_gmail_token = gmail.setup_gmail(config) - flash(_(u"G-Mail Account Verification Successfull"), category="success") + try: + config.mail_gmail_token = services.gmail.setup_gmail(config.mail_gmail_token) + flash(_(u"G-Mail Account Verification Successfull"), category="success") + except Exception as e: + flash(e, category="error") + log.error(e) + return edit_mailsettings() + else: _config_string(to_save, "mail_server") _config_int(to_save, "mail_port") diff --git a/cps/config_sql.py b/cps/config_sql.py index de1ee6d4..2ab0e3d6 100644 --- a/cps/config_sql.py +++ b/cps/config_sql.py @@ -249,15 +249,15 @@ class _ConfigSQL(object): def get_mail_server_configured(self): return bool((self.mail_server != constants.DEFAULT_MAIL_SERVER and self.mail_server_type == 0) - or (self.mail_gmail_token != b"" and self.mail_server_type == 1)) + or (self.mail_gmail_token != {} and self.mail_server_type == 1)) def set_from_dictionary(self, dictionary, field, convertor=None, default=None, encode=None): - '''Possibly updates a field of this object. + """Possibly updates a field of this object. The new value, if present, is grabbed from the given dictionary, and optionally passed through a convertor. :returns: `True` if the field has changed value - ''' + """ new_value = dictionary.get(field, default) if new_value is None: # log.debug("_ConfigSQL set_from_dictionary field '%s' not found", field) @@ -308,8 +308,11 @@ class _ConfigSQL(object): have_metadata_db = os.path.isfile(db_file) self.db_configured = have_metadata_db constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')] - # pylint: disable=access-member-before-definition - logfile = logger.setup(self.config_logfile, self.config_log_level) + if os.environ.get('FLASK_DEBUG'): + logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG) + else: + # pylint: disable=access-member-before-definition + logfile = logger.setup(self.config_logfile, self.config_log_level) if logfile != self.config_logfile: log.warning("Log path %s not valid, falling back to default", self.config_logfile) self.config_logfile = logfile diff --git a/cps/gmail.py b/cps/gmail.py deleted file mode 100644 index 50b60e85..00000000 --- a/cps/gmail.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import print_function -import os.path -from googleapiclient.discovery import build -from google_auth_oauthlib.flow import InstalledAppFlow -from google.auth.transport.requests import Request -from google.oauth2.credentials import Credentials -from .constants import BASE_DIR -import json -from datetime import datetime - -subject = "Test" -msg = "Testnachricht" -sender = "matthias1.knopp@googlemail.com" -receiver = "matthias.knopp@web.de" - -SCOPES = ['https://www.googleapis.com/auth/gmail.send'] - -def setup_gmail(config): - token = config.mail_gmail_token - # if config.mail_gmail_token != "{}": - # If there are no (valid) credentials available, let the user log in. - creds = None - if "token" in token: - creds = Credentials( - token=token['token'], - refresh_token=token['refresh_token'], - token_uri=token['token_uri'], - client_id=token['client_id'], - client_secret=token['client_secret'], - scopes=token['scopes'], - ) - creds.expiry = datetime.fromisoformat(token['expiry']) - - if not creds or not creds.valid: - # don't forget to dump one more time after the refresh - # also, some file-locking routines wouldn't be needless - if creds and creds.expired and creds.refresh_token: - creds.refresh(Request()) - else: - flow = InstalledAppFlow.from_client_secrets_file( - os.path.join(BASE_DIR, 'gmail.json'), SCOPES) - creds = flow.run_local_server(port=0) - - return { - 'token': creds.token, - 'refresh_token': creds.refresh_token, - 'token_uri': creds.token_uri, - 'client_id': creds.client_id, - 'client_secret': creds.client_secret, - 'scopes': creds.scopes, - 'expiry': creds.expiry.isoformat(), - } - -# implement your storage logic here, e.g. just good old json.dump() / json.load() - -# service = build('gmail', 'v1', credentials=creds) -# message = MIMEText(msg) -# message['to'] = receiver -# message['from'] = sender -# message['subject'] = subject -# raw = base64.urlsafe_b64encode(message.as_bytes()) -# raw = raw.decode() -# body = {'raw' : raw} -# message = (service.users().messages().send(userId='me', body=body).execute()) diff --git a/cps/services/__init__.py b/cps/services/__init__.py index 17f1f529..efd55621 100644 --- a/cps/services/__init__.py +++ b/cps/services/__init__.py @@ -45,3 +45,9 @@ except ImportError as err: log.debug("Cannot import SyncToken, syncing books with Kobo Devices will not work: %s", err) kobo = None SyncToken = None + +try: + from . import gmail +except ImportError as err: + log.debug("Cannot import Gmail, sending books via G-Mail Accounts will not work: %s", err) + gmail = None diff --git a/cps/services/gmail.py b/cps/services/gmail.py new file mode 100644 index 00000000..9524dd75 --- /dev/null +++ b/cps/services/gmail.py @@ -0,0 +1,80 @@ +from __future__ import print_function +import os.path +from google_auth_oauthlib.flow import InstalledAppFlow +from google.auth.transport.requests import Request +from googleapiclient.discovery import build +from google.oauth2.credentials import Credentials + +from datetime import datetime +import base64 +from flask_babel import gettext as _ +from ..constants import BASE_DIR +from .. import logger + + +log = logger.create() + +SCOPES = ['openid', 'https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/userinfo.email'] + +def setup_gmail(token): + # If there are no (valid) credentials available, let the user log in. + creds = None + if "token" in token: + creds = Credentials( + token=token['token'], + refresh_token=token['refresh_token'], + token_uri=token['token_uri'], + client_id=token['client_id'], + client_secret=token['client_secret'], + scopes=token['scopes'], + ) + creds.expiry = datetime.fromisoformat(token['expiry']) + + if not creds or not creds.valid: + # don't forget to dump one more time after the refresh + # also, some file-locking routines wouldn't be needless + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + cred_file = os.path.join(BASE_DIR, 'gmail.json') + if not os.path.exists(cred_file): + raise Exception(_("Found no valid gmail.json file with OAuth information")) + flow = InstalledAppFlow.from_client_secrets_file( + os.path.join(BASE_DIR, 'gmail.json'), SCOPES) + creds = flow.run_local_server(port=0) + user_info = get_user_info(creds) + return { + 'token': creds.token, + 'refresh_token': creds.refresh_token, + 'token_uri': creds.token_uri, + 'client_id': creds.client_id, + 'client_secret': creds.client_secret, + 'scopes': creds.scopes, + 'expiry': creds.expiry.isoformat(), + 'email': user_info + } + +def get_user_info(credentials): + user_info_service = build(serviceName='oauth2', version='v2',credentials=credentials) + user_info = user_info_service.userinfo().get().execute() + return user_info.get('email', "") + +def send_messsage(token, msg): + creds = Credentials( + token=token['token'], + refresh_token=token['refresh_token'], + token_uri=token['token_uri'], + client_id=token['client_id'], + client_secret=token['client_secret'], + scopes=token['scopes'], + ) + creds.expiry = datetime.fromisoformat(token['expiry']) + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + service = build('gmail', 'v1', credentials=creds) + message_as_bytes = msg.as_bytes() # the message should converted from string to bytes. + message_as_base64 = base64.urlsafe_b64encode(message_as_bytes) # encode in base64 (printable letters coding) + raw = message_as_base64.decode() # convert to something JSON serializable + body = {'raw': raw} + + (service.users().messages().send(userId='me', body=body).execute()) diff --git a/cps/tasks/mail.py b/cps/tasks/mail.py index 6aa18fde..770087b4 100644 --- a/cps/tasks/mail.py +++ b/cps/tasks/mail.py @@ -4,6 +4,8 @@ import os import smtplib import threading import socket +import mimetypes +import base64 try: from StringIO import StringIO @@ -16,11 +18,14 @@ except ImportError: from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText + + from email import encoders from email.utils import formatdate, make_msgid from email.generator import Generator from cps.services.worker import CalibreTask +from cps.services import gmail from cps import logger, config from cps import gdriveutils @@ -98,7 +103,7 @@ class EmailSSL(EmailBase, smtplib.SMTP_SSL): class TaskEmail(CalibreTask): - def __init__(self, subject, filepath, attachment, settings, recipient, taskMessage, text, internal=False): + def __init__(self, subject, filepath, attachment, settings, recipient, taskMessage, text): super(TaskEmail, self).__init__(taskMessage) self.subject = subject self.attachment = attachment @@ -107,70 +112,38 @@ class TaskEmail(CalibreTask): self.recipent = recipient self.text = text self.asyncSMTP = None - self.results = dict() def prepare_message(self): - msg = MIMEMultipart() - msg['Subject'] = self.subject - msg['Message-Id'] = make_msgid('calibre-web') - msg['Date'] = formatdate(localtime=True) + message = MIMEMultipart() + message['to'] = self.recipent + message['from'] = self.settings["mail_from"] + message['subject'] = self.subject + message['Message-Id'] = make_msgid('calibre-web') + message['Date'] = formatdate(localtime=True) text = self.text - msg.attach(MIMEText(text.encode('UTF-8'), 'plain', 'UTF-8')) + msg = MIMEText(text.encode('UTF-8'), 'plain', 'UTF-8') + message.attach(msg) if self.attachment: result = self._get_attachment(self.filepath, self.attachment) if result: - msg.attach(result) + message.attach(result) else: self._handleError(u"Attachment not found") return - - msg['From'] = self.settings["mail_from"] - msg['To'] = self.recipent - # convert MIME message to string - fp = StringIO() - gen = Generator(fp, mangle_from_=False) - gen.flatten(msg) - return fp.getvalue() + return message def run(self, worker_thread): # create MIME message msg = self.prepare_message() - - use_ssl = int(self.settings.get('mail_use_ssl', 0)) try: - # send email - timeout = 600 # set timeout to 5mins - - # redirect output to logfile on python2 on python3 debugoutput is caught with overwritten - # _print_debug function - if sys.version_info < (3, 0): - org_smtpstderr = smtplib.stderr - smtplib.stderr = logger.StderrLogger('worker.smtp') - - if use_ssl == 2: - self.asyncSMTP = EmailSSL(self.settings["mail_server"], self.settings["mail_port"], - timeout=timeout) + if self.settings['mail_server_type'] == 0: + self.send_standard_email(msg) else: - self.asyncSMTP = Email(self.settings["mail_server"], self.settings["mail_port"], timeout=timeout) - - # link to logginglevel - if logger.is_debug_enabled(): - self.asyncSMTP.set_debuglevel(1) - if use_ssl == 1: - self.asyncSMTP.starttls() - if self.settings["mail_password"]: - self.asyncSMTP.login(str(self.settings["mail_login"]), str(self.settings["mail_password"])) - self.asyncSMTP.sendmail(self.settings["mail_from"], self.recipent, msg) - self.asyncSMTP.quit() - self._handleSuccess() - - if sys.version_info < (3, 0): - smtplib.stderr = org_smtpstderr - - except (MemoryError) as e: + self.send_gmail_email(msg) + except MemoryError as e: log.debug_or_exception(e) - self._handleError(u'MemoryError sending email: ' + str(e)) + self._handleError(u'MemoryError sending email: {}'.format(str(e))) except (smtplib.SMTPException, smtplib.SMTPAuthenticationError) as e: log.debug_or_exception(e) if hasattr(e, "smtp_error"): @@ -181,12 +154,55 @@ class TaskEmail(CalibreTask): text = '\n'.join(e.args) else: text = '' - self._handleError(u'Smtplib Error sending email: ' + text) - except (socket.error) as e: + self._handleError(u'Smtplib Error sending email: {}'.format(text)) + except socket.error as e: log.debug_or_exception(e) - self._handleError(u'Socket Error sending email: ' + e.strerror) + self._handleError(u'Socket Error sending email: {}'.format(e.strerror)) + except Exception as e: + log.debug_or_exception(e) + self._handleError(u'Error sending email: {}'.format(e)) + def send_standard_email(self, msg): + use_ssl = int(self.settings.get('mail_use_ssl', 0)) + timeout = 600 # set timeout to 5mins + + # redirect output to logfile on python2 on python3 debugoutput is caught with overwritten + # _print_debug function + if sys.version_info < (3, 0): + org_smtpstderr = smtplib.stderr + smtplib.stderr = logger.StderrLogger('worker.smtp') + + if use_ssl == 2: + self.asyncSMTP = EmailSSL(self.settings["mail_server"], self.settings["mail_port"], + timeout=timeout) + else: + self.asyncSMTP = Email(self.settings["mail_server"], self.settings["mail_port"], timeout=timeout) + + # link to logginglevel + if logger.is_debug_enabled(): + self.asyncSMTP.set_debuglevel(1) + if use_ssl == 1: + self.asyncSMTP.starttls() + if self.settings["mail_password"]: + self.asyncSMTP.login(str(self.settings["mail_login"]), str(self.settings["mail_password"])) + + # Convert message to something to send + fp = StringIO() + gen = Generator(fp, mangle_from_=False) + gen.flatten(msg) + + self.asyncSMTP.sendmail(self.settings["mail_from"], self.recipent, fp.getvalue()) + self.asyncSMTP.quit() + self._handleSuccess() + + if sys.version_info < (3, 0): + smtplib.stderr = org_smtpstderr + + + def send_gmail_email(self, message): + return gmail.send_messsage(self.settings.get('mail_gmail_token', None), message) + @property def progress(self): if self.asyncSMTP is not None: @@ -205,13 +221,13 @@ class TaskEmail(CalibreTask): @classmethod def _get_attachment(cls, bookpath, filename): """Get file as MIMEBase message""" - calibrepath = config.config_calibre_dir + calibre_path = config.config_calibre_dir if config.config_use_google_drive: df = gdriveutils.getFileFromEbooksFolder(bookpath, filename) if df: - datafile = os.path.join(calibrepath, bookpath, filename) - if not os.path.exists(os.path.join(calibrepath, bookpath)): - os.makedirs(os.path.join(calibrepath, bookpath)) + datafile = os.path.join(calibre_path, bookpath, filename) + if not os.path.exists(os.path.join(calibre_path, bookpath)): + os.makedirs(os.path.join(calibre_path, bookpath)) df.GetContentFile(datafile) else: return None @@ -221,19 +237,22 @@ class TaskEmail(CalibreTask): os.remove(datafile) else: try: - file_ = open(os.path.join(calibrepath, bookpath, filename), 'rb') + file_ = open(os.path.join(calibre_path, bookpath, filename), 'rb') data = file_.read() file_.close() except IOError as e: log.debug_or_exception(e) log.error(u'The requested file could not be read. Maybe wrong permissions?') return None - - attachment = MIMEBase('application', 'octet-stream') + # Set mimetype + content_type, encoding = mimetypes.guess_type(filename) + if content_type is None or encoding is not None: + content_type = 'application/octet-stream' + main_type, sub_type = content_type.split('/', 1) + attachment = MIMEBase(main_type, sub_type) attachment.set_payload(data) encoders.encode_base64(attachment) - attachment.add_header('Content-Disposition', 'attachment', - filename=filename) + attachment.add_header('Content-Disposition', 'attachment', filename=filename) return attachment @property diff --git a/cps/templates/admin.html b/cps/templates/admin.html index 44374137..5226151e 100644 --- a/cps/templates/admin.html +++ b/cps/templates/admin.html @@ -55,29 +55,42 @@

{{_('E-mail Server Settings')}}

{% if config.get_mail_server_configured() %} -
-
+ {% if email.mail_server_type == 0 %} +
+
{{_('SMTP Hostname')}}
{{email.mail_server}}
-
-
+
+
{{_('SMTP Port')}}
{{email.mail_port}}
-
-
+
+
{{_('Encryption')}}
{{ display_bool_setting(email.mail_use_ssl) }}
+
+
+
{{_('SMTP Login')}}
+
{{email.mail_login}}
+
+
+
{{_('From E-mail')}}
+
{{email.mail_from}}
+
-
-
{{_('SMTP Login')}}
-
{{email.mail_login}}
+ {% else %} +
+
+
{{_('E-Mail Service')}}
+
{{_('Gmail via Oauth2')}}
+
+
+
{{_('From E-mail')}}
+
{{email.mail_gmail_token['email']}}
+
-
-
{{_('From E-mail')}}
-
{{email.mail_from}}
-
-
{% endif %} + {% endif %} {{_('Edit E-mail Server Settings')}}
diff --git a/cps/templates/email_edit.html b/cps/templates/email_edit.html index 7a468884..8b92a248 100644 --- a/cps/templates/email_edit.html +++ b/cps/templates/email_edit.html @@ -7,6 +7,7 @@

{{title}}

+ {% if feature_support['gmail'] %}
@@ -61,8 +63,10 @@
+ {% if feature_support['gmail'] %}
- {{_('Cancel')}} + {% endif %} + {{_('Back')}} {% if g.allow_registration %}
diff --git a/gmail.json b/gmail.json new file mode 100644 index 00000000..9a67b59c --- /dev/null +++ b/gmail.json @@ -0,0 +1 @@ +{"installed":{"client_id":"686643671665-uglhp9pmlvjhsoq5q0528cttd16krgpj.apps.googleusercontent.com","project_id":"calibre-web-260207","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"hbLugwKAw0xqMctO1KZuhRKy"}} \ No newline at end of file