希望能看到还总账的那天 發表於 2025-7-29 16:13:00

C# 调用邮箱应用发送带附件的邮件

<h1 id="c调用邮箱应用发送带附件的邮件">C#&nbsp;调用邮箱应用发送带附件的邮件</h1>
<h2 id="邮件的几大要素">邮件的几大要素</h2>
<ul>
<li>发件人&nbsp;From</li>
<li>收件人(主要收件人&nbsp;To,抄送&nbsp;CC,密送&nbsp;BCC)</li>
<li>主题&nbsp;Subject</li>
<li>正文&nbsp;Body</li>
<li>附件&nbsp;Attachments</li>
</ul>
<h2 id="smtpclient和mailkit">SmtpClient&nbsp;和&nbsp;MailKit</h2>
<p>如果有邮箱服务器并且已知发件人邮箱和密码,可以通过&nbsp;C#&nbsp;自带的&nbsp;SmtpClient&nbsp;或者使用开源库&nbsp;MailKit</p>
<h2 id="调用第三方邮箱应用">调用第三方邮箱应用</h2>
<p>C#&nbsp;自带的&nbsp;MailMessage&nbsp;类中的&nbsp;Attachments&nbsp;会直接打开文件流,且没有属性可以获取文件路径</p>
<p>我们可以创建一个简单的邮件信息类,调用第三方邮箱客户端一般不需要发件人,可去掉发件人属性</p>
<pre><code class="language-csharp">using System.Collections.Generic;
using System.Net.Mail;

public sealed class MailInfo
{
    // /// &lt;summary&gt;发件人&lt;/summary&gt;
    // public MailAddress From { get; set; }

    /// &lt;summary&gt;主要收件人&lt;/summary&gt;
    public List&lt;MailAddress&gt; Recipients { get; } = new List&lt;MailAddress&gt;();

    /// &lt;summary&gt;抄送收件人&lt;/summary&gt;
    public List&lt;MailAddress&gt; CcRecipients { get; } = new List&lt;MailAddress&gt;();

    /// &lt;summary&gt;密送收件人&lt;/summary&gt;
    public List&lt;MailAddress&gt; BccRecipients { get; } = new List&lt;MailAddress&gt;();

    /// &lt;summary&gt;主题&lt;/summary&gt;
    public string Subject { get; set; }

    /// &lt;summary&gt;正文&lt;/summary&gt;
    public string Body { get; set; }

    /// &lt;summary&gt;附件文件列表&lt;/summary&gt;
    /// &lt;remarks&gt;Key 为显示文件名, Value 为文件路径&lt;/remarks&gt;
    public Dictionary&lt;string, string&gt; Attachments { get; } = new Dictionary&lt;string, string&gt;();
}
</code></pre>
<h3 id="mailto协议">mailto&nbsp;协议</h3>
<p>mailto&nbsp;是全平台支持的协议,支持多个收件人,抄送和密送,但不支持添加附件</p>
<h4 id="mailto关联应用">mailto&nbsp;关联应用</h4>
<p>在&nbsp;Windows&nbsp;上使用&nbsp;mailto&nbsp;会调用其关联应用,未设置关联应用时,会弹出打开方式对话框询问使用什么应用打开</p>
<p><img src="https://img2024.cnblogs.com/blog/1823651/202507/1823651-20250729155550674-133004506.png" alt="image" loading="lazy"></p>
<p>关联注册表位置<br>
<code>HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\Shell\Associations\UrlAssociations\mailto\UserChoice</code></p>
<pre><code class="language-csharp">// 常见的邮箱应用 mailto ProgID
const string OutlookNewProgID = "AppXbx2ce4vcxjdhff3d1ms66qqzk12zn827"; // Outlook(New)
const string EMClientProgID = "eM Client.Url.mailto"; // eM Client
const string ThunderbirdProgID = "Thunderbird.Url.mailto"; // Mozilla Thunderbird
const string MailMasterProgID = "MailMaster"; // 网易邮箱大师

/// &lt;summary&gt;查找 mailto 协议关联的邮箱应用 ProgID&lt;/summary&gt;
private static string FindMailToClientProgID()
{
    // Win10 以上支持 AssocQueryString 查找 ProgID, 为兼容低版本使用注册表查询
    // return NativeMethods.AssocQueryString(AssocStr.ProgID, "mailto");
    const string keyPath = @"HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\Shell\Associations\UrlAssociations\mailto\UserChoice";
    return Registry.GetValue(keyPath, "ProgId", null)?.ToString();
}

/// &lt;summary&gt;判断是否是 Outlook 关联的 ProgID&lt;/summary&gt;
private static bool IsOutlookProgID(string progID)
{
    var st = StringComparison.OrdinalIgnoreCase;
    return progID.IndexOf("Outlook", st) &gt;= 0 // Outlook(Classic) 版本相关,如 Outlook.URL.mailto.15
      || progID.Equals(OutlookNewProgID, st);
}
</code></pre>
<h4 id="mailto标准">mailto&nbsp;标准</h4>
<p>语法:<code>mailto:sAddress</code><br>
示例:<code>mailto:example@to.com?subject=Test%20Subject</code></p>
<p>主要收件人写在&nbsp;sAddress,抄送、密送、主题和正文都放在&nbsp;sHeaders&nbsp;里面,需要对所有&nbsp;URL&nbsp;保留字符进行编码转义</p>
<p>大部分邮箱应用都使用较新的&nbsp;RFC&nbsp;6068&nbsp;标准(收件人、抄送、密送使用逗号分隔),且部分应用同时兼容分号和逗号</p>
<p>但是&nbsp;&nbsp;Microsoft&nbsp;Outlook&nbsp;还在使用着比较旧的&nbsp;RFC&nbsp;2368&nbsp;标准(收件人、抄送、密送使用分号分隔)</p>
<p>故当关联应用为&nbsp;Outlook&nbsp;时,包括&nbsp;Classic&nbsp;版本和&nbsp;UWP&nbsp;新版,都无法正确解析逗号连接的多个收件人、抄送、密送</p>
<p>Classic&nbsp;版本支持的&nbsp;COM&nbsp;Interop&nbsp;方式中也是使用的分号分隔</p>
<p>另外&nbsp;PDF&nbsp;表单&nbsp;JavaScript&nbsp;动作中&nbsp;mailDoc、mailForm&nbsp;等发送邮件的方法也是使用的分号分隔符</p>
<p>因此我们可以给上文中的&nbsp;MailInfo&nbsp;类添加几个获取指定分隔符连接的收件人地址字符串的方法</p>
<pre><code class="language-csharp">/// &lt;summary&gt;获取指定分隔符连接的收件地址&lt;/summary&gt;
/// &lt;param name="separator"&gt;遵循 mailto RFC 6068 规范默认为逗号,部分邮箱客户端支持逗号和分号,
/// &lt;para&gt;但 Outlook 仅支持分号; PDF 表单 JavaScript 动作中使用分号&lt;/para&gt;&lt;/param&gt;
public string GetTO(string separator = ",")
{
    return string.Join(separator, Recipients.ToArray());
}

