ASP.NET Core使用MailKit发送邮件

1.添加MailKit依赖

bash 复制代码
dotnet add package MailKit

2.发送邮件服务

cs 复制代码
using System.Text;
using dotnet_start.Model;
using MailKit.Net.Smtp;
using MimeKit;
using MimeKit.Text;
using MimeKit.Utils;
using Path = System.IO.Path;

namespace dotnet_start.Services;

public class EmailSendService
{
    public readonly IWebHostEnvironment Env;

    public readonly ILogger<EmailSendService> Logger;

    public EmailSendService(IWebHostEnvironment env, ILogger<EmailSendService> logger)
    {
        Env = env;
        Logger = logger;
    }

    public async Task SendEmailByTextPartAsync(string fromEmail, string toEmail, string subject, string message)
    {
        var mineMessage = new MimeMessage();
        var fromAddress = new MailboxAddress("这是来ASP.NET Core------文本方式邮件", fromEmail);
        mineMessage.From.Add(fromAddress);

        var toAddress = new MailboxAddress("菜哥", toEmail);
        mineMessage.To.Add(toAddress);

        mineMessage.Subject = subject;
        mineMessage.Body = new TextPart(TextFormat.Html)
        {
            Text = message
        };

        await SendBySmtp(fromEmail, mineMessage);
    }

    public async Task SendEmailByHtmlAsync(string fromEmail, string toEmail, string subject, string message)
    {
        var mineMessage = new MimeMessage();
        mineMessage.Subject = subject;
        var fromAddress = new MailboxAddress("这是来ASP.NET Core------HTML方式的邮件", fromEmail);
        mineMessage.From.Add(fromAddress);
        var toAddress = new MailboxAddress("菜哥", toEmail);
        mineMessage.To.Add(toAddress);

        var bodyBuilder = new BodyBuilder();

        var imagePath = Path.Combine(Env.ContentRootPath, "Templates", "img", "vue-js.png");
        if (File.Exists(imagePath))
        {
            // 添加附件
            var mimeEntity = await bodyBuilder.LinkedResources.AddAsync(imagePath);
            mimeEntity.ContentId = MimeUtils.GenerateMessageId();
            bodyBuilder.HtmlBody = $"<p>{message}</p>" +
                                   $"<p><img src=\"cid:{mimeEntity.ContentId}\" alt=\"\" width=\"500\" height=\"300\"/></p>";
        }

        mineMessage.Body = bodyBuilder.ToMessageBody();
        await SendBySmtp(fromEmail, mineMessage);
    }

    public async Task SendEmailByTemplateAsync(string fromEmail, string toEmail, string subject,
        string tempName,
        Dictionary<string, string> variables,
        List<string>? attachments = null,
        List<string>? inlineImages = null)
    {
        var mineMessage = new MimeMessage();
        mineMessage.Subject = subject;

        var fromAddress = new MailboxAddress("ASP.NET Core 模板邮件", fromEmail);
        mineMessage.From.Add(fromAddress);

        var toAddress = new MailboxAddress("菜哥", toEmail);
        mineMessage.To.Add(toAddress);

        var bodyBuilder = new BodyBuilder();

        // 读取模板
        var templatePath = Path.Combine(Env.ContentRootPath, "Templates", tempName);
        if (!File.Exists(templatePath))
        {
            throw new FileNotFoundException($"邮件模板不存在: {templatePath}");
        }


        var templateContent = await File.ReadAllTextAsync(templatePath);

        // 替换变量 {{Key}}
        templateContent = variables.Aggregate(templateContent, (current, kv) => current.Replace($"{{{{{kv.Key}}}}}", kv.Value));

        // 内联图片
        if (inlineImages != null)
        {
            foreach (var imgPath in inlineImages.Where(File.Exists))
            {
                var img = await bodyBuilder.LinkedResources.AddAsync(imgPath);
                var contentId = Path.GetFileNameWithoutExtension(imgPath);
                img.ContentId = contentId;
            }
        }

        // 附件
        if (attachments != null)
        {
            foreach (var filePath in attachments.Where(File.Exists))
            {
                await bodyBuilder.Attachments.AddAsync(filePath);
            }
        }

        bodyBuilder.HtmlBody = templateContent;
        mineMessage.Body = bodyBuilder.ToMessageBody();

        await SendBySmtp(fromEmail, mineMessage);
    }


