16
README.md
16
README.md
@@ -1,16 +1,16 @@
|
|||||||
# Declawed (A Mail Bot)
|
# Declawed: A Configurable, Promptable AI Mail Assisty Kitty
|
||||||
|
|
||||||
A local Model Context Protocol (MCP) server connecing LLM platform APIs (ie Claude Desktop), or locally hosted models to your mail account.
|
A local Model Context Protocol (MCP) server and LLM integration platform. Built to connect to LLM APIs - be itClaude Desktop, *others*... or locally hosted models. Executes your prompts to manage your mail, while you... watch Fellini films, solve climate change, sip Mai Tais, or.... whatever.
|
||||||
Ssimple.
|
|
||||||
No Surprises, unlike the blue-plate-special-crustacean of the day.
|
Ssssimple. Siamsese, if you please.... Eats the blue-plate-crustacean for breakfast.
|
||||||
Automates review, classification, and action -- response, deletion, archiving -- for inbox items.
|
|
||||||
|
Infinitely mod-able. Dead simple. Privacy centric.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Scope
|
# Scope
|
||||||
|
|
||||||
## Current implementaiton contemplates: Claude Desktop + Local Custom Model Context Protocol Server + Goog-MX'ed SMTP for your Registered, DNS'd XXX.YYY domain
|
## Current implementation contemplates: Claude Desktop + Custom, Local Model Context Protocol Server + Commercial SMTP Server (MX'config’d properly) + your DNS-config’d XXX.YYY
|
||||||
(highly config'able- more to follow)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -268,3 +268,5 @@ assistant/
|
|||||||
├── tsconfig.json # TypeScript compiler config
|
├── tsconfig.json # TypeScript compiler config
|
||||||
└── README.md # This file
|
└── README.md # This file
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
137
src/index.ts
137
src/index.ts
@@ -12,10 +12,54 @@ import path from "path";
|
|||||||
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||||
const PROJECT_ROOT = path.resolve(__dirname, "..");
|
const PROJECT_ROOT = path.resolve(__dirname, "..");
|
||||||
const CREDENTIALS_PATH = path.join(PROJECT_ROOT, "credentials.json");
|
const CREDENTIALS_PATH = path.join(PROJECT_ROOT, "credentials.json");
|
||||||
const TOKEN_PATH = path.join(PROJECT_ROOT, "token.json");
|
const ACCOUNTS_PATH = path.join(PROJECT_ROOT, "accounts.json");
|
||||||
const SUMMARY_PATH = path.join(PROJECT_ROOT, "summary.json");
|
|
||||||
const PROMPT_PATH = path.join(PROJECT_ROOT, "classify-emails.txt");
|
const PROMPT_PATH = path.join(PROJECT_ROOT, "classify-emails.txt");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Account configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
interface AccountConfig {
|
||||||
|
label: string;
|
||||||
|
tokenFile: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountsMap {
|
||||||
|
[key: string]: AccountConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAccounts(): AccountsMap {
|
||||||
|
if (!fs.existsSync(ACCOUNTS_PATH)) {
|
||||||
|
throw new Error(`Missing accounts.json at ${ACCOUNTS_PATH}.`);
|
||||||
|
}
|
||||||
|
return JSON.parse(fs.readFileSync(ACCOUNTS_PATH, "utf-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTokenPath(account: string): string {
|
||||||
|
const accounts = loadAccounts();
|
||||||
|
const acct = accounts[account];
|
||||||
|
if (!acct) {
|
||||||
|
const available = Object.keys(accounts).join(", ");
|
||||||
|
throw new Error(
|
||||||
|
`Unknown account "${account}". Available accounts: ${available}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return path.join(PROJECT_ROOT, acct.tokenFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSummaryPath(account: string): string {
|
||||||
|
if (account === "work") {
|
||||||
|
return path.join(PROJECT_ROOT, "summary.json");
|
||||||
|
}
|
||||||
|
return path.join(PROJECT_ROOT, `summary-${account}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_ACCOUNTS = ["work", "secondary"] as const;
|
||||||
|
const accountSchema = z
|
||||||
|
.enum(VALID_ACCOUNTS)
|
||||||
|
.describe(
|
||||||
|
"Which email account to use: \"work\" (sj@sjdev.co) or \"secondary\" (ken.jannette@gmail.com)"
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Load classification prompt
|
// Load classification prompt
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -28,17 +72,19 @@ function loadClassificationPrompt(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Gmail auth helper
|
// Gmail auth helper — parameterized by account key
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function getGmailClient(): gmail_v1.Gmail {
|
function getGmailClient(account: string): gmail_v1.Gmail {
|
||||||
|
const tokenPath = getTokenPath(account);
|
||||||
|
|
||||||
if (!fs.existsSync(CREDENTIALS_PATH)) {
|
if (!fs.existsSync(CREDENTIALS_PATH)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Missing credentials.json at ${CREDENTIALS_PATH}. Run "npm run auth" first.`
|
`Missing credentials.json at ${CREDENTIALS_PATH}. Run "npm run auth" first.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!fs.existsSync(TOKEN_PATH)) {
|
if (!fs.existsSync(tokenPath)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Missing token.json at ${TOKEN_PATH}. Run "npm run auth" first.`
|
`Missing token file at ${tokenPath} for account "${account}". Run "npm run auth" first.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,17 +98,17 @@ function getGmailClient(): gmail_v1.Gmail {
|
|||||||
redirect_uris[0]
|
redirect_uris[0]
|
||||||
);
|
);
|
||||||
|
|
||||||
const token = JSON.parse(fs.readFileSync(TOKEN_PATH, "utf-8"));
|
const token = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
|
||||||
oAuth2Client.setCredentials(token);
|
oAuth2Client.setCredentials(token);
|
||||||
|
|
||||||
// Persist refreshed tokens automatically
|
// Persist refreshed tokens automatically
|
||||||
oAuth2Client.on("tokens", (newTokens) => {
|
oAuth2Client.on("tokens", (newTokens) => {
|
||||||
const current = JSON.parse(fs.readFileSync(TOKEN_PATH, "utf-8"));
|
const current = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
TOKEN_PATH,
|
tokenPath,
|
||||||
JSON.stringify({ ...current, ...newTokens }, null, 2)
|
JSON.stringify({ ...current, ...newTokens }, null, 2)
|
||||||
);
|
);
|
||||||
console.error("Token refreshed and saved.");
|
console.error(`Token refreshed and saved for account "${account}".`);
|
||||||
});
|
});
|
||||||
|
|
||||||
return google.gmail({ version: "v1", auth: oAuth2Client });
|
return google.gmail({ version: "v1", auth: oAuth2Client });
|
||||||
@@ -117,13 +163,13 @@ const server = new McpServer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Prompt: review_emails
|
// Prompt: review_emails (work account)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
server.registerPrompt(
|
server.registerPrompt(
|
||||||
"review_emails",
|
"review_emails",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Review inbox, classify job application emails (A/B/C/D), delete A+C, summarize B+D.",
|
"Review WORK inbox (sj@sjdev.co): classify job application emails (A/B/C/D), delete A+C, summarize B+D.",
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
const instructions = loadClassificationPrompt();
|
const instructions = loadClassificationPrompt();
|
||||||
@@ -133,8 +179,38 @@ server.registerPrompt(
|
|||||||
role: "user" as const,
|
role: "user" as const,
|
||||||
content: {
|
content: {
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: instructions ||
|
text:
|
||||||
"Review my new emails and classify them by job application category.",
|
`ACCOUNT: Use account = "work" for ALL tool calls in this session.\n\n` +
|
||||||
|
(instructions ||
|
||||||
|
"Review my new emails and classify them by job application category."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Prompt: review_secondary_emails (secondary account)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
server.registerPrompt(
|
||||||
|
"review_secondary_emails",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Review SECONDARY inbox (ken.jannette@gmail.com): classify job application emails (A/B/C/D), delete A+C, summarize B+D.",
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
const instructions = loadClassificationPrompt();
|
||||||
|
return {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user" as const,
|
||||||
|
content: {
|
||||||
|
type: "text" as const,
|
||||||
|
text:
|
||||||
|
`ACCOUNT: Use account = "secondary" for ALL tool calls in this session.\n\n` +
|
||||||
|
(instructions ||
|
||||||
|
"Review my new emails and classify them by job application category."),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -149,10 +225,11 @@ server.registerTool(
|
|||||||
"fetch_new_emails",
|
"fetch_new_emails",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Fetch unread emails from the Gmail inbox. Returns sender, date, " +
|
"Fetch unread emails from a Gmail inbox. Returns sender, date, " +
|
||||||
"subject, message ID, and body text for each message. The message IDs " +
|
"subject, message ID, and body text for each message. The message IDs " +
|
||||||
"can be passed to delete_emails later.",
|
"can be passed to delete_emails later. Specify which account to fetch from.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
|
account: accountSchema,
|
||||||
maxResults: z
|
maxResults: z
|
||||||
.number()
|
.number()
|
||||||
.min(1)
|
.min(1)
|
||||||
@@ -160,9 +237,9 @@ server.registerTool(
|
|||||||
.describe("Maximum number of unread emails to fetch (1-100)"),
|
.describe("Maximum number of unread emails to fetch (1-100)"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ maxResults }) => {
|
async ({ account, maxResults }) => {
|
||||||
try {
|
try {
|
||||||
const gmail = getGmailClient();
|
const gmail = getGmailClient(account);
|
||||||
|
|
||||||
const listResponse = await gmail.users.messages.list({
|
const listResponse = await gmail.users.messages.list({
|
||||||
userId: "me",
|
userId: "me",
|
||||||
@@ -246,16 +323,18 @@ server.registerTool(
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Move emails to trash by their Gmail message IDs. Use this for " +
|
"Move emails to trash by their Gmail message IDs. Use this for " +
|
||||||
"category A (acknowledgements) and category C (rejections) emails.",
|
"category A (acknowledgements) and category C (rejections) emails. " +
|
||||||
|
"Specify which account the emails belong to.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
|
account: accountSchema,
|
||||||
messageIds: z
|
messageIds: z
|
||||||
.array(z.string())
|
.array(z.string())
|
||||||
.describe("Array of Gmail message IDs to move to trash"),
|
.describe("Array of Gmail message IDs to move to trash"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ messageIds }) => {
|
async ({ account, messageIds }) => {
|
||||||
try {
|
try {
|
||||||
const gmail = getGmailClient();
|
const gmail = getGmailClient(account);
|
||||||
const results: string[] = [];
|
const results: string[] = [];
|
||||||
|
|
||||||
for (const id of messageIds) {
|
for (const id of messageIds) {
|
||||||
@@ -311,8 +390,10 @@ server.registerTool(
|
|||||||
description:
|
description:
|
||||||
"Append classified email entries to the local summary file. Use this " +
|
"Append classified email entries to the local summary file. Use this " +
|
||||||
"for category B (advancement to next step) and category D (other) emails. " +
|
"for category B (advancement to next step) and category D (other) emails. " +
|
||||||
"Each entry records the sender, date, subject, and category.",
|
"Each entry records the sender, date, subject, and category. " +
|
||||||
|
"Specify which account the emails belong to.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
|
account: accountSchema,
|
||||||
entries: z
|
entries: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -332,12 +413,14 @@ server.registerTool(
|
|||||||
.describe("Array of email summary entries to append"),
|
.describe("Array of email summary entries to append"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ entries }) => {
|
async ({ account, entries }) => {
|
||||||
try {
|
try {
|
||||||
|
const summaryPath = getSummaryPath(account);
|
||||||
|
|
||||||
// Load existing summary or start fresh
|
// Load existing summary or start fresh
|
||||||
let summary: SummaryEntry[] = [];
|
let summary: SummaryEntry[] = [];
|
||||||
if (fs.existsSync(SUMMARY_PATH)) {
|
if (fs.existsSync(summaryPath)) {
|
||||||
summary = JSON.parse(fs.readFileSync(SUMMARY_PATH, "utf-8"));
|
summary = JSON.parse(fs.readFileSync(summaryPath, "utf-8"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -347,7 +430,7 @@ server.registerTool(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
summary.push(...newEntries);
|
summary.push(...newEntries);
|
||||||
fs.writeFileSync(SUMMARY_PATH, JSON.stringify(summary, null, 2));
|
fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -356,7 +439,7 @@ server.registerTool(
|
|||||||
text:
|
text:
|
||||||
`Appended ${newEntries.length} entry/entries to summary.\n` +
|
`Appended ${newEntries.length} entry/entries to summary.\n` +
|
||||||
`Total entries in summary: ${summary.length}\n` +
|
`Total entries in summary: ${summary.length}\n` +
|
||||||
`Summary file: ${SUMMARY_PATH}`,
|
`Summary file: ${summaryPath}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
130
summary-secondary.json
Normal file
130
summary-secondary.json
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"senderName": "Yelena Nikolov",
|
||||||
|
"senderEmail": "Yelena.Nikolov@meridianlink.com",
|
||||||
|
"dateReceived": "Tue, 17 Feb 2026 13:57:42 +0000",
|
||||||
|
"subject": "Interview next steps with MeridianLink",
|
||||||
|
"category": "B",
|
||||||
|
"addedAt": "2026-02-17T14:10:30.911Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"senderName": "LDEAskHR",
|
||||||
|
"senderEmail": "LDEAskHR@epiqglobal.com",
|
||||||
|
"dateReceived": "Fri, 13 Feb 2026 17:12:26 +0000",
|
||||||
|
"subject": "Immediate Action Requested - Background check",
|
||||||
|
"category": "B",
|
||||||
|
"addedAt": "2026-02-17T14:10:30.911Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"senderName": "Talent Experience Team",
|
||||||
|
"senderEmail": "talentsupport@mbopartners.com",
|
||||||
|
"dateReceived": "Fri, 13 Feb 2026 17:03:27 +0000",
|
||||||
|
"subject": "[URGENT ACTION NEEDED!] FINAL NOTICE - Security Training - OVERDUE",
|
||||||
|
"category": "B",
|
||||||
|
"addedAt": "2026-02-17T14:10:30.911Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"senderName": "Talent Experience Team",
|
||||||
|
"senderEmail": "talentsupport@mbopartners.com",
|
||||||
|
"dateReceived": "Fri, 13 Feb 2026 17:01:12 +0000",
|
||||||
|
"subject": "[ACTION NEEDED!] KPMG Ethics & Integrity Training for KPMG's Contingent Worker Training - OVERDUE",
|
||||||
|
"category": "B",
|
||||||
|
"addedAt": "2026-02-17T14:10:30.911Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"senderName": "Penny from Rocket Money",
|
||||||
|
"senderEmail": "support@rocketmoney.com",
|
||||||
|
"dateReceived": "Tue, 17 Feb 2026 14:00:20 +0000",
|
||||||
|
"subject": "Re: [External] New Cell Number -m Cannot 2FA to My Account",
|
||||||
|
"category": "D",
|
||||||
|
"addedAt": "2026-02-17T14:10:30.911Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"senderName": "Mondaq Newsletters",
|
||||||
|
"senderEmail": "newsletters@webiis01.mondaq.com",
|
||||||
|
"dateReceived": "Tue, 17 Feb 2026 13:37:53 +0000",
|
||||||
|
"subject": "Mondaq Personalized News Alert",
|
||||||
|
"category": "D",
|
||||||
|
"addedAt": "2026-02-17T14:10:30.911Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"senderName": "MoneyLion Partner",
|
||||||
|
"senderEmail": "partners@iemail.moneylion.com",
|
||||||
|
"dateReceived": "Tue, 17 Feb 2026 04:00:40 +0000",
|
||||||
|
"subject": "3 days left to view your personalized offers.",
|
||||||
|
"category": "D",
|
||||||
|
"addedAt": "2026-02-17T14:10:30.911Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"senderName": "JobCopilot",
|
||||||
|
"senderEmail": "noreply@jobcopilot.com",
|
||||||
|
"dateReceived": "Mon, 16 Feb 2026 23:33:56 +0000",
|
||||||
|
"subject": "JobCopilot has applied to 50 jobs",
|
||||||
|
"category": "D",
|
||||||
|
"addedAt": "2026-02-17T14:10:30.911Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"senderName": "no-reply@equifax.com",
|
||||||
|
"senderEmail": "no-reply@equifax.com",
|
||||||
|
"dateReceived": "Mon, 16 Feb 2026 21:01:48 +0000",
|
||||||
|
"subject": "New Equifax Credit Report Available",
|
||||||
|
"category": "D",
|
||||||
|
"addedAt": "2026-02-17T14:10:30.911Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"senderName": "Call-On-Doc",
|
||||||
|
"senderEmail": "no-reply@callondoc.com",
|
||||||
|
"dateReceived": "Mon, 16 Feb 2026 20:46:24 +0000",
|
||||||
|
"subject": "CallonDoc - Your Login Email has Been Changed.",
|
||||||
|
"category": "D",
|
||||||
|
"addedAt": "2026-02-17T14:10:30.911Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"senderName": "Bank of America",
|
||||||
|
"senderEmail": "customerservice@ealerts.bankofamerica.com",
|
||||||
|
"dateReceived": "Mon, 16 Feb 2026 09:55:20 -0600",
|
||||||
|
"subject": "Kenneth S Jannette sent you $150.00",
|
||||||
|
"category": "D",
|
||||||
|
"addedAt": "2026-02-17T14:10:30.911Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"senderName": "Concora Credit",
|
||||||
|
"senderEmail": "donotreply_concoracredit@mail.concoracredit.com",
|
||||||
|
"dateReceived": "Sat, 14 Feb 2026 16:01:10 -0600",
|
||||||
|
"subject": "Your Statement Is Ready To View",
|
||||||
|
"category": "D",
|
||||||
|
"addedAt": "2026-02-17T14:10:30.911Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"senderName": "MBO Talent Experience & Support Team",
|
||||||
|
"senderEmail": "talentsupport@mbopartners.com",
|
||||||
|
"dateReceived": "Fri, 13 Feb 2026 10:44:08 -0600",
|
||||||
|
"subject": "KPMG Standard Work Hours and Overtime Policy",
|
||||||
|
"category": "D",
|
||||||
|
"addedAt": "2026-02-17T14:10:30.911Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"senderName": "Noun Project",
|
||||||
|
"senderEmail": "yourfriends@thenounproject.com",
|
||||||
|
"dateReceived": "Fri, 13 Feb 2026 01:00:53 +0000",
|
||||||
|
"subject": "Your Noun Project Invoice",
|
||||||
|
"category": "D",
|
||||||
|
"addedAt": "2026-02-17T14:10:30.911Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"senderName": "The Termly Team",
|
||||||
|
"senderEmail": "termlyservices@email.termly.io",
|
||||||
|
"dateReceived": "Thu, 12 Feb 2026 20:16:59 +0000",
|
||||||
|
"subject": "Upcoming IAB TCF v2.3 update: disclosedVendors support",
|
||||||
|
"category": "D",
|
||||||
|
"addedAt": "2026-02-17T14:10:30.911Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"senderName": "Brave Search API",
|
||||||
|
"senderEmail": "search-api@brave.com",
|
||||||
|
"dateReceived": "Thu, 12 Feb 2026 19:57:43 +0000",
|
||||||
|
"subject": "New ToS & plans for the Brave Search API",
|
||||||
|
"category": "D",
|
||||||
|
"addedAt": "2026-02-17T14:10:30.911Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user