feat: generic OAuth for email
This commit is contained in:
parent
b930cb923b
commit
ebc5861210
7 changed files with 176 additions and 114 deletions
|
|
@ -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 += ` <a target="_blank" href="https://docs.erpnext.com/docs/v13/user/manual/en/setting-up/email/email_account_setup_with_gmail">${cta}</a>`;
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
100
frappe/email/oauth.py
Normal file
100
frappe/email/oauth.py
Normal file
|
|
@ -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]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ""))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue