@hasna/contacts 0.2.3 → 0.2.4

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.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Gmail contact import — fetches messages matching a query, extracts unique
3
+ * senders/recipients, and returns them as CreateContactInput objects.
4
+ *
5
+ * Uses the Gmail REST API directly with tokens stored at
6
+ * ~/.connectors/connect-gmail/profiles/default/tokens.json
7
+ * (requires connect-gmail auth login to be completed first).
8
+ */
9
+ import type { CreateContactInput } from "../types/index.js";
10
+ /**
11
+ * Parse a single RFC 5322 address header value into { name, email } pairs.
12
+ * Handles: "Name <email>", "<email>", "email", and comma-separated lists.
13
+ */
14
+ export declare function parseAddressHeader(header: string): Array<{
15
+ name: string;
16
+ email: string;
17
+ }>;
18
+ export interface GmailImportOptions {
19
+ query: string;
20
+ /** Max messages to scan (default 200, max 500) */
21
+ max_messages?: number;
22
+ /** Gmail profile to use (default: "default") */
23
+ gmail_profile?: string;
24
+ tag_ids?: string[];
25
+ group_id?: string;
26
+ }
27
+ export interface ExtractedContact {
28
+ email: string;
29
+ name: string;
30
+ company_hint: string | null;
31
+ contact_input: CreateContactInput;
32
+ }
33
+ /**
34
+ * Fetch messages matching `query` from Gmail and extract unique contacts.
35
+ * Does NOT write to the database — returns prepared CreateContactInput objects.
36
+ */
37
+ export declare function extractContactsFromGmail(opts: GmailImportOptions): Promise<ExtractedContact[]>;
38
+ //# sourceMappingURL=gmail-import.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gmail-import.d.ts","sourceRoot":"","sources":["../../src/lib/gmail-import.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAKH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAoD5D;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAoBzF;AAqCD,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,kDAAkD;IAClD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gDAAgD;IAChD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,aAAa,EAAE,kBAAkB,CAAC;CACnC;AAED;;;GAGG;AACH,wBAAsB,wBAAwB,CAC5C,IAAI,EAAE,kBAAkB,GACvB,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAoF7B"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=gmail-import.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gmail-import.test.d.ts","sourceRoot":"","sources":["../../src/lib/gmail-import.test.ts"],"names":[],"mappings":""}
package/dist/mcp/index.js CHANGED
@@ -1688,6 +1688,152 @@ async function exportContacts(format, contacts) {
1688
1688
  }
1689
1689
  }
1690
1690
 
