crinkl-agent 0.1.0
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/LICENSE +21 -0
- package/README.md +161 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.js +49 -0
- package/dist/crinkl.d.ts +67 -0
- package/dist/crinkl.js +49 -0
- package/dist/gmail.d.ts +22 -0
- package/dist/gmail.js +111 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +184 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Crinkl Protocol
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# crinkl-agent
|
|
4
|
+
|
|
5
|
+
Scan Gmail for SaaS billing emails. Submit them to [Crinkl](https://crinkl.xyz). Get sats over Lightning.
|
|
6
|
+
|
|
7
|
+
[Quick start](#quick-start) · [API](#api-reference) · [Privacy](#privacy)
|
|
8
|
+
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
[Crinkl](https://crinkl.xyz) verifies real-world spend and mints **identity-free spend tokens** — cryptographic proofs with no personal data. This agent is a reference implementation for the [email receipt API](#api-reference). Humans scan physical receipts in the [PWA](https://app.crinkl.xyz). Agents submit DKIM-signed emails via REST. Both produce the same protocol artifact.
|
|
14
|
+
|
|
15
|
+
> Building your own agent? You may only need the [API endpoints](#api-reference).
|
|
16
|
+
|
|
17
|
+
## How it works
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
Gmail (readonly) → crinkl-agent (your machine) → api.crinkl.xyz (DKIM verify + attest) → spend token → ₿ sats
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
1. **Fetch** allowed vendors from the Crinkl API
|
|
24
|
+
2. **Search** Gmail for billing emails from those vendors (last 14 days, read-only)
|
|
25
|
+
3. **Download** each email as raw `.eml` — in memory, never written to disk
|
|
26
|
+
4. **Submit** to Crinkl — server verifies the DKIM signature, extracts invoice data, mints a spend token
|
|
27
|
+
5. **Dedup** locally so the same email is never submitted twice
|
|
28
|
+
|
|
29
|
+
The server does all verification and data extraction. The agent is just a pipe from your inbox to the API.
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
### 1. Get a Crinkl API key
|
|
34
|
+
|
|
35
|
+
Sign up at [app.crinkl.xyz](https://app.crinkl.xyz) — it's a PWA, works in any browser. Once you have a wallet:
|
|
36
|
+
|
|
37
|
+
**Settings → Agent API Keys → Generate**
|
|
38
|
+
|
|
39
|
+
This gives you a `crk_...` key tied to your wallet. Spend tokens minted by the agent are credited to this wallet.
|
|
40
|
+
|
|
41
|
+
### 2. Set up Gmail OAuth
|
|
42
|
+
|
|
43
|
+
1. [Create an OAuth 2.0 Client ID](https://console.cloud.google.com/apis/credentials) (type: Desktop app)
|
|
44
|
+
2. [Enable the Gmail API](https://console.cloud.google.com/apis/library/gmail.googleapis.com)
|
|
45
|
+
|
|
46
|
+
### 3. Run
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
git clone https://github.com/crinkl-protocol/crinkl-agent.git
|
|
50
|
+
cd crinkl-agent
|
|
51
|
+
npm install
|
|
52
|
+
cp .env.example .env # add your API key + OAuth credentials
|
|
53
|
+
npm run auth # one-time Gmail authorization
|
|
54
|
+
npm run dev # scan + submit
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Usage
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
npm run dev # scan Gmail + submit receipts
|
|
61
|
+
npm run dev -- --scan # dry run (preview only)
|
|
62
|
+
npm run dev -- --auth # set up Gmail auth only
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Run on a schedule
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# Every 6 hours
|
|
69
|
+
0 */6 * * * cd /path/to/crinkl-agent && npm run dev >> ~/.crinkl/agent.log 2>&1
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Supported vendors
|
|
73
|
+
|
|
74
|
+
The server maintains the allowlist. The agent fetches it on every run.
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
curl https://api.crinkl.xyz/api/agent/allowed-vendors
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Vendors must send DKIM-signed billing emails. Web-only invoices (download from dashboard) have no DKIM signature and can't be verified.
|
|
81
|
+
|
|
82
|
+
If you submit an email from an unknown vendor, it's **queued for review** (not rejected). Once approved, the vendor is added to the allowlist and your spend is created retroactively.
|
|
83
|
+
|
|
84
|
+
> **Want to add a vendor?** Just submit an email from them. If the domain has valid DKIM, we'll review and approve it.
|
|
85
|
+
|
|
86
|
+
## API reference
|
|
87
|
+
|
|
88
|
+
### Public (no auth)
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
GET https://api.crinkl.xyz/api/agent/allowed-vendors
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Authenticated (`x-api-key` header)
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
POST https://api.crinkl.xyz/api/agent/submit-email-receipt
|
|
98
|
+
Body: { "eml": "<base64-encoded .eml>" }
|
|
99
|
+
Returns: 201 (created) | 202 (queued for vendor review) | 409 (duplicate) | 422 (validation error)
|
|
100
|
+
|
|
101
|
+
POST https://api.crinkl.xyz/api/agent/verify-email-receipt
|
|
102
|
+
Body: { "eml": "<base64-encoded .eml>" }
|
|
103
|
+
Returns: 200 (preview without submitting)
|
|
104
|
+
|
|
105
|
+
GET https://api.crinkl.xyz/api/agent/spends/:spendId/token/latest
|
|
106
|
+
Returns: the signed spend attestation token
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### For MCP-capable agents
|
|
110
|
+
|
|
111
|
+
If you're running Claude Desktop, Cursor, OpenClaw, or any MCP client — you can use the public MCP server for read-only commerce intelligence:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"mcpServers": {
|
|
116
|
+
"crinkl": {
|
|
117
|
+
"url": "https://mcp.crinkl.xyz/mcp"
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Email receipt submission uses the REST API above (requires an API key).
|
|
124
|
+
|
|
125
|
+
## Privacy
|
|
126
|
+
|
|
127
|
+
This agent runs on your machine. Here's what leaves it:
|
|
128
|
+
|
|
129
|
+
| Data | Destination | Purpose |
|
|
130
|
+
|------|-------------|---------|
|
|
131
|
+
| Individual `.eml` files | `api.crinkl.xyz` | DKIM verification + spend token minting |
|
|
132
|
+
| Nothing else | — | — |
|
|
133
|
+
|
|
134
|
+
- **Read-only Gmail access** — `gmail.readonly` scope. Cannot send, delete, or modify.
|
|
135
|
+
- **No inbox access shared** — Crinkl receives individual emails, not credentials or tokens.
|
|
136
|
+
- **OAuth token stays local** — stored at `~/.crinkl/gmail-credentials.json`.
|
|
137
|
+
- **Spend tokens are identity-free** — no email, no name, no account ID in the signed payload.
|
|
138
|
+
|
|
139
|
+
## Architecture
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
src/
|
|
143
|
+
├── index.ts # CLI entry — Gmail scan loop, submit/dedup logic
|
|
144
|
+
├── config.ts # .env loader
|
|
145
|
+
├── gmail.ts # Gmail OAuth + search + download
|
|
146
|
+
└── crinkl.ts # Crinkl API client (verify, submit, vendors)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
~200 lines of core logic. The server does the hard part.
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
[MIT](LICENSE)
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
<div align="center">
|
|
158
|
+
|
|
159
|
+
**[crinkl.xyz](https://crinkl.xyz)** · verified spend, identity detached
|
|
160
|
+
|
|
161
|
+
</div>
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration — loads from environment variables or .env file.
|
|
3
|
+
* All secrets stay local on the user's machine.
|
|
4
|
+
*/
|
|
5
|
+
export interface Config {
|
|
6
|
+
crinklApiKey: string;
|
|
7
|
+
crinklApiUrl: string;
|
|
8
|
+
gmailClientId: string;
|
|
9
|
+
gmailClientSecret: string;
|
|
10
|
+
maxEmailAgeDays: number;
|
|
11
|
+
credentialsPath: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function loadConfig(): Config;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration — loads from environment variables or .env file.
|
|
3
|
+
* All secrets stay local on the user's machine.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
/** Load .env file (simple key=value parser, no npm dep) */
|
|
8
|
+
function loadDotEnv() {
|
|
9
|
+
const envPath = resolve(process.cwd(), ".env");
|
|
10
|
+
if (!existsSync(envPath))
|
|
11
|
+
return;
|
|
12
|
+
const lines = readFileSync(envPath, "utf-8").split("\n");
|
|
13
|
+
for (const line of lines) {
|
|
14
|
+
const trimmed = line.trim();
|
|
15
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
16
|
+
continue;
|
|
17
|
+
const eq = trimmed.indexOf("=");
|
|
18
|
+
if (eq === -1)
|
|
19
|
+
continue;
|
|
20
|
+
const key = trimmed.slice(0, eq).trim();
|
|
21
|
+
const value = trimmed.slice(eq + 1).trim();
|
|
22
|
+
if (!process.env[key]) {
|
|
23
|
+
process.env[key] = value;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function loadConfig() {
|
|
28
|
+
loadDotEnv();
|
|
29
|
+
const crinklApiKey = process.env.CRINKL_API_KEY;
|
|
30
|
+
if (!crinklApiKey) {
|
|
31
|
+
console.error("CRINKL_API_KEY is required. Get one from https://app.crinkl.xyz");
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const gmailClientId = process.env.GMAIL_CLIENT_ID;
|
|
35
|
+
const gmailClientSecret = process.env.GMAIL_CLIENT_SECRET;
|
|
36
|
+
if (!gmailClientId || !gmailClientSecret) {
|
|
37
|
+
console.error("GMAIL_CLIENT_ID and GMAIL_CLIENT_SECRET are required.");
|
|
38
|
+
console.error("Create an OAuth app at https://console.cloud.google.com/apis/credentials");
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
crinklApiKey,
|
|
43
|
+
crinklApiUrl: process.env.CRINKL_API_URL || "https://api.crinkl.xyz",
|
|
44
|
+
gmailClientId,
|
|
45
|
+
gmailClientSecret,
|
|
46
|
+
maxEmailAgeDays: parseInt(process.env.MAX_EMAIL_AGE_DAYS || "14", 10),
|
|
47
|
+
credentialsPath: resolve(process.env.HOME || "~", ".crinkl", "gmail-credentials.json"),
|
|
48
|
+
};
|
|
49
|
+
}
|
package/dist/crinkl.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crinkl API client — wrappers for the DKIM email receipt endpoints.
|
|
3
|
+
*
|
|
4
|
+
* All communication goes through the public REST API.
|
|
5
|
+
* Only the .eml content is sent — no inbox access is shared.
|
|
6
|
+
*/
|
|
7
|
+
import type { Config } from "./config.js";
|
|
8
|
+
interface Vendor {
|
|
9
|
+
domain: string;
|
|
10
|
+
displayName: string;
|
|
11
|
+
}
|
|
12
|
+
interface VerifyResult {
|
|
13
|
+
success: boolean;
|
|
14
|
+
data?: {
|
|
15
|
+
dkimVerified: boolean;
|
|
16
|
+
dkimDomain: string;
|
|
17
|
+
provider: string;
|
|
18
|
+
totalCents: number;
|
|
19
|
+
date: string;
|
|
20
|
+
invoiceId: string | null;
|
|
21
|
+
subject: string;
|
|
22
|
+
currency: string;
|
|
23
|
+
lineItems: Array<{
|
|
24
|
+
description: string;
|
|
25
|
+
amountCents: number;
|
|
26
|
+
}>;
|
|
27
|
+
};
|
|
28
|
+
error?: string;
|
|
29
|
+
domain?: string;
|
|
30
|
+
date?: string;
|
|
31
|
+
maxAgeDays?: number;
|
|
32
|
+
}
|
|
33
|
+
interface SubmitResult {
|
|
34
|
+
success: boolean;
|
|
35
|
+
/** Present when spend was created (201) */
|
|
36
|
+
data?: {
|
|
37
|
+
submissionId: string;
|
|
38
|
+
spendId: string;
|
|
39
|
+
provider: string;
|
|
40
|
+
store: string;
|
|
41
|
+
storeId: string;
|
|
42
|
+
totalCents: number;
|
|
43
|
+
date: string;
|
|
44
|
+
currency: string;
|
|
45
|
+
invoiceId: string | null;
|
|
46
|
+
dkimDomain: string;
|
|
47
|
+
status: string;
|
|
48
|
+
verificationMethod: string;
|
|
49
|
+
};
|
|
50
|
+
/** Present when vendor is unknown and queued for admin review (202) */
|
|
51
|
+
status?: "QUEUED_FOR_REVIEW";
|
|
52
|
+
message?: string;
|
|
53
|
+
error?: string;
|
|
54
|
+
domain?: string;
|
|
55
|
+
}
|
|
56
|
+
export declare class CrinklClient {
|
|
57
|
+
private apiUrl;
|
|
58
|
+
private apiKey;
|
|
59
|
+
constructor(config: Config);
|
|
60
|
+
/** Fetch allowed vendors from the server */
|
|
61
|
+
getAllowedVendors(): Promise<Vendor[]>;
|
|
62
|
+
/** Preview DKIM verification without submitting */
|
|
63
|
+
verifyEmailReceipt(rawEml: string): Promise<VerifyResult>;
|
|
64
|
+
/** Submit a DKIM-verified email receipt for rewards */
|
|
65
|
+
submitEmailReceipt(rawEml: string): Promise<SubmitResult>;
|
|
66
|
+
}
|
|
67
|
+
export {};
|
package/dist/crinkl.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crinkl API client — wrappers for the DKIM email receipt endpoints.
|
|
3
|
+
*
|
|
4
|
+
* All communication goes through the public REST API.
|
|
5
|
+
* Only the .eml content is sent — no inbox access is shared.
|
|
6
|
+
*/
|
|
7
|
+
export class CrinklClient {
|
|
8
|
+
apiUrl;
|
|
9
|
+
apiKey;
|
|
10
|
+
constructor(config) {
|
|
11
|
+
this.apiUrl = config.crinklApiUrl;
|
|
12
|
+
this.apiKey = config.crinklApiKey;
|
|
13
|
+
}
|
|
14
|
+
/** Fetch allowed vendors from the server */
|
|
15
|
+
async getAllowedVendors() {
|
|
16
|
+
const response = await fetch(`${this.apiUrl}/api/agent/allowed-vendors`);
|
|
17
|
+
if (!response.ok) {
|
|
18
|
+
throw new Error(`Failed to fetch vendors: ${response.status} ${response.statusText}`);
|
|
19
|
+
}
|
|
20
|
+
const body = (await response.json());
|
|
21
|
+
return body.data.vendors;
|
|
22
|
+
}
|
|
23
|
+
/** Preview DKIM verification without submitting */
|
|
24
|
+
async verifyEmailReceipt(rawEml) {
|
|
25
|
+
const eml = Buffer.from(rawEml).toString("base64");
|
|
26
|
+
const response = await fetch(`${this.apiUrl}/api/agent/verify-email-receipt`, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: {
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
"x-api-key": this.apiKey,
|
|
31
|
+
},
|
|
32
|
+
body: JSON.stringify({ eml }),
|
|
33
|
+
});
|
|
34
|
+
return response.json();
|
|
35
|
+
}
|
|
36
|
+
/** Submit a DKIM-verified email receipt for rewards */
|
|
37
|
+
async submitEmailReceipt(rawEml) {
|
|
38
|
+
const eml = Buffer.from(rawEml).toString("base64");
|
|
39
|
+
const response = await fetch(`${this.apiUrl}/api/agent/submit-email-receipt`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: {
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
"x-api-key": this.apiKey,
|
|
44
|
+
},
|
|
45
|
+
body: JSON.stringify({ eml }),
|
|
46
|
+
});
|
|
47
|
+
return response.json();
|
|
48
|
+
}
|
|
49
|
+
}
|
package/dist/gmail.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gmail OAuth + email search/download.
|
|
3
|
+
*
|
|
4
|
+
* Privacy: OAuth tokens are stored locally only (~/.crinkl/gmail-credentials.json).
|
|
5
|
+
* Only gmail.readonly scope is requested — no send/delete/modify access.
|
|
6
|
+
* Emails are downloaded to memory (never written to disk).
|
|
7
|
+
*/
|
|
8
|
+
import { google } from "googleapis";
|
|
9
|
+
import type { Config } from "./config.js";
|
|
10
|
+
/** Get authenticated Gmail client. Runs OAuth flow on first use. */
|
|
11
|
+
export declare function getGmailClient(config: Config): Promise<import("googleapis").gmail_v1.Gmail>;
|
|
12
|
+
/** Search Gmail for receipt emails from allowed vendors */
|
|
13
|
+
export declare function searchReceiptEmails(gmail: ReturnType<typeof google.gmail>, vendors: Array<{
|
|
14
|
+
domain: string;
|
|
15
|
+
}>, maxAgeDays: number): Promise<Array<{
|
|
16
|
+
messageId: string;
|
|
17
|
+
snippet: string;
|
|
18
|
+
}>>;
|
|
19
|
+
/** Download raw .eml content for a message (in memory only — never written to disk) */
|
|
20
|
+
export declare function downloadRawEml(gmail: ReturnType<typeof google.gmail>, messageId: string): Promise<string>;
|
|
21
|
+
/** Get email subject for display */
|
|
22
|
+
export declare function getMessageSubject(gmail: ReturnType<typeof google.gmail>, messageId: string): Promise<string>;
|
package/dist/gmail.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gmail OAuth + email search/download.
|
|
3
|
+
*
|
|
4
|
+
* Privacy: OAuth tokens are stored locally only (~/.crinkl/gmail-credentials.json).
|
|
5
|
+
* Only gmail.readonly scope is requested — no send/delete/modify access.
|
|
6
|
+
* Emails are downloaded to memory (never written to disk).
|
|
7
|
+
*/
|
|
8
|
+
import { google } from "googleapis";
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
|
+
import { createInterface } from "node:readline";
|
|
12
|
+
const SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"];
|
|
13
|
+
const REDIRECT_URI = "http://localhost";
|
|
14
|
+
/** Get authenticated Gmail client. Runs OAuth flow on first use. */
|
|
15
|
+
export async function getGmailClient(config) {
|
|
16
|
+
const oauth2 = new google.auth.OAuth2(config.gmailClientId, config.gmailClientSecret, REDIRECT_URI);
|
|
17
|
+
// Try loading saved credentials
|
|
18
|
+
if (existsSync(config.credentialsPath)) {
|
|
19
|
+
const saved = JSON.parse(readFileSync(config.credentialsPath, "utf-8"));
|
|
20
|
+
oauth2.setCredentials(saved);
|
|
21
|
+
return google.gmail({ version: "v1", auth: oauth2 });
|
|
22
|
+
}
|
|
23
|
+
// First-time OAuth flow
|
|
24
|
+
const authUrl = oauth2.generateAuthUrl({
|
|
25
|
+
access_type: "offline",
|
|
26
|
+
scope: SCOPES,
|
|
27
|
+
prompt: "consent",
|
|
28
|
+
});
|
|
29
|
+
console.log("\n--- Gmail Authorization ---");
|
|
30
|
+
console.log("1. Open this URL in your browser:\n");
|
|
31
|
+
console.log(` ${authUrl}\n`);
|
|
32
|
+
console.log("2. Authorize the app. You'll be redirected to a page that won't load.");
|
|
33
|
+
console.log("3. Copy the FULL URL from your browser's address bar and paste it below.\n");
|
|
34
|
+
console.log(" It will look like: http://localhost?code=4/0AQ...\n");
|
|
35
|
+
const rawUrl = await prompt("Paste the full redirect URL: ");
|
|
36
|
+
// Extract code from the pasted URL
|
|
37
|
+
let code;
|
|
38
|
+
if (rawUrl.startsWith("http")) {
|
|
39
|
+
const url = new URL(rawUrl);
|
|
40
|
+
code = url.searchParams.get("code") || rawUrl;
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
code = rawUrl;
|
|
44
|
+
}
|
|
45
|
+
const { tokens } = await oauth2.getToken(code);
|
|
46
|
+
oauth2.setCredentials(tokens);
|
|
47
|
+
// Save credentials locally
|
|
48
|
+
mkdirSync(dirname(config.credentialsPath), { recursive: true });
|
|
49
|
+
writeFileSync(config.credentialsPath, JSON.stringify(tokens, null, 2), { mode: 0o600 });
|
|
50
|
+
console.log(`Credentials saved to ${config.credentialsPath}\n`);
|
|
51
|
+
return google.gmail({ version: "v1", auth: oauth2 });
|
|
52
|
+
}
|
|
53
|
+
/** Search Gmail for receipt emails from allowed vendors */
|
|
54
|
+
export async function searchReceiptEmails(gmail, vendors, maxAgeDays) {
|
|
55
|
+
if (vendors.length === 0) {
|
|
56
|
+
console.log("No allowed vendors found.");
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
// Build search query: from:@vendor1 OR from:@vendor2 ... newer_than:14d
|
|
60
|
+
const fromClauses = vendors.map((v) => `from:@${v.domain}`).join(" OR ");
|
|
61
|
+
const query = `(${fromClauses}) newer_than:${maxAgeDays}d`;
|
|
62
|
+
console.log(`Searching Gmail: ${query}`);
|
|
63
|
+
const response = await gmail.users.messages.list({
|
|
64
|
+
userId: "me",
|
|
65
|
+
q: query,
|
|
66
|
+
maxResults: 50,
|
|
67
|
+
});
|
|
68
|
+
const messages = response.data.messages || [];
|
|
69
|
+
console.log(`Found ${messages.length} matching emails.`);
|
|
70
|
+
return messages.map((m) => ({
|
|
71
|
+
messageId: m.id,
|
|
72
|
+
snippet: m.snippet || "",
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
/** Download raw .eml content for a message (in memory only — never written to disk) */
|
|
76
|
+
export async function downloadRawEml(gmail, messageId) {
|
|
77
|
+
const response = await gmail.users.messages.get({
|
|
78
|
+
userId: "me",
|
|
79
|
+
id: messageId,
|
|
80
|
+
format: "raw",
|
|
81
|
+
});
|
|
82
|
+
// Gmail returns URL-safe base64
|
|
83
|
+
const raw = response.data.raw;
|
|
84
|
+
return Buffer.from(raw, "base64url").toString("utf-8");
|
|
85
|
+
}
|
|
86
|
+
/** Get email subject for display */
|
|
87
|
+
export async function getMessageSubject(gmail, messageId) {
|
|
88
|
+
const response = await gmail.users.messages.get({
|
|
89
|
+
userId: "me",
|
|
90
|
+
id: messageId,
|
|
91
|
+
format: "metadata",
|
|
92
|
+
metadataHeaders: ["Subject", "From", "Date"],
|
|
93
|
+
});
|
|
94
|
+
const headers = response.data.payload?.headers || [];
|
|
95
|
+
const subject = headers.find((h) => h.name === "Subject")?.value || "(no subject)";
|
|
96
|
+
const from = headers.find((h) => h.name === "From")?.value || "";
|
|
97
|
+
const date = headers.find((h) => h.name === "Date")?.value || "";
|
|
98
|
+
return `${date} | ${from} | ${subject}`;
|
|
99
|
+
}
|
|
100
|
+
function prompt(question) {
|
|
101
|
+
const rl = createInterface({
|
|
102
|
+
input: process.stdin,
|
|
103
|
+
output: process.stdout,
|
|
104
|
+
});
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
rl.question(question, (answer) => {
|
|
107
|
+
rl.close();
|
|
108
|
+
resolve(answer.trim());
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Crinkl Email Receipt Agent
|
|
4
|
+
*
|
|
5
|
+
* Scans your Gmail for billing receipts from approved vendors,
|
|
6
|
+
* verifies DKIM signatures, and submits them to earn BTC rewards.
|
|
7
|
+
*
|
|
8
|
+
* Privacy: Gmail OAuth runs locally. Emails are processed in memory.
|
|
9
|
+
* Only individual .eml files are sent to Crinkl for verification.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* crinkl-agent # scan + submit (default)
|
|
13
|
+
* crinkl-agent --auth # just set up Gmail auth
|
|
14
|
+
* crinkl-agent --scan # scan only (dry run, no submit)
|
|
15
|
+
* crinkl-agent --help # show help
|
|
16
|
+
*/
|
|
17
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Crinkl Email Receipt Agent
|
|
4
|
+
*
|
|
5
|
+
* Scans your Gmail for billing receipts from approved vendors,
|
|
6
|
+
* verifies DKIM signatures, and submits them to earn BTC rewards.
|
|
7
|
+
*
|
|
8
|
+
* Privacy: Gmail OAuth runs locally. Emails are processed in memory.
|
|
9
|
+
* Only individual .eml files are sent to Crinkl for verification.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* crinkl-agent # scan + submit (default)
|
|
13
|
+
* crinkl-agent --auth # just set up Gmail auth
|
|
14
|
+
* crinkl-agent --scan # scan only (dry run, no submit)
|
|
15
|
+
* crinkl-agent --help # show help
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
18
|
+
import { resolve, dirname } from "node:path";
|
|
19
|
+
import { loadConfig } from "./config.js";
|
|
20
|
+
import { getGmailClient, searchReceiptEmails, downloadRawEml, getMessageSubject, } from "./gmail.js";
|
|
21
|
+
import { CrinklClient } from "./crinkl.js";
|
|
22
|
+
const SUBMITTED_IDS_FILE = resolve(process.env.HOME || "~", ".crinkl", "submitted-emails.json");
|
|
23
|
+
const HELP = `
|
|
24
|
+
Crinkl Email Receipt Agent
|
|
25
|
+
|
|
26
|
+
Scans your Gmail for billing receipts from approved vendors,
|
|
27
|
+
verifies DKIM signatures, and submits them to Crinkl for BTC rewards.
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
crinkl-agent Scan + submit (default)
|
|
31
|
+
crinkl-agent --auth Set up Gmail authorization only
|
|
32
|
+
crinkl-agent --scan Dry run — preview without submitting
|
|
33
|
+
crinkl-agent --help Show this help
|
|
34
|
+
|
|
35
|
+
Environment variables (or .env file):
|
|
36
|
+
CRINKL_API_KEY Your Crinkl agent API key (required)
|
|
37
|
+
GMAIL_CLIENT_ID Google OAuth client ID (required)
|
|
38
|
+
GMAIL_CLIENT_SECRET Google OAuth client secret (required)
|
|
39
|
+
CRINKL_API_URL API base URL (default: https://api.crinkl.xyz)
|
|
40
|
+
MAX_EMAIL_AGE_DAYS How far back to search (default: 14)
|
|
41
|
+
|
|
42
|
+
Get started:
|
|
43
|
+
1. Get an API key at https://app.crinkl.xyz
|
|
44
|
+
2. Create a Google OAuth app at https://console.cloud.google.com/apis/credentials
|
|
45
|
+
3. Copy .env.example to .env and fill in your credentials
|
|
46
|
+
4. Run: crinkl-agent
|
|
47
|
+
`.trim();
|
|
48
|
+
/** Load set of already-submitted Gmail message IDs */
|
|
49
|
+
function loadSubmittedIds() {
|
|
50
|
+
if (!existsSync(SUBMITTED_IDS_FILE))
|
|
51
|
+
return new Set();
|
|
52
|
+
try {
|
|
53
|
+
const data = JSON.parse(readFileSync(SUBMITTED_IDS_FILE, "utf-8"));
|
|
54
|
+
return new Set(Array.isArray(data) ? data : []);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return new Set();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Save submitted IDs to disk */
|
|
61
|
+
function saveSubmittedIds(ids) {
|
|
62
|
+
mkdirSync(dirname(SUBMITTED_IDS_FILE), { recursive: true });
|
|
63
|
+
writeFileSync(SUBMITTED_IDS_FILE, JSON.stringify([...ids], null, 2));
|
|
64
|
+
}
|
|
65
|
+
async function main() {
|
|
66
|
+
const args = process.argv.slice(2);
|
|
67
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
68
|
+
console.log(HELP);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const authOnly = args.includes("--auth");
|
|
72
|
+
const scanOnly = args.includes("--scan");
|
|
73
|
+
console.log("Crinkl Email Receipt Agent v0.1.0\n");
|
|
74
|
+
// 1. Load config
|
|
75
|
+
const config = loadConfig();
|
|
76
|
+
const crinkl = new CrinklClient(config);
|
|
77
|
+
// 2. Authenticate with Gmail
|
|
78
|
+
console.log("Connecting to Gmail...");
|
|
79
|
+
const gmail = await getGmailClient(config);
|
|
80
|
+
console.log("Gmail connected.\n");
|
|
81
|
+
if (authOnly) {
|
|
82
|
+
console.log("Auth setup complete. Run without --auth to scan emails.");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// 3. Fetch allowed vendors from Crinkl
|
|
86
|
+
console.log("Fetching allowed vendors...");
|
|
87
|
+
const vendors = await crinkl.getAllowedVendors();
|
|
88
|
+
console.log(`Allowed vendors: ${vendors.map((v) => v.displayName).join(", ")}\n`);
|
|
89
|
+
if (vendors.length === 0) {
|
|
90
|
+
console.log("No vendors are currently approved. Check back later.");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// 4. Search Gmail for receipt emails
|
|
94
|
+
const emails = await searchReceiptEmails(gmail, vendors, config.maxEmailAgeDays);
|
|
95
|
+
if (emails.length === 0) {
|
|
96
|
+
console.log("No receipt emails found in the last " +
|
|
97
|
+
config.maxEmailAgeDays +
|
|
98
|
+
" days.");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// 5. Process each email
|
|
102
|
+
const submittedIds = loadSubmittedIds();
|
|
103
|
+
let submitted = 0;
|
|
104
|
+
let skipped = 0;
|
|
105
|
+
let errors = 0;
|
|
106
|
+
for (const email of emails) {
|
|
107
|
+
// Dedup: skip already-submitted emails
|
|
108
|
+
if (submittedIds.has(email.messageId)) {
|
|
109
|
+
skipped++;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const subject = await getMessageSubject(gmail, email.messageId);
|
|
113
|
+
console.log(`\n--- Processing: ${subject}`);
|
|
114
|
+
try {
|
|
115
|
+
// Download raw .eml (in memory only)
|
|
116
|
+
const rawEml = await downloadRawEml(gmail, email.messageId);
|
|
117
|
+
// Preview: verify DKIM first
|
|
118
|
+
const preview = await crinkl.verifyEmailReceipt(rawEml);
|
|
119
|
+
if (!preview.success) {
|
|
120
|
+
console.log(` SKIP: ${preview.error}`);
|
|
121
|
+
// Mark as "seen" to avoid retrying non-DKIM emails
|
|
122
|
+
submittedIds.add(email.messageId);
|
|
123
|
+
skipped++;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const data = preview.data;
|
|
127
|
+
const amount = (data.totalCents / 100).toFixed(2);
|
|
128
|
+
console.log(` DKIM: ${data.dkimVerified ? "PASS" : "FAIL"} (${data.dkimDomain})`);
|
|
129
|
+
console.log(` Amount: $${amount} ${data.currency}`);
|
|
130
|
+
console.log(` Date: ${data.date}`);
|
|
131
|
+
if (data.invoiceId)
|
|
132
|
+
console.log(` Invoice: ${data.invoiceId}`);
|
|
133
|
+
if (!data.dkimVerified) {
|
|
134
|
+
console.log(" SKIP: DKIM verification failed");
|
|
135
|
+
submittedIds.add(email.messageId);
|
|
136
|
+
skipped++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (scanOnly) {
|
|
140
|
+
console.log(" DRY RUN: would submit (run without --scan to submit)");
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// Submit for rewards
|
|
144
|
+
const result = await crinkl.submitEmailReceipt(rawEml);
|
|
145
|
+
if (result.status === "QUEUED_FOR_REVIEW") {
|
|
146
|
+
console.log(` QUEUED: vendor ${result.domain || "unknown"} not yet approved — queued for admin review`);
|
|
147
|
+
submittedIds.add(email.messageId);
|
|
148
|
+
skipped++;
|
|
149
|
+
}
|
|
150
|
+
else if (result.success && result.data) {
|
|
151
|
+
console.log(` SUBMITTED: ${result.data.store} — $${amount} — status: ${result.data.status}`);
|
|
152
|
+
submittedIds.add(email.messageId);
|
|
153
|
+
submitted++;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
console.log(` ERROR: ${result.error}`);
|
|
157
|
+
if (result.error?.includes("already been submitted")) {
|
|
158
|
+
submittedIds.add(email.messageId);
|
|
159
|
+
skipped++;
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
errors++;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
console.error(` ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
|
168
|
+
errors++;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Save dedup state
|
|
172
|
+
saveSubmittedIds(submittedIds);
|
|
173
|
+
// Summary
|
|
174
|
+
console.log("\n--- Summary ---");
|
|
175
|
+
console.log(`Submitted: ${submitted}`);
|
|
176
|
+
console.log(`Skipped: ${skipped} (already submitted or non-receipt)`);
|
|
177
|
+
if (errors > 0)
|
|
178
|
+
console.log(`Errors: ${errors}`);
|
|
179
|
+
console.log("");
|
|
180
|
+
}
|
|
181
|
+
main().catch((err) => {
|
|
182
|
+
console.error("Fatal error:", err);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "crinkl-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Earn BTC from email receipts. Scans Gmail for billing emails, verifies DKIM signatures, and submits to Crinkl.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"crinkl-agent": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"start": "node dist/index.js",
|
|
15
|
+
"dev": "tsx src/index.ts",
|
|
16
|
+
"scan": "tsx src/index.ts --scan",
|
|
17
|
+
"auth": "tsx src/index.ts --auth",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"test:watch": "vitest",
|
|
20
|
+
"lint": "tsc --noEmit",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"crinkl",
|
|
25
|
+
"bitcoin",
|
|
26
|
+
"receipt",
|
|
27
|
+
"dkim",
|
|
28
|
+
"email",
|
|
29
|
+
"agent"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "https://github.com/crinkl-protocol/crinkl-agent.git"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://crinkl.xyz",
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"googleapis": "^144.0.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^20.0.0",
|
|
45
|
+
"tsx": "^4.19.0",
|
|
46
|
+
"typescript": "^5.7.0",
|
|
47
|
+
"vitest": "^4.0.18"
|
|
48
|
+
}
|
|
49
|
+
}
|