/// &lt;summary&gt;获取指定分隔符连接的抄送地址&lt;/summary&gt;
public string GetCC(string separator = ",")
{
    return string.Join(separator, CcRecipients.ToArray());
}

/// &lt;summary&gt;获取指定分隔符连接的密送地址&lt;/summary&gt;
public string GetBCC(string separator = ",")
{
    return string.Join(separator, BccRecipients.ToArray());
}
</code></pre>
<h4 id="调用mailto关联邮箱">调用&nbsp;mailto&nbsp;关联邮箱</h4>
<pre><code class="language-csharp">/// &lt;summary&gt;通过 mailto 协议调用默认邮箱客户端发送邮件&lt;/summary&gt;
/// &lt;remarks&gt;不支持附件, 支持 Outlook(New)&lt;/remarks&gt;
public static bool SendByProtocol(MailInfo info)
{
    bool isOutlook = IsOutlookProgID(FindMailToClientProgID());
    string separator = isOutlook ? ";" : ","; // Outlook 仅支持分号, 其他客户端支持标准的逗号
    var url = new StringBuilder("mailto:");
    url.Append(info.GetTO(separator));
    url.Append("?");
    string cc = info.GetCC(separator);
    string bcc = info.GetBCC(separator);
    if (!string.IsNullOrEmpty(cc))
    {
      url.Append($"cc={Uri.EscapeDataString(cc)}&amp;");
    }
    if (!string.IsNullOrEmpty(bcc))
    {
      url.Append($"bcc={Uri.EscapeDataString(bcc)}&amp;");
    }
    if (!string.IsNullOrEmpty(info.Subject))
    {
      url.Append($"subject={Uri.EscapeDataString(info.Subject)}&amp;");
    }
    if (!string.IsNullOrEmpty(info.Body))
    {
      url.Append($"body={Uri.EscapeDataString(info.Body)}&amp;");
    }
    url.Remove(url.Length - 1, 1);
    var startInfo = new ProcessStartInfo
    {
      FileName = url.ToString(),
      UseShellExecute = true,
    };
    try
    {
      Process.Start(startInfo);
      return true;
    }
    catch
    {
      return false;
    }
}
</code></pre>
<h3 id="win32mapi">Win32&nbsp;MAPI</h3>
<p>Windows&nbsp;定义了&nbsp;MAPI&nbsp;接口供第三方邮箱应用实现集成,例如&nbsp;Outlook(Classic)、eM&nbsp;Client、Thunderbird</p>
<p>C#&nbsp;中可以使用&nbsp;MAPISendMail&nbsp;进行调用,需要注意不一定成功,可能会遇到未知的<code>MAPI_E_FAILURE</code>错误</p>
<p>另外&nbsp;MAPI&nbsp;方式支持设置是否显示&nbsp;UI&nbsp;(<code>MAPI_DIALOG</code>、<code>MAPI_DIALOG_MODELESS</code>、<code>MAPI_LOGON_UI</code>)</p>
<p>可以为上文中的&nbsp;MailInfo&nbsp;类添加一个是否显示&nbsp;UI&nbsp;的属性</p>
<pre><code class="language-csharp">/// &lt;summary&gt;是否不显示UI自动发送, 至少需要一名收件人&lt;/summary&gt;
public bool WithoutUI { get; set; }
</code></pre>
<h4 id="mapi关联应用">MAPI&nbsp;关联应用</h4>
<p>支持&nbsp;MAPI&nbsp;的邮箱应用一般会在<code>{HKLM|HKCU}\SOFTWARE\Clients\Mail</code>下写入子项</p>
<p>通过修改&nbsp;Mail&nbsp;项默认键值修改默认&nbsp;MAPI&nbsp;邮箱,HKCU&nbsp;优先,键值需要与&nbsp;Mail&nbsp;子项名称一致</p>
<pre><code class="language-csharp">/// &lt;summary&gt;查找 MAPI 邮箱客户端&lt;/summary&gt;
private static string FindMAPIClientName()
{
    const string MapiKeyPath = @"Software\Clients\Mail";
    using (var cuKey = Registry.CurrentUser.OpenSubKey(MapiKeyPath))
    using (var lmKey = Registry.LocalMachine.OpenSubKey(MapiKeyPath))
    {
      var cuKeyNames = cuKey?.GetSubKeyNames() ?? new string;
      var lmKeyNames = lmKey?.GetSubKeyNames() ?? new string;
      // HKCU 可获取 HKLM 子健, HKLM 不可反向获取 HKCU 子健
      cuKeyNames = cuKeyNames.Concat(lmKeyNames).ToArray();
      string cuValue = cuKey?.GetValue(null)?.ToString();
      if (cuKeyNames.Contains(cuValue, StringComparer.OrdinalIgnoreCase))
      {
            return cuValue;
      }
      string lmValue = lmKey?.GetValue(null)?.ToString();
      if (lmKeyNames.Contains(lmValue, StringComparer.OrdinalIgnoreCase))
      {
            return lmValue;
      }
    }
    return null;
}
</code></pre>
<h4 id="调用mapi关联邮箱">调用&nbsp;MAPI&nbsp;关联邮箱</h4>
<p>文件系统对象右键菜单的发送到子菜单中的就是调用的&nbsp;MAPI&nbsp;关联邮箱</p>
<p>未设置&nbsp;MAPI&nbsp;关联邮箱时调用会弹窗提示,如果&nbsp;Mail&nbsp;项中<code>PreFirstRun</code>键值不为空,则弹窗优先显示其内容,*分隔内容和标题</p>
<p>但此弹窗内容会误导用户,因为控制面板默认程序中只能设置&nbsp;mailto&nbsp;关联邮箱而不能设置&nbsp;MAPI&nbsp;关联邮箱,两者无关</p>
<p><img src="https://img2024.cnblogs.com/blog/1823651/202507/1823651-20250729155644306-1628775417.png" alt="image" loading="lazy"><br>
<img src="https://img2024.cnblogs.com/blog/1823651/202507/1823651-20250729155650521-667082313.png" alt="image" loading="lazy"></p>
<p>另外建议异步调用,否则外部出错可能会卡死进程</p>
<p>比如同时安装了&nbsp;Outlook(Classic)&nbsp;和&nbsp;Outlook(New)&nbsp;且启用&nbsp;New&nbsp;时,Outlook(Classic)后台启动后会调起&nbsp;Outlook(New)并结束自身,提前关闭了&nbsp;Outlook(New)&nbsp;主窗口,&nbsp;或设置了收件人、抄送、密送</p>
<p><img src="https://img2024.cnblogs.com/blog/1823651/202507/1823651-20250729155708841-1617718329.png" alt="image" loading="lazy"></p>
<p>而且下文中的&nbsp;Outlook&nbsp;COM&nbsp;和命令行方式也都是只支持&nbsp;Classic&nbsp;不支持&nbsp;New,所以我们需要一个判断是否启用了&nbsp;Outlook(New)&nbsp;的方法</p>
<p>这里我们可以使用&nbsp;AssocQueryString&nbsp;根据&nbsp;ProgID&nbsp;获取其友好名称来判断是否安装了新版&nbsp;Outlook,上文代码中也提到了&nbsp;Win10&nbsp;以上系统可以用&nbsp;AssocQueryString&nbsp;直接查询&nbsp;mailto&nbsp;关联的&nbsp;ProgID,而下文中也会用其根据&nbsp;ProgID&nbsp;获取关联可执行文件路径</p>
<pre><code class="language-csharp">/// &lt;summary&gt;是否同时安装了 Outlook Classic 和 New 两个版本,且启用 New&lt;/summary&gt;
public static bool IsUseNewOutlook()
{
    string name = NativeMethods.AssocQueryString(AssocStr.FriendlyAppName, OutlookNewProgID);
    bool existsNew = name.Equals("Outlook", StringComparison.OrdinalIgnoreCase);
    if (existsNew)
    {
      string regPath = @"HKEY_CURRENT_USER\SOFTWARE\Microsoft\Office\16.0\Outlook\Preferences";
      bool useNew = Convert.ToInt32(Registry.GetValue(regPath, "UseNewOutlook", 0)) == 1;
      if (useNew)
      {
            return true;
      }
    }
    return false;
}
</code></pre>
<p>另外如果只安装了&nbsp;Outlook(New)(Win11&nbsp;默认预装)的情况下,无法通过&nbsp;MAPI&nbsp;方式调起,如若可获知&nbsp;Outlook(Classic)&nbsp;是如何启动&nbsp;Outlook(New)&nbsp;即可有方法单独启动&nbsp;Outlook(New)。现今未找到只安装了&nbsp;Outlook(New)&nbsp;创建带附件邮件的方法</p>
<pre><code class="language-csharp">const string OutlookClientName = "Microsoft Outlook";

