Przeglądaj źródła

feat: add multi-queue system with per-repository parallel processing #30

- Add QUEUE_PARALLEL_PER_REPOSITORY configuration option (default: false)
- Implement separate FIFO queue for each Gogs repository in multi-queue mode
- Allow jobs from different repositories to execute in parallel
- Maintain FIFO order within each repository's queue
- Support both single-queue (default) and multi-queue operating modes
- Update queue persistence to handle both modes in queue.json
- Add automatic cleanup of empty repository queues
- Update documentation (README.md and CLAUDE.md) with new feature
- Backward compatible: defaults to single-queue mode
Claude 9 miesięcy temu
rodzic
commit
9c5fbf18ba
4 zmienionych plików z 895 dodań i 141 usunięć
  1. 7 0
      .env.example
  2. 218 16
      CLAUDE.md
  3. 256 12
      README.md
  4. 414 113
      src/jobQueue.js

+ 7 - 0
.env.example

@@ -8,6 +8,13 @@ WEBHOOK_SECRET=
 # Log level: DEBUG, INFO, WARN, ERROR (default: INFO)
 LOG_LEVEL=INFO
 
+# Queue Configuration
+# Enable parallel job processing per repository (default: false)
+# When enabled, each Gogs repository gets its own FIFO queue
+# This allows jobs from different repositories to run independently
+# Recommended for multi-repository setups where jobs can run in parallel
+QUEUE_PARALLEL_PER_REPOSITORY=false
+
 # Webhook Event Configuration
 # Enable/disable webhook events (commands are configured in commands.json)
 # Set to 'true' to enable event handling, 'false' to disable

+ 218 - 16
CLAUDE.md

@@ -12,7 +12,7 @@ This is a modular Node.js webhook server for Gogs (Git service) that receives we
 
 ### Module Structure
 
-The codebase follows a clean separation of concerns with five main modules:
+The codebase follows a clean separation of concerns with seven main modules:
 
 ```
 index.js (Entry Point)
@@ -20,6 +20,8 @@ index.js (Entry Point)
     │   └── webhookHandler.js (Event Management)
     ├── jobQueue.js (Job Queue & Persistence)
     │   └── commandExecutor.js (Command Execution)
+    ├── emailNotifier.js (Email Notifications)
+    │   └── emailSender.js (SMTP Email Sender)
     ├── config.js (Configuration)
     └── logger.js (Logging)
 ```
@@ -43,11 +45,14 @@ index.js (Entry Point)
 
 **JobQueue (src/jobQueue.js)**
 - Persistent job queue with FIFO (first-in-first-out) processing
-- Ensures only one job executes at a time to prevent conflicts
+- **Two operating modes:**
+  - **Single-queue mode (default)**: One global queue, jobs execute sequentially
+  - **Multi-queue mode**: Separate FIFO queue per repository, enables parallel execution across repositories
 - Webhooks are accepted immediately and added to queue
 - Queue state persisted to `queue.json` for crash recovery
 - Automatically resumes processing pending jobs after restart
 - Jobs that were running during shutdown are reset to pending state
+- Mode is configurable via `QUEUE_PARALLEL_PER_REPOSITORY` environment variable
 
 **CommandExecutor (src/commandExecutor.js)**
 - Variable substitution engine using `{{variable}}` syntax
@@ -66,17 +71,41 @@ index.js (Entry Point)
 - Methods: `info()`, `error()`, `webhook()`
 - Exported as singleton
 
+**EmailNotifier (src/emailNotifier.js)**
+- Email notification callback system
+- Loads `.smtp-config.json` for SMTP server configuration
+- Loads `email-rules.json` for notification rules
+- Registers callbacks for events defined in rules
+- Evaluates conditions (mention, branch, action, user, repository)
+- Substitutes variables in email subjects and bodies
+- Resolves recipient variables ({{mentioned_user}}, {{sender}}, {{assignee}})
+- Exported as singleton
+
+**EmailSender (src/emailSender.js)**
+- SMTP email sender using Node.js built-in `net` and `tls` modules
+- Zero external dependencies
+- Supports STARTTLS and direct TLS connections
+- Implements SMTP authentication (AUTH LOGIN)
+- Builds RFC 5322 compliant email messages
+- Supports both plain text and HTML emails
+- Exported as class (instantiated by EmailNotifier)
+
 ### Application Flow
 
 1. `index.js` loads config and creates WebhookServer
 2. JobQueue loads persisted queue state from `queue.json` (if exists)
-3. Gets enabled events from config (`WEBHOOK_PUSH_ENABLED=true`, etc.)
-4. Registers callbacks for enabled events (webhooks → enqueue jobs)
-5. Starts server on configured host:port
-6. On webhook: parses body → triggers callbacks → enqueues job → responds immediately
-7. JobQueue processes jobs sequentially (one at a time, FIFO)
-8. Each job: extracts variables → substitutes in command → executes → logs results
-9. Graceful shutdown on SIGTERM/SIGINT: saves queue state and stops server
+3. EmailNotifier loads SMTP config and email rules
+4. Gets enabled events from config (`WEBHOOK_PUSH_ENABLED=true`, etc.)
+5. Registers callbacks for enabled events (webhooks → enqueue jobs)
+6. Registers email notifier callbacks for events in rules
+7. Starts server on configured host:port
+8. On webhook: parses body → triggers callbacks → enqueues job + sends emails → responds immediately
+9. JobQueue processes jobs:
+   - **Single-queue mode**: All jobs execute sequentially (one at a time, FIFO)
+   - **Multi-queue mode**: Jobs from different repositories execute in parallel, each repository maintains FIFO order
+10. Each job: extracts variables → substitutes in command → executes → logs results
+11. Email notifier evaluates conditions and sends emails for matching rules
+12. Graceful shutdown on SIGTERM/SIGINT: saves queue state and stops server
 
 ## Development Commands
 
@@ -193,10 +222,14 @@ Copy from `commands.json.example` and configure commands for each event type.
 ## Important Patterns
 
 1. **ES6 Modules**: All files use `import/export` syntax (`"type": "module"` in package.json)
