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:
@@ -0,0 +1 @@
|
||||
"""Email channel package."""
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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 d’application et consentement explicite",
|
||||
"setup": {
|
||||
"docsLabel": "Ouvrir le guide Email",
|
||||
"officialLabel": "Ouvrir le guide des mots de passe d’application",
|
||||
"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 l’activation.",
|
||||
"steps": [
|
||||
"Créez une boîte dédiée et, si nécessaire, un mot de passe d’application.",
|
||||
"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. N’activez qu’aprè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 d’utilisateur IMAP",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"imapPassword": {
|
||||
"label": "Mot de passe IMAP",
|
||||
"placeholder": "Mot de passe d’application",
|
||||
"help": "Utilisez un mot de passe d’application si le fournisseur l’exige."
|
||||
},
|
||||
"smtpHost": {
|
||||
"label": "Hôte SMTP",
|
||||
"placeholder": "smtp.gmail.com"
|
||||
},
|
||||
"smtpUsername": {
|
||||
"label": "Nom d’utilisateur SMTP",
|
||||
"placeholder": "bot@example.com"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"label": "Mot de passe SMTP",
|
||||
"placeholder": "Mot de passe d’application",
|
||||
"help": "Généralement identique à celui d’IMAP."
|
||||
},
|
||||
"imapPort": {
|
||||
"label": "Port IMAP",
|
||||
"placeholder": "993"
|
||||
},
|
||||
"smtpPort": {
|
||||
"label": "Port SMTP",
|
||||
"placeholder": "587"
|
||||
},
|
||||
"fromAddress": {
|
||||
"label": "Adresse d’envoi",
|
||||
"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 l’association 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": "關閉"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user