/// &lt;summary&gt;通过 Win32 MAPI 发送邮件&lt;/summary&gt;
/// &lt;remarks&gt;⚠: 调用 MAPI 方式是同步执行,外部出错可能会卡死进程,
/// &lt;para&gt;比如同时安装了 Outlook(Classic) 和 Outlook(New) 且启用 New 时,&lt;/para&gt;
/// &lt;para&gt;提前关闭了 Outlook(New) 主窗口, 或设置了收件人、抄送、密送&lt;/para&gt;&lt;/remarks&gt;
public static bool SendByMAPI(MailInfo info)
{
    var msg = new MapiMessage
    {
      subject = info.Subject,
      noteText = info.Body,
    };
    var recipients =
      info.Recipients.Select(x =&gt; MapiRecipDesc.Create(x, RecipClass.TO)).Concat(
      info.CcRecipients.Select(x =&gt; MapiRecipDesc.Create(x, RecipClass.CC))).Concat(
      info.BccRecipients.Select(x =&gt; MapiRecipDesc.Create(x, RecipClass.BCC))).ToArray();
    if (recipients.Length &gt; 0)
    {
      // 测试设置了收件人、抄送、密送 Outlook(New) 会卡住
      if (OutlookClientName.Equals(FindMAPIClientName(), StringComparison.OrdinalIgnoreCase) &amp;&amp; IsUseNewOutlook())
            return false;
      IntPtr pRecips = NativeMethods.GetStructArrayPointer(recipients);
      if (pRecips != IntPtr.Zero)
      {
            msg.recips = pRecips;
            msg.recipCount = recipients.Length;
      }
    }
    var attachments = info.Attachments.Select(x =&gt; MapiFileDesc.Create(x.Value, x.Key)).ToArray();
    if (attachments.Length &gt; 0)
    {
      IntPtr pFiles = NativeMethods.GetStructArrayPointer(attachments);
      if (pFiles != IntPtr.Zero)
      {
            msg.files = pFiles;
            msg.fileCount = attachments.Length;
      }
    }
    var flags = MapiFlags.ForceUnicode;
    if (!(info.WithoutUI &amp;&amp; info.Recipients.Count &gt; 0))
    {
      flags |= MapiFlags.DialogModeless | MapiFlags.LogonUI;
    }
    try
    {
      var error = NativeMethods.MAPISendMail(IntPtr.Zero, IntPtr.Zero, msg, flags, 0);
      if (error == MapiError.UnicodeNotSupported)
      {
            flags &amp;= ~MapiFlags.ForceUnicode; // 不支持 Unicode 时移除标志
            error = NativeMethods.MAPISendMail(IntPtr.Zero, IntPtr.Zero, msg, flags, 0);
      }
      return error == MapiError.Success || error == MapiError.UserAbort;
    }
    finally
    {
      NativeMethods.FreeStructArrayPointer&lt;MapiRecipDesc&gt;(msg.recips, recipients.Length);
      NativeMethods.FreeStructArrayPointer&lt;MapiFileDesc&gt;(msg.files, attachments.Length);
    }
}
</code></pre>
<details open="">
<summary>用到的本机方法、结构体、枚举</summary>
<pre><code class="language-csharp">static class NativeMethods
{
   
    public static extern MapiError MAPISendMail(IntPtr session, IntPtr hWndParent, MapiMessage message, MapiFlags flags, int reserved);

   
    public static extern int AssocQueryString(AssocFlags assocFlag, AssocStr assocStr, string pszAssoc, string pszExtra, StringBuilder pszOut, ref int pcchOut);

    public static string AssocQueryString(AssocStr type, string assocStr, AssocFlags flags = AssocFlags.None)
    {
      int length = 0;
      AssocQueryString(flags, type, assocStr, null, null, ref length); // 获取长度
      var sb = new StringBuilder(length);
      AssocQueryString(flags, type, assocStr, null, sb, ref length);
      return sb.ToString();
    }

    /// &lt;summary&gt;获取结构体数组指针&lt;/summary&gt;
    public static IntPtr GetStructArrayPointer&lt;T&gt;(T[] array) where T : struct
    {
      IntPtr hglobal = IntPtr.Zero;
      int copiedCount = 0;
      try
      {
            int size = Marshal.SizeOf(typeof(T));
            hglobal = Marshal.AllocHGlobal(size * array.Length);
            for (int i = 0; i &lt; array.Length; i++)
            {
                IntPtr ptr = new IntPtr(hglobal.ToInt64() + i * size);
                Marshal.StructureToPtr(array, ptr, false);
                copiedCount++;
            }
      }
      catch
      {
            FreeStructArrayPointer&lt;T&gt;(hglobal, copiedCount);
            throw;
      }
      return hglobal;
    }

    /// &lt;summary&gt;释放结构体数组指针&lt;/summary&gt;
    public static void FreeStructArrayPointer&lt;T&gt;(IntPtr ptr, int count) where T : struct
    {
      if (ptr != IntPtr.Zero &amp;&amp; count &gt; 0)
      {
            int size = Marshal.SizeOf(typeof(T));
            for (int i = 0; i &lt; count; i++)
            {
                IntPtr itemPtr = new IntPtr(ptr.ToInt64() + i * size);
                Marshal.DestroyStructure(itemPtr, typeof(T));
            }
            Marshal.FreeHGlobal(ptr);
      }
    }
}