    public async Task SendEmailByTemplateFormAsync(string fromEmail, string toEmail, string subject, string tempName,
        Dictionary<string, string> variables,
        List<IFormFile>? attachments = null,
        List<IFormFile>? inlineImages = null)
    {
        var mimeMessage = new MimeMessage();
        mimeMessage.Subject = subject;
        mimeMessage.From.Add(new MailboxAddress("ASP.NET Core 模板邮件", fromEmail));
        mimeMessage.To.Add(new MailboxAddress("收件人", toEmail));

        var bodyBuilder = new BodyBuilder();
        try
        {
            // 读取模板
            var templatePath = Path.Combine(Env.ContentRootPath, "Templates", tempName);
            if (!File.Exists(templatePath))
                throw new FileNotFoundException($"邮件模板不存在: {templatePath}");

            var templateContent = await File.ReadAllTextAsync(templatePath);

            // 替换普通变量
            templateContent = variables.Aggregate(templateContent, (current, kv) =>
                current.Replace($"{{{{{kv.Key}}}}}", kv.Value));

            using var tempDir = new TempDirectory(Logger);

            // 内联图片
            if (inlineImages is { Count: > 0 })
            {
                var sb = new StringBuilder();
                foreach (var file in inlineImages.Where(file => file.Length > 0))
                {
                    var fileName = file.FileName;

                    // 读取文件内容到字节数组
                    byte[] bytes;
                    await using (var stream = file.OpenReadStream())
                    {
                        bytes = new byte[stream.Length];
                        var readAsync = await stream.ReadAsync(bytes);
                        if (readAsync == file.Length)
                        {
                            Logger.LogInformation("内联文件==={fileName},大小==={readAsync} 读取完成", fileName, readAsync);
                        }
                    }

                    await HandlerAttachment(file, bodyBuilder, tempDir, bytes);

                    // 转 Base64
                    var base64 = Convert.ToBase64String(bytes);
                    var ext = Path.GetExtension(fileName).TrimStart('.').ToLower();

                    // 生成对应 img 标签
                    sb.AppendLine($"<img src=\"data:image/{ext};base64,{base64}\" alt=\"{fileName}\" />");
                }

                // 替换模板占位符
                templateContent = templateContent.Replace("{{InlineImages}}", sb.ToString());
            }

            // 处理附件
            if (attachments is { Count: > 0 })
            {
                foreach (var file in attachments.Where(file => file.Length > 0))
                {
                    await HandlerAttachment(file, bodyBuilder, tempDir);
                }
            }

            bodyBuilder.HtmlBody = templateContent;
            mimeMessage.Body = bodyBuilder.ToMessageBody();

            await SendBySmtp(fromEmail, mimeMessage);
        }
        catch (Exception e)
        {
            Console.WriteLine(e);
            throw;
        }
    }

