brainiac-zoho 0.0.1
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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +86 -0
- data/lib/brainiac/plugins/zoho/cli.rb +90 -0
- data/lib/brainiac/plugins/zoho/config.rb +113 -0
- data/lib/brainiac/plugins/zoho/handler.rb +141 -0
- data/lib/brainiac/plugins/zoho/mail_api.rb +108 -0
- data/lib/brainiac/plugins/zoho/metadata.rb +25 -0
- data/lib/brainiac/plugins/zoho/triage.rb +244 -0
- data/lib/brainiac/plugins/zoho/version.rb +9 -0
- data/lib/brainiac/plugins/zoho.rb +161 -0
- data/lib/brainiac_zoho.rb +3 -0
- data/templates/zoho.json.example +27 -0
- metadata +123 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 386e959b01ecbdba6859b7256d0af025b4f76efe95c77e497ff78a761ee5fd5e
|
|
4
|
+
data.tar.gz: e035b1208494431bf24a56891fc998000567e4c49bae08edbe1b24b4145d816a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2bf6504d360b62f1626d6fd2db180be29fd24e2efa16a47e1fa04a7e9731af005bd223e61a46eeff4eab9c36489477435debcd0b79e31ab64c8b642297cef8d7
|
|
7
|
+
data.tar.gz: ebc6c1dd39d5d79601da0a58ae9b07612159cbbb49a281a7b0276be6cf2930bdf0ebfb8fd33db1d970f5ca594336e6626754203fb2bcad27ef123642e5feddf0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andy Davis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# brainiac-zoho
|
|
2
|
+
|
|
3
|
+
Zoho Mail webhook plugin for [Brainiac](https://github.com/stowzilla/brainiac) — the multi-agent orchestration layer.
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
- **Email Notifications** — rule-based matching of incoming emails, notifications to Discord
|
|
8
|
+
- **Email Triage** — AI agent dispatched to decide if support emails need a card
|
|
9
|
+
- **OAuth Flow** — browser-based Zoho OAuth for API access (content fetching)
|
|
10
|
+
- **Fallback Rules** — catches unmatched emails so nothing slips through
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
brainiac install zoho
|
|
16
|
+
brainiac restart
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Configuration
|
|
20
|
+
|
|
21
|
+
Config lives at `~/.brainiac/zoho.json`:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"hook_secret": null,
|
|
26
|
+
"default_discord_channel_id": "YOUR_CHANNEL_ID",
|
|
27
|
+
"notify_as": "merlin",
|
|
28
|
+
"rules": [
|
|
29
|
+
{
|
|
30
|
+
"label": "Item Sold",
|
|
31
|
+
"enabled": true,
|
|
32
|
+
"subject_contains": "sold",
|
|
33
|
+
"emoji": "💰"
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"fallback": {
|
|
37
|
+
"enabled": true,
|
|
38
|
+
"label": "Unmatched Email",
|
|
39
|
+
"emoji": "📬"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The `hook_secret` is auto-captured from Zoho's initial handshake — no manual configuration needed.
|
|
45
|
+
|
|
46
|
+
### Zoho Webhook Setup
|
|
47
|
+
|
|
48
|
+
1. Go to Zoho Mail → Settings → Developer Space → Outgoing Webhooks
|
|
49
|
+
2. Add webhook URL: `https://your-ngrok.ngrok-free.app/zoho`
|
|
50
|
+
3. On first trigger, Zoho sends the signing secret automatically
|
|
51
|
+
|
|
52
|
+
### Zoho Mail API (Optional)
|
|
53
|
+
|
|
54
|
+
For fetching full email content (webhook payloads only include summaries):
|
|
55
|
+
|
|
56
|
+
1. Add `api` section to `zoho.json` with `client_id` and `client_secret`
|
|
57
|
+
2. Visit `http://localhost:4567/zoho/auth` to complete OAuth
|
|
58
|
+
3. The `refresh_token` and `account_id` are saved automatically
|
|
59
|
+
|
|
60
|
+
## CLI
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
brainiac zoho setup # Create config file from template
|
|
64
|
+
brainiac zoho config # Show current configuration
|
|
65
|
+
brainiac zoho auth # Instructions for OAuth setup
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Hooks Emitted
|
|
69
|
+
|
|
70
|
+
| Hook | When | Payload |
|
|
71
|
+
|------|------|---------|
|
|
72
|
+
| `:create_work_item` | Triage agent decides to create card | board_id, title, description, tags, assign_to |
|
|
73
|
+
|
|
74
|
+
## Development
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
git clone https://github.com/stowzilla/brainiac-zoho.git
|
|
78
|
+
cd brainiac-zoho
|
|
79
|
+
bundle install
|
|
80
|
+
bundle exec rake test
|
|
81
|
+
bundle exec rubocop
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brainiac
|
|
4
|
+
module Plugins
|
|
5
|
+
module Zoho
|
|
6
|
+
module Cli
|
|
7
|
+
class << self
|
|
8
|
+
def run(args)
|
|
9
|
+
command = args.shift
|
|
10
|
+
case command
|
|
11
|
+
when "setup" then cmd_setup
|
|
12
|
+
when "config" then cmd_config
|
|
13
|
+
when "auth" then cmd_auth
|
|
14
|
+
else print_help
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def cmd_setup
|
|
21
|
+
brainiac_dir = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
|
|
22
|
+
config_file = File.join(brainiac_dir, "zoho.json")
|
|
23
|
+
|
|
24
|
+
if File.exist?(config_file)
|
|
25
|
+
puts "Zoho config already exists at #{config_file}"
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
template = File.expand_path("../../../../templates/zoho.json.example", __dir__)
|
|
30
|
+
if File.exist?(template)
|
|
31
|
+
FileUtils.cp(template, config_file)
|
|
32
|
+
puts "Created #{config_file} from template"
|
|
33
|
+
else
|
|
34
|
+
puts "Template not found — creating minimal config"
|
|
35
|
+
default_config = { "hook_secret" => nil, "default_discord_channel_id" => "", "rules" => [], "fallback" => { "enabled" => true } }
|
|
36
|
+
File.write(config_file, JSON.pretty_generate(default_config))
|
|
37
|
+
puts "Created #{config_file}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
puts "Edit the file to configure notification rules."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def cmd_config
|
|
44
|
+
brainiac_dir = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
|
|
45
|
+
config_file = File.join(brainiac_dir, "zoho.json")
|
|
46
|
+
|
|
47
|
+
unless File.exist?(config_file)
|
|
48
|
+
puts "No Zoho config found. Run: brainiac zoho setup"
|
|
49
|
+
return
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
config = JSON.parse(File.read(config_file))
|
|
53
|
+
rules = config["rules"] || []
|
|
54
|
+
puts "Zoho Configuration:"
|
|
55
|
+
puts " Config file: #{config_file}"
|
|
56
|
+
puts " Hook secret: #{config["hook_secret"] ? "captured" : "(pending — waiting for first webhook)"}"
|
|
57
|
+
puts " Default channel: #{config["default_discord_channel_id"] || "(not set)"}"
|
|
58
|
+
puts " Notify as: #{config["notify_as"] || "(default)"}"
|
|
59
|
+
puts " Rules: #{rules.size} configured"
|
|
60
|
+
rules.each { |r| puts " - #{r["label"]} (#{r["enabled"] == false ? "disabled" : "enabled"})" }
|
|
61
|
+
puts " API configured: #{config.dig("api", "refresh_token") ? "yes" : "no"}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def cmd_auth
|
|
65
|
+
puts "Visit http://localhost:4567/zoho/auth in your browser to start OAuth flow."
|
|
66
|
+
puts "Make sure brainiac server is running and zoho.json has api.client_id + api.client_secret."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def print_help
|
|
70
|
+
puts "Usage: brainiac zoho <command>"
|
|
71
|
+
puts ""
|
|
72
|
+
puts "Commands:"
|
|
73
|
+
puts " setup Create Zoho config file (~/.brainiac/zoho.json)"
|
|
74
|
+
puts " config Show current Zoho configuration"
|
|
75
|
+
puts " auth Instructions for OAuth setup"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Plugin CLI entry points
|
|
81
|
+
def self.cli(args)
|
|
82
|
+
Cli.run(args)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.completions
|
|
86
|
+
%w[setup config auth]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brainiac
|
|
4
|
+
module Plugins
|
|
5
|
+
module Zoho
|
|
6
|
+
module Config
|
|
7
|
+
CONFIG_FILE = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "zoho.json")
|
|
8
|
+
|
|
9
|
+
@config = {}
|
|
10
|
+
@last_mtime = nil
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
attr_reader :config
|
|
14
|
+
|
|
15
|
+
def load!
|
|
16
|
+
@config = load_config
|
|
17
|
+
@last_mtime = File.exist?(CONFIG_FILE) ? File.mtime(CONFIG_FILE) : nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def reload!
|
|
21
|
+
return unless file_changed?
|
|
22
|
+
|
|
23
|
+
@config = load_config
|
|
24
|
+
@last_mtime = File.exist?(CONFIG_FILE) ? File.mtime(CONFIG_FILE) : nil
|
|
25
|
+
LOG.info "[Zoho] Reloaded configuration"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def hook_secret
|
|
29
|
+
@config["hook_secret"]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def save_hook_secret(secret)
|
|
33
|
+
@config["hook_secret"] = secret
|
|
34
|
+
File.write(CONFIG_FILE, JSON.pretty_generate(@config))
|
|
35
|
+
LOG.info "[Zoho] Saved hook_secret to #{CONFIG_FILE}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def default_notify_target
|
|
39
|
+
@config["default_notify_target"] || @config["default_discord_channel_id"]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def notify_channel
|
|
43
|
+
@config["notify_channel"] || "discord"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def notify_as
|
|
47
|
+
@config["notify_as"]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def rules
|
|
51
|
+
@config["rules"] || []
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def fallback
|
|
55
|
+
@config["fallback"]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def triage_board_id
|
|
59
|
+
@config["triage_board_id"]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def triage_project_tags
|
|
63
|
+
tags = @config["triage_project_tags"]
|
|
64
|
+
return "Use your best judgement to identify the relevant project." unless tags&.any?
|
|
65
|
+
|
|
66
|
+
tags.map { |t| " - `#{t["tag"]}` — #{t["description"]}" }.join("\n")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def triage_agent_assignment
|
|
70
|
+
rules = @config["triage_agent_assignment"]
|
|
71
|
+
return "Assign to the default agent." unless rules&.any?
|
|
72
|
+
|
|
73
|
+
rules.map { |r| " - #{r}" }.join("\n")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def api_section
|
|
77
|
+
@config["api"] || {}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def api_configured?
|
|
81
|
+
api = api_section
|
|
82
|
+
api["client_id"] && api["client_secret"] && api["refresh_token"] && api["account_id"]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def save_config!
|
|
86
|
+
File.write(CONFIG_FILE, JSON.pretty_generate(@config))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def load_config
|
|
92
|
+
return {} unless File.exist?(CONFIG_FILE)
|
|
93
|
+
|
|
94
|
+
JSON.parse(File.read(CONFIG_FILE))
|
|
95
|
+
rescue JSON::ParserError => e
|
|
96
|
+
LOG.error "[Zoho] Failed to parse config: #{e.message}"
|
|
97
|
+
{}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def file_changed?
|
|
101
|
+
return false unless File.exist?(CONFIG_FILE)
|
|
102
|
+
|
|
103
|
+
current_mtime = File.mtime(CONFIG_FILE)
|
|
104
|
+
return false if @last_mtime && current_mtime == @last_mtime
|
|
105
|
+
|
|
106
|
+
@last_mtime = current_mtime
|
|
107
|
+
true
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brainiac
|
|
4
|
+
module Plugins
|
|
5
|
+
module Zoho
|
|
6
|
+
module Handler
|
|
7
|
+
class << self
|
|
8
|
+
def handle_webhook(email)
|
|
9
|
+
LOG.info "[Zoho] Received email: subject=#{email["subject"]}, from=#{email["fromAddress"]}, to=#{email["toAddress"]}"
|
|
10
|
+
|
|
11
|
+
dump_payload(email)
|
|
12
|
+
Config.reload!
|
|
13
|
+
|
|
14
|
+
rule = match_rule(email)
|
|
15
|
+
if rule
|
|
16
|
+
LOG.info "[Zoho] Matched rule: #{rule["label"]}"
|
|
17
|
+
if rule["dispatch_agent"]
|
|
18
|
+
Triage.dispatch(email, rule)
|
|
19
|
+
[200, { status: "triage_dispatched", rule: rule["label"], agent: rule["dispatch_agent"] }.to_json]
|
|
20
|
+
else
|
|
21
|
+
notify_match(email, rule)
|
|
22
|
+
[200, { status: "matched", rule: rule["label"] }.to_json]
|
|
23
|
+
end
|
|
24
|
+
else
|
|
25
|
+
LOG.info "[Zoho] No rules matched"
|
|
26
|
+
[200, { status: "no_match" }.to_json]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def match_rule(email)
|
|
31
|
+
rules = Config.rules
|
|
32
|
+
matched = rules.find { |rule| rule_matches?(rule, email) }
|
|
33
|
+
return matched if matched
|
|
34
|
+
|
|
35
|
+
fallback = fallback_rule
|
|
36
|
+
return nil if fallback && email_excluded?(email, Config.fallback&.dig("exclude_words"))
|
|
37
|
+
|
|
38
|
+
fallback
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def rule_matches?(rule, email)
|
|
42
|
+
return false if rule["enabled"] == false
|
|
43
|
+
|
|
44
|
+
%w[from_contains to_contains subject_contains].each do |field|
|
|
45
|
+
pattern = rule[field]
|
|
46
|
+
next if pattern.nil? || pattern.empty?
|
|
47
|
+
|
|
48
|
+
email_field = case field
|
|
49
|
+
when "from_contains" then email["fromAddress"]
|
|
50
|
+
when "to_contains" then email["toAddress"]
|
|
51
|
+
when "subject_contains" then email["subject"]
|
|
52
|
+
end
|
|
53
|
+
return false unless email_field.to_s.downcase.include?(pattern.downcase)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if rule["body_contains"] && !rule["body_contains"].empty?
|
|
57
|
+
body = "#{email["summary"]}#{email["html"]}"
|
|
58
|
+
return false unless body.downcase.include?(rule["body_contains"].downcase)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
!email_excluded?(email, rule["exclude_words"])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def email_excluded?(email, exclude_words)
|
|
65
|
+
return false if exclude_words.nil? || exclude_words.empty?
|
|
66
|
+
|
|
67
|
+
searchable = [email["subject"], email["fromAddress"], email["toAddress"],
|
|
68
|
+
email["summary"], email["html"]].join(" ").downcase
|
|
69
|
+
Array(exclude_words).any? { |word| searchable.include?(word.downcase) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def notify_match(email, rule)
|
|
73
|
+
target = rule["notify_target"] || rule["discord_channel_id"] || Config.default_notify_target
|
|
74
|
+
channel = rule["notify_channel"] || Config.notify_channel
|
|
75
|
+
|
|
76
|
+
unless target
|
|
77
|
+
LOG.warn "[Zoho] No notify_target configured for rule '#{rule["label"]}' and no default set"
|
|
78
|
+
return
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
message = format_notification(email, rule)
|
|
82
|
+
agent = rule["notify_as"] || Config.notify_as
|
|
83
|
+
|
|
84
|
+
LOG.info "[Zoho] Sending notification via #{channel} to #{target}"
|
|
85
|
+
send_notification(:zoho_email, message, channel: channel, target: target, agent: agent)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def format_notification(email, rule)
|
|
89
|
+
label = rule["label"] || "Zoho Mail"
|
|
90
|
+
emoji = rule["emoji"] || "📧"
|
|
91
|
+
parts = ["#{emoji} **#{label}**"]
|
|
92
|
+
parts << "**Subject:** #{email["subject"]}" if email["subject"]
|
|
93
|
+
parts << "**From:** #{email["fromAddress"]}" if email["fromAddress"]
|
|
94
|
+
parts << "**To:** #{email["toAddress"]}" if email["toAddress"]
|
|
95
|
+
|
|
96
|
+
body_text = extract_body_text(email, rule)
|
|
97
|
+
parts << "```\n#{body_text}\n```" if body_text
|
|
98
|
+
|
|
99
|
+
parts.join("\n")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def fallback_rule
|
|
105
|
+
fallback = Config.fallback
|
|
106
|
+
return nil unless fallback && fallback["enabled"] != false
|
|
107
|
+
|
|
108
|
+
{ "label" => fallback["label"] || "Unmatched Email",
|
|
109
|
+
"emoji" => fallback["emoji"] || "📬",
|
|
110
|
+
"discord_channel_id" => fallback["discord_channel_id"],
|
|
111
|
+
"notify_as" => fallback["notify_as"] }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def extract_body_text(email, rule)
|
|
115
|
+
body_text = email["summary"].to_s.strip
|
|
116
|
+
if body_text.empty?
|
|
117
|
+
raw_html = (email["html"] || email["content"] || email["body"] || "").to_s
|
|
118
|
+
body_text = raw_html.gsub(/<[^>]+>/, " ").gsub(/ /i, " ").gsub(/\s+/, " ").strip
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
body_text = MailApi.fetch_email_content(email["messageId"]).to_s if body_text.empty? && rule["show_body"] && email["messageId"]
|
|
122
|
+
|
|
123
|
+
return nil if body_text.empty?
|
|
124
|
+
|
|
125
|
+
max_len = rule["show_body"] ? 1800 : 500
|
|
126
|
+
body_text = "#{body_text[0..max_len]}..." if body_text.length > max_len
|
|
127
|
+
body_text
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def dump_payload(email)
|
|
131
|
+
zoho_debug_dir = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "tmp", "zoho", "payloads")
|
|
132
|
+
FileUtils.mkdir_p(zoho_debug_dir)
|
|
133
|
+
File.write(File.join(zoho_debug_dir, "#{Time.now.strftime("%Y%m%d-%H%M%S")}.json"), JSON.pretty_generate(email))
|
|
134
|
+
rescue StandardError => e
|
|
135
|
+
LOG.warn "[Zoho] Could not dump payload: #{e.message}"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
|
|
5
|
+
module Brainiac
|
|
6
|
+
module Plugins
|
|
7
|
+
module Zoho
|
|
8
|
+
module MailApi
|
|
9
|
+
ZOHO_TOKEN_URL = "https://accounts.zoho.com/oauth/v2/token"
|
|
10
|
+
ZOHO_MAIL_API_BASE = "https://mail.zoho.com/api/accounts"
|
|
11
|
+
|
|
12
|
+
@access_token = nil
|
|
13
|
+
@token_expires_at = Time.at(0)
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def configured?
|
|
17
|
+
Config.api_configured?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def refresh_access_token!
|
|
21
|
+
api = Config.api_section
|
|
22
|
+
uri = URI(ZOHO_TOKEN_URL)
|
|
23
|
+
res = Net::HTTP.post_form(uri, {
|
|
24
|
+
"grant_type" => "refresh_token",
|
|
25
|
+
"client_id" => api["client_id"],
|
|
26
|
+
"client_secret" => api["client_secret"],
|
|
27
|
+
"refresh_token" => api["refresh_token"]
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
data = JSON.parse(res.body)
|
|
31
|
+
if data["access_token"]
|
|
32
|
+
@access_token = data["access_token"]
|
|
33
|
+
@token_expires_at = Time.now + 3300
|
|
34
|
+
LOG.info "[Zoho:API] Refreshed access token"
|
|
35
|
+
@access_token
|
|
36
|
+
else
|
|
37
|
+
LOG.error "[Zoho:API] Token refresh failed: #{data["error"]}"
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
LOG.error "[Zoho:API] Token refresh error: #{e.message}"
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def access_token
|
|
46
|
+
return @access_token if @access_token && Time.now < @token_expires_at
|
|
47
|
+
|
|
48
|
+
refresh_access_token!
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Store token obtained during OAuth callback
|
|
52
|
+
def store_token(token)
|
|
53
|
+
@access_token = token
|
|
54
|
+
@token_expires_at = Time.now + 3300
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def fetch_email_content(message_id)
|
|
58
|
+
return nil unless configured?
|
|
59
|
+
|
|
60
|
+
token = access_token
|
|
61
|
+
return nil unless token
|
|
62
|
+
|
|
63
|
+
account_id = Config.api_section["account_id"]
|
|
64
|
+
uri = URI("#{ZOHO_MAIL_API_BASE}/#{account_id}/messages/#{message_id}/originalmessage")
|
|
65
|
+
|
|
66
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
67
|
+
http.use_ssl = true
|
|
68
|
+
req = Net::HTTP::Get.new(uri)
|
|
69
|
+
req["Authorization"] = "Zoho-oauthtoken #{token}"
|
|
70
|
+
req["Accept"] = "application/json"
|
|
71
|
+
|
|
72
|
+
res = http.request(req)
|
|
73
|
+
data = JSON.parse(res.body)
|
|
74
|
+
|
|
75
|
+
if data.dig("status", "code") == 200
|
|
76
|
+
raw_mime = data.dig("data", "content").to_s
|
|
77
|
+
text = extract_text_from_mime(raw_mime)
|
|
78
|
+
LOG.info "[Zoho:API] Fetched content for message #{message_id} (#{text.length} chars)"
|
|
79
|
+
text
|
|
80
|
+
else
|
|
81
|
+
LOG.warn "[Zoho:API] Failed to fetch content: #{data.dig("status", "description")}"
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
LOG.error "[Zoho:API] Error fetching content: #{e.message}"
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def extract_text_from_mime(mime)
|
|
92
|
+
if mime =~ %r{Content-Type: text/plain[^\r\n]*\r?\n(?:Content-Transfer-Encoding:[^\r\n]*\r?\n)?(?:\r?\n)(.*?)(?:\r?\n------=_Part|\z)}mi
|
|
93
|
+
return Regexp.last_match(1).gsub("\r\n", "\n").strip
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if mime =~ %r{Content-Type: text/html[^\r\n]*\r?\n(?:Content-Transfer-Encoding:[^\r\n]*\r?\n)?(?:\r?\n)(.*?)(?:\r?\n------=_Part|\z)}mi
|
|
97
|
+
html = Regexp.last_match(1).gsub("\r\n", "\n")
|
|
98
|
+
return html.gsub(/<[^>]+>/, " ").gsub(/ /i, " ").gsub(/&/i, "&")
|
|
99
|
+
.gsub(/</i, "<").gsub(/>/i, ">").gsub(/\s+/, " ").strip
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
mime.gsub(/<[^>]+>/, " ").gsub(/\s+/, " ").strip
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Lightweight metadata for brainiac-zoho.
|
|
4
|
+
# Loaded by `brainiac help` without pulling in the full plugin runtime.
|
|
5
|
+
|
|
6
|
+
require_relative "version"
|
|
7
|
+
|
|
8
|
+
module Brainiac
|
|
9
|
+
module Plugins
|
|
10
|
+
module Zoho
|
|
11
|
+
# Returns true if Zoho config file exists.
|
|
12
|
+
def self.configured?
|
|
13
|
+
config_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "zoho.json")
|
|
14
|
+
File.exist?(config_file)
|
|
15
|
+
rescue StandardError
|
|
16
|
+
false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Help text shown in `brainiac help` when the plugin is installed.
|
|
20
|
+
def self.help_text
|
|
21
|
+
" brainiac zoho <command> Manage Zoho Mail webhooks (setup, config, auth)"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brainiac
|
|
4
|
+
module Plugins
|
|
5
|
+
module Zoho
|
|
6
|
+
module Triage
|
|
7
|
+
PROMPT_TEMPLATE = <<~PROMPT
|
|
8
|
+
You are triaging a support email. Decide whether this email needs a card or not.
|
|
9
|
+
|
|
10
|
+
## Email
|
|
11
|
+
**From:** {{FROM}}
|
|
12
|
+
**To:** {{TO}}
|
|
13
|
+
**Subject:** {{SUBJECT}}
|
|
14
|
+
**Body:**
|
|
15
|
+
```
|
|
16
|
+
{{BODY}}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Decision Criteria
|
|
20
|
+
**Needs a card** (something is broken, a bug report, a feature request, a workflow issue):
|
|
21
|
+
- Create a card with a clear title summarizing the issue
|
|
22
|
+
- Tag with `support` plus a project tag if you can identify the relevant project
|
|
23
|
+
- Assign to the appropriate agent
|
|
24
|
+
|
|
25
|
+
**Does NOT need a card** (account questions, password resets, general inquiries, spam, marketing):
|
|
26
|
+
- Just explain why briefly
|
|
27
|
+
|
|
28
|
+
**Borderline** (you're not sure):
|
|
29
|
+
- Mark as borderline and explain why — a human will decide
|
|
30
|
+
|
|
31
|
+
## Project Tags (use the tag name, not the ID)
|
|
32
|
+
{{PROJECT_TAGS}}
|
|
33
|
+
|
|
34
|
+
## Agent Assignment
|
|
35
|
+
{{AGENT_ASSIGNMENT}}
|
|
36
|
+
|
|
37
|
+
## Response Format
|
|
38
|
+
Write ONLY valid JSON to stdout (no markdown, no explanation outside the JSON):
|
|
39
|
+
|
|
40
|
+
For "needs a card":
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"decision": "create_card",
|
|
44
|
+
"title": "Brief descriptive title for the card",
|
|
45
|
+
"description": "HTML description with relevant details from the email",
|
|
46
|
+
"project_tag": "project-tag-name or null",
|
|
47
|
+
"assign_to": "agent-name from the assignment rules above"
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
For "does not need a card":
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"decision": "skip",
|
|
55
|
+
"reason": "Brief explanation of why no card is needed"
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For "borderline":
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"decision": "borderline",
|
|
63
|
+
"reason": "Why you're unsure — what makes this ambiguous"
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
PROMPT
|
|
67
|
+
|
|
68
|
+
class << self
|
|
69
|
+
def dispatch(email, rule)
|
|
70
|
+
agent_name = rule["dispatch_agent"]
|
|
71
|
+
timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
|
72
|
+
triage_dir = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "tmp", "zoho", "triage")
|
|
73
|
+
FileUtils.mkdir_p(triage_dir)
|
|
74
|
+
|
|
75
|
+
response_file = File.join(triage_dir, "triage-#{timestamp}.json")
|
|
76
|
+
log_file = File.join(triage_dir, "triage-#{timestamp}.log")
|
|
77
|
+
prompt_file = write_prompt(email, response_file, timestamp, triage_dir)
|
|
78
|
+
|
|
79
|
+
agent_key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-")
|
|
80
|
+
project_config = default_project_config
|
|
81
|
+
resolved = resolve_project_cli_config(project_config || {})
|
|
82
|
+
|
|
83
|
+
cmd = [resolved["agent_cli"]]
|
|
84
|
+
cmd.push("--agent", agent_key)
|
|
85
|
+
cmd.concat(resolved["agent_cli_args"].split)
|
|
86
|
+
|
|
87
|
+
spawn_env = {}
|
|
88
|
+
agent_env = agent_env_for(agent_name)
|
|
89
|
+
spawn_env.merge!(agent_env) unless agent_env.empty?
|
|
90
|
+
|
|
91
|
+
work_dir = project_config ? project_config["repo_path"] : Dir.pwd
|
|
92
|
+
|
|
93
|
+
LOG.info "[Zoho:Triage] Dispatching #{agent_name} for: #{email["subject"]}"
|
|
94
|
+
LOG.info "[Zoho:Triage] Command: #{cmd.join(" ")}"
|
|
95
|
+
|
|
96
|
+
pid = spawn(spawn_env, *cmd,
|
|
97
|
+
chdir: work_dir, in: prompt_file,
|
|
98
|
+
out: [log_file, "w"], err: %i[child out])
|
|
99
|
+
|
|
100
|
+
monitor(pid, response_file, log_file, prompt_file, email, rule)
|
|
101
|
+
pid
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def default_project_config
|
|
107
|
+
key = default_project_key
|
|
108
|
+
key ? PROJECTS[key] : PROJECTS.values.first
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def write_prompt(email, response_file, timestamp, triage_dir)
|
|
112
|
+
body = (email["summary"] || email["html"] || "").to_s.gsub(/\s+/, " ").strip
|
|
113
|
+
body = body[0..2000] if body.length > 2000
|
|
114
|
+
|
|
115
|
+
prompt = PROMPT_TEMPLATE
|
|
116
|
+
.gsub("{{FROM}}", email["fromAddress"].to_s)
|
|
117
|
+
.gsub("{{TO}}", email["toAddress"].to_s)
|
|
118
|
+
.gsub("{{SUBJECT}}", email["subject"].to_s)
|
|
119
|
+
.gsub("{{BODY}}", body)
|
|
120
|
+
.gsub("{{PROJECT_TAGS}}", Config.triage_project_tags)
|
|
121
|
+
.gsub("{{AGENT_ASSIGNMENT}}", Config.triage_agent_assignment)
|
|
122
|
+
prompt += "\n\nWrite your JSON response to: #{response_file}\n"
|
|
123
|
+
|
|
124
|
+
prompt_file = File.join(triage_dir, "triage-prompt-#{timestamp}.md")
|
|
125
|
+
File.write(prompt_file, prompt)
|
|
126
|
+
prompt_file
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def monitor(pid, response_file, log_file, prompt_file, email, rule)
|
|
130
|
+
Thread.new do
|
|
131
|
+
Process.wait(pid)
|
|
132
|
+
LOG.info "[Zoho:Triage] Agent finished (exit: #{$CHILD_STATUS.exitstatus})"
|
|
133
|
+
|
|
134
|
+
decision = read_response(response_file, log_file)
|
|
135
|
+
if decision
|
|
136
|
+
execute_decision(decision, email, rule)
|
|
137
|
+
else
|
|
138
|
+
LOG.warn "[Zoho:Triage] No valid decision from agent — falling back to notification"
|
|
139
|
+
Handler.notify_match(email, rule)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
Thread.new do
|
|
143
|
+
sleep 300
|
|
144
|
+
FileUtils.rm_f(prompt_file)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def read_response(response_file, log_file)
|
|
150
|
+
if File.exist?(response_file) && !File.empty?(response_file)
|
|
151
|
+
content = File.read(response_file).strip
|
|
152
|
+
return parse_json(content)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
if File.exist?(log_file)
|
|
156
|
+
log_content = File.read(log_file)
|
|
157
|
+
if (match = log_content.match(/\{[^{}]*"decision"\s*:\s*"[^"]+?"[^{}]*\}/m))
|
|
158
|
+
return parse_json(match[0])
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def parse_json(content)
|
|
166
|
+
content = content.gsub(/```json\s*/, "").gsub(/```\s*/, "").strip
|
|
167
|
+
JSON.parse(content)
|
|
168
|
+
rescue JSON::ParserError => e
|
|
169
|
+
LOG.warn "[Zoho:Triage] Failed to parse response JSON: #{e.message}"
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def execute_decision(decision, email, rule)
|
|
174
|
+
channel_id = rule["notify_target"] || rule["discord_channel_id"] || Config.default_notify_target
|
|
175
|
+
notify_channel = rule["notify_channel"] || Config.notify_channel
|
|
176
|
+
bot_name = rule["notify_as"] || Config.notify_as
|
|
177
|
+
|
|
178
|
+
case decision["decision"]
|
|
179
|
+
when "create_card"
|
|
180
|
+
create_card(decision, email, channel_id, notify_channel, bot_name)
|
|
181
|
+
when "skip"
|
|
182
|
+
msg = "📧 **Support Email — No Card Needed**\n"
|
|
183
|
+
msg += "**Subject:** #{email["subject"]}\n"
|
|
184
|
+
msg += "**From:** #{email["fromAddress"]}\n"
|
|
185
|
+
msg += "**Reason:** #{decision["reason"]}"
|
|
186
|
+
send_notification(:zoho_triage, msg, channel: notify_channel, target: channel_id, agent: bot_name)
|
|
187
|
+
LOG.info "[Zoho:Triage] Skipped card: #{decision["reason"]}"
|
|
188
|
+
when "borderline"
|
|
189
|
+
msg = "⚠️ **Support Email — Needs Human Decision**\n"
|
|
190
|
+
msg += "**Subject:** #{email["subject"]}\n"
|
|
191
|
+
msg += "**From:** #{email["fromAddress"]}\n"
|
|
192
|
+
msg += "**Why borderline:** #{decision["reason"]}\n"
|
|
193
|
+
summary = (email["summary"] || "").to_s[0..300]
|
|
194
|
+
msg += "```\n#{summary}\n```" unless summary.empty?
|
|
195
|
+
send_notification(:zoho_triage, msg, channel: notify_channel, target: channel_id, agent: bot_name)
|
|
196
|
+
LOG.info "[Zoho:Triage] Borderline — posted for human decision"
|
|
197
|
+
else
|
|
198
|
+
LOG.warn "[Zoho:Triage] Unknown decision: #{decision["decision"]}"
|
|
199
|
+
Handler.notify_match(email, rule)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def create_card(decision, email, channel_id, notify_channel, bot_name)
|
|
204
|
+
board_id = Config.triage_board_id
|
|
205
|
+
unless board_id
|
|
206
|
+
LOG.error "[Zoho:Triage] No triage_board_id configured in zoho.json"
|
|
207
|
+
return
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
title = decision["title"] || email["subject"]
|
|
211
|
+
description = decision["description"] || "<p>Support email from #{email["fromAddress"]}: #{email["subject"]}</p>"
|
|
212
|
+
tags = ["support"]
|
|
213
|
+
tags << decision["project_tag"] if decision["project_tag"]
|
|
214
|
+
|
|
215
|
+
results = Brainiac.emit(:create_work_item,
|
|
216
|
+
board_id: board_id, title: title,
|
|
217
|
+
description: description, tags: tags,
|
|
218
|
+
assign_to: decision["assign_to"])
|
|
219
|
+
|
|
220
|
+
card_info = results.compact.first
|
|
221
|
+
if card_info
|
|
222
|
+
card_number = card_info[:number]
|
|
223
|
+
card_url = card_info[:url]
|
|
224
|
+
LOG.info "[Zoho:Triage] Created card ##{card_number}: #{title}"
|
|
225
|
+
|
|
226
|
+
msg = "🎫 **Support Card Created: [##{card_number}](#{card_url})**\n"
|
|
227
|
+
msg += "**Title:** #{title}\n"
|
|
228
|
+
msg += "**Assigned to:** #{decision["assign_to"] || "unassigned"}\n"
|
|
229
|
+
msg += "**Tags:** #{tags.join(", ")}\n"
|
|
230
|
+
msg += "**From:** #{email["fromAddress"]}"
|
|
231
|
+
send_notification(:zoho_triage, msg, channel: notify_channel, target: channel_id, agent: bot_name)
|
|
232
|
+
else
|
|
233
|
+
LOG.warn "[Zoho:Triage] No work item plugin handled card creation"
|
|
234
|
+
Handler.notify_match(email, { "label" => "Support Email (no card plugin)", "emoji" => "⚠️" })
|
|
235
|
+
end
|
|
236
|
+
rescue StandardError => e
|
|
237
|
+
LOG.error "[Zoho:Triage] Error creating card: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
|
238
|
+
Handler.notify_match(email, { "label" => "Support Email", "emoji" => "🆘" })
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "English"
|
|
4
|
+
require "net/http"
|
|
5
|
+
|
|
6
|
+
require_relative "zoho/version"
|
|
7
|
+
require_relative "zoho/metadata"
|
|
8
|
+
require_relative "zoho/cli"
|
|
9
|
+
require_relative "zoho/config"
|
|
10
|
+
require_relative "zoho/mail_api"
|
|
11
|
+
require_relative "zoho/handler"
|
|
12
|
+
require_relative "zoho/triage"
|
|
13
|
+
|
|
14
|
+
module Brainiac
|
|
15
|
+
module Plugins
|
|
16
|
+
module Zoho
|
|
17
|
+
ZOHO_AUTH_SCOPES = "ZohoMail.messages.READ,ZohoMail.accounts.READ,ZohoMail.folders.READ"
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Called by Brainiac plugin system during server startup.
|
|
21
|
+
#
|
|
22
|
+
# @param app [Sinatra::Application] The running Brainiac server
|
|
23
|
+
def register(app)
|
|
24
|
+
Config.load!
|
|
25
|
+
setup_routes(app)
|
|
26
|
+
LOG.info "[Zoho] Plugin registered (webhook: /zoho)"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Called after `brainiac install zoho` — creates config from template.
|
|
30
|
+
def post_install(brainiac_dir)
|
|
31
|
+
config_file = File.join(brainiac_dir, "zoho.json")
|
|
32
|
+
return if File.exist?(config_file)
|
|
33
|
+
|
|
34
|
+
template = File.expand_path("../../templates/zoho.json.example", __dir__)
|
|
35
|
+
if File.exist?(template)
|
|
36
|
+
FileUtils.cp(template, config_file)
|
|
37
|
+
else
|
|
38
|
+
default_config = {
|
|
39
|
+
"hook_secret" => nil,
|
|
40
|
+
"default_discord_channel_id" => "",
|
|
41
|
+
"notify_as" => "merlin",
|
|
42
|
+
"rules" => [],
|
|
43
|
+
"fallback" => { "enabled" => true, "label" => "Unmatched Email", "emoji" => "📬" }
|
|
44
|
+
}
|
|
45
|
+
File.write(config_file, JSON.pretty_generate(default_config))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def setup_routes(app)
|
|
52
|
+
app.post "/zoho" do
|
|
53
|
+
content_type :json
|
|
54
|
+
request.body.rewind
|
|
55
|
+
payload_body = request.body.read
|
|
56
|
+
|
|
57
|
+
# Zoho sends X-Hook-Secret on the very first request — capture and store it
|
|
58
|
+
hook_secret = request.env["HTTP_X_HOOK_SECRET"]
|
|
59
|
+
if hook_secret
|
|
60
|
+
Brainiac::Plugins::Zoho::Config.save_hook_secret(hook_secret)
|
|
61
|
+
LOG.info "[Zoho] Received and stored hook_secret from initial handshake"
|
|
62
|
+
halt 200, { status: "hook_secret_received" }.to_json
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
Brainiac::Plugins::Zoho.verify_signature!(request, payload_body)
|
|
66
|
+
|
|
67
|
+
email = JSON.parse(payload_body)
|
|
68
|
+
status_code, body = Brainiac::Plugins::Zoho::Handler.handle_webhook(email)
|
|
69
|
+
halt status_code, body
|
|
70
|
+
rescue JSON::ParserError => e
|
|
71
|
+
LOG.error "[Zoho] Invalid JSON: #{e.message}"
|
|
72
|
+
halt 400, { error: "Invalid JSON" }.to_json
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
LOG.error "[Zoho] Unhandled error: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
|
|
75
|
+
halt 500, { error: e.message }.to_json
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
app.get "/zoho/auth" do
|
|
79
|
+
Brainiac::Plugins::Zoho::Config.reload!
|
|
80
|
+
api = Brainiac::Plugins::Zoho::Config.api_section
|
|
81
|
+
halt 500, "Missing client_id in zoho.json api section" unless api["client_id"]
|
|
82
|
+
|
|
83
|
+
redirect_uri = "#{request.base_url}/zoho/callback"
|
|
84
|
+
query_params = URI.encode_www_form(
|
|
85
|
+
scope: ZOHO_AUTH_SCOPES,
|
|
86
|
+
client_id: api["client_id"],
|
|
87
|
+
response_type: "code",
|
|
88
|
+
access_type: "offline",
|
|
89
|
+
redirect_uri: redirect_uri,
|
|
90
|
+
prompt: "consent"
|
|
91
|
+
)
|
|
92
|
+
redirect "https://accounts.zoho.com/oauth/v2/auth?#{query_params}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
app.get "/zoho/callback" do
|
|
96
|
+
content_type :html
|
|
97
|
+
code = params["code"]
|
|
98
|
+
halt 400, "No authorization code received" unless code
|
|
99
|
+
|
|
100
|
+
Brainiac::Plugins::Zoho::Config.reload!
|
|
101
|
+
api = Brainiac::Plugins::Zoho::Config.api_section
|
|
102
|
+
redirect_uri = "#{request.base_url}/zoho/callback"
|
|
103
|
+
|
|
104
|
+
uri = URI(Brainiac::Plugins::Zoho::MailApi::ZOHO_TOKEN_URL)
|
|
105
|
+
res = Net::HTTP.post_form(uri, {
|
|
106
|
+
"grant_type" => "authorization_code",
|
|
107
|
+
"client_id" => api["client_id"],
|
|
108
|
+
"client_secret" => api["client_secret"],
|
|
109
|
+
"code" => code,
|
|
110
|
+
"redirect_uri" => redirect_uri
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
data = JSON.parse(res.body)
|
|
114
|
+
if data["refresh_token"]
|
|
115
|
+
Brainiac::Plugins::Zoho::Config.config["api"] ||= {}
|
|
116
|
+
Brainiac::Plugins::Zoho::Config.config["api"]["refresh_token"] = data["refresh_token"]
|
|
117
|
+
Brainiac::Plugins::Zoho::MailApi.store_token(data["access_token"])
|
|
118
|
+
Brainiac::Plugins::Zoho::Config.save_config!
|
|
119
|
+
LOG.info "[Zoho:OAuth] Stored refresh_token and access_token"
|
|
120
|
+
|
|
121
|
+
unless Brainiac::Plugins::Zoho::Config.api_section["account_id"]
|
|
122
|
+
acct_uri = URI(Brainiac::Plugins::Zoho::MailApi::ZOHO_MAIL_API_BASE.to_s)
|
|
123
|
+
http = Net::HTTP.new(acct_uri.host, acct_uri.port)
|
|
124
|
+
http.use_ssl = true
|
|
125
|
+
req = Net::HTTP::Get.new(acct_uri)
|
|
126
|
+
req["Authorization"] = "Zoho-oauthtoken #{data["access_token"]}"
|
|
127
|
+
acct_res = http.request(req)
|
|
128
|
+
acct_data = JSON.parse(acct_res.body)
|
|
129
|
+
if (account_id = acct_data.dig("data", 0, "accountId"))
|
|
130
|
+
Brainiac::Plugins::Zoho::Config.config["api"]["account_id"] = account_id
|
|
131
|
+
Brainiac::Plugins::Zoho::Config.save_config!
|
|
132
|
+
LOG.info "[Zoho:OAuth] Auto-fetched account_id: #{account_id}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
"<h1>✅ Zoho OAuth Complete</h1><p>Refresh token and account_id saved to zoho.json. You can close this tab.</p>"
|
|
137
|
+
else
|
|
138
|
+
LOG.error "[Zoho:OAuth] Token exchange failed: #{data}"
|
|
139
|
+
"<h1>❌ OAuth Failed</h1><pre>#{data.to_json}</pre>"
|
|
140
|
+
end
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
LOG.error "[Zoho:OAuth] Error: #{e.message}"
|
|
143
|
+
"<h1>❌ Error</h1><pre>#{e.message}</pre>"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Verify the X-Hook-Signature header (base64 HMAC-SHA256 of the raw body).
|
|
149
|
+
def self.verify_signature!(request, payload_body)
|
|
150
|
+
signature = request.env["HTTP_X_HOOK_SIGNATURE"]
|
|
151
|
+
return unless signature
|
|
152
|
+
|
|
153
|
+
secret = Config.hook_secret
|
|
154
|
+
halt 500, { error: "No hook_secret configured — waiting for initial Zoho handshake" }.to_json unless secret
|
|
155
|
+
|
|
156
|
+
computed = Base64.strict_encode64(OpenSSL::HMAC.digest("sha256", secret, payload_body))
|
|
157
|
+
halt 403, { error: "Invalid Zoho signature" }.to_json unless Rack::Utils.secure_compare(signature, computed)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hook_secret": null,
|
|
3
|
+
"default_discord_channel_id": "YOUR_DISCORD_CHANNEL_ID",
|
|
4
|
+
"notify_as": "merlin",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"label": "Item Sold",
|
|
8
|
+
"enabled": true,
|
|
9
|
+
"from_contains": "",
|
|
10
|
+
"to_contains": "",
|
|
11
|
+
"subject_contains": "sold",
|
|
12
|
+
"body_contains": "",
|
|
13
|
+
"exclude_words": [],
|
|
14
|
+
"emoji": "💰",
|
|
15
|
+
"discord_channel_id": null,
|
|
16
|
+
"notify_as": null
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"fallback": {
|
|
20
|
+
"enabled": true,
|
|
21
|
+
"label": "Unmatched Email",
|
|
22
|
+
"emoji": "📬",
|
|
23
|
+
"exclude_words": [],
|
|
24
|
+
"discord_channel_id": null,
|
|
25
|
+
"notify_as": null
|
|
26
|
+
}
|
|
27
|
+
}
|
metadata
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: brainiac-zoho
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Andy Davis
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: brainiac
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.0.9
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.0.9
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.25'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.25'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rake
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '13.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '13.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rubocop
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '1.75'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '1.75'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rubocop-performance
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '1.25'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '1.25'
|
|
82
|
+
description: Zoho Mail integration for Brainiac — receives email webhooks, matches
|
|
83
|
+
rules, sends notifications, and optionally triages support emails into work items.
|
|
84
|
+
Includes OAuth flow for Zoho Mail API access.
|
|
85
|
+
executables: []
|
|
86
|
+
extensions: []
|
|
87
|
+
extra_rdoc_files: []
|
|
88
|
+
files:
|
|
89
|
+
- LICENSE
|
|
90
|
+
- README.md
|
|
91
|
+
- lib/brainiac/plugins/zoho.rb
|
|
92
|
+
- lib/brainiac/plugins/zoho/cli.rb
|
|
93
|
+
- lib/brainiac/plugins/zoho/config.rb
|
|
94
|
+
- lib/brainiac/plugins/zoho/handler.rb
|
|
95
|
+
- lib/brainiac/plugins/zoho/mail_api.rb
|
|
96
|
+
- lib/brainiac/plugins/zoho/metadata.rb
|
|
97
|
+
- lib/brainiac/plugins/zoho/triage.rb
|
|
98
|
+
- lib/brainiac/plugins/zoho/version.rb
|
|
99
|
+
- lib/brainiac_zoho.rb
|
|
100
|
+
- templates/zoho.json.example
|
|
101
|
+
homepage: https://github.com/stowzilla/brainiac-zoho
|
|
102
|
+
licenses:
|
|
103
|
+
- MIT
|
|
104
|
+
metadata:
|
|
105
|
+
rubygems_mfa_required: 'true'
|
|
106
|
+
rdoc_options: []
|
|
107
|
+
require_paths:
|
|
108
|
+
- lib
|
|
109
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
110
|
+
requirements:
|
|
111
|
+
- - ">="
|
|
112
|
+
- !ruby/object:Gem::Version
|
|
113
|
+
version: '3.4'
|
|
114
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
115
|
+
requirements:
|
|
116
|
+
- - ">="
|
|
117
|
+
- !ruby/object:Gem::Version
|
|
118
|
+
version: '0'
|
|
119
|
+
requirements: []
|
|
120
|
+
rubygems_version: 3.6.9
|
|
121
|
+
specification_version: 4
|
|
122
|
+
summary: Zoho Mail webhook plugin for Brainiac
|
|
123
|
+
test_files: []
|