-2. **Singleton Exports**: Config, Logger, CommandExecutor are singletons (created on import)
-3. **Zero Dependencies**: Pure Node.js - no npm packages required beyond Node.js built-ins
+2. **Singleton Exports**: Config, Logger, CommandExecutor, EmailNotifier are singletons (created on import)
+3. **Zero Dependencies**: Pure Node.js - no npm packages required beyond Node.js built-ins (including SMTP implementation)
 4. **Graceful Shutdown**: Both SIGTERM and SIGINT trigger `server.stop()` before exit
-5. **Configuration Files**: `.env` for enable/disable flags, `commands.json` for command definitions
+5. **Configuration Files**:
+   - `.env` for enable/disable flags
+   - `commands.json` for command definitions
+   - `.smtp-config.json` for SMTP server configuration
+   - `email-rules.json` for email notification rules
 6. **Backward Compatibility**: Legacy .env command format still supported (deprecated)
 7. **Error Handling**: Try-catch blocks with logging; errors don't stop server
 8. **Command Filtering**: Optional per-command filtering via:
@@ -205,6 +238,7 @@ Copy from `commands.json.example` and configure commands for each event type.
    - `filterAssignee` - Execute only when assigned to specific user (e.g., `"claude"`)
 9. **Multiple Commands**: Each event type can have multiple commands that execute sequentially
 10. **Shell & Node Execution**: Commands can be shell scripts or Node.js scripts
+11. **Email Notifications**: Optional callback-based email notifications with condition matching
 
 ## Extending with Callbacks
 
@@ -262,17 +296,34 @@ Variables are extracted in `commandExecutor.js` via `extractVariables()` and sub
 
 ## Job Queue System
 
-The webhook server uses a persistent job queue to ensure reliable command execution:
+The webhook server uses a persistent job queue to ensure reliable command execution. The queue system supports two operating modes:
+
+### Operating Modes
+
+**Single-Queue Mode (Default)**
+- One global FIFO queue for all repositories
+- Jobs execute sequentially, one at a time
+- Prevents conflicts and resource contention
+- Recommended for single-repository setups or when strict sequential execution is required
+
+**Multi-Queue Mode (Parallel Per Repository)**
+- Separate FIFO queue for each Gogs repository
+- Jobs from different repositories execute in parallel
+- Each repository maintains FIFO order internally
+- Recommended for multi-repository setups where independent execution is desired
+- Enable with: `QUEUE_PARALLEL_PER_REPOSITORY=true`
 
 ### Features
-- **Sequential Execution**: Only one job runs at a time, preventing conflicts and resource contention
-- **FIFO Processing**: Jobs are executed in the order they are received
+- **FIFO Processing**: Jobs are executed in the order they are received (per repository in multi-queue mode)
 - **Immediate Response**: Webhooks are accepted and queued instantly, ensuring no webhook delivery timeouts
 - **Persistence**: Queue state is saved to `queue.json` after every change
 - **Crash Recovery**: Pending jobs are automatically restored and processed after server restart
 - **Safe Restart**: Jobs marked as "running" during shutdown are reset to "pending" on restart
+- **Automatic Cleanup**: Empty repository queues are automatically removed (multi-queue mode)
 
 ### Queue File Structure
+
+**Single-Queue Mode:**
 ```json
 {
   "queue": [
@@ -289,6 +340,38 @@ The webhook server uses a persistent job queue to ensure reliable command execut
 }
 ```
 
+**Multi-Queue Mode:**
+```json
+{
+  "queues": {
+    "owner/repo1": {
+      "queue": [
+        {
+          "id": 1,
+          "eventType": "push",
+          "status": "pending",
+          "createdAt": "2025-10-28T12:00:00.000Z",
+          "config": { "command": "...", "enabled": true }
+        }
+      ]
+    },
+    "owner/repo2": {
+      "queue": [
+        {
+          "id": 2,
+          "eventType": "issues",
+          "status": "running",
+          "createdAt": "2025-10-28T12:01:00.000Z",
+          "config": { "command": "...", "enabled": true }
+        }
+      ]
+    }
+  },
+  "jobIdCounter": 5,
+  "lastSaved": "2025-10-28T12:05:00.000Z"
+}
+```
+
 ### Job Lifecycle
 1. **Created**: Webhook received → Job created with status "pending"
 2. **Queued**: Job added to queue and persisted to disk
@@ -297,10 +380,129 @@ The webhook server uses a persistent job queue to ensure reliable command execut
 5. **Next**: Queue processes next pending job (if any)
 
 ### Benefits
+
+**Single-Queue Mode:**
 - **No Lost Jobs**: Server crashes won't lose pending webhook events
 - **No Concurrent Execution**: Prevents race conditions in deployment scripts
 - **High Availability**: Can accept webhooks even while executing long-running commands
