mailpit-integrationSkill mailpit-integration

这是一个用于在.NET Aspire开发环境中进行邮件测试的技能。它通过集成Mailpit工具,允许开发者在本地捕获和检查所有外发的SMTP邮件,而无需实际发送。该技能支持邮件内容渲染查看、邮件头检查、附件处理,并提供了完整的集成测试方案,帮助开发者高效验证邮件发送逻辑、调试邮件模板和确保邮件功能的可靠性。关键词:邮件测试、Mailpit、.NET Aspire、集成测试、SMTP模拟、邮件调试、开发环境、邮件验证。

测试 0 次安装 0 次浏览 更新于 2/26/2026

name: mailpit-integration description: 使用Mailpit和.NET Aspire在本地测试邮件发送。捕获所有外发邮件而不实际发送。查看渲染的HTML、检查邮件头,并在集成测试中验证邮件投递。 invocable: false

使用Mailpit和.NET Aspire进行邮件测试

何时使用此技能

在以下情况下使用此技能:

  • 在本地测试邮件投递,无需发送真实邮件
  • 在.NET Aspire中设置邮件基础设施
  • 编写验证邮件是否发送的集成测试
  • 调试邮件渲染和邮件头

相关技能:

  • aspnetcore/mjml-email-templates - MJML模板编写
  • testing/verify-email-snapshots - 快照测试渲染的HTML
  • aspire/integration-testing - 通用的Aspire测试模式

什么是Mailpit?

Mailpit 是一个轻量级的邮件测试工具,它:

  • 捕获所有SMTP流量而不投递邮件
  • 提供一个Web UI来查看捕获的邮件
  • 暴露一个API用于程序化访问
  • 支持HTML渲染、邮件头和附件

非常适合开发和集成测试。


Aspire AppHost配置

在您的AppHost中添加Mailpit作为容器:

// AppHost/Program.cs
var builder = DistributedApplication.CreateBuilder(args);

// 为邮件测试添加Mailpit
var mailpit = builder.AddContainer("mailpit", "axllent/mailpit")
    .WithHttpEndpoint(port: 8025, targetPort: 8025, name: "ui")
    .WithEndpoint(port: 1025, targetPort: 1025, name: "smtp");

// 在您的API项目中引用
var api = builder.AddProject<Projects.MyApp_Api>("api")
    .WithReference(mailpit.GetEndpoint("smtp"))
    .WithEnvironment("Smtp__Host", mailpit.GetEndpoint("smtp"));

builder.Build().Run();

SMTP配置

appsettings.json

{
  "Smtp": {
    "Host": "localhost",
    "Port": 1025,
    "EnableSsl": false,
    "FromAddress": "noreply@myapp.com",
    "FromName": "MyApp"
  }
}

配置类

public class SmtpSettings
{
    public string Host { get; set; } = "localhost";
    public int Port { get; set; } = 1025;
    public bool EnableSsl { get; set; } = false;
    public string FromAddress { get; set; } = "noreply@myapp.com";
    public string FromName { get; set; } = "MyApp";

    // 可选:用于生产环境SMTP
    public string? Username { get; set; }
    public string? Password { get; set; }
}

服务注册

// 在Program.cs或扩展方法中
services.Configure<SmtpSettings>(configuration.GetSection("Smtp"));

services.AddSingleton<IEmailSender>(sp =>
{
    var settings = sp.GetRequiredService<IOptions<SmtpSettings>>().Value;
    return new SmtpEmailSender(settings);
});

邮件发送器实现

public interface IEmailSender
{
    Task SendEmailAsync(EmailMessage message, CancellationToken ct = default);
}

public sealed class SmtpEmailSender : IEmailSender
{
    private readonly SmtpSettings _settings;

    public SmtpEmailSender(SmtpSettings settings)
    {
        _settings = settings;
    }

    public async Task SendEmailAsync(EmailMessage message, CancellationToken ct = default)
    {
        using var client = new SmtpClient();

        await client.ConnectAsync(
            _settings.Host,
            _settings.Port,
            _settings.EnableSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.None,
            ct);

        if (!string.IsNullOrEmpty(_settings.Username))
        {
            await client.AuthenticateAsync(_settings.Username, _settings.Password, ct);
        }

        var mailMessage = new MimeMessage();
        mailMessage.From.Add(new MailboxAddress(_settings.FromName, _settings.FromAddress));
        mailMessage.To.Add(new MailboxAddress(message.ToName, message.To));
        mailMessage.Subject = message.Subject;

        var bodyBuilder = new BodyBuilder { HtmlBody = message.HtmlBody };
        mailMessage.Body = bodyBuilder.ToMessageBody();

        await client.SendAsync(mailMessage, ct);
        await client.DisconnectAsync(true, ct);
    }
}

需要 MailKit 包:

dotnet add package MailKit

查看捕获的邮件

Web UI

导航到 http://localhost:8025 查看:

  • 收件箱 - 所有捕获的邮件
  • HTML视图 - 渲染的邮件
  • 源代码视图 - 原始的HTML/MJML输出
  • 邮件头 - 完整的邮件头信息
  • 附件 - 任何附件文件

Aspire仪表板

Mailpit UI端点会出现在Aspire仪表板的资源部分。


集成测试

使用Aspire的测试夹具

public class EmailIntegrationTests : IClassFixture<AspireFixture>
{
    private readonly HttpClient _client;
    private readonly MailpitClient _mailpit;

    public EmailIntegrationTests(AspireFixture fixture)
    {
        _client = fixture.CreateClient();
        _mailpit = new MailpitClient(fixture.GetMailpitUrl());
    }

    [Fact]
    public async Task SignupFlow_SendsWelcomeEmail()
    {
        // 准备
        await _mailpit.ClearMessagesAsync();

        // 执行 - 触发注册流程
        var response = await _client.PostAsJsonAsync("/api/auth/signup", new
        {
            Email = "test@example.com",
            Password = "SecurePassword123!"
        });
        response.EnsureSuccessStatusCode();

        // 断言 - 验证邮件已发送
        var messages = await _mailpit.GetMessagesAsync();

        var welcomeEmail = messages.Should().ContainSingle()
            .Which;

        welcomeEmail.To.Should().Contain("test@example.com");
        welcomeEmail.Subject.Should().Contain("欢迎");
        welcomeEmail.HtmlBody.Should().Contain("感谢您注册");
    }
}

Mailpit API客户端

public class MailpitClient
{
    private readonly HttpClient _client;

    public MailpitClient(string baseUrl)
    {
        _client = new HttpClient { BaseAddress = new Uri(baseUrl) };
    }

    public async Task<List<MailpitMessage>> GetMessagesAsync()
    {
        var response = await _client.GetFromJsonAsync<MailpitResponse>("/api/v1/messages");
        return response?.Messages ?? new List<MailpitMessage>();
    }

    public async Task ClearMessagesAsync()
    {
        await _client.DeleteAsync("/api/v1/messages");
    }

    public async Task<MailpitMessage?> WaitForMessageAsync(
        Func<MailpitMessage, bool> predicate,
        TimeSpan timeout)
    {
        var deadline = DateTime.UtcNow + timeout;

        while (DateTime.UtcNow < deadline)
        {
            var messages = await GetMessagesAsync();
            var match = messages.FirstOrDefault(predicate);

            if (match != null)
                return match;

            await Task.Delay(100);
        }

        return null;
    }
}

public class MailpitResponse
{
    public List<MailpitMessage> Messages { get; set; } = new();
}

public class MailpitMessage
{
    public string Id { get; set; } = "";
    public List<string> To { get; set; } = new();
    public string Subject { get; set; } = "";
    public string HtmlBody { get; set; } = "";
}

Aspire测试夹具

public class AspireFixture : IAsyncLifetime
{
    private DistributedApplication? _app;
    private string _mailpitUrl = "";

