1
0
mirror of https://github.com/janeczku/calibre-web synced 2024-11-28 04:19:59 +00:00

Added ability to send emails via gmail (#1905)

Gmail email sending
This commit is contained in:
Ozzie Isaacs 2021-03-28 14:50:55 +02:00
parent e10a8c078b
commit 99520d54a5
9 changed files with 219 additions and 150 deletions

View File

@ -39,7 +39,7 @@ from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError
from sqlalchemy.sql.expression import func, or_ 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 .cli import filepicker
from . import db, calibre_db, ub, web_server, get_locale, config, updater_thread, babel, gdriveutils 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 from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash
@ -58,7 +58,8 @@ feature_support = {
'ldap': bool(services.ldap), 'ldap': bool(services.ldap),
'goodreads': bool(services.goodreads_support), 'goodreads': bool(services.goodreads_support),
'kobo': bool(services.kobo), 'kobo': bool(services.kobo),
'updater': constants.UPDATER_AVAILABLE 'updater': constants.UPDATER_AVAILABLE,
'gmail': bool(services.gmail)
} }
try: try:
@ -1311,7 +1312,7 @@ def new_user():
def edit_mailsettings(): def edit_mailsettings():
content = config.get_mail_settings() content = config.get_mail_settings()
return render_title_template("email_edit.html", content=content, title=_(u"Edit E-mail Server 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"]) @admi.route("/admin/mailsettings", methods=["POST"])
@ -1320,15 +1321,21 @@ def edit_mailsettings():
def update_mailsettings(): def update_mailsettings():
to_save = request.form.to_dict() to_save = request.form.to_dict()
_config_int(to_save, "mail_server_type") _config_int(to_save, "mail_server_type")
if to_save.get("invalidate_server"): if to_save.get("invalidate"):
config.mail_gmail_token = {} config.mail_gmail_token = {}
try: try:
flag_modified(config, "mail_gmail_token") flag_modified(config, "mail_gmail_token")
except AttributeError: except AttributeError:
pass pass
elif to_save.get("gmail"): elif to_save.get("gmail"):
config.mail_gmail_token = gmail.setup_gmail(config) try:
config.mail_gmail_token = services.gmail.setup_gmail(config.mail_gmail_token)
flash(_(u"G-Mail Account Verification Successfull"), category="success") flash(_(u"G-Mail Account Verification Successfull"), category="success")
except Exception as e:
flash(e, category="error")
log.error(e)
return edit_mailsettings()
else: else:
_config_string(to_save, "mail_server") _config_string(to_save, "mail_server")
_config_int(to_save, "mail_port") _config_int(to_save, "mail_port")

View File

