Mailpit과 .NET Aspire를 사용한 이메일 테스트
이 스킬을 사용해야 하는 경우
다음과 같은 경우에 이 스킬을 사용하세요:
- 실제 이메일을 보내지 않고 로컬에서 이메일 전송을 테스트할 때
- .NET Aspire에서 이메일 인프라를 설정할 때
- 이메일 전송을 확인하는 통합 테스트를 작성할 때
- 이메일 렌더링 및 헤더를 디버깅할 때
관련 스킬:
aspnetcore/mjml-email-templates- MJML 템플릿 작성testing/verify-email-snapshots- 렌더링된 HTML 스냅샷 테스트aspire/integration-testing- 일반적인 Aspire 테스트 패턴
Mailpit이란?
Mailpit은 이메일을 실제로 전달하지 않고 모든 SMTP 트래픽을 캡처하는 경량 이메일 테스트 도구입니다:
- 캡처된 이메일을 볼 수 있는 웹 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": "[email protected]",
"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; } = "[email protected]";
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
캡처된 이메일 보기
웹 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()
{
// Arrange
await _mailpit.ClearMessagesAsync();
// Act - 회원가입 흐름 트리거
var response = await _client.PostAsJsonAsync("/api/auth/signup", new
{
Email = "[email protected]",
Password = "SecurePassword123!"
});
response.EnsureSuccessStatusCode();
// Assert - 이메일 전송 확인
var messages = await _mailpit.GetMessagesAsync();
var welcomeEmail = messages.Should().ContainSingle()
.Which;
welcomeEmail.To.Should().Contain("[email protected]");
welcomeEmail.Subject.Should().Contain("Welcome");
welcomeEmail.HtmlBody.Should().Contain("Thank you for signing up");
}
}
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("Workflow Complete"),
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[] { "[email protected]", "[email protected]", "[email protected]" }
});
var messages = await _mailpit.WaitForMessagesAsync(
expectedCount: 3,
timeout: TimeSpan.FromSeconds(10));
messages.Should().HaveCount(3);
messages.Select(m => m.To.First())
.Should().BeEquivalentTo("[email protected]", "[email protected]", "[email protected]");
}
이메일 콘텐츠 확인
[Fact]
public async Task PasswordReset_ContainsValidResetLink()
{
await _mailpit.ClearMessagesAsync();
await _client.PostAsJsonAsync("/api/auth/forgot-password", new
{
Email = "[email protected]"
});
var email = await _mailpit.WaitForMessageAsync(
m => m.Subject.Contains("Password Reset"),
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
});
}
});
문제 해결
이메일이 나타나지 않음
- Aspire 대시보드에서 Mailpit 컨테이너가 실행 중인지 확인
- SMTP 호스트/포트 구성 확인
- 애플리케이션 로그에서 예외 확인
연결 거부됨
# 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))
리소스
- Mailpit: https://github.com/axllent/mailpit
- Mailpit API: https://mailpit.axllent.org/docs/api-v1/
- MailKit: https://github.com/jstedfast/MailKit
- Aspire Containers: https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/container-resources