mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-24 05:15:16 +02:00
231 lines
5.9 KiB
TypeScript
231 lines
5.9 KiB
TypeScript
import type { StartedNetwork } from 'testcontainers';
|
|
import { GenericContainer, Wait } from 'testcontainers';
|
|
|
|
import { createSilentLogConsumer } from '../helpers/utils';
|
|
import { TEST_CONTAINER_IMAGES } from '../test-containers';
|
|
import type { HelperContext, Service, ServiceResult } from './types';
|
|
|
|
const HOSTNAME = 'mailpit';
|
|
const SMTP_PORT = 1025;
|
|
const HTTP_PORT = 8025;
|
|
|
|
type MailpitAddress = {
|
|
Address: string;
|
|
Name?: string;
|
|
};
|
|
|
|
export type MailpitMessageSummary = {
|
|
ID: string;
|
|
MessageID: string;
|
|
Read: boolean;
|
|
From: MailpitAddress;
|
|
To: MailpitAddress[];
|
|
Cc: MailpitAddress[] | null;
|
|
Bcc: MailpitAddress[] | null;
|
|
ReplyTo: MailpitAddress[];
|
|
Subject: string;
|
|
Created: string;
|
|
Username: string;
|
|
Tags: string[];
|
|
Size: number;
|
|
Attachments: number;
|
|
Snippet: string;
|
|
};
|
|
|
|
export type MailpitMessage = MailpitMessageSummary & {
|
|
Text?: string;
|
|
HTML?: string;
|
|
Inline?: Array<{
|
|
PartID: string;
|
|
FileName: string;
|
|
ContentType: string;
|
|
ContentID: string;
|
|
Size: number;
|
|
}>;
|
|
Attachments?: Array<{
|
|
PartID: string;
|
|
FileName: string;
|
|
ContentType: string;
|
|
ContentID: string;
|
|
Size: number;
|
|
}>;
|
|
};
|
|
|
|
export type MailpitQuery = {
|
|
to?: string | RegExp;
|
|
subject?: string | RegExp;
|
|
};
|
|
|
|
type MailpitListResponse = {
|
|
total: number;
|
|
unread: number;
|
|
count: number;
|
|
messages_count: number;
|
|
messages_unread: number;
|
|
start: number;
|
|
tags: string[];
|
|
messages: MailpitMessageSummary[];
|
|
};
|
|
|
|
export interface MailpitMeta {
|
|
apiBaseUrl: string;
|
|
}
|
|
|
|
export type MailpitResult = ServiceResult<MailpitMeta>;
|
|
|
|
export const mailpit: Service<MailpitResult> = {
|
|
description: 'Email testing server',
|
|
|
|
async start(network: StartedNetwork, projectName: string): Promise<MailpitResult> {
|
|
const { consumer, throwWithLogs } = createSilentLogConsumer();
|
|
|
|
try {
|
|
const container = await new GenericContainer(TEST_CONTAINER_IMAGES.mailpit)
|
|
.withNetwork(network)
|
|
.withNetworkAliases(HOSTNAME)
|
|
.withExposedPorts(SMTP_PORT, HTTP_PORT)
|
|
.withEnvironment({
|
|
MP_UI_BIND_ADDR: `0.0.0.0:${HTTP_PORT}`,
|
|
MP_SMTP_BIND_ADDR: `0.0.0.0:${SMTP_PORT}`,
|
|
})
|
|
.withWaitStrategy(
|
|
Wait.forAll([
|
|
Wait.forListeningPorts(),
|
|
Wait.forHttp('/api/v1/info', HTTP_PORT).forStatusCode(200).withStartupTimeout(30000),
|
|
]),
|
|
)
|
|
.withLabels({
|
|
'com.docker.compose.project': projectName,
|
|
'com.docker.compose.service': HOSTNAME,
|
|
})
|
|
.withName(`${projectName}-${HOSTNAME}`)
|
|
.withReuse()
|
|
.withLogConsumer(consumer)
|
|
.start();
|
|
|
|
return {
|
|
container,
|
|
meta: {
|
|
apiBaseUrl: `http://${container.getHost()}:${container.getMappedPort(HTTP_PORT)}`,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
return throwWithLogs(error);
|
|
}
|
|
},
|
|
|
|
env(result: MailpitResult, external?: boolean): Record<string, string> {
|
|
return {
|
|
N8N_EMAIL_MODE: 'smtp',
|
|
N8N_SMTP_HOST: external ? result.container.getHost() : HOSTNAME,
|
|
N8N_SMTP_PORT: external
|
|
? String(result.container.getMappedPort(SMTP_PORT))
|
|
: String(SMTP_PORT),
|
|
N8N_SMTP_SSL: 'false',
|
|
N8N_SMTP_SENDER: 'test@n8n.local',
|
|
};
|
|
},
|
|
};
|
|
|
|
export class MailpitHelper {
|
|
private readonly apiBaseUrl: string;
|
|
|
|
/** SMTP host that n8n should use to send email (internal hostname in container mode, localhost in local mode) */
|
|
readonly smtpHost: string;
|
|
|
|
/** SMTP port that n8n should use to send email (1025 in container mode, mapped port in local mode) */
|
|
readonly smtpPort: number;
|
|
|
|
constructor(apiBaseUrl: string, smtpHost = HOSTNAME, smtpPort = SMTP_PORT) {
|
|
this.apiBaseUrl = apiBaseUrl;
|
|
this.smtpHost = smtpHost;
|
|
this.smtpPort = smtpPort;
|
|
}
|
|
|
|
async clear(): Promise<void> {
|
|
const res = await fetch(`${this.apiBaseUrl}/api/v1/messages`, { method: 'DELETE' });
|
|
if (!res.ok) {
|
|
throw new Error(`Mailpit clear failed: ${res.status} ${res.statusText}`);
|
|
}
|
|
}
|
|
|
|
async list(): Promise<MailpitMessageSummary[]> {
|
|
const res = await fetch(`${this.apiBaseUrl}/api/v1/messages`);
|
|
if (!res.ok) {
|
|
throw new Error(`Mailpit list failed: ${res.status} ${res.statusText}`);
|
|
}
|
|
|
|
const data = (await res.json()) as MailpitListResponse;
|
|
return data.messages || [];
|
|
}
|
|
|
|
async get(id: string): Promise<MailpitMessage> {
|
|
const res = await fetch(`${this.apiBaseUrl}/api/v1/message/${id}`);
|
|
if (!res.ok) {
|
|
throw new Error(`Mailpit get failed: ${res.status} ${res.statusText}`);
|
|
}
|
|
|
|
return (await res.json()) as MailpitMessage;
|
|
}
|
|
|
|
async waitForMessage(
|
|
query: MailpitQuery,
|
|
options: { timeoutMs?: number; pollMs?: number } = {},
|
|
): Promise<MailpitMessageSummary> {
|
|
const { timeoutMs = 10000, pollMs = 200 } = options;
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
const messageMatches = (message: MailpitMessageSummary): boolean => {
|
|
if (query.to) {
|
|
const hasMatchingRecipient = message.To.some((recipient) =>
|
|
typeof query.to === 'string'
|
|
? recipient.Address === query.to
|
|
: query.to!.test(recipient.Address),
|
|
);
|
|
if (!hasMatchingRecipient) return false;
|
|
}
|
|
|
|
if (query.subject) {
|
|
const subjectMatches =
|
|
typeof query.subject === 'string'
|
|
? message.Subject === query.subject
|
|
: query.subject.test(message.Subject);
|
|
if (!subjectMatches) return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
while (Date.now() < deadline) {
|
|
const messages = await this.list();
|
|
const match = messages.find(messageMatches);
|
|
|
|
if (match) {
|
|
return match;
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
}
|
|
|
|
const queryParts = [];
|
|
if (query.to) queryParts.push(`to: ${query.to}`);
|
|
if (query.subject) queryParts.push(`subject: ${query.subject}`);
|
|
|
|
throw new Error(`Mail not received within ${timeoutMs}ms. Query: ${queryParts.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
export function createMailpitHelper(ctx: HelperContext): MailpitHelper {
|
|
const result = ctx.serviceResults.mailpit as MailpitResult | undefined;
|
|
if (!result) {
|
|
throw new Error('Mailpit service not found in context');
|
|
}
|
|
return new MailpitHelper(result.meta.apiBaseUrl);
|
|
}
|
|
|
|
declare module './types' {
|
|
interface ServiceHelpers {
|
|
mailpit: MailpitHelper;
|
|
}
|
|
}
|