ErisPulse-EmailAdapter 4.0.2__tar.gz → 4.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,76 +15,94 @@ from typing import BinaryIO, Dict, List, Optional, Union
15
15
  from ErisPulse.Core import adapter as adapter_mgr
16
16
  from ErisPulse.Core import client
17
17
  from ErisPulse.Core.Bases.adapter import BaseAdapter
18
+ from ErisPulse.Core.Bases import BaseConfig, BotAccountConfig
19
+ from ErisPulse.Core.Bases.config_schema import dict_to_dataclass
18
20
  from ErisPulse.Core.config import config as config_mgr
19
- from ErisPulse.runtime.config_schema import (
20
- AdapterConfig,
21
- BotAccountConfig,
22
- dict_to_dataclass,
23
- )
21
+ from ErisPulse.Core.i18n import i18n
22
+
23
+ try:
24
+ from ErisPulse.runtime.tasks import spawn_background
25
+ except ImportError: # pragma: no cover
26
+ spawn_background = None
27
+
28
+ __version__ = "4.2.0"
29
+
30
+ # 软依赖的框架最低版本(运行时检测,仅提示不强制)
31
+ MIN_FRAMEWORK_VERSION = (2, 7, 1)
24
32
 
25
33
 
26
34
  @dataclass
27
- class EmailGlobalConfig(AdapterConfig):
35
+ class EmailGlobalConfig(BaseConfig):
28
36
  """Email 适配器全局配置(适用于所有账户的默认值)"""
29
37
 
30
38
  imap_server: str = field(
31
39
  default="imap.example.com",
32
40
  metadata={
33
- "description": "Default IMAP server address",
34
- "webui": {"widget": "text", "group": "imap", "order": 1},
41
+ "description": {"i18n": "email.imap_server", "default": "默认 IMAP 服务器地址"},
42
+ "ui": {"widget": "text", "group": "imap", "order": 1},
35
43
  },
36
44
  )
37
45
  imap_port: int = field(
38
46
  default=993,
39
47
  metadata={
40
- "description": "Default IMAP port",
41
- "webui": {"widget": "number", "group": "imap", "order": 2},
48
+ "description": {"i18n": "email.imap_port", "default": "默认 IMAP 端口"},
49
+ "ui": {"widget": "number", "group": "imap", "order": 2},
42
50
  },
43
51
  )
44
52
  smtp_server: str = field(
45
53
  default="smtp.example.com",
46
54
  metadata={
47
- "description": "Default SMTP server address",
48
- "webui": {"widget": "text", "group": "smtp", "order": 3},
55
+ "description": {"i18n": "email.smtp_server", "default": "默认 SMTP 服务器地址"},
56
+ "ui": {"widget": "text", "group": "smtp", "order": 3},
49
57
  },
50
58
  )
51
59
  smtp_port: int = field(
52
60
  default=465,
53
61
  metadata={
54
- "description": "Default SMTP port",
55
- "webui": {"widget": "number", "group": "smtp", "order": 4},
62
+ "description": {"i18n": "email.smtp_port", "default": "默认 SMTP 端口"},
63
+ "ui": {"widget": "number", "group": "smtp", "order": 4},
56
64
  },
57
65
  )
58
66
  ssl: bool = field(
59
67
  default=True,
60
68
  metadata={
61
- "description": "Whether to enable SSL by default",
62
- "webui": {"widget": "switch", "group": "basic", "order": 5},
69
+ "description": {"i18n": "email.ssl", "default": "是否默认启用 SSL"},
70
+ "ui": {"widget": "switch", "group": "basic", "order": 5},
63
71
  },
64
72
  )
65
73
  timeout: int = field(
66
74
  default=30,
67
75
  metadata={
68
- "description": "Default connection timeout (seconds)",
69
- "webui": {"widget": "number", "group": "basic", "order": 6},
76
+ "description": {"i18n": "email.timeout", "default": "默认连接超时(秒)"},
77
+ "ui": {"widget": "number", "group": "basic", "order": 6},
70
78
  },
71
79
  )
72
80
  poll_interval: int = field(
73
81
  default=60,
74
82
  metadata={
75
- "description": "IMAP polling interval (seconds)",
76
- "webui": {"widget": "number", "group": "polling", "order": 7},
83
+ "description": {"i18n": "email.poll_interval", "default": "IMAP 轮询间隔(秒)"},
84
+ "ui": {"widget": "number", "group": "polling", "order": 7},
77
85
  },
78
86
  )
79
87
  max_retries: int = field(
80
88
  default=3,
81
89
  metadata={
82
- "description": "Max retries for failed connections",
83
- "webui": {"widget": "number", "group": "polling", "order": 8},
90
+ "description": {"i18n": "email.max_retries", "default": "连接失败最大重试次数"},
91
+ "ui": {"widget": "number", "group": "polling", "order": 8},
84
92
  },
85
93
  )
86
94
 
87
95
 
96
+ EmailGlobalConfig._schema_meta = {
97
+ "group_labels": {
98
+ "basic": {"i18n": "email.group.basic", "default": "基本设置"},
99
+ "imap": {"i18n": "email.group.imap", "default": "IMAP 设置"},
100
+ "smtp": {"i18n": "email.group.smtp", "default": "SMTP 设置"},
101
+ "polling": {"i18n": "email.group.polling", "default": "轮询设置"},
102
+ }
103
+ } # type: ignore[attr-defined]
104
+
105
+
88
106
  @dataclass
89
107
  class EmailAccountConfig(BotAccountConfig):
90
108
  """Email 单个账户配置"""
@@ -92,72 +110,123 @@ class EmailAccountConfig(BotAccountConfig):
92
110
  email: str = field(
93
111
  default="",
94
112
  metadata={
95
- "description": "Email address",
113
+ "description": {"i18n": "email.account.email", "default": "邮箱地址"},
96
114
  "required": True,
97
115
  "secret": True,
98
- "webui": {"widget": "text", "group": "basic", "order": 1},
116
+ "ui": {"widget": "text", "group": "basic", "order": 1},
99
117
  },
100
118
  )
