better-auth-mail
v0.1.3
Published
Better Auth plugin for Gmail + Outlook (Microsoft Graph) integration.
Readme
better-auth-mail
Better Auth plugin that adds Gmail + Outlook (Microsoft Graph) integrations.
Status
Gmail watch + Pub/Sub webhook processing is implemented.
Outlook (Microsoft Graph) is not implemented yet.
This package stores no email bodies or attachments—only minimal state (watch/subscription cursors + expirations).
Quickstart (Gmail)
bun add better-auth-mail- In your Better Auth config:
- enable
account.encryptOAuthTokens+account.updateAccountOnSignIn - configure
socialProviders.googlewithaccessType: "offline"andprompt: "consent" - add the plugin with:
providerIds.gmail = "google"gmail.pubsubTopicName = "projects/<gcp-project-id>/topics/<topic>"maintenanceKey = <random secret>
- enable
- In Google Cloud:
- create the Pub/Sub topic
- grant publisher to
[email protected] - create a push subscription to:
https://<your-domain>/api/auth/better-auth-mail/webhooks/gmail
- include header:
x-better-auth-mail-maintenance-key: <maintenanceKey>
- In your app UI: call
POST /better-auth-mail/enablewith{ provider: "gmail" } - Verify:
GET /better-auth-mail/statusshows Gmailvalid: true- send yourself an email and confirm your
onEventshandler fires (or webhook returnsprocessed > 0)
- Run maintenance periodically (scheduler/worker of your choice):
POST /better-auth-mail/maintenance/renewwith the maintenance header
Quickstart (Outlook / Microsoft Graph)
- Configure Better Auth
socialProviders.microsoftand request:Mail.Readoffline_access
- Add plugin config:
providerIds.outlook = "microsoft"- ensure Better Auth
baseURLis set (or passoutlook.notificationUrl)
- Microsoft Graph will validate your webhook URL via a GET request with
validationToken:GET /better-auth-mail/webhooks/graph?validationToken=...(the plugin supports this)
- In your app UI: call
POST /better-auth-mail/enablewith{ provider: "outlook" } - Verify:
GET /better-auth-mail/statusshows Outlookvalid: true
Notes:
- Graph will call your webhook with a GET
validationTokenduring subscription creation. - Subscriptions expire and must be renewed via
POST /better-auth-mail/maintenance/renew.
Install
bun add better-auth-mailServer usage (Better Auth plugin)
import { betterAuth } from "better-auth";
import {
betterAuthMail,
DEFAULT_REQUIRED_SCOPES,
GMAIL_SCOPE_READONLY,
} from "better-auth-mail";
export const auth = betterAuth({
account: {
// recommended when storing refresh tokens in the DB
encryptOAuthTokens: true,
updateAccountOnSignIn: true,
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
// Needed to reliably get a refresh token from Google for server-side sync/webhooks.
accessType: "offline",
// Often needed to get a refresh token for existing users.
prompt: "consent",
// You can also request these incrementally via linkSocial({ scopes }) later.
scope: [GMAIL_SCOPE_READONLY],
},
},
plugins: [
betterAuthMail({
providerIds: {
gmail: "google",
outlook: "microsoft",
},
// optional override
requiredScopes: DEFAULT_REQUIRED_SCOPES,
maintenanceKey: process.env.BETTER_AUTH_MAIL_MAINTENANCE_KEY!,
gmail: {
pubsubTopicName: process.env.GMAIL_PUBSUB_TOPIC_NAME!, // projects/<gcp-project-id>/topics/<topic>
},
}),
],
});Client usage
import { createAuthClient } from "better-auth/client";
import { betterAuthMailClient } from "better-auth-mail";
export const authClient = createAuthClient({
plugins: [betterAuthMailClient()],
});Search/list messages from the client
// Gmail search (Gmail UI search syntax)
const gmail = await authClient.betterAuthMail.messages({
provider: "gmail",
q: "from:stripe has:attachment newer_than:30d",
include: "metadata",
maxResults: 25,
});
// Outlook search (Graph $search)
const outlook = await authClient.betterAuthMail.messages({
provider: "outlook",
q: "invoice",
});Gmail setup (Pub/Sub + watch)
1) OAuth scopes (Google)
This plugin’s default Gmail scope requirement is:
https://www.googleapis.com/auth/gmail.readonly
If a user signed up without Gmail scopes, you must re-run OAuth for that provider (incremental consent). In Better Auth that’s typically done via:
authClient.linkSocial({ provider: "google", scopes: [GMAIL_SCOPE_READONLY] })
2) Pub/Sub topic + push subscription (Google Cloud)
Per app:
- Create a Pub/Sub topic named by
GMAIL_PUBSUB_TOPIC_NAME:- Format:
projects/<gcp-project-id>/topics/<topic>
- Format:
- Grant Gmail permission to publish to your topic:
- Grant the Gmail push service account publisher access on the topic:
- Create a push subscription pointing to your webhook endpoint.
3) Webhook endpoint
The plugin exposes:
POST /better-auth-mail/webhooks/gmail
Because Better Auth is usually mounted under /api/auth, your full URL will typically look like:
https://<your-domain>/api/auth/better-auth-mail/webhooks/gmail
4) Webhook authentication
The plugin currently protects webhooks with a shared secret header:
x-better-auth-mail-maintenance-key: <BETTER_AUTH_MAIL_MAINTENANCE_KEY>
Configure your Pub/Sub push subscription to include this header.
Using it
Enable Gmail for the signed-in user
Call the plugin endpoint (either via the inferred client API or the helper actions from betterAuthMailClient()):
POST /better-auth-mail/enablewith{ provider: "gmail" }
On success, the plugin:
- Calls Gmail
users.watch - Stores
historyIdandwatchExpiration - Stores the mailbox
emailAddressfor webhook routing
List/search messages
GET /better-auth-mail/messages?provider=gmail&q=<query>&pageToken=<token>&maxResults=<n>&include=ids|metadata
Notes:
quses Gmail’s search syntax (same as the Gmail UI search box).include=idsreturns message IDs + thread IDs (fast, no per-message fetch).include=metadatareturns basic metadata (subject/from/to/snippet/date/unread). This reduces client-side N+1 calls, but the server will make one Gmail API call per message ID.
Get a message (metadata + attachment list)
GET /better-auth-mail/messages/<messageId>?provider=gmail
Download an attachment
GET /better-auth-mail/messages/<messageId>/attachments/<attachmentId>?provider=gmail&filename=<optional>
Check status
GET /better-auth-mail/status
Returns per provider:
enabled(user opted in)valid(has account + refresh token + required scopes + active watch/subscription)reasons(actionable reasons likeMISSING_REFRESH_TOKEN,MISSING_SCOPES,SUBSCRIPTION_EXPIRED)
Watch expiration / renewals
Gmail watches expire. This package exposes a secured maintenance endpoint that your scheduler/worker can call (TBD):
POST /better-auth-mail/maintenance/renew
Send:
x-better-auth-mail-maintenance-key: <BETTER_AUTH_MAIL_MAINTENANCE_KEY>
Body (optional):
{ "provider": "gmail", "renewBeforeMinutes": 60, "limit": 200, "repairMissingState": true }
Repairing missing state
If mailIntegration is enabled but mailGmailState is missing/corrupt (no historyId or watchExpiration), status will show SUBSCRIPTION_MISSING.
To repair, call:
POST /better-auth-mail/maintenance/renewwithrepairMissingState: true(default)
Production note: history cursor expiry
Gmail history cursors (historyId) can become invalid (for example, if you don’t process notifications for too long).
When this happens, webhook processing will re-establish a watch and reset the stored cursor, so you may miss historical events during the gap.