    public async Task InitializeAsync()
    {
        var appHost = await DistributedApplicationTestingBuilder
            .CreateAsync<Projects.MyApp_AppHost>();

        // 为干净的测试禁用持久化
        appHost.Configuration["MyApp:UseVolumes"] = "false";

        _app = await appHost.BuildAsync();
        await _app.StartAsync();

        // 从Aspire获取Mailpit URL
        var mailpit = _app.GetContainerResource("mailpit");
        _mailpitUrl = await mailpit.GetEndpointAsync("ui");
    }

    public HttpClient CreateClient()
    {
        var api = _app!.GetProjectResource("api");
        return api.CreateHttpClient();
    }

    public string GetMailpitUrl() => _mailpitUrl;

    public async Task DisposeAsync()
    {
        if (_app != null)
            await _app.DisposeAsync();
    }
}

常见测试模式

等待异步邮件

有些邮件是异步发送的。等待它们:

[Fact]
public async Task AsyncWorkflow_EventuallySendsEmail()
{
    await _mailpit.ClearMessagesAsync();

    // 触发异步工作流
    await _client.PostAsync("/api/workflows/start", null);

    // 等待邮件(带超时)
    var email = await _mailpit.WaitForMessageAsync(
        m => m.Subject.Contains("工作流完成"),
        timeout: TimeSpan.FromSeconds(10));

    email.Should().NotBeNull();
}

验证多封邮件

[Fact]
public async Task BulkOperation_SendsMultipleEmails()
{
    await _mailpit.ClearMessagesAsync();

    await _client.PostAsJsonAsync("/api/invitations/bulk", new
    {
        Emails = new[] { "a@test.com", "b@test.com", "c@test.com" }
    });

    var messages = await _mailpit.WaitForMessagesAsync(
        expectedCount: 3,
        timeout: TimeSpan.FromSeconds(10));

    messages.Should().HaveCount(3);
    messages.Select(m => m.To.First())
        .Should().BeEquivalentTo("a@test.com", "b@test.com", "c@test.com");
}

验证邮件内容

[Fact]
public async Task PasswordReset_ContainsValidResetLink()
{
    await _mailpit.ClearMessagesAsync();

    await _client.PostAsJsonAsync("/api/auth/forgot-password", new
    {
        Email = "user@test.com"
    });

    var email = await _mailpit.WaitForMessageAsync(
        m => m.Subject.Contains("密码重置"),
        timeout: TimeSpan.FromSeconds(5));

    // 从HTML中提取重置链接
    var resetLink = Regex.Match(email!.HtmlBody, @"href=""([^""]+/reset/[^""]+)""")
        .Groups[1].Value;

    resetLink.Should().StartWith("https://myapp.com/reset/");

    // 验证链接有效
    var resetResponse = await _client.GetAsync(resetLink);
    resetResponse.StatusCode.Should().Be(HttpStatusCode.OK);
}

生产环境 vs 开发环境

services.AddSingleton<IEmailSender>(sp =>
{
    var settings = sp.GetRequiredService<IOptions<SmtpSettings>>().Value;
    var env = sp.GetRequiredService<IHostEnvironment>();

    if (env.IsDevelopment())
    {
        // Mailpit - 无需认证,无需SSL
        return new SmtpEmailSender(settings);
    }
    else
    {
        // 生产环境SMTP(SendGrid, Postmark等)
        return new SmtpEmailSender(settings with
        {
            EnableSsl = true
        });
    }
});

故障排除

邮件未出现

  1. 检查Aspire仪表板中Mailpit容器是否正在运行
  2. 验证SMTP主机/端口配置
  3. 检查应用程序日志中的异常

连接被拒绝

# 验证Mailpit是否在监听
curl http://localhost:8025/api/v1/messages

Aspire端点无法解析

// 确保端点引用正确
.WithEnvironment("Smtp__Host", mailpit.GetEndpoint("smtp").Property(EndpointProperty.Host))
.WithEnvironment("Smtp__Port", mailpit.GetEndpoint("smtp").Property(EndpointProperty.Port))

资源