通过谷歌服务安排邮寄活动·进阶篇
📧

通过谷歌服务安排邮寄活动·进阶篇

(2/3)· 当 MQL alone 搞不定群发邮件,如何用谷歌联系人组 + C# 桥接实现带附件的定向邮寄

新手友好 第 2/3 篇

很多交易者想给订户或客户定期发截图和报告,却在 MQL 里卡死——原生函数根本不够灵活。本文接上篇,继续深挖如何用谷歌服务托管联系人、并由 C# 桥接终端完成发送。把重复劳动交给小布盯盘,你专注决策。

◍ 把 Google 授权与联系人组接进 C# 类

做邮件触达前,先得在 C# 里把 Google 服务授权和联系人组打通。新建一个密封类 ContactsPeople,用静态只读属性 Applicationname 存项目名,再塞进 People API 和 Gmail API 的引用,否则后面连服务都会报缺少程序集。 类里用 PersonStatus 枚举区分联系人是否收邮件:Active 进邮寄名单,Passive 跳过。配套私有字段包括 _groupsresourcename(对应 Google 联系人里建的组,例子里组名叫 Forex)、_list(待发联系人列表)、_credential(应用权限令牌)、_pservice 与 _gservice(联系人和邮件服务实例)。 主函数 WorkWithGoogle 的参数决定了运行边界:credentialfile 是下载的 JSON 路径,user 是 xxxxx@gmail.com 这类账户,filedatastore 是 AppData 下自动生成的辅助令牌目录,groupname 指定 Forex 组,subject/body/isHtml 控制邮件形态,attach 是附件列表,返回值是实际发出邮件数。 授权字符串数组只需两项:GmailService.Scope.GmailSend 发信权、PeopleService.Scope.ContactsReadonly 只读联系人。调用 GoogleWebAuthorizationBroker.AuthorizeAsync 是异步的,若旧令牌过期,代码会刷新并把 _list 清空重抓。 辅助 CreateServicies() 用 JSON 里的“powers”初始化 _credential,再把它和项目名传进服务构造函数。抓组联系人走 GetPeople(…),包在 try 里——catch 故意写最泛化形式,避免吞掉调试信息,你可按需往异常里塞上下文再抛出。 _list 仅在拿到新令牌或刷新令牌时才重建;调试版会把整列联系人打印到控制台。若名单为空,函数直接拦下并提示没必要继续。最后用 MailMessage 实例填主题正文和附件,交给 SendOneEmail(…) 逐发,同样包 try/catch,结束返回 _list.Count。

MQL5 / C++
<span class="keyword">using</span> System;
<span class="keyword">using</span> System.Collections.Generic;
<span class="keyword">using</span> System.Linq;
<span class="keyword">using</span> System.IO;
<span class="keyword">using</span> System.Net.Mail;
<span class="keyword">using</span> System.Text;
<span class="keyword">using</span> System.Threading;
<span class="keyword">using</span> System.Threading.Tasks;
<span class="keyword">using</span> Google.Apis.Auth.OAuth2;
<span class="keyword">using</span> Google.Apis.People.v1;
<span class="keyword">using</span> Google.Apis.Services;
<span class="keyword">using</span> Google.Apis.Util.Store;
<span class="keyword">using</span> Google.Apis.Http;
<span class="keyword">using</span> Google.Apis.PeopleService.v1;
<span class="keyword">using</span> Google.Apis.PeopleService.v1.Data;
<span class="keyword">using</span> Google.Apis.Gmail.v1;
<span class="keyword">using</span> Google.Apis.Gmail.v1.Data;
<span class="keyword">class="kw">namespace</span> WorkWithPeople
{
&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">internal </span><span class="keyword">sealed </span><span class="keyword">class</span> ContactsPeople
&nbsp;&nbsp;&nbsp;&nbsp;{
class="kw">public class="kw">static class="type">class="kw">string Applicationname { get; } = "WorkWithPeople";
.....
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">private</span><span class="keyword"> enum</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; PersonStatus
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Active,
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Passive
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;};
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">private</span><span class="keyword"> class="type">class="kw">string</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; _groupsresourcename;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">private</span> List&lt;OneContact&gt; _list = <span class="keyword">new</span> List&lt;OneContact&gt;();
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">private</span> UserCredential&nbsp;&nbsp; _credential;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">private</span> PeopleService&nbsp;&nbsp;&nbsp;&nbsp;_pservice;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">private</span> GmailService&nbsp;&nbsp;&nbsp;&nbsp; _gservice;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">public</span><span class="keyword">class="type">int</span> WorkWithGoogle(<span class="keyword">class="type">class="kw">string</span> credentialfile,
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="type">class="kw">string</span> user,
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="type">class="kw">string</span> filedatastore,
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="type">class="kw">string</span> groupname,
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="type">class="kw">string</span> subject,
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="type">class="kw">string</span> body,
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="type">bool</span>&nbsp;&nbsp; isHtml,

「凭据失效时的静默退回逻辑」

这段 C# 片段处理 Google 凭据的载入与刷新:若本地凭据文件不存在直接抛 FileNotFoundException,存在则走 AuthorizationBroker 异步授权,并申请 GmailSend 与 ContactsReadonly 两个 scope。 授权成功后由 CreateServicies() 构造 PeopleService 与 GmailService 实例,HttpClientInitializer 均绑定同一 _credential,ApplicationName 取自变量 Applicationname。 当 _credential.Token.IsExpired 为真,代码调用 RefreshTokenAsync 并取 .Result;刷新失败则函数直接 return 0,且先执行 _list.Clear() 清空收件人缓存。这个 return 0 是调用方需要捕获的硬退出信号——意味着本次发信流程中止。 在 MT5 外部 C# 小工具里接这套逻辑时,建议把 return 0 改成抛自定义异常,否则上层循环可能把『令牌过期』误判为『无收件人』。

MQL5 / C++
if (!File.Exists(credentialfile))
   throw (new FileNotFoundException("Not found: " + credentialfile));
using (var stream = new FileStream(credentialfile, FileMode.Open, FileAccess.Read))
{
   if (_credential == null) {
      _credential = GoogleWebAuthorizationBroker.AuthorizeAsync(
         GoogleClientSecrets.Load(stream).Secrets,
         new[]
         {
            GmailService.Scope.GmailSend,
            PeopleService.Scope.ContactsReadonly
         },
         user,
         CancellationToken.None,
         new FileDataStore(filedatastore)).Result;
      CreateServicies();
   }
   else if (_credential.Token.IsExpired(Google.Apis.Util.SystemClock.Default)) {
      class="type">bool refreshResult = _credential.RefreshTokenAsync(CancellationToken.None).Result;
      _list.Clear();
      if (!refreshResult) class="kw">return class="num">0;
      CreateServicies();
   }
}

class="kw">private class="type">void CreateServicies()
{
   _pservice = new PeopleService(new BaseClientService.Initializer()
   {
      HttpClientInitializer = _credential,
      ApplicationName = Applicationname
   });
   _gservice = new GmailService(new BaseClientService.Initializer()
   {
      HttpClientInitializer = _credential,
      ApplicationName = Applicationname
   });
}

群发邮件的收尾与异常透传

这段逻辑接在拉取联系人列表之后,负责把拿到的 _list 真正变成一封群发邮件。DEBUG 宏里用 i 从 1 自增,把每个人的序号、Name、Email 打到控制台,方便你本地跑的时候确认名单没拉空。 如果 _list.Count == 0,直接打印 'Sorry, List is empty...' 并 return 0,避免对空列表调 SMTP。这个提前返回能省掉一次无意义的发信请求,也方便在日志里定位是分组名写错还是接口没授权。 using 块里 new 了 MailMessage,Subject / Body / IsBodyHtml 由入参填。attach 不为 null 时逐个 path 挂 Attachment;随后 foreach 把 _list 里每个 nm.Email 和 nm.Name 加进 mail.To。 SendOneEmail 调用包在 try 里,抛异常就往 ex.Data 塞 'call SendOneEmail: ' 加错误信息再 throw,异常链不中断。外汇和贵金属信号推送走邮件网关本身有延迟与拦截风险,实盘提醒别只依赖这一条通道。

MQL5 / C++
GetPeople(_pservice, null, groupname);
            }
            catch (Exception ex) {
                ex.Data.Add("call GetPeople: ", ex.Message);
                throw;
            }
class="macro">#if DEBUG
            class="type">int i = class="num">1;
            foreach(var nm in _list) {
                Console.WriteLine("{class="num">0} {class="num">1} {class="num">2}", i++, nm.Name, nm.Email);
            }
class="macro">#endif
            if (_list.Count == class="num">0) {
                Console.WriteLine("Sorry, List is empty...");
                class="kw">return class="num">0;
            }
            using (MailMessage mail = new MailMessage
            {
                Subject = subject,
                Body = body,
                IsBodyHtml = isHtml
            }) class=class="str">"cmt">// MailMessage mail = new MailMessage
            {
                if (attach != null)
                {
                    foreach(var path in attach)
                        mail.Attachments.Add(new Attachment(path));
                } class=class="str">"cmt">//  if (attach != null)
                foreach(var nm in _list)
                    mail.To.Add(new MailAddress(nm.Email, nm.Name));
                try
                {
                    SendOneEmail(_gservice, mail);
                }
                catch (Exception ex)
                {
                    ex.Data.Add("call SendOneEmail: ", ex.Message);
                    throw;
                }
            }class=class="str">"cmt">// using (MailMessage mail = new MailMessage

◍ 按组分页拉取谷歌联系人

拿到访问权限后,真正往 _list 里灌数据靠的是 GetPeople 这个函数。它接收三个参数:service 是之前建好的 Google 联系人访问类实例;pageToken 用于翻页——谷歌联系人可能跨多页返回,首次调用传 NULL,若返回值非空就递归带着新令牌再查;groupName 指定要筛选的联系人组名。 组资源名得先按名定位。代码里用 lambda 从所有 ContactGroups 中匹配 FormattedName(转大写后等值比较),期望唯一定位,找不到就抛 MissingFieldException。这一步是后续成员过滤的前提。 请求字段特意选了 names、emailAddresses、memberships 和 biographies。前两者用来构造 OneContact 实例,memberships 判断归属组,biographies 本用于存储简历,这里被借来当开关:内容以 '1' 开头视为活跃联系人,否则即便在组内也忽略。选它纯粹因为日常少用、方便手动启停邮件活动。 下面这段是 GetPeople 的核心片段,注意它是项目里唯一不完全依赖外层异常重抛、而在现场做部分处理的函数。 请求执行后用 LINQ 多层 where 过滤:biographies 非空、成员属于目标组、且 PersonActive 返回 Active。未拉完则携新 pageToken 递归,把结果并回 _list。

MQL5 / C++
        class="kw">private class="type">void        GetPeople(PeopleService service, class="type">class="kw">string pageToken, class="type">class="kw">string groupName)
        {
          ...
            if (class="type">class="kw">string.IsNullOrEmpty(_groupsresourcename))
            {
                ContactGroupsResource groupsResource = new ContactGroupsResource(service);
                ContactGroupsResource.ListRequest listRequest = groupsResource.List();
                ListContactGroupsResponse response = listRequest.Execute();
                _groupsresourcename = (from gr in response.ContactGroups
                                                                       where class="type">class="kw">string.Equals(groupName.ToUpperInvariant(), gr.FormattedName.ToUpperInvariant())
                                                                       select gr.ResourceName).Single();
                if (class="type">class="kw">string.IsNullOrEmpty(_groupsresourcename))
                    throw (new MissingFieldException($"Can&class="macro">#x27;t find GroupName: {groupName}"));
            }class=class="str">"cmt">// if (class="type">class="kw">string.IsNullOrEmpty(_groupsresourcename))
Google.Apis.PeopleService.v1.PeopleResource.ConnectionsResource.ListRequest peopleRequest =
                new Google.Apis.PeopleService.v1.PeopleResource.ConnectionsResource.ListRequest(service, "people/me")
                {
                    PersonFields = "names,emailAddresses,memberships,biographies"
                };
            if (pageToken != null) {
                peopleRequest.PageToken = pageToken;
            }
            var request = peopleRequest.Execute();
            var list1 = from person in request.Connections
                                        where person.Biographies != null
                                        from mem in person.Memberships
                                        where class="type">class="kw">string.Equals(_groupsresourcename, mem.ContactGroupMembership.ContactGroupResourceName) &&
                                              PersonActive(person.Biographies.FirstOrDefault()?.Value) == PersonStatus.Active

「通讯录分页拉取与状态解析的实现细节」

这段逻辑负责把 Google People API 返回的联系人做 LINQ 投影,并按显示名排序后筛掉无有效邮箱的条目。 let name = person.Names.First().DisplayName 取首个姓名作为排序键;orderby name 决定列表顺序;email 那行用 ?. 链式判空,只保留 IsValidEmail() 为真的地址,where 子句再过滤空串,最终 select new OneContact 生成轻量对象。 _list.AddRange(list1) 把当前页结果并入总表;若 request.NextPageToken 不为 null,则递归调用 GetPeople 翻页,直到令牌耗尽。 PersonActive 方法把字符串数值映射成活跃状态:Int32.Parse 成功且等于 1 返回 Active,其余及格式化/溢出异常统统归为 Passive,调用方据此区分联系人是否有效。 在 MT5 外接 C# 工具里复用这段代码时,注意 NextPageToken 递归深度——单次拉取上限 1000 条,超量账户可能触发 5 层以上嵌套。

MQL5 / C++
let name = person.Names.First().DisplayName
orderby name
let email = person.EmailAddresses?.FirstOrDefault(p => p.Value.IsValidEmail())?.Value
where !class="type">class="kw">string.IsNullOrEmpty(email)
select new OneContact(name, email);
_list.AddRange(list1);
if (request.NextPageToken != null) {
GetPeople(service, request.NextPageToken, groupName);
}
class=class="str">"cmt">//class="type">void GetPeople(PeopleService service, class="type">class="kw">string pageToken, class="type">class="kw">string groupName)
class="kw">private PersonStatus PersonActive(class="type">class="kw">string value)
{
	try {
		class="kw">switch (Int32.Parse(value))
		{
			case class="num">1:
				class="kw">return PersonStatus.Active;
			class="kw">default:
				class="kw">return PersonStatus.Passive;
		}
	}
	catch (FormatException)  { class="kw">return PersonStatus.Passive; }
	catch (OverflowException) { class="kw">return PersonStatus.Passive; }
}
class=class="str">"cmt">//PersonStatus PersonActive(class="type">class="kw">string value)

让 Gmail 代发邮件的最后一步

ContactsPeople 类里真正干活的发送逻辑,落在 SendOneEmail 这个私有函数上。它不负责拼邮件内容,只管把 .NET 的 MailMessage 转成 Google 能收的 MIME 格式并推送到 Gmail API。 Google 的 MailMessage 接口不吃原生 MailMessage 对象,必须先用 MimeKit 的 CreateFromMailMessage 转成 MimeMessage,再字符串化后做 Base64URL 编码。代码里 encodedText 就是这么来的,Raw 字段只认这种编码串。 调用 service.Users.Messages.Send 时第二个参数写死 "me",这是 Gmail API 的特殊 userId 值,代表用当前已授权账号发信,省去查账号 ID 的麻烦。外汇与贵金属信号推送若接这套,需注意 API 授权失效会导致发送中断,属高风险依赖。 下面这段是可直接粘进 MT5 外部 C# 辅助工程的发送核心,逐行拆完就能跑: private void SendOneEmail(GmailService service, MailMessage mail) — 定义函数,入参是已初始化的 Gmail 服务实例和待发邮件 MimeKit.MimeMessage mimeMessage = MimeKit.MimeMessage.CreateFromMailMessage(mail); — 把 .NET 邮件转成 MimeKit 标准 MIME 对象 var encodedText = Base64UrlEncode(mimeMessage.ToString()); — MIME 文本做 Base64URL 编码,适配 Raw 字段 var message = new Message { Raw = encodedText }; — 构造 Google 的 Message 对象,只填 Raw var request = service.Users.Messages.Send(message, "me").Execute(); — 以 "me" 身份执行发送,返回发送结果 // bool SendOneEmail(...) 是原想暴露的签名注释,实际为 void 实现

MQL5 / C++
        class="kw">private class="type">void SendOneEmail(GmailService service, MailMessage mail)
        {
            MimeKit.MimeMessage mimeMessage = MimeKit.MimeMessage.CreateFromMailMessage(mail);
            var encodedText = Base64UrlEncode(mimeMessage.ToString());
            var message = new Message { Raw = encodedText };
            var request = service.Users.Messages.Send(message, "me").Execute();
        }class=class="str">"cmt">//  class="type">bool SendOneEmail(GmailService service, MailMessage mail)
让小布替你跑这套
这些诊断与桥接逻辑小布盯盘的 AIGC 已内置,打开对应品种页即可看到邮件任务的运行状态,不必自己盯日志。

常见问题

按本文设计,列表允许同一联系人多个地址,发送模块会遍历有效地址,但可在活动配置中排除重复或无效项。
本篇定位新手友好,重点在理清任务边界与谷歌侧准备,代码拼接步骤拆得很细,复制改参即可跑通。
小布盯盘内置 AIGC 可生成报告并对接通知渠道,但群发邮件涉及谷歌授权,仍需参考本篇的 C# 桥结构自行部署。
CSV 不够可靠且难跨设备,谷歌服务能受保护地存联系人、建组并随时发信,适合需要动态调整列表的场景。
给相关联系人打组标签(如 Forex),邮寄时只取该组,并把不活跃联系人标记为排除即可。