brainiac 0.0.11 → 0.0.13

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.
@@ -1,429 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Zoho Mail outgoing webhook handler.
4
- #
5
- # Zoho Mail fires outgoing webhooks when emails match configured conditions.
6
- # This handler receives those webhooks, verifies the HMAC-SHA256 signature,
7
- # and dispatches notifications to Discord.
8
- #
9
- # Config: ~/.brainiac/zoho.json
10
- # Docs: https://www.zoho.com/mail/help/dev-platform/webhook.html
11
-
12
- require "English"
13
- require_relative "../zoho_mail_api"
14
-
15
- ZOHO_CONFIG_FILE = File.join(BRAINIAC_DIR, "zoho.json")
16
-
17
- def load_zoho_config
18
- return {} unless File.exist?(ZOHO_CONFIG_FILE)
19
-
20
- JSON.parse(File.read(ZOHO_CONFIG_FILE))
21
- rescue JSON::ParserError => e
22
- LOG.error "[Zoho] Failed to parse config: #{e.message}"
23
- {}
24
- end
25
-
26
- ZOHO_CONFIG = load_zoho_config
27
-
28
- def reload_zoho_config!(force: false)
29
- return unless file_changed?(ZOHO_CONFIG_FILE, force: force)
30
-
31
- ZOHO_CONFIG.replace(load_zoho_config)
32
- LOG.info "[Zoho] Reloaded configuration"
33
- end
34
-
35
- def default_project_config
36
- key = default_project_key
37
- key ? PROJECTS[key] : PROJECTS.values.first
38
- end
39
-
40
- def zoho_triage_project_tags
41
- tags = ZOHO_CONFIG["triage_project_tags"]
42
- return "Use your best judgement to identify the relevant project." unless tags&.any?
43
-
44
- tags.map { |t| " - `#{t["tag"]}` — #{t["description"]}" }.join("\n")
45
- end
46
-
47
- def zoho_triage_agent_assignment
48
- rules = ZOHO_CONFIG["triage_agent_assignment"]
49
- return "Assign to the default agent." unless rules&.any?
50
-
51
- rules.map { |r| " - #{r}" }.join("\n")
52
- end
53
-
54
- # Zoho sends the signing secret in the X-Hook-Secret header on the very first
55
- # request. We store it in the config file so subsequent requests can be verified.
56
- # If the secret is already in the config, we use that.
57
- def zoho_hook_secret
58
- ZOHO_CONFIG["hook_secret"]
59
- end
60
-
61
- def save_zoho_hook_secret(secret)
62
- ZOHO_CONFIG["hook_secret"] = secret
63
- File.write(ZOHO_CONFIG_FILE, JSON.pretty_generate(ZOHO_CONFIG))
64
- LOG.info "[Zoho] Saved hook_secret to #{ZOHO_CONFIG_FILE}"
65
- end
66
-
67
- # Verify the X-Hook-Signature header (base64 HMAC-SHA256 of the raw body).
68
- def verify_zoho_signature!(request, payload_body)
69
- signature = request.env["HTTP_X_HOOK_SIGNATURE"]
70
- return unless signature # First request won't have a signature, just the secret
71
-
72
- secret = zoho_hook_secret
73
- halt 500, { error: "No hook_secret configured — waiting for initial Zoho handshake" }.to_json unless secret
74
-
75
- computed = Base64.strict_encode64(OpenSSL::HMAC.digest("sha256", secret, payload_body))
76
- halt 403, { error: "Invalid Zoho signature" }.to_json unless Rack::Utils.secure_compare(signature, computed)
77
- end
78
-
79
- # Check if an email contains any of the exclude words (checked against subject, from, and body).
80
- def zoho_email_excluded?(email, exclude_words)
81
- return false if exclude_words.nil? || exclude_words.empty?
82
-
83
- searchable = [email["subject"], email["fromAddress"], email["toAddress"], email["summary"], email["html"]].join(" ").downcase
84
- Array(exclude_words).any? { |word| searchable.include?(word.downcase) }
85
- end
86
-
87
- # Match an email against configured rules. Returns the first matching rule or nil.
88
- # If no rules match, returns a fallback rule (if configured) so nothing is missed.
89
- def zoho_rule_matches?(rule, email)
90
- return false if rule["enabled"] == false
91
-
92
- %w[from_contains to_contains subject_contains].each do |field|
93
- pattern = rule[field]
94
- next if pattern.nil? || pattern.empty?
95
-
96
- email_field = case field
97
- when "from_contains" then email["fromAddress"]
98
- when "to_contains" then email["toAddress"]
99
- when "subject_contains" then email["subject"]
100
- end
101
- return false unless email_field.to_s.downcase.include?(pattern.downcase)
102
- end
103
-
104
- if rule["body_contains"] && !rule["body_contains"].empty?
105
- body = "#{email["summary"]}#{email["html"]}"
106
- return false unless body.downcase.include?(rule["body_contains"].downcase)
107
- end
108
-
109
- !zoho_email_excluded?(email, rule["exclude_words"])
110
- end
111
-
112
- def match_zoho_rule(email)
113
- rules = ZOHO_CONFIG["rules"] || []
114
- matched = rules.find { |rule| zoho_rule_matches?(rule, email) }
115
- return matched if matched
116
-
117
- # Fallback: post unmatched emails so nothing slips through
118
- fallback = zoho_fallback_rule
119
- return nil if fallback && zoho_email_excluded?(email, ZOHO_CONFIG.dig("fallback", "exclude_words"))
120
-
121
- fallback
122
- end
123
-
124
- # Returns the fallback rule config, or nil if not configured.
125
- def zoho_fallback_rule
126
- fallback = ZOHO_CONFIG["fallback"]
127
- return nil unless fallback && fallback["enabled"] != false
128
-
129
- { "label" => fallback["label"] || "Unmatched Email",
130
- "emoji" => fallback["emoji"] || "📬",
131
- "discord_channel_id" => fallback["discord_channel_id"],
132
- "notify_as" => fallback["notify_as"] }
133
- end
134
-
135
- # Extract body text from email payload for notification display.
136
- def extract_zoho_body_text(email, rule)
137
- body_text = email["summary"].to_s.strip
138
- if body_text.empty?
139
- raw_html = (email["html"] || email["content"] || email["body"] || "").to_s
140
- body_text = raw_html.gsub(/<[^>]+>/, " ").gsub(/&nbsp;/i, " ").gsub(/\s+/, " ").strip
141
- end
142
-
143
- body_text = fetch_zoho_email_content(email["messageId"]).to_s if body_text.empty? && rule["show_body"] && email["messageId"]
144
-
145
- return nil if body_text.empty?
146
-
147
- max_len = rule["show_body"] ? 1800 : 500
148
- body_text = "#{body_text[0..max_len]}..." if body_text.length > max_len
149
- body_text
150
- end
151
-
152
- # Format a Discord notification for a matched email.
153
- def format_zoho_notification(email, rule)
154
- label = rule["label"] || "Zoho Mail"
155
- emoji = rule["emoji"] || "📧"
156
- parts = ["#{emoji} **#{label}**"]
157
- parts << "**Subject:** #{email["subject"]}" if email["subject"]
158
- parts << "**From:** #{email["fromAddress"]}" if email["fromAddress"]
159
- parts << "**To:** #{email["toAddress"]}" if email["toAddress"]
160
-
161
- body_text = extract_zoho_body_text(email, rule)
162
- parts << "```\n#{body_text}\n```" if body_text
163
-
164
- parts.join("\n")
165
- end
166
-
167
- # Send the notification to the configured channel.
168
- def notify_zoho_match(email, rule)
169
- target = rule["notify_target"] || rule["discord_channel_id"] || ZOHO_CONFIG["default_notify_target"] || ZOHO_CONFIG["default_discord_channel_id"]
170
- channel = rule["notify_channel"] || ZOHO_CONFIG["notify_channel"] || "discord"
171
-
172
- unless target
173
- LOG.warn "[Zoho] No notify_target configured for rule '#{rule["label"]}' and no default set"
174
- return
175
- end
176
-
177
- message = format_zoho_notification(email, rule)
178
- agent = rule["notify_as"] || ZOHO_CONFIG["notify_as"]
179
-
180
- LOG.info "[Zoho] Sending notification via #{channel} to #{target}"
181
- send_notification(:zoho_email, message, channel: channel, target: target, agent: agent)
182
- end
183
-
184
- # ---------------------------------------------------------------------------
185
- # Zoho Email Triage — dispatch an agent to decide if a support email needs a card
186
- # ---------------------------------------------------------------------------
187
-
188
- ZOHO_TRIAGE_DIR = File.join(BRAINIAC_DIR, "tmp", "zoho", "triage")
189
- FileUtils.mkdir_p(ZOHO_TRIAGE_DIR)
190
-
191
- ZOHO_TRIAGE_PROMPT = <<~PROMPT
192
- You are triaging a support email. Decide whether this email needs a card or not.
193
-
194
- ## Email
195
- **From:** {{FROM}}
196
- **To:** {{TO}}
197
- **Subject:** {{SUBJECT}}
198
- **Body:**
199
- ```
200
- {{BODY}}
201
- ```
202
-
203
- ## Decision Criteria
204
- **Needs a card** (something is broken, a bug report, a feature request, a workflow issue):
205
- - Create a card with a clear title summarizing the issue
206
- - Tag with `support` plus a project tag if you can identify the relevant project
207
- - Assign to the appropriate agent
208
-
209
- **Does NOT need a card** (account questions, password resets, general inquiries, spam, marketing):
210
- - Just explain why briefly
211
-
212
- **Borderline** (you're not sure):
213
- - Mark as borderline and explain why — a human will decide
214
-
215
- ## Project Tags (use the tag name, not the ID)
216
- {{PROJECT_TAGS}}
217
-
218
- ## Agent Assignment
219
- {{AGENT_ASSIGNMENT}}
220
-
221
- ## Response Format
222
- Write ONLY valid JSON to stdout (no markdown, no explanation outside the JSON):
223
-
224
- For "needs a card":
225
- ```json
226
- {
227
- "decision": "create_card",
228
- "title": "Brief descriptive title for the card",
229
- "description": "HTML description with relevant details from the email",
230
- "project_tag": "project-tag-name or null",
231
- "assign_to": "agent-name from the assignment rules above"
232
- }
233
- ```
234
-
235
- For "does not need a card":
236
- ```json
237
- {
238
- "decision": "skip",
239
- "reason": "Brief explanation of why no card is needed"
240
- }
241
- ```
242
-
243
- For "borderline":
244
- ```json
245
- {
246
- "decision": "borderline",
247
- "reason": "Why you're unsure — what makes this ambiguous"
248
- }
249
- ```
250
- PROMPT
251
-
252
- # Dispatch an agent to triage a support email. The agent decides whether to create
253
- # a card or just notify Discord.
254
- def dispatch_zoho_triage(email, rule)
255
- agent_name = rule["dispatch_agent"]
256
- timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
257
- response_file = File.join(ZOHO_TRIAGE_DIR, "triage-#{timestamp}.json")
258
- log_file = File.join(ZOHO_TRIAGE_DIR, "triage-#{timestamp}.log")
259
-
260
- prompt_file = write_zoho_triage_prompt(email, response_file, timestamp)
261
-
262
- agent_key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-")
263
- project_config = default_project_config
264
- resolved = resolve_project_cli_config(project_config || DEFAULT_PROJECT)
265
-
266
- cmd = [resolved["agent_cli"]]
267
- cmd.push("--agent", agent_key)
268
- cmd.concat(resolved["agent_cli_args"].split)
269
-
270
- spawn_env = {}
271
- agent_env = agent_env_for(agent_name)
272
- spawn_env.merge!(agent_env) unless agent_env.empty?
273
-
274
- work_dir = project_config ? project_config["repo_path"] : Dir.pwd
275
-
276
- LOG.info "[Zoho:Triage] Dispatching #{agent_name} for: #{email["subject"]}"
277
- LOG.info "[Zoho:Triage] Command: #{cmd.join(" ")}"
278
-
279
- pid = spawn(spawn_env, *cmd,
280
- chdir: work_dir, in: prompt_file,
281
- out: [log_file, "w"], err: %i[child out])
282
-
283
- monitor_zoho_triage(pid, response_file, log_file, prompt_file, email, rule)
284
- pid
285
- end
286
-
287
- def write_zoho_triage_prompt(email, response_file, timestamp)
288
- body = (email["summary"] || email["html"] || "").to_s.gsub(/\s+/, " ").strip
289
- body = body[0..2000] if body.length > 2000
290
-
291
- prompt = ZOHO_TRIAGE_PROMPT
292
- .gsub("{{FROM}}", email["fromAddress"].to_s)
293
- .gsub("{{TO}}", email["toAddress"].to_s)
294
- .gsub("{{SUBJECT}}", email["subject"].to_s)
295
- .gsub("{{BODY}}", body)
296
- .gsub("{{PROJECT_TAGS}}", zoho_triage_project_tags)
297
- .gsub("{{AGENT_ASSIGNMENT}}", zoho_triage_agent_assignment)
298
- prompt += "\n\nWrite your JSON response to: #{response_file}\n"
299
-
300
- prompt_file = File.join(ZOHO_TRIAGE_DIR, "triage-prompt-#{timestamp}.md")
301
- File.write(prompt_file, prompt)
302
- prompt_file
303
- end
304
-
305
- def monitor_zoho_triage(pid, response_file, log_file, prompt_file, email, rule)
306
- Thread.new do
307
- Process.wait(pid)
308
- exit_status = $CHILD_STATUS
309
- LOG.info "[Zoho:Triage] Agent finished (exit: #{exit_status.exitstatus})"
310
-
311
- decision = read_zoho_triage_response(response_file, log_file)
312
- if decision
313
- execute_zoho_triage_decision(decision, email, rule)
314
- else
315
- LOG.warn "[Zoho:Triage] No valid decision from agent — falling back to Discord notification"
316
- notify_zoho_match(email, rule)
317
- end
318
-
319
- Thread.new do
320
- sleep 300
321
- FileUtils.rm_f(prompt_file)
322
- end
323
- end
324
- end
325
-
326
- # Read the triage response — try the response file first, then extract from log
327
- def read_zoho_triage_response(response_file, log_file)
328
- # Try response file
329
- if File.exist?(response_file) && !File.empty?(response_file)
330
- content = File.read(response_file).strip
331
- return parse_triage_json(content)
332
- end
333
-
334
- # Fallback: extract JSON from log output
335
- if File.exist?(log_file)
336
- log_content = File.read(log_file)
337
- # Look for JSON block in the log
338
- if (match = log_content.match(/\{[^{}]*"decision"\s*:\s*"[^"]+?"[^{}]*\}/m))
339
- return parse_triage_json(match[0])
340
- end
341
- end
342
-
343
- nil
344
- end
345
-
346
- def parse_triage_json(content)
347
- # Strip markdown code fences if present
348
- content = content.gsub(/```json\s*/, "").gsub(/```\s*/, "").strip
349
- JSON.parse(content)
350
- rescue JSON::ParserError => e
351
- LOG.warn "[Zoho:Triage] Failed to parse response JSON: #{e.message}"
352
- nil
353
- end
354
-
355
- # Act on the triage decision
356
- def execute_zoho_triage_decision(decision, email, rule)
357
- channel_id = rule["notify_target"] || rule["discord_channel_id"] ||
358
- ZOHO_CONFIG["default_notify_target"] || ZOHO_CONFIG["default_discord_channel_id"]
359
- notify_channel = rule["notify_channel"] || ZOHO_CONFIG["notify_channel"] || "discord"
360
- bot_name = rule["notify_as"] || ZOHO_CONFIG["notify_as"]
361
-
362
- case decision["decision"]
363
- when "create_card"
364
- create_zoho_triage_card(decision, email, channel_id, notify_channel, bot_name)
365
- when "skip"
366
- msg = "📧 **Support Email — No Card Needed**\n"
367
- msg += "**Subject:** #{email["subject"]}\n"
368
- msg += "**From:** #{email["fromAddress"]}\n"
369
- msg += "**Reason:** #{decision["reason"]}"
370
- send_notification(:zoho_triage, msg, channel: notify_channel, target: channel_id, agent: bot_name)
371
- LOG.info "[Zoho:Triage] Skipped card: #{decision["reason"]}"
372
- when "borderline"
373
- msg = "⚠️ **Support Email — Needs Human Decision**\n"
374
- msg += "**Subject:** #{email["subject"]}\n"
375
- msg += "**From:** #{email["fromAddress"]}\n"
376
- msg += "**Why borderline:** #{decision["reason"]}\n"
377
- summary = (email["summary"] || "").to_s[0..300]
378
- msg += "```\n#{summary}\n```" unless summary.empty?
379
- send_notification(:zoho_triage, msg, channel: notify_channel, target: channel_id, agent: bot_name)
380
- LOG.info "[Zoho:Triage] Borderline — posted for human decision"
381
- else
382
- LOG.warn "[Zoho:Triage] Unknown decision: #{decision["decision"]}"
383
- notify_zoho_match(email, rule)
384
- end
385
- end
386
-
387
- # Create a card from the triage decision
388
- def create_zoho_triage_card(decision, email, channel_id, notify_channel, bot_name)
389
- board_id = ZOHO_CONFIG["triage_board_id"]
390
- unless board_id
391
- LOG.error "[Zoho:Triage] No triage_board_id configured in zoho.json"
392
- return
393
- end
394
- title = decision["title"] || email["subject"]
395
- description = decision["description"] || "<p>Support email from #{email["fromAddress"]}: #{email["subject"]}</p>"
396
-
397
- # Build tag list — always include 'support', plus project tag if identified
398
- tags = ["support"]
399
- tags << decision["project_tag"] if decision["project_tag"]
400
-
401
- # Emit hook — work item plugin handles card creation (card management plugins)
402
- results = Brainiac.emit(:create_work_item,
403
- board_id: board_id,
404
- title: title,
405
- description: description,
406
- tags: tags,
407
- assign_to: decision["assign_to"])
408
-
409
- card_info = results.compact.first
410
- if card_info
411
- card_number = card_info[:number]
412
- card_url = card_info[:url]
413
- LOG.info "[Zoho:Triage] Created card ##{card_number}: #{title}"
414
-
415
- # Notify configured channel
416
- msg = "🎫 **Support Card Created: [##{card_number}](#{card_url})**\n"
417
- msg += "**Title:** #{title}\n"
418
- msg += "**Assigned to:** #{decision["assign_to"] || "unassigned"}\n"
419
- msg += "**Tags:** #{tags.join(", ")}\n"
420
- msg += "**From:** #{email["fromAddress"]}"
421
- send_notification(:zoho_triage, msg, channel: notify_channel, target: channel_id, agent: bot_name)
422
- else
423
- LOG.warn "[Zoho:Triage] No work item plugin handled card creation"
424
- notify_zoho_match(email, { "label" => "Support Email (no card plugin)", "emoji" => "⚠️" }.merge(rule_defaults(nil)))
425
- end
426
- rescue StandardError => e
427
- LOG.error "[Zoho:Triage] Error creating card: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
428
- notify_zoho_match(email, { "label" => "Support Email", "emoji" => "🆘" }.merge(rule_defaults(nil)))
429
- end
@@ -1,110 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Zoho Mail REST API client for fetching email content.
4
- # Used when webhook payloads don't include the body (which is most of the time).
5
- #
6
- # Requires zoho.json to have:
7
- # "api": {
8
- # "client_id": "...",
9
- # "client_secret": "...",
10
- # "refresh_token": "...",
11
- # "account_id": "..."
12
- # }
13
-
14
- ZOHO_TOKEN_URL = "https://accounts.zoho.com/oauth/v2/token"
15
- ZOHO_MAIL_API_BASE = "https://mail.zoho.com/api/accounts"
16
-
17
- # In-memory cache for access token (expires after ~55 min to be safe)
18
- @zoho_access_token = nil
19
- @zoho_token_expires_at = Time.at(0)
20
-
21
- def zoho_api_configured?
22
- api = ZOHO_CONFIG["api"]
23
- api && api["client_id"] && api["client_secret"] && api["refresh_token"] && api["account_id"]
24
- end
25
-
26
- def zoho_refresh_access_token!
27
- api = ZOHO_CONFIG["api"]
28
- uri = URI(ZOHO_TOKEN_URL)
29
- res = Net::HTTP.post_form(uri, {
30
- "grant_type" => "refresh_token",
31
- "client_id" => api["client_id"],
32
- "client_secret" => api["client_secret"],
33
- "refresh_token" => api["refresh_token"]
34
- })
35
-
36
- data = JSON.parse(res.body)
37
- if data["access_token"]
38
- @zoho_access_token = data["access_token"]
39
- @zoho_token_expires_at = Time.now + 3300 # ~55 min
40
- LOG.info "[Zoho:API] Refreshed access token"
41
- @zoho_access_token
42
- else
43
- LOG.error "[Zoho:API] Token refresh failed: #{data["error"]}"
44
- nil
45
- end
46
- rescue StandardError => e
47
- LOG.error "[Zoho:API] Token refresh error: #{e.message}"
48
- nil
49
- end
50
-
51
- def zoho_access_token
52
- return @zoho_access_token if @zoho_access_token && Time.now < @zoho_token_expires_at
53
-
54
- zoho_refresh_access_token!
55
- end
56
-
57
- # Fetch email content by messageId using the "original message" endpoint.
58
- # This endpoint doesn't require a folder ID — just accountId + messageId.
59
- # Returns plain-text body extracted from the MIME content, or nil.
60
- def fetch_zoho_email_content(message_id)
61
- return nil unless zoho_api_configured?
62
-
63
- token = zoho_access_token
64
- return nil unless token
65
-
66
- account_id = ZOHO_CONFIG.dig("api", "account_id")
67
- uri = URI("#{ZOHO_MAIL_API_BASE}/#{account_id}/messages/#{message_id}/originalmessage")
68
-
69
- http = Net::HTTP.new(uri.host, uri.port)
70
- http.use_ssl = true
71
- req = Net::HTTP::Get.new(uri)
72
- req["Authorization"] = "Zoho-oauthtoken #{token}"
73
- req["Accept"] = "application/json"
74
-
75
- res = http.request(req)
76
- data = JSON.parse(res.body)
77
-
78
- if data.dig("status", "code") == 200
79
- raw_mime = data.dig("data", "content").to_s
80
- text = extract_text_from_mime(raw_mime)
81
- LOG.info "[Zoho:API] Fetched content for message #{message_id} (#{text.length} chars)"
82
- text
83
- else
84
- LOG.warn "[Zoho:API] Failed to fetch content: #{data.dig("status", "description")}"
85
- nil
86
- end
87
- rescue StandardError => e
88
- LOG.error "[Zoho:API] Error fetching content: #{e.message}"
89
- nil
90
- end
91
-
92
- # Extract readable text from raw MIME content.
93
- # Prefers text/plain part; falls back to stripping HTML from text/html part.
94
- def extract_text_from_mime(mime)
95
- # Try to find text/plain part
96
- 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
97
- return Regexp.last_match(1).gsub("\r\n",
98
- "\n").strip
99
- end
100
-
101
- # Fallback: extract text/html part and strip tags
102
- 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
103
- html = Regexp.last_match(1).gsub("\r\n", "\n")
104
- return html.gsub(/<[^>]+>/, " ").gsub(/&nbsp;/i, " ").gsub(/&amp;/i, "&")
105
- .gsub(/&lt;/i, "<").gsub(/&gt;/i, ">").gsub(/\s+/, " ").strip
106
- end
107
-
108
- # Last resort: strip all HTML-ish content from the whole thing
109
- mime.gsub(/<[^>]+>/, " ").gsub(/\s+/, " ").strip
110
- end
@@ -1,4 +0,0 @@
1
- {
2
- "webhook_secret": "your-github-webhook-secret",
3
- "repos": {}
4
- }
@@ -1,27 +0,0 @@
1
- {
2
- "hook_secret": null,
3
- "default_discord_channel_id": "YOUR_DISCORD_CHANNEL_ID",
4
- "notify_as": "threepio",
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
- }