1691
+ // src/lib/gmail-import.ts
1692
+ import { existsSync as existsSync2, readFileSync } from "fs";
1693
+ import { homedir } from "os";
1694
+ import { join as join2 } from "path";
1695
+ function loadGmailToken(profile = "default") {
1696
+ const bases = [
1697
+ join2(homedir(), ".connectors", "connect-gmail"),
1698
+ join2(homedir(), ".connect", "connect-gmail")
1699
+ ];
1700
+ for (const base of bases) {
1701
+ const tokenPath = join2(base, "profiles", profile, "tokens.json");
1702
+ if (existsSync2(tokenPath)) {
1703
+ try {
1704
+ const raw = readFileSync(tokenPath, "utf-8");
1705
+ const tokens = JSON.parse(raw);
1706
+ if (tokens.accessToken)
1707
+ return tokens.accessToken;
1708
+ } catch {}
1709
+ }
1710
+ }
1711
+ throw new Error("Gmail not authenticated. Run `connect-gmail auth login` first.");
1712
+ }
1713
+ function parseAddressHeader(header) {
1714
+ const results = [];
1715
+ const parts = header.split(/,(?![^<>]*>)(?![^"]*")/);
1716
+ for (const part of parts) {
1717
+ const trimmed = part.trim();
1718
+ if (!trimmed)
1719
+ continue;
1720
+ const angleMatch = trimmed.match(/^(.*?)\s*<([^>]+)>\s*$/);
1721
+ if (angleMatch) {
1722
+ const name = angleMatch[1].trim().replace(/^"|"$/g, "");
1723
+ const email = angleMatch[2].trim().toLowerCase();
1724
+ if (email.includes("@"))
1725
+ results.push({ name, email });
1726
+ } else if (trimmed.includes("@")) {
1727
+ results.push({ name: "", email: trimmed.toLowerCase() });
1728
+ }
1729
+ }
1730
+ return results;
1731
+ }
1732
+ function domainToCompany(email) {
1733
+ const domain = email.split("@")[1];
1734
+ if (!domain)
1735
+ return null;
1736
+ const genericDomains = new Set([
1737
+ "gmail.com",
1738
+ "googlemail.com",
1739
+ "yahoo.com",
1740
+ "yahoo.co.uk",
1741
+ "hotmail.com",
1742
+ "hotmail.co.uk",
1743
+ "outlook.com",
1744
+ "live.com",
1745
+ "icloud.com",
1746
+ "me.com",
1747
+ "mac.com",
1748
+ "protonmail.com",
1749
+ "pm.me",
1750
+ "fastmail.com",
1751
+ "hey.com",
1752
+ "aol.com",
1753
+ "msn.com"
1754
+ ]);
1755
+ if (genericDomains.has(domain.toLowerCase()))
1756
+ return null;
1757
+ const parts = domain.split(".");
1758
+ const name = parts.length > 2 ? parts[parts.length - 2] : parts[0];
1759
+ return name.charAt(0).toUpperCase() + name.slice(1);
1760
+ }
1761
+ function parseName(displayName) {
1762
+ const name = displayName.trim();
1763
+ if (!name)
1764
+ return {};
1765
+ const parts = name.split(/\s+/);
1766
+ if (parts.length === 1)
1767
+ return { first_name: parts[0] };
1768
+ const last = parts.pop();
1769
+ return { first_name: parts.join(" "), last_name: last };
1770
+ }
1771
+ async function extractContactsFromGmail(opts) {
1772
+ const maxMessages = Math.min(opts.max_messages ?? 200, 500);
1773
+ const profile = opts.gmail_profile ?? "default";
1774
+ const token = loadGmailToken(profile);
1775
+ const listUrl = new URL("https://gmail.googleapis.com/gmail/v1/users/me/messages");
1776
+ listUrl.searchParams.set("q", opts.query);
1777
+ listUrl.searchParams.set("maxResults", String(maxMessages));
1778
+ const listResp = await fetch(listUrl.toString(), {
1779
+ headers: { Authorization: `Bearer ${token}` }
1780
+ });
1781
+ if (!listResp.ok) {
1782
+ const body = await listResp.text();
1783
+ if (listResp.status === 401) {
1784
+ throw new Error("Gmail token expired. Run `connect-gmail auth login` to re-authenticate.");
1785
+ }
1786
+ throw new Error(`Gmail API error ${listResp.status}: ${body}`);
1787
+ }
1788
+ const listData = await listResp.json();
1789
+ const messageRefs = listData.messages ?? [];
1790
+ if (messageRefs.length === 0) {
1791
+ return [];
1792
+ }
1793
+ const seen = new Map;
1794
+ const batchSize = 20;
1795
+ for (let i = 0;i < messageRefs.length; i += batchSize) {
1796
+ const batch = messageRefs.slice(i, i + batchSize);
1797
+ const fetches = batch.map((ref) => {
1798
+ const url = new URL(`https://gmail.googleapis.com/gmail/v1/users/me/messages/${ref.id}`);
1799
+ url.searchParams.set("format", "metadata");
1800
+ url.searchParams.set("metadataHeaders", "From");
1801
+ url.searchParams.set("metadataHeaders", "To");
1802
+ url.searchParams.set("metadataHeaders", "Cc");
1803
+ return fetch(url.toString(), {
1804
+ headers: { Authorization: `Bearer ${token}` }
1805
+ }).then((r) => r.ok ? r.json() : null);
1806
+ });
1807
+ const results = await Promise.all(fetches);
1808
+ for (const msg of results) {
1809
+ if (!msg?.payload?.headers)
1810
+ continue;
1811
+ const headers = msg.payload.headers;
1812
+ const getHeader = (name) => headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? "";
1813
+ const addresses = [
1814
+ ...parseAddressHeader(getHeader("From")),
1815
+ ...parseAddressHeader(getHeader("To")),
1816
+ ...parseAddressHeader(getHeader("Cc"))
1817
+ ];
1818
+ for (const { name, email } of addresses) {
1819
+ if (seen.has(email))
1820
+ continue;
1821
+ const company_hint = domainToCompany(email);
1822
+ const nameParts = parseName(name);
1823
+ const contact_input = {
1824
+ ...nameParts,
1825
+ display_name: name || email.split("@")[0],
1826
+ emails: [{ address: email, type: "work", is_primary: true }],
1827
+ ...opts.tag_ids?.length ? { tag_ids: opts.tag_ids } : {},
1828
+ source: "email"
1829
+ };
1830
+ seen.set(email, { email, name, company_hint, contact_input });
1831
+ }
1832
+ }
1833
+ }
1834
+ return Array.from(seen.values());
1835
+ }
1836
+
1691
1837
  // src/mcp/index.ts
1692
1838
  var server = new Server({ name: "contacts", version: "0.1.0" }, { capabilities: { tools: {} } });
1693
1839
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -2431,6 +2577,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2431
2577
  properties: { contact_id: { type: "string" } },
2432
2578
  required: ["contact_id"]
2433
2579
  }
