@mcinteerj/openclaw-gmail 1.3.1 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -67
- package/package.json +7 -4
- package/src/accounts.ts +8 -0
- package/src/api-client.ts +406 -0
- package/src/auth.ts +217 -0
- package/src/channel.ts +52 -22
- package/src/config.ts +6 -0
- package/src/gmail-client.ts +50 -0
- package/src/gog-client.ts +230 -0
- package/src/mime.ts +37 -0
- package/src/monitor.ts +52 -158
- package/src/onboarding.ts +171 -30
- package/src/outbound-check.ts +67 -107
- package/src/outbound.ts +63 -109
- package/src/quoting.ts +17 -58
package/README.md
CHANGED
|
@@ -1,117 +1,145 @@
|
|
|
1
|
-
#
|
|
1
|
+
# openclaw-gmail
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Gmail channel plugin for [OpenClaw](https://github.com/openclaw/openclaw). Supports two backends:
|
|
4
|
+
|
|
5
|
+
- **API** (recommended) — connects directly via the Gmail API using OAuth2. No external CLI needed.
|
|
6
|
+
- **gog** — shells out to the [gog CLI](https://github.com/jay/gog). The original backend, still fully supported.
|
|
7
|
+
|
|
8
|
+
Both backends coexist — you can run different accounts on different backends.
|
|
4
9
|
|
|
5
10
|
## Installation
|
|
6
11
|
|
|
7
|
-
|
|
12
|
+
```bash
|
|
13
|
+
openclaw plugins install @mcinteerj/openclaw-gmail
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or from a local clone:
|
|
8
17
|
|
|
9
18
|
```bash
|
|
10
|
-
|
|
19
|
+
openclaw plugins install --link /path/to/openclaw-gmail
|
|
11
20
|
```
|
|
12
21
|
|
|
13
|
-
|
|
22
|
+
Requires `openclaw >= 2026.1.0`.
|
|
14
23
|
|
|
15
|
-
|
|
16
|
-
- **Circuit Breaker**: Handles API failures and rate limiting gracefully.
|
|
17
|
-
- **Rich Text**: Markdown support for outbound emails.
|
|
18
|
-
- **Threading**: Native Gmail thread support with quoted reply context.
|
|
19
|
-
- **Archiving**: Automatically archives threads upon reply.
|
|
20
|
-
- **Email Body Sanitisation**: Automatically cleans incoming email bodies for LLM consumption.
|
|
24
|
+
## Setup
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
### Option 1: API backend (recommended)
|
|
23
27
|
|
|
24
|
-
|
|
28
|
+
The API backend connects directly to Gmail — no gog CLI required.
|
|
25
29
|
|
|
26
|
-
|
|
30
|
+
**If you have gog installed**, the onboarding flow will detect your existing OAuth client credentials from `~/.config/gogcli/credentials.json` and reuse them. You only need a one-time browser authorization to get a new refresh token.
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
- **Footer junk removal**: Strips common noise like unsubscribe links, "Sent from my iPhone", and confidentiality notices.
|
|
30
|
-
- **Whitespace cleanup**: Collapses excessive blank lines and trims leading/trailing whitespace.
|
|
31
|
-
- **Signature stripping**: Removes content below `-- ` signature separators by default.
|
|
32
|
+
**If you don't have gog**, you'll need to create a GCP OAuth client:
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
1. Go to [Google Cloud Console → Credentials](https://console.cloud.google.com/apis/credentials)
|
|
35
|
+
2. Create a project (or use an existing one)
|
|
36
|
+
3. Enable the Gmail API
|
|
37
|
+
4. Create an OAuth 2.0 Client ID (type: **Desktop app**)
|
|
38
|
+
5. Copy the Client ID and Client Secret
|
|
34
39
|
|
|
35
|
-
|
|
40
|
+
Then run `openclaw configure`, select Gmail, and follow the prompts. The flow will:
|
|
41
|
+
- Ask for your email address
|
|
42
|
+
- Prompt for client credentials (or reuse gog's)
|
|
43
|
+
- Open a browser for OAuth consent
|
|
44
|
+
- Store the refresh token in your OpenClaw config
|
|
45
|
+
- Set `backend: "api"` on the account
|
|
36
46
|
|
|
37
|
-
|
|
38
|
-
|
|
47
|
+
**Manual config** (if you prefer to skip the wizard):
|
|
48
|
+
|
|
49
|
+
```json5
|
|
50
|
+
{
|
|
51
|
+
"channels": {
|
|
52
|
+
"openclaw-gmail": {
|
|
53
|
+
"accounts": {
|
|
54
|
+
"you@gmail.com": {
|
|
55
|
+
"email": "you@gmail.com",
|
|
56
|
+
"backend": "api",
|
|
57
|
+
"oauth": {
|
|
58
|
+
"clientId": "your-client-id.apps.googleusercontent.com",
|
|
59
|
+
"clientSecret": "your-client-secret",
|
|
60
|
+
"refreshToken": "your-refresh-token"
|
|
61
|
+
},
|
|
62
|
+
"allowFrom": ["*"],
|
|
63
|
+
"pollIntervalMs": 60000
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
39
69
|
```
|
|
40
70
|
|
|
41
|
-
|
|
71
|
+
### Option 2: gog backend
|
|
42
72
|
|
|
43
|
-
|
|
73
|
+
Install the [gog CLI](https://github.com/jay/gog) (v1.2.0+), authorize it (`gog auth add you@gmail.com`), then run `openclaw configure`. The account will use gog by default (no `backend` field needed).
|
|
44
74
|
|
|
45
|
-
|
|
46
|
-
- **Quoted Replies**: By default, replies include the full thread history as quoted text (standard Gmail format: "On [date], [author] wrote:"). This can be disabled per-account or globally.
|
|
47
|
-
- **Allowlist Gatekeeping**: The bot only responds to emails from senders on the `allowFrom` list. However, if an allowed user includes others (CC) who are *not* on the allowlist, the bot will still "Reply All", including them in the conversation. This allows authorized users to bring others into the loop.
|
|
48
|
-
- **Outbound Restrictions** (optional): Use `allowOutboundTo` and `threadReplyPolicy` to control who the bot can send emails to. By default, no outbound restrictions are applied (backwards compatible).
|
|
75
|
+
### Upgrading from gog to API
|
|
49
76
|
|
|
50
|
-
|
|
77
|
+
Existing gog users upgrading the plugin will continue working with no changes — gog remains the default. To migrate an account to the API backend:
|
|
78
|
+
|
|
79
|
+
1. Run `openclaw configure` → select Gmail
|
|
80
|
+
2. The wizard detects your gog credentials and offers migration
|
|
81
|
+
3. Authorize in the browser (one-time, ~10 seconds)
|
|
82
|
+
4. Done — your account now uses the API directly
|
|
83
|
+
|
|
84
|
+
Your gog installation is not affected and other accounts can continue using it.
|
|
85
|
+
|
|
86
|
+
## Features
|
|
51
87
|
|
|
52
|
-
|
|
88
|
+
- **Polling-based sync**: Fetches new unread emails from Inbox
|
|
89
|
+
- **Rich text**: Markdown responses are converted to HTML emails via `marked`
|
|
90
|
+
- **Threading**: Native Gmail thread support with quoted reply context
|
|
91
|
+
- **Reply All**: Replies include all thread participants
|
|
92
|
+
- **Archiving**: Automatically archives threads upon reply
|
|
93
|
+
- **Email body sanitization**: Cleans incoming HTML for LLM consumption
|
|
94
|
+
- **Circuit breaker** (gog backend): Handles API failures and rate limiting
|
|
95
|
+
- **MIME construction** (API backend): Builds RFC 2822 messages with proper threading headers
|
|
96
|
+
|
|
97
|
+
## Configuration
|
|
53
98
|
|
|
54
99
|
```json5
|
|
55
100
|
{
|
|
56
101
|
"channels": {
|
|
57
|
-
"gmail": {
|
|
102
|
+
"openclaw-gmail": {
|
|
58
103
|
"accounts": {
|
|
59
|
-
"
|
|
60
|
-
"email": "
|
|
104
|
+
"you@gmail.com": {
|
|
105
|
+
"email": "you@gmail.com",
|
|
61
106
|
"allowFrom": ["*"],
|
|
62
|
-
"
|
|
63
|
-
//
|
|
64
|
-
"allowOutboundTo": ["@
|
|
65
|
-
"threadReplyPolicy": "allowlist"
|
|
107
|
+
"pollIntervalMs": 60000,
|
|
108
|
+
"includeQuotedReplies": true, // default: true
|
|
109
|
+
"allowOutboundTo": ["@company.com"], // optional
|
|
110
|
+
"threadReplyPolicy": "allowlist" // default: "open"
|
|
66
111
|
}
|
|
67
112
|
},
|
|
68
113
|
"defaults": {
|
|
69
|
-
"includeQuotedReplies": true
|
|
114
|
+
"includeQuotedReplies": true
|
|
70
115
|
}
|
|
71
116
|
}
|
|
72
117
|
}
|
|
73
118
|
}
|
|
74
119
|
```
|
|
75
120
|
|
|
76
|
-
### Configuration Options
|
|
77
|
-
|
|
78
121
|
| Key | Type | Default | Description |
|
|
79
122
|
|-----|------|---------|-------------|
|
|
80
|
-
| `
|
|
81
|
-
| `
|
|
82
|
-
| `
|
|
123
|
+
| `backend` | `"api"` \| `"gog"` | `"gog"` | Which backend to use for this account |
|
|
124
|
+
| `oauth` | object | — | OAuth credentials (required for API backend) |
|
|
125
|
+
| `allowFrom` | string[] | `[]` | Sender allowlist. `["*"]` allows all. |
|
|
126
|
+
| `pollIntervalMs` | number | `60000` | Polling interval in milliseconds |
|
|
127
|
+
| `includeQuotedReplies` | boolean | `true` | Include thread history as quoted text in replies |
|
|
128
|
+
| `allowOutboundTo` | string[] | (falls back to `allowFrom`) | Restrict who the bot can send to. Supports domain wildcards (`@company.com`). |
|
|
129
|
+
| `threadReplyPolicy` | `"open"` \| `"allowlist"` \| `"sender-only"` | `"open"` | Controls reply restrictions |
|
|
83
130
|
|
|
84
131
|
### Thread Reply Policies
|
|
85
132
|
|
|
86
|
-
- **`open`** (default): No outbound restrictions.
|
|
87
|
-
- **`allowlist`**: All thread participants
|
|
88
|
-
- **`sender-only`**: Only checks if the original thread sender is
|
|
133
|
+
- **`open`** (default): No outbound restrictions. Backwards compatible.
|
|
134
|
+
- **`allowlist`**: All thread participants must be in `allowOutboundTo`.
|
|
135
|
+
- **`sender-only`**: Only checks if the original thread sender is allowed.
|
|
89
136
|
|
|
90
137
|
## Development
|
|
91
138
|
|
|
92
|
-
Run tests:
|
|
93
139
|
```bash
|
|
94
|
-
|
|
95
|
-
# or
|
|
96
|
-
./node_modules/.bin/vitest run src/
|
|
140
|
+
npx vitest run
|
|
97
141
|
```
|
|
98
142
|
|
|
99
143
|
## Publishing
|
|
100
144
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
### Automatic (on release)
|
|
104
|
-
Create a GitHub release → automatically publishes to npm.
|
|
105
|
-
|
|
106
|
-
### Manual (workflow dispatch)
|
|
107
|
-
Go to Actions → "Publish to npm" → Run workflow.
|
|
108
|
-
|
|
109
|
-
**Note:** Manual dispatch will:
|
|
110
|
-
1. Bump the version (patch by default, or specify major/minor)
|
|
111
|
-
2. Commit and push the version bump with a tag
|
|
112
|
-
3. Publish to npm
|
|
113
|
-
|
|
114
|
-
### Setup
|
|
115
|
-
Add `NPM_TOKEN` secret to the repository:
|
|
116
|
-
1. Generate token at npmjs.com → Access Tokens → Classic Token (Automation)
|
|
117
|
-
2. Add to repo: Settings → Secrets → Actions → New repository secret
|
|
145
|
+
Create a GitHub release or run the "Publish to npm" workflow via Actions.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mcinteerj/openclaw-gmail",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Gmail channel plugin for OpenClaw -
|
|
3
|
+
"version": "1.4.1",
|
|
4
|
+
"description": "Gmail channel plugin for OpenClaw - direct API or gog CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
7
7
|
"author": "Jake McInteer <mcinteerj@gmail.com>",
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
"channel": {
|
|
25
25
|
"id": "openclaw-gmail",
|
|
26
26
|
"label": "Gmail",
|
|
27
|
-
"selectionLabel": "Gmail
|
|
27
|
+
"selectionLabel": "Gmail",
|
|
28
28
|
"detailLabel": "Gmail",
|
|
29
29
|
"docsPath": "/channels/gmail",
|
|
30
30
|
"docsLabel": "gmail",
|
|
31
|
-
"blurb": "
|
|
31
|
+
"blurb": "Gmail integration via direct API or gog CLI.",
|
|
32
32
|
"systemImage": "envelope",
|
|
33
33
|
"order": 100,
|
|
34
34
|
"showConfigured": true
|
|
@@ -41,9 +41,12 @@
|
|
|
41
41
|
"README.md"
|
|
42
42
|
],
|
|
43
43
|
"dependencies": {
|
|
44
|
+
"@googleapis/gmail": "^16.1.1",
|
|
44
45
|
"dompurify": "^3.2.3",
|
|
46
|
+
"google-auth-library": "^10.5.0",
|
|
45
47
|
"jsdom": "^25.0.1",
|
|
46
48
|
"marked": "^15.0.6",
|
|
49
|
+
"nodemailer": "^8.0.1",
|
|
47
50
|
"proper-lockfile": "^4.1.2",
|
|
48
51
|
"sanitize-html": "^2.14.0",
|
|
49
52
|
"zod": "^4.3.5"
|
package/src/accounts.ts
CHANGED
|
@@ -10,6 +10,12 @@ export interface ResolvedGmailAccount extends ResolvedChannelAccount {
|
|
|
10
10
|
historyId?: string;
|
|
11
11
|
delegate?: string;
|
|
12
12
|
pollIntervalMs?: number;
|
|
13
|
+
backend?: "gog" | "api";
|
|
14
|
+
oauth?: {
|
|
15
|
+
clientId: string;
|
|
16
|
+
clientSecret: string;
|
|
17
|
+
refreshToken: string;
|
|
18
|
+
};
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
export function resolveGmailAccount(
|
|
@@ -42,6 +48,8 @@ export function resolveGmailAccount(
|
|
|
42
48
|
delegate: account.delegate,
|
|
43
49
|
allowFrom: account.allowFrom,
|
|
44
50
|
pollIntervalMs: account.pollIntervalMs,
|
|
51
|
+
backend: account.backend,
|
|
52
|
+
oauth: account.oauth,
|
|
45
53
|
};
|
|
46
54
|
}
|
|
47
55
|
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { gmail as gmailApi, type gmail_v1 } from "@googleapis/gmail";
|
|
2
|
+
import type { OAuth2Client } from "google-auth-library";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import type { GmailClient } from "./gmail-client.js";
|
|
5
|
+
import type { ThreadResponse, GogRawMessage } from "./quoting.js";
|
|
6
|
+
import type { GogSearchMessage } from "./inbound.js";
|
|
7
|
+
import { buildMimeMessage } from "./mime.js";
|
|
8
|
+
import { parseEmailAddresses } from "./outbound-check.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Gmail API client using googleapis library directly.
|
|
12
|
+
* Implements GmailClient interface for the "api" backend.
|
|
13
|
+
*/
|
|
14
|
+
export class ApiGmailClient implements GmailClient {
|
|
15
|
+
private gmail: gmail_v1.Gmail;
|
|
16
|
+
|
|
17
|
+
constructor(auth: OAuth2Client) {
|
|
18
|
+
this.gmail = gmailApi({ version: "v1", auth });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async send(opts: {
|
|
22
|
+
account?: string;
|
|
23
|
+
to?: string;
|
|
24
|
+
subject: string;
|
|
25
|
+
textBody: string;
|
|
26
|
+
htmlBody?: string;
|
|
27
|
+
threadId?: string;
|
|
28
|
+
replyToMessageId?: string;
|
|
29
|
+
replyAll?: boolean;
|
|
30
|
+
}): Promise<void> {
|
|
31
|
+
const selfEmail = opts.account || "";
|
|
32
|
+
let to = opts.to || "";
|
|
33
|
+
let cc: string | undefined;
|
|
34
|
+
let inReplyTo: string | undefined;
|
|
35
|
+
let references: string | undefined;
|
|
36
|
+
|
|
37
|
+
// For thread replies, resolve recipients and threading headers
|
|
38
|
+
if (opts.threadId) {
|
|
39
|
+
const replyCtx = await this.resolveReplyContext(
|
|
40
|
+
opts.threadId,
|
|
41
|
+
selfEmail,
|
|
42
|
+
opts.replyAll ?? false,
|
|
43
|
+
);
|
|
44
|
+
if (replyCtx) {
|
|
45
|
+
to = replyCtx.to;
|
|
46
|
+
cc = replyCtx.cc;
|
|
47
|
+
inReplyTo = replyCtx.inReplyTo;
|
|
48
|
+
references = replyCtx.references;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!to) {
|
|
53
|
+
throw new Error("Cannot send: no recipient resolved");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const mime = await buildMimeMessage({
|
|
57
|
+
from: selfEmail,
|
|
58
|
+
to,
|
|
59
|
+
cc,
|
|
60
|
+
subject: opts.subject,
|
|
61
|
+
text: opts.textBody,
|
|
62
|
+
html: opts.htmlBody,
|
|
63
|
+
inReplyTo,
|
|
64
|
+
references,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await this.gmail.users.messages.send({
|
|
68
|
+
userId: "me",
|
|
69
|
+
requestBody: {
|
|
70
|
+
raw: mime.toString("base64url"),
|
|
71
|
+
threadId: opts.threadId,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve reply recipients and threading headers from the last message in a thread.
|
|
78
|
+
*/
|
|
79
|
+
private async resolveReplyContext(
|
|
80
|
+
threadId: string,
|
|
81
|
+
selfEmail: string,
|
|
82
|
+
replyAll: boolean,
|
|
83
|
+
): Promise<{ to: string; cc?: string; inReplyTo?: string; references?: string } | null> {
|
|
84
|
+
const thread = await this.getThread(threadId, { full: true });
|
|
85
|
+
if (!thread || thread.messages.length === 0) return null;
|
|
86
|
+
|
|
87
|
+
const lastMsg = thread.messages[thread.messages.length - 1];
|
|
88
|
+
|
|
89
|
+
// Resolve To: reply to the sender of the last message
|
|
90
|
+
const to = lastMsg.from;
|
|
91
|
+
|
|
92
|
+
// For reply-all, Cc = everyone from To + Cc minus self and the new To
|
|
93
|
+
let cc: string | undefined;
|
|
94
|
+
if (replyAll) {
|
|
95
|
+
const selfLower = selfEmail.toLowerCase();
|
|
96
|
+
const toAddresses = parseEmailAddresses(to);
|
|
97
|
+
const toLower = new Set(toAddresses.map((a) => a.email.toLowerCase()));
|
|
98
|
+
|
|
99
|
+
const ccCandidates: string[] = [];
|
|
100
|
+
for (const field of [lastMsg.to, lastMsg.cc]) {
|
|
101
|
+
if (!field) continue;
|
|
102
|
+
const addresses = parseEmailAddresses(field);
|
|
103
|
+
for (const addr of addresses) {
|
|
104
|
+
if (addr.email.toLowerCase() !== selfLower && !toLower.has(addr.email.toLowerCase())) {
|
|
105
|
+
ccCandidates.push(addr.name ? `${addr.name} <${addr.email}>` : addr.email);
|
|
106
|
+
toLower.add(addr.email.toLowerCase()); // dedupe
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (ccCandidates.length > 0) {
|
|
111
|
+
cc = ccCandidates.join(", ");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Resolve In-Reply-To and References from the last message
|
|
116
|
+
// We need the raw Message-ID header — fetch it from the API directly
|
|
117
|
+
let messageId: string | undefined;
|
|
118
|
+
try {
|
|
119
|
+
const res = await this.gmail.users.messages.get({
|
|
120
|
+
userId: "me",
|
|
121
|
+
id: lastMsg.id,
|
|
122
|
+
format: "metadata",
|
|
123
|
+
metadataHeaders: ["Message-ID", "References"],
|
|
124
|
+
});
|
|
125
|
+
const headers = res.data.payload?.headers || [];
|
|
126
|
+
messageId = headers.find((h) => h.name?.toLowerCase() === "message-id")?.value ?? undefined;
|
|
127
|
+
const existingRefs = headers.find((h) => h.name?.toLowerCase() === "references")?.value;
|
|
128
|
+
if (messageId) {
|
|
129
|
+
return {
|
|
130
|
+
to,
|
|
131
|
+
cc,
|
|
132
|
+
inReplyTo: messageId,
|
|
133
|
+
references: existingRefs ? `${existingRefs} ${messageId}` : messageId,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// Fall through — send without threading headers
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { to, cc };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async getThread(threadId: string, opts?: { full?: boolean }): Promise<ThreadResponse | null> {
|
|
144
|
+
try {
|
|
145
|
+
const res = await this.gmail.users.threads.get({
|
|
146
|
+
userId: "me",
|
|
147
|
+
id: threadId,
|
|
148
|
+
format: opts?.full ? "full" : "metadata",
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const thread = res.data;
|
|
152
|
+
if (!thread || !thread.messages) return null;
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
id: thread.id!,
|
|
156
|
+
historyId: thread.historyId!,
|
|
157
|
+
messages: thread.messages.map((msg) => {
|
|
158
|
+
const raw = mapApiMessage(msg);
|
|
159
|
+
return parseRawToThreadMessage(raw);
|
|
160
|
+
}),
|
|
161
|
+
};
|
|
162
|
+
} catch (err: any) {
|
|
163
|
+
if (err?.code === 404) return null;
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async getMessage(messageId: string): Promise<Record<string, unknown> | null> {
|
|
169
|
+
try {
|
|
170
|
+
const res = await this.gmail.users.messages.get({
|
|
171
|
+
userId: "me",
|
|
172
|
+
id: messageId,
|
|
173
|
+
format: "full",
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Wrap in { message: ... } to match gog output shape consumed by monitor.ts
|
|
177
|
+
return { message: mapApiMessage(res.data) };
|
|
178
|
+
} catch (err: any) {
|
|
179
|
+
if (err?.code === 404) return null;
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async searchMessages(
|
|
185
|
+
query: string,
|
|
186
|
+
opts?: { maxResults?: number; includeBody?: boolean },
|
|
187
|
+
): Promise<GogSearchMessage[]> {
|
|
188
|
+
const res = await this.gmail.users.messages.list({
|
|
189
|
+
userId: "me",
|
|
190
|
+
q: query,
|
|
191
|
+
maxResults: opts?.maxResults ?? 50,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const ids = res.data.messages || [];
|
|
195
|
+
if (ids.length === 0) return [];
|
|
196
|
+
|
|
197
|
+
// Fetch full message details in parallel (N+1 pattern)
|
|
198
|
+
const messages = await Promise.all(
|
|
199
|
+
ids.map(async (m) => {
|
|
200
|
+
try {
|
|
201
|
+
const detail = await this.gmail.users.messages.get({
|
|
202
|
+
userId: "me",
|
|
203
|
+
id: m.id!,
|
|
204
|
+
format: "full",
|
|
205
|
+
});
|
|
206
|
+
return detail.data;
|
|
207
|
+
} catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
return messages
|
|
214
|
+
.filter((m): m is gmail_v1.Schema$Message => m !== null)
|
|
215
|
+
.map((msg) => {
|
|
216
|
+
const headers = msg.payload?.headers || [];
|
|
217
|
+
const getH = (n: string) =>
|
|
218
|
+
headers.find((h) => h.name?.toLowerCase() === n.toLowerCase())?.value || "";
|
|
219
|
+
|
|
220
|
+
const body = extractPlainText(msg.payload ?? {});
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
id: msg.id!,
|
|
224
|
+
threadId: msg.threadId!,
|
|
225
|
+
date: getH("Date"),
|
|
226
|
+
from: getH("From"),
|
|
227
|
+
subject: getH("Subject"),
|
|
228
|
+
body,
|
|
229
|
+
labels: msg.labelIds || [],
|
|
230
|
+
};
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async searchThreads(
|
|
235
|
+
query: string,
|
|
236
|
+
opts?: { maxResults?: number },
|
|
237
|
+
): Promise<Record<string, unknown> | null> {
|
|
238
|
+
const res = await this.gmail.users.threads.list({
|
|
239
|
+
userId: "me",
|
|
240
|
+
q: query,
|
|
241
|
+
maxResults: opts?.maxResults ?? 50,
|
|
242
|
+
});
|
|
243
|
+
return res.data as Record<string, unknown>;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async modifyLabels(id: string, opts: { add?: string[]; remove?: string[] }): Promise<void> {
|
|
247
|
+
await this.gmail.users.messages.modify({
|
|
248
|
+
userId: "me",
|
|
249
|
+
id,
|
|
250
|
+
requestBody: {
|
|
251
|
+
addLabelIds: opts.add,
|
|
252
|
+
removeLabelIds: opts.remove,
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async modifyThreadLabels(
|
|
258
|
+
threadId: string,
|
|
259
|
+
opts: { add?: string[]; remove?: string[] },
|
|
260
|
+
): Promise<void> {
|
|
261
|
+
await this.gmail.users.threads.modify({
|
|
262
|
+
userId: "me",
|
|
263
|
+
id: threadId,
|
|
264
|
+
requestBody: {
|
|
265
|
+
addLabelIds: opts.add,
|
|
266
|
+
removeLabelIds: opts.remove,
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async listLabels(): Promise<{ id: string; name: string }[]> {
|
|
272
|
+
const res = await this.gmail.users.labels.list({ userId: "me" });
|
|
273
|
+
return (res.data.labels || []).map((l) => ({
|
|
274
|
+
id: l.id!,
|
|
275
|
+
name: l.name!,
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async createLabel(name: string): Promise<void> {
|
|
280
|
+
await this.gmail.users.labels.create({
|
|
281
|
+
userId: "me",
|
|
282
|
+
requestBody: { name },
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async downloadAttachment(
|
|
287
|
+
messageId: string,
|
|
288
|
+
attachmentId: string,
|
|
289
|
+
outPath: string,
|
|
290
|
+
): Promise<void> {
|
|
291
|
+
const res = await this.gmail.users.messages.attachments.get({
|
|
292
|
+
userId: "me",
|
|
293
|
+
messageId,
|
|
294
|
+
id: attachmentId,
|
|
295
|
+
});
|
|
296
|
+
// Gmail API returns base64url-encoded data
|
|
297
|
+
const buf = Buffer.from(res.data.data!, "base64url");
|
|
298
|
+
await fs.writeFile(outPath, buf);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async getSendAs(): Promise<{ displayName?: string; email: string; isPrimary?: boolean }[]> {
|
|
302
|
+
const res = await this.gmail.users.settings.sendAs.list({ userId: "me" });
|
|
303
|
+
return (res.data.sendAs || []).map((s) => ({
|
|
304
|
+
displayName: s.displayName || undefined,
|
|
305
|
+
email: s.sendAsEmail!,
|
|
306
|
+
isPrimary: s.isPrimary || false,
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── Response mapping helpers ──────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Map Gmail API Schema$Message to GogRawMessage shape.
|
|
315
|
+
* This keeps all downstream consumers (quoting.ts, inbound.ts, monitor.ts) working
|
|
316
|
+
* without changes.
|
|
317
|
+
*/
|
|
318
|
+
function mapApiMessage(msg: gmail_v1.Schema$Message): GogRawMessage {
|
|
319
|
+
return {
|
|
320
|
+
id: msg.id!,
|
|
321
|
+
threadId: msg.threadId!,
|
|
322
|
+
internalDate: msg.internalDate!,
|
|
323
|
+
labelIds: msg.labelIds || [],
|
|
324
|
+
payload: mapPayload(msg.payload ?? {}),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function mapPayload(
|
|
329
|
+
p: gmail_v1.Schema$MessagePart,
|
|
330
|
+
): GogRawMessage["payload"] {
|
|
331
|
+
return {
|
|
332
|
+
headers: (p.headers || []).map((h) => ({
|
|
333
|
+
name: h.name!,
|
|
334
|
+
value: h.value!,
|
|
335
|
+
})),
|
|
336
|
+
parts: p.parts?.map((part) => ({
|
|
337
|
+
body: part.body?.data ? { data: part.body.data } : undefined,
|
|
338
|
+
mimeType: part.mimeType!,
|
|
339
|
+
})),
|
|
340
|
+
body: p.body?.data ? { data: p.body.data } : undefined,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Parse a GogRawMessage into a ThreadMessage (same logic as quoting.ts:parseGogMessage).
|
|
346
|
+
*/
|
|
347
|
+
function parseRawToThreadMessage(raw: GogRawMessage) {
|
|
348
|
+
const getH = (name: string) =>
|
|
349
|
+
raw.payload.headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value;
|
|
350
|
+
|
|
351
|
+
const body = extractPlainFromRaw(raw);
|
|
352
|
+
const bodyHtml = extractHtmlFromRaw(raw);
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
id: raw.id,
|
|
356
|
+
threadId: raw.threadId,
|
|
357
|
+
date: getH("Date") || new Date(parseInt(raw.internalDate)).toISOString(),
|
|
358
|
+
from: getH("From") || "",
|
|
359
|
+
to: getH("To"),
|
|
360
|
+
cc: getH("Cc"),
|
|
361
|
+
subject: getH("Subject") || "",
|
|
362
|
+
body,
|
|
363
|
+
bodyHtml,
|
|
364
|
+
labels: raw.labelIds,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function extractPlainFromRaw(raw: GogRawMessage): string {
|
|
369
|
+
if (raw.payload.parts) {
|
|
370
|
+
const plain = raw.payload.parts.find((p) => p.mimeType === "text/plain");
|
|
371
|
+
if (plain?.body?.data) return Buffer.from(plain.body.data, "base64").toString("utf-8");
|
|
372
|
+
}
|
|
373
|
+
if (raw.payload.body?.data) return Buffer.from(raw.payload.body.data, "base64").toString("utf-8");
|
|
374
|
+
return "";
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function extractHtmlFromRaw(raw: GogRawMessage): string {
|
|
378
|
+
if (raw.payload.parts) {
|
|
379
|
+
const html = raw.payload.parts.find((p) => p.mimeType === "text/html");
|
|
380
|
+
if (html?.body?.data) return Buffer.from(html.body.data, "base64").toString("utf-8");
|
|
381
|
+
}
|
|
382
|
+
if (raw.payload.body?.data) return Buffer.from(raw.payload.body.data, "base64").toString("utf-8");
|
|
383
|
+
return "";
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Extract plain text body from a Gmail API MessagePart.
|
|
388
|
+
*/
|
|
389
|
+
function extractPlainText(part: gmail_v1.Schema$MessagePart): string {
|
|
390
|
+
if (part.mimeType === "text/plain" && part.body?.data) {
|
|
391
|
+
return Buffer.from(part.body.data, "base64").toString("utf-8");
|
|
392
|
+
}
|
|
393
|
+
if (part.parts) {
|
|
394
|
+
// multipart/alternative: prefer text/plain
|
|
395
|
+
if (part.mimeType === "multipart/alternative") {
|
|
396
|
+
const plain = part.parts.find((p) => p.mimeType === "text/plain");
|
|
397
|
+
if (plain) return extractPlainText(plain);
|
|
398
|
+
}
|
|
399
|
+
// Recurse into sub-parts
|
|
400
|
+
for (const sub of part.parts) {
|
|
401
|
+
const text = extractPlainText(sub);
|
|
402
|
+
if (text) return text;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return "";
|
|
406
|
+
}
|