dwyeapi 0.9.4__tar.gz → 0.9.6__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.
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/PKG-INFO +2 -1
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/README.md +2 -1
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/pyproject.toml +3 -1
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/__init__.py +2 -1
- dwyeapi-0.9.6/src/dwyeapi/email.py +41 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/providers/email/__init__.py +3 -2
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/providers/email/base.py +21 -5
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/providers/email/config.py +4 -4
- dwyeapi-0.9.6/src/dwyeapi/providers/email/factory.py +86 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/providers/email/protocol.py +4 -4
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/providers/email/resend.py +24 -0
- dwyeapi-0.9.6/src/dwyeapi/tasks/schema.py +42 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi.egg-info/PKG-INFO +2 -1
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi.egg-info/SOURCES.txt +2 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi.egg-info/requires.txt +1 -0
- dwyeapi-0.9.6/tests/test_email.py +31 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_tasks.py +24 -0
- dwyeapi-0.9.4/src/dwyeapi/providers/email/factory.py +0 -80
- dwyeapi-0.9.4/src/dwyeapi/tasks/schema.py +0 -31
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/setup.cfg +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/cache.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/config.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/database.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/dependencies.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/dt.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/exceptions.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/health.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/logger.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/masking.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/pagination.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/providers/__init__.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/response.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/security.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/tasks/__init__.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/tasks/context.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/tasks/model.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/tasks/pool.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/tasks/registry.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/tasks/router.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/tasks/service.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/tasks/worker.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi.egg-info/dependency_links.txt +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi.egg-info/top_level.txt +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_cache.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_config.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_database.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_dependencies.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_dt.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_exceptions.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_health.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_logger.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_masking.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_pagination.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_response.py +0 -0
- {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_security.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dwyeapi
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.6
|
|
4
4
|
Summary: Lightweight FastAPI infrastructure — exceptions, database, config, security, cache, pagination, logger
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Repository, https://github.com/danweiyuancircle/dwy-ai-experience
|
|
@@ -27,3 +27,4 @@ Requires-Dist: aiosqlite>=0.20.0; extra == "dev"
|
|
|
27
27
|
Requires-Dist: ruff>=0.6.0; extra == "dev"
|
|
28
28
|
Requires-Dist: arq>=0.26.0; extra == "dev"
|
|
29
29
|
Requires-Dist: fakeredis>=2.0.0; extra == "dev"
|
|
30
|
+
Requires-Dist: resend[async]>=2.29.0; extra == "dev"
|
|
@@ -19,7 +19,8 @@ pip install dwyeapi
|
|
|
19
19
|
- `response` — Unified API response helpers
|
|
20
20
|
- `pagination` — PaginationParams + paginate helper
|
|
21
21
|
- `logger` — Facade logger (loguru-backed) with daily + size rotation and stdlib interception; downstream code depends on `dwyeapi.logger.Logger`, not loguru directly
|
|
22
|
-
- `
|
|
22
|
+
- `email` — 邮箱规范化(Gmail plus / 点号 / googlemail 折成同一收件箱;注册查重、登录查找必须走 `canonicalize_email`)
|
|
23
|
+
- `providers.email` — 邮件验证码 Provider 工厂(内置 resend;支持业务通过 `register_email_provider` 注入自定义实现;验证码 Redis key 已自动规范化)
|
|
23
24
|
|
|
24
25
|
## Environment (dev / prod)
|
|
25
26
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dwyeapi"
|
|
3
|
-
version = "0.9.
|
|
3
|
+
version = "0.9.6"
|
|
4
4
|
description = "Lightweight FastAPI infrastructure — exceptions, database, config, security, cache, pagination, logger"
|
|
5
5
|
requires-python = ">=3.11"
|
|
6
6
|
license = {text = "MIT"}
|
|
@@ -35,6 +35,8 @@ dev = [
|
|
|
35
35
|
"ruff>=0.6.0",
|
|
36
36
|
"arq>=0.26.0",
|
|
37
37
|
"fakeredis>=2.0.0",
|
|
38
|
+
# 内置 Email provider 是 Resend;不装则构造/_send 用例只能 skip,测不到内置实现
|
|
39
|
+
"resend[async]>=2.29.0",
|
|
38
40
|
]
|
|
39
41
|
|
|
40
42
|
[project.urls]
|
|
@@ -15,6 +15,7 @@ Modules:
|
|
|
15
15
|
logger — 基于 loguru 的全局日志,支持按天与按大小轮转、stdlib 拦截
|
|
16
16
|
tasks — 异步任务系统(基于 ARQ,需安装 [tasks] extra)
|
|
17
17
|
masking — PII 数据脱敏工具
|
|
18
|
+
email — 邮箱规范化(Gmail plus/点号/googlemail 折成同一收件箱)
|
|
18
19
|
health — 健康检查路由工厂(只探活,不探依赖)
|
|
19
20
|
"""
|
|
20
21
|
|
|
@@ -32,4 +33,4 @@ __all__ = [
|
|
|
32
33
|
"is_prod",
|
|
33
34
|
"logger",
|
|
34
35
|
]
|
|
35
|
-
__version__ = "0.9.
|
|
36
|
+
__version__ = "0.9.6"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""邮箱规范化.
|
|
2
|
+
|
|
3
|
+
Gmail / Googlemail 把 plus 别名和点号视为同一收件箱;其它域名只做大小写折叠.
|
|
4
|
+
查重、发码 Redis key、登录查找必须走规范化结果,否则同一邮箱可开多个号.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Gmail 把 googlemail.com 当成 gmail.com 的别名,必须折到同一域名.
|
|
8
|
+
GMAIL_DOMAINS = frozenset({"gmail.com", "googlemail.com"})
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def canonicalize_email(email: str) -> str:
|
|
12
|
+
"""把邮箱折成唯一收件箱标识.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
email: 用户输入的邮箱.长度建议 [3, 254].示例:``Bai.Wen+1@Gmail.com``.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
规范化邮箱.Gmail 示例:``baiwen@gmail.com``;其它域名仅 lower.
|
|
19
|
+
无 ``@`` 时返回 strip + lower,不抛异常,交给上游格式校验.
|
|
20
|
+
"""
|
|
21
|
+
normalized = email.strip().lower()
|
|
22
|
+
if "@" not in normalized:
|
|
23
|
+
return normalized
|
|
24
|
+
local, domain = normalized.rsplit("@", 1)
|
|
25
|
+
if domain in GMAIL_DOMAINS:
|
|
26
|
+
local = local.split("+", 1)[0].replace(".", "")
|
|
27
|
+
domain = "gmail.com"
|
|
28
|
+
return f"{local}@{domain}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_folded_alias(email: str) -> bool:
|
|
32
|
+
"""输入是否因 Gmail 点号 / plus / googlemail 被折叠.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
email: 用户输入.示例:``a+shop@gmail.com``.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
True 表示与规范化结果不同,应按同一收件箱处理.
|
|
39
|
+
仅大小写差异不算折叠(``A@gmail.com`` -> False).
|
|
40
|
+
"""
|
|
41
|
+
return canonicalize_email(email) != email.strip().lower()
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""Email Provider 模块 -- 提供可插拔的邮件验证码发送能力。
|
|
2
2
|
|
|
3
3
|
模块由 Protocol 抽象 + 抽象基类 + 配置类 + 工厂函数 + 内置 resend 实现组成。
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
内置与自定义走同一注册表;`make_email_provider` 只按名查表。
|
|
5
|
+
业务可通过 `register_email_provider` 注入自定义 provider
|
|
6
|
+
(继承 `EmailProviderBase` 实现 `_send`,即可复用 Redis 验证码 + 品牌化模板,无需改 eapi)。
|
|
6
7
|
|
|
7
8
|
典型用法 -- 内置 resend:
|
|
8
9
|
from dwyeapi.providers.email import EmailSettings, make_email_provider
|
|
@@ -7,6 +7,7 @@ from html import escape
|
|
|
7
7
|
import redis.asyncio as aioredis
|
|
8
8
|
|
|
9
9
|
from dwyeapi.cache import get_redis
|
|
10
|
+
from dwyeapi.email import canonicalize_email
|
|
10
11
|
|
|
11
12
|
CODE_KEY_PREFIX = "dwyeapi:email:code:"
|
|
12
13
|
DEFAULT_CODE_TTL = 300
|
|
@@ -62,17 +63,32 @@ class EmailProviderBase(ABC):
|
|
|
62
63
|
"""生成指定位数的数字验证码。"""
|
|
63
64
|
return "".join(secrets.choice("0123456789") for _ in range(self._length))
|
|
64
65
|
|
|
66
|
+
def _code_key(self, target: str) -> str:
|
|
67
|
+
"""验证码 Redis key.Gmail plus/点号/googlemail 折成同一收件箱,避免别名绕过."""
|
|
68
|
+
return f"{CODE_KEY_PREFIX}{canonicalize_email(target)}"
|
|
69
|
+
|
|
65
70
|
async def send_code(self, target: str) -> bool:
|
|
66
|
-
"""
|
|
71
|
+
"""生成验证码:先发送成功再写入 Redis.
|
|
72
|
+
|
|
73
|
+
顺序约束:``_send`` 失败时不落库,避免用户未收到邮件却有可校验码.
|
|
74
|
+
发送成功后写 Redis;若写库失败则返回 False(邮件已发出属极端边界,业务可重发覆盖).
|
|
75
|
+
发信信封用用户输入的地址;Redis key 走 ``canonicalize_email``,Gmail 别名共用同一码.
|
|
76
|
+
"""
|
|
67
77
|
code = self._generate_code()
|
|
78
|
+
deliver_to = target.strip()
|
|
79
|
+
if not await self._send(deliver_to, code):
|
|
80
|
+
return False
|
|
68
81
|
redis = await self._get_redis()
|
|
69
|
-
await redis.set(
|
|
70
|
-
return
|
|
82
|
+
await redis.set(self._code_key(target), code, ex=self._ttl)
|
|
83
|
+
return True
|
|
71
84
|
|
|
72
85
|
async def verify_code(self, target: str, code: str) -> bool:
|
|
73
|
-
"""从 Redis 读取存储的验证码比对,成功则删除 key(一次性)
|
|
86
|
+
"""从 Redis 读取存储的验证码比对,成功则删除 key(一次性).
|
|
87
|
+
|
|
88
|
+
``target`` 经 ``canonicalize_email`` 后再查 key,与 ``send_code`` 对齐.
|
|
89
|
+
"""
|
|
74
90
|
redis = await self._get_redis()
|
|
75
|
-
key =
|
|
91
|
+
key = self._code_key(target)
|
|
76
92
|
stored = await redis.get(key)
|
|
77
93
|
if stored is None:
|
|
78
94
|
return False
|
|
@@ -34,16 +34,16 @@ class EmailSettings(BaseModel):
|
|
|
34
34
|
`.env` 自动识别(双下划线嵌套):
|
|
35
35
|
EMAIL__PROVIDER=resend
|
|
36
36
|
EMAIL__BRAND_NAME=宽舟科技
|
|
37
|
-
EMAIL__BRAND_URL=https://
|
|
38
|
-
EMAIL__SUPPORT_EMAIL=support@
|
|
37
|
+
EMAIL__BRAND_URL=https://example.com
|
|
38
|
+
EMAIL__SUPPORT_EMAIL=support@example.com
|
|
39
39
|
EMAIL__RESEND__API_KEY=re_xxx
|
|
40
40
|
"""
|
|
41
41
|
|
|
42
42
|
provider: str = Field(
|
|
43
43
|
default="resend",
|
|
44
44
|
description=(
|
|
45
|
-
"Email Provider
|
|
46
|
-
"
|
|
45
|
+
"Email Provider 名称。内置见工厂注册表(当前 resend);"
|
|
46
|
+
"自定义先 register_email_provider(name, factory) 再填对应名称"
|
|
47
47
|
),
|
|
48
48
|
)
|
|
49
49
|
code_ttl: int = Field(
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Email Provider 工厂 -- 内置与自定义走同一注册表。
|
|
2
|
+
|
|
3
|
+
`make_email_provider` 只按名查表,不再对 resend 写死 if。
|
|
4
|
+
内置通道用懒加载器写入注册表,缺 extra 时由对应 builder 抛 ImportError。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
|
|
9
|
+
from dwyeapi.logger import get_logger
|
|
10
|
+
from dwyeapi.providers.email.config import EmailSettings
|
|
11
|
+
from dwyeapi.providers.email.protocol import EmailProvider
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
EmailProviderFactory = Callable[[EmailSettings], EmailProvider]
|
|
16
|
+
|
|
17
|
+
_REGISTRY: dict[str, EmailProviderFactory] = {}
|
|
18
|
+
# 测试仍 import 此名;与 _REGISTRY 是同一 dict
|
|
19
|
+
_PROVIDER_REGISTRY = _REGISTRY
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_resend() -> EmailProviderFactory:
|
|
23
|
+
"""延迟 import,避免未装 email-resend extra 时加载 SDK。"""
|
|
24
|
+
from dwyeapi.providers.email.resend import build_resend_provider
|
|
25
|
+
|
|
26
|
+
return build_resend_provider
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# 新增内置通道只在此加一行,工厂查找逻辑不动
|
|
30
|
+
_BUILTIN_LOADERS: dict[str, Callable[[], EmailProviderFactory]] = {
|
|
31
|
+
"resend": _load_resend,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _ensure_builtin(name: str) -> None:
|
|
36
|
+
"""首次用到某内置名时写入注册表。注册表被测试 clear 后也能补回。"""
|
|
37
|
+
if name in _REGISTRY or name not in _BUILTIN_LOADERS:
|
|
38
|
+
return
|
|
39
|
+
_REGISTRY[name] = _BUILTIN_LOADERS[name]()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def register_email_provider(name: str, factory: EmailProviderFactory) -> None:
|
|
43
|
+
"""注册自定义 Email Provider。
|
|
44
|
+
|
|
45
|
+
业务项目继承 `EmailProviderBase` 实现 `_send` 后,通过此函数注册到工厂,
|
|
46
|
+
`.env` 设 `EMAIL__PROVIDER=<name>` 即可启用,无需改 eapi。
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
name: provider 名称,与 EMAIL__PROVIDER 环境变量值对应。
|
|
50
|
+
factory: 工厂函数,接收 EmailSettings 返回 EmailProvider 实例。
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
ValueError: 名称为空或与内置名称冲突。
|
|
54
|
+
"""
|
|
55
|
+
if not name:
|
|
56
|
+
raise ValueError("provider 名称不能为空")
|
|
57
|
+
if name in _BUILTIN_LOADERS:
|
|
58
|
+
raise ValueError(f"'{name}' 为内置 provider 名称,不可覆盖")
|
|
59
|
+
if name in _REGISTRY:
|
|
60
|
+
logger.warning("Email provider %s 被覆盖注册", name)
|
|
61
|
+
_REGISTRY[name] = factory
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def make_email_provider(settings: EmailSettings) -> EmailProvider:
|
|
65
|
+
"""根据 `settings.provider` 从注册表构造实例。
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
settings: Email 模块配置(项目 Settings 的嵌套字段)。
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
实现了 `EmailProvider` Protocol 的实例。
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ValueError: 未知 provider 或必填配置缺失。
|
|
75
|
+
ImportError: 对应内置 extra 未安装。
|
|
76
|
+
"""
|
|
77
|
+
name = settings.provider
|
|
78
|
+
_ensure_builtin(name)
|
|
79
|
+
factory = _REGISTRY.get(name)
|
|
80
|
+
if factory is None:
|
|
81
|
+
builtins = ", ".join(sorted(_BUILTIN_LOADERS))
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"未知 email provider: {name};"
|
|
84
|
+
f"内置: {builtins};自定义需先调用 register_email_provider 注册"
|
|
85
|
+
)
|
|
86
|
+
return factory(settings)
|
|
@@ -6,15 +6,15 @@ from typing import Protocol
|
|
|
6
6
|
class EmailProvider(Protocol):
|
|
7
7
|
"""邮箱验证码服务抽象接口。
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
内置与自定义走同一注册表;通过 `make_email_provider(settings.email)` 按名构造。
|
|
10
|
+
业务可通过 `register_email_provider` 注入自定义实现,无需改 eapi。
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
async def send_code(self, target: str) -> bool:
|
|
14
14
|
"""生成并发送邮箱验证码。
|
|
15
15
|
|
|
16
16
|
Args:
|
|
17
|
-
target:
|
|
17
|
+
target: 收件邮箱.发信用原地址;Redis key 走 ``canonicalize_email``.
|
|
18
18
|
|
|
19
19
|
Returns:
|
|
20
20
|
发送成功 True,失败 False(异常已记录日志)。
|
|
@@ -25,7 +25,7 @@ class EmailProvider(Protocol):
|
|
|
25
25
|
"""校验邮箱验证码,成功后一次性删除。
|
|
26
26
|
|
|
27
27
|
Args:
|
|
28
|
-
target:
|
|
28
|
+
target: 收件邮箱(Gmail 别名与发码时同一收件箱即可).
|
|
29
29
|
code: 用户提交的验证码。
|
|
30
30
|
|
|
31
31
|
Returns:
|
|
@@ -7,6 +7,7 @@ import redis.asyncio as aioredis
|
|
|
7
7
|
|
|
8
8
|
from dwyeapi.logger import get_logger
|
|
9
9
|
from dwyeapi.providers.email.base import DEFAULT_CODE_LENGTH, DEFAULT_CODE_TTL, EmailProviderBase
|
|
10
|
+
from dwyeapi.providers.email.config import EmailSettings
|
|
10
11
|
|
|
11
12
|
logger = get_logger(__name__)
|
|
12
13
|
|
|
@@ -85,3 +86,26 @@ class ResendEmailProvider(EmailProviderBase):
|
|
|
85
86
|
except Exception as e:
|
|
86
87
|
logger.error("Resend email failed: target=%s error=%s", target, e)
|
|
87
88
|
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_resend_provider(settings: EmailSettings) -> ResendEmailProvider:
|
|
92
|
+
"""注册表回调:校验 Resend 配置后构造实例。
|
|
93
|
+
|
|
94
|
+
工厂只按名查表,通道特有校验留在本函数,避免 factory.py 写死 if resend。
|
|
95
|
+
"""
|
|
96
|
+
if not settings.resend.api_key:
|
|
97
|
+
raise ValueError("EMAIL__RESEND__API_KEY 未配置")
|
|
98
|
+
if not settings.resend.from_email:
|
|
99
|
+
raise ValueError("EMAIL__RESEND__FROM_EMAIL 未配置")
|
|
100
|
+
return ResendEmailProvider(
|
|
101
|
+
api_key=settings.resend.api_key,
|
|
102
|
+
from_email=settings.resend.from_email,
|
|
103
|
+
subject=settings.resend.subject,
|
|
104
|
+
code_ttl=settings.code_ttl,
|
|
105
|
+
code_length=settings.code_length,
|
|
106
|
+
brand_name=settings.brand_name,
|
|
107
|
+
brand_tagline=settings.brand_tagline,
|
|
108
|
+
brand_url=settings.brand_url,
|
|
109
|
+
brand_slogan=settings.brand_slogan,
|
|
110
|
+
support_email=settings.support_email,
|
|
111
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""任务 API 请求/响应的 Pydantic schema。"""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
from dwyeapi.tasks.model import TaskStatus
|
|
9
|
+
|
|
10
|
+
# 与 Task ORM 列 String(50) 对齐;params 键数/体积限制防超大 body
|
|
11
|
+
_TASK_TYPE_MAX_LEN = 50
|
|
12
|
+
_PARAMS_MAX_KEYS = 50
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TaskCreate(BaseModel):
|
|
16
|
+
"""提交新任务的请求体。
|
|
17
|
+
|
|
18
|
+
约束与 ORM / 输入限长规则对齐:``task_type`` 最长 50;``params`` 最多 50 个键。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
task_type: str = Field(min_length=1, max_length=_TASK_TYPE_MAX_LEN, description="已注册的任务类型名")
|
|
22
|
+
params: dict[str, Any] = Field(
|
|
23
|
+
default_factory=dict,
|
|
24
|
+
description="任务参数;键数量上限 50,避免超大 JSON 入库",
|
|
25
|
+
max_length=_PARAMS_MAX_KEYS,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TaskResponse(BaseModel):
|
|
30
|
+
"""任务详情响应体,覆盖状态、进度、日志与结果等字段。"""
|
|
31
|
+
|
|
32
|
+
id: str
|
|
33
|
+
task_type: str
|
|
34
|
+
status: TaskStatus
|
|
35
|
+
params: dict[str, Any]
|
|
36
|
+
result: dict[str, Any] | None
|
|
37
|
+
progress: int
|
|
38
|
+
logs: str
|
|
39
|
+
created_at: datetime
|
|
40
|
+
updated_at: datetime
|
|
41
|
+
|
|
42
|
+
model_config = ConfigDict(from_attributes=True)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dwyeapi
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.6
|
|
4
4
|
Summary: Lightweight FastAPI infrastructure — exceptions, database, config, security, cache, pagination, logger
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Repository, https://github.com/danweiyuancircle/dwy-ai-experience
|
|
@@ -27,3 +27,4 @@ Requires-Dist: aiosqlite>=0.20.0; extra == "dev"
|
|
|
27
27
|
Requires-Dist: ruff>=0.6.0; extra == "dev"
|
|
28
28
|
Requires-Dist: arq>=0.26.0; extra == "dev"
|
|
29
29
|
Requires-Dist: fakeredis>=2.0.0; extra == "dev"
|
|
30
|
+
Requires-Dist: resend[async]>=2.29.0; extra == "dev"
|
|
@@ -6,6 +6,7 @@ src/dwyeapi/config.py
|
|
|
6
6
|
src/dwyeapi/database.py
|
|
7
7
|
src/dwyeapi/dependencies.py
|
|
8
8
|
src/dwyeapi/dt.py
|
|
9
|
+
src/dwyeapi/email.py
|
|
9
10
|
src/dwyeapi/exceptions.py
|
|
10
11
|
src/dwyeapi/health.py
|
|
11
12
|
src/dwyeapi/logger.py
|
|
@@ -39,6 +40,7 @@ tests/test_config.py
|
|
|
39
40
|
tests/test_database.py
|
|
40
41
|
tests/test_dependencies.py
|
|
41
42
|
tests/test_dt.py
|
|
43
|
+
tests/test_email.py
|
|
42
44
|
tests/test_exceptions.py
|
|
43
45
|
tests/test_health.py
|
|
44
46
|
tests/test_logger.py
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Tests for dwyeapi.email(Gmail plus/点号/googlemail 折成同一收件箱)."""
|
|
2
|
+
|
|
3
|
+
from dwyeapi.email import canonicalize_email, is_folded_alias
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestCanonicalizeEmail:
|
|
7
|
+
def test_strips_gmail_plus_and_dots(self):
|
|
8
|
+
"""gmail.com / googlemail.com 去掉 +tag 与点,域名统一 gmail.com."""
|
|
9
|
+
assert canonicalize_email("BaiWen4243+5623@gmail.com") == "baiwen4243@gmail.com"
|
|
10
|
+
assert canonicalize_email("bai.wen.4243+x@googlemail.com") == "baiwen4243@gmail.com"
|
|
11
|
+
assert canonicalize_email(" User.Name+tag@Gmail.COM ") == "username@gmail.com"
|
|
12
|
+
|
|
13
|
+
def test_other_domains_only_lower(self):
|
|
14
|
+
"""非 Gmail 只做大小写与首尾空白规范化,保留 + 与点."""
|
|
15
|
+
assert canonicalize_email("Alfred.Yuan+ops@icloud.com") == "alfred.yuan+ops@icloud.com"
|
|
16
|
+
assert canonicalize_email("a.b@example.com") == "a.b@example.com"
|
|
17
|
+
|
|
18
|
+
def test_missing_at_returns_stripped_lower(self):
|
|
19
|
+
"""无 @ 的非法输入不抛,只做 strip + lower,交给上游校验."""
|
|
20
|
+
assert canonicalize_email(" NotAnEmail ") == "notanemail"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestIsFoldedAlias:
|
|
24
|
+
def test_detects_gmail_variants(self):
|
|
25
|
+
"""Gmail plus / 点 / googlemail 视为折叠别名;仅大小写不算."""
|
|
26
|
+
assert is_folded_alias("a+x@gmail.com") is True
|
|
27
|
+
assert is_folded_alias("a.b@gmail.com") is True
|
|
28
|
+
assert is_folded_alias("a@googlemail.com") is True
|
|
29
|
+
assert is_folded_alias("a@gmail.com") is False
|
|
30
|
+
assert is_folded_alias("A@gmail.com") is False
|
|
31
|
+
assert is_folded_alias("a+x@icloud.com") is False
|
|
@@ -127,6 +127,30 @@ class TestSchemas:
|
|
|
127
127
|
assert tc.task_type == "export"
|
|
128
128
|
assert tc.params == {"format": "csv"}
|
|
129
129
|
|
|
130
|
+
def test_task_create_rejects_empty_task_type(self):
|
|
131
|
+
"""task_type 最短 1 字符。"""
|
|
132
|
+
import pytest
|
|
133
|
+
from pydantic import ValidationError
|
|
134
|
+
|
|
135
|
+
with pytest.raises(ValidationError):
|
|
136
|
+
TaskCreate(task_type="", params={})
|
|
137
|
+
|
|
138
|
+
def test_task_create_rejects_long_task_type(self):
|
|
139
|
+
"""task_type 最长 50,对齐 ORM String(50)。"""
|
|
140
|
+
import pytest
|
|
141
|
+
from pydantic import ValidationError
|
|
142
|
+
|
|
143
|
+
with pytest.raises(ValidationError):
|
|
144
|
+
TaskCreate(task_type="x" * 51, params={})
|
|
145
|
+
|
|
146
|
+
def test_task_create_rejects_too_many_params_keys(self):
|
|
147
|
+
"""params 键数上限 50。"""
|
|
148
|
+
import pytest
|
|
149
|
+
from pydantic import ValidationError
|
|
150
|
+
|
|
151
|
+
with pytest.raises(ValidationError):
|
|
152
|
+
TaskCreate(task_type="export", params={f"k{i}": i for i in range(51)})
|
|
153
|
+
|
|
130
154
|
async def test_task_response_from_attributes(self, session):
|
|
131
155
|
"""TaskResponse should be constructible from a persisted Task ORM object."""
|
|
132
156
|
task = Task(
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
"""Email Provider 工厂 -- 内置 resend,支持外部注册自定义 provider。"""
|
|
2
|
-
|
|
3
|
-
from collections.abc import Callable
|
|
4
|
-
|
|
5
|
-
from dwyeapi.logger import get_logger
|
|
6
|
-
from dwyeapi.providers.email.config import EmailSettings
|
|
7
|
-
from dwyeapi.providers.email.protocol import EmailProvider
|
|
8
|
-
|
|
9
|
-
logger = get_logger(__name__)
|
|
10
|
-
|
|
11
|
-
EmailProviderFactory = Callable[[EmailSettings], EmailProvider]
|
|
12
|
-
|
|
13
|
-
_BUILTIN_RESEND = "resend"
|
|
14
|
-
_PROVIDER_REGISTRY: dict[str, EmailProviderFactory] = {}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def register_email_provider(name: str, factory: EmailProviderFactory) -> None:
|
|
18
|
-
"""注册自定义 Email Provider。
|
|
19
|
-
|
|
20
|
-
业务项目继承 `EmailProviderBase` 实现 `_send` 后,通过此函数注册到工厂,
|
|
21
|
-
`.env` 设 `EMAIL__PROVIDER=<name>` 即可启用。
|
|
22
|
-
|
|
23
|
-
Args:
|
|
24
|
-
name: provider 名称,与 EMAIL__PROVIDER 环境变量值对应。
|
|
25
|
-
factory: 工厂函数,接收 EmailSettings 返回 EmailProvider 实例。
|
|
26
|
-
|
|
27
|
-
Raises:
|
|
28
|
-
ValueError: 名称为空或与内置名称冲突。
|
|
29
|
-
"""
|
|
30
|
-
if not name:
|
|
31
|
-
raise ValueError("provider 名称不能为空")
|
|
32
|
-
if name == _BUILTIN_RESEND:
|
|
33
|
-
raise ValueError(f"'{name}' 为内置 provider 名称,不可覆盖")
|
|
34
|
-
if name in _PROVIDER_REGISTRY:
|
|
35
|
-
logger.warning("Email provider %s 被覆盖注册", name)
|
|
36
|
-
_PROVIDER_REGISTRY[name] = factory
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def make_email_provider(settings: EmailSettings) -> EmailProvider:
|
|
40
|
-
"""根据 `settings.provider` 构造对应的 Email Provider 实例。
|
|
41
|
-
|
|
42
|
-
Args:
|
|
43
|
-
settings: Email 模块配置(项目 Settings 的嵌套字段)。
|
|
44
|
-
|
|
45
|
-
Returns:
|
|
46
|
-
实现了 `EmailProvider` Protocol 的实例。
|
|
47
|
-
|
|
48
|
-
Raises:
|
|
49
|
-
ValueError: 未知 provider 或必填配置缺失。
|
|
50
|
-
ImportError: resend extra 未安装。
|
|
51
|
-
"""
|
|
52
|
-
common = {
|
|
53
|
-
"code_ttl": settings.code_ttl,
|
|
54
|
-
"code_length": settings.code_length,
|
|
55
|
-
"brand_name": settings.brand_name,
|
|
56
|
-
"brand_tagline": settings.brand_tagline,
|
|
57
|
-
"brand_url": settings.brand_url,
|
|
58
|
-
"brand_slogan": settings.brand_slogan,
|
|
59
|
-
"support_email": settings.support_email,
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if settings.provider == _BUILTIN_RESEND:
|
|
63
|
-
from dwyeapi.providers.email.resend import ResendEmailProvider
|
|
64
|
-
|
|
65
|
-
if not settings.resend.api_key:
|
|
66
|
-
raise ValueError("EMAIL__RESEND__API_KEY 未配置")
|
|
67
|
-
return ResendEmailProvider(
|
|
68
|
-
api_key=settings.resend.api_key,
|
|
69
|
-
from_email=settings.resend.from_email,
|
|
70
|
-
subject=settings.resend.subject,
|
|
71
|
-
**common,
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
if settings.provider in _PROVIDER_REGISTRY:
|
|
75
|
-
return _PROVIDER_REGISTRY[settings.provider](settings)
|
|
76
|
-
|
|
77
|
-
raise ValueError(
|
|
78
|
-
f"未知 email provider: {settings.provider};"
|
|
79
|
-
f"内置仅支持 'resend',自定义 provider 需先调用 register_email_provider 注册"
|
|
80
|
-
)
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
"""任务 API 请求/响应的 Pydantic schema。"""
|
|
2
|
-
|
|
3
|
-
from datetime import datetime
|
|
4
|
-
from typing import Any
|
|
5
|
-
|
|
6
|
-
from pydantic import BaseModel, ConfigDict
|
|
7
|
-
|
|
8
|
-
from dwyeapi.tasks.model import TaskStatus
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class TaskCreate(BaseModel):
|
|
12
|
-
"""提交新任务的请求体。"""
|
|
13
|
-
|
|
14
|
-
task_type: str
|
|
15
|
-
params: dict[str, Any]
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class TaskResponse(BaseModel):
|
|
19
|
-
"""任务详情响应体,覆盖状态、进度、日志与结果等字段。"""
|
|
20
|
-
|
|
21
|
-
id: str
|
|
22
|
-
task_type: str
|
|
23
|
-
status: TaskStatus
|
|
24
|
-
params: dict[str, Any]
|
|
25
|
-
result: dict[str, Any] | None
|
|
26
|
-
progress: int
|
|
27
|
-
logs: str
|
|
28
|
-
created_at: datetime
|
|
29
|
-
updated_at: datetime
|
|
30
|
-
|
|
31
|
-
model_config = ConfigDict(from_attributes=True)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|