101
119
  password: str = field(
102
120
  default="",
103
121
  metadata={
104
- "description": "Email password / authorization code",
122
+ "description": {"i18n": "email.account.password", "default": "邮箱密码 / 授权码"},
105
123
  "required": True,
106
124
  "secret": True,
107
- "webui": {"widget": "password", "group": "basic", "order": 2},
125
+ "ui": {"widget": "password", "group": "basic", "order": 2},
108
126
  },
109
127
  )
110
128
  imap_server: Optional[str] = field(
111
129
  default=None,
112
130
  metadata={
113
- "description": "IMAP server address (leave empty to use global default)",
114
- "webui": {"widget": "text", "group": "imap", "order": 3},
131
+ "description": {"i18n": "email.account.imap_server", "default": "IMAP 服务器地址(留空使用全局默认)"},
132
+ "ui": {"widget": "text", "group": "imap", "order": 3},
115
133
  },
116
134
  )
117
135
  imap_port: Optional[int] = field(
118
136
  default=None,
119
137
  metadata={
120
- "description": "IMAP port (leave empty to use global default)",
121
- "webui": {"widget": "number", "group": "imap", "order": 4},
138
+ "description": {"i18n": "email.account.imap_port", "default": "IMAP 端口(留空使用全局默认)"},
139
+ "ui": {"widget": "number", "group": "imap", "order": 4},
122
140
  },
123
141
  )
124
142
  smtp_server: Optional[str] = field(
125
143
  default=None,
126
144
  metadata={
127
- "description": "SMTP server address (leave empty to use global default)",
128
- "webui": {"widget": "text", "group": "smtp", "order": 5},
145
+ "description": {"i18n": "email.account.smtp_server", "default": "SMTP 服务器地址(留空使用全局默认)"},
146
+ "ui": {"widget": "text", "group": "smtp", "order": 5},
129
147
  },
130
148
  )
131
149
  smtp_port: Optional[int] = field(
132
150
  default=None,
133
151
  metadata={
134
- "description": "SMTP port (leave empty to use global default)",
135
- "webui": {"widget": "number", "group": "smtp", "order": 6},
152
+ "description": {"i18n": "email.account.smtp_port", "default": "SMTP 端口(留空使用全局默认)"},
153
+ "ui": {"widget": "number", "group": "smtp", "order": 6},
136
154
  },
137
155
  )
138
156
  ssl: bool = field(
139
157
  default=True,
140
158
  metadata={
141
- "description": "Enable SSL",
142
- "webui": {"widget": "switch", "group": "basic", "order": 7},
159
+ "description": {"i18n": "email.account.ssl", "default": "启用 SSL"},
160
+ "ui": {"widget": "switch", "group": "basic", "order": 7},
143
161
  },
144
162
  )
145
163
  timeout: int = field(
146
164
  default=30,
147
165
  metadata={
148
- "description": "Connection timeout (seconds)",
149
- "webui": {"widget": "number", "group": "basic", "order": 8},
166
+ "description": {"i18n": "email.account.timeout", "default": "连接超时(秒)"},
167
+ "ui": {"widget": "number", "group": "basic", "order": 8},
150
168
  },
151
169
  )
152
170
 
153
171
 
172
+ EmailAccountConfig._schema_meta = {
173
+ "group_labels": {
174
+ "basic": {"i18n": "email.group.basic", "default": "基本设置"},
175
+ "imap": {"i18n": "email.group.imap", "default": "IMAP 设置"},
176
+ "smtp": {"i18n": "email.group.smtp", "default": "SMTP 设置"},
177
+ }
178
+ } # type: ignore[attr-defined]
179
+
180
+
154
181
  class EmailAdapter(BaseAdapter):
155
- """Email 适配器(多账户),基于 stdlib smtplib/imaplib 实现"""
182
+ """Email 邮件适配器(多账户管理,基于 stdlib smtplib/imaplib 实现)"""
156
183
 
157
184
  _platform = "email"
158
185
  AccountConfigClass = EmailAccountConfig
159
186
  ConfigClass = EmailGlobalConfig
160
187
 
188
+ # ==================== Api DSL(最小集) ====================
189
+
190
+ class Api(BaseAdapter.Api):
191
+ """Email 标准 API 动作实现(最小集)
192
+
193
+ {!--< tips >!--}
194
+ 1. get_self_info → 返回账户邮箱地址
195
+ 2. Email 平台无群组/频道/撤回等概念,相关标准动作由默认实现返回 retcode=10002
196
+ {!--< /tips >!--}
197
+ """
198
+
199
+ async def get_self_info(self) -> dict:
200
+ account_name, account = self._adapter._resolve_account(self._account_id)
201
+ return self._adapter.make_response(
202
+ data={
203
+ "user_id": account.email,
204
+ "user_name": account.email.split("@")[0],
205
+ "user_displayname": account.email,
206
+ }
207
+ )
208
+
209
+ async def get_status(self) -> dict:
210
+ ad = self._adapter
211
+ bots = []
212
+ for name, account in ad.accounts.items():
213
+ bots.append({
214
+ "self": {"platform": ad.platform, "user_id": account.email, "account_id": name},
215
+ "online": ad._is_running and name in ad.poll_tasks,
216
+ })
217
+ return ad.make_response(data={"good": any(b["online"] for b in bots), "bots": bots})
218
+
219
+ async def get_version(self) -> dict:
220
+ from . import __version__
221
+
222
+ return self._adapter.make_response(
223
+ data={"impl": "ErisPulse-EmailAdapter", "version": __version__, "onebot_version": "12"}
224
+ )
225
+
226
+ async def get_supported_actions(self) -> dict:
227
+ actions = {"get_self_info", "get_status", "get_version", "get_supported_actions"}
228
+ return self._adapter.make_response(data=sorted(actions))
229
+
161
230
  class Send(BaseAdapter.Send):
