feat(server): EO Pages 支持自定义 SMTP#980
Merged
Merged
Conversation
Contributor
审阅者指南为 EdgeOne Pages 新增一个基于 Go 的 SMTP bridge,并将其与现有 Node 运行时集成,使 EO Pages 在保留现有 SendGrid/MailChannels HTTP 流程的同时,可以通过自定义 SMTP 发送邮件。 EO Pages 通过 Go bridge 发送自定义 SMTP 邮件的时序图sequenceDiagram
actor User
participant Client as Browser_client
participant Node as Node_function_index_js
participant Go as Go_smtp_Handler
participant SMTP as SMTP_server
User->>Client: trigger emailTest / commentSubmit
Client->>Node: call(event, data, envId)
Node->>Node: createMailBridgeContext(req)
Node->>Node: withMailBridgeContext(mailContext, emailTest)
Node->>Node: nodemailer.createTransport(mailConfig)
Node->>Node: sendMail({from,to,subject,html})
Note over Node: mailConfig.host set (custom SMTP)
Node->>Go: requestSmtpBridge(send, mailConfig, mail)
Go->>Go: Handler(w, r)
Go->>Go: authorizeSMTPBridge(r)
Go->>Go: sendSMTP(req)
Go->>SMTP: openSMTPClient(req)
SMTP-->>Go: SMTP response
Go-->>Node: JSON { ok: true }
Node-->>Client: emailTest / sendNotice success
Client-->>User: show success result
文件级变更
与关联 Issue 的对照评估
可能关联的 Issue
技巧与命令与 Sourcery 交互
自定义你的体验访问你的 dashboard 以:
获取帮助Original review guide in EnglishReviewer's GuideAdds a Go-based SMTP bridge for EdgeOne Pages and integrates it with the existing Node runtime so EO Pages can send mail via custom SMTP while preserving existing SendGrid/MailChannels HTTP flows. Sequence diagram for EO Pages custom SMTP mail send via Go bridgesequenceDiagram
actor User
participant Client as Browser_client
participant Node as Node_function_index_js
participant Go as Go_smtp_Handler
participant SMTP as SMTP_server
User->>Client: trigger emailTest / commentSubmit
Client->>Node: call(event, data, envId)
Node->>Node: createMailBridgeContext(req)
Node->>Node: withMailBridgeContext(mailContext, emailTest)
Node->>Node: nodemailer.createTransport(mailConfig)
Node->>Node: sendMail({from,to,subject,html})
Note over Node: mailConfig.host set (custom SMTP)
Node->>Go: requestSmtpBridge(send, mailConfig, mail)
Go->>Go: Handler(w, r)
Go->>Go: authorizeSMTPBridge(r)
Go->>Go: sendSMTP(req)
Go->>SMTP: openSMTPClient(req)
SMTP-->>Go: SMTP response
Go-->>Node: JSON { ok: true }
Node-->>Client: emailTest / sendNotice success
Client-->>User: show success result
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
Contributor
There was a problem hiding this comment.
Hey - 我发现了两个问题,并给出了一些整体性的反馈:
- 在
openSMTPClient中,你创建了一个context.WithTimeout,但在拨号时从未使用该 context(非 TLS 路径使用DialContext,但 TLS 路径忽略了ctx,只依赖Dialer.Timeout);请要么通过tls.Dialer/DialContext传递该 context,要么移除未使用的 context,以避免混淆。 - 当前 SMTP bridge 的错误路径会直接将原始的
smtp/网络错误消息返回到 JSON 的message字段中,这可能会向客户端泄露内部服务器细节;请考虑在将错误返回给调用方之前对这些错误进行包装或清洗。
面向 AI Agent 的提示
请根据以下代码审查意见进行修改:
## 总体评论
- 在 `openSMTPClient` 中,你创建了一个 `context.WithTimeout`,但在拨号时从未使用该 context(非 TLS 路径使用 `DialContext`,但 TLS 路径忽略了 `ctx`,只依赖 `Dialer.Timeout`);请要么通过 `tls.Dialer`/`DialContext` 传递该 context,要么移除未使用的 context,以避免混淆。
- 当前 SMTP bridge 的错误路径会直接将原始的 `smtp`/网络错误消息返回到 JSON 的 `message` 字段中,这可能会向客户端泄露内部服务器细节;请考虑在将错误返回给调用方之前对这些错误进行包装或清洗。
## 单独评论
### 评论 1
<location path="src/server/eo-pages/cloud-functions/smtp.go" line_range="287-291" />
<code_context>
+ return "LOGIN", nil, nil
+}
+
+func (auth loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
+ if !more {
+ return nil, nil
+ }
+ challenge := strings.ToLower(strings.TrimSpace(string(fromServer)))
+ if strings.Contains(challenge, "user") {
+ return []byte(auth.username), nil
</code_context>
<issue_to_address>
**issue (bug_risk):** SMTP LOGIN 质询处理假设服务器返回明文提示,但服务器通常会发送 base64 编码的质询。
这里将 `fromServer` 作为明文解释,并与 "user"/"pass" 进行匹配,但实际服务器会发送 base64 编码的提示(例如 `VXNlcm5hbWU6`、`UGFzc3dvcmQ6`)。这会导致 LOGIN 在实际使用中失败。请在检查质询之前先对其进行解码,或者采用更完整的 LOGIN 实现,以确保已声明支持的机制真正能正常工作。
</issue_to_address>
### 评论 2
<location path="src/server/eo-pages/cloud-functions/index.js" line_range="161-170" />
<code_context>
+ }
+}
+
+async function verifySmtpBridgeUrl (url, token) {
+ const host = getSmtpBridgeCandidateHost(url)
+ if (!host) return false
+
+ const nonce = randomBytes(16).toString('hex')
+ let response
+ try {
+ response = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'probe', nonce, bridgeHost: host })
+ })
+ } catch (e) {
+ return false
+ }
+
+ let result
+ try {
+ result = await response.json()
+ } catch (e) {
+ return false
+ }
+
+ const expected = signSmtpBridgeProbe(token, nonce, host)
+ return response.ok &&
+ result.ok &&
+ result.bridgeHost === host &&
+ timingSafeEqualHex(result.signature, expected)
+}
</code_context>
<issue_to_address>
**suggestion (performance):** SMTP bridge URL 验证缺少超时控制,当候选主机响应缓慢或无响应时,存在长时间挂起的风险。
当前用于探测的 `fetch` 调用没有超时或中止信号,因此当候选主机响应慢、被黑洞路由或配置错误时,`getSmtpBridgeUrl` 可能会长时间阻塞,从而影响邮件测试和提交后的通知。请为每次探测添加超时(例如使用 `AbortController`),以便在候选主机未及时响应时快速失败。
建议实现如下:
```javascript
const SMTP_BRIDGE_PROBE_TIMEOUT_MS = 5000
async function verifySmtpBridgeUrl (url, token) {
const host = getSmtpBridgeCandidateHost(url)
if (!host) return false
const nonce = randomBytes(16).toString('hex')
let response
const controller = new AbortController()
const timeoutId = setTimeout(() => {
controller.abort()
}, SMTP_BRIDGE_PROBE_TIMEOUT_MS)
try {
response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'probe', nonce, bridgeHost: host }),
signal: controller.signal
})
} catch (e) {
return false
} finally {
clearTimeout(timeoutId)
}
let result
try {
result = await response.json()
} catch (e) {
return false
}
const expected = signSmtpBridgeProbe(token, nonce, host)
return response.ok &&
result.ok &&
result.bridgeHost === host &&
timingSafeEqualHex(result.signature, expected)
}
```
1. 如果该文件已经定义了共享的超时/配置常量,请将 `SMTP_BRIDGE_PROBE_TIMEOUT_MS` 放在这些常量旁边,或者重用已有的 HTTP 超时常量以保持一致。
2. 确保运行环境提供 `AbortController` 以及支持 `signal` 选项的 `fetch` 实现;如果没有,请在文件顶部添加相应的 polyfill/导入(例如来自 `node-fetch` 或类似库)。帮我变得更有用!请在每条评论上点击 👍 或 👎,我会根据你的反馈改进后续评审。
Original comment in English
Hey - I've found 2 issues, and left some high level feedback:
- In
openSMTPClientyou create acontext.WithTimeoutbut never use the context for dialing (the non-TLS path usesDialContextbut the TLS path ignoresctxand relies only onDialer.Timeout); either wire the context throughtls.Dialer/DialContextor remove the unused context to avoid confusion. - The SMTP bridge error path currently returns raw
smtp/network error messages directly in the JSONmessagefield, which may leak internal server details to clients; consider wrapping or sanitizing these errors before sending them back to the caller.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `openSMTPClient` you create a `context.WithTimeout` but never use the context for dialing (the non-TLS path uses `DialContext` but the TLS path ignores `ctx` and relies only on `Dialer.Timeout`); either wire the context through `tls.Dialer`/`DialContext` or remove the unused context to avoid confusion.
- The SMTP bridge error path currently returns raw `smtp`/network error messages directly in the JSON `message` field, which may leak internal server details to clients; consider wrapping or sanitizing these errors before sending them back to the caller.
## Individual Comments
### Comment 1
<location path="src/server/eo-pages/cloud-functions/smtp.go" line_range="287-291" />
<code_context>
+ return "LOGIN", nil, nil
+}
+
+func (auth loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
+ if !more {
+ return nil, nil
+ }
+ challenge := strings.ToLower(strings.TrimSpace(string(fromServer)))
+ if strings.Contains(challenge, "user") {
+ return []byte(auth.username), nil
</code_context>
<issue_to_address>
**issue (bug_risk):** SMTP LOGIN challenge handling assumes plain-text prompts, but servers typically send base64-encoded challenges.
Here `fromServer` is interpreted as plain text and matched against "user"/"pass", but real servers send base64-encoded prompts (e.g., `VXNlcm5hbWU6`, `UGFzc3dvcmQ6`). This will cause LOGIN to fail in practice. Please decode the challenge before inspecting it or adopt a more complete LOGIN implementation so the advertised mechanism actually works.
</issue_to_address>
### Comment 2
<location path="src/server/eo-pages/cloud-functions/index.js" line_range="161-170" />
<code_context>
+ }
+}
+
+async function verifySmtpBridgeUrl (url, token) {
+ const host = getSmtpBridgeCandidateHost(url)
+ if (!host) return false
+
+ const nonce = randomBytes(16).toString('hex')
+ let response
+ try {
+ response = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'probe', nonce, bridgeHost: host })
+ })
+ } catch (e) {
+ return false
+ }
+
+ let result
+ try {
+ result = await response.json()
+ } catch (e) {
+ return false
+ }
+
+ const expected = signSmtpBridgeProbe(token, nonce, host)
+ return response.ok &&
+ result.ok &&
+ result.bridgeHost === host &&
+ timingSafeEqualHex(result.signature, expected)
+}
</code_context>
<issue_to_address>
**suggestion (performance):** SMTP bridge URL verification lacks a timeout, risking long hangs when a candidate host is slow or unresponsive.
The `fetch` probe runs without a timeout or abort signal, so `getSmtpBridgeUrl` can block for a long time if a candidate is slow, blackholed, or misconfigured, impacting email tests and post-submit notifications. Please add a per-probe timeout (for example using an `AbortController`) so discovery fails fast when a candidate doesn’t respond promptly.
Suggested implementation:
```javascript
const SMTP_BRIDGE_PROBE_TIMEOUT_MS = 5000
async function verifySmtpBridgeUrl (url, token) {
const host = getSmtpBridgeCandidateHost(url)
if (!host) return false
const nonce = randomBytes(16).toString('hex')
let response
const controller = new AbortController()
const timeoutId = setTimeout(() => {
controller.abort()
}, SMTP_BRIDGE_PROBE_TIMEOUT_MS)
try {
response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'probe', nonce, bridgeHost: host }),
signal: controller.signal
})
} catch (e) {
return false
} finally {
clearTimeout(timeoutId)
}
let result
try {
result = await response.json()
} catch (e) {
return false
}
const expected = signSmtpBridgeProbe(token, nonce, host)
return response.ok &&
result.ok &&
result.bridgeHost === host &&
timingSafeEqualHex(result.signature, expected)
}
```
1. If this file already defines shared timeout/configuration constants, move `SMTP_BRIDGE_PROBE_TIMEOUT_MS` next to them or reuse an existing HTTP timeout constant to stay consistent.
2. Ensure that the runtime environment provides `AbortController` and a `fetch` implementation that supports the `signal` option; if not, add the appropriate polyfill/import at the top of the file (e.g. from `node-fetch` or a similar library).
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
Contributor
Author
|
已根据 review 反馈在
已重新验证:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
原因
Fixes #979
EdgeOne Pages 的 Node Function 运行时不支持 TCP socket,无法通过 Nodemailer 直接连接自定义 SMTP 服务器,导致 EO Pages 目前只能使用 SendGrid 和 MailChannels 发送邮件。
由于 EO Pages 的 Blob SDK 仍由 Node Function 使用,本 PR 保留 Node 作为 Twikoo 主后端,仅新增 Go Cloud Function 处理 SMTP TCP 连接。
Summary
/smtp,负责自定义 SMTP 的verify和sendSMTP_HOST时通过 HTTP 调用同项目/smtpenvId,用于跨域博客场景自动发现同项目/smtpTWIKOO_SMTP_BRIDGE_TOKEN保护/smtp,避免公开接口被滥用使用说明
使用自定义 SMTP 时需要配置:
TWIKOO_SMTP_BRIDGE_TOKENSMTP_HOSTSMTP_PORTSMTP_SECURESMTP_USERSMTP_PASSSENDER_EMAILSMTP_SERVICE=SendGrid和SMTP_SERVICE=MailChannels继续走原有 HTTP API,不经过 Go SMTP Bridge。Verification
node --check src/server/eo-pages/cloud-functions/index.jspnpm exec eslint src/server/eo-pages/cloud-functions/index.js src/client/utils/api.jsGO111MODULE=off go testinsrc/server/eo-pages/cloud-functionsgit diff --check origin/mainSummary by Sourcery
添加一个 EO Pages SMTP 桥接组件,通过 Go Cloud Function 启用自定义 SMTP,同时保留现有的 SendGrid/MailChannels 支持,并将其集成到 Node 运行时的邮件流程中。
新功能:
/smtpCloud Function,用于处理自定义 SMTP 验证以及通过 TCP 发送邮件。/smtp端点。增强:
smtp.go函数。文档:
测试:
Original summary in English
Summary by Sourcery
Add an EO Pages SMTP bridge that enables custom SMTP via a Go Cloud Function while preserving existing SendGrid/MailChannels support and integrating it with the Node runtime’s mail flow.
New Features:
/smtpCloud Function to handle custom SMTP verification and email sending over TCP./smtpendpoint using a protected token and envId-based auto-discovery.Enhancements:
smtp.gofunction in the project structure.Documentation:
Tests: