DEV Community

Alain Airom (Ayrom)
Alain Airom (Ayrom)

Posted on

Building a Secure, Local Outlook Mail Extractor with Electron, PKCE, and Microsoft Graph

Extracting your email(s) from MS Outlook client

Introduction

Extracting corporate data securely often comes with a major hurdle: credential management. When building tools to parse, back up, or process emails, handling user credentials safely is paramount.

In this post, we’ll break down how to build a desktop application using Electron, TypeScript, and the Microsoft Graph API that allows users to authenticate securely via OAuth 2.0 Authorization Code Flow with PKCE, discover their Outlook folder hierarchy, and safely extract message payloads locally without exposing sensitive access tokens to an external server.

⚠️ Disclaimer & Didactic Scope
Important Note: This application is intended solely for personal use and should be treated as a didactical demonstration. It is explicitly not designed or vetted for accessing corporate production email servers. In an enterprise environment, rigorous security controls — including robust credential encryption at rest, token validation lifecycles, advanced error boundary auditing, and corporate compliance guardrails — must be strictly enforced. Because this project prioritizes architectural clarity over enterprise hardening, it should not be used as a production-ready corporate solution.


The Architecture: Why PKCE in a Desktop App?

Standard OAuth 2.0 authorization code flows traditionally rely on a client_secret hosted on a secure backend server. Desktop applications are considered "public clients"—they run entirely on the user's machine, meaning a client secret cannot be safely hidden within the source code.

To overcome this, we implement Proof Key for Code Exchange (PKCE). Instead of a static secret, the application dynamically generates a cryptographic random string called a code_verifier, hashes it to create a code_challenge, and sends the challenge to the Microsoft Identity Platform. When exchanging the authorization code for tokens, the app sends the original verifier, proving it was the initiator of the login request.

The Lifecycle of the Extraction Flow

Here is exactly how the Electron Main process coordinates with the Renderer UI, a temporary local loopback server, and Microsoft’s APIs to pull the data:

  1. Triggering Auth: The UI (Renderer) sends a message via IPC (IPC "connect") to the Main process.
  2. Spinning Up a Local Server: The Main process boots a temporary, short-lived HTTP loopback server (localhost:8765) to await the authentication callback.
  3. User Login: The app opens the system’s default browser targeting Microsoft’s /authorize endpoint, passing along the PKCE code challenge.
  4. Token Exchange: Once the user consents, Microsoft redirects to localhost:8765 with an authorization code. The local server captures it, shuts down immediately, and the Main process POSTs this code alongside the plain text code_verifier to get the access_tokenand refresh_token.
  5. Data Extraction: The tokens are securely cached locally, enabling recursive calls to Microsoft Graph (/me/mailFolders and /me/mailFolders/{id}/messages) to map folders and stream paginated emails down to disk.

Core Implementation Deep Dive

Let’s look at the actual mechanics making this happen under the hood, starting with how the UI triggers the action and how the Main process orchestrates the secure loopback.

Inter-Process Communication (IPC) Bridge

Electron keeps the UI isolated from the operating system for security. To talk to the filesystem or network libraries safely, we use a preload.ts script to expose a sanitized context bridge:

import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('electronAPI', {
  connect: () => ipcRenderer.send('connect'),
  listFolders: () => ipcRenderer.send('list-folders'),
  startExtraction: (folderIds: string[], exportParams: any) => 
    ipcRenderer.send('start-extraction', { folderIds, exportParams }),
  onProgress: (callback: (status: string) => void) =>
    ipcRenderer.on('progress', (_event, status) => callback(status)),
  onFoldersReturned: (callback: (folders: any[]) => void) =>
    ipcRenderer.on('return-folders', (_event, folders) => callback(folders)),
  onDone: (callback: (result: any) => void) =>
    ipcRenderer.on('done', (_event, result) => callback(result))
});
Enter fullscreen mode Exit fullscreen mode

The Main Process: Managing the PKCE Challenge & Local Loopback

In main.ts, we handle the complex orchestration. When connect is invoked, we spin up a micro HTTP server via Node's native http module to intercept the incoming redirect from the browser.

