Skip to content

feat(email-oauth): one-click Email oauth mail sign-in for V2 sites - #506

Merged
prathameshkurunkar7 merged 9 commits into
frappe:developfrom
RitvikSardana:feat/email-oauth-relay
Oct 22, 2026
Merged

prathameshkurunkar7 merged 9 commits into
frappe:developfrom
RitvikSardana:feat/email-oauth-relay

Conversation

@RitvikSardana

@RitvikSardana RitvikSardana commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

What this does

A site on Frappe Cloud V2 can connect a mailbox in one click. The site uses the mail provider app that Frappe Cloud owns, for example Microsoft or Google. Central keeps the secret of each provider app. Central does not give the secret to a site. The site gets only the tokens for its own mailbox.

  • New DocType Email OAuth Provider. Each record is one mail provider app that Frappe Cloud owns. The record has these values: enabled or not, the client ID and secret, the authorize URL, the token URL, the scopes and the extra query parameters.
  • Install, migrate and the test setup add a record for microsoft, google, yahoo and yandex if the record is not there. Each new record is disabled. These hooks do not change a record that is already there.
  • You cannot enable a provider without a client ID and a client secret. The two URLs must start with https://.
  • central.api.email_oauth.start: a site calls this method through its Pilot. Central makes sure that the return URL is the email callback of a site on that server. Central keeps a note for 10 minutes, with state as the key. Then Central sends back the sign-in link of the provider (PKCE, S256) and the scopes.
  • central.api.email_oauth.callback: the browser comes to this method from the provider. It is the Redirect URI of each provider app, and each state works one time only. Central sends the code back to the site only if the Central user can see the server of that site. If not, Central sends not_team_member and no code. Thus a site cannot get the code of a different team. If the browser is logged out, Central sends it to the dashboard login one time, and then back with the same code.
  • central.api.email_oauth.relay_token: a site calls this method through its Pilot. Central uses the app secret to change a code or a refresh token into new tokens at the provider. If the provider refuses, Central sends back the OAuth error code of the provider, for example invalid_grant. Thus the site knows when a token is dead.
  • If a provider has no record, or its record is disabled, start and relay_token send back the code provider_unavailable. The site then shows its usual mailbox form. Thus an admin can always add a mailbox, also when a provider is off.
  • Dashboard authRedirect.ts: after login, the login page can send the browser back to the email callback.

How it works

flowchart TD
    A["Admin selects a provider<br/>in Helpdesk or Desk"] -- "admin's site login" --> S1

    subgraph Site["Site (frappe)"]
        S1{"Can this user create<br/>an Email Account?"} --> S2["Make state + PKCE verifier,<br/>keep a 10 min note: user, verifier"]
    end

    S2 -- "pilot_auth_token" --> P1

    subgraph Pilot["Site's Pilot"]
        P1{"Token valid for this site?<br/>Method on the allowlist?"} --> P2["Send to Central"]
    end

    P2 -- "Pilot credential" --> C0

    subgraph CS["Central: start"]
        C0{"Provider enabled?"} -- no --> C4["provider_unavailable:<br/>the site shows its usual form"]
        C0 -- yes --> C1["Pilot credential gives team + server"]
        C1 --> C2{"return_url on a site<br/>of this server?"}
        C2 --> C3["Keep a 10 min note under state,<br/>send back the provider's sign-in link"]
    end

    C3 -- "link goes back to the browser" --> M1["<b>Mail provider</b><br/>admin signs in and gives consent,<br/>browser goes to Central with code + state"]

    M1 -- "browser's Central login" --> CB1

    subgraph CC["Central: callback"]
        CB1["Find the note by state, delete it"] --> CB2{"Can this user see<br/>the site's server?"}
        CB2 -- no --> CB3["Back to the site with<br/>not_team_member, no code"]
        CB2 -- yes --> CB4["Back to the site with the code"]
    end

    CB4 -- "admin's site login" --> F1

    subgraph SC["Site: callback"]
        F1{"Note for this state,<br/>made by this user?"} --> F2["Change the code into tokens: Pilot, then Central relay_token.<br/>Central adds the app secret and asks the provider"]
        F2 --> F3["Save the Connected App (Token URI frappe-cloud-relay:{provider}, no secret),<br/>the Token Cache and the Email Account"]
    end

    F3 --> OK(["Mailbox connected"])
Loading

Every 10 minutes after that:

