wr-common-lib 0.1.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.
@@ -0,0 +1,7 @@
1
+ dist/
2
+ build/
3
+ *.egg-info/
4
+ .venv/
5
+ __pycache__/
6
+ *.py[cod]
7
+ .pytest_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wr-common-lib contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.4
2
+ Name: wr-common-lib
3
+ Version: 0.1.0
4
+ Summary: 气象导航公用库:领域枚举与共享数据访问
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Provides-Extra: db
9
+ Requires-Dist: async-db-tools>=0.1.0; extra == 'db'
10
+ Description-Content-Type: text/markdown
11
+
12
+ # wr-common-lib
13
+
14
+ 气象导航各微服务共用的 Python 库:领域枚举、任务类型、以及已定表的共享数据访问(`EmailDbOper` 等)。
15
+
16
+ | 概念 | 名称 |
17
+ |------|------|
18
+ | PyPI / pip 包名 | `wr-common-lib` |
19
+ | import 包名 | `wr_common_lib` |
20
+ | 本仓库目录 | `common-lib/` |
21
+
22
+ 要求 **Python ≥ 3.12**。
23
+
24
+ ## 目录结构
25
+
26
+ ```
27
+ common-lib/
28
+ ├── LICENSE
29
+ ├── pyproject.toml
30
+ ├── README.md
31
+ └── src/wr_common_lib/
32
+ ├── __init__.py # __version__
33
+ └── email/ # email 表相关
34
+ ├── constants.py # MailFlow, MailStatus(对齐 PG 枚举)
35
+ ├── types.py # OutboundTask, InboundTask
36
+ └── db_oper.py # EmailDbOper(需 [db] 可选依赖)
37
+ ```
38
+
39
+ 后续新业务域可在 `wr_common_lib/` 下增加子包(例如 `voyage/`),与 `email/` 平级。
40
+
41
+ ## 安装
42
+
43
+ ### 仅枚举 / 类型(无数据库依赖)
44
+
45
+ ```bash
46
+ pip install wr-common-lib
47
+ # 或 monorepo 本地
48
+ pip install -e /path/to/common-lib
49
+ ```
50
+
51
+ ### 含 `EmailDbOper`(依赖 async-db-tools)
52
+
53
+ ```bash
54
+ pip install "wr-common-lib[db]"
55
+ ```
56
+
57
+ ### monorepo 本地联调(推荐)
58
+
59
+ 在 `publish` 仓库内,先装数据库工具包,再装本库:
60
+
61
+ ```bash
62
+ pip install -e ../dbtools
63
+ pip install -e ".[db]"
64
+ ```
65
+
66
+ 若业务项目的 `pyproject.toml` 使用 path 依赖,示例:
67
+
68
+ ```toml
69
+ dependencies = [
70
+ "wr-common-lib[db] @ file:///${PROJECT_ROOT}/../common-lib",
71
+ "async-db-tools @ file:///${PROJECT_ROOT}/../dbtools",
72
+ ]
73
+ ```
74
+
75
+ (路径按你本机 monorepo 布局调整;也可用 `uv` / `poetry` 的 path / workspace 写法。)
76
+
77
+ ## 使用
78
+
79
+ ### 邮件枚举
80
+
81
+ 与 PostgreSQL `mail_flow`、`mail_status` 枚举值一致,可直接 `.value` 写入 SQL 或 ORM。
82
+
83
+ ```python
84
+ from wr_common_lib.email import MailFlow, MailStatus
85
+
86
+ MailFlow.OUTBOUND # "OUTBOUND"
87
+ MailStatus.QUEUED.value # "QUEUED"
88
+
89
+ if MailStatus.SENT.is_terminal:
90
+ ...
91
+ if MailStatus.PENDING.is_outbound_lifecycle:
92
+ ...
93
+ ```
94
+
95
+ ### 任务类型(TypedDict)
96
+
97
+ 出站、入站字段名不同,用类型区分,避免 `dict` 混用 `to` / `mail_to`:
98
+
99
+ ```python
100
+ from wr_common_lib.email import OutboundTask, InboundTask
101
+
102
+ task: OutboundTask = {
103
+ "imo": 1234567,
104
+ "voyage_id": "...",
105
+ "mail_from": "noreply@example.com",
106
+ "to": "ship@example.com",
107
+ "content_hash": "abc...",
108
+ }
109
+ ```
110
+
111
+ ### 共享表操作 `EmailDbOper`
112
+
113
+ 需已安装 `[db]`,并注入各服务自己的 `PostgresPool`:
114
+
115
+ ```python
116
+ from async_db_tools import PostgresPool
117
+ from wr_common_lib.email import EmailDbOper, MailStatus
118
+
119
+ db_oper = EmailDbOper(pool)
120
+
121
+ email_id = await db_oper.mark_queued(task)
122
+ await db_oper.mark_sent(email_id)
123
+ await db_oper.mark_failed(email_id)
124
+
125
+ row = await db_oper.find_by_content_hash("...")
126
+ email_id = await db_oper.insert_inbound_received(inbound_task, content_hash)
127
+ await db_oper.mark_parsed(email_id, {"key": "value"})
128
+ ```
129
+
130
+ `EmailDbOper` 在 `wr_common_lib.email` 中为**懒加载**:只 `import MailStatus` 时不会要求安装 `async-db-tools`;只有 `import EmailDbOper` 时才加载 `db_oper` 模块。
131
+
132
+ ### 邮件状态(约定)
133
+
134
+ ```
135
+ 出站 · 发送服务: PENDING → QUEUED → SENT | FAILED
136
+ 出站 · Webhook: SENT → DELIVERED | BOUNCED | DEFERRED
137
+ 入站 · 收件服务: RECEIVED → PARSED | PARSE_FAILED
138
+ ```
139
+
140
+ ## 依赖说明
141
+
142
+ | 安装方式 | 引入的依赖 |
143
+ |----------|------------|
144
+ | `wr-common-lib` | 无 |
145
+ | `wr-common-lib[db]` | `async-db-tools`(PostgreSQL 连接池) |
146
+
147
+ 业务服务仍需自行配置数据库连接;本库只封装对 `email` 表的 SQL。
148
+
149
+ ## 开发与发布
150
+
151
+ ```bash
152
+ # 可编辑安装(monorepo 内先装 dbtools)
153
+ uv pip install -e ../dbtools
154
+ uv pip install -e ".[db]"
155
+
156
+ # 发版前改版本(可选)
157
+ uv version 0.1.1
158
+
159
+ # 构建并发布到 PyPI(推荐)
160
+ export UV_PUBLISH_TOKEN=pypi-xxxxxxxx # PyPI → Account → API tokens
161
+ uv build
162
+ uv publish
163
+
164
+ # 或一步:构建产物在 dist/ 后上传
165
+ # uv build && uv publish
166
+ ```
167
+
168
+ `uv publish` 会读取 `dist/` 里的 wheel/sdist 并上传;失败重试同一命令即可(PyPI 会忽略已存在的相同文件)。
169
+
170
+ 也可用传统方式:`python -m build` + `twine upload dist/*`。
171
+
172
+ 表结构变更时,在 `src/wr_common_lib/email/db_oper.py` 中扩展方法即可;各服务将 `app.email.*` 的引用改为 `wr_common_lib.email` 后统一升级版本。
173
+
174
+ ## 版本
175
+
176
+ 当前版本见 `wr_common_lib.__version__` 与 `pyproject.toml` 中的 `[project].version`。
177
+
178
+ ## 许可证
179
+
180
+ MIT,见 [LICENSE](LICENSE)。
@@ -0,0 +1,169 @@
1
+ # wr-common-lib
2
+
3
+ 气象导航各微服务共用的 Python 库:领域枚举、任务类型、以及已定表的共享数据访问(`EmailDbOper` 等)。
4
+
5
+ | 概念 | 名称 |
6
+ |------|------|
7
+ | PyPI / pip 包名 | `wr-common-lib` |
8
+ | import 包名 | `wr_common_lib` |
9
+ | 本仓库目录 | `common-lib/` |
10
+
11
+ 要求 **Python ≥ 3.12**。
12
+
13
+ ## 目录结构
14
+
15
+ ```
16
+ common-lib/
17
+ ├── LICENSE
18
+ ├── pyproject.toml
19
+ ├── README.md
20
+ └── src/wr_common_lib/
21
+ ├── __init__.py # __version__
22
+ └── email/ # email 表相关
23
+ ├── constants.py # MailFlow, MailStatus(对齐 PG 枚举)
24
+ ├── types.py # OutboundTask, InboundTask
25
+ └── db_oper.py # EmailDbOper(需 [db] 可选依赖)
26
+ ```
27
+
28
+ 后续新业务域可在 `wr_common_lib/` 下增加子包(例如 `voyage/`),与 `email/` 平级。
29
+
30
+ ## 安装
31
+
32
+ ### 仅枚举 / 类型(无数据库依赖)
33
+
34
+ ```bash
35
+ pip install wr-common-lib
36
+ # 或 monorepo 本地
37
+ pip install -e /path/to/common-lib
38
+ ```
39
+
40
+ ### 含 `EmailDbOper`(依赖 async-db-tools)
41
+
42
+ ```bash
43
+ pip install "wr-common-lib[db]"
44
+ ```
45
+
46
+ ### monorepo 本地联调(推荐)
47
+
48
+ 在 `publish` 仓库内,先装数据库工具包,再装本库:
49
+
50
+ ```bash
51
+ pip install -e ../dbtools
52
+ pip install -e ".[db]"
53
+ ```
54
+
55
+ 若业务项目的 `pyproject.toml` 使用 path 依赖,示例:
56
+
57
+ ```toml
58
+ dependencies = [
59
+ "wr-common-lib[db] @ file:///${PROJECT_ROOT}/../common-lib",
60
+ "async-db-tools @ file:///${PROJECT_ROOT}/../dbtools",
61
+ ]
62
+ ```
63
+
64
+ (路径按你本机 monorepo 布局调整;也可用 `uv` / `poetry` 的 path / workspace 写法。)
65
+
66
+ ## 使用
67
+
68
+ ### 邮件枚举
69
+
70
+ 与 PostgreSQL `mail_flow`、`mail_status` 枚举值一致,可直接 `.value` 写入 SQL 或 ORM。
71
+
72
+ ```python
73
+ from wr_common_lib.email import MailFlow, MailStatus
74
+
75
+ MailFlow.OUTBOUND # "OUTBOUND"
76
+ MailStatus.QUEUED.value # "QUEUED"
77
+
78
+ if MailStatus.SENT.is_terminal:
79
+ ...
80
+ if MailStatus.PENDING.is_outbound_lifecycle:
81
+ ...
82
+ ```
83
+
84
+ ### 任务类型(TypedDict)
85
+
86
+ 出站、入站字段名不同,用类型区分,避免 `dict` 混用 `to` / `mail_to`:
87
+
88
+ ```python
89
+ from wr_common_lib.email import OutboundTask, InboundTask
90
+
91
+ task: OutboundTask = {
92
+ "imo": 1234567,
93
+ "voyage_id": "...",
94
+ "mail_from": "noreply@example.com",
95
+ "to": "ship@example.com",
96
+ "content_hash": "abc...",
97
+ }
98
+ ```
99
+
100
+ ### 共享表操作 `EmailDbOper`
101
+
102
+ 需已安装 `[db]`,并注入各服务自己的 `PostgresPool`:
103
+
104
+ ```python
105
+ from async_db_tools import PostgresPool
106
+ from wr_common_lib.email import EmailDbOper, MailStatus
107
+
108
+ db_oper = EmailDbOper(pool)
109
+
110
+ email_id = await db_oper.mark_queued(task)
111
+ await db_oper.mark_sent(email_id)
112
+ await db_oper.mark_failed(email_id)
113
+
114
+ row = await db_oper.find_by_content_hash("...")
115
+ email_id = await db_oper.insert_inbound_received(inbound_task, content_hash)
116
+ await db_oper.mark_parsed(email_id, {"key": "value"})
117
+ ```
118
+
119
+ `EmailDbOper` 在 `wr_common_lib.email` 中为**懒加载**:只 `import MailStatus` 时不会要求安装 `async-db-tools`;只有 `import EmailDbOper` 时才加载 `db_oper` 模块。
120
+
121
+ ### 邮件状态(约定)
122
+
123
+ ```
124
+ 出站 · 发送服务: PENDING → QUEUED → SENT | FAILED
125
+ 出站 · Webhook: SENT → DELIVERED | BOUNCED | DEFERRED
126
+ 入站 · 收件服务: RECEIVED → PARSED | PARSE_FAILED
127
+ ```
128
+
129
+ ## 依赖说明
130
+
131
+ | 安装方式 | 引入的依赖 |
132
+ |----------|------------|
133
+ | `wr-common-lib` | 无 |
134
+ | `wr-common-lib[db]` | `async-db-tools`(PostgreSQL 连接池) |
135
+
136
+ 业务服务仍需自行配置数据库连接;本库只封装对 `email` 表的 SQL。
137
+
138
+ ## 开发与发布
139
+
140
+ ```bash
141
+ # 可编辑安装(monorepo 内先装 dbtools)
142
+ uv pip install -e ../dbtools
143
+ uv pip install -e ".[db]"
144
+
145
+ # 发版前改版本(可选)
146
+ uv version 0.1.1
147
+
148
+ # 构建并发布到 PyPI(推荐)
149
+ export UV_PUBLISH_TOKEN=pypi-xxxxxxxx # PyPI → Account → API tokens
150
+ uv build
151
+ uv publish
152
+
153
+ # 或一步:构建产物在 dist/ 后上传
154
+ # uv build && uv publish
155
+ ```
156
+
157
+ `uv publish` 会读取 `dist/` 里的 wheel/sdist 并上传;失败重试同一命令即可(PyPI 会忽略已存在的相同文件)。
158
+
159
+ 也可用传统方式:`python -m build` + `twine upload dist/*`。
160
+
161
+ 表结构变更时,在 `src/wr_common_lib/email/db_oper.py` 中扩展方法即可;各服务将 `app.email.*` 的引用改为 `wr_common_lib.email` 后统一升级版本。
162
+
163
+ ## 版本
164
+
165
+ 当前版本见 `wr_common_lib.__version__` 与 `pyproject.toml` 中的 `[project].version`。
166
+
167
+ ## 许可证
168
+
169
+ MIT,见 [LICENSE](LICENSE)。
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "wr-common-lib"
7
+ version = "0.1.0"
8
+ description = "气象导航公用库:领域枚举与共享数据访问"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = { text = "MIT" }
12
+ dependencies = []
13
+
14
+ [project.optional-dependencies]
15
+ db = ["async-db-tools>=0.1.0"]
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["src/wr_common_lib"]
19
+
20
+ [tool.hatch.build.targets.sdist]
21
+ include = [
22
+ "/src",
23
+ "/README.md",
24
+ "/LICENSE",
25
+ "/pyproject.toml",
26
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,18 @@
1
+ from wr_common_lib.email.constants import MailFlow, MailStatus
2
+ from wr_common_lib.email.types import InboundTask, OutboundTask
3
+
4
+ __all__ = [
5
+ "EmailDbOper",
6
+ "InboundTask",
7
+ "MailFlow",
8
+ "MailStatus",
9
+ "OutboundTask",
10
+ ]
11
+
12
+
13
+ def __getattr__(name: str):
14
+ if name == "EmailDbOper":
15
+ from wr_common_lib.email.db_oper import EmailDbOper
16
+
17
+ return EmailDbOper
18
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,63 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class MailFlow(StrEnum):
5
+ """邮件流向,对应 PostgreSQL mail_flow 枚举。"""
6
+
7
+ INBOUND = "INBOUND" # 入站:船舶发来的午报
8
+ OUTBOUND = "OUTBOUND" # 出站:系统发送的邮件
9
+
10
+
11
+ class MailStatus(StrEnum):
12
+ """邮件状态,对应 PostgreSQL mail_status 枚举。
13
+
14
+ 出站 · 本发送服务: PENDING → QUEUED → SENT | FAILED
15
+ 出站 · FastAPI 回调: DELIVERED | BOUNCED | DEFERRED
16
+ 入站 · 收件服务: RECEIVED → PARSED | PARSE_FAILED
17
+ """
18
+
19
+ # 出站 · 本发送服务
20
+ PENDING = "PENDING" # 待发(API 创建)
21
+ QUEUED = "QUEUED" # 已入队,消费中
22
+ SENT = "SENT" # 已提交邮件服务商
23
+ FAILED = "FAILED" # 本服务发送失败
24
+
25
+ # 出站 · FastAPI 回调(webhook)
26
+ DELIVERED = "DELIVERED" # 已送达
27
+ BOUNCED = "BOUNCED" # 拒收 / 无效邮箱
28
+ DEFERRED = "DEFERRED" # 临时失败,可重试
29
+
30
+ # 入站 · 收件服务(后续实现)
31
+ RECEIVED = "RECEIVED" # 已接收
32
+ PARSED = "PARSED" # 已解析
33
+ PARSE_FAILED = "PARSE_FAILED" # 解析失败
34
+
35
+ @property
36
+ def is_terminal(self) -> bool:
37
+ """是否已进入终态(不再由本状态机主动推进)。"""
38
+ return self in _TERMINAL_STATUSES
39
+
40
+ @property
41
+ def is_outbound_lifecycle(self) -> bool:
42
+ """是否属于出站邮件生命周期内的状态。"""
43
+ return self in _OUTBOUND_STATUSES
44
+
45
+
46
+ _TERMINAL_STATUSES = frozenset({
47
+ MailStatus.SENT,
48
+ MailStatus.FAILED,
49
+ MailStatus.DELIVERED,
50
+ MailStatus.BOUNCED,
51
+ MailStatus.PARSED,
52
+ MailStatus.PARSE_FAILED,
53
+ })
54
+
55
+ _OUTBOUND_STATUSES = frozenset({
56
+ MailStatus.PENDING,
57
+ MailStatus.QUEUED,
58
+ MailStatus.SENT,
59
+ MailStatus.FAILED,
60
+ MailStatus.DELIVERED,
61
+ MailStatus.BOUNCED,
62
+ MailStatus.DEFERRED,
63
+ })
@@ -0,0 +1,218 @@
1
+ import json
2
+ from typing import Any
3
+ from uuid import UUID
4
+
5
+ from async_db_tools import PostgresPool
6
+
7
+ from wr_common_lib.email.constants import MailFlow, MailStatus
8
+ from wr_common_lib.email.types import InboundTask, OutboundTask
9
+
10
+
11
+ class EmailDbOper:
12
+ """email 表共享读写;各服务注入 PostgresPool 后使用。"""
13
+
14
+ def __init__(self, db: PostgresPool):
15
+ self._db = db
16
+
17
+ async def mark_queued(self, task: OutboundTask) -> UUID:
18
+ """开始发送:已有记录则更新为 QUEUED,否则插入出站记录。"""
19
+ email_id = task.get("id")
20
+ if email_id:
21
+ return await self._update_status(self._as_uuid(email_id), MailStatus.QUEUED)
22
+
23
+ existing_id = await self._find_id_by_content_hash(task.get("content_hash"))
24
+ if existing_id:
25
+ return await self._update_status(existing_id, MailStatus.QUEUED)
26
+
27
+ return await self._insert_outbound(task, MailStatus.QUEUED)
28
+
29
+ async def mark_sent(self, email_id: UUID) -> None:
30
+ await self._update_status(email_id, MailStatus.SENT)
31
+
32
+ async def mark_failed(self, email_id: UUID) -> None:
33
+ await self._update_status(email_id, MailStatus.FAILED)
34
+
35
+ async def find_by_content_hash(self, content_hash: str) -> dict[str, Any] | None:
36
+ row = await self._db.fetchrow(
37
+ """
38
+ SELECT id, mail_status, mail_flow
39
+ FROM email
40
+ WHERE content_hash = $1
41
+ """,
42
+ content_hash,
43
+ )
44
+ return dict(row) if row else None
45
+
46
+ async def insert_inbound_received(self, task: InboundTask, content_hash: str) -> UUID:
47
+ """写入入站记录,状态 RECEIVED。"""
48
+ return await self._insert_inbound(task, content_hash, MailStatus.RECEIVED)
49
+
50
+ async def mark_parsed(self, email_id: UUID, parsed_data: dict[str, Any]) -> None:
51
+ await self._db.execute(
52
+ """
53
+ UPDATE email
54
+ SET mail_status = $2::mail_status,
55
+ parsed_data = $3::jsonb,
56
+ updated_at = now()
57
+ WHERE id = $1
58
+ """,
59
+ email_id,
60
+ MailStatus.PARSED.value,
61
+ json.dumps(parsed_data),
62
+ )
63
+
64
+ async def mark_parse_failed(
65
+ self,
66
+ email_id: UUID,
67
+ parsed_data: dict[str, Any] | None = None,
68
+ ) -> None:
69
+ await self._db.execute(
70
+ """
71
+ UPDATE email
72
+ SET mail_status = $2::mail_status,
73
+ parsed_data = COALESCE($3::jsonb, parsed_data),
74
+ updated_at = now()
75
+ WHERE id = $1
76
+ """,
77
+ email_id,
78
+ MailStatus.PARSE_FAILED.value,
79
+ json.dumps(parsed_data) if parsed_data else None,
80
+ )
81
+
82
+ async def _find_id_by_content_hash(self, content_hash: str | None) -> UUID | None:
83
+ if not content_hash:
84
+ return None
85
+ email_id = await self._db.fetchval(
86
+ "SELECT id FROM email WHERE content_hash = $1",
87
+ content_hash,
88
+ )
89
+ return self._as_uuid(email_id) if email_id else None
90
+
91
+ async def _update_status(self, email_id: UUID, status: MailStatus) -> UUID:
92
+ await self._db.execute(
93
+ """
94
+ UPDATE email
95
+ SET mail_status = $2::mail_status, updated_at = now()
96
+ WHERE id = $1
97
+ """,
98
+ email_id,
99
+ status.value,
100
+ )
101
+ return email_id
102
+
103
+ async def _insert_outbound(self, task: OutboundTask, status: MailStatus) -> UUID:
104
+ self._require_keys(
105
+ task,
106
+ ("imo", "voyage_id", "mail_from", "to", "content_hash"),
107
+ "新建邮件记录缺少必填字段: imo, voyage_id, mail_from, to, content_hash",
108
+ )
109
+
110
+ row_id = await self._db.fetchval(
111
+ """
112
+ INSERT INTO email (
113
+ imo, voyage_id, mail_flow, mail_status,
114
+ mail_from, mail_to, mail_cc, subject, content,
115
+ attachment, content_hash, created_user_id
116
+ )
117
+ VALUES (
118
+ $1, $2::uuid, $3::mail_flow, $4::mail_status,
119
+ $5, $6, $7, $8, $9,
120
+ $10::jsonb, $11, $12::uuid
121
+ )
122
+ ON CONFLICT (content_hash) DO UPDATE SET
123
+ mail_status = EXCLUDED.mail_status,
124
+ mail_to = EXCLUDED.mail_to,
125
+ mail_cc = EXCLUDED.mail_cc,
126
+ subject = EXCLUDED.subject,
127
+ content = EXCLUDED.content,
128
+ attachment = EXCLUDED.attachment,
129
+ updated_at = now()
130
+ RETURNING id
131
+ """,
132
+ int(task["imo"]),
133
+ str(task["voyage_id"]),
134
+ MailFlow.OUTBOUND.value,
135
+ status.value,
136
+ task["mail_from"],
137
+ task["to"],
138
+ task.get("cc") or None,
139
+ task.get("subject"),
140
+ task.get("content"),
141
+ self._to_jsonb(task.get("attachments")),
142
+ task["content_hash"],
143
+ task.get("created_user_id"),
144
+ )
145
+ return self._as_uuid(row_id)
146
+
147
+ async def _insert_inbound(
148
+ self,
149
+ task: InboundTask,
150
+ content_hash: str,
151
+ status: MailStatus,
152
+ ) -> UUID:
153
+ self._require_keys(
154
+ task,
155
+ ("imo", "voyage_id", "mail_from", "mail_to"),
156
+ "入站邮件缺少必填字段: imo, voyage_id, mail_from, mail_to",
157
+ )
158
+
159
+ body = task.get("body_markdown") or task.get("raw_text") or ""
160
+ meta = {
161
+ "brevo": {
162
+ "uuid": task.get("brevo_uuid"),
163
+ "message_id": task.get("message_id"),
164
+ "in_reply_to": task.get("in_reply_to"),
165
+ }
166
+ }
167
+
168
+ row_id = await self._db.fetchval(
169
+ """
170
+ INSERT INTO email (
171
+ imo, voyage_id, mail_flow, mail_status,
172
+ mail_from, mail_to, mail_cc, subject, content,
173
+ attachment, parsed_data, content_hash
174
+ )
175
+ VALUES (
176
+ $1, $2::uuid, $3::mail_flow, $4::mail_status,
177
+ $5, $6, $7, $8, $9,
178
+ $10::jsonb, $11::jsonb, $12
179
+ )
180
+ ON CONFLICT (content_hash) DO UPDATE SET
181
+ mail_status = EXCLUDED.mail_status,
182
+ content = EXCLUDED.content,
183
+ attachment = EXCLUDED.attachment,
184
+ parsed_data = EXCLUDED.parsed_data,
185
+ updated_at = now()
186
+ RETURNING id
187
+ """,
188
+ int(task["imo"]),
189
+ str(task["voyage_id"]),
190
+ MailFlow.INBOUND.value,
191
+ status.value,
192
+ task["mail_from"],
193
+ task["mail_to"],
194
+ task.get("mail_cc") or None,
195
+ task.get("subject"),
196
+ body,
197
+ self._to_jsonb(task.get("attachments")),
198
+ json.dumps(meta),
199
+ content_hash,
200
+ )
201
+ return self._as_uuid(row_id)
202
+
203
+ @staticmethod
204
+ def _as_uuid(value: str | UUID) -> UUID:
205
+ return value if isinstance(value, UUID) else UUID(str(value))
206
+
207
+ @staticmethod
208
+ def _require_keys(task: dict[str, Any], keys: tuple[str, ...], message: str) -> None:
209
+ if not all(task.get(key) for key in keys):
210
+ raise ValueError(message)
211
+
212
+ @staticmethod
213
+ def _to_jsonb(value: Any) -> str | None:
214
+ if not value:
215
+ return None
216
+ if isinstance(value, str):
217
+ return value
218
+ return json.dumps(value)
@@ -0,0 +1,35 @@
1
+ from typing import Any, NotRequired, TypedDict
2
+ from uuid import UUID
3
+
4
+
5
+ class OutboundTask(TypedDict, total=False):
6
+ """出站邮件任务字段(发送服务)。"""
7
+
8
+ id: str | UUID
9
+ imo: int | str
10
+ voyage_id: str | UUID
11
+ mail_from: str
12
+ to: str
13
+ content_hash: str
14
+ cc: NotRequired[str | None]
15
+ subject: NotRequired[str | None]
16
+ content: NotRequired[str | None]
17
+ attachments: NotRequired[Any]
18
+ created_user_id: NotRequired[str | UUID | None]
19
+
20
+
21
+ class InboundTask(TypedDict, total=False):
22
+ """入站邮件任务字段(收件 / 解析服务)。"""
23
+
24
+ imo: int | str
25
+ voyage_id: str | UUID
26
+ mail_from: str
27
+ mail_to: str
28
+ mail_cc: NotRequired[str | None]
29
+ subject: NotRequired[str | None]
30
+ body_markdown: NotRequired[str | None]
31
+ raw_text: NotRequired[str | None]
32
+ attachments: NotRequired[Any]
33
+ brevo_uuid: NotRequired[str | None]
34
+ message_id: NotRequired[str | None]
35
+ in_reply_to: NotRequired[str | None]