Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/NestJS-framework-E0234E?logo=nestjs&logoColor=white" alt="NestJS" />
<img src="https://img.shields.io/badge/PostgreSQL-database-4169E1?logo=postgresql&logoColor=white" alt="PostgreSQL" />
<img src="https://img.shields.io/badge/License-Apache%202.0-blue" alt="Apache 2.0 License" />
<img src="https://img.shields.io/badge/Tests-1578%20passing-brightgreen" alt="1578 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
<img src="https://img.shields.io/badge/Tests-1591%20passing-brightgreen" alt="1591 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
</p>

<p align="center">
Expand Down Expand Up @@ -510,7 +510,7 @@ Operator notes for activating existing adapters, metasearch landings on the dire
| OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) |
| XML Processing | fast-xml-parser | Booking.com OTA XML protocol |
| Package Manager | pnpm workspaces | Monorepo management |
| Testing | Vitest (1578 tests across 219 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
| Testing | Vitest (1591 tests across 220 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
| Containers | Docker + docker-compose | Local dev and production deployment |
| CI/CD | GitHub Actions | Automated testing, builds, and releases |

Expand Down Expand Up @@ -642,7 +642,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment.
### Run tests

```bash
# All tests (1578 tests across 219 test files)
# All tests (1591 tests across 220 test files)

# API tests only
pnpm --filter @telivityhaip/api test
Expand Down Expand Up @@ -1190,7 +1190,7 @@ HAIP is built in public and contributions are welcome.
pnpm install # Install dependencies
pnpm build # Build all workspace packages
pnpm dev # Start API in dev mode (hot reload)
pnpm test # Run all tests (1578 tests, 219 files)
pnpm test # Run all tests (1591 tests, 220 files)
pnpm lint # ESLint
```

Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"fast-xml-parser": "^5.5.10",
"jsonwebtoken": "^9.0.3",
"jwks-rsa": "^4.0.1",
"nodemailer": "^9.0.5",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"postgres": "^3.4.5",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,51 @@ export interface EmailMessage {
html: string;
text: string;
from?: string;
/**
* Correlation key forwarded to providers as custom metadata
* (e.g. Mailgun `v:haip-idempotency-key`, SendGrid custom_args). Useful for
* log/trace correlation across retries — NOT an exactly-once or deduplication
* guarantee in Mailgun, SendGrid, SES, or SMTP.
*/
idempotencyKey?: string;
/**
* Stable RFC Message-ID reused across retries for correlation when the
* provider supports setting it. Does not prevent duplicate delivery.
*/
messageId?: string;
}

/** Provider-confirmed acceptance vs definite failure vs ambiguous response. */
export type EmailDeliveryStatus = 'sent' | 'notSent' | 'outcomeUnknown';

export interface EmailResult {
/** `sent` = provider confirmed; `notSent` = safe to auto-retry; `outcomeUnknown` = do not auto-retry. */
status: EmailDeliveryStatus;
/** Convenience mirror of `status === 'sent'`. */
sent: boolean;
messageId?: string;
provider?: string;
error?: string;
}

export interface EmailSendOptions {
/**
* Hard send deadline in milliseconds. HTTP transports return at the deadline
* even when the underlying fetch ignores abort; SMTP hard-closes owned sockets
* and returns via `Promise.race` even if `sendMail` is still settling.
*/
timeoutMs?: number;
/**
* Max send attempts for definitely-not-sent failures (`status: 'notSent'`).
* Does not retry `outcomeUnknown` (ambiguous acceptance).
*/
maxAttempts?: number;
}

export interface EmailProvider {
readonly name: string;
isConfigured(): boolean;
send(message: EmailMessage): Promise<EmailResult>;
send(message: EmailMessage, options?: EmailSendOptions): Promise<EmailResult>;
}

export const EMAIL_PROVIDERS = Symbol('EMAIL_PROVIDERS');
234 changes: 230 additions & 4 deletions apps/api/src/modules/agent/guest-comms/email.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EmailService } from './email.service';
import type { EmailProvider } from './email-provider.interface';
import type { EmailProvider, EmailResult } from './email-provider.interface';

describe('EmailService', () => {
const consoleProvider: EmailProvider = {
name: 'console',
isConfigured: () => true,
send: vi.fn().mockResolvedValue({ sent: false, provider: 'console', error: 'logged' }),
send: vi.fn().mockResolvedValue({
status: 'notSent',
sent: false,
provider: 'console',
error: 'logged',
} satisfies EmailResult),
};

beforeEach(() => {
Expand All @@ -17,7 +22,12 @@ describe('EmailService', () => {
const sendgrid = {
name: 'sendgrid',
isConfigured: () => true,
send: vi.fn().mockResolvedValue({ sent: true, provider: 'sendgrid', messageId: 'sg-1' }),
send: vi.fn().mockResolvedValue({
status: 'sent',
sent: true,
provider: 'sendgrid',
messageId: 'sg-1',
} satisfies EmailResult),
};
const smtp = {
name: 'smtp',
Expand All @@ -32,10 +42,37 @@ describe('EmailService', () => {
text: 'Hi',
});
expect(result.sent).toBe(true);
expect(sendgrid.send).toHaveBeenCalled();
expect(result.status).toBe('sent');
expect(sendgrid.send).toHaveBeenCalledTimes(1);
expect(smtp.send).not.toHaveBeenCalled();
});

it('passes stable transport identity through to the selected provider', async () => {
const provider = {
name: 'sendgrid',
isConfigured: () => true,
send: vi.fn().mockResolvedValue({
status: 'sent',
sent: true,
messageId: 'provider-id',
} satisfies EmailResult),
};
const service = new EmailService([provider]);
const message = {
to: 'guest@example.com',
subject: 'Hi',
html: '<p>Hi</p>',
text: 'Hi',
idempotencyKey: 'booking-request-email:delivery-1',
messageId: '<booking-request-email-delivery-1@haip.local>',
};
await service.send(message, { timeoutMs: 1_234 });
expect(provider.send).toHaveBeenCalledWith(expect.objectContaining({
idempotencyKey: 'booking-request-email:delivery-1',
messageId: '<booking-request-email-delivery-1@haip.local>',
}), { timeoutMs: 1_234 });
});

it('falls back to console when no real provider is configured', async () => {
const smtp = { name: 'smtp', isConfigured: () => false, send: vi.fn() };
const sendgrid = { name: 'sendgrid', isConfigured: () => false, send: vi.fn() };
Expand All @@ -49,13 +86,95 @@ describe('EmailService', () => {
});
expect(consoleProvider.send).toHaveBeenCalled();
});

it('retries definitely-not-sent failures but not outcomeUnknown', async () => {
const provider = {
name: 'sendgrid',
isConfigured: () => true,
send: vi.fn()
.mockResolvedValueOnce({
status: 'notSent',
sent: false,
provider: 'sendgrid',
error: 'SendGrid HTTP 503',
} satisfies EmailResult)
.mockResolvedValueOnce({
status: 'outcomeUnknown',
sent: false,
provider: 'sendgrid',
error: 'Email transport timed out',
} satisfies EmailResult),
};
const service = new EmailService([provider]);
const message = {
to: 'guest@example.com',
subject: 'Hi',
html: '<p>Hi</p>',
text: 'Hi',
idempotencyKey: 'delivery-1',
messageId: '<delivery-1@haip.local>',
};

const result = await service.send(message, { maxAttempts: 3 });
expect(result.status).toBe('outcomeUnknown');
expect(provider.send).toHaveBeenCalledTimes(2);
});

it('does not retry outcomeUnknown on the first attempt', async () => {
const provider = {
name: 'sendgrid',
isConfigured: () => true,
send: vi.fn().mockResolvedValue({
status: 'outcomeUnknown',
sent: false,
provider: 'sendgrid',
error: 'Email transport timed out',
} satisfies EmailResult),
};
const service = new EmailService([provider]);
const result = await service.send({
to: 'guest@example.com',
subject: 'Hi',
html: '<p>Hi</p>',
text: 'Hi',
}, { maxAttempts: 3 });
expect(result.status).toBe('outcomeUnknown');
expect(provider.send).toHaveBeenCalledTimes(1);
});

it('retries up to maxAttempts for notSent then stops', async () => {
vi.useFakeTimers();
const provider = {
name: 'sendgrid',
isConfigured: () => true,
send: vi.fn().mockResolvedValue({
status: 'notSent',
sent: false,
provider: 'sendgrid',
error: 'SendGrid HTTP 503',
} satisfies EmailResult),
};
const service = new EmailService([provider]);
const sending = service.send({
to: 'guest@example.com',
subject: 'Hi',
html: '<p>Hi</p>',
text: 'Hi',
}, { maxAttempts: 3 });
await vi.runAllTimersAsync();
const result = await sending;
expect(result.status).toBe('notSent');
expect(provider.send).toHaveBeenCalledTimes(3);
vi.useRealTimers();
});
});

describe('SendgridEmailProvider', () => {
const originalFetch = global.fetch;
const originalEnv = { ...process.env };

afterEach(() => {
vi.useRealTimers();
global.fetch = originalFetch;
process.env = { ...originalEnv };
vi.resetModules();
Expand All @@ -73,6 +192,7 @@ describe('SendgridEmailProvider', () => {
html: 'h',
text: 't',
});
expect(result.status).toBe('notSent');
expect(result.sent).toBe(false);
});

Expand All @@ -92,11 +212,117 @@ describe('SendgridEmailProvider', () => {
subject: 'Confirm',
html: '<p>Hi</p>',
text: 'Hi',
idempotencyKey: 'stable-delivery-1',
messageId: '<stable-delivery-1@haip.local>',
});
expect(result.status).toBe('sent');
expect(result.sent).toBe(true);
expect(global.fetch).toHaveBeenCalledWith(
'https://api.sendgrid.com/v3/mail/send',
expect.objectContaining({ method: 'POST' }),
);
const init = vi.mocked(global.fetch).mock.calls[0]?.[1];
const payload = JSON.parse(String(init?.body));
expect(payload.personalizations[0]).toMatchObject({
headers: { 'Message-ID': '<stable-delivery-1@haip.local>' },
custom_args: { haip_idempotency_key: 'stable-delivery-1' },
});
});

it('returns at the hard HTTP deadline even when fetch ignores abort', async () => {
vi.useFakeTimers();
process.env['SENDGRID_API_KEY'] = 'SG.test';
process.env['SENDGRID_FROM'] = 'hotel@example.com';
global.fetch = vi.fn((_url, init) => new Promise((_resolve, _reject) => {
init?.signal?.addEventListener('abort', () => undefined, { once: true });
})) as any;

const { SendgridEmailProvider } = await import('./providers/sendgrid-email.provider');
const provider = new SendgridEmailProvider();
const sending = provider.send({
to: 'guest@example.com',
subject: 'Confirm',
html: '<p>Hi</p>',
text: 'Hi',
}, { timeoutMs: 100 });
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledOnce());
const signal = vi.mocked(global.fetch).mock.calls[0]?.[1]?.signal;
expect(signal).toBeInstanceOf(AbortSignal);

await vi.advanceTimersByTimeAsync(100);
await expect(sending).resolves.toMatchObject({
status: 'outcomeUnknown',
sent: false,
error: 'Email transport timed out',
});
expect(signal?.aborted).toBe(true);
});

it('swallows detached fetch rejection after the hard deadline', async () => {
vi.useFakeTimers();
process.env['SENDGRID_API_KEY'] = 'SG.test';
process.env['SENDGRID_FROM'] = 'hotel@example.com';
global.fetch = vi.fn((_url, init) => new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
queueMicrotask(() => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })));
}, { once: true });
})) as any;

