DEV Community

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

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.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
  • 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 <on********@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 's******@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.

  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.

Comments

No comments yet. Start the discussion.