struct MapiMessage
{
    public int reserved;
    public string subject;
    public string noteText;
    public string messageType;
    public string dateReceived;
    public string conversationID;
    public int flags;
    public IntPtr originator;
    public int recipCount;
    public IntPtr recips;
    public int fileCount;
    public IntPtr files;
}


struct MapiRecipDesc
{
    public int reserved;
    public RecipClass recipClass;
    public string name;
    public string address;
    public int eIDSize;
    public IntPtr entryID;

    public static MapiRecipDesc Create(MailAddress address, RecipClass recipClass = RecipClass.TO)
    {
      var result = new MapiRecipDesc
      {
            name = address.DisplayName,
            address = address.Address,
            recipClass = recipClass,
      };
      if (string.IsNullOrEmpty(result.name))
      {
            // Outlook name 不可为空, em Client 可设 address 或 name
            result.name = result.address;
      }
      return result;
    }
}


struct MapiFileDesc
{
    public int reserved;
    public int flags;
    public int position;
    public string pathName;
    public string fileName;
    public IntPtr fileType; // MapiFileTagExt

    public static MapiFileDesc Create(string filePath, string fileName = null)
    {
      return new MapiFileDesc
      {
            pathName = filePath,
            fileName = fileName ?? Path.GetFileName(filePath),
            position = -1, // 不指示附件位置
      };
    }
}


enum MapiFlags
{
    LogonUI = 0x1,
    NewSession = 0x2,
    Dialog = 0x8,
    DialogModeless = 0x4 | Dialog,
    ForceUnicode = 0x40000,
}

enum MapiError
{
    /// &lt;summary&gt;成功&lt;/summary&gt;
    Success = 0,
    /// &lt;summary&gt;用户中止&lt;/summary&gt;
    UserAbort = 1,
    /// &lt;summary&gt;发生一个或多个未指定错误&lt;/summary&gt;
    Failure = 2,
    /// &lt;summary&gt;登录失败&lt;/summary&gt;
    LoginFailure = 3,
    /// &lt;summary&gt;内存不足&lt;/summary&gt;
    InsufficientMemory = 5,
    /// &lt;summary&gt;文件附件太多&lt;/summary&gt;
    TooManyFiles = 9,
    /// &lt;summary&gt;收件人太多&lt;/summary&gt;
    TooManyRecipients = 10,
    /// &lt;summary&gt;找不到附件&lt;/summary&gt;
    AttachmentNotFound = 11,
    /// &lt;summary&gt;无法打开附件&lt;/summary&gt;
    AttachmentOpenFailure = 12,
    /// &lt;summary&gt;收件人未显示在地址列表中&lt;/summary&gt;
    UnknownRecipient = 14,
    /// &lt;summary&gt;收件人类型错误&lt;/summary&gt;
    BadRecipient = 15,
    /// &lt;summary&gt;消息中文本太大&lt;/summary&gt;
    TextTooLarge = 18,
    /// &lt;summary&gt;收件人与多个收件人描述符结构匹配,且未设置 MAPI_DIALOG&lt;/summary&gt;
    AmbiguousRecipient = 21,
    /// &lt;summary&gt;一个或多个收件人无效&lt;/summary&gt;
    InvalidRecips = 25,
    /// &lt;summary&gt;指定了 MAPI_FORCE_UNICODE 标志,但不支持 Unicode&lt;/summary&gt;
    UnicodeNotSupported = 27,
    /// &lt;summary&gt;附件太大&lt;/summary&gt;
    AttachmentTooLarge = 28,
}