    /// <summary>
    /// 处理附件
    /// </summary>
    /// <param name="file">附件文件</param>
    /// <param name="bodyBuilder">邮件内容构造器</param>
    /// <param name="tempDir">临时目录</param>
    /// <param name="bytes">文件字节</param>
    private async Task HandlerAttachment(IFormFile file, BodyBuilder bodyBuilder, TempDirectory tempDir, byte[]? bytes = null)
    {
        var fileName = file.FileName;
        var fileLength = file.Length;
        var filContentType = file.ContentType;
        var contentType = !string.IsNullOrWhiteSpace(filContentType)
            ? ContentType.Parse(filContentType)
            : new ContentType("application", "octet-stream");
        // 附件小于5M的直接使用内存流,
        if (file.Length < 5 << 20)
        {
            Logger.LogInformation("附件名称: {Name},大小==={Length}小于5MB使用内存流", fileName, fileLength);
            if (bytes is { Length: > 0 })
            {
                await bodyBuilder.Attachments.AddAsync(fileName, new MemoryStream(bytes), contentType);
            }
            else
            {
                var stream = new MemoryStream();
                await file.CopyToAsync(stream);
                // 重置流位置
                stream.Position = 0;
                await bodyBuilder.Attachments.AddAsync(fileName, stream, contentType);
            }
        }
        else
        {
            using var tempFilePath = new TempFile(tempDir.Path, Logger);
            Logger.LogInformation("附件名称: {Name},大小==={Length}大于5MB使用临时文件==={@tempFilePath}", fileName, fileLength, tempFilePath.Path);
            await using var stream = new FileStream(tempFilePath.Path, FileMode.Create);
            await file.CopyToAsync(stream);

            var attachment = await bodyBuilder.Attachments.AddAsync(tempFilePath.Path, contentType);
            // 设置附件的文件名为原始文件名
            attachment.ContentDisposition.FileName = fileName;
        }
    }

    private static async Task SendBySmtp(string fromEmail, MimeMessage mineMessage)
    {
        using var smtp = new SmtpClient();
        await smtp.ConnectAsync("smtp.qq.com", 465, true);
        // TODO 设置认证信息------授权码(不是密码),我这里是以QQ邮箱为例的,QQ邮箱的SMTP服务怎么开桶及授权码,请参照QQ邮箱安全设置相关
        await smtp.AuthenticateAsync(fromEmail, "授权码,请自行参照网上教程");
        await smtp.SendAsync(mineMessage);
        await smtp.DisconnectAsync(true);
    }
}

3.邮件发送控制器

cs 复制代码
using dotnet_start.Model.Response;
using dotnet_start.Services;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
using Path = System.IO.Path;

namespace dotnet_start.Controllers;

/// <summary>
/// 邮件发送控制器
/// </summary>
[SwaggerTag("邮件发送控制器")]
[ApiController]
[Route("email/send")]
public class EmailSendController(EmailSendService service) : ControllerBase
{
    /// <summary>
    /// 发送邮件
    /// </summary>
    /// <param name="from">发件人</param>
    /// <param name="to">收件人</param>
    /// <param name="subject">主题</param>
    /// <param name="message">信息</param>
    /// <returns>发送结果</returns>
    [HttpPost("text")]
    [ProducesResponseType(typeof(CommonResult<string>), StatusCodes.Status200OK)]
    public async Task<IActionResult> SendByTextPart([FromForm] string from, [FromForm] string to,
        [FromForm] string subject, [FromForm] string message)
    {
        await service.SendEmailByTextPartAsync(from, to, subject, message);
        return Ok(CommonResult<string>.Success(200, "邮件发送成功"));
    }

    /// <summary>
    /// 发送邮件-html
    /// </summary>
    /// <param name="from">发件人</param>
    /// <param name="to">收件人</param>
    /// <param name="subject">主题</param>
    /// <param name="message">信息</param>
    /// <returns>发送结果</returns>
    [HttpPost("html")]
    [ProducesResponseType(typeof(CommonResult<string>), StatusCodes.Status200OK)]
    public async Task<IActionResult> SendByHtml([FromForm] string from, [FromForm] string to,
        [FromForm] string subject, [FromForm] string message)
    {
        await service.SendEmailByHtmlAsync(from, to, subject, message);
        return Ok(CommonResult<string>.Success(200, "邮件发送成功"));
    }