Here is the structural framework of the token acquisition process:

import { app, BrowserWindow, ipcMain, shell } from 'electron';
import * as http from 'http';
import * as crypto from 'crypto';
import axios from 'axios';

let accessToken: string | null = null;
let codeVerifier: string = '';

// Helper to generate PKCE Code Verifier and Challenge
function generatePKCE() {
  codeVerifier = crypto.randomBytes(32).toString('base64url');
  const codeChallenge = crypto
    .createHash('sha256')
    .update(codeVerifier)
    .digest('base64url');
  return codeChallenge;
}

ipcMain.on('connect', async (event) => {
  const challenge = generatePKCE();

  // 1. Start temporary loopback server
  const server = http.createServer((req, res) => {
    const urlParams = new URL(req.url!, `http://${req.headers.host}`);
    const authCode = urlParams.searchParams.get('code');

    if (authCode) {
      res.writeHead(200, { 'Content-Type': 'text/html' });
      res.end('<h1>Authentication successful! You can close this window.</h1>');

      // Close the server immediately after capturing the code
      server.close();

      // 2. Exchange Code + Verifier for Tokens
      exchangeCodeForToken(authCode, event);
    } else {
      res.writeHead(400).end('Auth code missing');
    }
  });

  server.listen(8765, () => {
    // 3. Open system browser for explicit user authentication
    const authUrl = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?` +
      `client_id=YOUR_CLIENT_ID` +
      `&response_type=code` +
      `&redirect_uri=http://localhost:8765` +
      `&scope=Mail.Read offline_access` +
      `&code_challenge=${challenge}` +
      `&code_challenge_method=S256`;

    shell.openExternal(authUrl);
  });
});

async function exchangeCodeForToken(code: string, event: Electron.IpcMainEvent) {
  try {
    const response = await axios.post(
      'https://login.microsoftonline.com/common/oauth2/v2.0/token',
      new URLSearchParams({
        client_id: 'YOUR_CLIENT_ID',
        grant_type: 'authorization_code',
        code: code,
        redirect_uri: 'http://localhost:8765',
        code_verifier: codeVerifier, // Proves identity without client_secret
      }).toString(),
      { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
    );

    accessToken = response.data.access_token;
    // Cache tokens locally if persistence across sessions is desired

    event.sender.send('progress', 'authenticated');
  } catch (error) {
    console.error('Token exchange failed', error);
  }
}
Enter fullscreen mode Exit fullscreen mode

Fetching and Extracting Hierarchical Data

Once accessToken is set, the application uses it to target the Microsoft Graph API endpoints asynchronously. Because mailboxes can grow infinitely large, the extraction handles recursive folder mapping and cursor-based pagination using the @odata.nextLink property returned by Microsoft.

ipcMain.on('list-folders', async (event) => {
  try {
    // Graph call to list recursive mail folders
    const response = await axios.get('https://graph.microsoft.com/v1.0/me/mailFolders?$top=100', {
      headers: { Authorization: `Bearer ${accessToken}` }
    });

    event.sender.send('return-folders', response.data.value);
  } catch (error) {
    console.error('Failed to fetch mail folders', error);
  }
});

ipcMain.on('start-extraction', async (event, { folderIds, exportParams }) => {
  for (const id of folderIds) {
    let url = `https://graph.microsoft.com/v1.0/me/mailFolders/${id}/messages?$top=50`;

    while (url) {
      const res = await axios.get(url, { headers: { Authorization: `Bearer ${accessToken}` } });
      const messages = res.data.value;

      // Filter, transform, and stream raw email content to a local file path
      // e.g., outputting to JSON, CSV, or raw .eml strings

      url = res.data['@odata.nextLink']; // Keep pulling until pagination ends
    }
  }
  event.sender.send('done', { count: 150, format: 'JSON' });
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

By marrying Electron’s isolated execution architecture with OAuth 2.0 + PKCE, you eliminate the need to maintain an intermediate backend service just to hold secrets or route tokens. The credentials live where the data lives: completely on the client machine. This keeps the integration footprint small, highly performant, and perfectly aligned with privacy best practices.

Thanks for reading 😊

Links

Top comments (0)