diff --git a/frappe/email/doctype/email_account/email_account.js b/frappe/email/doctype/email_account/email_account.js index 4392e1db1d..4d84f69395 100644 --- a/frappe/email/doctype/email_account/email_account.js +++ b/frappe/email/doctype/email_account/email_account.js @@ -135,7 +135,7 @@ frappe.ui.form.on("Email Account", { show_gmail_message_for_less_secure_apps: function(frm) { frm.dashboard.clear_headline(); - let msg = __("GMail will only work if you enable 2-step authentication and use app-specific password OR use Google OAuth."); + let msg = __("GMail will only work if you enable 2-step authentication and use app-specific password OR use OAuth."); let cta = __("Read the step by step guide here."); msg += ` ${cta}`; if (frm.doc.service==="GMail") { @@ -143,11 +143,12 @@ frappe.ui.form.on("Email Account", { } }, - authorize_google_api_access: function(frm) { + authorize_api_access: function(frm) { frappe.call({ - method: "frappe.email.doctype.email_account.email_account.authorize_google_access", + method: "frappe.email.doctype.email_account.email_account.oauth_access", args: { "email_account": frm.doc.name, + "service": frm.doc.service, "reauthorize": frm.doc.refresh_token ? 1 : 0 }, callback: function(r) { diff --git a/frappe/email/doctype/email_account/email_account.json b/frappe/email/doctype/email_account/email_account.json index 0f62e103e8..72be4f2e42 100644 --- a/frappe/email/doctype/email_account/email_account.json +++ b/frappe/email/doctype/email_account/email_account.json @@ -18,10 +18,10 @@ "awaiting_password", "ascii_encode_password", "column_break_10", - "use_google_oauth", - "authorize_google_api_access", - "google_refresh_token", - "google_access_token", + "use_oauth", + "authorize_api_access", + "refresh_token", + "access_token", "login_id_is_different", "login_id", "mailbox_settings", @@ -585,28 +585,28 @@ { "default": "0", "depends_on": "eval: doc.service === \"GMail\"", - "fieldname": "use_google_oauth", + "fieldname": "use_oauth", "fieldtype": "Check", - "label": "Use Google OAuth" + "label": "Use OAuth" }, { - "depends_on": "eval: doc.service === \"GMail\" && doc.use_google_oauth && !doc.__islocal", - "fieldname": "authorize_google_api_access", + "depends_on": "eval: doc.service === \"GMail\" && doc.use_oauth && !doc.__islocal", + "fieldname": "authorize_api_access", "fieldtype": "Button", "label": "Authorize API Access" }, { - "fieldname": "google_refresh_token", + "fieldname": "refresh_token", "fieldtype": "Data", "hidden": 1, - "label": "Google Refresh Token", + "label": "Refresh Token", "read_only": 1 }, { - "fieldname": "google_access_token", + "fieldname": "access_token", "fieldtype": "Small Text", "hidden": 1, - "label": "Google Access Token", + "label": "Access Token", "read_only": 1 } ], diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index 7779dea38c..b2f8e1ed64 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -89,8 +89,11 @@ class EmailAccount(Document): if frappe.local.flags.in_patch or frappe.local.flags.in_test: return - if not frappe.local.flags.in_install and (self.use_google_oauth or not self.awaiting_password): - if self.google_refresh_token or self.password or self.smtp_server in ("127.0.0.1", "localhost"): + if getattr(self, "service", "") != "GMail" and self.use_oauth: + self.use_oauth = 0 + + if not frappe.local.flags.in_install and (self.use_oauth or not self.awaiting_password): + if self.refresh_token or self.password or self.smtp_server in ("127.0.0.1", "localhost"): if self.enable_incoming: self.get_incoming_server() self.no_failed = 0 @@ -99,9 +102,9 @@ class EmailAccount(Document): self.validate_smtp_conn() else: if self.enable_incoming or (self.enable_outgoing and not self.no_smtp_authentication): - if self.use_google_oauth: + if self.use_oauth: if not self.is_new(): - frappe.throw(_("Please Authorize Google by using `Authorize API access` button")) + frappe.throw(_("Please Enable OAuth by using `Authorize API access` button")) else: frappe.throw(_("Password is required or select Awaiting Password")) @@ -158,9 +161,9 @@ class EmailAccount(Document): ) def after_insert(self): - if self.use_google_oauth and not self.google_refresh_token: + if self.use_oauth and not self.refresh_token: frappe.msgprint( - _("Please Authorize Google by using `Authorize API access` button"), + _("Please Enable OAuth by using `Authorize API access` button"), indicator="orange" ) @@ -211,13 +214,14 @@ class EmailAccount(Document): "host": self.email_server, "use_ssl": self.use_ssl, "username": getattr(self, "login_id", None) or self.email_id, + "service": getattr(self, "service", None), "use_imap": self.use_imap, "email_sync_rule": email_sync_rule, "incoming_port": get_port(self), "initial_sync_count": self.initial_sync_count or 100, - "use_google_oauth": self.use_google_oauth or 0, - "google_refresh_token": getattr(self, "google_refresh_token", None), - "google_access_token": getattr(self, "google_access_token", None), + "use_oauth": self.use_oauth or 0, + "refresh_token": getattr(self, "refresh_token", None), + "access_token": getattr(self, "access_token", None), } ) @@ -285,7 +289,7 @@ class EmailAccount(Document): @property def _password(self): raise_exception = not ( - self.use_google_oauth or self.no_smtp_authentication or frappe.flags.in_test + self.use_oauth or self.no_smtp_authentication or frappe.flags.in_test ) return self.get_password(raise_exception=raise_exception) @@ -424,9 +428,10 @@ class EmailAccount(Document): "password": self._password, "use_ssl": cint(self.use_ssl_for_outgoing), "use_tls": cint(self.use_tls), - "use_google_oauth": self.use_google_oauth or 0, - "google_refresh_token": getattr(self, "google_refresh_token", None), - "google_access_token": getattr(self, "google_access_token", None), + "service": getattr(self, "service", None), + "use_oauth": self.use_oauth or 0, + "refresh_token": getattr(self, "refresh_token", None), + "access_token": getattr(self, "access_token", None), } def get_smtp_server(self): @@ -799,7 +804,7 @@ def pull(now=False): for email_account in frappe.get_list( "Email Account", filters={"enable_incoming": 1}, - or_filters={"awaiting_password": 0, "use_google_oauth": 1}, + or_filters={"awaiting_password": 0, "use_oauth": 1}, ): if now: pull_from_email_account(email_account.name) @@ -930,9 +935,15 @@ def set_email_password(email_account, user, password): @frappe.whitelist(methods=["POST"]) -def authorize_google_access(email_account, reauthorize=False, code=None): +def oauth_access(email_account: str, reauthorize: bool = False, service: str = None): doctype = "Email Account" - refresh_token = frappe.db.get_value(doctype, email_account, "google_refresh_token") + refresh_token = frappe.db.get_value(doctype, email_account, "refresh_token") + + if service == "GMail": + return authorize_google_access(email_account, reauthorize, refresh_token, doctype) + + +def authorize_google_access(email_account, reauthorize: bool = False, refresh_token: str = None, doctype: str = "Email Account", code: str = None): oauth_obj = GoogleOAuth("mail") if not (refresh_token or code) or reauthorize: @@ -946,5 +957,5 @@ def authorize_google_access(email_account, reauthorize=False, code=None): ) res = oauth_obj.authorize(code, get_request_site_address(True)) - frappe.db.set_value(doctype, email_account, "google_refresh_token", res.get("refresh_token")) - frappe.db.set_value(doctype, email_account, "google_access_token", res.get("access_token")) + frappe.db.set_value(doctype, email_account, "refresh_token", res.get("refresh_token")) + frappe.db.set_value(doctype, email_account, "access_token", res.get("access_token")) diff --git a/frappe/email/oauth.py b/frappe/email/oauth.py new file mode 100644 index 0000000000..b4efac2e07 --- /dev/null +++ b/frappe/email/oauth.py @@ -0,0 +1,100 @@ +import base64 +from typing import Callable, Union +from imaplib import IMAP4 +from poplib import POP3 +from smtplib import SMTP + +import frappe +from frappe.integrations.google_oauth import GoogleOAuth + + +class OAuthenticationError(Exception): + pass + + +class Oauth: + def __init__(self, + conn: Union[IMAP4, POP3, SMTP], + email_account: str, + email: str, + access_token: str, + refresh_token: str, + service: str, + mechanism: str = "XOAUTH2", + ) -> None: + + self.email_account = email_account + self.email = email + self.service = service + self._mechanism = mechanism + self._conn = conn + self._access_token = access_token + self._refresh_token = refresh_token + + self.validate_implementation() + + def validate_implementation(self) -> None: + if self.service != "GMail": + raise NotImplementedError(f"Service {self.service} currently doesn't have oauth implementation.") + + @property + def _auth_string(self) -> str: + return "user=%s\1auth=Bearer %s\1\1" % (self.email, self._access_token) + + def connect(self, _retry: int = 0) -> None: + try: + if isinstance(self._conn, POP3): + res = self._connect_pop() + + if not res.startswith(b"+OK"): + raise + + elif isinstance(self._conn, IMAP4): + self._connect_imap() + + else: + # SMTP + self._connect_smtp() + + except Exception: + # maybe the access token expired - refreshing + access_token = self._refresh_access_token() + print(self._auth_string) + + if not access_token or _retry > 0: + frappe.throw( + frappe._("Authentication Failed. Please Check and Update the credentials."), + OAuthenticationError, + frappe._("OAuth Error"), + ) + + self._access_token = access_token + self.connect(_retry + 1) + + def _connect_pop(self) -> bytes: + # poplib doesn't have AUTH command implementation + res = self._conn._shortcmd( + "AUTH {0} {1}".format(self._mechanism, base64.b64encode(bytes(self._auth_string, "utf-8")).decode("utf-8")) + ) + + return res + + def _connect_imap(self) -> None: + self._conn.authenticate(self._mechanism, lambda x: self._auth_string) + + def _connect_smtp(self) -> None: + self._conn.auth(self._mechanism, lambda x: self._auth_string, initial_response_ok=False) + + def _refresh_access_token(self) -> str: + service_obj = self._get_service_object() + access_token = service_obj.refresh_access_token(self._refresh_token).get("access_token", None) + + # set the new access token in db + frappe.db.set_value("Email Account", self.email_account, "access_token", access_token) + frappe.db.commit() + return access_token + + def _get_service_object(self): + return { + "GMail": GoogleOAuth("mail"), + }[self.service] diff --git a/frappe/email/receive.py b/frappe/email/receive.py index 81ef7927c1..074ec8e77d 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -18,7 +18,7 @@ from email_reply_parser import EmailReplyParser import frappe from frappe import _, safe_decode, safe_encode from frappe.core.doctype.file import MaxFileSizeReachedError, get_random_filename -from frappe.email.utils import connect_google_oauth +from frappe.email.oauth import Oauth from frappe.utils import ( add_days, cint, @@ -100,14 +100,16 @@ class EmailServer: self.settings.host, self.settings.incoming_port, timeout=frappe.conf.get("pop_timeout") ) - if self.settings.use_google_oauth and self.settings.google_refresh_token: - connect_google_oauth( + if self.settings.use_oauth and self.settings.refresh_token: + Oauth( self.imap, self.settings.email_account, self.settings.username, - self.settings.google_access_token, - self.settings.google_refresh_token, - ) + self.settings.access_token, + self.settings.refresh_token, + self.settings.service + ).connect() + else: self.imap.login(self.settings.username, self.settings.password) @@ -131,14 +133,16 @@ class EmailServer: self.settings.host, self.settings.incoming_port, timeout=frappe.conf.get("pop_timeout") ) - if self.settings.use_google_oauth and self.settings.google_refresh_token: - connect_google_oauth( + if self.settings.use_oauth and self.settings.refresh_token: + Oauth( self.pop, self.settings.email_account, self.settings.username, - self.settings.google_access_token, - self.settings.google_refresh_token, - ) + self.settings.access_token, + self.settings.refresh_token, + self.settings.service + ).connect() + else: self.pop.user(self.settings.username) self.pop.pass_(self.settings.password) diff --git a/frappe/email/smtp.py b/frappe/email/smtp.py index cd31aa0d5c..df88ddb986 100644 --- a/frappe/email/smtp.py +++ b/frappe/email/smtp.py @@ -5,7 +5,7 @@ import smtplib import frappe from frappe import _ -from frappe.email.utils import connect_google_oauth +from frappe.email.oauth import Oauth from frappe.utils import cint, cstr @@ -53,9 +53,10 @@ class SMTPServer: port=None, use_tls=None, use_ssl=None, - use_google_oauth=0, - google_refresh_token=None, - google_access_token=None, + use_oauth=0, + refresh_token=None, + access_token=None, + service=None ): self.login = login self.email_account = email_account @@ -64,9 +65,10 @@ class SMTPServer: self._port = port self.use_tls = use_tls self.use_ssl = use_ssl - self.use_google_oauth = use_google_oauth - self.google_refresh_token = google_refresh_token - self.google_access_token = google_access_token + self.use_oauth = use_oauth + self.refresh_token = refresh_token + self.access_token = access_token + self.service = service self._session = None if not self.server: @@ -109,14 +111,15 @@ class SMTPServer: self.secure_session(_session) - if self.use_google_oauth and self.google_refresh_token: - connect_google_oauth( + if self.use_oauth and self.refresh_token: + Oauth( _session, self.email_account, self.login, - self.google_access_token, - self.google_refresh_token, - ) + self.access_token, + self.refresh_token, + self.service + ).connect() elif self.password: res = _session.login(str(self.login or ""), str(self.password or "")) diff --git a/frappe/email/utils.py b/frappe/email/utils.py index 97cfaaa1e7..7fc2e0ff89 100644 --- a/frappe/email/utils.py +++ b/frappe/email/utils.py @@ -1,13 +1,9 @@ # Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors # License: MIT. See LICENSE -import base64 + import imaplib import poplib -import smtplib -from typing import Union -from frappe import _, db, log_error, throw -from frappe.integrations.google_oauth import GoogleAuthenticationError, GoogleOAuth from frappe.utils import cint @@ -20,56 +16,3 @@ def get_port(doc): doc.incoming_port = poplib.POP3_SSL_PORT if doc.use_ssl else poplib.POP3_PORT return cint(doc.incoming_port) - - -def connect_google_oauth( - connection_obj: Union[imaplib.IMAP4, poplib.POP3, smtplib.SMTP], - email_account: str, - email: str, - google_access_token: str, - google_refresh_token: str, - retry: int = 0, -) -> None: - auth_string = "user=%s\1auth=Bearer %s\1\1" % (email, google_access_token) - mechanism = "XOAUTH2" - _func = lambda x: auth_string # noqa: E731 - - try: - if isinstance(connection_obj, poplib.POP3): - # poplib doesn't have AUTH command implementation - res = connection_obj._shortcmd( - "AUTH {0} {1}".format(mechanism, base64.b64encode(bytes(auth_string, "utf-8")).decode("utf-8")) - ) - - if not res.startswith(b"+OK"): - raise - - elif isinstance(connection_obj, imaplib.IMAP4): - connection_obj.authenticate(mechanism, _func) - - else: - # SMTP - connection_obj.auth(mechanism, _func, initial_response_ok=False) - - except Exception: - # maybe the access token expired - refreshing - access_token = refresh_google_access_token(email_account, google_refresh_token) - - if not access_token or retry > 0: - throw( - _("Google Authentication Failed. Please Check and Update the credentials."), - GoogleAuthenticationError, - _("Google Authentication Error"), - ) - - connect_google_oauth( - connection_obj, email_account, email, access_token, google_refresh_token, retry + 1 - ) - - -def refresh_google_access_token(email_account: str, google_refresh_token: str) -> str: - oauth_obj = GoogleOAuth("mail") - res = oauth_obj.refresh_access_token(google_refresh_token) - db.set_value("Email Account", email_account, "google_access_token", res.get("access_token")) - - return res.get("access_token")