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 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>
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }