如何用 Go 调用 SUBMAIL SMS API 发送国际短信

0 阅读10分钟

如何用 Go 调用 SUBMAIL SMS API 发送国际短信


一、为什么选择 Go + SUBMAIL?

Go 语言凭借其出色的并发模型、极低的内存占用和接近 C 的执行性能,已成为微服务和云原生场景的首选语言。结合 SUBMAIL SMS API,Go 开发者可以:

  • 零第三方依赖接入:完全使用 Go 标准库(net/httpcrypto/md5mime/multipart),无需任何外部包
  • goroutine 天然支持高并发:利用 goroutine + sync.WaitGroup 实现高并发批量发送,性能远超同步方案
  • 覆盖全球 200+ 国家和地区,国内 + 国际短信一站式覆盖
  • 编译为单一二进制文件,部署到 Docker / Kubernetes 极其简便


二、前期准备

1. 注册账号并创建应用

前往 SUBMAIL 注册,进入控制台「应用集成」→「国际短信」,创建应用,获取:

  • AppID:应用唯一标识
  • AppKey:应用密钥(切勿提交至代码仓库)

2. 确认 Go 版本

go version   # 需要 Go 1.18 及以上


三、核心 API 说明

  • 接口地址https://api-v4.mysubmail.com/internationalsms/send
  • 请求方式:HTTP POST
  • Content-Typemultipart/form-dataapplication/x-www-form-urlencoded

主要请求参数:

  • appid(必需):国际短信应用 AppID
  • signature(必需):normal 模式填 AppKey 明文;md5 模式填签名字符串
  • to(必需):收件人手机号,必须携带国际区号,如 +1xxxxxxxxxx(美国)、+44xxxxxxxxxx(英国)
  • content(必需):短信正文内容
  • sign_type(可选):normal(测试用)或 md5(生产推荐)


四、完整 Go 示例代码

方式一:multipart/form-data + normal 认证(官方推荐方式)

// main.go
package main
import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"os"
)
const (
	API_URL = "https://api-v4.mysubmail.com/internationalsms/send"
)
// SMSResponse SUBMAIL API 响应结构
type SMSResponse struct {
	Status     string `json:"status"`
	SendID     string `json:"send_id"`
	Fee        int    `json:"fee"`
	SMSCredits int    `json:"sms_credits"`
	Code       string `json:"code"`
	Msg        string `json:"msg"`
}
// sendInternationalSMS 发送国际短信(multipart/form-data + normal 明文认证)
//
// 参数:
//   - to:      收件人号码,需带国际区号,如 +1xxxxxxxxxx
//   - content: 短信正文
//
// 返回:SMSResponse 和 error
func sendInternationalSMS(to, content string) (*SMSResponse, error) {
	appid  := os.Getenv("SUBMAIL_APPID")
	appkey := os.Getenv("SUBMAIL_APPKEY")
	// 构建 postdata
	postdata := map[string]string{
		"appid":     appid,
		"signature": appkey, // normal 模式直接传 AppKey
		"to":        to,
		"content":   content,
		"sign_type": "normal",
	}
	// 使用 mime/multipart 构建请求体(官方推荐方式)
	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	for key, val := range postdata {
		if err := writer.WriteField(key, val); err != nil {
			return nil, fmt.Errorf("写入字段失败 [%s]: %w", key, err)
		}
	}
	contentType := writer.FormDataContentType()
	writer.Close()
	// 发起 HTTP POST 请求
	resp, err := http.Post(API_URL, contentType, body)
	if err != nil {
		return nil, fmt.Errorf("HTTP 请求失败: %w", err)
	}
	defer resp.Body.Close()
	// 读取并解析响应
	result, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("读取响应失败: %w", err)
	}
	var smsResp SMSResponse
	if err := json.Unmarshal(result, &smsResp); err != nil {
		return nil, fmt.Errorf("JSON 解析失败: %w", err)
	}
	return &smsResp, nil
}
// handleResponse 统一处理 API 响应
func handleResponse(resp *SMSResponse, context string) {
	if resp.Status == "success" {
		fmt.Printf("✅ %s 成功!\n", context)
		fmt.Printf("   send_id:  %s\n", resp.SendID)
		fmt.Printf("   消耗额度: %d\n", resp.Fee)
		fmt.Printf("   剩余额度: %d\n", resp.SMSCredits)
	} else {
		fmt.Printf("❌ %s 失败!\n", context)
		fmt.Printf("   错误码: %s\n", resp.Code)
		fmt.Printf("   原因:   %s\n", resp.Msg)
	}
}
func main() {
	resp, err := sendInternationalSMS(
		"+11234567890",
		"Your verification code is 8866. Valid for 5 minutes. --SUBMAIL",
	)
	if err != nil {
		fmt.Println("发送失败:", err)
		return
	}
	handleResponse(resp, "发送国际短信")
}

方式二:x-www-form-urlencoded + normal 认证

package main
import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"strings"
)
// sendSMSFormURLEncoded 使用 x-www-form-urlencoded 发送国际短信
func sendSMSFormURLEncoded(to, content string) (*SMSResponse, error) {
	appid  := os.Getenv("SUBMAIL_APPID")
	appkey := os.Getenv("SUBMAIL_APPKEY")
	// 构建表单参数
	formData := url.Values{}
	formData.Set("appid",     appid)
	formData.Set("signature", appkey)
	formData.Set("to",        to)
	formData.Set("content",   content)
	formData.Set("sign_type", "normal")
	// 发起请求
	req, err := http.NewRequest(
		"POST",
		"https://api-v4.mysubmail.com/internationalsms/send",
		strings.NewReader(formData.Encode()),
	)
	if err != nil {
		return nil, fmt.Errorf("创建请求失败: %w", err)
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("HTTP 请求失败: %w", err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	var smsResp SMSResponse
	if err := json.Unmarshal(body, &smsResp); err != nil {
		return nil, fmt.Errorf("JSON 解析失败: %w", err)
	}
	return &smsResp, nil
}

方式三:MD5 签名认证(生产环境推荐)

package main
import (
	"bytes"
	"crypto/md5"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"os"
	"sort"
	"strings"
)
// buildMD5Signature 构建 SUBMAIL MD5 数字签名
//
// 签名规则:appid + appkey + 排序后参数字符串 + appid + appkey,取 MD5
func buildMD5Signature(params map[string]string, appid, appkey string) string {
	// 过滤掉 sign_type 和 signature,收集所有 key
	keys := make([]string, 0, len(params))
	for k := range params {
		if k != "sign_type" && k != "signature" {
			keys = append(keys, k)
		}
	}
	// 按字典序排序
	sort.Strings(keys)
	// 拼接参数字符串 key=value&key=value
	parts := make([]string, 0, len(keys))
	for _, k := range keys {
		parts = append(parts, k+"="+params[k])
	}
	paramStr := strings.Join(parts, "&")
	// 拼接签名原文
	raw := appid + appkey + paramStr + appid + appkey
	// 计算 MD5
	h := md5.New()
	h.Write([]byte(raw))
	return hex.EncodeToString(h.Sum(nil))
}
// sendSMSWithMD5 使用 MD5 签名认证发送国际短信(生产环境推荐)
func sendSMSWithMD5(to, content string) (*SMSResponse, error) {
	appid  := os.Getenv("SUBMAIL_APPID")
	appkey := os.Getenv("SUBMAIL_APPKEY")
	// 先组装核心参数(不含 signature 和 sign_type)
	params := map[string]string{
		"appid":   appid,
		"to":      to,
		"content": content,
	}
	// 生成 MD5 签名
	signature := buildMD5Signature(params, appid, appkey)
	// 加入签名和认证类型
	params["signature"] = signature
	params["sign_type"] = "md5"
	// 构建 multipart 请求体
	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	for key, val := range params {
		_ = writer.WriteField(key, val)
	}
	contentType := writer.FormDataContentType()
	writer.Close()
	resp, err := http.Post(
		"https://api-v4.mysubmail.com/internationalsms/send",
		contentType,
		body,
	)
	if err != nil {
		return nil, fmt.Errorf("HTTP 请求失败: %w", err)
	}
	defer resp.Body.Close()
	result, _ := io.ReadAll(resp.Body)
	var smsResp SMSResponse
	if err := json.Unmarshal(result, &smsResp); err != nil {
		return nil, fmt.Errorf("JSON 解析失败: %w", err)
	}
	return &smsResp, nil
}
func main() {
	resp, err := sendSMSWithMD5(
		"+447911123456", // 英国号码
		"Hello! Your order has been shipped. --SUBMAIL",
	)
	if err != nil {
		fmt.Println("发送失败:", err)
		return
	}
	handleResponse(resp, "MD5 签名发送")
}

方式四:模板发送(internationalsms/xsend)

package main
import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"os"
)
// sendWithTemplate 使用模板发送国际短信(internationalsms/xsend)
//
// 参数:
//   - to:        收件人号码(带国际区号)
//   - projectID: 模板 ID(控制台中的 project 字段)
//   - variables: 模板变量,如 map[string]string{"code": "8866", "minutes": "5"}
func sendWithTemplate(to, projectID string, variables map[string]string) (*SMSResponse, error) {
	appid  := os.Getenv("SUBMAIL_APPID")
	appkey := os.Getenv("SUBMAIL_APPKEY")
	// 将模板变量序列化为 JSON 字符串
	varsJSON, err := json.Marshal(variables)
	if err != nil {
		return nil, fmt.Errorf("序列化模板变量失败: %w", err)
	}
	postdata := map[string]string{
		"appid":     appid,
		"signature": appkey,
		"to":        to,
		"project":   projectID,
		"vars":      string(varsJSON),
		"sign_type": "normal",
	}
	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	for key, val := range postdata {
		_ = writer.WriteField(key, val)
	}
	contentType := writer.FormDataContentType()
	writer.Close()
	resp, err := http.Post(
		"https://api-v4.mysubmail.com/internationalsms/xsend",
		contentType,
		body,
	)
	if err != nil {
		return nil, fmt.Errorf("HTTP 请求失败: %w", err)
	}
	defer resp.Body.Close()
	result, _ := io.ReadAll(resp.Body)
	var smsResp SMSResponse
	_ = json.Unmarshal(result, &smsResp)
	return &smsResp, nil
}
func main() {
	// 模板示例:【SUBMAIL】你好 @var(name),你的验证码是 @var(code),@var(minutes) 分钟内有效
	resp, err := sendWithTemplate(
		"+8613812345678",
		"your_template_id",
		map[string]string{
			"name":    "张三",
			"code":    "9527",
			"minutes": "5",
		},
	)
	if err != nil {
		fmt.Println("发送失败:", err)
		return
	}
	handleResponse(resp, "模板发送")
}


五、封装为可复用的 SubMailClient

将所有功能封装为结构体,方便在大型项目中统一管理:

// submail/client.go
package submail
import (
	"bytes"
	"crypto/md5"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"sort"
	"strings"
)
const (
	sendURL  = "https://api-v4.mysubmail.com/internationalsms/send"
	xsendURL = "https://api-v4.mysubmail.com/internationalsms/xsend"
)
// Client SUBMAIL 国际短信客户端
type Client struct {
	AppID    string
	AppKey   string
	SignType  string // "normal" 或 "md5"
	client   *http.Client
}
// SMSResponse API 响应结构
type SMSResponse struct {
	Status     string `json:"status"`
	SendID     string `json:"send_id"`
	Fee        int    `json:"fee"`
	SMSCredits int    `json:"sms_credits"`
	Code       string `json:"code"`
	Msg        string `json:"msg"`
}
// New 创建新的 SUBMAIL 客户端
func New(appID, appKey, signType string) *Client {
	return &Client{
		AppID:   appID,
		AppKey:  appKey,
		SignType: signType,
		client:  &http.Client{},
	}
}
// buildSignature 生成签名
func (c *Client) buildSignature(params map[string]string) string {
	if c.SignType == "normal" {
		return c.AppKey
	}
	// MD5 签名
	keys := make([]string, 0)
	for k := range params {
		if k != "sign_type" && k != "signature" {
			keys = append(keys, k)
		}
	}
	sort.Strings(keys)
	parts := make([]string, 0, len(keys))
	for _, k := range keys {
		parts = append(parts, k+"="+params[k])
	}
	raw := c.AppID + c.AppKey + strings.Join(parts, "&") + c.AppID + c.AppKey
	h := md5.New()
	h.Write([]byte(raw))
	return hex.EncodeToString(h.Sum(nil))
}
// post 发起 multipart POST 请求
func (c *Client) post(apiURL string, params map[string]string) (*SMSResponse, error) {
	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	for key, val := range params {
		_ = writer.WriteField(key, val)
	}
	contentType := writer.FormDataContentType()
	writer.Close()
	resp, err := c.client.Post(apiURL, contentType, body)
	if err != nil {
		return nil, fmt.Errorf("HTTP 请求失败: %w", err)
	}
	defer resp.Body.Close()
	result, _ := io.ReadAll(resp.Body)
	var smsResp SMSResponse
	if err := json.Unmarshal(result, &smsResp); err != nil {
		return nil, fmt.Errorf("JSON 解析失败: %w", err)
	}
	return &smsResp, nil
}
// Send 发送国际短信
func (c *Client) Send(to, content string) (*SMSResponse, error) {
	params := map[string]string{
		"appid":   c.AppID,
		"to":      to,
		"content": content,
	}
	params["signature"] = c.buildSignature(params)
	params["sign_type"] = c.SignType
	return c.post(sendURL, params)
}
// SendWithTemplate 使用模板发送国际短信
func (c *Client) SendWithTemplate(to, projectID string,
	variables map[string]string) (*SMSResponse, error) {
	varsJSON, err := json.Marshal(variables)
	if err != nil {
		return nil, fmt.Errorf("序列化变量失败: %w", err)
	}
	params := map[string]string{
		"appid":   c.AppID,
		"to":      to,
		"project": projectID,
		"vars":    string(varsJSON),
	}
	params["signature"] = c.buildSignature(params)
	params["sign_type"] = c.SignType
	return c.post(xsendURL, params)
}

使用方式:

package main
import (
	"fmt"
	"os"
	"your_project/submail"
)
func main() {
	client := submail.New(
		os.Getenv("SUBMAIL_APPID"),
		os.Getenv("SUBMAIL_APPKEY"),
		"md5", // 生产环境使用 MD5 签名
	)
	// 直接发送
	resp, err := client.Send("+11234567890", "Your OTP is 8866. --SUBMAIL")
	if err != nil {
		fmt.Println("错误:", err)
		return
	}
	fmt.Printf("状态: %s,send_id: %s\n", resp.Status, resp.SendID)
	// 模板发送
	resp2, _ := client.SendWithTemplate(
		"+8613812345678",
		"your_template_id",
		map[string]string{"code": "9527", "minutes": "5"},
	)
	fmt.Printf("模板发送状态: %s\n", resp2.Status)
}


六、批量发送:goroutine 并发控制

利用 Go 的并发优势,高效批量发送短信:

package main
import (
	"fmt"
	"sync"
)
type SMSTask struct {
	To      string
	Content string
}
// sendBulk 并发批量发送短信
// concurrency 控制最大并发数,避免超出 SUBMAIL QPS 限制
func sendBulk(client *SubMailClient, tasks []SMSTask, concurrency int) {
	sem := make(chan struct{}, concurrency) // 信号量控制并发数
	var wg sync.WaitGroup
	for _, task := range tasks {
		wg.Add(1)
		go func(t SMSTask) {
			defer wg.Done()
			sem <- struct{}{}        // 占用一个并发槽
			defer func() { <-sem }() // 释放并发槽
			resp, err := client.Send(t.To, t.Content)
			if err != nil {
				fmt.Printf("❌ 发送失败 [%s]: %v\n", t.To, err)
				return
			}
			if resp.Status == "success" {
				fmt.Printf("✅ 发送成功 [%s],send_id: %s\n", t.To, resp.SendID)
			} else {
				fmt.Printf("❌ 发送失败 [%s],错误码: %s,原因: %s\n",
					t.To, resp.Code, resp.Msg)
			}
		}(task)
	}
	wg.Wait()
	fmt.Println("所有短信发送完毕!")
}
// 示例调用
func main() {
	client := submail.New(
		os.Getenv("SUBMAIL_APPID"),
		os.Getenv("SUBMAIL_APPKEY"),
		"md5",
	)
	tasks := []SMSTask{
		{To: "+11234567890", Content: "Your code is 1111. --SUBMAIL"},
		{To: "+447911123456", Content: "Your code is 2222. --SUBMAIL"},
		{To: "+819012345678", Content: "Your code is 3333. --SUBMAIL"},
		{To: "+8613812345678", Content: "你的验证码是 4444。--SUBMAIL"},
	}
	sendBulk(client, tasks, 5) // 最大 5 个并发
}

超过 100 条时,建议改用 internationalsms/multisend 接口,单次请求支持最多 10,000 个号码,性能更优。



七、在 Gin 框架中集成

package main
import (
	"fmt"
	"math/rand"
	"net/http"
	"os"
	"regexp"
	"github.com/gin-gonic/gin"
	"your_project/submail"
)
var smsClient *submail.Client
func init() {
	smsClient = submail.New(
		os.Getenv("SUBMAIL_APPID"),
		os.Getenv("SUBMAIL_APPKEY"),
		"md5",
	)
}
// SendOTPRequest 发送 OTP 请求体
type SendOTPRequest struct {
	Phone string `json:"phone" binding:"required"`
}
// sendOTPHandler 发送语音/短信验证码 Handler
func sendOTPHandler(c *gin.Context) {
	var req SendOTPRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
		return
	}
	// 验证手机号格式(需带国际区号)
	matched, _ := regexp.MatchString(`^+\d{7,15}$`, req.Phone)
	if !matched {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "手机号格式错误,需带国际区号,如 +1xxxxxxxxxx",
		})
		return
	}
	// 生成 6 位 OTP
	otp := fmt.Sprintf("%06d", rand.Intn(1000000))
	content := fmt.Sprintf(
		"Your OTP is %s. Valid for 5 minutes. --SUBMAIL", otp,
	)
	resp, err := smsClient.Send(req.Phone, content)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	if resp.Status == "success" {
		// 实际项目中将 otp 存入 Redis,设置 5 分钟过期
		// rdb.Set(ctx, "otp:"+req.Phone, otp, 5*time.Minute)
		c.JSON(http.StatusOK, gin.H{"success": true, "send_id": resp.SendID})
	} else {
		c.JSON(http.StatusInternalServerError, gin.H{"error": resp.Msg})
	}
}
func main() {
	r := gin.Default()
	r.POST("/api/auth/send-otp", sendOTPHandler)
	r.Run(":8080")
}