2580
+ },
2581
+ {
2582
+ name: "import_contacts_from_gmail",
2583
+ description: "Extract unique contacts from Gmail messages matching a search query and batch-upsert them. Requires connect-gmail auth login first. Returns { imported, skipped, errors }.",
2584
+ inputSchema: {
2585
+ type: "object",
2586
+ properties: {
2587
+ query: { type: "string", description: "Gmail search query, e.g. 'from:(company.com) newer_than:30d'" },
2588
+ max_messages: { type: "number", description: "Max messages to scan (default 200, max 500)" },
2589
+ gmail_profile: { type: "string", description: "connect-gmail profile to use (default: 'default')" },
2590
+ tag_ids: { type: "array", items: { type: "string" }, description: "Tag IDs to apply to all imported contacts" },
2591
+ group_id: { type: "string", description: "Group ID to add all imported contacts to" },
2592
+ dry_run: { type: "boolean", description: "If true, extract contacts but do not save to database" }
2593
+ },
2594
+ required: ["query"]
2595
+ }
2434
2596
  }
2435
2597
  ]
2436
2598
  }));
@@ -2977,6 +3139,54 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2977
3139
  return { content: [{ type: "text", text: "null" }] };
2978
3140
  return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
2979
3141
  }
3142
+ case "import_contacts_from_gmail": {
3143
+ const db = getDatabase();
3144
+ const extracted = await extractContactsFromGmail({
3145
+ query: a.query,
3146
+ max_messages: a.max_messages,
3147
+ gmail_profile: a.gmail_profile,
3148
+ tag_ids: a.tag_ids,
3149
+ group_id: a.group_id
3150
+ });
3151
+ if (a.dry_run) {
3152
+ return {
3153
+ content: [{
3154
+ type: "text",
3155
+ text: JSON.stringify({ dry_run: true, would_import: extracted.length, contacts: extracted }, null, 2)
3156
+ }]
3157
+ };
3158
+ }
3159
+ let imported = 0;
3160
+ let skipped = 0;
3161
+ const errors = [];
3162
+ for (const { email, contact_input, company_hint } of extracted) {
3163
+ try {
3164
+ const emailRow = db.prepare(`SELECT contact_id FROM emails WHERE LOWER(address) = LOWER(?) AND contact_id IS NOT NULL LIMIT 1`).get(email);
3165
+ if (emailRow) {
3166
+ skipped++;
3167
+ continue;
3168
+ }
3169
+ const contact = createContact(contact_input);
3170
+ if (a.group_id && typeof a.group_id === "string") {
3171
+ try {
3172
+ db.prepare(`INSERT OR IGNORE INTO contact_groups (contact_id, group_id) VALUES (?, ?)`).run(contact.id, a.group_id);
3173
+ } catch {}
3174
+ }
3175
+ if (company_hint) {
3176
+ autoLinkContactToCompany(contact.id);
3177
+ }
3178
+ imported++;
3179
+ } catch (err) {
3180
+ errors.push(`${email}: ${err instanceof Error ? err.message : String(err)}`);
3181
+ }
3182
+ }
3183
+ return {
3184
+ content: [{
3185
+ type: "text",
3186
+ text: JSON.stringify({ imported, skipped, errors: errors.length, error_details: errors }, null, 2)
3187
+ }]
3188
+ };
3189
+ }
2980
3190
  default:
2981
3191
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
2982
3192
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/contacts",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Contact management for AI coding agents — CLI + MCP + Web",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -16,7 +16,12 @@
16
16
  "import": "./dist/index.js"
17
17
  }
18
18
  },
19
- "files": ["dist", "dashboard/dist", "LICENSE", "README.md"],
19
+ "files": [
20
+ "dist",
21
+ "dashboard/dist",
22
+ "LICENSE",
23
+ "README.md"
24
+ ],
20
25
  "scripts": {
21
26
  "build": "cd dashboard && bun run build && cd .. && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/index.ts --outdir dist --target bun && tsc --emitDeclarationOnly --outDir dist",
22
27
  "build:core": "bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/index.ts --outdir dist --target bun && tsc --emitDeclarationOnly --outDir dist",
@@ -27,12 +32,30 @@
27
32
  "dev:serve": "bun run src/server/index.ts",
28
33
  "prepublishOnly": "bun run build"
29
34
  },
30
- "keywords": ["contacts", "mcp", "ai", "coding-agent", "claude", "cli", "rolodex", "address-book"],
31
- "publishConfig": {"access": "public"},
32
- "repository": {"type": "git", "url": "https://github.com/hasna/contacts.git"},
35
+ "keywords": [
36
+ "contacts",
37
+ "mcp",
38
+ "ai",
39
+ "coding-agent",
40
+ "claude",
41
+ "cli",
42
+ "rolodex",
43
+ "address-book"
44
+ ],
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "https://github.com/hasna/contacts.git"
51
+ },
33
52
  "homepage": "https://github.com/hasna/contacts",
34
- "bugs": {"url": "https://github.com/hasna/contacts/issues"},
35
- "engines": {"bun": ">=1.0.0"},
53
+ "bugs": {
54
+ "url": "https://github.com/hasna/contacts/issues"
55
+ },
56
+ "engines": {
57
+ "bun": ">=1.0.0"
58
+ },
36
59
  "author": "Andrei Hasna <andrei@hasna.com>",
37
60
  "license": "Apache-2.0",
38
61
  "dependencies": {