flowchart TD
    R0["Scheduler: email_account.pull"] --> R1{"Access token expired?"}
    R1 -- yes --> R2{"Token URI starts with<br/>frappe-cloud-relay:?"}
    R2 -- no --> R3["Refresh at its own Token URI, as before"]
    R2 -- yes --> R4["Pilot, then Central relay_token<br/>with grant_type=refresh_token"]
    R4 --> R5(["New tokens in the Token Cache,<br/>IMAP and SMTP sign in with them"])
Loading

The site does not keep a provider secret or a Central secret. Its only key is pilot_auth_token, and it sends this key only to its own Pilot.

The flow in detail
sequenceDiagram
    autonumber
    actor Admin as Admin's browser
    participant Site as Site (frappe)
    participant Pilot as Site's Pilot
    participant Central
    participant MP as Mail provider

    Admin->>Site: Add a mailbox
    Note over Site: state + PKCE verifier,<br/>kept in the site's cache for 10 min
    Site->>Pilot: start(provider, state, code_challenge, return_url)
    Pilot->>Central: start (Pilot credential)
    Note over Central: provider must be enabled, else provider_unavailable<br/>return_url must be the email callback<br/>of a site on this server
    Central-->>Site: authorize_url, client_id, scopes
    Site-->>Admin: redirect to authorize_url
    Admin->>MP: sign in and give consent
    MP-->>Admin: redirect to Central's callback
    Admin->>Central: callback(state, code)
    Note over Central: each note works one time only<br/>logged out: dashboard login one time, then back<br/>can this user see the server? else not_team_member
    Central-->>Admin: redirect to the site's callback
    Admin->>Site: callback(state, code)
    Site->>Pilot: relay_token(authorization_code, code, code_verifier)
    Pilot->>Central: relay_token
    Central->>MP: token request with Frappe Cloud's client secret
    MP-->>Central: access, refresh and ID token
    Central-->>Site: tokens (through Pilot)
    Note over Site: Connected App with Token URI<br/>frappe-cloud-relay:{provider}, no secret,<br/>and the Email Account

    loop when the access token expires
        Site->>Pilot: relay_token(refresh_token)
        Pilot->>Central: relay_token
        Central->>MP: refresh with the client secret
        MP-->>Central: new tokens
        Central-->>Site: new tokens (through Pilot)
    end
Loading

Setup

Do these steps for each provider:

  1. Register an app at the provider, for example in the Azure portal for Microsoft.
  2. Add https://<central host>/api/method/central.api.email_oauth.callback as a Web Redirect URI of the app.
  3. Open the Email OAuth Provider record of the provider, for example microsoft.
  4. Put in the client ID and the client secret of the app.
  5. Select Enabled.
image image

For a provider that has no record, add a record with its authorize URL, token URL and scopes. The site side has its own list of the services that can sign in in one click (ONE_CLICK_PROVIDERS in frappe/frappe#44677).

Testing

  • bench --site <site> run-tests --module central.tests.test_email_oauth: 16 tests pass.
  • node --test dashboard/tests/auth-redirect.test.mjs: 2 tests pass. They check the return URL of the login page.
  • On a local setup (a V2 site, its Pilot and Central), I connected and reconnected a real Microsoft 365 mailbox from start to end.

Related

Closes #84

@greptile-apps

greptile-apps Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[High impact] The changes since the last review appear safe to merge.

Reviews (7) · Last reviewed commit: "fix(email-oauth): mark the provider-unav..." · Reviewed by Greptile

Comment thread central/api/email_oauth.py Resolved
Comment thread central/api/email_oauth.py Resolved
Comment thread central/api/email_oauth.py Resolved
Comment thread dashboard/src/lib/authRedirect.ts Resolved
Comment thread central/tests/test_email_oauth.py Resolved
Comment thread central/api/email_oauth.py Resolved
@RitvikSardana
RitvikSardana force-pushed the feat/email-oauth-relay branch from ec2f75e to 69d609d Compare October 18, 2026 18:31
Comment thread central/integrations/email_oauth.py Resolved
@RitvikSardana
RitvikSardana force-pushed the feat/email-oauth-relay branch from 69d609d to fbeb3a2 Compare October 18, 2026 18:40
@RitvikSardana RitvikSardana changed the title feat(email-oauth): relay one-click mailbox sign-in for V2 sites feat(email-oauth): one-click mailbox sign-in for V2 sites Oct 21, 2026
@prathameshkurunkar7 prathameshkurunkar7 changed the title feat(email-oauth): one-click mailbox sign-in for V2 sites feat(email-oauth): one-click Email oauth mail sign-in for V2 sites Oct 22, 2026
@prathameshkurunkar7
prathameshkurunkar7 merged commit f002b21 into frappe:develop Oct 22, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: OAuth app

2 participants