refactor(channels): make built-in channels self-contained (#4908)

* refactor(channels): own setup and instance contracts

* refactor(channels): isolate management contracts

* refactor(channels): normalize activation contracts

* fix(channels): enforce management contracts

* refactor(channels): finish setup ownership migration

* fix(channels): harden management contracts

* fix(channels): enforce lazy loading and runtime ownership

* fix(feishu): make multi-instance startup idempotent

* fix(webui): render channel setup contracts cleanly

* fix(feishu): stop websocket clients cleanly

* fix(channels): enforce persistence and activation gates

* fix(channels): preserve global feature action scope

* fix(channels): apply defaults for single plugins

* fix(channels): enforce management contract boundaries

* refactor(feishu): remove identity helper indirection

* fix(channels): preserve management setup contracts

* refactor(channels): generalize instance settings UI

* refactor(channels): package channel plugins with web UI metadata

* refactor(channels): make built-ins self-contained packages

* test(channels): colocate tests with channel packages

* fix(dingtalk): use official brand icon

* feat(channels): colocate webui translations

* docs(channels): clarify plugin ownership

* test(exec): remove output wait race

* refactor(channels): unify plugin descriptors

* fix(channels): enforce descriptor-owned contracts

* refactor(channels): finish package-owned plugin setup

* refactor(channels): use repository-owned packages only

* fix(channels): self-describe dependencies and runtime state

* fix(channels): warn about legacy entry points
This commit is contained in:
chengyongru
2026-07-19 23:30:49 +08:00
committed by GitHub
parent 7aaac37bca
commit 462a0dfb0f
388 changed files with 17093 additions and 5110 deletions
+1
View File
@@ -0,0 +1 @@
"""Email channel package."""
+44
View File
@@ -0,0 +1,44 @@
"""Email management contract."""
from nanobot.channels._manifest import field, required_fields
from nanobot.channels.contracts import ChannelSetupSpec
from nanobot.channels.email.validation import validate
from nanobot.channels.plugin import ChannelPlugin
SETUP_SPEC = ChannelSetupSpec(
fields={
"consentGranted": field("bool", default=False),
"imapHost": field(),
"imapPort": field("int", default=993),
"imapUsername": field(),
"imapPassword": field("secret"),
"smtpHost": field(),
"smtpPort": field("int", default=587),
"smtpUsername": field(),
"smtpPassword": field("secret"),
"fromAddress": field(),
"pollIntervalSeconds": field("int", default=30),
"allowFrom": field("list"),
"verifyDkim": field("bool", default=True),
"verifySpf": field("bool", default=True),
},
required=required_fields(
"consentGranted",
"imapHost",
"imapUsername",
"imapPassword",
"smtpHost",
"smtpUsername",
"smtpPassword",
),
official_url="https://support.google.com/accounts/answer/185833",
validator=validate,
)
PLUGIN = ChannelPlugin(
name="email",
display_name="Email",
runtime=f"{__package__}.runtime:EmailChannel",
setup=SETUP_SPEC,
webui="webui/index.ts",
)
+915
View File
@@ -0,0 +1,915 @@
"""Email channel implementation using IMAP polling + SMTP replies."""
import asyncio
import html
import imaplib
import mimetypes
import re
import smtplib
import ssl
from contextlib import suppress
from dataclasses import dataclass
from datetime import date
from email import policy
from email.header import decode_header, make_header
from email.message import EmailMessage
from email.parser import BytesParser
from email.utils import parseaddr
from fnmatch import fnmatch
from pathlib import Path
from typing import Any, Literal
from loguru import logger
from pydantic import Field
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from nanobot.utils.helpers import safe_filename
class EmailConfig(Base):
"""Email channel configuration (IMAP inbound + SMTP outbound)."""
enabled: bool = False
consent_granted: bool = False
imap_host: str = ""
imap_port: int = 993
imap_username: str = ""
imap_password: str = ""
imap_mailbox: str = "INBOX"
imap_use_ssl: bool = True
smtp_host: str = ""
smtp_port: int = 587
smtp_username: str = ""
smtp_password: str = ""
smtp_use_tls: bool = True
smtp_use_ssl: bool = False
from_address: str = ""
auto_reply_enabled: bool = True
poll_interval_seconds: int = 30
mark_seen: bool = True
post_action: Literal["delete", "move"] | None = None
post_action_move_mailbox: str | None = None
post_action_expunge: bool = False
post_action_ignore_skipped: bool = True
max_body_chars: int = 12000
subject_prefix: str = "Re: "
allow_from: list[str] = Field(default_factory=list)
# Email authentication verification (anti-spoofing)
verify_dkim: bool = True # Require Authentication-Results with dkim=pass
verify_spf: bool = True # Require Authentication-Results with spf=pass
# Attachment handling — set allowed types to enable (e.g. ["application/pdf", "image/*"], or ["*"] for all)
allowed_attachment_types: list[str] = Field(default_factory=list)
max_attachment_size: int = 2_000_000 # 2MB per attachment
max_attachments_per_email: int = 5
@dataclass
class _ServerFeatures:
move: bool
uidplus: bool
uid_store: bool | None = None
class EmailChannel(BaseChannel):
"""
Email channel.
Inbound:
- Poll IMAP mailbox for unread messages.
- Convert each message into an inbound event.
Outbound:
- Send responses via SMTP back to the sender address.
"""
name = "email"
display_name = "Email"
_IMAP_MONTHS = (
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
)
_IMAP_RECONNECT_MARKERS = (
"disconnected for inactivity",
"eof occurred in violation of protocol",
"socket error",
"connection reset",
"broken pipe",
"bye",
)
_IMAP_MISSING_MAILBOX_MARKERS = (
"mailbox doesn't exist",
"select failed",
"no such mailbox",
"can't open mailbox",
"does not exist",
)
@classmethod
def default_config(cls) -> dict[str, Any]:
return EmailConfig().model_dump(by_alias=True)
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = EmailConfig.model_validate(config)
super().__init__(config, bus)
self.config: EmailConfig = config
self._self_addresses = self._collect_self_addresses()
self._last_subject_by_chat: dict[str, str] = {}
self._last_message_id_by_chat: dict[str, str] = {}
self._processed_uids: set[str] = set() # Capped to prevent unbounded growth
self._MAX_PROCESSED_UIDS = 100000
async def start(self) -> None:
"""Start polling IMAP for inbound emails."""
if not self.config.consent_granted:
self.logger.warning(
"Email channel disabled: consent_granted is false. "
"Set channels.email.consentGranted=true after explicit user permission."
)
return
if not self._validate_config():
return
self._running = True
if not self.config.verify_dkim and not self.config.verify_spf:
self.logger.warning(
"DKIM and SPF verification are both DISABLED. "
"Emails with spoofed From headers will be accepted. "
"Set verify_dkim=true and verify_spf=true for anti-spoofing protection."
)
self.logger.info("Starting Email channel (IMAP polling mode)...")
poll_seconds = max(5, int(self.config.poll_interval_seconds))
while self._running:
try:
inbound_items, skipped_uids = await asyncio.to_thread(self._fetch_new_messages)
should_apply_post_action = self._should_apply_post_action()
post_actions_uids: set[str] = set()
for item in inbound_items:
sender = item["sender"]
subject = item.get("subject", "")
message_id = item.get("message_id", "")
if subject:
self._last_subject_by_chat[sender] = subject
if message_id:
self._last_message_id_by_chat[sender] = message_id
try:
await self._handle_message(
sender_id=sender,
chat_id=sender,
content=item["content"],
media=item.get("media") or None,
metadata=item.get("metadata", {}),
)
except Exception:
self.logger.exception("Error delivering email from {}", sender)
continue
uid = str((item.get("metadata") or {}).get("uid") or "")
if uid and should_apply_post_action:
post_actions_uids.add(uid)
if should_apply_post_action and not self.config.post_action_ignore_skipped:
post_actions_uids.update(skipped_uids)
if post_actions_uids:
await asyncio.to_thread(self._apply_post_actions_batch, sorted(post_actions_uids))
except Exception:
self.logger.exception("Polling error")
if not self._running:
break
await asyncio.sleep(poll_seconds)
async def stop(self) -> None:
"""Stop polling loop."""
self._running = False
async def send(self, msg: OutboundMessage) -> None:
"""Send email via SMTP."""
if not self.config.consent_granted:
self.logger.warning("Skip email send: consent_granted is false")
return
if not self.config.smtp_host:
self.logger.warning("SMTP host not configured")
return
# Skip progress messages to prevent sending an empty email after each tool call
if isinstance(msg.event, ProgressEvent):
self.logger.debug("Skip progress message to {}", msg.chat_id)
return
to_addr = msg.chat_id.strip()
if not to_addr:
self.logger.warning("Missing recipient address")
return
# Determine if this is a reply (recipient has sent us an email before)
is_reply = to_addr in self._last_subject_by_chat
force_send = bool((msg.metadata or {}).get("force_send"))
# autoReplyEnabled only controls automatic replies, not proactive sends
if is_reply and not self.config.auto_reply_enabled and not force_send:
self.logger.info("Skip automatic reply to {}: auto_reply_enabled is false", to_addr)
return
base_subject = self._last_subject_by_chat.get(to_addr, "nanobot reply")
subject = self._reply_subject(base_subject)
if msg.metadata and isinstance(msg.metadata.get("subject"), str):
override = msg.metadata["subject"].strip()
if override:
subject = override
attachments: list[tuple[bytes, str, str, str]] = []
failed_attachments: list[str] = []
max_attachment_size = max(0, int(self.config.max_attachment_size))
max_attachment_count = max(0, int(self.config.max_attachments_per_email))
for media_path in msg.media or []:
path = Path(media_path)
filename = path.name or "attachment"
if len(attachments) >= max_attachment_count:
failed_attachments.append(f"[attachment: {filename} - too many attachments]")
self.logger.warning("Attachment count limit reached, skipping: {}", media_path)
continue
if not path.is_file():
failed_attachments.append(f"[attachment: {filename} - send failed]")
self.logger.warning("Attachment not found, skipping: {}", media_path)
continue
try:
size = path.stat().st_size
if max_attachment_size <= 0 or size > max_attachment_size:
failed_attachments.append(f"[attachment: {filename} - too large]")
self.logger.warning(
"Attachment too large, skipping: {} ({} > {} bytes)",
media_path,
size,
max_attachment_size,
)
continue
data = path.read_bytes()
ctype, _ = mimetypes.guess_type(str(path))
if ctype is None:
ctype = "application/octet-stream"
maintype, subtype = ctype.split("/", 1)
attachments.append((data, maintype, subtype, filename))
self.logger.info("Attached file: {}", filename)
except Exception:
failed_attachments.append(f"[attachment: {filename} - send failed]")
self.logger.exception("Failed to attach file {}", media_path)
content = msg.content or ""
if failed_attachments:
fallback = "\n".join(failed_attachments)
content = f"{content.rstrip()}\n\n{fallback}" if content.strip() else fallback
email_msg = EmailMessage()
email_msg["From"] = self.config.from_address or self.config.smtp_username or self.config.imap_username
email_msg["To"] = to_addr
email_msg["Subject"] = subject
email_msg.set_content(content)
for data, maintype, subtype, filename in attachments:
email_msg.add_attachment(
data,
maintype=maintype,
subtype=subtype,
filename=filename,
)
in_reply_to = self._last_message_id_by_chat.get(to_addr)
if in_reply_to:
email_msg["In-Reply-To"] = in_reply_to
email_msg["References"] = in_reply_to
try:
await asyncio.to_thread(self._smtp_send, email_msg)
except Exception:
self.logger.exception("Error sending to {}", to_addr)
raise
def _validate_config(self) -> bool:
missing = []
if not self.config.imap_host:
missing.append("imap_host")
if not self.config.imap_username:
missing.append("imap_username")
if not self.config.imap_password:
missing.append("imap_password")
if not self.config.smtp_host:
missing.append("smtp_host")
if not self.config.smtp_username:
missing.append("smtp_username")
if not self.config.smtp_password:
missing.append("smtp_password")
if self.config.post_action == "move" and not (self.config.post_action_move_mailbox or "").strip():
missing.append("post_action_move_mailbox")
if missing:
self.logger.error("Channel not configured, missing: {}", ', '.join(missing))
return False
return True
def _smtp_send(self, msg: EmailMessage) -> None:
timeout = 30
if self.config.smtp_use_ssl:
with smtplib.SMTP_SSL(
self.config.smtp_host,
self.config.smtp_port,
timeout=timeout,
) as smtp:
smtp.login(self.config.smtp_username, self.config.smtp_password)
smtp.send_message(msg)
return
with smtplib.SMTP(self.config.smtp_host, self.config.smtp_port, timeout=timeout) as smtp:
if self.config.smtp_use_tls:
smtp.starttls(context=ssl.create_default_context())
smtp.login(self.config.smtp_username, self.config.smtp_password)
smtp.send_message(msg)
def _fetch_new_messages(self) -> tuple[list[dict[str, Any]], set[str]]:
"""Poll IMAP and return parsed unread messages plus skipped message UIDs."""
return self._fetch_messages(
search_criteria=("UNSEEN",),
mark_seen=self.config.mark_seen,
dedupe=True,
limit=0,
)
def fetch_messages_between_dates(
self,
start_date: date,
end_date: date,
limit: int = 20,
) -> list[dict[str, Any]]:
"""
Fetch messages in [start_date, end_date) by IMAP date search.
This is used for historical summarization tasks (e.g. "yesterday").
"""
if end_date <= start_date:
return []
messages, _ = self._fetch_messages(
search_criteria=(
"SINCE",
self._format_imap_date(start_date),
"BEFORE",
self._format_imap_date(end_date),
),
mark_seen=False,
dedupe=False,
limit=max(1, int(limit)),
)
return messages
def _fetch_messages(
self,
search_criteria: tuple[str, ...],
mark_seen: bool,
dedupe: bool,
limit: int,
) -> tuple[list[dict[str, Any]], set[str]]:
messages: list[dict[str, Any]] = []
skipped_uids: set[str] = set()
cycle_uids: set[str] = set()
for attempt in range(2):
try:
self._fetch_messages_once(
search_criteria,
mark_seen,
dedupe,
limit,
messages,
skipped_uids,
cycle_uids,
)
return messages, skipped_uids
except Exception as exc:
if attempt == 1 or not self._is_stale_imap_error(exc):
raise
self.logger.warning("IMAP connection went stale, retrying once: {}", exc)
return messages, skipped_uids
def _fetch_messages_once(
self,
search_criteria: tuple[str, ...],
mark_seen: bool,
dedupe: bool,
limit: int,
messages: list[dict[str, Any]],
skipped_uids: set[str],
cycle_uids: set[str],
) -> None:
"""Fetch messages by arbitrary IMAP search criteria."""
mailbox = self.config.imap_mailbox or "INBOX"
client = self._open_imap_client(mailbox=mailbox, missing_mailbox_ok=True)
if client is None:
return messages
try:
status, data = client.search(None, *search_criteria)
if status != "OK" or not data:
return messages
ids = data[0].split()
if limit > 0 and len(ids) > limit:
ids = ids[-limit:]
for imap_id in ids:
status, fetched = client.fetch(imap_id, "(BODY.PEEK[] UID)")
if status != "OK" or not fetched:
continue
raw_bytes = self._extract_message_bytes(fetched)
if raw_bytes is None:
continue
uid = self._extract_uid(fetched)
if uid and uid in cycle_uids:
continue
if dedupe and uid and uid in self._processed_uids:
continue
parsed = BytesParser(policy=policy.default).parsebytes(raw_bytes)
sender = parseaddr(parsed.get("From", ""))[1].strip().lower()
if not sender:
continue
if self._is_self_address(sender):
self.logger.info("From {} ignored: matches bot-owned address", sender)
self._remember_processed_uid(uid, dedupe, cycle_uids)
if mark_seen:
client.store(imap_id, "+FLAGS", "\\Seen")
if uid:
skipped_uids.add(uid)
continue
# --- Anti-spoofing: verify Authentication-Results ---
spf_pass, dkim_pass = self._check_authentication_results(parsed)
if self.config.verify_spf and not spf_pass:
self.logger.warning(
"From {} rejected: SPF verification failed "
"(no 'spf=pass' in Authentication-Results header)",
sender,
)
self._remember_processed_uid(uid, dedupe, cycle_uids)
if uid:
skipped_uids.add(uid)
continue
if self.config.verify_dkim and not dkim_pass:
self.logger.warning(
"From {} rejected: DKIM verification failed "
"(no 'dkim=pass' in Authentication-Results header)",
sender,
)
self._remember_processed_uid(uid, dedupe, cycle_uids)
if uid:
skipped_uids.add(uid)
continue
if not self.is_allowed(sender):
self._remember_processed_uid(uid, dedupe, cycle_uids)
if mark_seen:
client.store(imap_id, "+FLAGS", "\\Seen")
if uid:
skipped_uids.add(uid)
continue
subject = self._decode_header_value(parsed.get("Subject", ""))
date_value = parsed.get("Date", "")
message_id = parsed.get("Message-ID", "").strip()
body = self._extract_text_body(parsed)
if not body:
body = "(empty email body)"
body = body[: self.config.max_body_chars]
content = (
f"[EMAIL-CONTEXT] Email received.\n"
f"From: {sender}\n"
f"Subject: {subject}\n"
f"Date: {date_value}\n\n"
f"{body}"
)
# --- Attachment extraction ---
attachment_paths: list[str] = []
if self.config.allowed_attachment_types:
saved = self._extract_attachments(
parsed,
uid or "noid",
allowed_types=self.config.allowed_attachment_types,
max_size=self.config.max_attachment_size,
max_count=self.config.max_attachments_per_email,
)
for p in saved:
attachment_paths.append(str(p))
content += f"\n[attachment: {p.name} — saved to {p}]"
metadata = {
"message_id": message_id,
"subject": subject,
"date": date_value,
"sender_email": sender,
"uid": uid,
}
messages.append(
{
"sender": sender,
"subject": subject,
"message_id": message_id,
"content": content,
"metadata": metadata,
"media": attachment_paths,
}
)
self._remember_processed_uid(uid, dedupe, cycle_uids)
if mark_seen:
client.store(imap_id, "+FLAGS", "\\Seen")
finally:
self._close_imap_client(client)
def _open_imap_client(self, mailbox: str, *, missing_mailbox_ok: bool = False) -> Any | None:
if self.config.imap_use_ssl:
client: Any = imaplib.IMAP4_SSL(self.config.imap_host, self.config.imap_port)
else:
client = imaplib.IMAP4(self.config.imap_host, self.config.imap_port)
try:
client.login(self.config.imap_username, self.config.imap_password)
try:
status, _ = client.select(mailbox)
except Exception as exc:
if missing_mailbox_ok and self._is_missing_mailbox_error(exc):
self.logger.warning("Mailbox unavailable, skipping poll for {}: {}", mailbox, exc)
self._close_imap_client(client)
return None
raise
if status != "OK":
self.logger.warning("Mailbox select returned {}, skipping poll for {}", status, mailbox)
self._close_imap_client(client)
return None
except Exception:
self._close_imap_client(client)
raise
return client
@staticmethod
def _close_imap_client(client: Any) -> None:
with suppress(Exception):
client.logout()
def _collect_self_addresses(self) -> set[str]:
"""Return normalized email addresses owned by this channel instance."""
candidates = (
self.config.from_address,
self.config.smtp_username,
self.config.imap_username,
)
normalized = {
addr
for candidate in candidates
if (addr := self._normalize_address(candidate))
}
return normalized
@staticmethod
def _normalize_address(value: str) -> str:
"""Normalize an address or mailbox-like identifier for comparisons."""
raw = (value or "").strip()
if not raw:
return ""
parsed = parseaddr(raw)[1].strip().lower()
if parsed:
return parsed
if "@" in raw:
return raw.lower()
return ""
def _is_self_address(self, sender: str) -> bool:
"""Return True when an inbound sender belongs to the bot itself."""
normalized_sender = self._normalize_address(sender)
return bool(normalized_sender) and normalized_sender in self._self_addresses
def _remember_processed_uid(self, uid: str, dedupe: bool, cycle_uids: set[str]) -> None:
"""Track a fetched UID so skipped messages are not reprocessed forever."""
if not uid:
return
cycle_uids.add(uid)
if dedupe:
self._processed_uids.add(uid)
# mark_seen is the primary dedup; this set is a safety net
if len(self._processed_uids) > self._MAX_PROCESSED_UIDS:
# Evict a random half to cap memory; mark_seen is the primary dedup
self._processed_uids = set(list(self._processed_uids)[len(self._processed_uids) // 2:])
def _should_apply_post_action(self) -> bool:
return self.config.post_action in {"delete", "move"}
def _apply_post_actions_batch(self, post_actions_uids: list[str]) -> None:
if not self._should_apply_post_action() or not post_actions_uids:
return
mailbox = self.config.imap_mailbox or "INBOX"
client = self._open_imap_client(mailbox=mailbox)
if client is None:
return
try:
features = self._server_features(client)
# Apply all post-actions in one IMAP session. `features` also carries
# session-learned behavior (e.g. UID STORE support) so later UIDs can
# skip known-broken paths.
for uid in post_actions_uids:
if uid:
self._apply_post_action(client, uid, features)
finally:
self._close_imap_client(client)
def _apply_post_action(
self,
client: Any,
uid: str,
features: _ServerFeatures,
) -> None:
action = self.config.post_action
if action == "delete":
if not self._uid_store_deleted(client, uid, features):
return
self._uid_expunge_or_fallback(client, uid, features)
return
if action == "move":
target = (self.config.post_action_move_mailbox or "").strip()
if features.move:
status, _ = client.uid("MOVE", uid, target)
if status != "OK":
self.logger.warning("Post-action move failed (UID MOVE) for UID {} to mailbox {}", uid, target)
return
status, _ = client.uid("COPY", uid, target)
if status != "OK":
self.logger.warning("Post-action move failed (UID COPY) for UID {} to mailbox {}", uid, target)
return
if not self._uid_store_deleted(client, uid, features):
return
self._uid_expunge_or_fallback(client, uid, features)
@staticmethod
def _server_features(client: Any) -> _ServerFeatures:
caps: set[str] = set()
with suppress(Exception):
status, data = client.capability()
if status == "OK" and data:
for raw in data:
if isinstance(raw, (bytes, bytearray)):
caps.update(token.upper() for token in raw.decode("utf-8", errors="ignore").split())
elif isinstance(raw, str):
caps.update(token.upper() for token in raw.split())
return _ServerFeatures(move="MOVE" in caps, uidplus="UIDPLUS" in caps)
@staticmethod
def _lookup_imap_id_by_uid(client: Any, uid: str) -> bytes | None:
# IMAP exposes two message identifiers: UID (stable) and sequence number
# (session-local). We target by UID first, but some servers may reject
# UID STORE. In that case we resolve the current sequence number for the
# UID and retry with STORE using that sequence id.
status, data = client.search(None, "UID", uid)
if status != "OK" or not data or not data[0]:
return None
return data[0].split()[0]
def _uid_store_deleted(self, client: Any, uid: str, features: _ServerFeatures) -> bool:
# Optimistic path: try UID STORE first because UID is stable and avoids
# sequence-number lookup. If this fails once for the session, remember it
# and use the sequence STORE fallback directly for remaining UIDs.
if features.uid_store is not False:
status, _ = client.uid("STORE", uid, "+FLAGS", "(\\Deleted)")
if status == "OK":
features.uid_store = True
return True
features.uid_store = False
# Compatibility fallback for servers where UID STORE is unavailable or
# unreliable: resolve the current sequence number from UID and use STORE.
imap_id = self._lookup_imap_id_by_uid(client, uid)
if not imap_id:
self.logger.warning("Post-action skipped: UID {} not found", uid)
return False
status, _ = client.store(imap_id, "+FLAGS", "\\Deleted")
if status != "OK":
self.logger.warning("Post-action failed: could not mark UID {} as deleted", uid)
return False
return True
def _uid_expunge_or_fallback(self, client: Any, uid: str, features: _ServerFeatures) -> None:
# Prefer UID-scoped expunge when supported to avoid expunging unrelated
# messages already marked \Deleted in the selected mailbox.
if features.uidplus:
status, _ = client.uid("EXPUNGE", uid)
if status == "OK":
return
self.logger.warning("UID EXPUNGE failed for UID {}, falling back to EXPUNGE", uid)
if self.config.post_action_expunge:
client.expunge()
@classmethod
def _is_stale_imap_error(cls, exc: Exception) -> bool:
message = str(exc).lower()
return any(marker in message for marker in cls._IMAP_RECONNECT_MARKERS)
@classmethod
def _is_missing_mailbox_error(cls, exc: Exception) -> bool:
message = str(exc).lower()
return any(marker in message for marker in cls._IMAP_MISSING_MAILBOX_MARKERS)
@classmethod
def _format_imap_date(cls, value: date) -> str:
"""Format date for IMAP search (always English month abbreviations)."""
month = cls._IMAP_MONTHS[value.month - 1]
return f"{value.day:02d}-{month}-{value.year}"
@staticmethod
def _extract_message_bytes(fetched: list[Any]) -> bytes | None:
for item in fetched:
if isinstance(item, tuple) and len(item) >= 2 and isinstance(item[1], (bytes, bytearray)):
return bytes(item[1])
return None
@staticmethod
def _extract_uid(fetched: list[Any]) -> str:
for item in fetched:
if isinstance(item, tuple) and item and isinstance(item[0], (bytes, bytearray)):
head = bytes(item[0]).decode("utf-8", errors="ignore")
m = re.search(r"UID\s+(\d+)", head)
if m:
return m.group(1)
return ""
@staticmethod
def _decode_header_value(value: str) -> str:
if not value:
return ""
try:
return str(make_header(decode_header(value)))
except Exception:
return value
@classmethod
def _extract_text_body(cls, msg: Any) -> str:
"""Best-effort extraction of readable body text."""
if msg.is_multipart():
plain_parts: list[str] = []
html_parts: list[str] = []
for part in msg.walk():
if part.get_content_disposition() == "attachment":
continue
content_type = part.get_content_type()
try:
payload = part.get_content()
except Exception:
payload_bytes = part.get_payload(decode=True) or b""
charset = part.get_content_charset() or "utf-8"
payload = payload_bytes.decode(charset, errors="replace")
if not isinstance(payload, str):
continue
if content_type == "text/plain":
plain_parts.append(payload)
elif content_type == "text/html":
html_parts.append(payload)
if plain_parts:
return "\n\n".join(plain_parts).strip()
if html_parts:
return cls._html_to_text("\n\n".join(html_parts)).strip()
return ""
try:
payload = msg.get_content()
except Exception:
payload_bytes = msg.get_payload(decode=True) or b""
charset = msg.get_content_charset() or "utf-8"
payload = payload_bytes.decode(charset, errors="replace")
if not isinstance(payload, str):
return ""
if msg.get_content_type() == "text/html":
return cls._html_to_text(payload).strip()
return payload.strip()
@staticmethod
def _check_authentication_results(parsed_msg: Any) -> tuple[bool, bool]:
"""Parse Authentication-Results headers for SPF and DKIM verdicts.
Returns:
A tuple of (spf_pass, dkim_pass) booleans.
"""
spf_pass = False
dkim_pass = False
for ar_header in parsed_msg.get_all("Authentication-Results") or []:
ar_lower = ar_header.lower()
if re.search(r"\bspf\s*=\s*pass\b", ar_lower):
spf_pass = True
if re.search(r"\bdkim\s*=\s*pass\b", ar_lower):
dkim_pass = True
return spf_pass, dkim_pass
@classmethod
def _extract_attachments(
cls,
msg: Any,
uid: str,
*,
allowed_types: list[str],
max_size: int,
max_count: int,
) -> list[Path]:
"""Extract and save email attachments to the media directory.
Returns list of saved file paths.
"""
if not msg.is_multipart():
return []
saved: list[Path] = []
media_dir = get_media_dir("email")
for part in msg.walk():
if len(saved) >= max_count:
break
if part.get_content_disposition() != "attachment":
continue
content_type = part.get_content_type()
if not any(fnmatch(content_type, pat) for pat in allowed_types):
logger.debug("Attachment skipped (type {}): not in allowed list", content_type)
continue
payload = part.get_payload(decode=True)
if payload is None:
continue
if len(payload) > max_size:
logger.warning(
"Attachment skipped: size {} exceeds limit {}",
len(payload),
max_size,
)
continue
raw_name = part.get_filename() or "attachment"
sanitized = safe_filename(raw_name) or "attachment"
dest = media_dir / f"{uid}_{sanitized}"
try:
dest.write_bytes(payload)
saved.append(dest)
logger.info("Attachment saved: {}", dest)
except Exception as exc:
logger.warning("Failed to save attachment {}: {}", dest, exc)
return saved
@staticmethod
def _html_to_text(raw_html: str) -> str:
text = re.sub(r"<\s*br\s*/?>", "\n", raw_html, flags=re.IGNORECASE)
text = re.sub(r"<\s*/\s*p\s*>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"<[^>]+>", "", text)
return html.unescape(text)
def _reply_subject(self, base_subject: str) -> str:
subject = (base_subject or "").strip() or "nanobot reply"
prefix = self.config.subject_prefix or "Re: "
if subject.lower().startswith("re:"):
return subject
return f"{prefix}{subject}"
+1
View File
@@ -0,0 +1 @@
"""Tests for the email channel package."""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
from __future__ import annotations
import pytest
from nanobot.channels.email import validation as email_validation
from nanobot.channels.validation import validate_channel_config
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config
def test_validate_email_presets_are_checked_without_saving(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr(email_validation, "probe_tcp", lambda *_args, **_kwargs: None)
result = validate_channel_config(
"email",
{
"channels.email.consentGranted": "true",
"channels.email.imapHost": "imap.gmail.com",
"channels.email.imapUsername": "bot@example.com",
"channels.email.imapPassword": "imap-secret",
"channels.email.smtpHost": "smtp.gmail.com",
"channels.email.smtpUsername": "bot@example.com",
"channels.email.smtpPassword": "smtp-secret",
},
)
assert result["status"] == "connected"
assert result["can_enable"] is True
assert not hasattr(load_config(config_path).channels, "email")
def test_validate_email_blocks_private_targets_when_local_access_is_disabled(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.tools.webui_allow_local_service_access = False
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr(
"nanobot.channels.validation.socket.create_connection",
lambda *_args, **_kwargs: pytest.fail("blocked target must not be connected"),
)
result = validate_channel_config(
"email",
{
"channels.email.consentGranted": "true",
"channels.email.imapHost": "127.0.0.1",
"channels.email.imapUsername": "bot@example.com",
"channels.email.imapPassword": "imap-secret",
"channels.email.smtpHost": "192.168.1.10",
"channels.email.smtpUsername": "bot@example.com",
"channels.email.smtpPassword": "smtp-secret",
},
)
warnings = [check["message"] for check in result["checks"] if check["status"] == "warn"]
assert len(warnings) == 2
assert all("private/internal" in message for message in warnings)
+91
View File
@@ -0,0 +1,91 @@
"""Email setup validation owned by the channel package."""
from typing import Any
from nanobot.channels.contracts import ChannelValidationContext
from nanobot.channels.validation import (
check,
int_value,
probe_tcp,
required_checks,
status_from_checks,
string_value,
truthy,
)
def validate(
values: dict[str, Any],
context: ChannelValidationContext,
) -> dict[str, Any]:
checks, missing = required_checks("email", values)
if truthy(values.get("consentGranted")):
checks.append(check("consent", "Mailbox consent", "pass", "Consent is enabled for this mailbox."))
else:
checks.append(
check(
"consent",
"Mailbox consent",
"fail",
"Grant consent before nanobot reads this mailbox.",
)
)
for prefix, default_port in (("imap", 993), ("smtp", 587)):
host = string_value(values.get(f"{prefix}Host"))
port = int_value(values.get(f"{prefix}Port")) or default_port
if not host:
continue
if port <= 0 or port > 65535:
checks.append(
check(
f"{prefix}_port",
f"{prefix.upper()} port",
"fail",
"Port must be between 1 and 65535.",
)
)
continue
checks.append(
check(
f"{prefix}_settings",
f"{prefix.upper()} settings",
"pass",
f"{host}:{port} is set.",
)
)
try:
probe_tcp(
host,
port,
allow_loopback=context.allow_local_service_access,
)
checks.append(
check(
f"{prefix}_reachability",
f"{prefix.upper()} reachability",
"pass",
"The server accepted a TCP connection.",
)
)
except Exception as exc:
checks.append(
check(
f"{prefix}_reachability",
f"{prefix.upper()} reachability",
"warn",
f"Could not verify network reachability now: {exc}",
)
)
identity = {
"account": string_value(
values.get("fromAddress")
or values.get("imapUsername")
or values.get("smtpUsername")
)
}
return status_from_checks("email", checks, missing, identity=identity)
__all__ = ["validate"]
+66
View File
@@ -0,0 +1,66 @@
import type { ChannelUiContribution } from "@/channel-plugins/types";
import {
type ChannelProviderPresetDefinition,
chatAppGuideUrl,
} from "@/components/settings/channels/catalog";
const EMAIL_PROVIDER_PRESETS: ChannelProviderPresetDefinition[] = [
{
id: "gmail",
values: {
"channels.email.imapHost": "imap.gmail.com",
"channels.email.imapPort": "993",
"channels.email.smtpHost": "smtp.gmail.com",
"channels.email.smtpPort": "587",
},
},
{
id: "outlook",
values: {
"channels.email.imapHost": "outlook.office365.com",
"channels.email.imapPort": "993",
"channels.email.smtpHost": "smtp.office365.com",
"channels.email.smtpPort": "587",
},
},
{
id: "icloud",
values: {
"channels.email.imapHost": "imap.mail.me.com",
"channels.email.imapPort": "993",
"channels.email.smtpHost": "smtp.mail.me.com",
"channels.email.smtpPort": "587",
},
},
{ id: "custom", values: {} },
];
export default {
presentation: {
displayName: "Email",
initials: "EM",
color: "#64748B",
logoUrl: "https://gmail.com/favicon.ico",
setup: {
mode: "credentials",
docsUrl: chatAppGuideUrl("email"),
presets: EMAIL_PROVIDER_PRESETS,
fields: [
{ key: "channels.email.consentGranted" },
{ key: "channels.email.imapHost" },
{ key: "channels.email.imapUsername" },
{ key: "channels.email.imapPassword" },
{ key: "channels.email.smtpHost" },
{ key: "channels.email.smtpUsername" },
{ key: "channels.email.smtpPassword" },
{ key: "channels.email.imapPort" },
{ key: "channels.email.smtpPort" },
{ key: "channels.email.fromAddress" },
{ key: "channels.email.pollIntervalSeconds" },
{ key: "channels.email.allowFrom" },
{ key: "channels.email.verifyDkim" },
{ key: "channels.email.verifySpf" },
],
},
},
} satisfies ChannelUiContribution;
@@ -0,0 +1,92 @@
{
"description": "Let nanobot receive and answer email messages.",
"requirements": "IMAP inbox, SMTP sender, app password, explicit consent",
"setup": {
"docsLabel": "Open Email setup",
"officialLabel": "Open app password guide",
"tryIt": "Send a test email to the connected mailbox.",
"summary": "Email reads messages over IMAP and replies over SMTP. Use a dedicated mailbox and grant consent before enabling it.",
"steps": [
"Create a dedicated mailbox and, when required, an app password.",
"Choose a provider preset or enter the IMAP and SMTP settings manually.",
"Grant consent, save and enable Email, then send a test message to the mailbox."
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "Custom"
},
"fields": {
"consentGranted": {
"label": "Consent granted",
"help": "Required safety switch. Leave false until this bot mailbox is intentionally connected.",
"choices": {
"true": "Granted",
"false": "Not granted"
}
},
"imapHost": {
"label": "IMAP host",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "IMAP username",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "IMAP password",
"placeholder": "App password",
"help": "Use an app password when your mail provider requires one."
},
"smtpHost": {
"label": "SMTP host",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "SMTP username",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "SMTP password",
"placeholder": "App password",
"help": "Usually the same app password used for IMAP."
},
"imapPort": {
"label": "IMAP port",
"placeholder": "993"
},
"smtpPort": {
"label": "SMTP port",
"placeholder": "587"
},
"fromAddress": {
"label": "From address",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "Poll interval",
"placeholder": "30"
},
"allowFrom": {
"label": "Allowed senders",
"placeholder": "Email addresses, comma separated",
"help": "Leave empty to require pairing before a sender can use email."
},
"verifyDkim": {
"label": "Verify DKIM",
"choices": {
"true": "On",
"false": "Off"
}
},
"verifySpf": {
"label": "Verify SPF",
"choices": {
"true": "On",
"false": "Off"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "Permite que nanobot reciba y responda correos.",
"requirements": "Bandeja IMAP, envío SMTP, contraseña de app y consentimiento explícito",
"setup": {
"docsLabel": "Abrir guía de Email",
"officialLabel": "Abrir guía de contraseñas de app",
"tryIt": "Envía un correo de prueba al buzón conectado.",
"summary": "Email lee mensajes por IMAP y responde por SMTP. Usa un buzón dedicado y da tu consentimiento antes de activarlo.",
"steps": [
"Crea un buzón dedicado y, si hace falta, una contraseña de app.",
"Elige un proveedor o introduce manualmente IMAP y SMTP.",
"Da tu consentimiento, guarda y activa Email; después envía un mensaje de prueba."
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "Personalizado"
},
"fields": {
"consentGranted": {
"label": "Consentimiento concedido",
"help": "Control de seguridad obligatorio. Déjalo desactivado hasta decidir conectar este buzón al bot.",
"choices": {
"true": "Concedido",
"false": "No concedido"
}
},
"imapHost": {
"label": "Host IMAP",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "Usuario IMAP",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "Contraseña IMAP",
"placeholder": "Contraseña de app",
"help": "Usa una contraseña de app si el proveedor la exige."
},
"smtpHost": {
"label": "Host SMTP",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "Usuario SMTP",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "Contraseña SMTP",
"placeholder": "Contraseña de app",
"help": "Normalmente es la misma que para IMAP."
},
"imapPort": {
"label": "Puerto IMAP",
"placeholder": "993"
},
"smtpPort": {
"label": "Puerto SMTP",
"placeholder": "587"
},
"fromAddress": {
"label": "Dirección remitente",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "Intervalo de consulta",
"placeholder": "30"
},
"allowFrom": {
"label": "Remitentes permitidos",
"placeholder": "Correos separados por comas",
"help": "Déjalo vacío para exigir vinculación previa."
},
"verifyDkim": {
"label": "Verificar DKIM",
"choices": {
"true": "Activado",
"false": "Desactivado"
}
},
"verifySpf": {
"label": "Verificar SPF",
"choices": {
"true": "Activado",
"false": "Desactivado"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "Permettez à nanobot de recevoir et répondre aux e-mails.",
"requirements": "Boîte IMAP, envoi SMTP, mot de passe dapplication et consentement explicite",
"setup": {
"docsLabel": "Ouvrir le guide Email",
"officialLabel": "Ouvrir le guide des mots de passe dapplication",
"tryIt": "Envoyez un e-mail test à la boîte connectée.",
"summary": "Email lit les messages via IMAP et répond via SMTP. Utilisez une boîte dédiée et accordez votre consentement avant lactivation.",
"steps": [
"Créez une boîte dédiée et, si nécessaire, un mot de passe dapplication.",
"Choisissez un fournisseur ou saisissez les paramètres IMAP et SMTP.",
"Accordez le consentement, enregistrez et activez Email, puis envoyez un message test."
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "Personnalisé"
},
"fields": {
"consentGranted": {
"label": "Consentement accordé",
"help": "Sécurité obligatoire. Nactivez quaprès avoir choisi de connecter cette boîte au bot.",
"choices": {
"true": "Accordé",
"false": "Non accordé"
}
},
"imapHost": {
"label": "Hôte IMAP",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "Nom dutilisateur IMAP",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "Mot de passe IMAP",
"placeholder": "Mot de passe dapplication",
"help": "Utilisez un mot de passe dapplication si le fournisseur lexige."
},
"smtpHost": {
"label": "Hôte SMTP",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "Nom dutilisateur SMTP",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "Mot de passe SMTP",
"placeholder": "Mot de passe dapplication",
"help": "Généralement identique à celui dIMAP."
},
"imapPort": {
"label": "Port IMAP",
"placeholder": "993"
},
"smtpPort": {
"label": "Port SMTP",
"placeholder": "587"
},
"fromAddress": {
"label": "Adresse denvoi",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "Intervalle de relève",
"placeholder": "30"
},
"allowFrom": {
"label": "Expéditeurs autorisés",
"placeholder": "Adresses séparées par des virgules",
"help": "Laissez vide pour imposer lassociation avant utilisation."
},
"verifyDkim": {
"label": "Vérifier DKIM",
"choices": {
"true": "Activé",
"false": "Désactivé"
}
},
"verifySpf": {
"label": "Vérifier SPF",
"choices": {
"true": "Activé",
"false": "Désactivé"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "Izinkan nanobot menerima dan membalas email.",
"requirements": "Kotak masuk IMAP, pengirim SMTP, kata sandi aplikasi, dan persetujuan eksplisit",
"setup": {
"docsLabel": "Buka panduan Email",
"officialLabel": "Buka panduan kata sandi aplikasi",
"tryIt": "Kirim email uji ke kotak surat yang terhubung.",
"summary": "Email membaca pesan melalui IMAP dan membalas melalui SMTP. Gunakan kotak surat khusus dan berikan persetujuan sebelum mengaktifkan.",
"steps": [
"Buat kotak surat khusus dan kata sandi aplikasi bila diperlukan.",
"Pilih preset penyedia atau masukkan IMAP dan SMTP secara manual.",
"Berikan persetujuan, simpan dan aktifkan Email, lalu kirim pesan uji."
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "Kustom"
},
"fields": {
"consentGranted": {
"label": "Persetujuan diberikan",
"help": "Sakelar keamanan wajib. Aktifkan hanya setelah sengaja menghubungkan kotak surat ini ke bot.",
"choices": {
"true": "Diberikan",
"false": "Belum diberikan"
}
},
"imapHost": {
"label": "Host IMAP",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "Nama pengguna IMAP",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "Kata sandi IMAP",
"placeholder": "Kata sandi aplikasi",
"help": "Gunakan kata sandi aplikasi jika diwajibkan penyedia."
},
"smtpHost": {
"label": "Host SMTP",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "Nama pengguna SMTP",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "Kata sandi SMTP",
"placeholder": "Kata sandi aplikasi",
"help": "Biasanya sama dengan kata sandi aplikasi IMAP."
},
"imapPort": {
"label": "Port IMAP",
"placeholder": "993"
},
"smtpPort": {
"label": "Port SMTP",
"placeholder": "587"
},
"fromAddress": {
"label": "Alamat pengirim",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "Interval pemeriksaan",
"placeholder": "30"
},
"allowFrom": {
"label": "Pengirim yang diizinkan",
"placeholder": "Alamat email, dipisahkan koma",
"help": "Kosongkan untuk mewajibkan pairing terlebih dahulu."
},
"verifyDkim": {
"label": "Verifikasi DKIM",
"choices": {
"true": "Aktif",
"false": "Nonaktif"
}
},
"verifySpf": {
"label": "Verifikasi SPF",
"choices": {
"true": "Aktif",
"false": "Nonaktif"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "nanobot でメールを受信し、返信します。",
"requirements": "IMAP 受信箱、SMTP 送信、アプリパスワード、明示的な同意",
"setup": {
"docsLabel": "メール設定ガイドを開く",
"officialLabel": "アプリパスワードガイドを開く",
"tryIt": "接続したメールボックスにテストメールを送信します。",
"summary": "メールは IMAP で受信し SMTP で返信します。専用メールボックスを使い、有効化前に同意してください。",
"steps": [
"専用メールボックスを作成し、必要ならアプリパスワードを発行します。",
"プロバイダープリセットを選ぶか、IMAP と SMTP を手動入力します。",
"同意して保存し、メールを有効にしてテストメールを送信します。"
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "カスタム"
},
"fields": {
"consentGranted": {
"label": "同意済み",
"help": "必須の安全設定です。このボット用メールボックスを接続すると決めるまでオフにしてください。",
"choices": {
"true": "同意済み",
"false": "未同意"
}
},
"imapHost": {
"label": "IMAP ホスト",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "IMAP ユーザー名",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "IMAP パスワード",
"placeholder": "アプリパスワード",
"help": "プロバイダーが求める場合はアプリパスワードを使います。"
},
"smtpHost": {
"label": "SMTP ホスト",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "SMTP ユーザー名",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "SMTP パスワード",
"placeholder": "アプリパスワード",
"help": "通常は IMAP と同じアプリパスワードです。"
},
"imapPort": {
"label": "IMAP ポート",
"placeholder": "993"
},
"smtpPort": {
"label": "SMTP ポート",
"placeholder": "587"
},
"fromAddress": {
"label": "送信元アドレス",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "確認間隔",
"placeholder": "30"
},
"allowFrom": {
"label": "許可する送信者",
"placeholder": "メールアドレス(カンマ区切り)",
"help": "空欄の場合、送信者は先にペアリングが必要です。"
},
"verifyDkim": {
"label": "DKIM を検証",
"choices": {
"true": "オン",
"false": "オフ"
}
},
"verifySpf": {
"label": "SPF を検証",
"choices": {
"true": "オン",
"false": "オフ"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "nanobot이 이메일을 받고 답장하도록 합니다.",
"requirements": "IMAP 받은편지함, SMTP 발신, 앱 비밀번호 및 명시적 동의",
"setup": {
"docsLabel": "이메일 설정 가이드 열기",
"officialLabel": "앱 비밀번호 가이드 열기",
"tryIt": "연결된 사서함으로 테스트 이메일을 보내세요.",
"summary": "이메일은 IMAP으로 읽고 SMTP로 답장합니다. 전용 사서함을 사용하고 활성화 전에 동의하세요.",
"steps": [
"전용 사서함을 만들고 필요하면 앱 비밀번호를 생성하세요.",
"제공자 프리셋을 선택하거나 IMAP 및 SMTP 설정을 직접 입력하세요.",
"동의하고 저장한 뒤 이메일을 활성화하고 테스트 메시지를 보내세요."
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "사용자 지정"
},
"fields": {
"consentGranted": {
"label": "동의함",
"help": "필수 안전 스위치입니다. 이 봇 사서함을 연결하기로 결정하기 전에는 끄세요.",
"choices": {
"true": "동의함",
"false": "동의하지 않음"
}
},
"imapHost": {
"label": "IMAP 호스트",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "IMAP 사용자 이름",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "IMAP 비밀번호",
"placeholder": "앱 비밀번호",
"help": "메일 제공자가 요구하면 앱 비밀번호를 사용하세요."
},
"smtpHost": {
"label": "SMTP 호스트",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "SMTP 사용자 이름",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "SMTP 비밀번호",
"placeholder": "앱 비밀번호",
"help": "보통 IMAP과 같은 앱 비밀번호를 사용합니다."
},
"imapPort": {
"label": "IMAP 포트",
"placeholder": "993"
},
"smtpPort": {
"label": "SMTP 포트",
"placeholder": "587"
},
"fromAddress": {
"label": "보내는 주소",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "확인 간격",
"placeholder": "30"
},
"allowFrom": {
"label": "허용된 발신자",
"placeholder": "이메일 주소, 쉼표로 구분",
"help": "비워 두면 발신자가 먼저 페어링해야 합니다."
},
"verifyDkim": {
"label": "DKIM 확인",
"choices": {
"true": "켜짐",
"false": "꺼짐"
}
},
"verifySpf": {
"label": "SPF 확인",
"choices": {
"true": "켜짐",
"false": "꺼짐"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "Permita que o nanobot receba e responda e-mails.",
"requirements": "Caixa IMAP, envio SMTP, senha de app e consentimento explícito",
"setup": {
"docsLabel": "Abrir guia de Email",
"officialLabel": "Abrir guia de senhas de app",
"tryIt": "Envie um e-mail de teste para a caixa conectada.",
"summary": "Email lê mensagens por IMAP e responde por SMTP. Use uma caixa dedicada e dê consentimento antes de ativar.",
"steps": [
"Crie uma caixa dedicada e, quando necessário, uma senha de app.",
"Escolha um provedor ou informe IMAP e SMTP manualmente.",
"Dê consentimento, salve e ative Email; depois, envie uma mensagem de teste."
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "Personalizado"
},
"fields": {
"consentGranted": {
"label": "Consentimento concedido",
"help": "Controle de segurança obrigatório. Deixe desativado até decidir conectar esta caixa ao bot.",
"choices": {
"true": "Concedido",
"false": "Não concedido"
}
},
"imapHost": {
"label": "Host IMAP",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "Usuário IMAP",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "Senha IMAP",
"placeholder": "Senha de app",
"help": "Use uma senha de app quando o provedor exigir."
},
"smtpHost": {
"label": "Host SMTP",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "Usuário SMTP",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "Senha SMTP",
"placeholder": "Senha de app",
"help": "Normalmente é a mesma senha usada no IMAP."
},
"imapPort": {
"label": "Porta IMAP",
"placeholder": "993"
},
"smtpPort": {
"label": "Porta SMTP",
"placeholder": "587"
},
"fromAddress": {
"label": "Endereço remetente",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "Intervalo de consulta",
"placeholder": "30"
},
"allowFrom": {
"label": "Remetentes permitidos",
"placeholder": "E-mails separados por vírgulas",
"help": "Deixe vazio para exigir pareamento prévio."
},
"verifyDkim": {
"label": "Verificar DKIM",
"choices": {
"true": "Ativado",
"false": "Desativado"
}
},
"verifySpf": {
"label": "Verificar SPF",
"choices": {
"true": "Ativado",
"false": "Desativado"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "Cho phép nanobot nhận và trả lời email.",
"requirements": "Hộp thư IMAP, gửi SMTP, mật khẩu ứng dụng và sự đồng ý rõ ràng",
"setup": {
"docsLabel": "Mở hướng dẫn Email",
"officialLabel": "Mở hướng dẫn mật khẩu ứng dụng",
"tryIt": "Gửi email thử đến hộp thư đã kết nối.",
"summary": "Email đọc thư qua IMAP và trả lời qua SMTP. Dùng hộp thư riêng và cấp quyền trước khi bật.",
"steps": [
"Tạo hộp thư riêng và mật khẩu ứng dụng nếu cần.",
"Chọn nhà cung cấp hoặc nhập thủ công cài đặt IMAP và SMTP.",
"Cấp quyền, lưu và bật Email, sau đó gửi tin nhắn thử."
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "Tùy chỉnh"
},
"fields": {
"consentGranted": {
"label": "Đã đồng ý",
"help": "Công tắc an toàn bắt buộc. Chỉ bật sau khi chủ động kết nối hộp thư này với bot.",
"choices": {
"true": "Đã đồng ý",
"false": "Chưa đồng ý"
}
},
"imapHost": {
"label": "Host IMAP",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "Tên người dùng IMAP",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "Mật khẩu IMAP",
"placeholder": "Mật khẩu ứng dụng",
"help": "Dùng mật khẩu ứng dụng khi nhà cung cấp yêu cầu."
},
"smtpHost": {
"label": "Host SMTP",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "Tên người dùng SMTP",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "Mật khẩu SMTP",
"placeholder": "Mật khẩu ứng dụng",
"help": "Thường giống mật khẩu ứng dụng dùng cho IMAP."
},
"imapPort": {
"label": "Cổng IMAP",
"placeholder": "993"
},
"smtpPort": {
"label": "Cổng SMTP",
"placeholder": "587"
},
"fromAddress": {
"label": "Địa chỉ gửi",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "Chu kỳ kiểm tra",
"placeholder": "30"
},
"allowFrom": {
"label": "Người gửi được phép",
"placeholder": "Địa chỉ email, phân tách bằng dấu phẩy",
"help": "Để trống để yêu cầu ghép nối trước."
},
"verifyDkim": {
"label": "Xác minh DKIM",
"choices": {
"true": "Bật",
"false": "Tắt"
}
},
"verifySpf": {
"label": "Xác minh SPF",
"choices": {
"true": "Bật",
"false": "Tắt"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "让 nanobot 接收并回复电子邮件。",
"requirements": "IMAP 收件箱、SMTP 发件服务、应用专用密码和明确授权",
"setup": {
"docsLabel": "打开邮件配置指南",
"officialLabel": "打开应用专用密码指南",
"tryIt": "向已连接的邮箱发送一封测试邮件。",
"summary": "邮件渠道通过 IMAP 读取邮件并通过 SMTP 回复。请使用专用邮箱,并在启用前明确授权。",
"steps": [
"创建专用邮箱,并在服务商要求时创建应用专用密码。",
"选择服务商预设,或手动填写 IMAP 和 SMTP 设置。",
"授予授权,保存并启用邮件渠道,然后向邮箱发送一封测试邮件。"
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "自定义"
},
"fields": {
"consentGranted": {
"label": "已授权",
"help": "必需的安全开关。仅在确定要连接此机器人邮箱后才开启。",
"choices": {
"true": "已授权",
"false": "未授权"
}
},
"imapHost": {
"label": "IMAP 主机",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "IMAP 用户名",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "IMAP 密码",
"placeholder": "应用专用密码",
"help": "如果邮件服务商要求,请使用应用专用密码。"
},
"smtpHost": {
"label": "SMTP 主机",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "SMTP 用户名",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "SMTP 密码",
"placeholder": "应用专用密码",
"help": "通常与 IMAP 使用同一个应用专用密码。"
},
"imapPort": {
"label": "IMAP 端口",
"placeholder": "993"
},
"smtpPort": {
"label": "SMTP 端口",
"placeholder": "587"
},
"fromAddress": {
"label": "发件地址",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "轮询间隔",
"placeholder": "30"
},
"allowFrom": {
"label": "允许的发件人",
"placeholder": "邮箱地址,用逗号分隔",
"help": "留空则要求发件人先完成配对。"
},
"verifyDkim": {
"label": "验证 DKIM",
"choices": {
"true": "开启",
"false": "关闭"
}
},
"verifySpf": {
"label": "验证 SPF",
"choices": {
"true": "开启",
"false": "关闭"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "讓 nanobot 接收並回覆電子郵件。",
"requirements": "IMAP 收件匣、SMTP 寄件服務、應用程式密碼和明確授權",
"setup": {
"docsLabel": "開啟郵件設定指南",
"officialLabel": "開啟應用程式密碼指南",
"tryIt": "向已連接的信箱傳送一封測試郵件。",
"summary": "郵件渠道透過 IMAP 讀取郵件並透過 SMTP 回覆。請使用專用信箱,並在啟用前明確授權。",
"steps": [
"建立專用信箱,並在服務商要求時建立應用程式密碼。",
"選擇服務商預設,或手動填入 IMAP 和 SMTP 設定。",
"授予權限,儲存並啟用郵件渠道,然後向信箱傳送一封測試郵件。"
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "自訂"
},
"fields": {
"consentGranted": {
"label": "已授權",
"help": "必要的安全開關。僅在確定要連接此機器人信箱後才開啟。",
"choices": {
"true": "已授權",
"false": "未授權"
}
},
"imapHost": {
"label": "IMAP 主機",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "IMAP 使用者名稱",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "IMAP 密碼",
"placeholder": "應用程式密碼",
"help": "若郵件服務商要求,請使用應用程式密碼。"
},
"smtpHost": {
"label": "SMTP 主機",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "SMTP 使用者名稱",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "SMTP 密碼",
"placeholder": "應用程式密碼",
"help": "通常與 IMAP 使用同一個應用程式密碼。"
},
"imapPort": {
"label": "IMAP 連接埠",
"placeholder": "993"
},
"smtpPort": {
"label": "SMTP 連接埠",
"placeholder": "587"
},
"fromAddress": {
"label": "寄件地址",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "輪詢間隔",
"placeholder": "30"
},
"allowFrom": {
"label": "允許的寄件者",
"placeholder": "電子郵件地址,以逗號分隔",
"help": "留空則要求寄件者先完成配對。"
},
"verifyDkim": {
"label": "驗證 DKIM",
"choices": {
"true": "開啟",
"false": "關閉"
}
},
"verifySpf": {
"label": "驗證 SPF",
"choices": {
"true": "開啟",
"false": "關閉"
}
}
}
}
}