From 1fd02f66422e984a34c80bdc6851e7dc662e6b5b Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Tue, 10 Feb 2026 19:14:55 +0100 Subject: [PATCH 1/4] fi_resend 1st version --- flexus_client_kit/integrations/fi_resend.py | 304 ++++++++++++++++++ flexus_client_kit/integrations/fi_telegram.py | 2 +- 2 files changed, 305 insertions(+), 1 deletion(-) 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 0000000..e96b369 --- /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), + }, + ) diff --git a/flexus_client_kit/integrations/fi_telegram.py b/flexus_client_kit/integrations/fi_telegram.py index fe97d2d..7129824 100644 --- a/flexus_client_kit/integrations/fi_telegram.py +++ b/flexus_client_kit/integrations/fi_telegram.py @@ -125,7 +125,7 @@ async def _register_and_set_webhook(self, bot_id: str) -> None: variable_values={ "persona_id": self.rcx.persona.persona_id, "channel": "TELEGRAM", - "addresses": [f"telegram:{bot_id}"], + "addresses": [f"TELEGRAM:{bot_id}"], }, ) logger.info("Registered telegram:%s for persona %s", bot_id, self.rcx.persona.persona_id) From e815627f72f29c0f1abb629bd459c976cf3e3865 Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Tue, 10 Feb 2026 19:15:03 +0100 Subject: [PATCH 2/4] vix with resend --- flexus_simple_bots/vix/vix_bot.py | 55 +++++++++++++++++++++++---- flexus_simple_bots/vix/vix_install.py | 12 +++++- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/flexus_simple_bots/vix/vix_bot.py b/flexus_simple_bots/vix/vix_bot.py index a88d380..b5e1793 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 = await fi_telegram.IntegrationTelegram.create(fclient, rcx, get_setup().get("TELEGRAM_BOT_TOKEN", "")) @rcx.on_updated_message @@ -72,6 +80,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) @@ -105,9 +146,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: @@ -164,7 +205,7 @@ def main(): scenario_fn=scenario_fn, install_func=vix_install.install, subscribe_to_erp_tables=ERP_TABLES, - subscribe_to_emsg_types=["TELEGRAM"], + subscribe_to_emsg_types=["EMAIL", "TELEGRAM"], )) diff --git a/flexus_simple_bots/vix/vix_install.py b/flexus_simple_bots/vix/vix_install.py index 00e0a86..2eb795f 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 ddaa5b794b47a4268d8b0374421a763507765da0 Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Tue, 10 Feb 2026 22:33:51 +0100 Subject: [PATCH 3/4] 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 e96b369..60cd19b 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 b5e1793..3d60691 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 4a87f2c..bc3fe29 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 f599f0b50a28de78031b55dbc5be25059b3042d4 Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Wed, 11 Feb 2026 18:55:12 +0100 Subject: [PATCH 4/4] 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 60cd19b..c9d79b0 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"