C# 调用邮箱应用发送带附件的邮件
邮件的几大要素
- 发件人 From
- 收件人(主要收件人 To,抄送 CC,密送 BCC)
- 主题 Subject
- 正文 Body
- 附件 Attachments
SmtpClient 和 MailKit
如果有邮箱服务器并且已知发件人邮箱和密码,可以通过 C# 自带的 SmtpClient 或者使用开源库 MailKit
调用第三方邮箱应用
C# 自带的 MailMessage 类中的 Attachments 会直接打开文件流,且没有属性可以获取文件路径
我们可以创建一个简单的邮件信息类,调用第三方邮箱客户端一般不需要发件人,可去掉发件人属性
/* by 01130.hk - online tools website : 01130.hk/zh/autoformat.html */
using System.Collections.Generic;
using System.Net.Mail;
public sealed class MailInfo
{
///// <summary>发件人</summary>
//public MailAddress From { get; set; }
/// <summary>主要收件人</summary>
public List<MailAddress> Recipients { get; } = new List<MailAddress>();
/// <summary>抄送收件人</summary>
public List<MailAddress> CcRecipients { get; } = new List<MailAddress>();
/// <summary>密送收件人</summary>
public List<MailAddress> BccRecipients { get; } = new List<MailAddress>();
/// <summary>主题</summary>
public string Subject { get; set; }
/// <summary>正文</summary>
public string Body { get; set; }
/// <summary>附件文件列表</summary>
/// <remarks>Key 为显示文件名, Value 为文件路径</remarks>
public Dictionary<string, string> Attachments { get; } = new Dictionary<string, string>();
}
mailto 协议
mailto 是全平台支持的协议,支持多个收件人,抄送和密送,但不支持添加附件
mailto 关联应用
在 Windows 上使用 mailto 会调用其关联应用,未设置关联应用时,会弹出打开方式对话框询问使用什么应用打开
关联注册表位置
HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\Shell\Associations\UrlAssociations\mailto\UserChoice
/* by 01130.hk - online tools website : 01130.hk/zh/autoformat.html */
// 常见的邮箱应用 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"; // 网易邮箱大师
/// <summary>查找 mailto 协议关联的邮箱应用 ProgID</summary>
private static string FindMailToClientProgID()
{
// Win10 以上支持 AssocQueryString 查找 ProgID, 为兼容低版本使用注册表查询
const string keyPath = @"HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\Shell\Associations\UrlAssociations\mailto\UserChoice";
return Registry.GetValue(keyPath, "ProgId", null)?.ToString();
}
/// <summary>判断是否是 Outlook 关联的 ProgID</summary>
private static bool IsOutlookProgID(string progID)
{
var st = StringComparison.OrdinalIgnoreCase;
return progID.IndexOf("Outlook", st) >= 0 // Outlook(Classic) 版本相关,如 Outlook.URL.mailto.15
|| progID.Equals(OutlookNewProgID, st);
}
mailto 标准
语法:mailto:sAddress[sHeaders]
示例:mailto:example@to.com?subject=Test%20Subject
主要收件人写在 sAddress,抄送、密送、主题和正文都放在 sHeaders 里面,需要对所有 URL 保留字符进行编码转义
大部分邮箱应用都使用较新的 RFC 6068 标准(收件人、抄送、密送使用逗号分隔),且部分应用同时兼容分号和逗号
但是 Microsoft Outlook 还在使用着比较旧的 RFC 2368 标准(收件人、抄送、密送使用分号分隔)
故当关联应用为 Outlook 时,包括 Classic 版本和 UWP 新版,都无法正确解析逗号连接的多个收件人、抄送、密送
Classic 版本支持的 COM Interop 方式中也是使用的分号分隔
另外 PDF 表单 JavaScript 动作中 mailDoc、mailForm 等发送邮件的方法也是使用的分号分隔符
因此我们可以给上文中的 MailInfo 类添加几个获取指定分隔符连接的收件人地址字符串的方法
/// <summary>获取指定分隔符连接的收件地址</summary>
/// <param name="separator">遵循 mailto RFC 6068 规范默认为逗号,部分邮箱客户端支持逗号和分号,
/// <para>但 Outlook 仅支持分号; PDF 表单 JavaScript 动作中使用分号</para></param>
public string GetTO(string separator = ",")
{
return string.Join(separator, Recipients.ToArray());
}
/// <summary>获取指定分隔符连接的抄送地址</summary>
public string GetCC(string separator = ",")
{
return string.Join(separator, CcRecipients.ToArray());
}
/// <summary>获取指定分隔符连接的密送地址</summary>
public string GetBCC(string separator = ",")
{
return string.Join(separator, BccRecipients.ToArray());
}
调用 mailto 关联邮箱
/// <summary>通过 mailto 协议调用默认邮箱客户端发送邮件</summary>
/// <remarks>不支持附件, 支持 Outlook(New)</remarks>
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)}&");
}
if (!string.IsNullOrEmpty(bcc))
{
url.Append($"bcc={Uri.EscapeDataString(bcc)}&");
}
if (!string.IsNullOrEmpty(info.Subject))
{
url.Append($"subject={Uri.EscapeDataString(info.Subject)}&");
}
if (!string.IsNullOrEmpty(info.Body))
{
url.Append($"body={Uri.EscapeDataString(info.Body)}&");
}
url.Remove(url.Length - 1, 1);
var startInfo = new ProcessStartInfo
{
FileName = url.ToString(),
UseShellExecute = true,
};
try
{
Process.Start(startInfo);
return true;
}
catch
{
return false;
}
}
Win32 MAPI
Windows 定义了 MAPI 接口供第三方邮箱应用实现集成,例如 Outlook(Classic)、eM Client、Thunderbird
C# 中可以使用 MAPISendMail 进行调用,需要注意不一定成功,可能会遇到未知的MAPI_E_FAILURE
错误
另外 MAPI 方式支持设置是否显示 UI (MAPI_DIALOG
、MAPI_DIALOG_MODELESS
、MAPI_LOGON_UI
)
可以为上文中的 MailInfo 类添加一个是否显示 UI 的属性
/// <summary>是否不显示UI自动发送, 至少需要一名收件人</summary>
public bool WithoutUI { get; set; }
MAPI 关联应用
支持 MAPI 的邮箱应用一般会在{HKLM|HKCU}\SOFTWARE\Clients\Mail
下写入子项
通过修改 Mail 项默认键值修改默认 MAPI 邮箱,HKCU 优先,键值需要与 Mail 子项名称一致
/// <summary>查找 MAPI 邮箱客户端</summary>
private static string FindMAPIClientName()
{
using (var cuKey = Registry.CurrentUser.OpenSubKey(MapiKeyPath))
using (var lmKey = Registry.LocalMachine.OpenSubKey(MapiKeyPath))
{
var cuKeyNames = cuKey?.GetSubKeyNames() ?? new string[0];
var lmKeyNames = lmKey?.GetSubKeyNames() ?? new string[0];
// 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;
}
调用 MAPI 关联邮箱
文件系统对象右键菜单的发送到子菜单中的就是调用的 MAPI 关联邮箱
未设置 MAPI 关联邮箱时调用会弹窗提示,如果 Mail 项中PreFirstRun
键值不为空,则弹窗优先显示其内容,*分隔内容和标题
但此弹窗内容会误导用户,因为控制面板默认程序中只能设置 mailto 关联邮箱而不能设置 MAPI 关联邮箱,两者无关
另外建议异步调用,否则外部出错可能会卡死进程
比如同时安装了 Outlook(Classic) 和 Outlook(New) 且启用 New 时,Outlook(Classic)后台启动后会调起 Outlook(New)并结束自身,提前关闭了 Outlook(New) 主窗口, 或设置了收件人、抄送、密送
而且下文中的 Outlook COM 和命令行方式也都是只支持 Classic 不支持 New,所以我们需要一个判断是否启用了 Outlook(New) 的方法
这里我们可以使用 AssocQueryString 根据 ProgID 获取其友好名称来判断是否安装了新版 Outlook,上文代码中也提到了 Win10 以上系统可以用 AssocQueryString 直接查询 mailto 关联的 ProgID,而下文中也会用其根据 ProgID 获取关联可执行文件路径
/// <summary>是否同时安装了 Outlook Classic 和 New 两个版本,且启用 New</summary>
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;
}
另外如果只安装了 Outlook(New)(Win11 默认预装)的情况下,无法通过 MAPI 方式调起,如若可获知 Outlook(Classic) 是如何启动 Outlook(New) 即可有方法单独启动 Outlook(New)。现今未找到只安装了 Outlook(New) 创建带附件邮件的方法
const string OutlookClientName = "Microsoft Outlook";
/// <summary>通过 Win32 MAPI 发送邮件</summary>
/// <remarks>⚠: 调用 MAPI 方式是同步执行,外部出错可能会卡死进程,
/// <para>比如同时安装了 Outlook(Classic) 和 Outlook(New) 且启用 New 时,</para>
/// <para>提前关闭了 Outlook(New) 主窗口, 或设置了收件人、抄送、密送</para></remarks>
public static bool SendByMAPI(MailInfo info)
{
var msg = new MapiMessage
{
subject = info.Subject,
noteText = info.Body,
};
var recipients =
info.Recipients.Select(x => MapiRecipDesc.Create(x, RecipClass.TO)).Concat(
info.CcRecipients.Select(x => MapiRecipDesc.Create(x, RecipClass.CC))).Concat(
info.BccRecipients.Select(x => MapiRecipDesc.Create(x, RecipClass.BCC))).ToArray();
if (recipients.Length > 0)
{
// 测试设置了收件人、抄送、密送 Outlook(New) 会卡住
if (OutlookClientName.Equals(FindMAPIClientName(), StringComparison.OrdinalIgnoreCase) && 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 => MapiFileDesc.Create(x.Value, x.Key)).ToArray();
if (attachments.Length > 0)
{
IntPtr pFiles = NativeMethods.GetStructArrayPointer(attachments);
if (pFiles != IntPtr.Zero)
{
msg.files = pFiles;
msg.fileCount = attachments.Length;
}
}
var flags = MapiFlags.ForceUnicode;
if (!(info.WithoutUI && info.Recipients.Count > 0))
{
flags |= MapiFlags.DialogModeless | MapiFlags.LogonUI;
}
try
{
var error = NativeMethods.MAPISendMail(IntPtr.Zero, IntPtr.Zero, msg, flags, 0);
if (error == MapiError.UnicodeNotSupported)
{
flags &= ~MapiFlags.ForceUnicode; // 不支持 Unicode 时移除标志
error = NativeMethods.MAPISendMail(IntPtr.Zero, IntPtr.Zero, msg, flags, 0);
}
return error == MapiError.Success || error == MapiError.UserAbort;
}
finally
{
NativeMethods.FreeStructArrayPointer<MapiRecipDesc>(msg.recips, recipients.Length);
NativeMethods.FreeStructArrayPointer<MapiFileDesc>(msg.files, attachments.Length);
}
}
用到的本机方法、结构体、枚举
static class NativeMethods
{
[DllImport("mapi32.dll", CharSet = CharSet.Auto)]
public static extern MapiError MAPISendMail(IntPtr session, IntPtr hWndParent, MapiMessage message, MapiFlags flags, int reserved);
[DllImport("shlwapi.dll", CharSet = CharSet.Auto)]
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();
}
/// <summary>获取结构体数组指针</summary>
public static IntPtr GetStructArrayPointer<T>(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 < array.Length; i++)
{
IntPtr ptr = new IntPtr(hglobal.ToInt64() + i * size);
Marshal.StructureToPtr(array[i], ptr, false);
copiedCount++;
}
}
catch
{
FreeStructArrayPointer<T>(hglobal, copiedCount);
throw;
}
return hglobal;
}
/// <summary>释放结构体数组指针</summary>
public static void FreeStructArrayPointer<T>(IntPtr ptr, int count) where T : struct
{
if (ptr != IntPtr.Zero && count > 0)
{
int size = Marshal.SizeOf(typeof(T));
for (int i = 0; i < count; i++)
{
IntPtr itemPtr = new IntPtr(ptr.ToInt64() + i * size);
Marshal.DestroyStructure(itemPtr, typeof(T));
}
Marshal.FreeHGlobal(ptr);
}
}
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
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;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
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;
}
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
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, // 不指示附件位置
};
}
}
[Flags]
enum MapiFlags
{
LogonUI = 0x1,
NewSession = 0x2,
Dialog = 0x8,
DialogModeless = 0x4 | Dialog,
ForceUnicode = 0x40000,
}
enum MapiError
{
/// <summary>成功</summary>
Success = 0,
/// <summary>用户中止</summary>
UserAbort = 1,
/// <summary>发生一个或多个未指定错误</summary>
Failure = 2,
/// <summary>登录失败</summary>
LoginFailure = 3,
/// <summary>内存不足</summary>
InsufficientMemory = 5,
/// <summary>文件附件太多</summary>
TooManyFiles = 9,
/// <summary>收件人太多</summary>
TooManyRecipients = 10,
/// <summary>找不到附件</summary>
AttachmentNotFound = 11,
/// <summary>无法打开附件</summary>
AttachmentOpenFailure = 12,
/// <summary>收件人未显示在地址列表中</summary>
UnknownRecipient = 14,
/// <summary>收件人类型错误</summary>
BadRecipient = 15,
/// <summary>消息中文本太大</summary>
TextTooLarge = 18,
/// <summary>收件人与多个收件人描述符结构匹配,且未设置 MAPI_DIALOG</summary>
AmbiguousRecipient = 21,
/// <summary>一个或多个收件人无效</summary>
InvalidRecips = 25,
/// <summary>指定了 MAPI_FORCE_UNICODE 标志,但不支持 Unicode</summary>
UnicodeNotSupported = 27,
/// <summary>附件太大</summary>
AttachmentTooLarge = 28,
}
[Flags]
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
}
调用其他 MAPI 邮箱
已知第三方邮箱应用包含 MAPI 相关导出函数的 dll 位置时,可通过 GetProcAddress 来调用
[DllImport("kernel32.dll")]
extern static IntPtr LoadLibrary(string lpLibFileName);
[DllImport("kernel32.dll")]
extern static IntPtr GetProcAddress(IntPtr hModule, string lpProcName);
[DllImport("kernel32.dll")]
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;
}
上述方法需要调用程序和 dll 位数相同,故为了兼容不同邮箱应用可能需要分别编译 x64 和 x86 的程序
这里提供一种兼容不同位数邮箱应用的方法:临时将目标邮箱设为 MAPI 关联邮箱,调用 MAPISendMail 后还原
/// <summary>通过 Win32 MAPI 发送邮件</summary>
/// <remarks>Microsoft Outlook、eM Client、Mozilla Thunderbird 支持, 其他待发现</remarks>
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;
}
}
}
Outlook(Classic)
Outlook(Classic) 还支持 COM 互操作和命令行的方式创建带附件的邮件,Outlook(New) 两种方式都不支持
/// <summary>通过 Outlook 发送邮件</summary>
public static bool SendByOutlook(MailInfo info)
{
return SendByOutlookMAPI(info) || SendByOutlookWithoutMAPI(info);
}
/// <summary>通过 Outlook MAPI 发送邮件</summary>
public static bool SendByOutlookMAPI(MailInfo info)
{
return SendByMAPI(info, OutlookClientName);
}
/// <summary>通过 Outlook COM 或进程方式发送邮件</summary>
public static bool SendByOutlookWithoutMAPI(MailInfo info)
{
return !IsUseNewOutlook() && (SendByOutlookCOM(info) || SendByOutlookProcess(info));
}
Outlook COM
通过引用 Microsoft.Office.Interop.Outlook 互操作库可用 COM 对象来创建带附件的邮件,支持添加多个附件
当同时安装了 Classic 和 New 且启用 New 时此方式无效:会卡在创建 app 对象
using System.Runtime.InteropServices;
using Microsoft.Office.Interop.Outlook;
/// <summary>通过 Outlook COM 对象发送邮件</summary>
/// <remarks>⚠: 当同时安装了 Outlook(Classic) 和 Outlook(New) 且启用 New 时会卡在创建 app 对象</remarks>
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 && info.Recipients.Count > 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);
}
}
}
Outlook 命令行
命令行方式只能添加一个附件
当同时安装了 Outlook(Classic) 和 Outlook(New) 且启用 New 时此方式无效
示例:outlook.exe /c ipm.note /m example@to.com?subject=Test%20Subject /a C:\dir\file
/// <summary>获取 Outlook 程序文件位置</summary>
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;
}
/// <summary>通过 Outlook 命令行方式发送邮件</summary>
/// <remarks>仅支持添加一个附件
/// <para>⚠: 当同时安装了 Outlook(Classic) 和 Outlook(New) 且启用 New 时命令行方式无效</para></remarks>
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 > 0;
bool hasCC = info.CcRecipients.Count > 0;
bool hasBCC = info.BccRecipients.Count > 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(";"))}&");
}
if (hasBCC)
{
args.Append($"bcc={Uri.EscapeDataString(info.GetBCC(";"))}&");
}
if (hasSubject)
{
args.Append($"subject={Uri.EscapeDataString(info.Subject)}&");
}
if (hasBody)
{
args.Append($"body={Uri.EscapeDataString(info.Body)}&");
}
args.Remove(args.Length - 1, 1);
}
if (info.Attachments.Count > 0)
{
args.Append($" /a \"{info.Attachments.First().Value}\""); // 仅支持添加一个附件
}
Process.Start(fileName, args.ToString());
return true;
}
return false;
}
其他邮箱应用
针对下方已知 ProgID 且支持命令行方式的邮箱应用,利用 AssocQueryString 可以快速获取可执行文件路径
/// <summary>通过关联字符串查找可执行文件位置</summary>
private static string GetExecutePath(string assocString)
{
return NativeMethods.AssocQueryString(AssocStr.Executable, assocString);
}
Mozilla Thunderbird
Command line arguments - Thunderbird - MozillaZine Knowledge Base
const string ThunderbirdClientName = "Mozilla Thunderbird";
/// <summary>获取 Mozilla Thunderbird 程序文件位置</summary>
public static string GetThunderbirdPath()
{
return GetExecutePath(ThunderbirdProgID);
}
/// <summary>通过 Mozilla Thunderbird 发送邮件</summary>
public static bool SendByThunderbird(MailInfo info)
{
return SendByMAPI(info, ThunderbirdClientName) || SendByThunderbirdProcess(info);
}
/// <summary>通过 Mozilla Thunderbird 程序发送邮件</summary>
public static bool SendByThunderbirdProcess(MailInfo info)
{
string exePath = GetThunderbirdPath();
if (File.Exists(exePath))
{
var options = new List<string>();
if (info.Recipients.Count > 0)
{
options.Add($"to='{info.GetTO()}'");
}
if (info.CcRecipients.Count > 0)
{
options.Add($"cc='{info.GetCC()}'");
}
if (info.BccRecipients.Count > 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 > 0)
{
var files = info.Attachments.Values.Select(x => new Uri(x).AbsoluteUri).ToArray();
options.Add($"attachment='{string.Join("','", files)}'");
}
string args = "-compose";
if (options.Count > 0)
{
args += " \"" + string.Join(",", options.ToArray()) + "\"";
}
Process.Start(exePath, args);
return true;
}
return false;
}
eM Client
New mail with multiple attachments with command line
eM Client 命令行方式是通过创建 .eml 文件并打开的方式创建邮件
const string EMClientClientName = "eM Client";
/// <summary>获取 eM Client 程序文件位置</summary>
public static string GetEmClientPath()
{
return GetExecutePath(EMClientProgID);
}
/// <summary>通过 eM Client 发送邮件</summary>
public static bool SendByEmClient(MailInfo info)
{
return SendByMAPI(info, EMClientClientName) || SendByEmClientProcess(info);
}
/// <summary>通过 eM Client 程序发送邮件</summary>
/// <remarks>通过创建 .eml 临时文件的方式发送</remarks>
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 => 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) =>
{
try
{
Directory.Delete(tempDir, true);
}
catch
{
// ignore
}
};
return true;
}
}
}
return false;
}
网易邮箱大师
命令行来自于文件系统对象发送到子菜单中的快捷方式
const string MailMasterProgID = "MailMaster";
/// <summary>获取网易邮箱大师程序文件位置</summary>
public static string GetMailMasterPath()
{
return GetExecutePath(MailMasterProgID);
}
/// <summary>通过网易邮箱大师发送邮件</summary>
/// <remarks>命令来自于"发送到"菜单目录快捷方式</remarks>
public static bool SendByMailMaster(MailInfo info)
{
string exePath = GetMailMasterPath();
if (File.Exists(exePath))
{
var args = new StringBuilder();
if (info.Attachments.Count > 0)
{
args.Append($"--send-as-attachment \"{info.Attachments.First().Value}\"");
}
Process.Start(exePath, args.ToString());
return true;
}
return false;
}
调用默认邮箱
综上,Windows 上默认邮箱有 mailto 关联邮箱和 MAPI 关联邮箱,但不懂注册表的普通用户可能只会在控制面板更改 mailto 关联邮箱,为提高兼容性,我们可以用以下步骤一一尝试调用默认邮箱:
-
当 MAPI 关联邮箱存在时(避免系统弹窗提示无关联邮箱),直接调用 MAPI 关联邮箱
-
读取 mailto 关联邮箱 ProgID,并尝试在 MAPI Mail 注册表子项下找到对应的项,临时设为 MAPI 关联邮箱调用
-
MAPI 方式失败后,尝试使用 COM 或命令行方式
-
以上支持添加附件的方式都失败后,最后使用 mailto 方式
/// <summary>通过默认的邮箱客户端发送邮件</summary>
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;
}
/// <summary>根据 ProgID 查找 MAPI 邮箱客户端名称</summary>
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<string>();
var lmKeyNames = lmKey?.GetSubKeyNames().ToList() ?? new List<string>();
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<RegistryKey, List<string>>
{
[lmKey] = lmKeyNames,
[cuKey] = 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;
}