Last night I needed to send one email to support@creem.io 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)
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
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
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
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')
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',
})
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' })
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
- 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 <onboarding@resend.dev>',
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 })
}
Client side is a PowerShell function I load with a dot-source:
Send-Email -To 'support@vendor.com' -Subject 'follow up' -Text 'body...'
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.
Top comments (0)