-- **Scalable**: Queue can grow to handle bursts of webhook activity
+- **Predictable Order**: Strict sequential execution across all repositories
+
+**Multi-Queue Mode:**
+- **All benefits of single-queue mode, plus:**
+- **Parallel Execution**: Jobs from different repositories run independently
+- **Better Resource Usage**: Multiple repositories can be processed simultaneously
+- **Reduced Wait Time**: One slow repository won't block others
+- **Repository Isolation**: Issues in one repository don't affect others
+
+## Email Notification System
+
+The webhook server includes an email notification system that sends emails based on webhook events and configurable conditions. The system follows the same callback pattern as the Claude agent client.
+
+### Architecture
+
+**EmailNotifier (src/emailNotifier.js)**
+- Callback-based notification system (similar to Claude agent callback)
+- Loads configuration from `.smtp-config.json` and `email-rules.json`
+- Registers callbacks with WebhookHandler for events defined in rules
+- Evaluates conditions and sends emails for matching events
+- Supports variable substitution in email subjects and bodies
+- Resolves recipient variables to email addresses
+
+**EmailSender (src/emailSender.js)**
+- Pure Node.js SMTP implementation using `net` and `tls` modules
+- Zero external dependencies (maintains project philosophy)
+- Supports STARTTLS (port 587) and direct TLS (port 465)
+- Implements SMTP AUTH LOGIN authentication
+- Builds RFC 5322 compliant email messages
+- Supports both plain text and HTML emails
+
+### Configuration Files
+
+**`.smtp-config.json`** - SMTP server configuration (excluded from git):
+```json
+{
+  "host": "mail.example.com",
+  "port": 1025,
+  "secure": false,
+  "user": "your-email@example.com",
+  "pass": "your-password",
+  "from": "noreply@example.com"
+}
+```
+
+**`email-rules.json`** - Email notification rules (excluded from git):
+```json
+{
+  "rules": [
+    {
+      "name": "mention-notification",
+      "event": "issue_comment",
+      "condition": {
+        "type": "mention",
+        "value": "username"
+      },
+      "recipients": ["{{mentioned_user}}"],
+      "subject": "You were mentioned in {{full_repo}}#{{issue_number}}",
+      "body": "Comment by {{sender}}:\n{{comment_body}}"
+    }
+  ]
+}
+```
+
+### Condition Types
+
+- **mention** - Check if a user is mentioned in the payload (issue, PR, or comment body)
+- **branch** - Check if event is for a specific branch
+- **action** - Check if webhook action matches (e.g., "opened", "assigned")
+- **user** - Check if sender/pusher matches
+- **repository** - Check if repository full name matches
+
+### Recipient Variables
+
+- `{{mentioned_user}}` - User mentioned in comment/issue (extracts email from payload)
+- `{{sender}}` - Event sender email
+- `{{assignee}}` - Issue/PR assignee email
+- Direct email addresses (e.g., `"admin@example.com"`)
+
+### Email Variable Substitution
+
+All standard webhook variables are available in email subjects and bodies:
+- Repository: `{{repo}}`, `{{full_repo}}`, `{{repo_owner}}`
+- Git: `{{branch}}`, `{{commit}}`, `{{commit_msg}}`
+- User: `{{sender}}`, `{{mentioned_user}}`
+- Issue/PR: `{{issue_number}}`, `{{issue_title}}`, `{{issue_action}}`, `{{pr_number}}`, `{{pr_action}}`
+- Content: `{{comment_body}}`, `{{issue_body}}`
+
+### Integration
+
+The email notifier is automatically integrated in `src/index.js`:
+
+```javascript
+import emailNotifier from './emailNotifier.js';
+
+// ... after handler setup ...
+emailNotifier.register(handler);
+```
+
+If `.smtp-config.json` is missing, email notifications are disabled automatically.
+
+### How It Works
+
+1. **Startup**: EmailNotifier loads SMTP config and email rules
+2. **Registration**: Registers callbacks for all events defined in rules
+3. **Event Trigger**: When a matching webhook arrives, callback is executed
+4. **Condition Check**: Rule condition is evaluated against payload
+5. **Variable Substitution**: Variables in subject/body are replaced with actual values
+6. **Recipient Resolution**: Recipient variables are converted to email addresses
+7. **Email Send**: Email is sent via SMTP using EmailSender
+8. **Error Handling**: Errors are logged but don't stop webhook processing
+
+### Security
+
+- Configuration files are excluded from git (`.gitignore`)
+- Credentials stored in local files only
+- File permissions should be restricted (600)
+- No external dependencies (no npm package vulnerabilities)
 
 ## TypeScript Client for Issue Automation
 

+ 256 - 12
README.md

@@ -7,6 +7,7 @@ A modular Node.js server for receiving and processing Gogs webhooks. The server
 - **Persistent Job Queue**: FIFO queue ensures sequential execution and crash recovery
 - **Modular Architecture**: Clean separation of concerns (server, handler, queue, logger)
 - **Command Execution**: Execute shell or Node.js commands on webhook events
+- **Email Notifications**: Event-based email notifications with condition matching
 - **JSON Configuration**: Flexible command configuration via `commands.json`
 - **Multiple Commands per Event**: Run multiple commands for each webhook event type
 - **Variable Substitution**: Access repo name, branch, pusher, issue details, and more in commands
@@ -37,13 +38,19 @@ npm install
 ```bash
 cp .env.example .env
 cp commands.json.example commands.json
+cp .smtp-config.json.example .smtp-config.json  # Optional, for email notifications
+cp email-rules.json.example email-rules.json    # Optional, for email notifications
 ```
 
 2. Edit `.env` to enable/disable webhook events
 
 3. Edit `commands.json` to configure commands for webhook events (see Configuration section below)
 
-4. Start the server:
+4. (Optional) Configure email notifications:
+   - Edit `.smtp-config.json` with your SMTP server settings
+   - Edit `email-rules.json` to define email notification rules
+
+5. Start the server:
 ```bash
 npm start
 ```
@@ -240,27 +247,53 @@ This ensures:
 
 ## Job Queue System
 
-The webhook server uses a persistent job queue to ensure reliable and sequential command execution:
+The webhook server uses a persistent job queue to ensure reliable command execution. The queue supports two operating modes:
+
+### Operating Modes
+
+**Single-Queue Mode (Default)**
+- One global FIFO queue for all repositories
+- Jobs execute sequentially, one at a time
+- Recommended for single-repository setups or when strict sequential execution is required
+
+**Multi-Queue Mode (Parallel Per Repository)**
+- Separate FIFO queue for each Gogs repository
+- Jobs from different repositories execute in parallel
+- Each repository maintains FIFO order internally
+- Recommended for multi-repository setups where independent execution is desired
+- Enable with: `QUEUE_PARALLEL_PER_REPOSITORY=true` in `.env`
 
 ### How It Works
 
 1. **Webhook Received**: When a webhook arrives, it's immediately accepted and added to the queue
 2. **Quick Response**: The server responds to Gogs instantly (no timeout issues)