enum AssocFlags
{
    None = 0,
    InitNoreMapClsid = 0x1,
    InitByExeName = 0x2,
    InitDefaultToStar = 0x4,
    InitDefaultToFolder = 0x8,
    NoUserSettings = 0x10,
    NotRunCate = 0x20,
    Verify = 0x40,
    RemapRunDll = 0x80,
    NoFixups = 0x100,
    IgnoreBaseClass = 0x200,
    InitIgnoreUnknown = 0x400,
    InitFixedProgID = 0x800,
    IsProtocol = 0x1000,
    InitForFile = 0x2000
}

enum AssocStr
{
    Command = 1,
    Executable,
    FriendlyDocName,
    FriendlyAppName,
    NoOpen,
    ShellNewValue,
    DDECommand,
    DDEIfExec,
    DDEApplication,
    DDEToPIC,
    InfoTip,
    QuickTip,
    TileInfo,
    ContentType,
    DefaultIcon,
    ShellExtension,
    DropTarget,
    DelegateExecute,
    SupportedURIProtocols,
    ProgID,
    AppID,
    AppPublisher,
    AppIconReference
}
</code></pre>
</details>
<h4 id="调用其他mapi邮箱">调用其他&nbsp;MAPI&nbsp;邮箱</h4>
<p>已知第三方邮箱应用包含&nbsp;MAPI&nbsp;相关导出函数的&nbsp;dll&nbsp;位置时,可通过&nbsp;GetProcAddress&nbsp;来调用</p>
<pre><code class="language-csharp">
extern static IntPtr LoadLibrary(string lpLibFileName);


extern static IntPtr GetProcAddress(IntPtr hModule, string lpProcName);


extern static bool FreeLibrary(IntPtr hLibModule);

// 定义与 MAPISendMail 方法相同签名的委托
delegate MapiError MAPISendMailDelegate(IntPtr session, IntPtr hWndParent, MapiMessage message, MapiFlags flags, int reserved);

public static bool SendMail(MapiMessage msg, MapiFlags flags, string dllPath)
{
    IntPtr hLib = LoadLibrary(dllPath);
    if(hLib != IntPtr.Zero)
    {
      try
      {
            IntPtr hProc = GetProcAddress(hLib, "MAPISendMail");
            if(hProc != IntPtr.Zero)
            {
                var func = Marshal.GetDelegateForFunctionPointer(hProc, typeof(MAPISendMailDelegate) as MAPISendMailDelegate);
                var error = func?.Invoke(IntPtr.Zero, IntPtr.Zero, msg, flags, 0);
                return error == MapiError.Success || error == MapiError.UserAbort;
            }
      }
      finally
      {
            FreeLibrary(hLib);
      }
    }
    return false;
}
</code></pre>
<p>上述方法需要调用程序和&nbsp;dll&nbsp;位数相同,故为了兼容不同邮箱应用可能需要分别编译&nbsp;x64&nbsp;和&nbsp;x86&nbsp;的程序</p>
<p>这里提供一种兼容不同位数邮箱应用的方法:临时将目标邮箱设为&nbsp;MAPI&nbsp;关联邮箱,调用&nbsp;MAPISendMail&nbsp;后还原</p>
<pre><code class="language-csharp">/// &lt;summary&gt;通过 Win32 MAPI 发送邮件&lt;/summary&gt;
/// &lt;remarks&gt;Microsoft Outlook、eM Client、Mozilla Thunderbird 支持, 其他待发现&lt;/remarks&gt;
private static bool SendByMAPI(MailInfo info, string clientName)
{
    if (string.IsNullOrEmpty(clientName))
    {
      return false;
    }
    if (FindMAPIClientName() == clientName)
    {
      return SendByMAPI(info);
    }
    else
    {
      try
      {
            using (var key = Registry.CurrentUser.OpenSubKey(MapiKeyPath, true)
                ?? Registry.CurrentUser.CreateSubKey(MapiKeyPath))
            {
                string currentValue = key.GetValue(null)?.ToString();
                key.SetValue(null, clientName);
                bool success = SendByMAPI(info);
                if (currentValue != null)
                {
                  key.SetValue(null, currentValue);
                }
                else
                {
                  key.DeleteValue(null, false);
                }
                return success;
            }
      }
      catch
      {
            return false;
      }
    }
}
</code></pre>
<h3 id="outlookclassic">Outlook(Classic)</h3>
<p>Outlook(Classic)&nbsp;还支持&nbsp;COM&nbsp;互操作和命令行的方式创建带附件的邮件,Outlook(New)&nbsp;两种方式都不支持</p>
<pre><code class="language-csharp">/// &lt;summary&gt;通过 Outlook 发送邮件&lt;/summary&gt;
public static bool SendByOutlook(MailInfo info)
{
    return SendByOutlookMAPI(info) || SendByOutlookWithoutMAPI(info);
}

/// &lt;summary&gt;通过 Outlook MAPI 发送邮件&lt;/summary&gt;
public static bool SendByOutlookMAPI(MailInfo info)
{
    return SendByMAPI(info, OutlookClientName);
}

/// &lt;summary&gt;通过 Outlook COM 或进程方式发送邮件&lt;/summary&gt;
public static bool SendByOutlookWithoutMAPI(MailInfo info)
{
    return !IsUseNewOutlook() &amp;&amp; (SendByOutlookCOM(info) || SendByOutlookProcess(info));
}
</code></pre>
<h4 id="outlookcom">Outlook&nbsp;COM</h4>
<p>通过引用&nbsp;Microsoft.Office.Interop.Outlook&nbsp;互操作库可用&nbsp;COM&nbsp;对象来创建带附件的邮件,支持添加多个附件</p>
<p>当同时安装了&nbsp;Classic&nbsp;和&nbsp;New&nbsp;且启用&nbsp;New&nbsp;时此方式无效:会卡在创建&nbsp;app&nbsp;对象</p>
<pre><code class="language-csharp">using System.Runtime.InteropServices;
using Microsoft.Office.Interop.Outlook;

/// &lt;summary&gt;通过 Outlook COM 对象发送邮件&lt;/summary&gt;
/// &lt;remarks&gt;⚠: 当同时安装了 Outlook(Classic) 和 Outlook(New) 且启用 New 时会卡在创建 app 对象&lt;/remarks&gt;
public static bool SendByOutlookCOM(MailInfo info)
{
    Application app = null;
    MailItem mail = null;
    Attachments attachments = null;
    try
    {
      app = (Application)Marshal.GetActiveObject("Outlook.Application");
    }
    catch
    {
      // 未找到活动的 Outlook 实例
    }
    bool isRunning = app != null; // Outlook 同时只允许一个实例进程
    try
    {
      if (!isRunning)
      {
            app = new Application(); // 同时安装 Classic 和 New 且启用 New 时会卡在这里
      }
      mail = app.CreateItem(OlItemType.olMailItem) as MailItem;
      mail.Subject = info.Subject;
      mail.Body = info.Body;
      mail.To = info.GetTO(";");
      mail.CC = info.GetCC(";");
      mail.BCC = info.GetBCC(";");
      if (info.Attachments != null)
      {
            attachments = mail.Attachments;
            foreach (var file in info.Attachments.Values)
            {
                attachments.Add(file);
            }
      }
      if (info.WithoutUI &amp;&amp; info.Recipients.Count &gt; 0)
      {
            mail.Send();
      }
      else
      {
            mail.Display(false);
      }
      return true;
    }
    catch
    {
      if (!isRunning)
      {
            app?.Quit(); // 之前未运行时,启动的新实例遇到错误时关闭程序
      }
      return false;
    }
    finally
    {
      if (attachments != null)
      {
            Marshal.ReleaseComObject(attachments);
      }
      if (mail != null)
      {
            Marshal.ReleaseComObject(mail);
      }
      if (app != null)
      {
            Marshal.ReleaseComObject(app);
      }
    }
}
</code></pre>
<h4 id="outlook命令行">Outlook&nbsp;命令行</h4>
<p>命令行方式只能添加一个附件</p>
<p>当同时安装了&nbsp;Outlook(Classic)&nbsp;和&nbsp;Outlook(New)&nbsp;且启用&nbsp;New&nbsp;时此方式无效</p>
<p>示例:<code>outlook.exe&nbsp;/c&nbsp;ipm.note&nbsp;/m&nbsp;example@to.com?subject=Test%20Subject&nbsp;/a&nbsp;C:\dir\file</code></p>
<pre><code class="language-csharp">/// &lt;summary&gt;获取 Outlook 程序文件位置&lt;/summary&gt;
public static string GetOutlookPath()
{
    // 此 CLSID 为固定值,与 Microsoft.Office.Interop.Outlook.ApplicationClass 的 GUID 值相同
    string regPath = @"HKEY_CLASSES_ROOT\CLSID\{0006F03A-0000-0000-C000-000000000046}\LocalServer32";
    string filePath = Registry.GetValue(regPath, null, null)?.ToString();
    return filePath;
}

/// &lt;summary&gt;通过 Outlook 命令行方式发送邮件&lt;/summary&gt;
/// &lt;remarks&gt;仅支持添加一个附件
/// &lt;para&gt;⚠: 当同时安装了 Outlook(Classic) 和 Outlook(New) 且启用 New 时命令行方式无效&lt;/para&gt;&lt;/remarks&gt;
public static bool SendByOutlookProcess(MailInfo info)
{
    string fileName = GetOutlookPath();
    if (File.Exists(fileName))
    {
      var args = new StringBuilder($"/c ipm.note");
      bool hasTO = info.Recipients.Count &gt; 0;
      bool hasCC = info.CcRecipients.Count &gt; 0;
      bool hasBCC = info.BccRecipients.Count &gt; 0;
      bool hasSubject = !string.IsNullOrEmpty(info.Subject);
      bool hasBody = !string.IsNullOrEmpty(info.Body);
      if (hasTO || hasSubject || hasBody)
      {
            args.Append(" /m ");
            if (hasTO)
            {
                args.Append($"{Uri.EscapeDataString(info.GetTO(";"))}");
            }
            args.Append("?");
            if (hasCC)
            {
                args.Append($"cc={Uri.EscapeDataString(info.GetCC(";"))}&amp;");
            }
            if (hasBCC)
            {
                args.Append($"bcc={Uri.EscapeDataString(info.GetBCC(";"))}&amp;");
            }
            if (hasSubject)
            {
                args.Append($"subject={Uri.EscapeDataString(info.Subject)}&amp;");
            }
            if (hasBody)
            {
                args.Append($"body={Uri.EscapeDataString(info.Body)}&amp;");
            }
            args.Remove(args.Length - 1, 1);
      }
      if (info.Attachments.Count &gt; 0)
      {
            args.Append($" /a \"{info.Attachments.First().Value}\""); // 仅支持添加一个附件
      }
      Process.Start(fileName, args.ToString());
      return true;
    }
    return false;
}
</code></pre>
<h3 id="其他邮箱应用">其他邮箱应用</h3>
<p>针对下方已知&nbsp;ProgID&nbsp;且支持命令行方式的邮箱应用,利用&nbsp;AssocQueryString&nbsp;可以快速获取可执行文件路径</p>
<pre><code class="language-csharp">/// &lt;summary&gt;通过关联字符串查找可执行文件位置&lt;/summary&gt;
private static string GetExecutePath(string assocString)
{
    return NativeMethods.AssocQueryString(AssocStr.Executable, assocString);
}
</code></pre>
<h4 id="mozillathunderbird">Mozilla&nbsp;Thunderbird</h4>
<p>Command&nbsp;line&nbsp;arguments&nbsp;-&nbsp;Thunderbird&nbsp;-&nbsp;MozillaZine&nbsp;Knowledge&nbsp;Base</p>
<pre><code class="language-csharp">const string ThunderbirdClientName = "Mozilla Thunderbird";