162
231
  """邮件发送 DSL
163
232
 
@@ -434,7 +503,7 @@ class EmailAdapter(BaseAdapter):
434
503
  response["email_raw"] = raw_response
435
504
  return response
436
505
  except Exception as e:
437
- adapter.logger.error(f"Failed to send email: {str(e)}")
506
+ adapter.logger.error(i18n.t("email.send_failed", error=e, default="发送邮件失败: {error}"))
438
507
  err = adapter.make_error(retcode=34000, message=str(e), raw=None)
439
508
  err["email_raw"] = None
440
509
  return err
@@ -448,6 +517,127 @@ class EmailAdapter(BaseAdapter):
448
517
  self.poll_tasks: Dict[str, asyncio.Task] = {}
449
518
  # 运行状态
450
519
  self._is_running = False
520
+ self._register_i18n()
521
+ self._check_framework_version()
522
+ self._get_logger().info(f"EmailAdapter v{__version__} 已加载")
523
+
524
+ @staticmethod
525
+ def _parse_version(version_str: str) -> tuple:
526
+ """解析版本号为可比较的三元组(忽略 dev/预发布后缀,如 2.8.0-dev.3 → (2, 8, 0))"""
527
+ parts = []
528
+ for piece in str(version_str).split("."):
529
+ digits = "".join(ch for ch in piece if ch.isdigit())
530
+ parts.append(int(digits) if digits else 0)
531
+ while len(parts) < 3:
532
+ parts.append(0)
533
+ return tuple(parts[:3])
534
+
535
+ def _check_framework_version(self):
536
+ """软依赖检测:框架版本过低时打警告(不阻断加载)"""
537
+ try:
538
+ from importlib.metadata import version as _pkg_version
539
+
540
+ raw = _pkg_version("ErisPulse")
541
+ except Exception:
542
+ return
543
+ try:
544
+ if self._parse_version(raw) < MIN_FRAMEWORK_VERSION:
545
+ self._get_logger().warning(
546
+ f"当前 ErisPulse 版本 {raw} 过低:EmailAdapter v{__version__} 需要 >= "
547
+ f"{'.'.join(map(str, MIN_FRAMEWORK_VERSION))}"
548
+ "(spawn_background 等特性),"
549
+ "部分功能可能不可用,建议升级框架"
550
+ )
551
+ except Exception:
552
+ pass
553
+
554
+ def _register_i18n(self):
555
+ """注册配置字段与日志消息的 i18n 翻译"""
556
+ try:
557
+ from ErisPulse.runtime.config_schema import register_config_i18n
558
+ register_config_i18n(EmailGlobalConfig, "zh-CN", domain="email")
559
+ register_config_i18n(EmailAccountConfig, "zh-CN", domain="email")
560
+ register_config_i18n(EmailGlobalConfig, "en", {
561
+ "email.imap_server": "Default IMAP server address",
562
+ "email.imap_port": "Default IMAP port",
563
+ "email.smtp_server": "Default SMTP server address",
564
+ "email.smtp_port": "Default SMTP port",
565
+ "email.ssl": "Whether to enable SSL by default",
566
+ "email.timeout": "Default connection timeout (seconds)",
567
+ "email.poll_interval": "IMAP polling interval (seconds)",
568
+ "email.max_retries": "Max retries for failed connections",
569
+ "email.account.email": "Email address",
570
+ "email.account.password": "Email password / authorization code",
571
+ "email.account.imap_server": "IMAP server address (leave empty to use global default)",
572
+ "email.account.imap_port": "IMAP port (leave empty to use global default)",
573
+ "email.account.smtp_server": "SMTP server address (leave empty to use global default)",
574
+ "email.account.smtp_port": "SMTP port (leave empty to use global default)",
575
+ "email.account.ssl": "Enable SSL",
576
+ "email.account.timeout": "Connection timeout (seconds)",
577
+ "email.group.basic": "Basic",
578
+ "email.group.imap": "IMAP",
579
+ "email.group.smtp": "SMTP",
580
+ "email.group.polling": "Polling",
581
+ }, domain="email")
582
+ register_config_i18n(EmailAccountConfig, "en", {
583
+ "email.account.email": "Email address",
584
+ "email.account.password": "Email password / authorization code",
585
+ "email.account.imap_server": "IMAP server address (leave empty to use global default)",
586
+ "email.account.imap_port": "IMAP port (leave empty to use global default)",
587
+ "email.account.smtp_server": "SMTP server address (leave empty to use global default)",
588
+ "email.account.smtp_port": "SMTP port (leave empty to use global default)",
589
+ "email.account.ssl": "Enable SSL",
590
+ "email.account.timeout": "Connection timeout (seconds)",
591
+ "email.group.basic": "Basic",
592
+ "email.group.imap": "IMAP",
593
+ "email.group.smtp": "SMTP",
594
+ }, domain="email")
595
+ except Exception:
596
+ pass
597
+
598
+ zh_CN = {
599
+ "email.config_migrated": "已迁移旧版 [EmailAdapter.global] 配置到 [EmailAdapter]",
600
+ "email.old_config_detected": "检测到旧格式配置,建议迁移到 EmailAdapter.accounts 下",
601
+ "email.no_config_found": "未找到配置文件,创建默认账户配置",
602
+ "email.save_default_failed": "保存默认账户配置失败: {error}",
603
+ "email.account_missing_credentials": "账户 {name} 缺少 email 或 password 配置,已跳过",
604
+ "email.accounts_loaded": "Email 适配器初始化完成,共加载 {count} 个账户",
605
+ "email.no_enabled_accounts": "没有找到任何启用的 Email 账户配置",
606
+ "email.adapter_started": "Email 适配器启动完成,共 {count} 个账户",
607
+ "email.adapter_shutdown": "Email 适配器已关闭",
608
+ "email.smtp_error": "SMTP 错误: {error}",
609
+ "email.polling_error": "轮询 {name} 出错: {error}",
610
+ "email.reconnect_failed": "重连 {max} 次后仍失败",
611
+ "email.smtp_connect_failed": "SMTP 连接失败 {name}: {error}",
612
+ "email.send_failed": "发送邮件失败: {error}",
613
+ "email.unsupported_endpoint": "不支持的端点: {endpoint}",
614
+ "email.smtp_not_configured": "SMTP 服务器或端口未配置(账户 {name}),请检查全局配置或账户配置",
615
+ "email.imap_not_configured": "IMAP 服务器或端口未配置(账户 {name}),请检查全局配置或账户配置",
616
+ }
617
+ en = {
618
+ "email.config_migrated": "Migrated legacy [EmailAdapter.global] config to [EmailAdapter]",
619
+ "email.old_config_detected": "Old config format detected, please migrate to EmailAdapter.accounts",
620
+ "email.no_config_found": "No config found, creating default account config",
621
+ "email.save_default_failed": "Failed to save default account config: {error}",
622
+ "email.account_missing_credentials": "Account {name} missing email or password, skipped",
623
+ "email.accounts_loaded": "Email adapter initialized, loaded {count} account(s)",
624
+ "email.no_enabled_accounts": "No enabled Email account config found",
625
+ "email.adapter_started": "Email adapter started, {count} account(s) total",
626
+ "email.adapter_shutdown": "Email adapter shut down",
627
+ "email.smtp_error": "SMTP error: {error}",
628
+ "email.polling_error": "Polling error for {name}: {error}",
629
+ "email.reconnect_failed": "Failed to reconnect after {max} attempts",
630
+ "email.smtp_connect_failed": "Failed to connect SMTP for {name}: {error}",
631
+ "email.send_failed": "Failed to send email: {error}",
632
+ "email.unsupported_endpoint": "Unsupported endpoint: {endpoint}",
633
+ "email.smtp_not_configured": "SMTP server or port not configured (account {name}), please check global or account config",
634
+ "email.imap_not_configured": "IMAP server or port not configured (account {name}), please check global or account config",
635
+ }
636
+ try:
637
+ i18n.register("zh-CN", zh_CN, domain="email")
638
+ i18n.register("en", en, domain="email")
639
+ except Exception:
640
+ pass
451
641
 
452
642
  def _get_config_key(self) -> str:
453
643
  return "EmailAdapter"
@@ -461,7 +651,7 @@ class EmailAdapter(BaseAdapter):
461
651
  for k, v in old_global.items():
462
652
  data.setdefault(k, v)
463
653
  config_mgr.setConfig(key, data, immediate=True)
464
- self.logger.info("已迁移旧版 [EmailAdapter.global] 配置到 [EmailAdapter]")
654
+ self.logger.info(i18n.t("email.config_migrated", default="已迁移旧版 [EmailAdapter.global] 配置到 [EmailAdapter]"))
465
655
  return super()._load_config()
466
656
 
467
657
  def _load_accounts(self) -> dict:
@@ -474,7 +664,7 @@ class EmailAdapter(BaseAdapter):
474
664
  old_config = config_mgr.getConfig("EmailAdapter")
475
665
  if old_config and ("email" in old_config or "accounts" not in old_config):
476
666
  self.logger.warning(
477
- "检测到旧格式配置,建议迁移到 EmailAdapter.accounts 下"
667
+ i18n.t("email.old_config_detected", default="检测到旧格式配置,建议迁移到 EmailAdapter.accounts 下")
478
668
  )
479
669
  data = {
480
670
  "default": {
@@ -484,7 +674,7 @@ class EmailAdapter(BaseAdapter):
484
674
  }
485
675
  }
486
676
  else:
487
- self.logger.info("未找到配置文件,创建默认账户配置")
677
+ self.logger.info(i18n.t("email.no_config_found", default="未找到配置文件,创建默认账户配置"))
488
678
  data = {
489
679
  "default": {
490
680
  "email": "user@example.com",
@@ -495,44 +685,47 @@ class EmailAdapter(BaseAdapter):
495
685
  try:
496
686
  config_mgr.setConfig(key, data)
497
687
  except Exception as e:
498
- self.logger.error(f"保存默认账户配置失败: {str(e)}")
688
+ self.logger.error(i18n.t("email.save_default_failed", error=e, default="保存默认账户配置失败: {error}"))
499
689
 
500
- # 全局默认值(来自 ConfigClass 实例)
690
+ # 全局默认值(来自 ConfigClass 实例,实时读取)
501
691
  global_defaults = {}
502
- cfg = self._config_instance
503
- if cfg is not None:
504
- for k in (
505
- "imap_server",
506
- "imap_port",
507
- "smtp_server",
508
- "smtp_port",
509
- "ssl",
510
- "timeout",
511
- ):
512
- global_defaults[k] = getattr(cfg, k, None)
692
+ try:
693
+ cfg = self.cfg
694
+ if cfg is not None:
695
+ for k in (
696
+ "imap_server",
697
+ "imap_port",
698
+ "smtp_server",
699
+ "smtp_port",
700
+ "ssl",
701
+ "timeout",
702
+ ):
703
+ global_defaults[k] = getattr(cfg, k, None)
704
+ except Exception:
705
+ pass
513
706
 
514
707
  accounts = {}
515
708
  for name, account_data in data.items():
516
709
  if not isinstance(account_data, dict):
517
710
  continue
518
711
 
519
- # 合并全局默认值(账户级配置优先)
712
+ # 合并全局默认值(账户级配置优先,但 None 值不覆盖全局默认)
520
713
  merged = dict(global_defaults)
521
714
  # 兼容旧版嵌套 server 子结构
522
715
  server_block = account_data.get("server", {})
523
716
  if isinstance(server_block, dict):
524
717
  merged.update({k: v for k, v in server_block.items() if v is not None})
525
- merged.update({k: v for k, v in account_data.items() if k != "server"})
718
+ merged.update({k: v for k, v in account_data.items() if k != "server" and v is not None and v != ""})
526
719
 
527
720
  if not merged.get("email") or not merged.get("password"):
528
- self.logger.error(f"账户 {name} 缺少 email 或 password 配置,已跳过")
721
+ self.logger.error(i18n.t("email.account_missing_credentials", name=name, default="账户 {name} 缺少 email 或 password 配置,已跳过"))
529
722
  continue
530
723
 
531
724
  instance = dict_to_dataclass(EmailAccountConfig, merged)
532
725
  instance.name = name
533
726
  accounts[name] = instance
534
727
 
535
- self.logger.info(f"Email 适配器初始化完成,共加载 {len(accounts)} 个账户")
728
+ self.logger.info(i18n.t("email.accounts_loaded", count=len(accounts), default="Email 适配器初始化完成,共加载 {count} 个账户"))
536
729
  return accounts
537
730
 
538
731
  async def _send_email(self, account_id: str, msg: MIMEMultipart) -> Dict:
@@ -552,7 +745,7 @@ class EmailAdapter(BaseAdapter):
552
745
  response["success"] = True
553
746
  response["message"] = "Email sent successfully"
554
747
  except Exception as e:
555
- self.logger.error(f"SMTP error: {str(e)}")
748
+ self.logger.error(i18n.t("email.smtp_error", error=e, default="SMTP 错误: {error}"))
556
749
  # 尝试重新连接
557
750
  try:
558
751
  await self._connect_smtp(account_id)
@@ -570,12 +763,21 @@ class EmailAdapter(BaseAdapter):
570
763
  async def _connect_smtp(self, account_id: str):
571
764
  account = self.accounts[account_id]
572
765
 
573
- # DEBUG: 临时调试,打印实际使用的主机名/端口/SSL
574
- self.logger.debug(
575
- f"[DEBUG] _connect_smtp account={account_id} "
576
- f"smtp_server={account.smtp_server!r} smtp_port={account.smtp_port!r} "
577
- f"ssl={account.ssl!r} timeout={account.timeout!r}"
578
- )
766
+ # 账户级配置优先,缺失时回退到全局配置
767
+ try:
768
+ cfg = self.cfg
769
+ except Exception:
770
+ cfg = None
771
+
772
+ smtp_server = account.smtp_server or (cfg.smtp_server if cfg else None)
773
+ smtp_port = account.smtp_port or (cfg.smtp_port if cfg else None)
774
+ use_ssl = account.ssl if account.ssl is not None else (cfg.ssl if cfg else True)
775
+ timeout = account.timeout or (cfg.timeout if cfg else 30)
776
+
777
+ if not smtp_server or not smtp_port:
778
+ raise ValueError(
779
+ i18n.t("email.smtp_not_configured", name=account_id, default="SMTP 服务器或端口未配置(账户 {name}),请检查全局配置或账户配置")
780
+ )
579
781
 
580
782
  if account_id in self.smtp_connections:
581
783
  try:
@@ -585,21 +787,19 @@ class EmailAdapter(BaseAdapter):
585
787
 
586
788
  context = ssl.create_default_context()
587
789
 
588
- if account.ssl:
790
+ if use_ssl:
589
791
  smtp = smtplib.SMTP_SSL(
590
- host=account.smtp_server,
591
- port=account.smtp_port,
592
- timeout=account.timeout,
792
+ host=smtp_server,
793
+ port=smtp_port,
794
+ timeout=timeout,
593
795
  context=context,
594
796
  )
595
797
  else:
596
798
  smtp = smtplib.SMTP(
597
- host=account.smtp_server,
598
- port=account.smtp_port,
599
- timeout=account.timeout,
799
+ host=smtp_server,
800
+ port=smtp_port,
801
+ timeout=timeout,
600
802
  )
601
- if account.ssl:
602
- smtp.starttls(context=context)
603
803
 
604
804
  smtp.login(account.email, account.password)
605
805
  self.smtp_connections[account_id] = smtp
@@ -608,6 +808,20 @@ class EmailAdapter(BaseAdapter):
608
808
  """连接 IMAP 服务器"""
609
809
  account = self.accounts[account_id]
610
810
 
811
+ # 账户级配置优先,缺失时回退到全局配置
812
+ try:
813
+ cfg = self.cfg
814
+ except Exception:
815
+ cfg = None
816
+
817
+ imap_server = account.imap_server or (cfg.imap_server if cfg else None)
818
+ imap_port = account.imap_port or (cfg.imap_port if cfg else None)
819
+
820
+ if not imap_server or not imap_port:
821
+ raise ValueError(
822
+ i18n.t("email.imap_not_configured", name=account_id, default="IMAP 服务器或端口未配置(账户 {name}),请检查全局配置或账户配置")
823
+ )
824
+
611
825
  if account_id in self.imap_connections:
612
826
  try:
613
827
  self.imap_connections[account_id].logout()
@@ -617,7 +831,7 @@ class EmailAdapter(BaseAdapter):
617
831
  context = ssl.create_default_context()
618
832
 
619
833
  imap = imaplib.IMAP4_SSL(
620
- host=account.imap_server, port=account.imap_port, ssl_context=context
834
+ host=imap_server, port=imap_port, ssl_context=context
621
835
  )
622
836
 
623
837
  imap.login(account.email, account.password)
@@ -627,12 +841,14 @@ class EmailAdapter(BaseAdapter):
627
841
  async def _poll_emails(self, account_id: str):
628
842
  poll_interval = 60
629
843
  max_retries = 3
630
- cfg = self._config_instance
631
- if cfg is not None:
844
+ try:
845
+ cfg = self.cfg
632
846
  poll_interval = (
633
847
  getattr(cfg, "poll_interval", poll_interval) or poll_interval
634
848
  )
635
849
  max_retries = getattr(cfg, "max_retries", max_retries) or max_retries
850
+ except Exception:
851
+ pass
636
852
 
637
853
  while self._is_running:
638
854
  try:
@@ -661,7 +877,7 @@ class EmailAdapter(BaseAdapter):
661
877
  except asyncio.CancelledError:
662
878
  return
663
879
  except Exception as e:
664
- self.logger.error(f"Polling error for {account_id}: {str(e)}")
880
+ self.logger.error(i18n.t("email.polling_error", name=account_id, error=e, default="轮询 {name} 出错: {error}"))
665
881
  retries = 0
666
882
  while retries < max_retries:
667
883
  try:
@@ -671,7 +887,7 @@ class EmailAdapter(BaseAdapter):
671
887
  retries += 1
672
888
  if retries >= max_retries:
673
889
  self.logger.error(
674
- f"Failed to reconnect after {max_retries} attempts"
890
+ i18n.t("email.reconnect_failed", max=max_retries, default="重连 {max} 次后仍失败")
675
891
  )
676
892
  raise
677
893
  await asyncio.sleep(5)
@@ -682,21 +898,23 @@ class EmailAdapter(BaseAdapter):
682
898
  """