-3. **Sequential Processing**: Jobs are executed one at a time in FIFO (first-in-first-out) order
-4. **Persistence**: Queue state is saved to `queue.json` after every change
-5. **Crash Recovery**: If the server crashes or restarts, pending jobs are automatically resumed
+3. **Queue Selection**:
+   - Single-queue mode: Job added to global queue
+   - Multi-queue mode: Job added to repository-specific queue
+4. **Processing**: Jobs execute in FIFO order (per repository in multi-queue mode)
+5. **Persistence**: Queue state is saved to `queue.json` after every change
+6. **Crash Recovery**: If the server crashes or restarts, pending jobs are automatically resumed
 
 ### Benefits
 
+**Single-Queue Mode:**
 - **No Lost Webhooks**: Even if the server crashes, queued jobs are preserved and executed after restart
 - **No Concurrent Execution**: Prevents conflicts when multiple webhooks trigger deployment scripts
 - **Handle Bursts**: Can accept many webhooks quickly while processing them sequentially
-- **Reliable**: Long-running commands won't block new webhook deliveries
+- **Predictable Order**: Strict sequential execution across all repositories
+
+**Multi-Queue Mode:**
+- **All benefits of single-queue mode, plus:**
+- **Parallel Execution**: Jobs from different repositories run independently
+- **Better Resource Usage**: Multiple repositories can be processed simultaneously
+- **Reduced Wait Time**: One slow repository won't block others
+- **Repository Isolation**: Issues in one repository don't affect others
 
 ### Queue File
 
-The queue state is stored in `queue.json` in the project root:
+The queue state is stored in `queue.json` in the project root.
 
+**Single-Queue Mode:**
 ```json
 {
   "queue": [
@@ -276,8 +309,213 @@ The queue state is stored in `queue.json` in the project root:
 }
 ```
 
+**Multi-Queue Mode:**
+```json
+{
+  "queues": {
+    "owner/repo1": {
+      "queue": [{ "id": 1, "eventType": "push", "status": "pending" }]
+    },
+    "owner/repo2": {
+      "queue": [{ "id": 2, "eventType": "issues", "status": "running" }]
+    }
+  },
+  "jobIdCounter": 5,
+  "lastSaved": "2025-10-28T12:05:00.000Z"
+}
+```
+
 This file is automatically managed and should not be manually edited. It's excluded from git via `.gitignore`.
 
+## Email Notifications
+
+The server includes an email notification system that can send emails based on webhook events and configurable conditions.
+
+### Configuration
+
+Email notifications require two configuration files:
+
+1. **`.smtp-config.json`** - SMTP server configuration (excluded from git)
+2. **`email-rules.json`** - Email notification rules (excluded from git)
+
+Copy the example files to get started:
+
+```bash
+cp .smtp-config.json.example .smtp-config.json
+cp email-rules.json.example email-rules.json
+```
+
+### SMTP Configuration (`.smtp-config.json`)
+
+```json
+{
+  "host": "mail.example.com",
+  "port": 1025,
+  "secure": false,
+  "user": "your-email@example.com",
+  "pass": "your-password",
+  "from": "noreply@example.com"
+}
+```
+
+**Fields:**
+- `host` - SMTP server hostname
+- `port` - SMTP server port (25, 587, 465, etc.)
+- `secure` - Use TLS from start (true for port 465, false for STARTTLS)
+- `user` - SMTP username
+- `pass` - SMTP password
+- `from` - Email address to send from
+
+### Email Rules Configuration (`email-rules.json`)
+
+```json
+{
+  "rules": [
+    {
+      "name": "mention-notification",
+      "event": "issue_comment",
+      "condition": {
+        "type": "mention",
+        "value": "username"
+      },
+      "recipients": ["{{mentioned_user}}"],
+      "subject": "You were mentioned in {{full_repo}}#{{issue_number}}",
+      "body": "Comment by {{sender}}:\n{{comment_body}}"
+    }
+  ]
+}
+```
+
+**Rule Fields:**
+- `name` - Unique identifier for the rule
+- `event` - Webhook event type to trigger on
+- `condition` - Condition that must be met (optional)
+- `recipients` - Array of email addresses or variables
+- `subject` - Email subject (supports variables)
+- `body` - Email body (supports variables)
+
+### Condition Types
+
+**`mention`** - Check if a user is mentioned in the payload:
+```json
+{
+  "type": "mention",
+  "value": "username"
+}
+```
+
+**`branch`** - Check if event is for a specific branch:
+```json
+{
+  "type": "branch",
+  "value": "main"
+}
+```
+
+**`action`** - Check if webhook action matches:
+```json
+{
+  "type": "action",
+  "value": "opened"
+}
+```
+
+**`user`** - Check if sender/pusher matches:
+```json
+{
+  "type": "user",
+  "value": "username"
+}
+```
+
+**`repository`** - Check if repository matches:
+```json
+{
+  "type": "repository",
+  "value": "owner/repo"
+}
+```
+
+### Recipient Variables
+
+- `{{mentioned_user}}` - User mentioned in comment/issue
+- `{{sender}}` - Event sender email
+- `{{assignee}}` - Issue/PR assignee email
+- Direct email addresses: `"admin@example.com"`
+
+### Email Body Variables
+
+All the standard webhook variables are available in email subjects and bodies:
+
+- `{{repo}}`, `{{full_repo}}`, `{{repo_owner}}`
+- `{{branch}}`, `{{sender}}`, `{{commit}}`, `{{commit_msg}}`
+- `{{issue_number}}`, `{{issue_title}}`, `{{issue_action}}`
+- `{{comment_body}}`, `{{mentioned_user}}`
+- `{{pr_number}}`, `{{pr_action}}`
+
+### Example Rules
+
+**Notify mentioned users in issue comments:**
+```json
+{
+  "name": "mention-notification",
+  "event": "issue_comment",
+  "condition": {
+    "type": "mention",
+    "value": ".*"
+  },
+  "recipients": ["{{mentioned_user}}"],
+  "subject": "You were mentioned in {{full_repo}}#{{issue_number}}",
+  "body": "Hi @{{mentioned_user}},\n\nYou were mentioned by {{sender}}:\n{{comment_body}}"
+}
+```
+
+**Notify team on push to main:**
+```json
+{
+  "name": "push-to-main",
+  "event": "push",
+  "condition": {
+    "type": "branch",
+    "value": "main"
+  },
+  "recipients": ["dev-team@example.com"],
+  "subject": "Push to {{full_repo}}/{{branch}}",
+  "body": "Pusher: {{sender}}\nCommit: {{commit}}\nMessage: {{commit_msg}}"
+}
+```
+
+**Notify assignee when issue is assigned:**
+```json
+{
+  "name": "issue-assigned",
+  "event": "issues",
+  "condition": {
+    "type": "action",
+    "value": "assigned"
+  },
+  "recipients": ["{{assignee}}"],
+  "subject": "Issue assigned: {{full_repo}}#{{issue_number}}",
+  "body": "You have been assigned to issue #{{issue_number}}: {{issue_title}}"
+}
+```
+
+### How It Works
+
+1. **Registration**: Email notifier registers callbacks for events defined in rules
+2. **Event Processing**: When a matching webhook arrives, conditions are evaluated
+3. **Condition Check**: If condition is met (or no condition exists), email is sent
+4. **Variable Substitution**: Variables in subject/body are replaced with actual values
+5. **Recipient Resolution**: User variables are converted to email addresses from payload
+6. **Email Delivery**: Email is sent via configured SMTP server
+
+### Security Notes
+
+- **Configuration files** (`.smtp-config.json`, `email-rules.json`) are excluded from git
+- **Credentials** are stored locally and never committed to version control
+- **File permissions** should be restricted (600) to protect SMTP credentials
+- Use **strong passwords** and consider using app-specific passwords where available
+
 ## Real-World Examples
 
 ### Example 1: Auto-deploy on push to main
