@ezetgalaxy/cxr 1.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,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2025, Ezet Galaxy
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,235 @@
1
+
2
+ # CXR - Email Rule eXecutor
3
+
4
+ **CXR** is a production-ready, open-source rule engine / compiler for email filtering logic, written in JavaScript (Node.js, ESM).
5
+ It parses a custom `.cxr` domain language and executes deterministically against normalized email objects.
6
+
7
+ ## Features
8
+
9
+ - **Safe**: No `eval`, no `Function`, purely AST-based execution.
10
+ - **Deterministic**: Input -> Output is always the same.
11
+ - **Explainable**: Returns metadata and logs of all actions taken.
12
+ - **Extensible**: Pure JSON output format.
13
+ - **Zero Dependencies**: Lightweight and fast.
14
+ - **Flexible Loading**: Load rules from strings, files (`.cxr`), or combine multiple sources.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install @ezetgalaxy/cxr
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```javascript
25
+ import cxr from "@ezetgalaxy/cxr";
26
+
27
+ // 1. Prepare email objects
28
+ const emails = [
29
+ {
30
+ id: "1",
31
+ from: "notifications@github.com",
32
+ subject: "New login from Chrome",
33
+ body: "...",
34
+ unread: true,
35
+ receivedAt: Date.now()
36
+ }
37
+ ];
38
+
39
+ // 2. Execute using file path
40
+ const result = cxr.init({
41
+ rules: "./rules/alerts.cxr", // Path to .cxr file
42
+ emails
43
+ });
44
+
45
+ // Or combine multiple files/strings
46
+ const resultCombined = cxr.init({
47
+ rules: [
48
+ "./rules/alerts.cxr",
49
+ "./rules/personal.cxr",
50
+ `Folder Temporary WHEN subject contains "urgent" THEN notify`
51
+ ],
52
+ emails
53
+ });
54
+
55
+
56
+ console.log("Folders:", result.folders);
57
+ console.log("Actions Log:", result.actionsLog);
58
+ ```
59
+
60
+ ## Output Example
61
+
62
+ The `result` object is pure JSON, making it easy to consume:
63
+
64
+ ```json
65
+ {
66
+ "folders": {
67
+ "Inbox": [],
68
+ "Alerts": [
69
+ {
70
+ "id": "1",
71
+ "from": "notifications@github.com",
72
+ "subject": "New login from Chrome",
73
+ "unread": true
74
+ }
75
+ ]
76
+ },
77
+ "actionsLog": [
78
+ {
79
+ "emailId": "1",
80
+ "folder": "Alerts",
81
+ "actions": ["move to Alerts", "notify", "mark as unread"]
82
+ }
83
+ ],
84
+ "meta": {
85
+ "engine": "cxr",
86
+ "version": "1.0.0",
87
+ "processedAt": 1700000000000,
88
+ "ruleCount": 5,
89
+ "emailCount": 1
90
+ }
91
+ }
92
+ ```
93
+
94
+ ## UI Integration Example (React)
95
+
96
+ Because CXR returns structured data, building a UI is straightforward.
97
+
98
+ ```jsx
99
+ import React from 'react';
100
+ import cxr from 'cxr';
101
+
102
+ function EmailClient({ rules, rawEmails }) {
103
+ // 1. Run the engine
104
+ const { folders } = cxr.init({ rules, emails: rawEmails });
105
+
106
+ return (
107
+ <div className="email-client">
108
+ {Object.entries(folders).map(([folderName, emails]) => (
109
+ <div key={folderName} className="folder">
110
+ <h3>📂 {folderName} <span className="count">({emails.length})</span></h3>
111
+
112
+ <ul className="email-list">
113
+ {emails.map(email => (
114
+ <li key={email.id} className={email.unread ? 'unread' : ''}>
115
+ <span className="sender">{email.from}</span>
116
+ <span className="subject">{email.subject}</span>
117
+ </li>
118
+ ))}
119
+ </ul>
120
+ </div>
121
+ ))}
122
+ </div>
123
+ );
124
+ }
125
+ ```
126
+
127
+
128
+ ## Language Syntax
129
+
130
+ See [GRAMMAR.md](./grammar.md) for full specification.
131
+
132
+ ### File Extension
133
+
134
+ ```
135
+ .cxr
136
+ ```
137
+
138
+ ### Example Rule File (`alerts.cxr`)
139
+
140
+ ```
141
+ Folder Alerts
142
+
143
+ WHEN sender contains "github"
144
+ OR subject contains "login"
145
+ AND NOT sender contains "newsletter"
146
+
147
+ THEN move to Alerts
148
+ AND notify
149
+ AND mark as unread
150
+
151
+ Folder Personal
152
+
153
+ WHEN sender IN ["mom@family.com", "dad@family.com"]
154
+ THEN move to Personal
155
+ AND mark as read
156
+ ```
157
+
158
+ ## API
159
+
160
+ ### `cxr.init(config)`
161
+
162
+ - `config.rules` (string | string[]):
163
+ - A string containing rule content.
164
+ - A string containing a file path (must end in `.cxr`).
165
+ - An array of strings (content or file paths).
166
+
167
+ - `config.emails` (Array): List of email objects.
168
+ - Each email must have: `id`, `from`, `subject`, `body`, `unread`, `receivedAt` (by default).
169
+ - `config.schema` (Object, optional): Custom field mapping.
170
+ - Maps rule field names (e.g., `sender`) to your email object keys (e.g., `from_address`).
171
+ - Default: `{ sender: 'from', subject: 'subject', body: 'body' }`
172
+
173
+ ### Custom Schema Example
174
+
175
+ If your email objects look different, you can map the fields:
176
+
177
+ ```javascript
178
+ /* Email Object:
179
+ {
180
+ id: '1',
181
+ author: 'admin@site.com', // Instead of 'from'
182
+ content: 'Text...', // Instead of 'body'
183
+ 'X-Priority': 'High' // Custom header
184
+ }
185
+ */
186
+
187
+ /* Rule:
188
+ Folder Urgent
189
+ WHEN priority contains "High" AND sender contains "admin"
190
+ THEN move to Urgent
191
+ */
192
+
193
+ cxr.init({
194
+ rules: rules,
195
+ emails: myEmails,
196
+ schema: {
197
+ sender: 'author',
198
+ body: 'content',
199
+ priority: 'X-Priority'
200
+ }
201
+ });
202
+ ```
203
+
204
+
205
+ Returns an object with:
206
+ - `folders`: Mapping of folder names to arrays of matched emails.
207
+
208
+ - `actionsLog`: Array of action records per email.
209
+ - `meta`: Execution metadata (engine name: "cxr").
210
+
211
+ ## VS Code Extension
212
+
213
+ Included in this repository is a VS Code extension for `.cxr` files.
214
+
215
+ **Features:**
216
+ - Syntax Highlighting for Keywords, Actions, and Fields.
217
+ - Autocomplete / IntelliSense for rule structures.
218
+ - Snippets for quick rule creation.
219
+
220
+
221
+ **Installation (Manual):**
222
+ 1. **Download**: Get the `cxr-language-support-0.0.1.vsix` file from this repository.
223
+ 2. **Open VS Code / Cursor**:
224
+ - Go to the **Extensions View** (Ctrl+Shift+X).
225
+ - Click the **... (More Actions)** menu (top right of the pane).
226
+ - Select **Install from VSIX...**.
227
+ 3. **Select File**: Choose the `.vsix` file you downloaded.
228
+ 4. **Restart**: Reload the window to activate full support.
229
+
230
+ *Note: This extension is not yet published to the Visual Studio Marketplace so it will not appear in search results.*
231
+
232
+
233
+ ## License
234
+
235
+ ISC
package/grammar.md ADDED
@@ -0,0 +1,150 @@
1
+
2
+ # CXR Grammar Specification
3
+
4
+ This document defines the formal grammar and syntax for the `.cxr` file format.
5
+
6
+ ## Language Overview
7
+
8
+ A `.cxr` file consists of one or more **Folder** definitions. Each folder contains a set of **Rules** that determine which emails should be sorted into that folder.
9
+
10
+ The engine evaluates rules from top to bottom. The **first matching folder wins**.
11
+
12
+ ---
13
+
14
+ ## Basic Structure
15
+
16
+ ```
17
+ Folder <FolderName>
18
+
19
+ WHEN <Condition>
20
+ THEN <Actions>
21
+ ```
22
+
23
+ ### Example
24
+
25
+ ```text
26
+ Folder "Important"
27
+
28
+ WHEN sender contains "boss"
29
+ THEN move to "Important"
30
+ AND notify
31
+ ```
32
+
33
+ ---
34
+
35
+ ## 1. Folder Definition
36
+
37
+ Every rule block must start with a `Folder` declaration.
38
+
39
+ - **Syntax**: `Folder <Name>`
40
+ - **Name**: Can be a simple word (`Alerts`) or a quoted string (`"My Alerts"`).
41
+
42
+ ```text
43
+ Folder Personal
44
+ Folder "Work Projects"
45
+ ```
46
+
47
+ ---
48
+
49
+ ## 2. Conditions (`WHEN`)
50
+
51
+ The `WHEN` clause defines the logic for matching an email.
52
+
53
+ ### Fields
54
+ - `sender`: The email address of the sender (mapped to `from`).
55
+ - `subject`: The subject line.
56
+ - `body`: The email body content.
57
+
58
+ ### Operators
59
+ - `contains`: Checks if the field contains a substring (case-insensitive).
60
+ - `IN`: Checks if the field matches any value in a list.
61
+
62
+ ### Examples
63
+
64
+ **Simple Match:**
65
+ ```text
66
+ WHEN subject contains "urgent"
67
+ ```
68
+
69
+ **List Match:**
70
+ ```text
71
+ WHEN sender IN ["mom@family.com", "dad@family.com"]
72
+ ```
73
+
74
+ **Logical Operators:**
75
+ - `AND`: Both conditions must be true.
76
+ - `OR`: At least one condition must be true.
77
+ - `NOT`: The condition must be false.
78
+
79
+ **Complex Logic:**
80
+ ```text
81
+ WHEN (sender contains "github" OR subject contains "login")
82
+ AND NOT sender contains "newsletter"
83
+ ```
84
+
85
+ *Note: You can use parentheses `()` to group logic.*
86
+
87
+ ---
88
+
89
+ ## 3. Actions (`THEN`)
90
+
91
+ The `THEN` clause defines what happens when a rule matches. Multiple actions can be chained with `AND`.
92
+
93
+ ### Supported Actions
94
+
95
+ | Action | Description |
96
+ | :--- | :--- |
97
+ | `move to <Folder>` | Sorts the email into the specified folder. |
98
+ | `remove from <Folder>` | (Metadata) Indicates removal from a source folder. |
99
+ | `notify` | Flags the email for a notification. |
100
+ | `call` | Flags the email for a phone call alert. |
101
+ | `remind` | Sets a reminder flag. |
102
+ | `mark as read` | Marks the email as read. |
103
+ | `mark as unread` | Marks the email as unread. |
104
+ | `auto` | Generic automation flag. |
105
+
106
+ ### Example
107
+
108
+ ```text
109
+ THEN move to "Finance"
110
+ AND notify
111
+ AND mark as read
112
+ ```
113
+
114
+ ---
115
+
116
+ ## Formal EBNF Grammar
117
+
118
+ For parser implementers, here is the Extended Backus-Naur Form (EBNF) grammar:
119
+
120
+ ```ebnf
121
+ program = { folder_block } ;
122
+ folder_block = "Folder" , identifier , { rule } ;
123
+ rule = "WHEN" , condition_expr , "THEN" , action_list ;
124
+
125
+ condition_expr = or_term ;
126
+ or_term = and_term , { "OR" , and_term } ;
127
+ and_term = not_factor , { "AND" , not_factor } ;
128
+ not_factor = [ "NOT" ] , factor ;
129
+ factor = "(" , condition_expr , ")" | predicate ;
130
+
131
+ predicate = field , op_contains , string_literal
132
+ | field , "IN" , ( string_literal | string_list ) ;
133
+
134
+ field = "sender" | "subject" | "body" ;
135
+ op_contains = "contains" ;
136
+
137
+ action_list = action , { "AND" , action } ;
138
+ action = "move to" , identifier
139
+ | "remove from" , identifier
140
+ | "notify"
141
+ | "call"
142
+ | "remind"
143
+ | "mark as read"
144
+ | "mark as unread"
145
+ | "auto" ;
146
+
147
+ identifier = ? alphanumeric string or quoted string ? ;
148
+ string_literal = '"' , { ? characters ? } , '"' ;
149
+ string_list = "[" , string_literal , { "," , string_literal } , "]" ;
150
+ ```
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@ezetgalaxy/cxr",
3
+ "version": "1.1.0",
4
+ "description": "CXR - Email Rule eXecutor. A safe, deterministic, and explainable rule engine for email filtering.",
5
+ "type": "module",
6
+ "publisher": "ezetgalaxy",
7
+ "author": "Ezet Galaxy",
8
+ "main": "src/index.js",
9
+ "exports": "./src/index.js",
10
+ "scripts": {
11
+ "test": "node --test"
12
+ },
13
+ "keywords": [
14
+ "email",
15
+ "rule-engine",
16
+ "compiler",
17
+ "automation",
18
+ "filtering",
19
+ "cxr"
20
+ ],
21
+ "license": "ISC",
22
+ "engines": {
23
+ "node": ">=18.0.0"
24
+ }
25
+ }
@@ -0,0 +1,159 @@
1
+
2
+ import { Parser } from './parser.js';
3
+ import { Lexer } from './lexer.js';
4
+
5
+ export class Executor {
6
+ constructor() {
7
+ }
8
+
9
+
10
+ static execute(rulesContent, emails, schema = {}) {
11
+ // 1. Lex & Parse
12
+ const lexer = new Lexer(rulesContent);
13
+ const tokens = lexer.tokenize();
14
+ const parser = new Parser(tokens);
15
+ const ast = parser.parse();
16
+
17
+ // 2. Prepare Output Structures
18
+ const folders = {
19
+ Inbox: []
20
+ };
21
+ const actionsLog = [];
22
+ let ruleCount = 0;
23
+
24
+ // Initialize folder buckets
25
+ for (const folder of ast.folders) {
26
+ // Ensure folder buckets exist, even if empty
27
+ if (!folders[folder.name]) {
28
+ folders[folder.name] = [];
29
+ }
30
+ ruleCount += folder.rules.length;
31
+ }
32
+
33
+ const customSchema = {
34
+ sender: 'from',
35
+ subject: 'subject',
36
+ body: 'body',
37
+ ...schema
38
+ };
39
+
40
+ // 3. Process Emails
41
+ for (const email of emails) {
42
+ let matched = false;
43
+ let targetFolder = 'Inbox';
44
+ let emailActions = [];
45
+ let matchedFolderName = null;
46
+
47
+ // Iterate Folders
48
+ for (const folder of ast.folders) {
49
+ // Iterate Rules in Folder
50
+ for (const rule of folder.rules) {
51
+ if (Executor.evaluateCondition(rule.condition, email, customSchema)) {
52
+ matched = true;
53
+ matchedFolderName = folder.name;
54
+
55
+ // Execute Actions
56
+ for (const actionNode of rule.actions) {
57
+ if (actionNode.action === 'move') {
58
+ targetFolder = actionNode.target;
59
+ }
60
+ // Add to log
61
+ emailActions.push(Executor.serializeAction(actionNode));
62
+ }
63
+ break; // Stop checking rules in this folder
64
+ }
65
+ }
66
+ if (matched) break; // Stop checking other folders (First matching folder wins)
67
+ }
68
+
69
+ // Assign to bucket
70
+ if (!folders[targetFolder]) {
71
+ folders[targetFolder] = [];
72
+ }
73
+ folders[targetFolder].push(email);
74
+
75
+ // Log actions if matched
76
+ if (matched) {
77
+ actionsLog.push({
78
+ emailId: email.id,
79
+ folder: matchedFolderName,
80
+ actions: emailActions
81
+ });
82
+ }
83
+ }
84
+
85
+ return {
86
+ folders,
87
+ actionsLog,
88
+ meta: {
89
+ engine: "cxr",
90
+ version: "1.0.0",
91
+ processedAt: Date.now(),
92
+ ruleCount,
93
+ emailCount: emails.length
94
+ }
95
+ };
96
+ }
97
+
98
+ static evaluateCondition(node, email, schema) {
99
+ switch (node.type) {
100
+ case 'BinaryExpression':
101
+ if (node.operator === 'AND') {
102
+ return Executor.evaluateCondition(node.left, email, schema) && Executor.evaluateCondition(node.right, email, schema);
103
+ } else if (node.operator === 'OR') {
104
+ return Executor.evaluateCondition(node.left, email, schema) || Executor.evaluateCondition(node.right, email, schema);
105
+ }
106
+ throw new Error(`Unknown binary operator: ${node.operator}`);
107
+
108
+ case 'UnaryExpression':
109
+ if (node.operator === 'NOT') {
110
+ return !Executor.evaluateCondition(node.argument, email, schema);
111
+ }
112
+ throw new Error(`Unknown unary operator: ${node.operator}`);
113
+
114
+ case 'Predicate':
115
+ return Executor.evaluatePredicate(node, email, schema);
116
+
117
+ case 'InPredicate':
118
+ return Executor.evaluateInPredicate(node, email, schema);
119
+
120
+ default:
121
+ throw new Error(`Unknown condition node type: ${node.type}`);
122
+ }
123
+ }
124
+
125
+ static evaluatePredicate(node, email, schema) {
126
+ const fieldName = schema[node.field] || node.field;
127
+ const value = (email[fieldName] || '').toString();
128
+ const target = node.value;
129
+
130
+ if (node.operator === 'contains') {
131
+ return value.toLowerCase().includes(target.toLowerCase());
132
+ }
133
+ return false;
134
+ }
135
+
136
+ static evaluateInPredicate(node, email, schema) {
137
+ const fieldName = schema[node.field] || node.field;
138
+ const value = (email[fieldName] || '').toString();
139
+ const targets = node.value; // Array of strings
140
+
141
+ const lowerValue = value.toLowerCase();
142
+ // Check if value includes any of the target strings (case-insensitive)
143
+ return targets.some(target => lowerValue.includes(target.toLowerCase()));
144
+ }
145
+
146
+ static serializeAction(actionNode) {
147
+ switch (actionNode.action) {
148
+ case 'move': return `move to ${actionNode.target}`;
149
+ case 'remove': return `remove from ${actionNode.target}`;
150
+ case 'notify': return 'notify';
151
+ case 'call': return 'call';
152
+ case 'remind': return 'remind';
153
+ case 'mark_read': return 'mark as read';
154
+ case 'mark_unread': return 'mark as unread';
155
+ case 'auto': return 'auto';
156
+ default: return actionNode.action;
157
+ }
158
+ }
159
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,141 @@
1
+
2
+ export interface Email {
3
+ id: string;
4
+ from: string; // Mapped from 'sender' in rules
5
+ subject: string;
6
+ body: string;
7
+ unread: boolean;
8
+ receivedAt: number;
9
+ [key: string]: any; // Allow other properties
10
+ }
11
+
12
+ export interface ActionLog {
13
+ emailId: string;
14
+ folder: string;
15
+ actions: string[];
16
+ }
17
+
18
+ export interface ResultMeta {
19
+ engine: string;
20
+ version: string;
21
+ processedAt: number;
22
+ ruleCount: number;
23
+ emailCount: number;
24
+ }
25
+
26
+ export interface ExecutionResult {
27
+ folders: Record<string, Email[]>;
28
+ actionsLog: ActionLog[];
29
+ meta: ResultMeta;
30
+ }
31
+
32
+ export interface InitConfig {
33
+ /**
34
+ * Rule content or file paths.
35
+ * Can be a single string (content or .cxr path) or an array of strings.
36
+ * If a string ends with '.cxr', it is treated as a file path and read from disk.
37
+ *
38
+ * @example
39
+ * // Load from file
40
+ * rules: "./rules/alerts.cxr"
41
+ *
42
+ * @example
43
+ * // Inline content
44
+ * rules: `Folder Alerts WHEN sender contains "github" THEN move to Alerts`
45
+ *
46
+ * @example
47
+ * // Combined
48
+ * rules: ["./common.cxr", "./personal.cxr"]
49
+ */
50
+ rules: string | string[];
51
+
52
+ /**
53
+ * List of email objects to process.
54
+ * Each email object must minimally contain: id, from, subject, body, unread, receivedAt.
55
+ */
56
+ emails: Email[];
57
+
58
+ /**
59
+ * Optional custom schema mapping.
60
+ * Maps rule field names to email object property names.
61
+ * Default schema: { sender: 'from', subject: 'subject', body: 'body' }
62
+ *
63
+ * @example
64
+ * // Map 'priority' in rules to 'X-Priority' in email object
65
+ * schema: { priority: 'X-Priority' }
66
+ */
67
+ schema?: Record<string, string>;
68
+ }
69
+
70
+ /**
71
+ * Initialize the CXR engine with rules and emails.
72
+ *
73
+ * This is the main entry point. It parses the provided rules (from strings or files)
74
+ * and executes them against the provided list of emails.
75
+ *
76
+ * @param config Configuration object containing rules and emails.
77
+ * @returns The classification result and action logs.
78
+ *
79
+ * @example
80
+ * const result = cxr.init({
81
+ * rules: "./my-rules.cxr",
82
+ * emails: myEmails
83
+ * });
84
+ * console.log(result.folders);
85
+ */
86
+ export function init(config: InitConfig): ExecutionResult;
87
+
88
+ /**
89
+ * **CXR - Email Rule eXecutor**
90
+ *
91
+ * A rule engine / compiler for email filtering logic.
92
+ *
93
+ * CXR parses a custom `.cxr` domain language and executes deterministically against normalized email objects without mutation.
94
+ *
95
+ * ### Features
96
+ * - **Safe**: No `eval`, no `Function`, purely AST-based execution.
97
+ * - **Deterministic**: Input -> Output is always the same.
98
+ * - **Explainable**: Returns metadata and logs of all actions taken.
99
+ * - **Extensible**: Pure JSON output format.
100
+ * - **Zero Dependencies**: Lightweight and fast.
101
+ *
102
+ * ### Example Usage
103
+ * ```javascript
104
+ * import cxr from "cxr";
105
+ *
106
+ * // 1. Define Rules (.cxr content)
107
+ * const rules = `
108
+ * Folder "Important"
109
+ * WHEN sender contains "boss"
110
+ * THEN move to "Important"
111
+ * AND notify
112
+ * `;
113
+ *
114
+ * // 2. Run Engine
115
+ * const result = cxr.init({
116
+ * rules,
117
+ * emails: [{
118
+ * id: '1',
119
+ * from: 'boss@company.com',
120
+ * subject: 'Urgent',
121
+ * body: 'Read this',
122
+ * unread: true,
123
+ * receivedAt: Date.now()
124
+ * }]
125
+ * });
126
+ *
127
+ * // 3. Use Results
128
+ * console.log(result.folders['Important']); // [ { id: '1', ... } ]
129
+ * ```
130
+ */
131
+ declare const cxr: {
132
+ /**
133
+ * Initialize the CXR engine.
134
+ *
135
+ * @param config {InitConfig} Configuration object containing rules and emails.
136
+ * @returns {ExecutionResult} The classification result and action logs.
137
+ */
138
+ init: typeof init;
139
+ };
140
+
141
+ export default cxr;
package/src/index.js ADDED
@@ -0,0 +1,56 @@
1
+
2
+ import { Executor } from './executor.js';
3
+ import fs from 'node:fs';
4
+
5
+ /**
6
+ * CXR Main Entry Point
7
+ */
8
+ const cxr = {
9
+ /**
10
+ * Initialize result engine with rules and emails.
11
+ * @param {Object} config
12
+ * @param {string|string[]} config.rules - The .cxr rules content or file path(s)
13
+ * @param {Array} config.emails - Array of email objects
14
+ * @returns {Object} Result object containing folders and actionsLog
15
+ */
16
+ init: function (config) {
17
+ if (!config || !config.rules) {
18
+ throw new Error("Invalid configuration: 'rules' is required (string or array).");
19
+ }
20
+ if (!Array.isArray(config.emails)) {
21
+ throw new Error("Invalid configuration: 'emails' array is required.");
22
+ }
23
+
24
+
25
+ // Resolve Rules (Handle Strings and File Paths)
26
+ const inputs = Array.isArray(config.rules) ? config.rules : [config.rules];
27
+ const combinedRules = inputs.map(input => {
28
+ if (typeof input !== 'string') {
29
+ throw new Error("Invalid rule format: expected string content or file path.");
30
+ }
31
+
32
+
33
+ // Heuristic: If string ends with .cxr, treat as file path.
34
+ if (input.trim().endsWith('.cxr')) {
35
+ try {
36
+ if (fs.existsSync(input)) {
37
+ return fs.readFileSync(input, 'utf8');
38
+ }
39
+ throw new Error(`Rule file not found: ${input}`);
40
+ } catch (err) {
41
+ throw err;
42
+ }
43
+ }
44
+
45
+ return input;
46
+ }).join('\n'); // Combine with newline for separation
47
+
48
+ try {
49
+ return Executor.execute(combinedRules, config.emails, config.schema);
50
+ } catch (error) {
51
+ throw error;
52
+ }
53
+ }
54
+ };
55
+
56
+ export default cxr;
package/src/lexer.js ADDED
@@ -0,0 +1,188 @@
1
+
2
+ /**
3
+ * TokenType definitions
4
+ */
5
+ export const TokenType = {
6
+ // Structural
7
+ FOLDER: 'FOLDER',
8
+ WHEN: 'WHEN',
9
+ THEN: 'THEN',
10
+
11
+ // Logic
12
+ AND: 'AND',
13
+ OR: 'OR',
14
+ NOT: 'NOT',
15
+ IN: 'IN',
16
+ CONTAINS: 'CONTAINS', // Although 'contains' acts like an operator
17
+
18
+ // Fields
19
+ FIELD_SENDER: 'FIELD_SENDER',
20
+ FIELD_SUBJECT: 'FIELD_SUBJECT',
21
+ FIELD_BODY: 'FIELD_BODY',
22
+
23
+ // Actions (Start words)
24
+ KW_MOVE: 'KW_MOVE',
25
+ KW_REMOVE: 'KW_REMOVE',
26
+ KW_NOTIFY: 'KW_NOTIFY',
27
+ KW_CALL: 'KW_CALL',
28
+ KW_REMIND: 'KW_REMIND',
29
+ KW_MARK: 'KW_MARK',
30
+ KW_AUTO: 'KW_AUTO',
31
+
32
+ // Prepositions / Helpers
33
+ KW_TO: 'KW_TO',
34
+ KW_FROM: 'KW_FROM',
35
+ KW_AS: 'KW_AS',
36
+ KW_READ: 'KW_READ',
37
+ KW_UNREAD: 'KW_UNREAD',
38
+
39
+ // Literals
40
+ STRING: 'STRING',
41
+ IDENTIFIER: 'IDENTIFIER',
42
+
43
+ // Symbols
44
+ LPAREN: 'LPAREN',
45
+ RPAREN: 'RPAREN',
46
+ LBRACKET: 'LBRACKET',
47
+ RBRACKET: 'RBRACKET',
48
+ COMMA: 'COMMA',
49
+
50
+ EOF: 'EOF'
51
+ };
52
+
53
+ class Token {
54
+ constructor(type, value, line, column) {
55
+ this.type = type;
56
+ this.value = value;
57
+ this.line = line;
58
+ this.column = column;
59
+ }
60
+ }
61
+
62
+ export class Lexer {
63
+ constructor(input) {
64
+ this.input = input;
65
+ this.pos = 0;
66
+ this.line = 1;
67
+ this.column = 1;
68
+ this.tokens = [];
69
+ }
70
+
71
+ tokenize() {
72
+ while (this.pos < this.input.length) {
73
+ const char = this.input[this.pos];
74
+
75
+ // Handle whitespace
76
+ if (/\s/.test(char)) {
77
+ if (char === '\n') {
78
+ this.line++;
79
+ this.column = 1;
80
+ } else {
81
+ this.column++;
82
+ }
83
+ this.pos++;
84
+ continue;
85
+ }
86
+
87
+ // Handle strings
88
+ if (char === '"') {
89
+ this.readString();
90
+ continue;
91
+ }
92
+
93
+ // Handle symbols
94
+ if (char === '(') { this.addToken(TokenType.LPAREN, '('); this.pos++; this.column++; continue; }
95
+ if (char === ')') { this.addToken(TokenType.RPAREN, ')'); this.pos++; this.column++; continue; }
96
+ if (char === '[') { this.addToken(TokenType.LBRACKET, '['); this.pos++; this.column++; continue; }
97
+ if (char === ']') { this.addToken(TokenType.RBRACKET, ']'); this.pos++; this.column++; continue; }
98
+ if (char === ',') { this.addToken(TokenType.COMMA, ','); this.pos++; this.column++; continue; }
99
+
100
+ // Handle keywords and identifiers
101
+ if (/[a-zA-Z0-9_]/.test(char)) {
102
+ this.readIdentifierOrKeyword();
103
+ continue;
104
+ }
105
+
106
+ // If we reach here, it's an unexpected character
107
+ throw new Error(`Unexpected character '${char}' at line ${this.line}, column ${this.column}`);
108
+ }
109
+
110
+ this.addToken(TokenType.EOF, null);
111
+ return this.tokens;
112
+ }
113
+
114
+ addToken(type, value) {
115
+ this.tokens.push(new Token(type, value, this.line, this.column));
116
+ }
117
+
118
+ readString() {
119
+ let value = '';
120
+ const startColumn = this.column;
121
+ this.pos++; // Skip opening quote
122
+ this.column++;
123
+
124
+ while (this.pos < this.input.length && this.input[this.pos] !== '"') {
125
+ value += this.input[this.pos];
126
+ this.pos++;
127
+ this.column++;
128
+ }
129
+
130
+ if (this.pos >= this.input.length) {
131
+ throw new Error(`Unterminated string starting at line ${this.line}, column ${startColumn}`);
132
+ }
133
+
134
+ this.pos++; // Skip closing quote
135
+ this.column++;
136
+ this.tokens.push(new Token(TokenType.STRING, value, this.line, startColumn));
137
+ }
138
+
139
+ readIdentifierOrKeyword() {
140
+ let value = '';
141
+ const startColumn = this.column;
142
+
143
+ // Read word
144
+ while (this.pos < this.input.length && /[a-zA-Z0-9_]/.test(this.input[this.pos])) {
145
+ value += this.input[this.pos];
146
+ this.pos++;
147
+ this.column++;
148
+ }
149
+
150
+ const key = value.toLowerCase();
151
+
152
+ // Mapping of keywords to TokenTypes
153
+ const KEYWORDS = {
154
+ 'folder': TokenType.FOLDER,
155
+ 'when': TokenType.WHEN,
156
+ 'then': TokenType.THEN,
157
+ 'and': TokenType.AND,
158
+ 'or': TokenType.OR,
159
+ 'not': TokenType.NOT,
160
+ 'in': TokenType.IN,
161
+ 'contains': TokenType.CONTAINS,
162
+
163
+ 'sender': TokenType.FIELD_SENDER,
164
+ 'subject': TokenType.FIELD_SUBJECT,
165
+ 'body': TokenType.FIELD_BODY,
166
+
167
+ 'move': TokenType.KW_MOVE,
168
+ 'remove': TokenType.KW_REMOVE,
169
+ 'notify': TokenType.KW_NOTIFY,
170
+ 'call': TokenType.KW_CALL,
171
+ 'remind': TokenType.KW_REMIND,
172
+ 'mark': TokenType.KW_MARK,
173
+ 'auto': TokenType.KW_AUTO,
174
+
175
+ 'to': TokenType.KW_TO,
176
+ 'from': TokenType.KW_FROM,
177
+ 'as': TokenType.KW_AS,
178
+ 'read': TokenType.KW_READ,
179
+ 'unread': TokenType.KW_UNREAD
180
+ };
181
+
182
+ if (KEYWORDS.hasOwnProperty(key)) {
183
+ this.addToken(KEYWORDS[key], value);
184
+ } else {
185
+ this.addToken(TokenType.IDENTIFIER, value);
186
+ }
187
+ }
188
+ }
package/src/parser.js ADDED
@@ -0,0 +1,246 @@
1
+
2
+ import { TokenType } from './lexer.js';
3
+
4
+ export class Parser {
5
+ constructor(tokens) {
6
+ this.tokens = tokens;
7
+ this.pos = 0;
8
+ }
9
+
10
+ parse() {
11
+ const folders = [];
12
+ while (this.peek().type !== TokenType.EOF) {
13
+ if (this.peek().type === TokenType.FOLDER) {
14
+ folders.push(this.parseFolder());
15
+ } else {
16
+ throw this.error(`Expected 'Folder' declaration, found ${this.peek().value}`);
17
+ }
18
+ }
19
+ return { type: 'Program', folders };
20
+ }
21
+
22
+ parseFolder() {
23
+ this.consume(TokenType.FOLDER);
24
+ const name = this.parseTarget();
25
+
26
+ const rules = [];
27
+ while (this.peek().type === TokenType.WHEN) {
28
+ rules.push(this.parseRule());
29
+ }
30
+
31
+ if (rules.length === 0) {
32
+ throw this.error(`Folder '${name}' must have at least one rule.`);
33
+ }
34
+
35
+ return { type: 'Folder', name, rules };
36
+ }
37
+
38
+ parseRule() {
39
+ this.consume(TokenType.WHEN);
40
+ const condition = this.parseCondition();
41
+
42
+ this.consume(TokenType.THEN);
43
+ const actions = this.parseActions();
44
+
45
+ return { type: 'Rule', condition, actions };
46
+ }
47
+
48
+ parseCondition() {
49
+ return this.parseOr();
50
+ }
51
+
52
+ parseOr() {
53
+ let left = this.parseAnd();
54
+
55
+ while (this.match(TokenType.OR)) {
56
+ const right = this.parseAnd();
57
+ left = {
58
+ type: 'BinaryExpression',
59
+ operator: 'OR',
60
+ left,
61
+ right
62
+ };
63
+ }
64
+ return left;
65
+ }
66
+
67
+ parseAnd() {
68
+ let left = this.parseNot();
69
+
70
+ while (this.match(TokenType.AND)) {
71
+ const right = this.parseNot();
72
+ left = {
73
+ type: 'BinaryExpression',
74
+ operator: 'AND',
75
+ left,
76
+ right
77
+ };
78
+ }
79
+ return left;
80
+ }
81
+
82
+ parseNot() {
83
+ if (this.match(TokenType.NOT)) {
84
+ return {
85
+ type: 'UnaryExpression',
86
+ operator: 'NOT',
87
+ argument: this.parseNot()
88
+ };
89
+ }
90
+ return this.parseFactor();
91
+ }
92
+
93
+ parseFactor() {
94
+ if (this.match(TokenType.LPAREN)) {
95
+ const expr = this.parseCondition();
96
+ this.consume(TokenType.RPAREN, "Expected ')'");
97
+ return expr;
98
+ }
99
+
100
+ const field = this.parseField();
101
+
102
+ if (this.match(TokenType.CONTAINS)) {
103
+ const valueToken = this.consume(TokenType.STRING, "Expected string literal after 'contains'");
104
+ return {
105
+ type: 'Predicate',
106
+ field: field.value,
107
+ operator: 'contains',
108
+ value: valueToken.value
109
+ };
110
+ }
111
+
112
+ if (this.match(TokenType.IN)) {
113
+ if (this.peek().type === TokenType.STRING) {
114
+ const val = this.consume(TokenType.STRING).value;
115
+ return {
116
+ type: 'InPredicate',
117
+ field: field.value,
118
+ value: [val]
119
+ };
120
+ } else if (this.match(TokenType.LBRACKET)) {
121
+ const values = [];
122
+ do {
123
+ const val = this.consume(TokenType.STRING, "Expected string in list").value;
124
+ values.push(val);
125
+ } while (this.match(TokenType.COMMA));
126
+ this.consume(TokenType.RBRACKET, "Expected ']'");
127
+ return {
128
+ type: 'InPredicate',
129
+ field: field.value,
130
+ value: values
131
+ };
132
+ } else {
133
+ throw this.error("Expected string or list after 'IN'");
134
+ }
135
+ }
136
+
137
+ throw this.error(`Unexpected token in condition: ${this.peek().value}`);
138
+ }
139
+
140
+ parseField() {
141
+ if (this.check(TokenType.FIELD_SENDER) ||
142
+ this.check(TokenType.FIELD_SUBJECT) ||
143
+ this.check(TokenType.FIELD_BODY) ||
144
+ this.check(TokenType.IDENTIFIER)) {
145
+ return this.advance();
146
+ }
147
+ throw this.error("Expected field (sender, subject, body, or custom field)");
148
+ }
149
+
150
+ parseActions() {
151
+ const actions = [];
152
+ actions.push(this.parseAction());
153
+
154
+ while (this.match(TokenType.AND)) {
155
+ actions.push(this.parseAction());
156
+ }
157
+ return actions;
158
+ }
159
+
160
+ parseTarget() {
161
+ if (this.check(TokenType.IDENTIFIER) || this.check(TokenType.STRING)) {
162
+ return this.advance().value;
163
+ }
164
+ throw this.error("Expected folder name (identifier or string)");
165
+ }
166
+
167
+ parseAction() {
168
+ const token = this.peek();
169
+
170
+ if (this.match(TokenType.KW_MOVE)) {
171
+ this.consume(TokenType.KW_TO, "Expected 'to' after 'move'");
172
+ const folder = this.parseTarget();
173
+ return { type: 'Action', action: 'move', target: folder };
174
+ }
175
+
176
+ if (this.match(TokenType.KW_REMOVE)) {
177
+ this.consume(TokenType.KW_FROM, "Expected 'from' after 'remove'");
178
+ const folder = this.parseTarget();
179
+ return { type: 'Action', action: 'remove', target: folder };
180
+ }
181
+
182
+ if (this.match(TokenType.KW_NOTIFY)) {
183
+ return { type: 'Action', action: 'notify' };
184
+ }
185
+
186
+ if (this.match(TokenType.KW_CALL)) {
187
+ return { type: 'Action', action: 'call' };
188
+ }
189
+
190
+ if (this.match(TokenType.KW_REMIND)) {
191
+ return { type: 'Action', action: 'remind' };
192
+ }
193
+
194
+ if (this.match(TokenType.KW_AUTO)) {
195
+ return { type: 'Action', action: 'auto' };
196
+ }
197
+
198
+ if (this.match(TokenType.KW_MARK)) {
199
+ if (this.match(TokenType.KW_AS)) {
200
+ if (this.match(TokenType.KW_READ)) {
201
+ return { type: 'Action', action: 'mark_read' };
202
+ } else if (this.match(TokenType.KW_UNREAD)) {
203
+ return { type: 'Action', action: 'mark_unread' };
204
+ }
205
+ }
206
+ throw this.error("Expected 'as read' or 'as unread' after 'mark'");
207
+ }
208
+
209
+ throw this.error(`Unknown or invalid action start: ${token.value}`);
210
+ }
211
+
212
+ peek() {
213
+ return this.tokens[this.pos];
214
+ }
215
+
216
+ advance() {
217
+ if (this.pos < this.tokens.length - 1) {
218
+ this.pos++;
219
+ }
220
+ return this.tokens[this.pos - 1];
221
+ }
222
+
223
+ check(type) {
224
+ return this.peek().type === type;
225
+ }
226
+
227
+ match(type) {
228
+ if (this.check(type)) {
229
+ this.advance();
230
+ return true;
231
+ }
232
+ return false;
233
+ }
234
+
235
+ consume(type, errorMessage) {
236
+ if (this.check(type)) {
237
+ return this.advance();
238
+ }
239
+ throw this.error(errorMessage || `Expected ${type}, found ${this.peek().type}`);
240
+ }
241
+
242
+ error(message) {
243
+ const token = this.peek();
244
+ return new Error(`Parser Error at line ${token.line}, column ${token.column}: ${message}`);
245
+ }
246
+ }