683
899
  将邮件转换为 OneBot12 标准事件
684
900
 
685
- - 添加 email_raw_type 字段保存原始事件类型
686
- - 确保时间戳为10位秒级
687
- - 保留原始数据在 email_raw 字段
901
+ 遵循 ErisPulse 适配器标准化转换规范:
902
+ - user_id 为纯邮箱地址,user_nickname 为发件人显示名
903
+ - message 为标准 OB12 消息段(text + file)
904
+ - email_raw 保留完整原始数据(向后兼容)
688
905
  """
906
+ from email.utils import parseaddr
907
+
689
908
  # 确定原始事件类型
690
- # 检查是否为回复邮件
691
909
  references = email_message.get("References", "")
692
910
  in_reply_to = email_message.get("In-Reply-To", "")
693
911
 
694
912
  if references or in_reply_to:
695
- raw_event_type = "email_reply" # 回复邮件
913
+ raw_event_type = "email_reply"
696
914
  else:
697
- raw_event_type = "email_new" # 新邮件
915
+ raw_event_type = "email_new"
698
916
 
699
- # 解析邮件内容
917
+ # 解析邮件头
700
918
  def decode_header(header):
701
919
  from email.header import decode_header
702
920
 
@@ -716,7 +934,13 @@ class EmailAdapter(BaseAdapter):
716
934
  to = decode_header(email_message.get("To", ""))
717
935
  date = email_message.get("Date", "")
718
936
 
719
- # 解析正文
937
+ # 从 From 头提取纯邮箱地址和显示名
938
+ # "Saber" <wsu2059@qq.com> -> ("Saber", "wsu2059@qq.com")
939
+ display_name, clean_email = parseaddr(from_)
940
+ if not display_name:
941
+ display_name = clean_email
942
+
943
+ # 解析正文和附件
720
944
  text_content = ""
721
945
  html_content = ""
722
946
  attachments = []
@@ -726,9 +950,9 @@ class EmailAdapter(BaseAdapter):
726
950
  content_disposition = str(part.get("Content-Disposition"))
727
951
 
728
952
  if "attachment" in content_disposition:
729
- # 处理附件
730
953
  filename = part.get_filename()
731
954
  if filename:
955
+ filename = decode_header(filename)
732
956
  attachments.append(
733
957
  {
734
958
  "filename": filename,
@@ -738,7 +962,6 @@ class EmailAdapter(BaseAdapter):
738
962
  }
739
963
  )
740
964
  elif content_type == "text/plain":
741
- # 纯文本内容
742
965
  payload = part.get_payload(decode=True)
743
966
  try:
744
967
  text_content = payload.decode("utf-8")
@@ -748,7 +971,6 @@ class EmailAdapter(BaseAdapter):
748
971
  except UnicodeDecodeError:
749
972
  text_content = payload.decode("utf-8", errors="replace")
750
973
  elif content_type == "text/html":
751
- # HTML内容
752
974
  payload = part.get_payload(decode=True)
753
975
  try:
754
976
  html_content = payload.decode("utf-8")
@@ -761,25 +983,56 @@ class EmailAdapter(BaseAdapter):
761
983
  account = self.accounts.get(account_id)
762
984
  self_email = account.email if account else account_id
763
985
 
986
+ # 构建 OB12 标准消息段
987
+ message_segments = []
988
+
989
+ # 正文文本段
990
+ if text_content:
991
+ message_segments.append(
992
+ {"type": "text", "data": {"text": text_content}}
993
+ )
994
+ elif html_content and not text_content:
995
+ # 无纯文本时,从 HTML 提取简单文本
996
+ import re
997
+ simple_text = re.sub(r"<[^>]+>", "", html_content).strip()
998
+ if simple_text:
999
+ message_segments.append(
1000
+ {"type": "text", "data": {"text": simple_text}}
1001
+ )
1002
+
1003
+ # 附件转为 OB12 file 消息段
1004
+ for att in attachments:
1005
+ message_segments.append(
1006
+ {
1007
+ "type": "file",
1008
+ "data": {
1009
+ "file_id": att["filename"],
1010
+ "file_name": att["filename"],
1011
+ "size": att["size"],
1012
+ },
1013
+ }
1014
+ )
1015
+
1016
+ # 确保至少有一个消息段
1017
+ if not message_segments:
1018
+ message_segments.append({"type": "text", "data": {"text": subject or ""}})
1019
+
764
1020
  # 构建标准事件
765
1021
  return {
766
1022
  "id": email_message.get("Message-ID", ""),
767
1023
  "time": self._parse_email_date(date),
768
1024
  "type": "message",
769
- "detail_type": "private", # 邮件默认为私聊
1025
+ "detail_type": "private",
770
1026
  "platform": "email",
771
1027
  "self": {"platform": "email", "user_id": self_email},
772
- "message": [
773
- {
774
- "type": "text",
775
- "data": {
776
- "text": f"Subject: {subject}\nFrom: {from_}\n\n{text_content}"
777
- },
778
- }
779
- ],
780
- "alt_message": f"邮件: {subject}",
781
- "user_id": from_,
782
- # 平台原始数据(包含完整邮件信息)
1028
+ "message": message_segments,
1029
+ "alt_message": subject or text_content[:50] if text_content else "",
1030
+ "user_id": clean_email or from_,
1031
+ "user_nickname": display_name,
1032
+ # 邮件扩展字段
1033
+ "email_subject": subject,
1034
+ "email_from": clean_email or from_,
1035
+ # 平台原始数据(向后兼容)
783
1036
  "email_raw": {
784
1037
  "subject": subject,
785
1038
  "from": from_,
@@ -794,8 +1047,8 @@ class EmailAdapter(BaseAdapter):
794
1047
  "html_content": html_content,
795
1048
  "attachments": [att["filename"] for att in attachments],
796
1049
  },
797
- # 原始事件类型
798
1050
  "email_raw_type": raw_event_type,
1051
+ # 向后兼容:保留顶层 attachments
799
1052
  "attachments": attachments,
800
1053
  }
801
1054
 
@@ -848,13 +1101,13 @@ class EmailAdapter(BaseAdapter):
848
1101
  else:
849
1102
  return self.make_error(
850
1103
  retcode=10002,
851
- message=f"Unsupported endpoint: {endpoint}",
1104
+ message=i18n.t("email.unsupported_endpoint", endpoint=endpoint, default="不支持的端点: {endpoint}"),
852
1105
  raw=None,
853
1106
  )
854
1107
 
855
1108
  async def start(self):
856
1109
  if not self.enabled_accounts:
857
- self.logger.warning("没有找到任何启用的 Email 账户配置")
1110
+ self.logger.warning(i18n.t("email.no_enabled_accounts", default="没有找到任何启用的 Email 账户配置"))
858
1111
  return
859
1112
 
860
1113
  self._is_running = True
@@ -865,7 +1118,7 @@ class EmailAdapter(BaseAdapter):
865
1118
  await self._connect_smtp(account_name)
866
1119
  except Exception as e:
867
1120
  self.logger.error(
868
- f"Failed to connect SMTP for {account_name}: {str(e)}"
1121
+ i18n.t("email.smtp_connect_failed", name=account_name, error=e, default="SMTP 连接失败 {name}: {error}")
869
1122
  )
870
1123
  try:
871
1124
  await self.emit_meta("connect", account.email)
@@ -875,12 +1128,14 @@ class EmailAdapter(BaseAdapter):
875
1128
  # 启动轮询任务(仅对配置了 IMAP 的账户)
876
1129
  for account_name, account in self.enabled_accounts.items():
877
1130
  if account.imap_server:
878
- self.poll_tasks[account_name] = asyncio.create_task(
879
- self._poll_emails(account_name)
1131
+ coro = self._poll_emails(account_name)
1132
+ # 生命周期任务使用 spawn_background(owner 归属,shutdown 自动回收)
1133
+ self.poll_tasks[account_name] = (
1134
+ spawn_background(coro) if spawn_background is not None else asyncio.create_task(coro)
880
1135
  )
881
1136
 
882
1137
  self.logger.info(
883
- f"Email 适配器启动完成,共 {len(self.enabled_accounts)} 个账户"
1138
+ i18n.t("email.adapter_started", count=len(self.enabled_accounts), default="Email 适配器启动完成,共 {count} 个账户")
884
1139
  )
885
1140
 
886
1141
  async def shutdown(self):
@@ -922,4 +1177,4 @@ class EmailAdapter(BaseAdapter):
922
1177
  except Exception:
923
1178
  pass
924
1179
 
925
- self.logger.info("Email 适配器已关闭")
1180
+ self.logger.info(i18n.t("email.adapter_shutdown", default="Email 适配器已关闭"))
@@ -0,0 +1,3 @@
1
+ from .Core import EmailAdapter, __version__
2
+
3
+ __all__ = ["EmailAdapter", "__version__"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ErisPulse-EmailAdapter
3
- Version: 4.0.2
3
+ Version: 4.2.0
4
4
  Summary: ErisPulse邮箱适配器,支持多账号管理、HTML内容和附件发送
5
5
  Author-email: wsu2059q <wsu2059@qq.com>
6
6
  License: MIT
@@ -22,9 +22,28 @@ Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Dynamic: license-file
24
24
 
25
- # ErisPulse Email Adapter
25
+ <div align="center">
26
26
 
27
- [English](#english) | [中文](#中文)
27
+ <img src=".github/assets/ErisPulseLogo.png" width="180" alt="ErisPulse EmailAdapter" />
28
+
29
+ # ErisPulse EmailAdapter
30
+
31
+ **Send and receive emails as events — turn any mailbox into a Bot.**
32
+
33
+ A powerful email adapter for the ErisPulse framework. It polls mailboxes for new messages and converts them into standard OneBot12 events, while emails are sent via the chainable Send DSL — with multi-account, attachments and HTML support.
34
+
35
+ <p>
36
+ <a href="https://pypi.org/project/ErisPulse-EmailAdapter/"><img src="https://img.shields.io/pypi/v/ErisPulse-EmailAdapter?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"></a>
37
+ <a href="https://pypi.org/project/ErisPulse-EmailAdapter/"><img src="https://img.shields.io/badge/Python-3.10+-FFD43B?style=for-the-badge&logo=python&logoColor=blue" alt="Python"></a>
38
+ <a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="License"></a>
39
+ <a href="https://github.com/ErisPulse/ErisPulse-EmailAdapter"><img src="https://img.shields.io/github/stars/ErisPulse/ErisPulse-EmailAdapter?style=for-the-badge&logo=github&color=brightgreen" alt="Stars"></a>
40
+ <a href="https://pepy.tech/project/ErisPulse-EmailAdapter"><img src="https://img.shields.io/pepy/dt/ErisPulse-EmailAdapter?style=for-the-badge&color=blue" alt="Downloads"></a>
41
+ <a href="https://github.com/ErisPulse/ErisPulse"><img src="https://img.shields.io/badge/Powered_by-ErisPulse-FF6B9D?style=for-the-badge&logo=bookstack&logoColor=white" alt="ErisPulse"></a>
42
+ </p>
43
+
44
+ [English](#english) | [简体中文](#中文)
45
+
46
+ </div>
28
47
 
29
48
  ---
30
49
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ErisPulse-EmailAdapter
3
- Version: 4.0.2
3
+ Version: 4.2.0
4
4
  Summary: ErisPulse邮箱适配器,支持多账号管理、HTML内容和附件发送
5
5
  Author-email: wsu2059q <wsu2059@qq.com>
6
6
  License: MIT
@@ -22,9 +22,28 @@ Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Dynamic: license-file
24
24
 
25
- # ErisPulse Email Adapter
25
+ <div align="center">
26
26
 
27
- [English](#english) | [中文](#中文)
27
+ <img src=".github/assets/ErisPulseLogo.png" width="180" alt="ErisPulse EmailAdapter" />
28
+
29
+ # ErisPulse EmailAdapter
30
+
31
+ **Send and receive emails as events — turn any mailbox into a Bot.**
32
+
33
+ A powerful email adapter for the ErisPulse framework. It polls mailboxes for new messages and converts them into standard OneBot12 events, while emails are sent via the chainable Send DSL — with multi-account, attachments and HTML support.
34
+
35
+ <p>
36
+ <a href="https://pypi.org/project/ErisPulse-EmailAdapter/"><img src="https://img.shields.io/pypi/v/ErisPulse-EmailAdapter?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"></a>
37
+ <a href="https://pypi.org/project/ErisPulse-EmailAdapter/"><img src="https://img.shields.io/badge/Python-3.10+-FFD43B?style=for-the-badge&logo=python&logoColor=blue" alt="Python"></a>
38
+ <a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="License"></a>
39
+ <a href="https://github.com/ErisPulse/ErisPulse-EmailAdapter"><img src="https://img.shields.io/github/stars/ErisPulse/ErisPulse-EmailAdapter?style=for-the-badge&logo=github&color=brightgreen" alt="Stars"></a>
40
+ <a href="https://pepy.tech/project/ErisPulse-EmailAdapter"><img src="https://img.shields.io/pepy/dt/ErisPulse-EmailAdapter?style=for-the-badge&color=blue" alt="Downloads"></a>
41
+ <a href="https://github.com/ErisPulse/ErisPulse"><img src="https://img.shields.io/badge/Powered_by-ErisPulse-FF6B9D?style=for-the-badge&logo=bookstack&logoColor=white" alt="ErisPulse"></a>
42
+ </p>
43
+
44
+ [English](#english) | [简体中文](#中文)
45
+
46
+ </div>
28
47
 
29
48
  ---
30
49
 
@@ -1,6 +1,25 @@
1
- # ErisPulse Email Adapter
1
+ <div align="center">
2
2
 
3
- [English](#english) | [中文](#中文)
3
+ <img src=".github/assets/ErisPulseLogo.png" width="180" alt="ErisPulse EmailAdapter" />
4
+
5
+ # ErisPulse EmailAdapter
6
+
7
+ **Send and receive emails as events — turn any mailbox into a Bot.**
8
+
9
+ A powerful email adapter for the ErisPulse framework. It polls mailboxes for new messages and converts them into standard OneBot12 events, while emails are sent via the chainable Send DSL — with multi-account, attachments and HTML support.
10
+
11
+ <p>
12
+ <a href="https://pypi.org/project/ErisPulse-EmailAdapter/"><img src="https://img.shields.io/pypi/v/ErisPulse-EmailAdapter?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"></a>
13
+ <a href="https://pypi.org/project/ErisPulse-EmailAdapter/"><img src="https://img.shields.io/badge/Python-3.10+-FFD43B?style=for-the-badge&logo=python&logoColor=blue" alt="Python"></a>
14
+ <a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="License"></a>
15
+ <a href="https://github.com/ErisPulse/ErisPulse-EmailAdapter"><img src="https://img.shields.io/github/stars/ErisPulse/ErisPulse-EmailAdapter?style=for-the-badge&logo=github&color=brightgreen" alt="Stars"></a>
16
+ <a href="https://pepy.tech/project/ErisPulse-EmailAdapter"><img src="https://img.shields.io/pepy/dt/ErisPulse-EmailAdapter?style=for-the-badge&color=blue" alt="Downloads"></a>
17
+ <a href="https://github.com/ErisPulse/ErisPulse"><img src="https://img.shields.io/badge/Powered_by-ErisPulse-FF6B9D?style=for-the-badge&logo=bookstack&logoColor=white" alt="ErisPulse"></a>
18
+ </p>
19
+
20
+ [English](#english) | [简体中文](#中文)
21
+
22
+ </div>
4
23
 
5
24
  ---
6
25
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ErisPulse-EmailAdapter"
3
- version = "4.0.2"
3
+ version = "4.2.0"
4
4
  description = "ErisPulse邮箱适配器,支持多账号管理、HTML内容和附件发送"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1 +0,0 @@
1
- from .Core import EmailAdapter