    /// <summary>
    /// 发送邮件-template
    /// </summary>
    /// <param name="from">发件人</param>
    /// <param name="to">收件人</param>
    /// <param name="subject">主题</param>
    /// <param name="message">信息</param>
    /// <returns>邮件发送结果</returns>
    [HttpPost("template")]
    [ProducesResponseType(typeof(CommonResult<string>), StatusCodes.Status200OK)]
    public async Task<IActionResult> SendByHtmlTemplate([FromForm] string from, [FromForm] string to,
        [FromForm] string subject)
    {
        var dictionary = new Dictionary<string, string>
        {
            { "UserName", "菜哥" },
            { "Message", "这里是带附件的邮件 📎" }
        };
        var attachments = new List<string>
        {
            // 这里表示项目根目录下的Templates目录中的files目录中的xxxx.pdf
            Path.Combine(service.Env.ContentRootPath, "Templates", "files", "xxxx.pdf"), 
            "/xxxx/xxxx/xxx.doc" // 绝对路径,请自行更换为自己系统上文件的路径
        };

        var inlineImages = new List<string>
        {
            Path.Combine(service.Env.ContentRootPath, "Templates", "img", "vue-js.png")
        };
        await service.SendEmailByTemplateAsync(from, to, subject, "mail_template.html", dictionary, attachments, inlineImages);
        return Ok(CommonResult<string>.Success(200, "邮件发送成功"));
    }

    /// <summary>
    /// 发送邮件------表单上传文件作为内联文件或者附件文件
    /// </summary>
    /// <param name="from">发件人</param>
    /// <param name="to">收件人</param>
    /// <param name="username">用户名</param>
    /// <param name="message">邮件信息</param>
    /// <param name="subject">主题</param>
    /// <param name="attachments">附件文件</param>
    /// <param name="inlineImages">内联图片</param>
    /// <returns>结果</returns>
    [HttpPost("form")]
    [ProducesResponseType(typeof(CommonResult<string>), StatusCodes.Status200OK)]
    public async Task<IActionResult> SendByHtmlTemplateByForm(
        [FromForm] string from, [FromForm] string to, [FromForm] string username, [FromForm] string message,
        [FromForm] string subject,
        [FromForm] List<IFormFile> attachments,
        [FromForm] List<IFormFile> inlineImages
    )
    {
        var dictionary = new Dictionary<string, string>
        {
            { "UserName", username },
            { "Message", message }
        };

        await service.SendEmailByTemplateFormAsync(
            from,
            to,
            subject,
            "mail_form.html",
            dictionary,
            attachments,
            inlineImages
        );

        return Ok(CommonResult<string>.Success(200, "邮件发送成功"));
    }
}

4.自定义的临时目录与临时文件

临时目录

cs 复制代码
namespace dotnet_start.Model;

/// <summary>
/// 自动删除的临时目录
/// </summary>
public class TempDirectory : IDisposable
{
    private readonly ILogger _logger;

    public string Path { get; }

    public TempDirectory(ILogger logger)
    {
        _logger = logger;
        Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), System.IO.Path.GetRandomFileName());
        Directory.CreateDirectory(Path);
    }

    public void Dispose()
    {
        try
        {
            if (!Directory.Exists(Path)) return;
            Directory.Delete(Path, true);
            _logger.LogDebug("删除临时目录==={Path}", Path);
        }
        catch(Exception ex)
        {
            // 忽略删除异常就直接使用 catch { }
            _logger.LogDebug(ex,"删除临时目录==={}出错了", Path);
        }
    }
}

临时文件

cs 复制代码
namespace dotnet_start.Model;

/// <summary>
/// 自动删除的临时文件
/// </summary>
public class TempFile : IDisposable
{

    private readonly ILogger _logger;

    public string Path { get; }

    public TempFile(string directory, ILogger logger)
    {
        Path = System.IO.Path.Combine(directory, System.IO.Path.GetRandomFileName());
        _logger = logger;
    }

    public void Dispose()
    {
        try
        {
            if (!File.Exists(Path)) return;
            File.Delete(Path);
            _logger.LogDebug("删除临时文件==={Path}",Path);
        }
        catch(Exception ex)
        {
            // 忽略删除异常就直接使用 catch { }
            _logger.LogDebug(ex,"删除临时文件==={}出错了", Path);
        }
    }
}

这样使用using来定义TempDirectory或者TempFile的时候,就会自动调用Dispose方法执行删除的操作了,不需要额外手动删除了。