八、错误处理与常见错误码

成功响应示例:

{
  "status": "success",
  "send_id": "abcdef1234567890",
  "fee": 1,
  "sms_credits": 99
}

失败响应示例:

{
  "status": "error",
  "code": "103",
  "msg": "Unauthorized"
}

常见错误码及解决方案:

  • 103:AppID 或 AppKey 错误 → 检查环境变量是否正确设置
  • 104:签名验证失败 → 检查 MD5 签名,注意 sort.Strings() 字典序排序
  • 108:短信额度不足 → 前往控制台充值
  • 401:收件人号码格式错误 → 国际号码必须以 + 开头加区号
  • 402:内容违规 → 检查短信正文是否含敏感词


九、安全建议

🔐 绝对不要将 AppKey 硬编码在代码中,Go 项目推荐以下方式:

使用环境变量(推荐):

# Linux / macOS
export SUBMAIL_APPID="your_appid"
export SUBMAIL_APPKEY="your_appkey"
// Go 中安全读取
appid  := os.Getenv("SUBMAIL_APPID")
appkey := os.Getenv("SUBMAIL_APPKEY")
if appid == "" || appkey == "" {
    log.Fatal("SUBMAIL_APPID 和 SUBMAIL_APPKEY 环境变量未设置")
}

生产环境推荐方案:

  • Docker / Kubernetes → 通过 Secret 注入环境变量
  • 阿里云 → 使用 KMS 密钥管理服务
  • AWS → 使用 AWS Secrets Manager
  • 本地开发 → 使用 .env 文件 + godotenv
