From cbb4562124be4400878a8e0ab896d4463ec1b72a Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Tue, 10 Feb 2026 19:14:55 +0100 Subject: [PATCH 01/10] fi_resend 1st version --- flexus_client_kit/integrations/fi_resend.py | 304 ++++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 flexus_client_kit/integrations/fi_resend.py diff --git a/flexus_client_kit/integrations/fi_resend.py b/flexus_client_kit/integrations/fi_resend.py new file mode 100644 index 00000000..e96b369d --- /dev/null +++ b/flexus_client_kit/integrations/fi_resend.py @@ -0,0 +1,304 @@ +import json +import logging +import os +from dataclasses import dataclass +from typing import Dict, Any, List, Optional + +import gql +import resend + +from flexus_client_kit import ckit_bot_exec, ckit_bot_query, ckit_client, ckit_cloudtool + +logger = logging.getLogger("resend") + +RESEND_TESTING_DOMAIN = os.environ.get("RESEND_TESTING_DOMAIN", "") + +RESEND_SETUP_SCHEMA = [ + { + "bs_name": "DOMAINS", + "bs_type": "string_multiline", + "bs_default": "{}", + "bs_group": "Email", + "bs_importance": 0, + "bs_description": 'Registered domains, e.g. {"mail.example.com": "d_abc123"}. Send and receive emails from these domains. Incoming emails are logged as CRM activities.', + }, +] + +RESEND_TOOL = ckit_cloudtool.CloudTool( + strict=False, + name="email", + description="Send and receive email, call with op=\"help\" for usage", + parameters={ + "type": "object", + "properties": { + "op": {"type": "string", "description": "Start with 'help' for usage"}, + "args": {"type": "object"}, + }, + "required": [] + }, +) + +HELP = """Help: + +email(op="send", args={ + "from": "Name ", + "to": "recipient@example.com", + "subject": "Hello", + "html": "

HTML body

", + "text": "Plain text fallback", # optional if html provided + "cc": "cc@example.com", # optional, comma-separated + "bcc": "bcc@example.com", # optional, comma-separated + "reply_to": "reply@example.com", # optional +}) + +email(op="add_domain", args={"domain": "yourdomain.com", "region": "us-east-1"}) + Register your own domain. Returns DNS records you need to configure. + Ask the user which region they prefer before calling. + Regions: us-east-1, eu-west-1, sa-east-1, ap-northeast-1. + +email(op="verify_domain", args={"domain_id": "..."}) + Trigger verification after adding DNS records. May take a few minutes. + +email(op="domain_status", args={"domain_id": "..."}) + Check verification status and DNS records. + +email(op="list_domains") + List all registered domains and their verification status. + +Notes: +- "from" and "to" are required for send. "to" can be comma-separated. +- Provide "html" and/or "text". At least one is required. +""" + + +@dataclass +class ActivityEmail: + email_id: str + from_addr: str + to_addrs: List[str] + cc_addrs: List[str] + bcc_addrs: List[str] + subject: str + body_text: str + body_html: str + + +def _help_text(has_domains: bool) -> str: + if not has_domains and RESEND_TESTING_DOMAIN: + return HELP + f"- No domains configured yet. Send from @{RESEND_TESTING_DOMAIN} in the meantime.\n" + return HELP + + +def _format_dns_records(records) -> str: + if not records: + return " (none)" + lines = [] + for rec in records: + lines.append(f" {rec['record']} {rec['type']} {rec['name']} -> {rec['value']} [{rec['status']}]") + return "\n".join(lines) + + +def parse_emessage(emsg: ckit_bot_query.FExternalMessageOutput) -> ActivityEmail: + payload = emsg.emsg_payload if isinstance(emsg.emsg_payload, dict) else json.loads(emsg.emsg_payload) + content = payload.get("email_content", {}) + data = payload.get("data", {}) + return ActivityEmail( + email_id=data.get("email_id", emsg.emsg_external_id), + from_addr=emsg.emsg_from or data.get("from", ""), + to_addrs=data.get("to", []), + cc_addrs=data.get("cc", []), + bcc_addrs=data.get("bcc", []), + subject=content.get("subject", data.get("subject", "")), + body_text=content.get("text", ""), + body_html=content.get("html", ""), + ) + + +async def register_email_addresses( + fclient: ckit_client.FlexusClient, + rcx: ckit_bot_exec.RobotContext, + email_addresses: List[str], +) -> None: + http = await fclient.use_http() + async with http as h: + await h.execute( + gql.gql("""mutation ResendRegister($persona_id: String!, $channel: String!, $addresses: [String!]!) { + persona_set_external_addresses(persona_id: $persona_id, channel: $channel, addresses: $addresses) + }"""), + variable_values={ + "persona_id": rcx.persona.persona_id, + "channel": "EMAIL", + "addresses": [f"EMAIL:{a}" for a in email_addresses], + }, + ) + logger.info("registered email addresses %s for persona %s", email_addresses, rcx.persona.persona_id) + + +class IntegrationResend: + + def __init__(self, fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext, domains: Dict[str, str]): + self.fclient = fclient + self.rcx = rcx + self.domains = domains # {"domain.com": "resend_domain_id"} + + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Optional[Dict[str, Any]]) -> str: + if not model_produced_args: + return _help_text(bool(self.domains)) + + op = model_produced_args.get("op", "") + args, args_error = ckit_cloudtool.sanitize_args(model_produced_args) + if args_error: + return args_error + + if not op or "help" in op: + return _help_text(bool(self.domains)) + if op == "send": + return self._send(args, model_produced_args) + if op == "add_domain": + return await self._add_domain(args, model_produced_args) + if op == "verify_domain": + return await self._verify_domain(args, model_produced_args) + if op == "domain_status": + return self._domain_status(args, model_produced_args) + if op == "list_domains": + return self._list_domains() + + return f"Unknown operation: {op}\n\nTry email(op='help') for usage." + + def _send(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]) -> str: + frm = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "from", None) + to = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "to", None) + if not frm or not to: + return "Missing required: 'from' and 'to'" + html = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "html", "") + text = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "text", "") + if not html and not text: + return "Provide 'html' and/or 'text'" + + params: Dict[str, Any] = { + "from": frm, + "to": [e.strip() for e in to.split(",")], + "subject": ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "subject", ""), + } + if html: + params["html"] = html + if text: + params["text"] = text + cc = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "cc", None) + bcc = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "bcc", None) + reply_to = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "reply_to", None) + if cc: + params["cc"] = [e.strip() for e in cc.split(",")] + if bcc: + params["bcc"] = [e.strip() for e in bcc.split(",")] + if reply_to: + params["reply_to"] = reply_to + + try: + r = resend.Emails.send(params) + logger.info("sent email %s to %s", r["id"], to) + return f"Email sent (id: {r['id']})" + except resend.exceptions.ResendError as e: + logger.error("resend send error: %s", e) + return "Internal error sending email, please try again later" + + async def _add_domain(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]) -> str: + if not (domain := ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "domain", None)): + return "Missing required: 'domain'" + if len(self.domains) >= 20: + return "Domain limit reached (20). Remove unused domains before adding new ones." + + region = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "region", "us-east-1") + if region not in ("us-east-1", "eu-west-1", "sa-east-1", "ap-northeast-1"): + return "Invalid region. Must be one of: us-east-1, eu-west-1, sa-east-1, ap-northeast-1." + + try: + r = resend.Domains.create({ + "name": domain, + "region": region, + "open_tracking": False, + "click_tracking": True, + "capabilities": {"sending": "enabled", "receiving": "enabled"}, + }) + except resend.exceptions.ResendError as e: + if "already" not in str(e).lower(): + logger.error("resend add domain error: %s", e) + return f"Failed to add domain: {e}" + # Resend does not support find domain without listing all + r = None + try: + for d in resend.Domains.list()["data"]: + if d["name"] == domain: + r = d + break + except Exception as ex: + logger.error("resend find domain error: %s", ex) + if not r: + return f"Domain {domain} already exists in Resend but could not retrieve it." + self.domains[domain] = r["id"] + await self._save_domains() + logger.info("resend domain %s id=%s", domain, r["id"]) + return ( + f"Domain: {domain}\n" + f"domain_id: {r['id']}\n" + f"status: {r['status']}\n\n" + f"DNS records:\n{_format_dns_records(r.get('records'))}\n\n" + f"After adding records, call verify_domain with domain_id=\"{r['id']}\".\n" + f"DNS propagation can take minutes to hours." + ) + + async def _verify_domain(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]) -> str: + domain_id = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "domain_id", None) + if not domain_id: + return "Missing required: 'domain_id'" + + try: + resend.Domains.verify(domain_id=domain_id) + msg = "Verification triggered. Check domain_status for results." + if domain_id not in self.domains.values(): + r = resend.Domains.get(domain_id=domain_id) + self.domains[r["name"]] = domain_id + await self._save_domains() + msg += f"\nDomain {r['name']} added to setup." + return msg + except resend.exceptions.ResendError as e: + logger.error("resend verify error: %s", e) + return "Failed to trigger verification" + + def _domain_status(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]) -> str: + domain_id = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "domain_id", None) + if not domain_id: + return "Missing required: 'domain_id'" + + try: + r = resend.Domains.get(domain_id=domain_id) + return f"Domain: {r['name']}\nstatus: {r['status']}\n\nDNS records:\n{_format_dns_records(r.get('records'))}" + except resend.exceptions.ResendError as e: + logger.error("resend domain status error: %s", e) + return "Failed to get domain status" + + def _list_domains(self) -> str: + if not self.domains: + return "No domains registered." + lines = [] + for domain, domain_id in self.domains.items(): + try: + r = resend.Domains.get(domain_id=domain_id) + lines.append(f" {r['name']} (id: {r['id']}) [{r['status']}]") + except resend.exceptions.ResendError: + lines.append(f" {domain} (id: {domain_id}) [error fetching status]") + return "Domains:\n" + "\n".join(lines) + + async def _save_domains(self): + http = await self.fclient.use_http() + async with http as h: + await h.execute( + gql.gql("""mutation SaveResendDomains($persona_id: String!, $set_key: String!, $set_val: String) { + persona_setup_set_key(persona_id: $persona_id, set_key: $set_key, set_val: $set_val) + }"""), + variable_values={ + "persona_id": self.rcx.persona.persona_id, + "set_key": "DOMAINS", + "set_val": json.dumps(self.domains), + }, + ) From 8907586d26fe1b2d3bb746f67a59d6e3c7419025 Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Tue, 10 Feb 2026 19:15:03 +0100 Subject: [PATCH 02/10] vix with resend --- flexus_simple_bots/vix/vix_bot.py | 53 ++++++++++++++++++++++++--- flexus_simple_bots/vix/vix_install.py | 12 +++++- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/flexus_simple_bots/vix/vix_bot.py b/flexus_simple_bots/vix/vix_bot.py index 188b4262..50abaebe 100644 --- a/flexus_simple_bots/vix/vix_bot.py +++ b/flexus_simple_bots/vix/vix_bot.py @@ -1,6 +1,7 @@ import asyncio import json import logging +import time from dataclasses import asdict from typing import Dict, Any @@ -12,12 +13,14 @@ from flexus_client_kit import ckit_shutdown from flexus_client_kit import ckit_ask_model from flexus_client_kit import ckit_mongo +from flexus_client_kit import ckit_erp from flexus_client_kit import ckit_kanban +from flexus_client_kit import erp_schema from flexus_client_kit.integrations import fi_mongo_store from flexus_client_kit.integrations import fi_pdoc from flexus_client_kit.integrations import fi_erp -from flexus_client_kit.integrations import fi_gmail from flexus_client_kit.integrations import fi_crm_automations +from flexus_client_kit.integrations import fi_resend from flexus_client_kit.integrations import fi_telegram from flexus_simple_bots.vix import vix_install from flexus_simple_bots.version_common import SIMPLE_BOTS_COMMON_VERSION @@ -36,8 +39,8 @@ fi_erp.ERP_TABLE_DATA_TOOL, fi_erp.ERP_TABLE_CRUD_TOOL, fi_erp.ERP_CSV_IMPORT_TOOL, - fi_gmail.GMAIL_TOOL, fi_crm_automations.CRM_AUTOMATION_TOOL, + fi_resend.RESEND_TOOL, fi_telegram.TELEGRAM_TOOL, ] @@ -54,10 +57,15 @@ def get_setup(): pdoc_integration = fi_pdoc.IntegrationPdoc(rcx, rcx.persona.ws_root_group_id) erp_integration = fi_erp.IntegrationErp(fclient, rcx.persona.ws_id, personal_mongo) - gmail_integration = fi_gmail.IntegrationGmail(fclient, rcx) automations_integration = fi_crm_automations.IntegrationCrmAutomations( fclient, rcx, get_setup, available_erp_tables=ERP_TABLES, ) + resend_domains = json.loads(get_setup().get("DOMAINS", "{}")) + resend_integration = fi_resend.IntegrationResend(fclient, rcx, resend_domains) + email_respond_to = set(a.strip().lower() for a in get_setup().get("EMAIL_RESPOND_TO", "").split(",") if a.strip()) + email_reg = [f"*@{d}" for d in resend_domains] + list(email_respond_to) + if email_reg: + await fi_resend.register_email_addresses(fclient, rcx, email_reg) telegram = fi_telegram.IntegrationTelegram(fclient, rcx, get_setup().get("TELEGRAM_BOT_TOKEN", "")) await telegram.register_webhook_and_start() @@ -73,6 +81,39 @@ async def updated_thread_in_db(th: ckit_ask_model.FThreadOutput): async def updated_task_in_db(t: ckit_kanban.FPersonaKanbanTaskOutput): pass + @rcx.on_emessage("EMAIL") + async def handle_email(emsg): + email = fi_resend.parse_emessage(emsg) + body = email.body_text or email.body_html or "(empty)" + try: + contacts = await ckit_erp.query_erp_table( + fclient, "crm_contact", rcx.persona.ws_id, erp_schema.CrmContact, + filters=f"contact_email:ILIKE:{email.from_addr}", limit=1, + ) + if contacts: + await ckit_erp.create_erp_record(fclient, "crm_activity", rcx.persona.ws_id, { + "ws_id": rcx.persona.ws_id, + "activity_title": email.subject, + "activity_type": "EMAIL", + "activity_direction": "INBOUND", + "activity_platform": "RESEND", + "activity_contact_id": contacts[0].contact_id, + "activity_summary": body[:500], + "activity_occurred_ts": time.time(), + }) + except Exception as e: + logger.warning("Failed to create CRM activity for inbound email from %s: %s", email.from_addr, e) + if not email_respond_to.intersection(a.lower() for a in email.to_addrs): + return + title = "Email from %s: %s" % (email.from_addr, email.subject) + if email.cc_addrs: + title += " (cc: %s)" % ", ".join(email.cc_addrs) + await ckit_kanban.bot_kanban_post_into_inbox( + fclient, rcx.persona.persona_id, + title=title, details_json=json.dumps({"from": email.from_addr, "to": email.to_addrs, "cc": email.cc_addrs, "subject": email.subject, "body": body[:2000]}), + provenance_message="vix_email_inbound", + ) + @rcx.on_emessage("TELEGRAM") async def handle_telegram_emessage(emsg): await telegram.handle_emessage(emsg) @@ -106,9 +147,9 @@ async def toolcall_erp_crud(toolcall: ckit_cloudtool.FCloudtoolCall, model_produ async def toolcall_erp_csv_import(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: return await erp_integration.handle_csv_import(toolcall, model_produced_args) - @rcx.on_tool_call(fi_gmail.GMAIL_TOOL.name) - async def toolcall_gmail(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: - return await gmail_integration.called_by_model(toolcall, model_produced_args) + @rcx.on_tool_call(fi_resend.RESEND_TOOL.name) + async def toolcall_resend(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + return await resend_integration.called_by_model(toolcall, model_produced_args) @rcx.on_tool_call(fi_crm_automations.CRM_AUTOMATION_TOOL.name) async def toolcall_crm_automation(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: diff --git a/flexus_simple_bots/vix/vix_install.py b/flexus_simple_bots/vix/vix_install.py index 00e0a86d..2eb795f3 100644 --- a/flexus_simple_bots/vix/vix_install.py +++ b/flexus_simple_bots/vix/vix_install.py @@ -7,6 +7,7 @@ from flexus_client_kit import ckit_bot_install from flexus_client_kit import ckit_cloudtool from flexus_client_kit.integrations import fi_crm_automations +from flexus_client_kit.integrations import fi_resend from flexus_simple_bots import prompts_common from flexus_simple_bots.vix import vix_bot, vix_prompts @@ -71,7 +72,16 @@ "bs_importance": 0, "bs_description": "When to offer human handoff: low (rarely), medium (balanced), high (proactive)", }, -] + fi_telegram.TELEGRAM_SETUP_SCHEMA +] + [ + { + "bs_name": "EMAIL_RESPOND_TO", + "bs_type": "string_long", + "bs_default": "", + "bs_group": "Email", + "bs_importance": 0, + "bs_description": "Email addresses the bot should respond to, comma-separated (e.g. sales@yourdomain.com). All other emails to your domains are logged as CRM activities only.", + }, +] + fi_resend.RESEND_SETUP_SCHEMA + fi_telegram.TELEGRAM_SETUP_SCHEMA async def install( From 5c46c59c1be6ec45542e52e1218bdf47e7888e79 Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Tue, 10 Feb 2026 22:33:51 +0100 Subject: [PATCH 03/10] add ws dns ownership verification, helper prompt, remove library to make async calls --- flexus_client_kit/integrations/fi_resend.py | 187 +++++++++++++------- flexus_simple_bots/vix/vix_bot.py | 2 +- flexus_simple_bots/vix/vix_prompts.py | 4 +- 3 files changed, 125 insertions(+), 68 deletions(-) diff --git a/flexus_client_kit/integrations/fi_resend.py b/flexus_client_kit/integrations/fi_resend.py index e96b369d..60cd19bc 100644 --- a/flexus_client_kit/integrations/fi_resend.py +++ b/flexus_client_kit/integrations/fi_resend.py @@ -5,13 +5,15 @@ from typing import Dict, Any, List, Optional import gql -import resend +import httpx from flexus_client_kit import ckit_bot_exec, ckit_bot_query, ckit_client, ckit_cloudtool logger = logging.getLogger("resend") +RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "") RESEND_TESTING_DOMAIN = os.environ.get("RESEND_TESTING_DOMAIN", "") +RESEND_BASE = "https://api.resend.com" RESEND_SETUP_SCHEMA = [ { @@ -24,6 +26,14 @@ }, ] +RESEND_PROMPT = f"""## Email + +Use email() tool to send emails and help users register their own domain for sending and receiving. Call email(op="help") first. +Users can configure EMAIL_RESPOND_TO addresses — emails to those addresses are handled as tasks, all others are logged as CRM activities. +Strongly recommend using a subdomain (e.g. mail.example.com) instead of the main domain. +If no domain is configured, send from *@{RESEND_TESTING_DOMAIN} for testing. +Never use flexus_my_setup() for email domains — they are saved automatically via email() tool.""" + RESEND_TOOL = ckit_cloudtool.CloudTool( strict=False, name="email", @@ -98,6 +108,21 @@ def _format_dns_records(records) -> str: return "\n".join(lines) +async def _check_dns_txt(domain: str, expected: str) -> bool: + try: + async with httpx.AsyncClient(timeout=5) as c: + r = await c.get(f"https://dns.google/resolve?name={domain}&type=TXT") + return any(expected in a.get("data", "") for a in r.json().get("Answer", [])) + except Exception as e: + logger.warning("DNS TXT check failed for %s: %s", domain, e) + return False + + +async def _resend_request(method: str, path: str, json_body: Optional[Dict] = None) -> httpx.Response: + async with httpx.AsyncClient(timeout=30) as c: + return await c.request(method, f"{RESEND_BASE}{path}", headers={"Authorization": f"Bearer {RESEND_API_KEY}"}, json=json_body) + + def parse_emessage(emsg: ckit_bot_query.FExternalMessageOutput) -> ActivityEmail: payload = emsg.emsg_payload if isinstance(emsg.emsg_payload, dict) else json.loads(emsg.emsg_payload) content = payload.get("email_content", {}) @@ -119,6 +144,18 @@ async def register_email_addresses( rcx: ckit_bot_exec.RobotContext, email_addresses: List[str], ) -> None: + txt_val = f"flexus-verify={rcx.persona.ws_id}" + verified = [] + for a in email_addresses: + domain = a.rsplit("@", 1)[1] if "@" in a else a + if RESEND_TESTING_DOMAIN and domain == RESEND_TESTING_DOMAIN: + verified.append(a) + elif await _check_dns_txt(domain, txt_val): + verified.append(a) + else: + logger.warning("address %s failed TXT ownership check, not registering", a) + if not verified: + return http = await fclient.use_http() async with http as h: await h.execute( @@ -128,18 +165,19 @@ async def register_email_addresses( variable_values={ "persona_id": rcx.persona.persona_id, "channel": "EMAIL", - "addresses": [f"EMAIL:{a}" for a in email_addresses], + "addresses": [f"EMAIL:{a}" for a in verified], }, ) - logger.info("registered email addresses %s for persona %s", email_addresses, rcx.persona.persona_id) + logger.info("registered email addresses %s for persona %s", verified, rcx.persona.persona_id) class IntegrationResend: - def __init__(self, fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext, domains: Dict[str, str]): + def __init__(self, fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext, domains: Dict[str, str], emails_to_register: set): self.fclient = fclient self.rcx = rcx self.domains = domains # {"domain.com": "resend_domain_id"} + self.emails_to_register = emails_to_register async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Optional[Dict[str, Any]]) -> str: if not model_produced_args: @@ -153,19 +191,19 @@ async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_p if not op or "help" in op: return _help_text(bool(self.domains)) if op == "send": - return self._send(args, model_produced_args) + return await self._send(args, model_produced_args) if op == "add_domain": return await self._add_domain(args, model_produced_args) if op == "verify_domain": return await self._verify_domain(args, model_produced_args) if op == "domain_status": - return self._domain_status(args, model_produced_args) + return await self._domain_status(args, model_produced_args) if op == "list_domains": - return self._list_domains() + return await self._list_domains() return f"Unknown operation: {op}\n\nTry email(op='help') for usage." - def _send(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]) -> str: + async def _send(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]) -> str: frm = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "from", None) to = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "to", None) if not frm or not to: @@ -194,13 +232,13 @@ def _send(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]) -> st if reply_to: params["reply_to"] = reply_to - try: - r = resend.Emails.send(params) - logger.info("sent email %s to %s", r["id"], to) - return f"Email sent (id: {r['id']})" - except resend.exceptions.ResendError as e: - logger.error("resend send error: %s", e) - return "Internal error sending email, please try again later" + r = await _resend_request("POST", "/emails", params) + if r.status_code == 200: + rid = r.json().get("id", "") + logger.info("sent email %s to %s", rid, to) + return f"Email sent (id: {rid})" + logger.error("resend send error: %s %s", r.status_code, r.text[:200]) + return "Internal error sending email, please try again later" async def _add_domain(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]) -> str: if not (domain := ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "domain", None)): @@ -212,38 +250,41 @@ async def _add_domain(self, args: Dict[str, Any], model_produced_args: Dict[str, if region not in ("us-east-1", "eu-west-1", "sa-east-1", "ap-northeast-1"): return "Invalid region. Must be one of: us-east-1, eu-west-1, sa-east-1, ap-northeast-1." - try: - r = resend.Domains.create({ - "name": domain, - "region": region, - "open_tracking": False, - "click_tracking": True, - "capabilities": {"sending": "enabled", "receiving": "enabled"}, - }) - except resend.exceptions.ResendError as e: - if "already" not in str(e).lower(): - logger.error("resend add domain error: %s", e) - return f"Failed to add domain: {e}" + r = await _resend_request("POST", "/domains", { + "name": domain, + "region": region, + "open_tracking": False, + "click_tracking": True, + "capabilities": {"sending": "enabled", "receiving": "enabled"}, + }) + if r.status_code == 200 or r.status_code == 201: + d = r.json() + elif "already" in r.text.lower(): # Resend does not support find domain without listing all - r = None - try: - for d in resend.Domains.list()["data"]: - if d["name"] == domain: - r = d + lr = await _resend_request("GET", "/domains") + d = None + if lr.status_code == 200: + for item in lr.json().get("data", []): + if item["name"] == domain: + d = item break - except Exception as ex: - logger.error("resend find domain error: %s", ex) - if not r: + if not d: return f"Domain {domain} already exists in Resend but could not retrieve it." - self.domains[domain] = r["id"] + else: + logger.error("resend add domain error: %s %s", r.status_code, r.text[:200]) + return f"Failed to add domain: {r.text[:200]}" + + self.domains[domain] = d["id"] await self._save_domains() - logger.info("resend domain %s id=%s", domain, r["id"]) + txt_val = f"flexus-verify={self.rcx.persona.ws_id}" + logger.info("resend domain %s id=%s", domain, d["id"]) return ( f"Domain: {domain}\n" - f"domain_id: {r['id']}\n" - f"status: {r['status']}\n\n" - f"DNS records:\n{_format_dns_records(r.get('records'))}\n\n" - f"After adding records, call verify_domain with domain_id=\"{r['id']}\".\n" + f"domain_id: {d['id']}\n" + f"status: {d.get('status', 'pending')}\n\n" + f"DNS records:\n{_format_dns_records(d.get('records'))}\n" + f" TXT {domain} -> {txt_val} (ownership verification)\n\n" + f"After adding records, call verify_domain with domain_id=\"{d['id']}\".\n" f"DNS propagation can take minutes to hours." ) @@ -252,40 +293,54 @@ async def _verify_domain(self, args: Dict[str, Any], model_produced_args: Dict[s if not domain_id: return "Missing required: 'domain_id'" - try: - resend.Domains.verify(domain_id=domain_id) - msg = "Verification triggered. Check domain_status for results." - if domain_id not in self.domains.values(): - r = resend.Domains.get(domain_id=domain_id) - self.domains[r["name"]] = domain_id - await self._save_domains() - msg += f"\nDomain {r['name']} added to setup." - return msg - except resend.exceptions.ResendError as e: - logger.error("resend verify error: %s", e) - return "Failed to trigger verification" - - def _domain_status(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]) -> str: + gr = await _resend_request("GET", f"/domains/{domain_id}") + if gr.status_code != 200: + logger.error("resend get domain error: %s %s", gr.status_code, gr.text[:200]) + return "Failed to get domain info" + d = gr.json() + txt_val = f"flexus-verify={self.rcx.persona.ws_id}" + if not await _check_dns_txt(d["name"], txt_val): + return f"TXT record '{txt_val}' not found for {d['name']}. DNS may still be propagating, try again later." + await _resend_request("POST", f"/domains/{domain_id}/verify") + msg = "Verification triggered. Check domain_status for results." + if domain_id not in self.domains.values(): + self.domains[d["name"]] = domain_id + await self._save_domains() + msg += f"\nDomain {d['name']} added to setup." + await register_email_addresses(self.fclient, self.rcx, + [f"*@{dom}" for dom in self.domains] + list(self.emails_to_register)) + return msg + + async def _domain_status(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]) -> str: domain_id = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "domain_id", None) if not domain_id: return "Missing required: 'domain_id'" - try: - r = resend.Domains.get(domain_id=domain_id) - return f"Domain: {r['name']}\nstatus: {r['status']}\n\nDNS records:\n{_format_dns_records(r.get('records'))}" - except resend.exceptions.ResendError as e: - logger.error("resend domain status error: %s", e) + r = await _resend_request("GET", f"/domains/{domain_id}") + if r.status_code != 200: + logger.error("resend domain status error: %s %s", r.status_code, r.text[:200]) return "Failed to get domain status" - - def _list_domains(self) -> str: + d = r.json() + txt_val = f"flexus-verify={self.rcx.persona.ws_id}" + txt_ok = await _check_dns_txt(d["name"], txt_val) + out = f"Domain: {d['name']}\n" + if not txt_ok: + out += f"ownership: NOT VERIFIED — add TXT record: {d['name']} -> {txt_val}, then call verify_domain. Domain cannot be used until verified.\n" + else: + out += "ownership: verified\n" + out += f"resend status: {d['status']}\n\nDNS records:\n{_format_dns_records(d.get('records'))}" + return out + + async def _list_domains(self) -> str: if not self.domains: return "No domains registered." lines = [] for domain, domain_id in self.domains.items(): - try: - r = resend.Domains.get(domain_id=domain_id) - lines.append(f" {r['name']} (id: {r['id']}) [{r['status']}]") - except resend.exceptions.ResendError: + r = await _resend_request("GET", f"/domains/{domain_id}") + if r.status_code == 200: + d = r.json() + lines.append(f" {d['name']} (id: {d['id']}) [{d['status']}]") + else: lines.append(f" {domain} (id: {domain_id}) [error fetching status]") return "Domains:\n" + "\n".join(lines) diff --git a/flexus_simple_bots/vix/vix_bot.py b/flexus_simple_bots/vix/vix_bot.py index 50abaebe..fd89b240 100644 --- a/flexus_simple_bots/vix/vix_bot.py +++ b/flexus_simple_bots/vix/vix_bot.py @@ -61,8 +61,8 @@ def get_setup(): fclient, rcx, get_setup, available_erp_tables=ERP_TABLES, ) resend_domains = json.loads(get_setup().get("DOMAINS", "{}")) - resend_integration = fi_resend.IntegrationResend(fclient, rcx, resend_domains) email_respond_to = set(a.strip().lower() for a in get_setup().get("EMAIL_RESPOND_TO", "").split(",") if a.strip()) + resend_integration = fi_resend.IntegrationResend(fclient, rcx, resend_domains, email_respond_to) email_reg = [f"*@{d}" for d in resend_domains] + list(email_respond_to) if email_reg: await fi_resend.register_email_addresses(fclient, rcx, email_reg) diff --git a/flexus_simple_bots/vix/vix_prompts.py b/flexus_simple_bots/vix/vix_prompts.py index 4a87f2c4..bc3fe299 100644 --- a/flexus_simple_bots/vix/vix_prompts.py +++ b/flexus_simple_bots/vix/vix_prompts.py @@ -1,5 +1,5 @@ from flexus_simple_bots import prompts_common -from flexus_client_kit.integrations import fi_crm_automations, fi_messenger +from flexus_client_kit.integrations import fi_crm_automations, fi_messenger, fi_resend vix_prompt_sales = f""" # Elite AI Sales Agent @@ -1088,6 +1088,8 @@ {fi_crm_automations.AUTOMATIONS_PROMPT} +{fi_resend.RESEND_PROMPT} + ### Expert Selection for Automations When creating automations that post tasks, use `fexp_name` to route to the right expert: From 3ffc112057dbb53ca4e2738e7e99ecbebf3de5b5 Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Wed, 11 Feb 2026 18:55:12 +0100 Subject: [PATCH 04/10] discount coins when sending emails --- flexus_client_kit/integrations/fi_resend.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/flexus_client_kit/integrations/fi_resend.py b/flexus_client_kit/integrations/fi_resend.py index 60cd19bc..c9d79b0d 100644 --- a/flexus_client_kit/integrations/fi_resend.py +++ b/flexus_client_kit/integrations/fi_resend.py @@ -179,7 +179,7 @@ def __init__(self, fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotCo self.domains = domains # {"domain.com": "resend_domain_id"} self.emails_to_register = emails_to_register - async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Optional[Dict[str, Any]]) -> str: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Optional[Dict[str, Any]]): if not model_produced_args: return _help_text(bool(self.domains)) @@ -203,7 +203,7 @@ async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_p return f"Unknown operation: {op}\n\nTry email(op='help') for usage." - async def _send(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]) -> str: + async def _send(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]): frm = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "from", None) to = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "to", None) if not frm or not to: @@ -232,11 +232,12 @@ async def _send(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]) if reply_to: params["reply_to"] = reply_to + n_recipients = len(params["to"]) + len(params.get("cc", [])) + len(params.get("bcc", [])) r = await _resend_request("POST", "/emails", params) if r.status_code == 200: rid = r.json().get("id", "") logger.info("sent email %s to %s", rid, to) - return f"Email sent (id: {rid})" + return ckit_cloudtool.ToolResult(content=f"Email sent (id: {rid})", dollars=0.0009 * n_recipients) logger.error("resend send error: %s %s", r.status_code, r.text[:200]) return "Internal error sending email, please try again later" From b97681de1b8b1da707daebb5f5db98612f53f9e7 Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Mon, 16 Feb 2026 12:16:42 +0100 Subject: [PATCH 05/10] email send and email setup domain tools --- flexus_client_kit/integrations/fi_resend.py | 157 +++++++++----------- flexus_simple_bots/vix/vix_bot.py | 13 +- flexus_simple_bots/vix/vix_install.py | 4 +- 3 files changed, 85 insertions(+), 89 deletions(-) diff --git a/flexus_client_kit/integrations/fi_resend.py b/flexus_client_kit/integrations/fi_resend.py index c9d79b0d..754ea043 100644 --- a/flexus_client_kit/integrations/fi_resend.py +++ b/flexus_client_kit/integrations/fi_resend.py @@ -28,56 +28,62 @@ RESEND_PROMPT = f"""## Email -Use email() tool to send emails and help users register their own domain for sending and receiving. Call email(op="help") first. +Use email_send() to send emails. Use email_setup_domain() to register and manage sending domains, call email_setup_domain(op="help") first. Users can configure EMAIL_RESPOND_TO addresses — emails to those addresses are handled as tasks, all others are logged as CRM activities. -Strongly recommend using a subdomain (e.g. mail.example.com) instead of the main domain. +Strongly recommend using a subdomain (e.g. mail.example.com) instead of the main domain, especially for inbound emails. If no domain is configured, send from *@{RESEND_TESTING_DOMAIN} for testing. -Never use flexus_my_setup() for email domains — they are saved automatically via email() tool.""" +Never use flexus_my_setup() for email domains — they are saved automatically via email_setup_domain() tool.""" -RESEND_TOOL = ckit_cloudtool.CloudTool( +RESEND_SEND_TOOL = ckit_cloudtool.CloudTool( + strict=True, + name="email_send", + description="Send an email. Provide html and/or text body.", + parameters={ + "type": "object", + "properties": { + "from": {"type": "string", "order": 1, "description": "Sender, e.g. Name "}, + "to": {"type": "string", "order": 2, "description": "Recipient(s), comma-separated"}, + "subject": {"type": "string", "order": 3, "description": "Subject line"}, + "html": {"type": "string", "order": 4, "description": "HTML body, or empty string if text-only"}, + "text": {"type": "string", "order": 5, "description": "Plain text fallback, or empty string if html-only"}, + "cc": {"type": "string", "order": 6, "description": "CC recipients comma-separated, or empty string"}, + "bcc": {"type": "string", "order": 7, "description": "BCC recipients comma-separated, or empty string"}, + "reply_to": {"type": "string", "order": 8, "description": "Reply-to address, or empty string"}, + }, + "required": ["from", "to", "subject", "html", "text", "cc", "bcc", "reply_to"], + "additionalProperties": False, + }, +) + +RESEND_SETUP_TOOL = ckit_cloudtool.CloudTool( strict=False, - name="email", - description="Send and receive email, call with op=\"help\" for usage", + name="email_setup_domain", + description="Manage email domains: add, verify, check status, list. Call with op=\"help\" for usage.", parameters={ "type": "object", "properties": { - "op": {"type": "string", "description": "Start with 'help' for usage"}, + "op": {"type": "string", "description": "Operation: help, add, verify, status, list"}, "args": {"type": "object"}, }, - "required": [] + "required": [], }, ) -HELP = """Help: - -email(op="send", args={ - "from": "Name ", - "to": "recipient@example.com", - "subject": "Hello", - "html": "