const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on('unhandledRejection', onUnhandled);

const { SendgridEmailProvider } = await import('./providers/sendgrid-email.provider');
const provider = new SendgridEmailProvider();
const sending = provider.send({
to: 'guest@example.com',
subject: 'Confirm',
html: '<p>Hi</p>',
text: 'Hi',
}, { timeoutMs: 100 });
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledOnce());
await vi.advanceTimersByTimeAsync(100);
await expect(sending).resolves.toMatchObject({ status: 'outcomeUnknown' });
await vi.advanceTimersByTimeAsync(100);
await Promise.resolve();
expect(unhandled).toEqual([]);
process.off('unhandledRejection', onUnhandled);
});
});

describe('boundedEmailFetch', () => {
const originalFetch = global.fetch;

afterEach(() => {
vi.useRealTimers();
global.fetch = originalFetch;
vi.resetModules();
});

it('returns at timeout without waiting for a hanging response body', async () => {
vi.useFakeTimers();
let bodySettled = false;
global.fetch = vi.fn((_url, init) => Promise.resolve({
ok: true,
json: () => new Promise((_resolve, _reject) => {
init?.signal?.addEventListener('abort', () => undefined, { once: true });
}),
})) as any;

const { boundedEmailFetch, EmailTransportTimeoutError } = await import('./providers/bounded-email-transport');
const work = boundedEmailFetch(
'https://example.test/send',
{ method: 'POST' },
{ timeoutMs: 100 },
async (response) => {
await response.json();
bodySettled = true;
return 'done';
},
);
const expectation = expect(work).rejects.toBeInstanceOf(EmailTransportTimeoutError);
await vi.advanceTimersByTimeAsync(100);
await expectation;
expect(bodySettled).toBe(false);
});
});
Loading
Loading