5.邮件发送html模板

mail_form.html

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>邮件发送模板</title>
    <style>
        body {
            font-family: "Microsoft YaHei", Arial, sans-serif;
            background-color: #f9f9f9;
            margin: 0;
            padding: 20px;
        }

        .container {
            margin: 0 auto;
            background: #fff;
            border-radius: 10px;
            padding: 20px;
            box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
        }

        h1 {
            color: #333;
            font-size: 22px;
        }

        p {
            color: #555;
            line-height: 1.6;
        }

        .footer {
            margin-top: 30px;
            font-size: 12px;
            color: #999;
            text-align: center;
        }

        .logo {
            display: flex;
            flex-wrap: wrap;
            justify-content: center;
            gap: 10px;
            margin-bottom: 20px;
        }

        .logo img {
            cursor: pointer;
            max-width: 300px;
            max-height: 200px;
            width: auto;
            height: auto;
            object-fit: contain;
            border-radius: 8px;
            transition: transform 0.3s ease, box-shadow 0.3s ease;
        }

        .logo img:hover {
            transform: scale(1.05);
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
        }

    </style>
</head>
<body>
<div class="container">
    <div class="logo">
        {{InlineImages}}
    </div>
    <h1>您好,{{UserName}} 👋</h1>
    <p>{{Message}}</p>


    <div class="footer">
        <p>这是一封系统自动发送的邮件,请勿直接回复。</p>
    </div>
</div>
</body>
</html>

mail_template.html

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>邮件发送模板</title>
    <style>
        body {
            font-family: "Microsoft YaHei", Arial, sans-serif;
            background-color: #f9f9f9;
            margin: 0;
            padding: 20px;
        }
        .container {
            max-width: 600px;
            margin: 0 auto;
            background: #fff;
            border-radius: 10px;
            padding: 20px;
            box-shadow: 0 2px 6px rgba(0,0,0,0.1);
        }
        h1 {
            color: #333;
            font-size: 22px;
        }
        p {
            color: #555;
            line-height: 1.6;
        }
        .footer {
            margin-top: 30px;
            font-size: 12px;
            color: #999;
            text-align: center;
        }
        .logo {
            text-align: center;
            margin-bottom: 20px;
        }
        .logo img {
            max-width: 300px;
            max-height: 200px;
        }
    </style>
</head>
<body>
<div class="container">
    <div class="logo">
        <img src="cid:vue-js" alt="vue"/>
    </div>
    <h1>您好,{{UserName}} 👋</h1>
    <p>{{Message}}</p>

    <div class="footer">
        <p>这是一封系统自动发送的邮件,请勿直接回复。</p>
    </div>
</div>
</body>
</html>

6.QQ邮箱开启SMTP页面

7.使用apifox测试邮件发送

到此,邮件发送功能完成,大家可以拷贝粘贴代码自行尝试即可。

相关推荐
时光追逐者2 小时前
C#/.NET/.NET Core技术前沿周刊 | 第 53 期(2025年9.1-9.7)
c#·.net·.netcore
智商偏低2 小时前
ASP.NET Core 身份验证概述
后端·asp.net
canonical_entropy3 小时前
XDef:一种面向演化的元模型及其构造哲学
后端
weixin_447103583 小时前
C#之LINQ
c#·linq
小林coding3 小时前
再也不怕面试了!程序员 AI 面试练习神器终于上线了
前端·后端·面试
lypzcgf3 小时前
Coze源码分析-资源库-删除插件-后端源码-错误处理与总结
人工智能·后端·go·coze·coze源码分析·ai应用平台·agent平台
文心快码BaiduComate3 小时前
WAVE SUMMIT深度学习开发者大会2025举行 文心大模型X1.1发布
前端·后端·程序员
SamDeepThinking3 小时前
在Windows 11上配置Cursor IDE进行Java开发
后端·ai编程·cursor
ysn111113 小时前
反编译分析C#闭包
c#