@ -249,15 +249,15 @@ class _ConfigSQL(object):
def get_mail_server_configured(self): def get_mail_server_configured(self):
return bool((self.mail_server != constants.DEFAULT_MAIL_SERVER and self.mail_server_type == 0) 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): 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. 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 :returns: `True` if the field has changed value
''' """
new_value = dictionary.get(field, default) new_value = dictionary.get(field, default)
if new_value is None: if new_value is None:
# log.debug("_ConfigSQL set_from_dictionary field '%s' not found", field) # log.debug("_ConfigSQL set_from_dictionary field '%s' not found", field)
@ -308,6 +308,9 @@ class _ConfigSQL(object):
have_metadata_db = os.path.isfile(db_file) have_metadata_db = os.path.isfile(db_file)
self.db_configured = have_metadata_db self.db_configured = have_metadata_db
constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')] constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')]
if os.environ.get('FLASK_DEBUG'):
logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG)
else:
# pylint: disable=access-member-before-definition # pylint: disable=access-member-before-definition
logfile = logger.setup(self.config_logfile, self.config_log_level) logfile = logger.setup(self.config_logfile, self.config_log_level)
if logfile != self.config_logfile: if logfile != self.config_logfile:

View File

@ -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())

View File

@ -45,3 +45,9 @@ except ImportError as err:
log.debug("Cannot import SyncToken, syncing books with Kobo Devices will not work: %s", err) log.debug("Cannot import SyncToken, syncing books with Kobo Devices will not work: %s", err)
kobo = None kobo = None
SyncToken = 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

80
cps/services/gmail.py Normal file
View File

@ -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())

View File

@ -4,6 +4,8 @@ import os
import smtplib import smtplib
import threading import threading
import socket import socket
import mimetypes
import base64
try: try:
from StringIO import StringIO from StringIO import StringIO
@ -16,11 +18,14 @@ except ImportError:
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email import encoders from email import encoders
from email.utils import formatdate, make_msgid from email.utils import formatdate, make_msgid
from email.generator import Generator from email.generator import Generator
from cps.services.worker import CalibreTask from cps.services.worker import CalibreTask
from cps.services import gmail
from cps import logger, config from cps import logger, config
from cps import gdriveutils from cps import gdriveutils
@ -98,7 +103,7 @@ class EmailSSL(EmailBase, smtplib.SMTP_SSL):
class TaskEmail(CalibreTask): 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) super(TaskEmail, self).__init__(taskMessage)
self.subject = subject self.subject = subject
self.attachment = attachment self.attachment = attachment
@ -107,39 +112,59 @@ class TaskEmail(CalibreTask):
self.recipent = recipient self.recipent = recipient
self.text = text self.text = text
self.asyncSMTP = None self.asyncSMTP = None
self.results = dict() self.results = dict()
def prepare_message(self): def prepare_message(self):
msg = MIMEMultipart() message = MIMEMultipart()
msg['Subject'] = self.subject message['to'] = self.recipent
msg['Message-Id'] = make_msgid('calibre-web') message['from'] = self.settings["mail_from"]
msg['Date'] = formatdate(localtime=True) message['subject'] = self.subject
message['Message-Id'] = make_msgid('calibre-web')
message['Date'] = formatdate(localtime=True)
text = self.text 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: if self.attachment:
result = self._get_attachment(self.filepath, self.attachment) result = self._get_attachment(self.filepath, self.attachment)
if result: if result:
msg.attach(result) message.attach(result)
else: else:
self._handleError(u"Attachment not found") self._handleError(u"Attachment not found")
return return
return message
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()
def run(self, worker_thread): def run(self, worker_thread):
# create MIME message # create MIME message
msg = self.prepare_message() msg = self.prepare_message()
use_ssl = int(self.settings.get('mail_use_ssl', 0))
try: try:
# send email if self.settings['mail_server_type'] == 0:
self.send_standard_email(msg)
else:
self.send_gmail_email(msg)
except MemoryError as e:
log.debug_or_exception(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"):
text = e.smtp_error.decode('utf-8').replace("\n", '. ')
elif hasattr(e, "message"):
text = e.message
elif hasattr(e, "args"):
text = '\n'.join(e.args)
else:
text = ''
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: {}'.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 timeout = 600 # set timeout to 5mins
# redirect output to logfile on python2 on python3 debugoutput is caught with overwritten # redirect output to logfile on python2 on python3 debugoutput is caught with overwritten
@ -161,31 +186,22 @@ class TaskEmail(CalibreTask):
self.asyncSMTP.starttls() self.asyncSMTP.starttls()
if self.settings["mail_password"]: if self.settings["mail_password"]:
self.asyncSMTP.login(str(self.settings["mail_login"]), str(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)
# 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.asyncSMTP.quit()
self._handleSuccess() self._handleSuccess()
if sys.version_info < (3, 0): if sys.version_info < (3, 0):
smtplib.stderr = org_smtpstderr smtplib.stderr = org_smtpstderr
except (MemoryError) as e:
log.debug_or_exception(e)
self._handleError(u'MemoryError sending email: ' + str(e))
except (smtplib.SMTPException, smtplib.SMTPAuthenticationError) as e:
log.debug_or_exception(e)
if hasattr(e, "smtp_error"):
text = e.smtp_error.decode('utf-8').replace("\n", '. ')
elif hasattr(e, "message"):
text = e.message
elif hasattr(e, "args"):
text = '\n'.join(e.args)
else:
text = ''
self._handleError(u'Smtplib Error sending email: ' + text)
except (socket.error) as e:
log.debug_or_exception(e)
self._handleError(u'Socket Error sending email: ' + e.strerror)
def send_gmail_email(self, message):
return gmail.send_messsage(self.settings.get('mail_gmail_token', None), message)
@property @property
def progress(self): def progress(self):
@ -205,13 +221,13 @@ class TaskEmail(CalibreTask):
@classmethod @classmethod
def _get_attachment(cls, bookpath, filename): def _get_attachment(cls, bookpath, filename):
"""Get file as MIMEBase message""" """Get file as MIMEBase message"""
calibrepath = config.config_calibre_dir calibre_path = config.config_calibre_dir
if config.config_use_google_drive: if config.config_use_google_drive:
df = gdriveutils.getFileFromEbooksFolder(bookpath, filename) df = gdriveutils.getFileFromEbooksFolder(bookpath, filename)
if df: if df:
datafile = os.path.join(calibrepath, bookpath, filename) datafile = os.path.join(calibre_path, bookpath, filename)
if not os.path.exists(os.path.join(calibrepath, bookpath)): if not os.path.exists(os.path.join(calibre_path, bookpath)):
os.makedirs(os.path.join(calibrepath, bookpath)) os.makedirs(os.path.join(calibre_path, bookpath))
df.GetContentFile(datafile) df.GetContentFile(datafile)
else: else:
return None return None
@ -221,19 +237,22 @@ class TaskEmail(CalibreTask):
os.remove(datafile) os.remove(datafile)
else: else:
try: try:
file_ = open(os.path.join(calibrepath, bookpath, filename), 'rb') file_ = open(os.path.join(calibre_path, bookpath, filename), 'rb')
data = file_.read() data = file_.read()
file_.close() file_.close()
except IOError as e: except IOError as e:
log.debug_or_exception(e) log.debug_or_exception(e)
log.error(u'The requested file could not be read. Maybe wrong permissions?') log.error(u'The requested file could not be read. Maybe wrong permissions?')
return None return None
# Set mimetype
attachment = MIMEBase('application', 'octet-stream') 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) attachment.set_payload(data)
encoders.encode_base64(attachment) encoders.encode_base64(attachment)
attachment.add_header('Content-Disposition', 'attachment', attachment.add_header('Content-Disposition', 'attachment', filename=filename)
filename=filename)
return attachment return attachment
@property @property

View File

@ -55,6 +55,7 @@
<div class="col"> <div class="col">
<h2>{{_('E-mail Server Settings')}}</h2> <h2>{{_('E-mail Server Settings')}}</h2>
{% if config.get_mail_server_configured() %} {% if config.get_mail_server_configured() %}
{% if email.mail_server_type == 0 %}
<div class="col-xs-12 col-sm-12"> <div class="col-xs-12 col-sm-12">
<div class="row"> <div class="row">
<div class="col-xs-6 col-sm-3">{{_('SMTP Hostname')}}</div> <div class="col-xs-6 col-sm-3">{{_('SMTP Hostname')}}</div>
@ -77,6 +78,18 @@
<div class="col-xs-6 col-sm-3">{{email.mail_from}}</div> <div class="col-xs-6 col-sm-3">{{email.mail_from}}</div>
</div> </div>
</div> </div>
{% else %}
<div class="col-xs-12 col-sm-12">
<div class="row">
<div class="col-xs-6 col-sm-3">{{_('E-Mail Service')}}</div>
<div class="col-xs-6 col-sm-3">{{_('Gmail via Oauth2')}}</div>
</div>
<div class="row">
<div class="col-xs-6 col-sm-3">{{_('From E-mail')}}</div>
<div class="col-xs-6 col-sm-3">{{email.mail_gmail_token['email']}}</div>
</div>
</div>
{% endif %}
{% endif %} {% endif %}
<a class="btn btn-default emailconfig" id="admin_edit_email" href="{{url_for('admin.edit_mailsettings')}}">{{_('Edit E-mail Server Settings')}}</a> <a class="btn btn-default emailconfig" id="admin_edit_email" href="{{url_for('admin.edit_mailsettings')}}">{{_('Edit E-mail Server Settings')}}</a>
</div> </div>

View File

@ -7,6 +7,7 @@
<div class="discover"> <div class="discover">
<h1>{{title}}</h1> <h1>{{title}}</h1>
<form role="form" class="col-md-10 col-lg-6" method="POST"> <form role="form" class="col-md-10 col-lg-6" method="POST">
{% if feature_support['gmail'] %}
<div class="form-group"> <div class="form-group">
<label for="mail_server_type">{{_('Choose Server Type')}}</label> <label for="mail_server_type">{{_('Choose Server Type')}}</label>
<select name="mail_server_type" id="config_email_type" class="form-control" data-control="email-settings"> <select name="mail_server_type" id="config_email_type" class="form-control" data-control="email-settings">
@ -24,6 +25,7 @@
</div> </div>
</div> </div>
<div data-related="email-settings-0"> <div data-related="email-settings-0">
{% endif %}
<div class="form-group"> <div class="form-group">
<label for="mail_server">{{_('SMTP Hostname')}}</label> <label for="mail_server">{{_('SMTP Hostname')}}</label>
<input type="text" class="form-control" name="mail_server" id="mail_server" value="{{content.mail_server}}" required> <input type="text" class="form-control" name="mail_server" id="mail_server" value="{{content.mail_server}}" required>
@ -61,8 +63,10 @@
</div> </div>
<button type="submit" name="submit" value="submit" class="btn btn-default">{{_('Save')}}</button> <button type="submit" name="submit" value="submit" class="btn btn-default">{{_('Save')}}</button>
<button type="submit" name="test" value="test" class="btn btn-default">{{_('Save and Send Test E-mail')}}</button> <button type="submit" name="test" value="test" class="btn btn-default">{{_('Save and Send Test E-mail')}}</button>
{% if feature_support['gmail'] %}
</div> </div>
<a href="{{ url_for('admin.admin') }}" id="back" class="btn btn-default">{{_('Cancel')}}</a> {% endif %}
<a href="{{ url_for('admin.admin') }}" id="back" class="btn btn-default">{{_('Back')}}</a>
</form> </form>
{% if g.allow_registration %} {% if g.allow_registration %}
<div class="col-md-10 col-lg-6"> <div class="col-md-10 col-lg-6">

1
gmail.json Normal file
View File

@ -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"}}