@@ -451,6 +689,8 @@ node examples/with-callbacks.js
 │   ├── webhookHandler.js  # Webhook processing and callbacks
 │   ├── jobQueue.js        # Persistent job queue with FIFO processing
 │   ├── commandExecutor.js # Command execution with variable substitution
+│   ├── emailNotifier.js   # Email notification callback module
+│   ├── emailSender.js     # SMTP email sender (zero dependencies)
 │   ├── config.js          # Configuration loader (.env and commands.json)
 │   └── logger.js          # Logging utility
 ├── client/                # TypeScript-based Claude Agent SDK client
@@ -467,11 +707,15 @@ node examples/with-callbacks.js
 │   ├── mcp-config-default.json   # Example default MCP config
 │   ├── mcp-config-multi-server.json  # Example multi-server MCP config
 │   └── MCP-CONFIG.md             # MCP configuration guide
-├── .env.example           # Example environment configuration
-├── commands.json.example  # Example command configuration
-├── .env                   # Your environment configuration (not in git)
-├── commands.json          # Your command configuration (not in git)
-├── queue.json             # Queue state (auto-generated, gitignored)
+├── .env.example                  # Example environment configuration
+├── commands.json.example         # Example command configuration
+├── .smtp-config.json.example     # Example SMTP configuration
+├── email-rules.json.example      # Example email notification rules
+├── .env                          # Your environment configuration (not in git)
+├── commands.json                 # Your command configuration (not in git)
+├── .smtp-config.json             # Your SMTP configuration (not in git)
+├── email-rules.json              # Your email notification rules (not in git)
+├── queue.json                    # Queue state (auto-generated, gitignored)
 ├── package.json
 └── README.md
 ```

+ 414 - 113
src/jobQueue.js

@@ -1,18 +1,32 @@
 /**
  * Job queue system with persistence
- * Ensures only one job runs at a time and persists queue to disk
+ * Supports both single-queue mode and per-repository parallel queues
  */
 import { readFileSync, writeFileSync, existsSync } from 'fs';
 import { resolve } from 'path';
 import logger from './logger.js';
 import commandExecutor from './commandExecutor.js';
+import config from './config.js';
 
 export class JobQueue {
   constructor(options = {}) {
     this.queueFile = options.queueFile || resolve(process.cwd(), 'queue.json');
-    this.queue = [];
-    this.isProcessing = false;
-    this.currentJob = null;
+
+    // Check if per-repository parallel queues are enabled
+    this.parallelPerRepository = config.getBoolean('QUEUE_PARALLEL_PER_REPOSITORY', false);
+
+    if (this.parallelPerRepository) {
+      // Multi-queue mode: separate queue for each repository
+      this.queues = new Map(); // key: full_repo_name, value: { queue: [], isProcessing: false, currentJob: null }
+      logger.info('Job queue initialized in parallel per-repository mode');
+    } else {
+      // Single-queue mode: original behavior
+      this.queue = [];
+      this.isProcessing = false;
+      this.currentJob = null;
+      logger.info('Job queue initialized in single-queue mode');
+    }
+
     this.jobIdCounter = 0;
 
     // Track recently processed issue events to prevent duplicates
@@ -31,27 +45,66 @@ export class JobQueue {
         const data = readFileSync(this.queueFile, 'utf-8');
         const savedState = JSON.parse(data);
 
-        this.queue = savedState.queue || [];
         this.jobIdCounter = savedState.jobIdCounter || 0;
 
-        // Reset any jobs that were running when server stopped
-        this.queue.forEach(job => {
-          if (job.status === 'running') {
-            job.status = 'pending';
-            logger.info(`Reset job ${job.id} from running to pending after restart`);
+        if (this.parallelPerRepository) {
+          // Load multi-queue state
+          const queuesData = savedState.queues || {};
+          let totalJobs = 0;
+
+          for (const [repoName, queueData] of Object.entries(queuesData)) {
+            const queue = queueData.queue || [];
+
+            // Reset any jobs that were running when server stopped
+            queue.forEach(job => {
+              if (job.status === 'running') {
+                job.status = 'pending';
+                logger.info(`Reset job ${job.id} in ${repoName} from running to pending after restart`);
+              }
+            });
+
+            this.queues.set(repoName, {
+              queue: queue,
+              isProcessing: false,
+              currentJob: null
+            });
+
+            totalJobs += queue.length;
+
+            // Start processing if there are jobs for this repository
+            if (queue.length > 0) {
+              this.processNext(repoName);
+            }
           }
-        });
 
-        logger.info(`Loaded ${this.queue.length} jobs from queue file`);
+          logger.info(`Loaded ${totalJobs} jobs from queue file across ${this.queues.size} repositories`);
+        } else {
+          // Load single-queue state
+          this.queue = savedState.queue || [];
+
+          // Reset any jobs that were running when server stopped
+          this.queue.forEach(job => {
+            if (job.status === 'running') {
+              job.status = 'pending';
+              logger.info(`Reset job ${job.id} from running to pending after restart`);
+            }
+          });
+
+          logger.info(`Loaded ${this.queue.length} jobs from queue file`);
 
-        // Start processing if there are jobs
-        if (this.queue.length > 0) {
-          this.processNext();
+          // Start processing if there are jobs
+          if (this.queue.length > 0) {
+            this.processNext();
+          }
         }
       }
     } catch (error) {
       logger.error('Failed to load queue from file', error);
-      this.queue = [];
+      if (this.parallelPerRepository) {
+        this.queues.clear();
+      } else {
+        this.queue = [];
+      }
       this.jobIdCounter = 0;
     }
   }
@@ -62,17 +115,54 @@ export class JobQueue {
   saveQueue() {
     try {
       const state = {
-        queue: this.queue,
         jobIdCounter: this.jobIdCounter,
         lastSaved: new Date().toISOString()
       };
 
+      if (this.parallelPerRepository) {
+        // Save multi-queue state
+        state.queues = {};
+        for (const [repoName, queueData] of this.queues.entries()) {
+          state.queues[repoName] = {
+            queue: queueData.queue
+          };
+        }
+      } else {
+        // Save single-queue state
+        state.queue = this.queue;
+      }
+
       writeFileSync(this.queueFile, JSON.stringify(state, null, 2), 'utf-8');
     } catch (error) {
       logger.error('Failed to save queue to file', error);
     }
   }
 
+  /**
+   * Get repository name from payload
+   * @param {object} payload - The webhook payload
+   * @returns {string} Repository full name (owner/repo)
+   */
+  getRepositoryName(payload) {
+    return payload.repository?.full_name || payload.repository?.name || 'unknown';
+  }
+
+  /**
+   * Get or create queue for a repository (multi-queue mode only)
+   * @param {string} repoName - Repository full name
+   * @returns {object} Queue data { queue: [], isProcessing: false, currentJob: null }
+   */
+  getOrCreateQueue(repoName) {
+    if (!this.queues.has(repoName)) {
+      this.queues.set(repoName, {
+        queue: [],
+        isProcessing: false,
+        currentJob: null
+      });
+    }
+    return this.queues.get(repoName);
+  }
+
   /**
    * Add a job to the queue
    * @param {string} eventType - The webhook event type
@@ -125,24 +215,48 @@ export class JobQueue {
       result: null
     };
 
-    this.queue.push(job);
-    this.saveQueue();
-
     // Extract issue number for logging if this is an issue-related event
     const issueNumber = payload.issue?.number || payload.number || null;
     const commandName = config.name || config.command || 'unknown';
 
-    // Use structured logging
-    logger.jobQueued(job.id, eventType, issueNumber, commandName);
+    if (this.parallelPerRepository) {
+      // Multi-queue mode: add to repository-specific queue
+      const repoName = this.getRepositoryName(payload);
+      const queueData = this.getOrCreateQueue(repoName);
+
+      queueData.queue.push(job);
+      this.saveQueue();
+
+      // Use structured logging
+      logger.jobQueued(job.id, eventType, issueNumber, commandName);
+
+      logger.info(`Job ${job.id} added to queue for ${repoName} (${eventType})`, {
+        queueLength: queueData.queue.length,
+        jobId: job.id,
+        repository: repoName
+      });
 
-    logger.info(`Job ${job.id} added to queue (${eventType})`, {
-      queueLength: this.queue.length,
-      jobId: job.id
-    });
+      // Start processing if not already processing for this repository
+      if (!queueData.isProcessing) {
+        this.processNext(repoName);
+      }
+    } else {
+      // Single-queue mode: original behavior
+      this.queue.push(job);
+      this.saveQueue();
 
-    // Start processing if not already processing
-    if (!this.isProcessing) {
-      this.processNext();
+      // Use structured logging
+      logger.jobQueued(job.id, eventType, issueNumber, commandName);
+
+      logger.info(`Job ${job.id} added to queue (${eventType})`, {
+        queueLength: this.queue.length,
+        jobId: job.id
+      });
+
+      // Start processing if not already processing
+      if (!this.isProcessing) {
+        this.processNext();
+      }
     }
 
     return job;
@@ -150,85 +264,185 @@ export class JobQueue {
 
   /**
    * Process the next job in the queue
+   * @param {string} repoName - Repository name (required in multi-queue mode)
    */
-  async processNext() {
-    // If already processing or queue is empty, do nothing
-    if (this.isProcessing || this.queue.length === 0) {
-      return;
-    }
+  async processNext(repoName = null) {
+    if (this.parallelPerRepository) {
+      // Multi-queue mode: process jobs for specific repository
+      if (!repoName) {
+        logger.error('Repository name required in parallel per-repository mode');
+        return;
+      }
 
-    this.isProcessing = true;
-    const job = this.queue[0]; // Get first job (FIFO)
-    this.currentJob = job;
+      const queueData = this.queues.get(repoName);
+      if (!queueData) {
+        logger.error(`Queue not found for repository: ${repoName}`);
+        return;
+      }
 
-    // Update job status
-    job.status = 'running';
-    job.startedAt = new Date().toISOString();
-    this.saveQueue();
+      // If already processing or queue is empty, do nothing
+      if (queueData.isProcessing || queueData.queue.length === 0) {
+        return;
+      }
 
-    // Extract issue number and command name for logging
-    const issueNumber = job.payload.issue?.number || job.payload.number || null;
-    const commandName = job.config.name || job.config.command || 'unknown';
+      queueData.isProcessing = true;
+      const job = queueData.queue[0]; // Get first job (FIFO)
+      queueData.currentJob = job;
 
-    // Use structured logging
-    logger.jobStarted(job.id, job.eventType, issueNumber, commandName);
+      // Update job status
+      job.status = 'running';
+      job.startedAt = new Date().toISOString();
+      this.saveQueue();
 
-    logger.info(`Starting job ${job.id} (${job.eventType})`, {
-      queueLength: this.queue.length,
-      jobId: job.id
-    });
+      // Extract issue number and command name for logging
+      const issueNumber = job.payload.issue?.number || job.payload.number || null;
+      const commandName = job.config.name || job.config.command || 'unknown';
 
-    try {
-      // Execute the webhook command
-      const result = await commandExecutor.executeWebhookCommand(
-        job.eventType,
-        job.payload,
-        job.headers,
-        job.config
-      );
+      // Use structured logging
+      logger.jobStarted(job.id, job.eventType, issueNumber, commandName);
+
+      logger.info(`Starting job ${job.id} for ${repoName} (${job.eventType})`, {
+        queueLength: queueData.queue.length,
+        jobId: job.id,
+        repository: repoName
+      });
+
+      try {
+        // Execute the webhook command
+        const result = await commandExecutor.executeWebhookCommand(
+          job.eventType,
+          job.payload,
+          job.headers,
+          job.config
+        );
+
+        // Update job with result
+        job.status = 'completed';
+        job.completedAt = new Date().toISOString();
+        job.result = result;
+
+        const durationMs = new Date(job.completedAt) - new Date(job.startedAt);
+
+        // Use structured logging
+        logger.jobCompleted(job.id, job.eventType, issueNumber, durationMs);
+
+        logger.info(`Job ${job.id} for ${repoName} completed successfully`, {
+          jobId: job.id,
+          duration: durationMs,
+          repository: repoName
+        });
+      } catch (error) {
+        // Update job with error
+        job.status = 'failed';
+        job.completedAt = new Date().toISOString();
+        job.result = {
+          success: false,
+          error: error.message
+        };
+
+        // Use structured logging
+        logger.jobFailed(job.id, job.eventType, issueNumber, error.message);
+
+        logger.error(`Job ${job.id} for ${repoName} failed`, error);
+      }
+
+      // Remove completed job from queue
+      queueData.queue.shift();
+      queueData.currentJob = null;
+      queueData.isProcessing = false;
+      this.saveQueue();
+
+      // Process next job if queue is not empty
+      if (queueData.queue.length > 0) {
+        logger.info(`${queueData.queue.length} jobs remaining in queue for ${repoName}`);
+        // Use setImmediate to avoid deep recursion
+        setImmediate(() => this.processNext(repoName));
+      } else {
+        logger.info(`Queue is empty for ${repoName}`);
+        // Remove empty queue to keep memory clean
+        this.queues.delete(repoName);
+        this.saveQueue();
+      }
+    } else {
+      // Single-queue mode: original behavior
+      // If already processing or queue is empty, do nothing
+      if (this.isProcessing || this.queue.length === 0) {
+        return;
+      }
+
+      this.isProcessing = true;
+      const job = this.queue[0]; // Get first job (FIFO)
+      this.currentJob = job;
 
-      // Update job with result
-      job.status = 'completed';
-      job.completedAt = new Date().toISOString();
-      job.result = result;
+      // Update job status
+      job.status = 'running';
+      job.startedAt = new Date().toISOString();
+      this.saveQueue();
 
-      const durationMs = new Date(job.completedAt) - new Date(job.startedAt);
+      // Extract issue number and command name for logging
+      const issueNumber = job.payload.issue?.number || job.payload.number || null;
+      const commandName = job.config.name || job.config.command || 'unknown';
 
       // Use structured logging
-      logger.jobCompleted(job.id, job.eventType, issueNumber, durationMs);
+      logger.jobStarted(job.id, job.eventType, issueNumber, commandName);
 
-      logger.info(`Job ${job.id} completed successfully`, {
-        jobId: job.id,
-        duration: durationMs
+      logger.info(`Starting job ${job.id} (${job.eventType})`, {
+        queueLength: this.queue.length,
+        jobId: job.id
       });
-    } catch (error) {
-      // Update job with error
-      job.status = 'failed';
-      job.completedAt = new Date().toISOString();
-      job.result = {
-        success: false,
-        error: error.message
-      };
 
-      // Use structured logging
-      logger.jobFailed(job.id, job.eventType, issueNumber, error.message);
+      try {
+        // Execute the webhook command
+        const result = await commandExecutor.executeWebhookCommand(
+          job.eventType,
+          job.payload,
+          job.headers,
+          job.config
+        );
 
-      logger.error(`Job ${job.id} failed`, error);
-    }
+        // Update job with result
+        job.status = 'completed';
+        job.completedAt = new Date().toISOString();
+        job.result = result;
 
-    // Remove completed job from queue
-    this.queue.shift();
-    this.currentJob = null;
-    this.isProcessing = false;
-    this.saveQueue();
+        const durationMs = new Date(job.completedAt) - new Date(job.startedAt);
 
-    // Process next job if queue is not empty
-    if (this.queue.length > 0) {
-      logger.info(`${this.queue.length} jobs remaining in queue`);
-      // Use setImmediate to avoid deep recursion
-      setImmediate(() => this.processNext());
-    } else {
-      logger.info('Queue is empty');
+        // Use structured logging
+        logger.jobCompleted(job.id, job.eventType, issueNumber, durationMs);
+
+        logger.info(`Job ${job.id} completed successfully`, {
+          jobId: job.id,
+          duration: durationMs
+        });
+      } catch (error) {
+        // Update job with error
+        job.status = 'failed';
+        job.completedAt = new Date().toISOString();
+        job.result = {
+          success: false,
+          error: error.message
+        };
+
+        // Use structured logging
+        logger.jobFailed(job.id, job.eventType, issueNumber, error.message);
+
+        logger.error(`Job ${job.id} failed`, error);
+      }
+
+      // Remove completed job from queue
+      this.queue.shift();
+      this.currentJob = null;
+      this.isProcessing = false;
+      this.saveQueue();
+
+      // Process next job if queue is not empty
+      if (this.queue.length > 0) {
+        logger.info(`${this.queue.length} jobs remaining in queue`);
+        // Use setImmediate to avoid deep recursion
+        setImmediate(() => this.processNext());
+      } else {
+        logger.info('Queue is empty');
+      }
     }
   }
 
@@ -236,46 +450,133 @@ export class JobQueue {
    * Get queue statistics
    */
   getStats() {
-    return {
-      queueLength: this.queue.length,
-      isProcessing: this.isProcessing,
-      currentJob: this.currentJob ? {
-        id: this.currentJob.id,
-        eventType: this.currentJob.eventType,
-        status: this.currentJob.status,
-        startedAt: this.currentJob.startedAt
-      } : null,
-      pendingJobs: this.queue.filter(j => j.status === 'pending').length,
-      runningJobs: this.queue.filter(j => j.status === 'running').length
-    };
+    if (this.parallelPerRepository) {
+      // Multi-queue mode: aggregate stats across all repositories
+      const repositoryStats = {};
+      let totalJobs = 0;
+      let totalPending = 0;
+      let totalRunning = 0;
+
+      for (const [repoName, queueData] of this.queues.entries()) {
+        const pending = queueData.queue.filter(j => j.status === 'pending').length;
+        const running = queueData.queue.filter(j => j.status === 'running').length;
+
+        repositoryStats[repoName] = {
+          queueLength: queueData.queue.length,
+          isProcessing: queueData.isProcessing,
+          currentJob: queueData.currentJob ? {
+            id: queueData.currentJob.id,
+            eventType: queueData.currentJob.eventType,
+            status: queueData.currentJob.status,
+            startedAt: queueData.currentJob.startedAt
+          } : null,
+          pendingJobs: pending,
+          runningJobs: running
+        };
+
+        totalJobs += queueData.queue.length;
+        totalPending += pending;
+        totalRunning += running;
+      }
+
+      return {
+        mode: 'parallel-per-repository',
+        repositories: repositoryStats,
+        totalRepositories: this.queues.size,
+        totalJobs: totalJobs,
+        totalPending: totalPending,
+        totalRunning: totalRunning
+      };
+    } else {
+      // Single-queue mode: original stats
+      return {
+        mode: 'single-queue',
+        queueLength: this.queue.length,
+        isProcessing: this.isProcessing,
+        currentJob: this.currentJob ? {
+          id: this.currentJob.id,
+          eventType: this.currentJob.eventType,
+          status: this.currentJob.status,
+          startedAt: this.currentJob.startedAt
+        } : null,
+        pendingJobs: this.queue.filter(j => j.status === 'pending').length,
+        runningJobs: this.queue.filter(j => j.status === 'running').length
+      };
+    }
   }
 
   /**
    * Get all jobs in the queue
    */
   getQueue() {
-    return [...this.queue];
+    if (this.parallelPerRepository) {
+      // Multi-queue mode: return all jobs from all repositories
+      const allJobs = [];
+      for (const [repoName, queueData] of this.queues.entries()) {
+        for (const job of queueData.queue) {
+          allJobs.push({ ...job, repository: repoName });
+        }
+      }
+      return allJobs;
+    } else {
+      // Single-queue mode: return all jobs
+      return [...this.queue];
+    }
   }
 
   /**
    * Clear completed jobs from history (if we decide to keep history)
    */
   clearCompleted() {
-    const beforeLength = this.queue.length;
-    this.queue = this.queue.filter(j => j.status !== 'completed');
-    this.saveQueue();
-
-    logger.info(`Cleared ${beforeLength - this.queue.length} completed jobs`);
+    if (this.parallelPerRepository) {
+      // Multi-queue mode: clear completed jobs from all repositories
+      let totalCleared = 0;
+      for (const [repoName, queueData] of this.queues.entries()) {
+        const beforeLength = queueData.queue.length;
+        queueData.queue = queueData.queue.filter(j => j.status !== 'completed');
+        totalCleared += beforeLength - queueData.queue.length;
+      }
+      this.saveQueue();
+      logger.info(`Cleared ${totalCleared} completed jobs across all repositories`);
+    } else {
+      // Single-queue mode: original behavior
+      const beforeLength = this.queue.length;
+      this.queue = this.queue.filter(j => j.status !== 'completed');
+      this.saveQueue();
+      logger.info(`Cleared ${beforeLength - this.queue.length} completed jobs`);
+    }
   }
 
   /**
    * Graceful shutdown - save queue state
    */
   async shutdown() {
-    logger.info('Shutting down job queue', {
-      remainingJobs: this.queue.length,
-      currentJob: this.currentJob?.id
-    });
+    if (this.parallelPerRepository) {
+      // Multi-queue mode: save all queues
+      let totalJobs = 0;
+      const runningJobs = [];
+
+      for (const [repoName, queueData] of this.queues.entries()) {
+        totalJobs += queueData.queue.length;
+        if (queueData.currentJob) {
+          runningJobs.push({ repo: repoName, jobId: queueData.currentJob.id });
+        }
+      }
+
+      logger.info('Shutting down job queue', {
+        mode: 'parallel-per-repository',
+        totalRepositories: this.queues.size,
+        remainingJobs: totalJobs,
+        runningJobs: runningJobs
+      });
+    } else {
+      // Single-queue mode: original behavior
+      logger.info('Shutting down job queue', {
+        mode: 'single-queue',
+        remainingJobs: this.queue.length,
+        currentJob: this.currentJob?.id
+      });
+    }
 
     this.saveQueue();