Last night I needed to send one email to [email protected] from Guangzhou, China. Simple email — 15 minutes to draft. Sending it took over an hour.
Here’s what I learned about the six most common ways to send Gmail from mainland China programmatically, and what finally worked.
Everything starts with SMTP
Habitual first try — Python smtplib with the Gmail app password:
import smtplib, ssl
s = smtplib.SMTP_SSL('smtp.gmail.com', 465, context=ssl.create_default_context())
s.login(user, app_password)
s.send_message(msg)
Enter fullscreen mode Exit fullscreen mode
ssl.SSLEOFError: EOF occurred in violation of protocol
Switched to 587 STARTTLS. SMTPServerDisconnected: Connection unexpectedly closed.
Not surprising. China Telecom / China Unicom / China Mobile backbone routers have been doing deep packet inspection on smtp.gmail.com for years across all three SMTP ports (25, 465, 587). The SMTPS handshake exposes the SNI in plaintext — GFW sees smtp.gmail.com and resets the connection.
I figured switching to a proxy would fix it. Wrong.
Clash + curl bricks on Windows schannel
Local Clash proxy was already running on port 7897. Standard Windows curl.exe:
curl.exe -x http://127.0.0.1:7897 \
--url smtps://smtp.gmail.com:465 --ssl-reqd \
--mail-from ... --mail-rcpt ... \
--user user:pass --upload-file email.txt
Enter fullscreen mode Exit fullscreen mode
Clash side works fine — HTTP/1.1 200 Connection established. Then:
* schannel: failed to receive handshake, SSL/TLS connection failed
curl: (35) schannel: failed to receive handshake, SSL/TLS connection failed
Enter fullscreen mode Exit fullscreen mode
Windows curl.exe ships with schannel (Windows native TLS). schannel has poorly supported tunneled-TLS-after-HTTP-CONNECT for years. Known issue, no clean fix without swapping to a git-for-windows curl or building from source. Not doing that at 11pm.
Python + PySocks fractures
import socks
socks.set_default_proxy(socks.HTTP, '127.0.0.1', 7897)
socket.socket = socks.socksocket
import smtplib
Enter fullscreen mode Exit fullscreen mode
OSError: [Errno 9] Bad file descriptor
PySocks has a socket-wrap bug when SSL is wrapped on top on Windows Python 3.9. Never fully fixed.
Manual CONNECT + Python ssl.wrap_socket also breaks
Went lower — open my own socket, hand-craft the HTTP CONNECT, then wrap in TLS:
sock = socket.create_connection(('127.0.0.1', 7897))
sock.send(b"CONNECT smtp.gmail.com:465 HTTP/1.1\r\nHost: smtp.gmail.com:465\r\n\r\n")
buf = sock.recv(4096) # Get "200 Connection established"
ctx = ssl.create_default_context()
ssock = ctx.wrap_socket(sock, server_hostname='smtp.gmail.com')
Enter fullscreen mode Exit fullscreen mode
FileNotFoundError: [Errno 2] No such file or directory
Blows up in do_handshake. Python 3.9 wrap_socket with a pre-connected socket + custom SSL context has a Windows-specific bug — internally tries to access a nonexistent cert path. Fixed in 3.10+. My machine is 3.9.
Node nodemailer + Clash also disconnects
Node uses OpenSSL not schannel — should work, right?
const transporter = nodemailer.createTransport({
host: 'smtp.gmail.com', port: 465, secure: true,
auth: {user, pass},
proxy: 'http://127.0.0.1:7897',
})
Enter fullscreen mode Exit fullscreen mode
Client network socket disconnected before secure TLS connection was established
CONNECT tunnel probably established, then TLS immediately drops. Guessing Clash’s rule list doesn’t identify smtp.gmail.com as a domain requiring the remote proxy exit — it routes DIRECT — GFW sees Gmail SMTP and resets.
Manual net + tls in Node
Went lower — net.createConnection to Clash, hand-craft CONNECT, then tls.connect(socket):
import net from 'node:net'
import tls from 'node:tls'
const raw = net.createConnection(7897, '127.0.0.1', () => {
raw.write('CONNECT smtp.gmail.com:465 HTTP/1.1\r\n...')
})
// wait for 200, then tls.connect({ socket: raw, servername: 'smtp.gmail.com' })
Enter fullscreen mode Exit fullscreen mode
Same disconnect. TLS handshake dies.
What finally worked: Gmail Web (mail.google.com)
Opened Chrome. Went to mail.google.com. Pasted the message. Clicked send. Done in 30 seconds.
An hour of programmatic wrestling — a manual browser click won.
The reason Gmail Web works when SMTP doesn’t is exactly what makes GFW’s approach both effective and brittle:
- HTTPS to
mail.google.comuses TLS 1.3 with Encrypted Client Hello (ECH) — the SNI is encrypted; GFW can’t see which Google service you’re hitting. - SMTP handshake exposes SNI in plaintext — GFW pattern-matches on
smtp.gmail.comand resets.
The Cloudflare Worker + Resend workaround I built after
The manual browser click won that battle but was clearly not scalable. If I ever need to send transactional email at scale — customer receipts, KYC follow-ups, welcome flows — I can’t open Chrome 100 times a day.
The setup I now have:
YOU · Windows ──HTTPS──▶ api.yourdomain/api/send-email ──HTTPS──▶ Resend ──SMTP──▶ Recipient
Enter fullscreen mode Exit fullscreen mode
- Your side is HTTPS to a domain you control (hosted on Cloudflare Anycast). Same TLS 1.3 + SNI-hiding as Gmail Web — GFW won’t touch it.
- Cloudflare Worker → Resend happens on Cloudflare edge (Tokyo when accessed from China). Both are foreign IPs. No GFW involvement.
-
Auth is a simple
X-Send-Tokenheader (32 hex chars stored client-side, matched against Worker envSEND_EMAIL_TOKEN). - Free tier: 100 emails/day on Resend — enough for a KYC follow-up burst plus first-year customer emails.
The Worker code is about 40 lines:
if (p === '/api/send-email' && request.method === 'POST') {
if (request.headers.get('x-send-token') !== env.SEND_EMAIL_TOKEN) {
return json({ error: 'unauthorized' }, 401)
}
const body = await request.json()
const r = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: body.from || 'Your Team <[email protected]>',
to: body.to,
subject: body.subject,
text: body.text,
reply_to: body.reply_to,
}),
})
const rd = await r.json()
return json({ status: r.status, resend: rd })
}
Enter fullscreen mode Exit fullscreen mode
Client side is a PowerShell function I load with a dot-source:
Send-Email -To '[email protected]' -Subject 'follow up' -Text 'body...'
Enter fullscreen mode Exit fullscreen mode
One line to send an email from any Windows terminal, anywhere. Never hits Chrome, never hits SMTP.
What I learned
Three things.
Don’t assume browser-works-therefore-SMTP-works. GFW treats protocols differently. HTTPS + encrypted SNI gets the friendliest treatment. SMTP on the classic three ports gets active blocking.
Customer service / transactional email infrastructure must be day-one. Grinding through six SMTP approaches at 11pm made me realize I couldn’t manually copy-paste for every future email. Cloudflare Worker + Resend is now the third foundational service in my stack (after Worker + D1 for the app itself).
Windows built-in
curl.exeschannel makes cross-environment scripting painful. On my next Windows machine I’ll install an OpenSSL-based curl day one.
Follow-up: what became of the email?
Creem support replied the next Monday morning. The situation was simpler than I thought — they reset the Sumsub applicant record. Reply was one sentence: “Done, please retry.”
The email itself was clear (I wrote it as an A/B/C choice for the support agent, not “I have a problem please help”), which probably helped. But that’s a different post.
Stack tl;dr
- 5 SMTP approaches (Python direct, curl+schannel, PySocks, manual Python socket, Node nodemailer, Node raw net+tls) — all fail behind GFW
- Gmail Web via browser — works (TLS 1.3 + ECH)
- The scalable solution: HTTPS-only path via Cloudflare Worker to a transactional email provider (Resend / Postmark / SendGrid)
- Client sends over HTTPS to a self-owned domain on Cloudflare Anycast — GFW-safe
If you’re building indie SaaS from China and need reliable email sending, this pattern is worth ~2 hours of setup for the rest of your product’s life.
답글 남기기