Skip to content

Commit 06c4aa2

Browse files
feat(mailbox): new /v5/mailbox area module (#196)
* feat(mailbox): new /v5/mailbox area module Adds the mailbox area (Gandi's current mailbox product) as a full module. Distinct from the legacy email area (/v5/email, gandi_email_*) — every tool docstring disambiguates so an agent picks the right product. Reads (9): list_domains, get_domain, list_mailboxes, get_mailbox, list_forwards, list_slots, get_slot, get_quotas, list_products Writes (6): validate_domain, update_mailbox, delete_mailbox, create_forward, update_forward, delete_forward Purchases (3): create_mailbox, renew_mailbox, buy_product (gated, default-off) New tools/mailbox.py with read/write/purchase register fns wired into register_read_tools / register_write_tools; tags {"gandi","mailbox"} (+write/purchase) so server.py applies the cascading visibility gates. Purchase handlers double-gate and are added to the safety-gate allowlist. Client methods use _seg(); write bodies use dict pass-through. Adds respx tests and regenerates the tool-schema matrix. The forwards endpoints without a {source} segment are not wrapped (ambiguous in the RAML — confirm at cassette time). Cassette recording deferred to #102. Closes #167 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(mailbox): cover mailbox tool handlers to >=90% Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(mailbox): expose pagination on list_mailboxes/list_forwards The list tools forwarded no query params, so per_page/page could never be supplied despite the client accepting them. Mirror the email area's list tools. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9eb208e commit 06c4aa2

7 files changed

Lines changed: 1019 additions & 1 deletion

File tree

‎docs/tool-schema-matrix.md‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Tiers follow the three-tier safety model: **read** (always visible), **write**
1313
(needs `GANDI_MODE=readwrite`), **purchase** (also needs `GANDI_ALLOW_PURCHASES=true`).
1414
Destructive and open-world columns reflect the tool's MCP annotation hints.
1515

16-
**159 tools** — 69 read, 80 write, 10 purchase.
16+
**177 tools** — 78 read, 86 write, 13 purchase.
1717

1818
| Area | Tier | Tool | Destructive | Open-world | Client method | Gandi v5 endpoint |
1919
|------|------|------|:-----------:|:----------:|---------------|-------------------|
@@ -148,6 +148,24 @@ Destructive and open-world columns reflect the tool's MCP annotation hints.
148148
| livedns | write | `gandi_livedns_update_domain` | no | no | `livedns_patch_domain` | `PATCH /v5/livedns/domains/{fqdn}` |
149149
| livedns | write | `gandi_livedns_update_record` | no | no | `livedns_update_record` | `PATCH /v5/livedns/domains/{fqdn}/records/{name}/{rrset_type}` |
150150
| livedns | write | `gandi_livedns_update_snapshot` | no | no | `livedns_update_snapshot` | `PATCH /v5/livedns/domains/{fqdn}/snapshots/{snapshot_id}` |
151+
| mailbox | read | `gandi_mailbox_get_domain` | no | no | `mailbox_get_domain` | `GET /v5/mailbox/domains/{domain}` |
152+
| mailbox | read | `gandi_mailbox_get_mailbox` | no | no | `mailbox_get_mailbox` | `GET /v5/mailbox/mailboxes/{email}` |
153+
| mailbox | read | `gandi_mailbox_get_quotas` | no | no | `mailbox_get_quotas` | `GET /v5/mailbox/quotas` |
154+
| mailbox | read | `gandi_mailbox_get_slot` | no | no | `mailbox_get_slot` | `GET /v5/mailbox/slots/{slot_id}` |
155+
| mailbox | read | `gandi_mailbox_list_domains` | no | no | `mailbox_list_domains` | `GET /v5/mailbox/domains` |
156+
| mailbox | read | `gandi_mailbox_list_forwards` | no | no | `mailbox_list_forwards` | `GET /v5/mailbox/forwards` |
157+
| mailbox | read | `gandi_mailbox_list_mailboxes` | no | no | `mailbox_list_mailboxes` | `GET /v5/mailbox/mailboxes` |
158+
| mailbox | read | `gandi_mailbox_list_products` | no | no | `mailbox_list_products` | `GET /v5/mailbox/products` |
159+
| mailbox | read | `gandi_mailbox_list_slots` | no | no | `mailbox_list_slots` | `GET /v5/mailbox/slots` |
160+
| mailbox | write | `gandi_mailbox_create_forward` | no | no | `mailbox_create_forward` | `POST /v5/mailbox/forwards` |
161+
| mailbox | write | `gandi_mailbox_delete_forward` | yes | no | `mailbox_delete_forward` | `DELETE /v5/mailbox/forwards/{source}` |
162+
| mailbox | write | `gandi_mailbox_delete_mailbox` | yes | no | `mailbox_delete_mailbox` | `DELETE /v5/mailbox/mailboxes/{email}` |
163+
| mailbox | write | `gandi_mailbox_update_forward` | no | no | `mailbox_update_forward` | `PUT /v5/mailbox/forwards/{source}` |
164+
| mailbox | write | `gandi_mailbox_update_mailbox` | no | no | `mailbox_update_mailbox` | `PATCH /v5/mailbox/mailboxes/{email}` |
165+
| mailbox | write | `gandi_mailbox_validate_domain` | no | no | `mailbox_validate_domain` | `POST /v5/mailbox/domains/{domain}/validate` |
166+
| mailbox | purchase | `gandi_mailbox_buy_product` | no | yes | `mailbox_buy_product` | `POST /v5/mailbox/products` |
167+
| mailbox | purchase | `gandi_mailbox_create_mailbox` | no | yes | `mailbox_create_mailbox` | `POST /v5/mailbox/mailboxes` |
168+
| mailbox | purchase | `gandi_mailbox_renew_mailbox` | no | yes | `mailbox_renew_mailbox` | `POST /v5/mailbox/mailboxes/{email}/renew` |
151169
| organization | read | `gandi_org_get_customer` | no | no | `get_customer` | `GET /v5/organization/organizations/{org_id}/customers/{customer_id}` |
152170
| organization | read | `gandi_org_get_organization` | no | no | `get_organization` | `GET /v5/organization/organizations/{org_id}` |
153171
| organization | read | `gandi_org_get_user_info` | no | no | `get_user_info` | `GET /v5/organization/user-info` |

‎src/gandi_mcp/clients/gandi.py‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -980,6 +980,106 @@ async def simplehosting_update_instance(self, instance_id: str, data: dict[str,
980980
result: dict[str, Any] = await self.patch(f"/v5/simplehosting/instances/{_seg(instance_id)}", json=data)
981981
return result
982982

983+
# ═════════════════════════════════════════════════════════════════════
984+
# Mailbox (/v5/mailbox) — current mailbox product, distinct from /v5/email
985+
# ═════════════════════════════════════════════════════════════════════
986+
987+
async def mailbox_list_domains(self) -> list[dict[str, Any]]:
988+
"""List domains enabled for the mailbox product."""
989+
result: list[dict[str, Any]] = await self.get("/v5/mailbox/domains")
990+
return result
991+
992+
async def mailbox_get_domain(self, domain: str) -> dict[str, Any]:
993+
"""Get mailbox-product info for a domain."""
994+
result: dict[str, Any] = await self.get(f"/v5/mailbox/domains/{_seg(domain)}")
995+
return result
996+
997+
async def mailbox_validate_domain(self, domain: str, data: dict[str, Any]) -> dict[str, Any]:
998+
"""Validate a domain's mailbox configuration (pass-through payload)."""
999+
result: dict[str, Any] = await self.post(f"/v5/mailbox/domains/{_seg(domain)}/validate", json=data)
1000+
return result
1001+
1002+
async def mailbox_list_mailboxes(self, **params: Any) -> list[dict[str, Any]]:
1003+
"""List mailboxes."""
1004+
result: list[dict[str, Any]] = await self.get(
1005+
"/v5/mailbox/mailboxes",
1006+
params={k: v for k, v in params.items() if v is not None},
1007+
)
1008+
return result
1009+
1010+
async def mailbox_create_mailbox(self, data: dict[str, Any]) -> dict[str, Any]:
1011+
"""Create a mailbox (SPENDS MONEY)."""
1012+
result: dict[str, Any] = await self.post("/v5/mailbox/mailboxes", json=data)
1013+
return result
1014+
1015+
async def mailbox_get_mailbox(self, email: str) -> dict[str, Any]:
1016+
"""Get a single mailbox by address."""
1017+
result: dict[str, Any] = await self.get(f"/v5/mailbox/mailboxes/{_seg(email)}")
1018+
return result
1019+
1020+
async def mailbox_update_mailbox(self, email: str, data: dict[str, Any]) -> dict[str, Any]:
1021+
"""Update a mailbox (pass-through payload)."""
1022+
result: dict[str, Any] = await self.patch(f"/v5/mailbox/mailboxes/{_seg(email)}", json=data)
1023+
return result
1024+
1025+
async def mailbox_delete_mailbox(self, email: str) -> dict[str, Any]:
1026+
"""Delete a mailbox."""
1027+
result: dict[str, Any] = await self.delete(f"/v5/mailbox/mailboxes/{_seg(email)}")
1028+
return result
1029+
1030+
async def mailbox_renew_mailbox(self, email: str, data: dict[str, Any]) -> dict[str, Any]:
1031+
"""Renew a mailbox (SPENDS MONEY)."""
1032+
result: dict[str, Any] = await self.post(f"/v5/mailbox/mailboxes/{_seg(email)}/renew", json=data)
1033+
return result
1034+
1035+
async def mailbox_list_forwards(self, **params: Any) -> list[dict[str, Any]]:
1036+
"""List mailbox forwards."""
1037+
result: list[dict[str, Any]] = await self.get(
1038+
"/v5/mailbox/forwards",
1039+
params={k: v for k, v in params.items() if v is not None},
1040+
)
1041+
return result
1042+
1043+
async def mailbox_create_forward(self, data: dict[str, Any]) -> dict[str, Any]:
1044+
"""Create a mailbox forward (pass-through payload)."""
1045+
result: dict[str, Any] = await self.post("/v5/mailbox/forwards", json=data)
1046+
return result
1047+
1048+
async def mailbox_update_forward(self, source: str, data: dict[str, Any]) -> dict[str, Any]:
1049+
"""Update a mailbox forward by source address (pass-through payload)."""
1050+
result: dict[str, Any] = await self.put(f"/v5/mailbox/forwards/{_seg(source)}", json=data)
1051+
return result
1052+
1053+
async def mailbox_delete_forward(self, source: str) -> dict[str, Any]:
1054+
"""Delete a mailbox forward by source address."""
1055+
result: dict[str, Any] = await self.delete(f"/v5/mailbox/forwards/{_seg(source)}")
1056+
return result
1057+
1058+
async def mailbox_list_slots(self) -> list[dict[str, Any]]:
1059+
"""List mailbox slots."""
1060+
result: list[dict[str, Any]] = await self.get("/v5/mailbox/slots")
1061+
return result
1062+
1063+
async def mailbox_get_slot(self, slot_id: str) -> dict[str, Any]:
1064+
"""Get a single mailbox slot by id."""
1065+
result: dict[str, Any] = await self.get(f"/v5/mailbox/slots/{_seg(slot_id)}")
1066+
return result
1067+
1068+
async def mailbox_get_quotas(self) -> dict[str, Any]:
1069+
"""Get mailbox quota usage for the account."""
1070+
result: dict[str, Any] = await self.get("/v5/mailbox/quotas")
1071+
return result
1072+
1073+
async def mailbox_list_products(self) -> list[dict[str, Any]]:
1074+
"""List mailbox products available for purchase."""
1075+
result: list[dict[str, Any]] = await self.get("/v5/mailbox/products")
1076+
return result
1077+
1078+
async def mailbox_buy_product(self, data: dict[str, Any]) -> dict[str, Any]:
1079+
"""Buy a mailbox product / slot (SPENDS MONEY)."""
1080+
result: dict[str, Any] = await self.post("/v5/mailbox/products", json=data)
1081+
return result
1082+
9831083
# ═════════════════════════════════════════════════════════════════════
9841084
# Linked zones (/v5/linkedzone)
9851085
# ═════════════════════════════════════════════════════════════════════

‎src/gandi_mcp/tools/__init__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def register_read_tools(mcp: FastMCP) -> None:
2525
from gandi_mcp.tools.email import register_email_read_tools
2626
from gandi_mcp.tools.linkedzone import register_linkedzone_read_tools
2727
from gandi_mcp.tools.livedns import register_livedns_read_tools
28+
from gandi_mcp.tools.mailbox import register_mailbox_read_tools
2829
from gandi_mcp.tools.organization import register_organization_read_tools
2930
from gandi_mcp.tools.simplehosting import register_simplehosting_read_tools
3031
from gandi_mcp.tools.template import register_template_read_tools
@@ -37,6 +38,7 @@ def register_read_tools(mcp: FastMCP) -> None:
3738
register_certificate_read_tools(mcp)
3839
register_comment_read_tools(mcp)
3940
register_linkedzone_read_tools(mcp)
41+
register_mailbox_read_tools(mcp)
4042
register_simplehosting_read_tools(mcp)
4143
register_template_read_tools(mcp)
4244

@@ -65,6 +67,10 @@ def register_write_tools(mcp: FastMCP) -> None:
6567
)
6668
from gandi_mcp.tools.linkedzone import register_linkedzone_write_tools
6769
from gandi_mcp.tools.livedns import register_livedns_write_tools
70+
from gandi_mcp.tools.mailbox import (
71+
register_mailbox_purchase_tools,
72+
register_mailbox_write_tools,
73+
)
6874
from gandi_mcp.tools.organization import register_organization_write_tools
6975
from gandi_mcp.tools.simplehosting import (
7076
register_simplehosting_purchase_tools,
@@ -82,6 +88,8 @@ def register_write_tools(mcp: FastMCP) -> None:
8288
register_certificate_purchase_tools(mcp)
8389
register_comment_write_tools(mcp)
8490
register_linkedzone_write_tools(mcp)
91+
register_mailbox_write_tools(mcp)
92+
register_mailbox_purchase_tools(mcp)
8593
register_simplehosting_write_tools(mcp)
8694
register_simplehosting_purchase_tools(mcp)
8795
register_template_write_tools(mcp)

0 commit comments

Comments
 (0)