Skip to content

Commit

Permalink
feat: email oauth (v13) (frappe#20790)
Browse files Browse the repository at this point in the history
  • Loading branch information
phot0n authored Apr 20, 2023
1 parent 83d1454 commit b4b6b66
Show file tree
Hide file tree
Showing 11 changed files with 273 additions and 93 deletions.
8 changes: 0 additions & 8 deletions frappe/core/doctype/user/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -769,14 +769,6 @@ def get_email_awaiting(user):
)
if waiting:
return waiting
else:
frappe.db.sql(
"""update `tabUser Email`
set awaiting_password =0
where parent = %(user)s""",
{"user": user},
)
return False


def ask_pass_update():
Expand Down
49 changes: 40 additions & 9 deletions frappe/email/doctype/email_account/email_account.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ frappe.ui.form.on("Email Account", {
frm.set_value(key, value);
});
}
frm.events.show_gmail_message_for_less_secure_apps(frm);
},

use_imap: function(frm) {
Expand Down Expand Up @@ -109,27 +108,59 @@ frappe.ui.form.on("Email Account", {
onload: function(frm) {
frm.set_df_property("append_to", "only_select", true);
frm.set_query("append_to", "frappe.email.doctype.email_account.email_account.get_append_to");
frm.events.show_oauth_authorization_message(frm);
},

refresh: function(frm) {
frm.events.set_domain_fields(frm);
frm.events.enable_incoming(frm);
frm.events.notify_if_unreplied(frm);
frm.events.show_gmail_message_for_less_secure_apps(frm);

if(frappe.route_flags.delete_user_from_locals && frappe.route_flags.linked_user) {
delete frappe.route_flags.delete_user_from_locals;
delete locals['User'][frappe.route_flags.linked_user];
}
},

show_gmail_message_for_less_secure_apps: function(frm) {
frm.dashboard.clear_headline();
if (frm.doc.service==="GMail") {
let msg = __("GMail will only work if you enable 2-step authentication and use app-specific password.");
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>`;
frm.dashboard.set_headline_alert(msg);
authorize_api_access: function (frm) {
frm.events.oauth_access(frm);
},

oauth_access: function(frm) {
frappe.model.with_doc("Connected App", frm.doc.connected_app, () => {
const connected_app = frappe.get_doc("Connected App", frm.doc.connected_app);
return frappe.call({
doc: connected_app,
method: "initiate_web_application_flow",
args: {
success_uri: window.location.pathname,
user: frm.doc.connected_user,
},
callback: function (r) {
window.open(r.message, "_self");
},
});
});
},

show_oauth_authorization_message(frm) {
if (frm.doc.auth_method === "OAuth") {
frappe.call({
method: "frappe.integrations.doctype.connected_app.connected_app.has_token",
args: {
connected_app: frm.doc.connected_app,
connected_user: frm.doc.connected_user,
},
callback: (r) => {
if (!r.message) {
let msg = __(
'OAuth has been enabled but not authorised. Please use "Authorise API Access" button to do the same.'
);
frm.dashboard.clear_headline();
frm.dashboard.set_headline_alert(msg, "yellow");
}
},
});
}
},

Expand Down
40 changes: 38 additions & 2 deletions frappe/email/doctype/email_account/email_account.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@
"domain",
"service",
"authentication_column",
"auth_method",
"password",
"awaiting_password",
"ascii_encode_password",
"authorize_api_access",
"column_break_10",
"connected_app",
"connected_user",
"login_id_is_different",
"login_id",
"mailbox_settings",
Expand Down Expand Up @@ -97,6 +101,7 @@
"label": "Email Login ID"
},
{
"depends_on": "eval: doc.auth_method === \"Basic\"",
"fieldname": "password",
"fieldtype": "Password",
"hide_days": 1,
Expand All @@ -105,6 +110,7 @@
},
{
"default": "0",
"depends_on": "eval: doc.auth_method === \"Basic\"",
"fieldname": "awaiting_password",
"fieldtype": "Check",
"hide_days": 1,
Expand All @@ -113,6 +119,7 @@
},
{
"default": "0",
"depends_on": "eval: doc.auth_method === \"Basic\"",
"fieldname": "ascii_encode_password",
"fieldtype": "Check",
"hide_days": 1,
Expand Down Expand Up @@ -571,12 +578,41 @@
"fieldname": "use_starttls",
"fieldtype": "Check",
"label": "Use STARTTLS"
},
{
"default": "Basic",
"fieldname": "auth_method",
"fieldtype": "Select",
"label": "Method",
"options": "Basic\nOAuth"
},
{
"depends_on": "eval: doc.auth_method === \"OAuth\"",
"fieldname": "connected_app",
"fieldtype": "Link",
"label": "Connected App",
"mandatory_depends_on": "eval: doc.auth_method === \"OAuth\"",
"options": "Connected App"
},
{
"depends_on": "eval: doc.auth_method === \"OAuth\"",
"fieldname": "connected_user",
"fieldtype": "Link",
"label": "Connected User",
"mandatory_depends_on": "eval: doc.auth_method === \"OAuth\"",
"options": "User"
},
{
"depends_on": "eval: doc.auth_method === \"OAuth\" && !doc.__islocal && !doc.__unsaved",
"fieldname": "authorize_api_access",
"fieldtype": "Button",
"label": "Authorize API Access"
}
],
"icon": "fa fa-inbox",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2022-08-19 10:26:00.762228",
"modified": "2023-04-20 17:47:17.222361",
"modified_by": "Administrator",
"module": "Email",
"name": "Email Account",
Expand All @@ -598,4 +634,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}
}
53 changes: 39 additions & 14 deletions frappe/email/doctype/email_account/email_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,17 +81,18 @@ def validate(self):
if frappe.local.flags.in_patch or frappe.local.flags.in_test:
return

# if self.enable_incoming and not self.append_to:
# frappe.throw(_("Append To is mandatory for incoming mails"))

use_oauth = self.auth_method == "OAuth"
self.use_starttls = cint(self.use_imap and self.use_starttls and not self.use_ssl)

if (
not self.awaiting_password
and not frappe.local.flags.in_install
and not frappe.local.flags.in_patch
):
if self.password or self.smtp_server in ("127.0.0.1", "localhost"):
validate_oauth = False
if use_oauth:
# no need for awaiting password for oauth
self.awaiting_password = 0
self.password = None
validate_oauth = not (self.is_new() and not self.get_oauth_token())

if not self.awaiting_password and not frappe.local.flags.in_install:
if validate_oauth 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
Expand All @@ -100,7 +101,8 @@ def validate(self):
self.check_smtp()
else:
if self.enable_incoming or (self.enable_outgoing and not self.no_smtp_authentication):
frappe.throw(_("Password is required or select Awaiting Password"))
if not use_oauth:
frappe.throw(_("Password is required or select Awaiting Password"))

if self.notify_if_unreplied:
if not self.send_notification_to:
Expand Down Expand Up @@ -188,12 +190,15 @@ def check_smtp(self):
if not self.smtp_server:
frappe.throw(_("{0} is required").format("SMTP Server"))

oauth_token = self.get_oauth_token()
server = SMTPServer(
login=getattr(self, "login_id", None) or self.email_id,
server=self.smtp_server,
port=cint(self.smtp_port),
use_tls=cint(self.use_tls),
use_ssl=cint(self.use_ssl_for_outgoing),
use_oauth=self.auth_method == "OAuth",
access_token=oauth_token.get_password("access_token") if oauth_token else None,
)
if self.password and not self.no_smtp_authentication:
server.password = self.get_password()
Expand All @@ -205,6 +210,7 @@ def get_incoming_server(self, in_receive=False, email_sync_rule="UNSEEN"):
if frappe.cache().get_value("workers:no-internet") == True:
return None

oauth_token = self.get_oauth_token()
args = frappe._dict(
{
"email_account": self.name,
Expand All @@ -217,6 +223,8 @@ def get_incoming_server(self, in_receive=False, email_sync_rule="UNSEEN"):
"uid_validity": self.uidvalidity,
"incoming_port": get_port(self),
"initial_sync_count": self.initial_sync_count or 100,
"use_oauth": self.auth_method == "OAuth",
"access_token": oauth_token.get_password("access_token") if oauth_token else None,
}
)

Expand Down Expand Up @@ -848,6 +856,11 @@ def append_email_to_sent_folder(self, message):
except Exception:
frappe.log_error()

def get_oauth_token(self):
if self.auth_method == "OAuth":
connected_app = frappe.get_doc("Connected App", self.connected_app)
return connected_app.get_active_token(self.connected_user)


@frappe.whitelist()
def get_append_to(
Expand Down Expand Up @@ -939,15 +952,27 @@ def notify_unreplied():

def pull(now=False):
"""Will be called via scheduler, pull emails from all enabled Email accounts."""
from frappe.integrations.doctype.connected_app.connected_app import has_token

if frappe.cache().get_value("workers:no-internet") == True:
if test_internet():
frappe.cache().set_value("workers:no-internet", False)
else:
return

queued_jobs = get_jobs(site=frappe.local.site, key="job_name")[frappe.local.site]
for email_account in frappe.get_list(
"Email Account", filters={"enable_incoming": 1, "awaiting_password": 0}

for email_account in frappe.get_all(
"Email Account",
filters={"enable_incoming": 1, "awaiting_password": 0},
fields=["name", "connected_user", "connected_app", "auth_method"],
):
if email_account.auth_method == "OAuth" and not has_token(
email_account.connected_app, email_account.connected_user
):
# don't try to pull from accounts which dont have access token (for Oauth)
continue

if now:
pull_from_email_account(email_account.name)

Expand Down Expand Up @@ -1068,10 +1093,10 @@ def remove_user_email_inbox(email_account):
doc.save(ignore_permissions=True)


@frappe.whitelist(allow_guest=False)
@frappe.whitelist()
def set_email_password(email_account, user, password):
account = frappe.get_doc("Email Account", email_account)
if account.awaiting_password:
if account.awaiting_password and account.auth_method != "OAuth":
account.awaiting_password = 0
account.password = password
try:
Expand Down
73 changes: 73 additions & 0 deletions frappe/email/oauth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import base64
from imaplib import IMAP4
from poplib import POP3
from smtplib import SMTP

import frappe


class Oauth:
def __init__(
self,
conn,
email_account,
email,
access_token,
mechanism="XOAUTH2",
):

self.email_account = email_account
self.email = email
self._mechanism = mechanism
self._conn = conn
self._access_token = access_token

self._validate()

def _validate(self) -> None:
if not self._access_token:
frappe.throw(
frappe._("Please Authorize OAuth for Email Account {}").format(self.email_account),
title=frappe._("OAuth Error"),
)

@property
def _auth_string(self) -> str:
return f"user={self.email}\1auth=Bearer {self._access_token}\1\1"

def connect(self) -> None:
try:
if isinstance(self._conn, POP3):
self._connect_pop()

elif isinstance(self._conn, IMAP4):
self._connect_imap()

else:
# SMTP
self._connect_smtp()

except Exception:
frappe.log_error(
title="Email Connection Error - Authentication Failed",
)
# raising a bare exception here as we have a lot of exception handling present
# where the connect method is called from - hence just logging and raising.
raise

def _connect_pop(self) -> None:
# NOTE: poplib doesn't have AUTH command implementation
res = self._conn._shortcmd(
"AUTH {} {}".format(
self._mechanism, base64.b64encode(bytes(self._auth_string, "utf-8")).decode("utf-8")
)
)

if not res.startswith(b"+OK"):
raise

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)
Loading

0 comments on commit b4b6b66

Please sign in to comment.