/// &lt;summary&gt;获取 Mozilla Thunderbird 程序文件位置&lt;/summary&gt;
public static string GetThunderbirdPath()
{
    return GetExecutePath(ThunderbirdProgID);
}

/// &lt;summary&gt;通过 Mozilla Thunderbird 发送邮件&lt;/summary&gt;
public static bool SendByThunderbird(MailInfo info)
{
    return SendByMAPI(info, ThunderbirdClientName) || SendByThunderbirdProcess(info);
}

/// &lt;summary&gt;通过 Mozilla Thunderbird 程序发送邮件&lt;/summary&gt;
public static bool SendByThunderbirdProcess(MailInfo info)
{
    string exePath = GetThunderbirdPath();
    if (File.Exists(exePath))
    {
      var options = new List&lt;string&gt;();
      if (info.Recipients.Count &gt; 0)
      {
            options.Add($"to='{info.GetTO()}'");
      }
      if (info.CcRecipients.Count &gt; 0)
      {
            options.Add($"cc='{info.GetCC()}'");
      }
      if (info.BccRecipients.Count &gt; 0)
      {
            options.Add($"bcc='{info.GetBCC()}'");
      }
      if (!string.IsNullOrEmpty(info.Subject))
      {
            string subject = info.Subject.Replace("',", "' ,"); // ',截断会导致参数解析错误
            options.Add($"subject='{subject}'");
      }
      if (!string.IsNullOrEmpty(info.Body))
      {
            string body = info.Body.Replace("',", "' ,"); // 同上
            options.Add($"body='{body}'");
      }
      if (info.Attachments.Count &gt; 0)
      {
            var files = info.Attachments.Values.Select(x =&gt; new Uri(x).AbsoluteUri).ToArray();
            options.Add($"attachment='{string.Join("','", files)}'");
      }
      string args = "-compose";
      if (options.Count &gt; 0)
      {
            args += " \"" + string.Join(",", options.ToArray()) + "\"";
      }
      Process.Start(exePath, args);
      return true;
    }
    return false;
}
</code></pre>
<h4 id="emclient">eM&nbsp;Client</h4>
<p>New&nbsp;mail&nbsp;with&nbsp;multiple&nbsp;attachments&nbsp;with&nbsp;command&nbsp;line</p>
<p>eM&nbsp;Client&nbsp;命令行方式是通过创建&nbsp;.eml&nbsp;文件并打开的方式创建邮件</p>
<pre><code class="language-csharp">const string EMClientClientName = "eM Client";

/// &lt;summary&gt;获取 eM Client 程序文件位置&lt;/summary&gt;
public static string GetEmClientPath()
{
    return GetExecutePath(EMClientProgID);
}

/// &lt;summary&gt;通过 eM Client 发送邮件&lt;/summary&gt;
public static bool SendByEmClient(MailInfo info)
{
    return SendByMAPI(info, EMClientClientName) || SendByEmClientProcess(info);
}

/// &lt;summary&gt;通过 eM Client 程序发送邮件&lt;/summary&gt;
/// &lt;remarks&gt;通过创建 .eml 临时文件的方式发送&lt;/remarks&gt;
public static bool SendByEmClientProcess(MailInfo info)
{
    string exePath = GetEmClientPath();
    if (File.Exists(exePath))
    {
      using (var mail = new MailMessage())
      {
            mail.Subject = info.Subject;
            mail.Body = info.Body;
            info.Recipients.ForEach(mail.To.Add);
            info.CcRecipients.ForEach(mail.CC.Add);
            info.BccRecipients.ForEach(mail.Bcc.Add);
            foreach (var file in info.Attachments.Values)
            {
                mail.Attachments.Add(new System.Net.Mail.Attachment(file));
            }
            string from = "from@exmple.com";
            string to = null;
            mail.From = new MailAddress(from); // 必须设置发件人地址, 否则会报错
            if (info.Recipients.Count == 0)
            {
                to = "to@exmple.com";
                mail.To.Add(to); // 至少有一个收件人, 否则会报错
            }
            var client = new SmtpClient();
            client.DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory;

            string tempDir = Path.Combine(Path.GetTempPath(), "tempMail");
            try
            {
                Directory.Delete(tempDir, true);
            }
            catch
            {
                // ignore
            }
            Directory.CreateDirectory(tempDir);
            client.PickupDirectoryLocation = tempDir;
            client.Send(mail);

            var emlFile = new DirectoryInfo(tempDir).GetFiles("*.eml").OrderByDescending(x =&gt; x.LastWriteTime).FirstOrDefault();
            if (emlFile != null)
            {
                string emlPath = emlFile.FullName;
                var lines = File.ReadAllLines(emlPath, Encoding.UTF8).ToList();
                lines.Remove($"X-Sender: {from}");
                lines.Remove($"From: {from}");
                if (to != null)
                {
                  lines.Remove($"X-Receiver: {to}");
                  lines.Remove($"To: {to}");
                }
                lines.Insert(0, "X-Unsent: 1"); // 标记为未发送
                File.WriteAllLines(emlPath, lines.ToArray(), Encoding.UTF8);
                var process = Process.Start(exePath, $"/open \"{emlPath}\"");
                process.EnableRaisingEvents = true;
                process.Exited += (s, e) =&gt;
                {
                  try
                  {
                        Directory.Delete(tempDir, true);
                  }
                  catch
                  {
                        // ignore
                  }
                };
                return true;
            }
      }
    }
    return false;
}
</code></pre>
<h4 id="网易邮箱大师">网易邮箱大师</h4>
<p>命令行来自于文件系统对象发送到子菜单中的快捷方式</p>
<pre><code class="language-csharp">const string MailMasterProgID = "MailMaster";