HTML body

", - "text": "Plain text fallback", # optional if html provided - "cc": "cc@example.com", # optional, comma-separated - "bcc": "bcc@example.com", # optional, comma-separated - "reply_to": "reply@example.com", # optional -}) - -email(op="add_domain", args={"domain": "yourdomain.com", "region": "us-east-1"}) - Register your own domain. Returns DNS records you need to configure. +SETUP_HELP = """Email domain setup: + +email_setup_domain(op="add", args={"domain": "yourdomain.com", "region": "us-east-1"}) + Register a domain. Returns DNS records to configure. Ask the user which region they prefer before calling. Regions: us-east-1, eu-west-1, sa-east-1, ap-northeast-1. -email(op="verify_domain", args={"domain_id": "..."}) +email_setup_domain(op="verify", args={"domain_id": "..."}) Trigger verification after adding DNS records. May take a few minutes. -email(op="domain_status", args={"domain_id": "..."}) +email_setup_domain(op="status", args={"domain_id": "..."}) Check verification status and DNS records. -email(op="list_domains") +email_setup_domain(op="list") List all registered domains and their verification status. - -Notes: -- "from" and "to" are required for send. "to" can be comma-separated. -- Provide "html" and/or "text". At least one is required. """ @@ -93,10 +99,10 @@ class ActivityEmail: body_html: str -def _help_text(has_domains: bool) -> str: +def _setup_help(has_domains: bool) -> str: if not has_domains and RESEND_TESTING_DOMAIN: - return HELP + f"- No domains configured yet. Send from @{RESEND_TESTING_DOMAIN} in the meantime.\n" - return HELP + return SETUP_HELP + f"No domains configured yet. Send from @{RESEND_TESTING_DOMAIN} in the meantime.\n" + return SETUP_HELP def _format_dns_records(records) -> str: @@ -179,59 +185,26 @@ def __init__(self, fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotCo self.domains = domains # {"domain.com": "resend_domain_id"} self.emails_to_register = emails_to_register - async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Optional[Dict[str, Any]]): + async def send_called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Optional[Dict[str, Any]]): if not model_produced_args: - return _help_text(bool(self.domains)) - - op = model_produced_args.get("op", "") - args, args_error = ckit_cloudtool.sanitize_args(model_produced_args) - if args_error: - return args_error - - if not op or "help" in op: - return _help_text(bool(self.domains)) - if op == "send": - return await self._send(args, model_produced_args) - if op == "add_domain": - return await self._add_domain(args, model_produced_args) - if op == "verify_domain": - return await self._verify_domain(args, model_produced_args) - if op == "domain_status": - return await self._domain_status(args, model_produced_args) - if op == "list_domains": - return await self._list_domains() - - return f"Unknown operation: {op}\n\nTry email(op='help') for usage." - - async def _send(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]): - frm = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "from", None) - to = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "to", None) + return "Provide from, to, subject, and html or text body." + a = model_produced_args + frm, to = a.get("from", ""), a.get("to", "") if not frm or not to: return "Missing required: 'from' and 'to'" - html = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "html", "") - text = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "text", "") + html, text = a.get("html", ""), a.get("text", "") if not html and not text: return "Provide 'html' and/or 'text'" - - params: Dict[str, Any] = { - "from": frm, - "to": [e.strip() for e in to.split(",")], - "subject": ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "subject", ""), - } + params: Dict[str, Any] = {"from": frm, "to": [e.strip() for e in to.split(",")], "subject": a.get("subject", "")} if html: params["html"] = html if text: params["text"] = text - cc = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "cc", None) - bcc = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "bcc", None) - reply_to = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "reply_to", None) - if cc: - params["cc"] = [e.strip() for e in cc.split(",")] - if bcc: - params["bcc"] = [e.strip() for e in bcc.split(",")] - if reply_to: + for k in ("cc", "bcc"): + if v := a.get(k, ""): + params[k] = [e.strip() for e in v.split(",")] + if reply_to := a.get("reply_to", ""): params["reply_to"] = reply_to - n_recipients = len(params["to"]) + len(params.get("cc", [])) + len(params.get("bcc", [])) r = await _resend_request("POST", "/emails", params) if r.status_code == 200: @@ -241,16 +214,34 @@ async def _send(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]) logger.error("resend send error: %s %s", r.status_code, r.text[:200]) return "Internal error sending email, please try again later" + async def setup_called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Optional[Dict[str, Any]]): + if not model_produced_args: + return _setup_help(bool(self.domains)) + op = model_produced_args.get("op", "") + args, args_error = ckit_cloudtool.sanitize_args(model_produced_args) + if args_error: + return args_error + if not op or "help" in op: + return _setup_help(bool(self.domains)) + if op == "add": + return await self._add_domain(args, model_produced_args) + if op == "verify": + return await self._verify_domain(args, model_produced_args) + if op == "status": + return await self._domain_status(args, model_produced_args) + if op == "list": + return await self._list_domains() + return f"Unknown operation: {op}\n\nTry email_setup_domain(op='help') for usage." + async def _add_domain(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]) -> str: if not (domain := ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "domain", None)): return "Missing required: 'domain'" + region = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "region", None) + if region not in ("us-east-1", "eu-west-1", "sa-east-1", "ap-northeast-1"): + return "Missing or invalid 'region'. Ask the user which region they prefer: us-east-1, eu-west-1, sa-east-1, ap-northeast-1." if len(self.domains) >= 20: return "Domain limit reached (20). Remove unused domains before adding new ones." - region = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "region", "us-east-1") - if region not in ("us-east-1", "eu-west-1", "sa-east-1", "ap-northeast-1"): - return "Invalid region. Must be one of: us-east-1, eu-west-1, sa-east-1, ap-northeast-1." - r = await _resend_request("POST", "/domains", { "name": domain, "region": region, @@ -285,7 +276,7 @@ async def _add_domain(self, args: Dict[str, Any], model_produced_args: Dict[str, f"status: {d.get('status', 'pending')}\n\n" f"DNS records:\n{_format_dns_records(d.get('records'))}\n" f" TXT {domain} -> {txt_val} (ownership verification)\n\n" - f"After adding records, call verify_domain with domain_id=\"{d['id']}\".\n" + f"After adding records, call email_setup_domain(op=\"verify\", args={{\"domain_id\": \"{d['id']}\"}})\n" f"DNS propagation can take minutes to hours." ) @@ -303,7 +294,7 @@ async def _verify_domain(self, args: Dict[str, Any], model_produced_args: Dict[s if not await _check_dns_txt(d["name"], txt_val): return f"TXT record '{txt_val}' not found for {d['name']}. DNS may still be propagating, try again later." await _resend_request("POST", f"/domains/{domain_id}/verify") - msg = "Verification triggered. Check domain_status for results." + msg = "Verification triggered. Check status for results." if domain_id not in self.domains.values(): self.domains[d["name"]] = domain_id await self._save_domains() @@ -326,7 +317,7 @@ async def _domain_status(self, args: Dict[str, Any], model_produced_args: Dict[s txt_ok = await _check_dns_txt(d["name"], txt_val) out = f"Domain: {d['name']}\n" if not txt_ok: - out += f"ownership: NOT VERIFIED — add TXT record: {d['name']} -> {txt_val}, then call verify_domain. Domain cannot be used until verified.\n" + out += f"ownership: NOT VERIFIED — add TXT record: {d['name']} -> {txt_val}, then call op=\"verify\". Domain cannot be used until verified.\n" else: out += "ownership: verified\n" out += f"resend status: {d['status']}\n\nDNS records:\n{_format_dns_records(d.get('records'))}" diff --git a/flexus_simple_bots/vix/vix_bot.py b/flexus_simple_bots/vix/vix_bot.py index fd89b240..c1a4aa6f 100644 --- a/flexus_simple_bots/vix/vix_bot.py +++ b/flexus_simple_bots/vix/vix_bot.py @@ -40,7 +40,8 @@ fi_erp.ERP_TABLE_CRUD_TOOL, fi_erp.ERP_CSV_IMPORT_TOOL, fi_crm_automations.CRM_AUTOMATION_TOOL, - fi_resend.RESEND_TOOL, + fi_resend.RESEND_SEND_TOOL, + fi_resend.RESEND_SETUP_TOOL, fi_telegram.TELEGRAM_TOOL, ] @@ -147,9 +148,13 @@ async def toolcall_erp_crud(toolcall: ckit_cloudtool.FCloudtoolCall, model_produ async def toolcall_erp_csv_import(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: return await erp_integration.handle_csv_import(toolcall, model_produced_args) - @rcx.on_tool_call(fi_resend.RESEND_TOOL.name) - async def toolcall_resend(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: - return await resend_integration.called_by_model(toolcall, model_produced_args) + @rcx.on_tool_call(fi_resend.RESEND_SEND_TOOL.name) + async def toolcall_email_send(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + return await resend_integration.send_called_by_model(toolcall, model_produced_args) + + @rcx.on_tool_call(fi_resend.RESEND_SETUP_TOOL.name) + async def toolcall_email_setup(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + return await resend_integration.setup_called_by_model(toolcall, model_produced_args) @rcx.on_tool_call(fi_crm_automations.CRM_AUTOMATION_TOOL.name) async def toolcall_crm_automation(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: diff --git a/flexus_simple_bots/vix/vix_install.py b/flexus_simple_bots/vix/vix_install.py index 2eb795f3..867a3755 100644 --- a/flexus_simple_bots/vix/vix_install.py +++ b/flexus_simple_bots/vix/vix_install.py @@ -132,7 +132,7 @@ async def install( ("sales", ckit_bot_install.FMarketplaceExpertInput( fexp_system_prompt=vix_prompts.vix_prompt_sales, fexp_python_kernel="", - fexp_block_tools="*setup*", + fexp_block_tools="*setup", fexp_allow_tools="", fexp_inactivity_timeout=3600, fexp_app_capture_tools=bot_internal_tools, @@ -141,7 +141,7 @@ async def install( ("nurturing", ckit_bot_install.FMarketplaceExpertInput( fexp_system_prompt=vix_prompts.vix_prompt_nurturing, fexp_python_kernel="", - fexp_block_tools="*setup*", + fexp_block_tools="*setup", fexp_allow_tools="", fexp_inactivity_timeout=600, fexp_app_capture_tools=nurturing_tools, From 614d6c15904f1ac83d6c8485f5994393b2e860d5 Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Mon, 16 Feb 2026 22:41:38 +0100 Subject: [PATCH 06/10] adding case insensitive equal to filters --- flexus_client_kit/ckit_erp.py | 4 +++- flexus_client_kit/integrations/fi_erp.py | 4 ++-- flexus_client_kit/integrations/fi_gmail.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/flexus_client_kit/ckit_erp.py b/flexus_client_kit/ckit_erp.py index daf890e5..5ab7d484 100644 --- a/flexus_client_kit/ckit_erp.py +++ b/flexus_client_kit/ckit_erp.py @@ -190,7 +190,7 @@ def check_record_matches_filter(record: dict, f: str, col_names: set = None) -> Check if a single record matches a single filter string. Filter format: "col:op:val" or "col:op" - Standard operators: =, !=, >, >=, <, <=, IN, NOT_IN, LIKE, ILIKE, IS_NULL, IS_NOT_NULL, IS_EMPTY, IS_NOT_EMPTY + Standard operators: =, !=, >, >=, <, <=, IN, NOT_IN, LIKE, ILIKE, IEQL, IS_NULL, IS_NOT_NULL, IS_EMPTY, IS_NOT_EMPTY Array operators: contains, not_contains JSON path: "task_details->email_subtype:=:welcome" """ @@ -295,6 +295,8 @@ def check_record_matches_filter(record: dict, f: str, col_names: set = None) -> if op in ("NOT_IN", "NOT IN"): vals = [v.strip() for v in filter_val.split(",")] return str(val) not in vals + if op == "IEQL": + return str(val).lower() == filter_val.lower() if op in ("LIKE", "ILIKE"): s = str(val).lower() if op == "ILIKE" else str(val) pattern = filter_val.lower() if op == "ILIKE" else filter_val diff --git a/flexus_client_kit/integrations/fi_erp.py b/flexus_client_kit/integrations/fi_erp.py index f64c8ab4..cb2828cb 100644 --- a/flexus_client_kit/integrations/fi_erp.py +++ b/flexus_client_kit/integrations/fi_erp.py @@ -38,8 +38,8 @@ name="erp_table_data", description=( "Query ERP table data with filtering. " - "Operators: =, !=, >, >=, <, <=, LIKE, ILIKE, IN, NOT_IN, IS_NULL, IS_NOT_NULL. " - "LIKE/ILIKE use SQL wildcards: % matches any chars. " + "Operators: =, !=, >, >=, <, <=, LIKE, ILIKE, IEQL, IN, NOT_IN, IS_NULL, IS_NOT_NULL. " + "LIKE/ILIKE use SQL wildcards: % matches any chars. IEQL is case-insensitive equals (no wildcards). " "JSON path: details->subtype:=:welcome. " "Examples: " 'filters="status:=:active" for single filter, ' diff --git a/flexus_client_kit/integrations/fi_gmail.py b/flexus_client_kit/integrations/fi_gmail.py index ea8e8181..777fd701 100644 --- a/flexus_client_kit/integrations/fi_gmail.py +++ b/flexus_client_kit/integrations/fi_gmail.py @@ -326,7 +326,7 @@ async def _create_activity_for_email(self, to: str, subject: str, body: str, ft_ try: contacts = await ckit_erp.query_erp_table( self.fclient, "crm_contact", self.rcx.persona.ws_id, erp_schema.CrmContact, - filters=f"contact_email:ILIKE:{email}", limit=1, + filters=f"contact_email:IEQL:{email}", limit=1, ) if not contacts: continue From f6b84ce51e99d91b5a3bdf8f877c037f2d3b7487 Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Mon, 16 Feb 2026 22:51:25 +0100 Subject: [PATCH 07/10] email handling in the back end, create contact if not exists on inbound --- flexus_client_kit/integrations/fi_resend.py | 213 +++++--------------- flexus_simple_bots/vix/vix_bot.py | 48 +++-- 2 files changed, 77 insertions(+), 184 deletions(-) diff --git a/flexus_client_kit/integrations/fi_resend.py b/flexus_client_kit/integrations/fi_resend.py index 754ea043..27d42091 100644 --- a/flexus_client_kit/integrations/fi_resend.py +++ b/flexus_client_kit/integrations/fi_resend.py @@ -42,12 +42,12 @@ "type": "object", "properties": { "from": {"type": "string", "order": 1, "description": "Sender, e.g. Name "}, - "to": {"type": "string", "order": 2, "description": "Recipient(s), comma-separated"}, + "to": {"type": "array", "items": {"type": "string"}, "order": 2, "description": "Recipient email addresses"}, "subject": {"type": "string", "order": 3, "description": "Subject line"}, "html": {"type": "string", "order": 4, "description": "HTML body, or empty string if text-only"}, "text": {"type": "string", "order": 5, "description": "Plain text fallback, or empty string if html-only"}, - "cc": {"type": "string", "order": 6, "description": "CC recipients comma-separated, or empty string"}, - "bcc": {"type": "string", "order": 7, "description": "BCC recipients comma-separated, or empty string"}, + "cc": {"type": "array", "items": {"type": "string"}, "order": 6, "description": "CC recipient email addresses"}, + "bcc": {"type": "array", "items": {"type": "string"}, "order": 7, "description": "BCC recipient email addresses"}, "reply_to": {"type": "string", "order": 8, "description": "Reply-to address, or empty string"}, }, "required": ["from", "to", "subject", "html", "text", "cc", "bcc", "reply_to"], @@ -58,11 +58,11 @@ RESEND_SETUP_TOOL = ckit_cloudtool.CloudTool( strict=False, name="email_setup_domain", - description="Manage email domains: add, verify, check status, list. Call with op=\"help\" for usage.", + description="Manage email domains: add, verify, check status, list, delete. Call with op=\"help\" for usage. Before adding a domain, ask the user if they want to enable receiving emails on it.", parameters={ "type": "object", "properties": { - "op": {"type": "string", "description": "Operation: help, add, verify, status, list"}, + "op": {"type": "string", "description": "Operation: help, add, verify, status, list, delete"}, "args": {"type": "object"}, }, "required": [], @@ -71,9 +71,9 @@ SETUP_HELP = """Email domain setup: -email_setup_domain(op="add", args={"domain": "yourdomain.com", "region": "us-east-1"}) +email_setup_domain(op="add", args={"domain": "yourdomain.com", "region": "us-east-1", "enable_receiving": true}) Register a domain. Returns DNS records to configure. - Ask the user which region they prefer before calling. + Ask the user which region they prefer and whether they want to enable receiving emails. Regions: us-east-1, eu-west-1, sa-east-1, ap-northeast-1. email_setup_domain(op="verify", args={"domain_id": "..."}) @@ -84,6 +84,9 @@ email_setup_domain(op="list") List all registered domains and their verification status. + +email_setup_domain(op="delete", args={"domain_id": "..."}) + Remove a domain. """ @@ -91,6 +94,7 @@ class ActivityEmail: email_id: str from_addr: str + from_full: str # "Name " if available to_addrs: List[str] cc_addrs: List[str] bcc_addrs: List[str] @@ -105,15 +109,6 @@ def _setup_help(has_domains: bool) -> str: return SETUP_HELP -def _format_dns_records(records) -> str: - if not records: - return " (none)" - lines = [] - for rec in records: - lines.append(f" {rec['record']} {rec['type']} {rec['name']} -> {rec['value']} [{rec['status']}]") - return "\n".join(lines) - - async def _check_dns_txt(domain: str, expected: str) -> bool: try: async with httpx.AsyncClient(timeout=5) as c: @@ -124,18 +119,15 @@ async def _check_dns_txt(domain: str, expected: str) -> bool: return False -async def _resend_request(method: str, path: str, json_body: Optional[Dict] = None) -> httpx.Response: - async with httpx.AsyncClient(timeout=30) as c: - return await c.request(method, f"{RESEND_BASE}{path}", headers={"Authorization": f"Bearer {RESEND_API_KEY}"}, json=json_body) - - def parse_emessage(emsg: ckit_bot_query.FExternalMessageOutput) -> ActivityEmail: payload = emsg.emsg_payload if isinstance(emsg.emsg_payload, dict) else json.loads(emsg.emsg_payload) content = payload.get("email_content", {}) data = payload.get("data", {}) + header_from = content.get("headers", {}).get("from", "") return ActivityEmail( email_id=data.get("email_id", emsg.emsg_external_id), from_addr=emsg.emsg_from or data.get("from", ""), + from_full=header_from or data.get("from", "") or emsg.emsg_from, to_addrs=data.get("to", []), cc_addrs=data.get("cc", []), bcc_addrs=data.get("bcc", []), @@ -189,30 +181,29 @@ async def send_called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, mo if not model_produced_args: return "Provide from, to, subject, and html or text body." a = model_produced_args - frm, to = a.get("from", ""), a.get("to", "") + frm, to = a.get("from", ""), a.get("to", []) if not frm or not to: return "Missing required: 'from' and 'to'" - html, text = a.get("html", ""), a.get("text", "") - if not html and not text: + if not a.get("html", "") and not a.get("text", ""): return "Provide 'html' and/or 'text'" - params: Dict[str, Any] = {"from": frm, "to": [e.strip() for e in to.split(",")], "subject": a.get("subject", "")} - if html: - params["html"] = html - if text: - params["text"] = text - for k in ("cc", "bcc"): - if v := a.get(k, ""): - params[k] = [e.strip() for e in v.split(",")] - if reply_to := a.get("reply_to", ""): - params["reply_to"] = reply_to - n_recipients = len(params["to"]) + len(params.get("cc", [])) + len(params.get("bcc", [])) - r = await _resend_request("POST", "/emails", params) - if r.status_code == 200: - rid = r.json().get("id", "") - logger.info("sent email %s to %s", rid, to) - return ckit_cloudtool.ToolResult(content=f"Email sent (id: {rid})", dollars=0.0009 * n_recipients) - logger.error("resend send error: %s %s", r.status_code, r.text[:200]) - return "Internal error sending email, please try again later" + http = await self.fclient.use_http() + async with http as h: + r = await h.execute(gql.gql("""mutation ResendBotSendEmail($input: ResendEmailSendInput!) { + resend_email_send(input: $input) + }"""), variable_values={"input": { + "persona_id": self.rcx.persona.persona_id, + "email_from": frm, + "email_to": to, + "email_subject": a.get("subject", ""), + "email_html": a.get("html", ""), + "email_text": a.get("text", ""), + "email_cc": a.get("cc", []), + "email_bcc": a.get("bcc", []), + "email_reply_to": a.get("reply_to", ""), + }}) + rid = r.get("resend_email_send", "") + logger.info("sent email %s to %s", rid, to) + return ckit_cloudtool.ToolResult(content=f"Email sent (id: {rid})", dollars=0) async def setup_called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Optional[Dict[str, Any]]): if not model_produced_args: @@ -223,129 +214,19 @@ async def setup_called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, m return args_error if not op or "help" in op: return _setup_help(bool(self.domains)) - if op == "add": - return await self._add_domain(args, model_produced_args) - if op == "verify": - return await self._verify_domain(args, model_produced_args) - if op == "status": - return await self._domain_status(args, model_produced_args) - if op == "list": - return await self._list_domains() - return f"Unknown operation: {op}\n\nTry email_setup_domain(op='help') for usage." - - async def _add_domain(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]) -> str: - if not (domain := ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "domain", None)): - return "Missing required: 'domain'" - region = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "region", None) - if region not in ("us-east-1", "eu-west-1", "sa-east-1", "ap-northeast-1"): - return "Missing or invalid 'region'. Ask the user which region they prefer: us-east-1, eu-west-1, sa-east-1, ap-northeast-1." - if len(self.domains) >= 20: - return "Domain limit reached (20). Remove unused domains before adding new ones." - - r = await _resend_request("POST", "/domains", { - "name": domain, - "region": region, - "open_tracking": False, - "click_tracking": True, - "capabilities": {"sending": "enabled", "receiving": "enabled"}, - }) - if r.status_code == 200 or r.status_code == 201: - d = r.json() - elif "already" in r.text.lower(): - # Resend does not support find domain without listing all - lr = await _resend_request("GET", "/domains") - d = None - if lr.status_code == 200: - for item in lr.json().get("data", []): - if item["name"] == domain: - d = item - break - if not d: - return f"Domain {domain} already exists in Resend but could not retrieve it." - else: - logger.error("resend add domain error: %s %s", r.status_code, r.text[:200]) - return f"Failed to add domain: {r.text[:200]}" - - self.domains[domain] = d["id"] - await self._save_domains() - txt_val = f"flexus-verify={self.rcx.persona.ws_id}" - logger.info("resend domain %s id=%s", domain, d["id"]) - return ( - f"Domain: {domain}\n" - f"domain_id: {d['id']}\n" - f"status: {d.get('status', 'pending')}\n\n" - f"DNS records:\n{_format_dns_records(d.get('records'))}\n" - f" TXT {domain} -> {txt_val} (ownership verification)\n\n" - f"After adding records, call email_setup_domain(op=\"verify\", args={{\"domain_id\": \"{d['id']}\"}})\n" - f"DNS propagation can take minutes to hours." - ) - - async def _verify_domain(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]) -> str: - domain_id = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "domain_id", None) - if not domain_id: - return "Missing required: 'domain_id'" - - gr = await _resend_request("GET", f"/domains/{domain_id}") - if gr.status_code != 200: - logger.error("resend get domain error: %s %s", gr.status_code, gr.text[:200]) - return "Failed to get domain info" - d = gr.json() - txt_val = f"flexus-verify={self.rcx.persona.ws_id}" - if not await _check_dns_txt(d["name"], txt_val): - return f"TXT record '{txt_val}' not found for {d['name']}. DNS may still be propagating, try again later." - await _resend_request("POST", f"/domains/{domain_id}/verify") - msg = "Verification triggered. Check status for results." - if domain_id not in self.domains.values(): - self.domains[d["name"]] = domain_id - await self._save_domains() - msg += f"\nDomain {d['name']} added to setup." - await register_email_addresses(self.fclient, self.rcx, - [f"*@{dom}" for dom in self.domains] + list(self.emails_to_register)) - return msg - - async def _domain_status(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]) -> str: - domain_id = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "domain_id", None) - if not domain_id: - return "Missing required: 'domain_id'" - - r = await _resend_request("GET", f"/domains/{domain_id}") - if r.status_code != 200: - logger.error("resend domain status error: %s %s", r.status_code, r.text[:200]) - return "Failed to get domain status" - d = r.json() - txt_val = f"flexus-verify={self.rcx.persona.ws_id}" - txt_ok = await _check_dns_txt(d["name"], txt_val) - out = f"Domain: {d['name']}\n" - if not txt_ok: - out += f"ownership: NOT VERIFIED — add TXT record: {d['name']} -> {txt_val}, then call op=\"verify\". Domain cannot be used until verified.\n" - else: - out += "ownership: verified\n" - out += f"resend status: {d['status']}\n\nDNS records:\n{_format_dns_records(d.get('records'))}" - return out - - async def _list_domains(self) -> str: - if not self.domains: - return "No domains registered." - lines = [] - for domain, domain_id in self.domains.items(): - r = await _resend_request("GET", f"/domains/{domain_id}") - if r.status_code == 200: - d = r.json() - lines.append(f" {d['name']} (id: {d['id']}) [{d['status']}]") - else: - lines.append(f" {domain} (id: {domain_id}) [error fetching status]") - return "Domains:\n" + "\n".join(lines) - - async def _save_domains(self): + gql_input = {"persona_id": self.rcx.persona.persona_id, "op": op} + for k in ("domain", "domain_id", "region"): + if v := ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, k, None): + gql_input[k] = v + if ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "enable_receiving", False): + gql_input["enable_receiving"] = True + if op == "add" and not gql_input.get("domain"): + return "domain is required for add" + if op in ("verify", "status", "delete") and not gql_input.get("domain_id"): + return "domain_id is required for " + op http = await self.fclient.use_http() async with http as h: - await h.execute( - gql.gql("""mutation SaveResendDomains($persona_id: String!, $set_key: String!, $set_val: String) { - persona_setup_set_key(persona_id: $persona_id, set_key: $set_key, set_val: $set_val) - }"""), - variable_values={ - "persona_id": self.rcx.persona.persona_id, - "set_key": "DOMAINS", - "set_val": json.dumps(self.domains), - }, - ) + r = await h.execute(gql.gql("""mutation ResendBotSetupDomain($input: ResendSetupDomainInput!) { + resend_setup_domain(input: $input) + }"""), variable_values={"input": gql_input}) + return r.get("resend_setup_domain", "") diff --git a/flexus_simple_bots/vix/vix_bot.py b/flexus_simple_bots/vix/vix_bot.py index c1a4aa6f..ac8bc04f 100644 --- a/flexus_simple_bots/vix/vix_bot.py +++ b/flexus_simple_bots/vix/vix_bot.py @@ -1,4 +1,5 @@ import asyncio +import email.utils import json import logging import time @@ -61,7 +62,7 @@ def get_setup(): automations_integration = fi_crm_automations.IntegrationCrmAutomations( fclient, rcx, get_setup, available_erp_tables=ERP_TABLES, ) - resend_domains = json.loads(get_setup().get("DOMAINS", "{}")) + resend_domains = (rcx.persona.persona_setup or {}).get("DOMAINS", {}) email_respond_to = set(a.strip().lower() for a in get_setup().get("EMAIL_RESPOND_TO", "").split(",") if a.strip()) resend_integration = fi_resend.IntegrationResend(fclient, rcx, resend_domains, email_respond_to) email_reg = [f"*@{d}" for d in resend_domains] + list(email_respond_to) @@ -84,34 +85,45 @@ async def updated_task_in_db(t: ckit_kanban.FPersonaKanbanTaskOutput): @rcx.on_emessage("EMAIL") async def handle_email(emsg): - email = fi_resend.parse_emessage(emsg) - body = email.body_text or email.body_html or "(empty)" + em = fi_resend.parse_emessage(emsg) + body = em.body_text or em.body_html or "(empty)" try: + display_name, addr = email.utils.parseaddr(em.from_full) + addr = addr or em.from_addr contacts = await ckit_erp.query_erp_table( fclient, "crm_contact", rcx.persona.ws_id, erp_schema.CrmContact, - filters=f"contact_email:ILIKE:{email.from_addr}", limit=1, + filters=f"contact_email:IEQL:{addr}", limit=1, ) if contacts: - await ckit_erp.create_erp_record(fclient, "crm_activity", rcx.persona.ws_id, { + contact_id = contacts[0].contact_id + else: + parts = display_name.split(None, 1) if display_name else [addr.split("@")[0]] + contact_id = await ckit_erp.create_erp_record(fclient, "crm_contact", rcx.persona.ws_id, { "ws_id": rcx.persona.ws_id, - "activity_title": email.subject, - "activity_type": "EMAIL", - "activity_direction": "INBOUND", - "activity_platform": "RESEND", - "activity_contact_id": contacts[0].contact_id, - "activity_summary": body[:500], - "activity_occurred_ts": time.time(), + "contact_email": addr.lower(), + "contact_first_name": parts[0], + "contact_last_name": parts[1] if len(parts) > 1 else "(unknown)", }) + await ckit_erp.create_erp_record(fclient, "crm_activity", rcx.persona.ws_id, { + "ws_id": rcx.persona.ws_id, + "activity_title": em.subject, + "activity_type": "EMAIL", + "activity_direction": "INBOUND", + "activity_platform": "RESEND", + "activity_contact_id": contact_id, + "activity_summary": body[:500], + "activity_occurred_ts": time.time(), + }) except Exception as e: - logger.warning("Failed to create CRM activity for inbound email from %s: %s", email.from_addr, e) - if not email_respond_to.intersection(a.lower() for a in email.to_addrs): + logger.warning("Failed to create CRM activity for inbound email from %s: %s", em.from_addr, e) + if not email_respond_to.intersection(a.lower() for a in em.to_addrs): return - title = "Email from %s: %s" % (email.from_addr, email.subject) - if email.cc_addrs: - title += " (cc: %s)" % ", ".join(email.cc_addrs) + title = "Email from %s: %s" % (em.from_addr, em.subject) + if em.cc_addrs: + title += " (cc: %s)" % ", ".join(em.cc_addrs) await ckit_kanban.bot_kanban_post_into_inbox( fclient, rcx.persona.persona_id, - title=title, details_json=json.dumps({"from": email.from_addr, "to": email.to_addrs, "cc": email.cc_addrs, "subject": email.subject, "body": body[:2000]}), + title=title, details_json=json.dumps({"from": em.from_addr, "to": em.to_addrs, "cc": em.cc_addrs, "subject": em.subject, "body": body[:2000]}), provenance_message="vix_email_inbound", ) From c4c3d31d85c5834b5feb7d23973805d360bd04af Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Tue, 17 Feb 2026 09:51:29 +0100 Subject: [PATCH 08/10] minor rename: ieql -> cieql --- flexus_client_kit/ckit_erp.py | 4 ++-- flexus_client_kit/integrations/fi_erp.py | 4 ++-- flexus_client_kit/integrations/fi_gmail.py | 2 +- flexus_simple_bots/vix/vix_bot.py | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/flexus_client_kit/ckit_erp.py b/flexus_client_kit/ckit_erp.py index 5ab7d484..d92ce455 100644 --- a/flexus_client_kit/ckit_erp.py +++ b/flexus_client_kit/ckit_erp.py @@ -190,7 +190,7 @@ def check_record_matches_filter(record: dict, f: str, col_names: set = None) -> Check if a single record matches a single filter string. Filter format: "col:op:val" or "col:op" - Standard operators: =, !=, >, >=, <, <=, IN, NOT_IN, LIKE, ILIKE, IEQL, IS_NULL, IS_NOT_NULL, IS_EMPTY, IS_NOT_EMPTY + Standard operators: =, !=, >, >=, <, <=, IN, NOT_IN, LIKE, ILIKE, CIEQL, IS_NULL, IS_NOT_NULL, IS_EMPTY, IS_NOT_EMPTY Array operators: contains, not_contains JSON path: "task_details->email_subtype:=:welcome" """ @@ -295,7 +295,7 @@ def check_record_matches_filter(record: dict, f: str, col_names: set = None) -> if op in ("NOT_IN", "NOT IN"): vals = [v.strip() for v in filter_val.split(",")] return str(val) not in vals - if op == "IEQL": + if op == "CIEQL": return str(val).lower() == filter_val.lower() if op in ("LIKE", "ILIKE"): s = str(val).lower() if op == "ILIKE" else str(val) diff --git a/flexus_client_kit/integrations/fi_erp.py b/flexus_client_kit/integrations/fi_erp.py index cb2828cb..1a821e22 100644 --- a/flexus_client_kit/integrations/fi_erp.py +++ b/flexus_client_kit/integrations/fi_erp.py @@ -38,8 +38,8 @@ name="erp_table_data", description=( "Query ERP table data with filtering. " - "Operators: =, !=, >, >=, <, <=, LIKE, ILIKE, IEQL, IN, NOT_IN, IS_NULL, IS_NOT_NULL. " - "LIKE/ILIKE use SQL wildcards: % matches any chars. IEQL is case-insensitive equals (no wildcards). " + "Operators: =, !=, >, >=, <, <=, LIKE, ILIKE, CIEQL, IN, NOT_IN, IS_NULL, IS_NOT_NULL. " + "LIKE/ILIKE use SQL wildcards: % matches any chars. CIEQL: Case Insensitive Equal. " "JSON path: details->subtype:=:welcome. " "Examples: " 'filters="status:=:active" for single filter, ' diff --git a/flexus_client_kit/integrations/fi_gmail.py b/flexus_client_kit/integrations/fi_gmail.py index 777fd701..03d2cea7 100644 --- a/flexus_client_kit/integrations/fi_gmail.py +++ b/flexus_client_kit/integrations/fi_gmail.py @@ -326,7 +326,7 @@ async def _create_activity_for_email(self, to: str, subject: str, body: str, ft_ try: contacts = await ckit_erp.query_erp_table( self.fclient, "crm_contact", self.rcx.persona.ws_id, erp_schema.CrmContact, - filters=f"contact_email:IEQL:{email}", limit=1, + filters=f"contact_email:CIEQL:{email}", limit=1, ) if not contacts: continue diff --git a/flexus_simple_bots/vix/vix_bot.py b/flexus_simple_bots/vix/vix_bot.py index ac8bc04f..65d4bb6a 100644 --- a/flexus_simple_bots/vix/vix_bot.py +++ b/flexus_simple_bots/vix/vix_bot.py @@ -92,7 +92,7 @@ async def handle_email(emsg): addr = addr or em.from_addr contacts = await ckit_erp.query_erp_table( fclient, "crm_contact", rcx.persona.ws_id, erp_schema.CrmContact, - filters=f"contact_email:IEQL:{addr}", limit=1, + filters=f"contact_email:CIEQL:{addr}", limit=1, ) if contacts: contact_id = contacts[0].contact_id @@ -114,8 +114,8 @@ async def handle_email(emsg): "activity_summary": body[:500], "activity_occurred_ts": time.time(), }) - except Exception as e: - logger.warning("Failed to create CRM activity for inbound email from %s: %s", em.from_addr, e) + except Exception: + logger.exception("Failed to create CRM activity for inbound email from %s", em.from_addr) if not email_respond_to.intersection(a.lower() for a in em.to_addrs): return title = "Email from %s: %s" % (em.from_addr, em.subject) From 0185494bb05366cba671a4dbc71def7e739fb808 Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Tue, 17 Feb 2026 11:13:32 +0100 Subject: [PATCH 09/10] vix email guardrail in prompt --- flexus_simple_bots/vix/vix_prompts.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/flexus_simple_bots/vix/vix_prompts.py b/flexus_simple_bots/vix/vix_prompts.py index bc3fe299..9900af53 100644 --- a/flexus_simple_bots/vix/vix_prompts.py +++ b/flexus_simple_bots/vix/vix_prompts.py @@ -1,6 +1,26 @@ from flexus_simple_bots import prompts_common from flexus_client_kit.integrations import fi_crm_automations, fi_messenger, fi_resend +EMAIL_GUARDRAILS = """ +## Email Guardrails + +NEVER send unsolicited marketing emails to contacts who haven't opted in. Sending spam gets the domain banned permanently. + +Allowed emails: +- Transactional: order confirmations, receipts, shipping updates, password resets +- User-initiated: contact form follow-ups, demo requests, quote requests +- Welcome emails: to contacts who just signed up or registered +- Replies: responding to inbound messages +- Follow-ups: to contacts who previously engaged (had a conversation, requested info) + +Forbidden: +- Cold outreach to purchased/scraped lists +- Mass campaigns to contacts who never interacted with the business +- Bulk promotional emails without prior opt-in + +When in doubt, don't send bulk emails. One wrong bulk email can permanently destroy the sender domain. +""" + vix_prompt_sales = f""" # Elite AI Sales Agent @@ -1078,6 +1098,8 @@ Keep communication natural and business-focused. Don't mention technical details like "ERP" or file paths. +{EMAIL_GUARDRAILS} + ## CRM Usage Use erp_table_*() tools to interact with the CRM. @@ -1163,6 +1185,8 @@ 3. If no reply/response (CRM Activity in Inbound direction, after last Outbound contact/conversation), send follow-up 4. Activities are logged automatically +{EMAIL_GUARDRAILS} + ## Execution Style - Act immediately, don't overthink From 3ab3adcdf4fd1a7922c96d27c1f10adcc3db5add Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Tue, 17 Feb 2026 15:03:52 +0100 Subject: [PATCH 10/10] remove unused code and improve help --- flexus_client_kit/integrations/fi_resend.py | 35 ++------------------- flexus_simple_bots/vix/vix_bot.py | 3 -- 2 files changed, 2 insertions(+), 36 deletions(-) diff --git a/flexus_client_kit/integrations/fi_resend.py b/flexus_client_kit/integrations/fi_resend.py index 27d42091..6d00c0ef 100644 --- a/flexus_client_kit/integrations/fi_resend.py +++ b/flexus_client_kit/integrations/fi_resend.py @@ -72,7 +72,8 @@ SETUP_HELP = """Email domain setup: email_setup_domain(op="add", args={"domain": "yourdomain.com", "region": "us-east-1", "enable_receiving": true}) - Register a domain. Returns DNS records to configure. + Register a domain or update an existing one. Returns DNS records to configure. + If the domain already exists, updates its settings (receiving, etc.). Ask the user which region they prefer and whether they want to enable receiving emails. Regions: us-east-1, eu-west-1, sa-east-1, ap-northeast-1. @@ -137,38 +138,6 @@ def parse_emessage(emsg: ckit_bot_query.FExternalMessageOutput) -> ActivityEmail ) -async def register_email_addresses( - fclient: ckit_client.FlexusClient, - rcx: ckit_bot_exec.RobotContext, - email_addresses: List[str], -) -> None: - txt_val = f"flexus-verify={rcx.persona.ws_id}" - verified = [] - for a in email_addresses: - domain = a.rsplit("@", 1)[1] if "@" in a else a - if RESEND_TESTING_DOMAIN and domain == RESEND_TESTING_DOMAIN: - verified.append(a) - elif await _check_dns_txt(domain, txt_val): - verified.append(a) - else: - logger.warning("address %s failed TXT ownership check, not registering", a) - if not verified: - return - http = await fclient.use_http() - async with http as h: - await h.execute( - gql.gql("""mutation ResendRegister($persona_id: String!, $channel: String!, $addresses: [String!]!) { - persona_set_external_addresses(persona_id: $persona_id, channel: $channel, addresses: $addresses) - }"""), - variable_values={ - "persona_id": rcx.persona.persona_id, - "channel": "EMAIL", - "addresses": [f"EMAIL:{a}" for a in verified], - }, - ) - logger.info("registered email addresses %s for persona %s", verified, rcx.persona.persona_id) - - class IntegrationResend: def __init__(self, fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext, domains: Dict[str, str], emails_to_register: set): diff --git a/flexus_simple_bots/vix/vix_bot.py b/flexus_simple_bots/vix/vix_bot.py index 65d4bb6a..860b72ff 100644 --- a/flexus_simple_bots/vix/vix_bot.py +++ b/flexus_simple_bots/vix/vix_bot.py @@ -65,9 +65,6 @@ def get_setup(): resend_domains = (rcx.persona.persona_setup or {}).get("DOMAINS", {}) email_respond_to = set(a.strip().lower() for a in get_setup().get("EMAIL_RESPOND_TO", "").split(",") if a.strip()) resend_integration = fi_resend.IntegrationResend(fclient, rcx, resend_domains, email_respond_to) - email_reg = [f"*@{d}" for d in resend_domains] + list(email_respond_to) - if email_reg: - await fi_resend.register_email_addresses(fclient, rcx, email_reg) telegram = fi_telegram.IntegrationTelegram(fclient, rcx, get_setup().get("TELEGRAM_BOT_TOKEN", "")) await telegram.register_webhook_and_start()