通过谷歌服务安排邮寄活动·进阶篇
(2/3)· 当 MQL alone 搞不定群发邮件,如何用谷歌联系人组 + C# 桥接实现带附件的定向邮寄
很多交易者想给订户或客户定期发截图和报告,却在 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。
<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 { <span class="keyword">internal </span><span class="keyword">sealed </span><span class="keyword">class</span> ContactsPeople { class="kw">public class="kw">static class="type">class="kw">string Applicationname { get; } = "WorkWithPeople"; ..... <span class="keyword">class="kw">private</span><span class="keyword"> enum</span> PersonStatus { Active, Passive }; <span class="keyword">class="kw">private</span><span class="keyword"> class="type">class="kw">string</span> _groupsresourcename; <span class="keyword">class="kw">private</span> List<OneContact> _list = <span class="keyword">new</span> List<OneContact>(); <span class="keyword">class="kw">private</span> UserCredential _credential; <span class="keyword">class="kw">private</span> PeopleService _pservice; <span class="keyword">class="kw">private</span> GmailService _gservice; <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, <span class="keyword">class="type">class="kw">string</span> user, <span class="keyword">class="type">class="kw">string</span> filedatastore, <span class="keyword">class="type">class="kw">string</span> groupname, <span class="keyword">class="type">class="kw">string</span> subject, <span class="keyword">class="type">class="kw">string</span> body, <span class="keyword">class="type">bool</span> 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 改成抛自定义异常,否则上层循环可能把『令牌过期』误判为『无收件人』。
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,异常链不中断。外汇和贵金属信号推送走邮件网关本身有延迟与拦截风险,实盘提醒别只依赖这一条通道。
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。
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 层以上嵌套。
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 实现
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)