DEV Community

Baolin Tikool
Baolin Tikool

Posted on • Originally published at blog.yeluoxiaoxiao.top

Sending Gmail from behind China's Great Firewall — six attempts that failed

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)
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.com uses 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.com and 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-Token header (32 hex chars stored client-side, matched against Worker env SEND_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 })
}
Enter fullscreen mode Exit fullscreen mode

Client side is a PowerShell function I load with a dot-source:

Send-Email -To 'support@vendor.com' -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.

  1. 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.

  2. 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).

  3. Windows built-in curl.exe schannel 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)