Repository navigation
Commit ede9e6b
fix(service-messaging): the email channel answers unavailable while only the log transport is live (#30547)
<!-- claude-projects-attribution: {"github_login":"os-bill"} -->
_Requested by **Bill** · [project
thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgLiQTZYATct8Zv1iH2wZvyz)_
domain:server#6 · os-bill · session_01Utp6CXCS98uhsFQYk64epE
## 维护者速读
-
改了什么:没配发信方式(只有「写日志」的假通道)时,邮件渠道不再自称可用;通知不再为邮件写投递记录,而是记一条「未配置发信」的抑制原因;已写下的投递记录被拒发,不再标成「成功」。
- 为什么改:执行 cloud#2549 裁定 B 第 ③ 项。过去所有托管租户在管理员配好邮箱前,邮件通知都显示「已送达」,其实一封没发。
- 风险与回滚:只动 service-messaging 的邮件渠道一个文件;没有实现 isConfigured
的第三方邮件服务行为不变。回滚即还原本 PR 一次提交。
- 先看:`packages/services/service-messaging/src/email-channel.ts` 的
`emailTransportRefusal` 与 `isAvailable` / `send` 两处调用;其次是测试文件末尾新增的
describe。
- 两分钟验证:`pnpm --filter @objectstack/service-messaging exec vitest run
--maxWorkers=2 src/email-channel.test.ts`,预期 47 个全绿,其中 `a LogTransport
is no transport (#30510)` 下 5 个。
- 需要你做的:无,席位落地。
Fixes #30510
Clause-②: no
Metadata-protocol: none
Skills-docs: none — `git grep -n -i -c "logtransport|log
transport|transport_not_configured|email channel|e-mail channel" --
'skills/**'` has no hit (exit 1)
## What changes
plugin-email registers the `email` service on every boot. With no `mail`
settings saved, its live transport is the `LogTransport`: a send
succeeds, writes `sys_email`, and no mail leaves the process. The
`email` channel answered `isAvailable()` from "an email service is
registered", so fan-out wrote a delivery row and the dispatcher marked
it `success` while nobody was mailed. Every hosted tenant is in this
state until its admin configures mail (objectstack-ai/cloud#2549, ruling
B, item 3).
- `email-channel.ts`: new `emailTransportRefusal(email)` asks the
service's own deliverability answer, `IEmailService.isConfigured()`
(already on the spec contract, already implemented by plugin-email from
the live transport and already following the `mail` settings hot swap).
An explicit `false` makes `isAvailable()` answer `{ available: false,
reason: 'transport_not_configured' }` and makes `send()` refuse with
that same reason, graded permanent, without calling the service. It is
read on every call, never cached, so a transport the settings swap in
after boot flips the channel back with no restart.
- `EmailSenderSurface` (the channel's structural view of the service)
gains the optional `isConfigured?(): boolean`, mirroring the contract
member.
- The existing reason token is reused. No new reason, no new error code,
no spec change, no plugin-email change.
## Tests
New top-level describe `email channel — a LogTransport is no transport
(#30510)` in `email-channel.test.ts` (the package's shared harness for
this channel). Its double mirrors plugin-email's `EmailService` on the
one axis that matters: `isConfigured()` answers from a swappable live
transport.
- unavailable with `transport_not_configured` while the transport only
logs;
- `send()` refuses with the same reason, graded permanent, and the
service's `send` is never called (no fake `sys_email` row);
- swap to a real transport: available and delivers; swap back:
unavailable again;
- control: a service with no `isConfigured` stays available and still
delivers;
- end to end (MessagingService + MemoryNotificationOutbox + dispatcher):
an emit on a log-only deployment writes no delivery row and reports the
suppression; after the swap one row lands `success`.
The producer half is already pinned in plugin-email
(`email-plugin.mail-settings.test.ts`, "registered email service —
isConfigured follows the mail settings": `false` on the out-of-the-box
boot, `true` after a saved SMTP config, no restart).
## Acceptance notes
- **Absent `isConfigured` is read as unknown and keeps today's behaviour
(available).** The contract makes the member optional and says a
consumer must not read absence as `true`; this channel keeps sending
through such a service, as it did before, because refusing would mute
every host-supplied email service that predates the member. This follows
the same choice the fan-out ruling made for a channel with no
`isAvailable`. Only an explicit `false` refuses. If the seat reads the
contract the other way, flipping it is a one-line change in
`emailTransportRefusal`.
- The real composition (plugin-email + service-messaging in one kernel)
is pinned as two halves joined by the spec contract: service-messaging
cannot import plugin-email (no dependency), and a dogfood pin is outside
this card's file surface.
- Serial note: this PR does not touch plugin-email, `EmailService` or
the `LogTransport`, so it does not overlap seat 5's #30212 row 3.
## Verification
All on head `fbfbaf47` (origin/main `fa9c0aa2` merged; no new commits on
main since the branch point).
<details><summary>Red on main, green on the fix, ablation</summary>
- Red (tests only, on `fa9c0aa2`): `Tests 4 failed | 43 passed (47)`;
the four new pins red, the control green.
- Green: `Test Files 1 passed (1)` · `Tests 47 passed (47)`.
- Ablation via `scripts/ablation-replace.mjs` (anchor `if (typeof
email.isConfigured !== 'function') return undefined;` replaced so the
helper always returns `undefined`, the pre-fix behaviour): anchor 1 to
0, blob `deebc1c7be23` to `a81f47328d2c`; `Tests 4 failed | 43 passed
(47)`; restored, blob equals HEAD, `git diff HEAD` empty. Tests import
the channel from `src/` by relative path, so no build sits between the
mutation and the run.
</details>
<details><summary>Package suite, typecheck, lint, gates</summary>
- `pnpm --filter @objectstack/service-messaging test` under the verify
lock: `Test Files 66 passed (66)` · `Tests 817 passed (817)` · `VERDICT
command-exit 0`.
- `pnpm --filter @objectstack/service-messaging typecheck`: exit 0 (`tsc
--listFiles` includes `email-channel.test.ts`).
- `eslint --no-inline-config` on the two changed source files: exit 0.
The repo-wide `pnpm lint` is left to CI's Lint job.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: 62 commands; each run with its exit code
captured before any pipe. `--ran` reconciliation: `62 derived, 59 run, 3
NOT-MEASURED, 0 UNRUN`. The three exit-3 prerequisite refusals: `pnpm
check:dual-build-cjs-loads` (needs the whole workspace built), `pnpm
check:i18n` (needs its build closure; this diff touches no translation
bundle), `node scripts/check-plugin-teardown-shape.mjs --self-test`
(needs an unshallow clone; checker health only). CI runs all three.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Utp6CaGmRyZ3XL5MuUgEZg)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent db04e5c commit ede9e6b
3 files changed
Lines changed: 209 additions & 3 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
745 | 745 | | |
746 | 746 | | |
747 | 747 | | |
| 748 | + | |
| 749 | + | |
| 750 | + | |
| 751 | + | |
| 752 | + | |
| 753 | + | |
| 754 | + | |
| 755 | + | |
| 756 | + | |
| 757 | + | |
| 758 | + | |
| 759 | + | |
| 760 | + | |
| 761 | + | |
| 762 | + | |
| 763 | + | |
| 764 | + | |
| 765 | + | |
| 766 | + | |
| 767 | + | |
| 768 | + | |
| 769 | + | |
| 770 | + | |
| 771 | + | |
| 772 | + | |
| 773 | + | |
| 774 | + | |
| 775 | + | |
| 776 | + | |
| 777 | + | |
| 778 | + | |
| 779 | + | |
| 780 | + | |
| 781 | + | |
| 782 | + | |
| 783 | + | |
| 784 | + | |
| 785 | + | |
| 786 | + | |
| 787 | + | |
| 788 | + | |
| 789 | + | |
| 790 | + | |
| 791 | + | |
| 792 | + | |
| 793 | + | |
| 794 | + | |
| 795 | + | |
| 796 | + | |
| 797 | + | |
| 798 | + | |
| 799 | + | |
| 800 | + | |
| 801 | + | |
| 802 | + | |
| 803 | + | |
| 804 | + | |
| 805 | + | |
| 806 | + | |
| 807 | + | |
| 808 | + | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
| 813 | + | |
| 814 | + | |
| 815 | + | |
| 816 | + | |
| 817 | + | |
| 818 | + | |
| 819 | + | |
| 820 | + | |
| 821 | + | |
| 822 | + | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
| 827 | + | |
| 828 | + | |
| 829 | + | |
| 830 | + | |
| 831 | + | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
| 846 | + | |
| 847 | + | |
| 848 | + | |
| 849 | + | |
| 850 | + | |
| 851 | + | |
| 852 | + | |
| 853 | + | |
| 854 | + | |
| 855 | + | |
| 856 | + | |
| 857 | + | |
| 858 | + | |
| 859 | + | |
| 860 | + | |
| 861 | + | |
| 862 | + | |
| 863 | + | |
| 864 | + | |
| 865 | + | |
| 866 | + | |
| 867 | + | |
| 868 | + | |
| 869 | + | |
| 870 | + | |
| 871 | + | |
| 872 | + | |
| 873 | + | |
| 874 | + | |
| 875 | + | |
| 876 | + | |
| 877 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
46 | 61 | | |
47 | 62 | | |
48 | 63 | | |
| |||
96 | 111 | | |
97 | 112 | | |
98 | 113 | | |
99 | | - | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
100 | 117 | | |
101 | 118 | | |
102 | 119 | | |
| |||
198 | 215 | | |
199 | 216 | | |
200 | 217 | | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
201 | 245 | | |
202 | 246 | | |
203 | 247 | | |
| |||
343 | 387 | | |
344 | 388 | | |
345 | 389 | | |
346 | | - | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
347 | 395 | | |
348 | | - | |
| 396 | + | |
349 | 397 | | |
350 | 398 | | |
351 | 399 | | |
| |||
378 | 426 | | |
379 | 427 | | |
380 | 428 | | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
381 | 444 | | |
382 | 445 | | |
383 | 446 | | |
| |||
0 commit comments