/// &lt;summary&gt;获取网易邮箱大师程序文件位置&lt;/summary&gt;
public static string GetMailMasterPath()
{
    return GetExecutePath(MailMasterProgID);
}

/// &lt;summary&gt;通过网易邮箱大师发送邮件&lt;/summary&gt;
/// &lt;remarks&gt;命令来自于"发送到"菜单目录快捷方式&lt;/remarks&gt;
public static bool SendByMailMaster(MailInfo info)
{
    string exePath = GetMailMasterPath();
    if (File.Exists(exePath))
    {
      var args = new StringBuilder();
      if (info.Attachments.Count &gt; 0)
      {
            args.Append($"--send-as-attachment \"{info.Attachments.First().Value}\"");
      }
      Process.Start(exePath, args.ToString());
      return true;
    }
    return false;
}
</code></pre>
<h3 id="调用默认邮箱">调用默认邮箱</h3>
<p>综上,Windows&nbsp;上默认邮箱有&nbsp;mailto&nbsp;关联邮箱和&nbsp;MAPI&nbsp;关联邮箱,但不懂注册表的普通用户可能只会在控制面板更改&nbsp;mailto&nbsp;关联邮箱,为提高兼容性,我们可以用以下步骤一一尝试调用默认邮箱:</p>
<ol>
<li>
<p>当&nbsp;MAPI&nbsp;关联邮箱存在时(避免系统弹窗提示无关联邮箱),直接调用&nbsp;MAPI&nbsp;关联邮箱</p>
</li>
<li>
<p>读取&nbsp;mailto&nbsp;关联邮箱&nbsp;ProgID,并尝试在&nbsp;MAPI&nbsp;Mail&nbsp;注册表子项下找到对应的项,临时设为&nbsp;MAPI&nbsp;关联邮箱调用</p>
</li>
<li>
<p>MAPI&nbsp;方式失败后,尝试使用&nbsp;COM&nbsp;或命令行方式</p>
</li>
<li>
<p>以上支持添加附件的方式都失败后,最后使用&nbsp;mailto&nbsp;方式</p>
</li>
</ol>
<pre><code class="language-csharp">/// &lt;summary&gt;通过默认的邮箱客户端发送邮件&lt;/summary&gt;
public static bool SendByDefault(MailInfo info)
{
    string progID = null;
    string clientName = FindMAPIClientName();
    if (clientName == null)
    {
      // 未设置 MAPI 客户端时, 尝试查找 mailto 协议关联的客户端是否支持 MAPI
      progID = FindMailToClientProgID();
      clientName = FindMAPIClientName(progID);
    }
    // 优先使用 MAPI 发送邮件
    bool success = SendByMAPI(info, clientName);
    if (!success)
    {
      progID = progID ?? FindMailToClientProgID();
      var st = StringComparison.OrdinalIgnoreCase;
      if (IsOutlookProgID(progID))
      {
            success = SendByOutlookWithoutMAPI(info);
      }
      else if (progID.Equals(EMClientProgID, st))
      {
            success = SendByEmClientProcess(info);
      }
      else if (progID.Equals(ThunderbirdProgID, st))
      {
            success = SendByThunderbirdProcess(info);
      }
      else if (progID.Equals(MailMasterProgID, st))
      {
            success = SendByMailMaster(info);
      }
      if (!success)
      {
            // 如果以上方式都失败了最后尝试 mailto 协议
            success = SendByProtocol(info);
      }
    }
    return success;
}

/// &lt;summary&gt;根据 ProgID 查找 MAPI 邮箱客户端名称&lt;/summary&gt;
private static string FindMAPIClientName(string progID)
{
    if (string.IsNullOrEmpty(progID))
    {
      return null;
    }
    using (var cuKey = Registry.CurrentUser.OpenSubKey(MapiKeyPath))
    using (var lmKey = Registry.LocalMachine.OpenSubKey(MapiKeyPath))
    {
      var cuKeyNames = cuKey?.GetSubKeyNames().ToList() ?? new List&lt;string&gt;();
      var lmKeyNames = lmKey?.GetSubKeyNames().ToList() ?? new List&lt;string&gt;();
      if (IsOutlookProgID(progID))
      {
            string name = OutlookClientName; // Microsoft Outlook 没有 Capabilities\URLAssociations 子项
            if (lmKeyNames.Contains(name, StringComparer.OrdinalIgnoreCase)
             || cuKeyNames.Contains(name, StringComparer.OrdinalIgnoreCase))
            {
                return name;
            }
      }
      else
      {
            var dic = new Dictionary&lt;RegistryKey, List&lt;string&gt;&gt;
            {
                = lmKeyNames,
                = cuKeyNames
            };
            foreach (var item in dic)
            {
                foreach (var keyName in item.Value)
                {
                  using (var key = item.Key.OpenSubKey($@"{keyName}\Capabilities\URLAssociations"))
                  {
                        string value = key?.GetValue("mailto")?.ToString();
                        if (progID.Equals(value, StringComparison.OrdinalIgnoreCase))
                        {
                            return keyName;
                        }
                  }
                }
            }
      }
    }
    return null;
}
</code></pre><br><br>
来源:https://www.cnblogs.com/BluePointLilac/p/19010985
頁: [1]
查看完整版本: C# 调用邮箱应用发送带附件的邮件