go get github.com/joho/godotenv
import "github.com/joho/godotenv"
func init() {
    _ = godotenv.Load() // 加载 .env 文件
}


十、小结

  • 注册账号img转存失败,建议直接上传图片文件mysubmail.com
  • Go 版本要求:Go 1.18 及以上
  • 零外部依赖:完全使用 Go 标准库(net/httpcrypto/md5mime/multipart
  • 发送接口https://api-v4.mysubmail.com/internationalsms/send
  • 模板接口https://api-v4.mysubmail.com/internationalsms/xsend
  • 号码格式:必须带 + 国际区号
  • 认证方式:测试用 normal,生产用 md5
  • 并发方案goroutine + sync.WaitGroup + 信号量 实现高性能批量发送

Go 标准库的强大让接入 SUBMAIL SMS API 无需任何第三方依赖,核心发送逻辑不超过 40 行代码,从注册到发出第一条国际短信通常只需 20 分钟

SUBMAIL 的 REST API 设计简洁,Go 接入门槛极低,通常 20 分钟内即可完成第一条国际短信的发送。结合 OTP 验证码、订单通知、营销触达等场景,可以大幅提升用户触达效率。

官方 Go 示例文档:global.mysubmail.com

社区 Go SDK:github.com

官方 API 文档:)en.mysubmail.com