diff --git a/server/src/nest/assistant/assistant-tool-offer.ts b/server/src/nest/assistant/assistant-tool-offer.ts index cee0cfb4e..2225db6d8 100644 --- a/server/src/nest/assistant/assistant-tool-offer.ts +++ b/server/src/nest/assistant/assistant-tool-offer.ts @@ -92,6 +92,13 @@ export const ASSISTANT_DENIED_TOOLS: Readonly> = { update_manual_school_holiday_region: 'changes the holidays of every user', delete_manual_school_holiday_region: 'changes the holidays of every user', delete_manual_school_holiday_country: 'changes the holidays of every user', + list_mail_inbox: 'reads the forwarded mails of the user, which would reach the AI provider', + add_mail_booking: 'the mail inbox is decided in the app, not from a trip chat', + move_mail_booking: 'the mail inbox is decided in the app, not from a trip chat', + file_mail_booking: 'the mail inbox is decided in the app, not from a trip chat', + discard_mail_booking: 'the mail inbox is decided in the app, not from a trip chat', + undo_mail_booking: 'the mail inbox is decided in the app, not from a trip chat', + update_mail_import_settings: 'changes how the mail import of the user works', ...STUDIO_ONLY_TOOLS, restore_book_page: 'an undo target only: it puts a book page back as an earlier change left it', restore_book_layout: 'an undo target only: it puts back a layout an earlier change took away', diff --git a/server/src/nest/mail-ingest/mail-ingest-actions.service.ts b/server/src/nest/mail-ingest/mail-ingest-actions.service.ts index 54aff5821..0e79efced 100644 --- a/server/src/nest/mail-ingest/mail-ingest-actions.service.ts +++ b/server/src/nest/mail-ingest/mail-ingest-actions.service.ts @@ -21,6 +21,7 @@ import { MailReadService } from '../llm-parse/mail-read.service'; import type { WalkedMail } from '../llm-parse/mail-read.types'; import { MailIngestApplyService, type WrittenBooking } from './mail-ingest-apply.service'; import { MailIngestNotifyService } from './mail-ingest-notify.service'; +import { MailIngestSettingsService } from './mail-ingest-settings.service'; import { fitsTrip, implausible, type TripOption } from './mail-ingest-checks'; import { MailIngestTripsService } from './mail-ingest-trips.service'; import { ADDED_RESOLUTIONS } from './mail-ingest-views'; @@ -62,6 +63,7 @@ export class MailIngestActionsService { private readonly reader: MailReadService, private readonly airports: AirportsService, private readonly demo: DemoService, + private readonly settings: MailIngestSettingsService, private readonly uow: UnitOfWork, ) {} @@ -80,6 +82,7 @@ export class MailIngestActionsService { confirm: boolean, now: Date = new Date(), ): Promise { + await this.requireAvailable(); const { entry, tripId, created } = await this.addedEntry(candidateId, userId, now); await this.requireConfirmed(tripId, created, confirm, 'Undoing this booking changes more than the booking'); const removed = await this.uow.transactional(async () => { @@ -119,6 +122,7 @@ export class MailIngestActionsService { confirm: boolean, now: Date = new Date(), ): Promise { + await this.requireAvailable(); const { entry, tripId: fromTripId, created } = await this.addedEntry(candidateId, userId, now); if (tripId === fromTripId) throw new DomainError(409, 'This booking is in that trip already'); const item = decodeJson(MAIL_INGEST_ITEM, entry.item_json); @@ -331,14 +335,20 @@ export class MailIngestActionsService { await this.candidates.setAttachments(entry.id, encodeJson(MAIL_INGEST_CREATED, created), error); } - /** The person's own waiting entry; anyone else's answers as missing. */ + /** The person's own waiting entry, while the import runs; anyone else's answers as missing. */ private async pendingEntry(candidateId: number, userId: number): Promise { + await this.requireAvailable(); const entry = await this.candidates.findById(candidateId); if (!entry || entry.user_id !== userId) throw new DomainError(404, 'Entry not found'); if (entry.resolution !== 'pending') throw new DomainError(409, 'This entry was decided already'); return entry; } + /** Every action stops with the import: on a plan without it, or once an admin switched it off. */ + private async requireAvailable(): Promise { + if (!(await this.settings.isAvailable())) throw new DomainError(404, 'Mail import is not available'); + } + private async tripFor(userId: number, tripId: number | null): Promise { const person = await this.trips.person(userId); const trip = person && tripId !== null ? await this.trips.editableTrip(person, tripId) : null; diff --git a/server/src/nest/mail-ingest/mail-ingest-admin.controller.ts b/server/src/nest/mail-ingest/mail-ingest-admin.controller.ts new file mode 100644 index 000000000..3a93a4e87 --- /dev/null +++ b/server/src/nest/mail-ingest/mail-ingest-admin.controller.ts @@ -0,0 +1,37 @@ +import { AdminGuard } from '../auth-core/admin.guard'; +import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; +import { ResponseContract } from '../common/response-contract'; +import { MailIngestStatusService } from './mail-ingest-status.service'; +import { Controller, Get, HttpCode, Post, UseGuards } from '@nestjs/common'; +import { + mailIngestAdminStatusResponseSchema, + mailIngestAdminTestResponseSchema, + type MailIngestAdminStatusResponse, + type MailIngestAdminTestResponse, +} from '@trek/shared'; + +/** + * `/api/admin/mail-ingest`: how the booking mail import runs, for the admin + * dialog. The status card is counts only, no mail content; the test reaches + * the configured transport and says what failed. Both read, so a managed + * install's admin sees them too; the settings themselves are the operator's + * there and go through the admin settings form, which withholds them. + */ +@Controller('api/admin/mail-ingest') +@UseGuards(JwtAuthGuard, AdminGuard) +export class MailIngestAdminController { + constructor(private readonly status: MailIngestStatusService) {} + + @Get('status') + @ResponseContract(mailIngestAdminStatusResponseSchema) + read(): Promise { + return this.status.status(); + } + + @Post('test') + @HttpCode(200) + @ResponseContract(mailIngestAdminTestResponseSchema) + test(): Promise { + return this.status.test(); + } +} diff --git a/server/src/nest/mail-ingest/mail-ingest-inbox.service.ts b/server/src/nest/mail-ingest/mail-ingest-inbox.service.ts index 1b25e6399..7a7dde4f7 100644 --- a/server/src/nest/mail-ingest/mail-ingest-inbox.service.ts +++ b/server/src/nest/mail-ingest/mail-ingest-inbox.service.ts @@ -35,13 +35,13 @@ import type { } from '@trek/shared'; /** How an upload the intake refused answers, by why. */ -const UPLOAD_REFUSALS: Record = { - unavailable: { status: 404, error: 'Mail import is not available' }, - too_large: { status: 413, error: 'This mail is larger than the import takes' }, - not_a_mail: { status: 422, error: 'This file is not a mail' }, - unknown_address: { status: 422, error: 'This file is not a mail' }, - address_daily_limit: { status: 429, error: 'Your address took all the mails it takes today' }, - inbox_full: { status: 409, error: 'Your inbox is full; decide some of its entries first' }, +const UPLOAD_REFUSALS: Record = { + unavailable: [404, 'Mail import is not available'], + too_large: [413, 'This mail is larger than the import takes'], + not_a_mail: [422, 'This file is not a mail'], + unknown_address: [422, 'This file is not a mail'], + address_daily_limit: [429, 'Your address took all the mails it takes today'], + inbox_full: [409, 'Your inbox is full; decide some of its entries first'], }; /** @@ -161,7 +161,7 @@ export class MailIngestInboxService { async upload(userId: number, raw: Buffer): Promise { const result = await this.intake.upload(raw, userId); if (result.status === 'refused') { - const { status, error } = UPLOAD_REFUSALS[result.reason]; + const [status, error] = UPLOAD_REFUSALS[result.reason]; throw new DomainError(status, error); } const [head] = await this.messages.listHeads([result.messageId]); diff --git a/server/src/nest/mail-ingest/mail-ingest-trip.controller.ts b/server/src/nest/mail-ingest/mail-ingest-trip.controller.ts new file mode 100644 index 000000000..400702fb0 --- /dev/null +++ b/server/src/nest/mail-ingest/mail-ingest-trip.controller.ts @@ -0,0 +1,49 @@ +import type { User } from '../../types'; +import { CurrentUser } from '../auth-core/current-user.decorator'; +import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; +import { ResponseContract } from '../common/response-contract'; +import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard'; +import { MailIngestInboxService } from './mail-ingest-inbox.service'; +import { Controller, Delete, Get, HttpCode, Param, Post, UseGuards } from '@nestjs/common'; +import { + mailIngestAddressResponseSchema, + successResponseSchema, + type MailIngestAddressResponse, + type SuccessResponse, +} from '@trek/shared'; + +/** + * `/api/trips/:tripId/mail-ingest/address`: the address a member keeps for + * one trip, so a mail sent there needs no trip to be picked. Only a member + * who may add bookings to the trip has one, and it is theirs alone: another + * member reading it sees their own or none. It stops taking mail when its + * owner can no longer add bookings, which the import checks on every mail. + */ +@Controller('api/trips/:tripId/mail-ingest/address') +@UseGuards(JwtAuthGuard, TripAccessGuard) +@RequirePermission('reservation_edit') +export class MailIngestTripController { + constructor(private readonly inbox: MailIngestInboxService) {} + + @Get() + @ResponseContract(mailIngestAddressResponseSchema) + get(@CurrentUser() user: User, @Param('tripId') tripId: string): Promise { + return this.inbox.tripAddress(user.id, Number(tripId)); + } + + /** Makes the person's address for the trip, or a new one in place of the old, which stops at once. */ + @Post() + @HttpCode(200) + @ResponseContract(mailIngestAddressResponseSchema) + renew(@CurrentUser() user: User, @Param('tripId') tripId: string): Promise { + return this.inbox.renewTripAddress(user.id, Number(tripId)); + } + + @Delete() + @HttpCode(200) + @ResponseContract(successResponseSchema) + async revoke(@CurrentUser() user: User, @Param('tripId') tripId: string): Promise { + await this.inbox.revokeTripAddress(user.id, Number(tripId)); + return { success: true }; + } +} diff --git a/server/src/nest/mail-ingest/mail-ingest.controller.ts b/server/src/nest/mail-ingest/mail-ingest.controller.ts new file mode 100644 index 000000000..ac085d71d --- /dev/null +++ b/server/src/nest/mail-ingest/mail-ingest.controller.ts @@ -0,0 +1,209 @@ +import type { MailIngestCandidateRow } from '../../db/repositories/MailIngestCandidates.repository'; +import type { User } from '../../types'; +import { CurrentUser } from '../auth-core/current-user.decorator'; +import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; +import { DomainError } from '../common/domain-error'; +import { ResponseContract } from '../common/response-contract'; +import { MailIngestActionsService } from './mail-ingest-actions.service'; +import { MailIngestInboxService } from './mail-ingest-inbox.service'; +import { MAIL_INGEST_LIMITS } from './mail-ingest.constants'; +import { + MailIngestApplyDto, + MailIngestFileDto, + MailIngestInboxQueryDto, + MailIngestMoveDto, + MailIngestPrefsUpdateDto, + MailIngestUndoDto, +} from './mail-ingest.dto'; +import { + Body, + Controller, + Get, + HttpCode, + Param, + Patch, + Post, + Query, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { + idParamSchema, + mailIngestAddressResponseSchema, + mailIngestCandidateResponseSchema, + mailIngestInboxCountResponseSchema, + mailIngestInboxResponseSchema, + mailIngestMessagePreviewResponseSchema, + mailIngestPrefillResponseSchema, + mailIngestSettingsResponseSchema, + mailIngestUploadResponseSchema, + type MailIngestAddressResponse, + type MailIngestCandidateResponse, + type MailIngestInboxCountResponse, + type MailIngestInboxResponse, + type MailIngestMessagePreviewResponse, + type MailIngestPrefillResponse, + type MailIngestSettingsResponse, + type MailIngestUploadResponse, +} from '@trek/shared'; + +import { memoryStorage } from 'multer'; +import { extname } from 'node:path'; + +const UPLOAD = { + storage: memoryStorage(), + // The largest mail any install takes; the import's own limit, smaller on a self-hosted one, is checked after. + limits: { fileSize: MAIL_INGEST_LIMITS.cloudMailBytes, files: 1 }, + defParamCharset: 'utf8' as const, +}; + +/** + * `/api/mail-ingest`: a person's own mail import. The settings card (their + * address and switches), the inbox of bookings that wait, and what they do + * with an entry: add it to a trip, move or undo an added one, file the mail + * as documents, take the form's starting values, or discard it. An .eml + * upload reads a mail without any transport. Every write takes the client's + * X-Idempotency-Key through the global interceptor, so a replay from the + * offline queue answers what the first call answered. + */ +@Controller('api/mail-ingest') +@UseGuards(JwtAuthGuard) +export class MailIngestController { + constructor( + private readonly inbox: MailIngestInboxService, + private readonly actions: MailIngestActionsService, + ) {} + + @Get('settings') + @ResponseContract(mailIngestSettingsResponseSchema) + settings(@CurrentUser() user: User): Promise { + return this.inbox.settingsOf(user.id); + } + + @Patch('settings') + @ResponseContract(mailIngestSettingsResponseSchema) + updateSettings( + @CurrentUser() user: User, + @Body() body: MailIngestPrefsUpdateDto, + ): Promise { + return this.inbox.updatePrefs(user.id, body); + } + + @Post('address/renew') + @HttpCode(200) + @ResponseContract(mailIngestAddressResponseSchema) + renewAddress(@CurrentUser() user: User): Promise { + return this.inbox.renewOwnAddress(user.id); + } + + @Get('inbox/count') + @ResponseContract(mailIngestInboxCountResponseSchema) + count(@CurrentUser() user: User): Promise { + return this.inbox.count(user.id); + } + + @Get('inbox') + @ResponseContract(mailIngestInboxResponseSchema) + list(@CurrentUser() user: User, @Query() query: MailIngestInboxQueryDto): Promise { + return this.inbox.inbox(user.id, query); + } + + @Get('messages/:id/preview') + @ResponseContract(mailIngestMessagePreviewResponseSchema) + preview(@CurrentUser() user: User, @Param('id') id: string): Promise { + return this.inbox.preview(user.id, idOf(id, 'Mail not found')); + } + + @Get('candidates/:id/prefill') + @ResponseContract(mailIngestPrefillResponseSchema) + prefill(@CurrentUser() user: User, @Param('id') id: string): Promise { + return this.actions.prefill(entryId(id), user.id); + } + + @Post('candidates/:id/apply') + @HttpCode(200) + @ResponseContract(mailIngestCandidateResponseSchema) + async apply( + @CurrentUser() user: User, + @Param('id') id: string, + @Body() body: MailIngestApplyDto, + ): Promise { + return this.answer(await this.actions.applyToTrip(entryId(id), user.id, body.trip_id ?? null)); + } + + @Post('candidates/:id/move') + @HttpCode(200) + @ResponseContract(mailIngestCandidateResponseSchema) + async move( + @CurrentUser() user: User, + @Param('id') id: string, + @Body() body: MailIngestMoveDto, + ): Promise { + return this.answer(await this.actions.move(entryId(id), user.id, body.trip_id, body.confirm === true)); + } + + @Post('candidates/:id/file') + @HttpCode(200) + @ResponseContract(mailIngestCandidateResponseSchema) + async file( + @CurrentUser() user: User, + @Param('id') id: string, + @Body() body: MailIngestFileDto, + ): Promise { + return this.answer(await this.actions.fileToTrip(entryId(id), user.id, body.trip_id)); + } + + @Post('candidates/:id/undo') + @HttpCode(200) + @ResponseContract(mailIngestCandidateResponseSchema) + async undo( + @CurrentUser() user: User, + @Param('id') id: string, + @Body() body: MailIngestUndoDto, + ): Promise { + return this.answer(await this.actions.undo(entryId(id), user.id, body.confirm === true)); + } + + @Post('candidates/:id/discard') + @HttpCode(200) + @ResponseContract(mailIngestCandidateResponseSchema) + async discard(@CurrentUser() user: User, @Param('id') id: string): Promise { + return this.answer(await this.actions.discard(entryId(id), user.id)); + } + + /** + * An .eml file, read as if it had been forwarded to the person's own + * address. Checked in the handler, never by a guard: an answer sent while + * the client still uploads reaches it as a reset connection. + */ + @Post('upload') + @UseInterceptors(FileInterceptor('file', UPLOAD)) + @ResponseContract(mailIngestUploadResponseSchema) + async upload( + @CurrentUser() user: User, + @UploadedFile() file: Express.Multer.File | undefined, + ): Promise { + if (!file) throw new DomainError(400, 'No file uploaded'); + if (extname(file.originalname).toLowerCase() !== '.eml' && file.mimetype !== 'message/rfc822') { + throw new DomainError(400, 'Upload a mail saved as an .eml file'); + } + return this.inbox.upload(user.id, file.buffer); + } + + private async answer(row: MailIngestCandidateRow): Promise { + return { candidate: await this.inbox.entry(row) }; + } +} + +/** An id from the path; one that is no id names nothing, like an id nobody holds. */ +function idOf(raw: string, missing: string): number { + const parsed = idParamSchema.safeParse(raw); + if (!parsed.success) throw new DomainError(404, missing); + return parsed.data; +} + +function entryId(raw: string): number { + return idOf(raw, 'Entry not found'); +} diff --git a/server/src/nest/mail-ingest/mail-ingest.dto.ts b/server/src/nest/mail-ingest/mail-ingest.dto.ts new file mode 100644 index 000000000..760ffd0eb --- /dev/null +++ b/server/src/nest/mail-ingest/mail-ingest.dto.ts @@ -0,0 +1,17 @@ +import { + mailIngestApplyRequestSchema, + mailIngestFileRequestSchema, + mailIngestInboxQuerySchema, + mailIngestMoveRequestSchema, + mailIngestPrefsUpdateRequestSchema, + mailIngestUndoRequestSchema, +} from '@trek/shared'; + +import { createZodDto } from 'nestjs-zod'; + +export class MailIngestPrefsUpdateDto extends createZodDto(mailIngestPrefsUpdateRequestSchema) {} +export class MailIngestInboxQueryDto extends createZodDto(mailIngestInboxQuerySchema) {} +export class MailIngestApplyDto extends createZodDto(mailIngestApplyRequestSchema) {} +export class MailIngestMoveDto extends createZodDto(mailIngestMoveRequestSchema) {} +export class MailIngestFileDto extends createZodDto(mailIngestFileRequestSchema) {} +export class MailIngestUndoDto extends createZodDto(mailIngestUndoRequestSchema) {} diff --git a/server/src/nest/mail-ingest/mail-ingest.mcp.ts b/server/src/nest/mail-ingest/mail-ingest.mcp.ts new file mode 100644 index 000000000..9c6ab292e --- /dev/null +++ b/server/src/nest/mail-ingest/mail-ingest.mcp.ts @@ -0,0 +1,131 @@ +import type { MailIngestCandidateRow } from '../../db/repositories/MailIngestCandidates.repository'; +import { + McpController, + Tool, + TOOL_ANNOTATIONS_DELETE, + TOOL_ANNOTATIONS_NON_IDEMPOTENT, + TOOL_ANNOTATIONS_READONLY, + TOOL_ANNOTATIONS_WRITE, + ok, + type McpContext, +} from '../../nest-mcp'; +import { MailIngestActionsService } from './mail-ingest-actions.service'; +import { MailIngestInboxService } from './mail-ingest-inbox.service'; +import { + idSchema, + mailIngestApplyRequestSchema, + mailIngestFileRequestSchema, + mailIngestInboxQuerySchema, + mailIngestMoveRequestSchema, + mailIngestPrefsUpdateRequestSchema, + mailIngestUndoRequestSchema, + type MailIngestInboxQuery, + type MailIngestPrefsUpdateRequest, +} from '@trek/shared'; + +const candidateId = idSchema.describe('The inbox entry, as list_mail_inbox names it (entries[].id).'); + +/** + * The booking mail import as MCP tools: the inbox of bookings read from + * forwarded mails, and what a person does with an entry, over the same + * services and checks as /api/mail-ingest. A forwarding address carries its + * secret token, so no tool reads or makes one; addresses stay in the app. + */ +@McpController() +export class MailIngestMcp { + constructor( + private readonly inbox: MailIngestInboxService, + private readonly actions: MailIngestActionsService, + ) {} + + @Tool({ + name: 'list_mail_inbox', + description: + 'List the bookings read from mails the user forwarded to TREK: by default those that wait for a decision (status "open"), with why each one waits (reason), the booking as read (item), the trip it suggests and the mail it came from (subject, received_at, attachments), newest first. status "resolved" lists the decided ones; an added booking names reservation_ids and, while it can still be undone or moved, undo_until. Page with before = next_before.', + inputSchema: mailIngestInboxQuerySchema.shape, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'reservations', mode: 'read' }, + }) + async listMailInbox(query: MailIngestInboxQuery, ctx: McpContext) { + return ok(await this.inbox.inbox(ctx.userId, query)); + } + + @Tool({ + name: 'add_mail_booking', + description: + 'Add a waiting booking from the mail inbox to a trip: to the trip it suggests, or with trip_id to another trip the user may add bookings to. The booking is written with what the mail said (times, places, reference, cost) and the mail is attached as its document; it can be undone for seven days.', + inputSchema: { candidateId, ...mailIngestApplyRequestSchema.shape }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: { group: 'reservations', mode: 'write' }, + }) + async addMailBooking({ candidateId: id, trip_id }: { candidateId: number; trip_id?: number }, ctx: McpContext) { + return this.answer(await this.actions.applyToTrip(id, ctx.userId, trip_id ?? null)); + } + + @Tool({ + name: 'move_mail_booking', + description: + 'Move a booking that was added from a mail into another trip (trip_id), while it can still be undone. The booking is written there anew and removed from the first trip. One whose cost was settled with, or that was edited since, is refused with needs_confirmation until confirm is true; the edit is lost.', + inputSchema: { candidateId, ...mailIngestMoveRequestSchema.shape }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: { group: 'reservations', mode: 'write' }, + }) + async moveMailBooking( + { candidateId: id, trip_id, confirm }: { candidateId: number; trip_id: number; confirm?: boolean }, + ctx: McpContext, + ) { + return this.answer(await this.actions.move(id, ctx.userId, trip_id, confirm === true)); + } + + @Tool({ + name: 'file_mail_booking', + description: + 'Put the mail of a waiting entry into a trip (trip_id) as documents, its attachments or else its text, without a booking. Needs the Documents addon.', + inputSchema: { candidateId, ...mailIngestFileRequestSchema.shape }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: { group: 'reservations', mode: 'write' }, + }) + async fileMailBooking({ candidateId: id, trip_id }: { candidateId: number; trip_id: number }, ctx: McpContext) { + return this.answer(await this.actions.fileToTrip(id, ctx.userId, trip_id)); + } + + @Tool({ + name: 'discard_mail_booking', + description: 'Set a waiting entry of the mail inbox aside without adding anything.', + inputSchema: { candidateId }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'reservations', mode: 'write' }, + }) + async discardMailBooking({ candidateId: id }: { candidateId: number }, ctx: McpContext) { + return this.answer(await this.actions.discard(id, ctx.userId)); + } + + @Tool({ + name: 'undo_mail_booking', + description: + 'Undo a booking that was added from a mail, within seven days: the booking, its stay, its cost and the documents it brought go. One whose cost was settled with, or that was edited since, is refused with needs_confirmation until confirm is true.', + inputSchema: { candidateId, ...mailIngestUndoRequestSchema.shape }, + annotations: TOOL_ANNOTATIONS_DELETE, + access: { group: 'reservations', mode: 'write' }, + }) + async undoMailBooking({ candidateId: id, confirm }: { candidateId: number; confirm?: boolean }, ctx: McpContext) { + return this.answer(await this.actions.undo(id, ctx.userId, confirm === true)); + } + + @Tool({ + name: 'update_mail_import_settings', + description: + "Change the user's own switches of the mail import: enabled (whether their address takes mail), ai_enabled (whether an AI reads their mails) and auto_add (add a booking that passes every check on its own, or ask first). Name at least one.", + inputSchema: mailIngestPrefsUpdateRequestSchema, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'reservations', mode: 'write' }, + }) + async updateMailImportSettings(update: MailIngestPrefsUpdateRequest, ctx: McpContext) { + const { available, prefs, ai_available, pending } = await this.inbox.updatePrefs(ctx.userId, update); + return ok({ available, prefs, ai_available, pending }); + } + + private async answer(row: MailIngestCandidateRow) { + return ok({ candidate: await this.inbox.entry(row) }); + } +} diff --git a/server/src/nest/mail-ingest/mail-ingest.module.ts b/server/src/nest/mail-ingest/mail-ingest.module.ts index ddeb063a9..e8dda5cff 100644 --- a/server/src/nest/mail-ingest/mail-ingest.module.ts +++ b/server/src/nest/mail-ingest/mail-ingest.module.ts @@ -20,6 +20,7 @@ import { SchedulingModule } from '../scheduling/scheduling.module'; import { SettingsModule } from '../settings/settings.module'; import { MailIngestActionsService } from './mail-ingest-actions.service'; import { MailIngestAddressService } from './mail-ingest-address.service'; +import { MailIngestAdminController } from './mail-ingest-admin.controller'; import { MailIngestApplyService } from './mail-ingest-apply.service'; import { MailIngestImapClient } from './mail-ingest-imap.client'; import { MailIngestImapJob } from './mail-ingest-imap.job'; @@ -36,8 +37,11 @@ import { MailIngestRelayHost } from './mail-ingest-relay.service'; import { MailIngestRetentionJob } from './mail-ingest-retention.job'; import { MailIngestSettingsService } from './mail-ingest-settings.service'; import { MailIngestStatusService } from './mail-ingest-status.service'; +import { MailIngestTripController } from './mail-ingest-trip.controller'; import { MailIngestTripsService } from './mail-ingest-trips.service'; import { MailIngestWebhookService } from './mail-ingest-webhook.service'; +import { MailIngestController } from './mail-ingest.controller'; +import { MailIngestMcp } from './mail-ingest.mcp'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { Module } from '@nestjs/common'; @@ -76,7 +80,12 @@ import { Module } from '@nestjs/common'; MailIngestCandidates, ]), ], - controllers: [MailIngestInboundController], + controllers: [ + MailIngestController, + MailIngestTripController, + MailIngestAdminController, + MailIngestInboundController, + ], providers: [ MailIngestSettingsService, MailIngestTripsService, @@ -92,6 +101,7 @@ import { Module } from '@nestjs/common'; MailIngestWebhookService, MailIngestImapClient, MailIngestRelayHost, + MailIngestMcp, MailIngestNotificationActions, MailIngestProcessJob, MailIngestRetentionJob, diff --git a/server/tests/e2e/mail-ingest.e2e.test.ts b/server/tests/e2e/mail-ingest.e2e.test.ts new file mode 100644 index 000000000..c1087edd7 --- /dev/null +++ b/server/tests/e2e/mail-ingest.e2e.test.ts @@ -0,0 +1,278 @@ +/** + * /api/mail-ingest e2e: the real JwtAuthGuard, TripAccessGuard, AdminGuard, + * Zod pipe, multer and response contracts over a snapshot SQLite, with + * MailIngestModule's real repositories and services. Replaced: what reaches + * outside (the AI reading, kitinerary, file storage, geocoding, the + * notifications). A mail goes in by upload and by the signed webhook, is + * read by the real pipeline, and is added, undone and refused through the + * routes the client and the admin dialog use. + */ +import { db } from '../../src/db/database'; +import { Reservations } from '../../src/db/entities/Reservations.entity'; +import { importExternalId } from '../../src/nest/booking-import/booking-origin'; +import { BookingImportService } from '../../src/nest/booking-import/booking-import.service'; +import { encrypt_api_key } from '../../src/nest/common/crypto/apiKeyCrypto'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { FilesService } from '../../src/nest/files/files.service'; +import { MailReadService } from '../../src/nest/llm-parse/mail-read.service'; +import { walkMail } from '../../src/nest/llm-parse/mime-walker'; +import { MailIngestProcessJob } from '../../src/nest/mail-ingest/mail-ingest-process.job'; +import { signWebhookMail } from '../../src/nest/mail-ingest/mail-ingest-webhook.service'; +import { MailIngestModule } from '../../src/nest/mail-ingest/mail-ingest.module'; +import { MapsService } from '../../src/nest/maps/maps.service'; +import { NotificationsService } from '../../src/nest/notifications/notifications.service'; +import { countRows } from '../helpers/factories/rows'; +import { setAppSetting } from '../helpers/factories/settings'; +import { makeTrip } from '../helpers/factories/trips'; +import { makeAdmin, makeUser } from '../helpers/factories/users'; +import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; +import { sessionCookie } from './harness'; +import { Test } from '@nestjs/testing'; +import type { BookingImportPreviewItem } from '@trek/shared'; + +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/db/database', async () => { + const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); + return buildDbMock(createSnapshotTestDb()); +}); + +const USER = 1; +const OTHER = 2; +const ADMIN = 3; +const SECRET = 'w'.repeat(40); +const TEMPLATE = 'trek+{token}@mail.example.com'; + +/** A day `days` from today: the checks take a booking only within a few years of now. */ +const dayFromToday = (days: number) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10); +const FLIGHT_DAY = dayFromToday(30); + +const endpoint = (role: 'from' | 'to', name: string, code: string, lat: number, lng: number) => ({ + role, + sequence: role === 'from' ? 0 : 1, + name, + code, + lat, + lng, + timezone: null, + local_time: null, + local_date: null, +}); +const FLIGHT: BookingImportPreviewItem = { + type: 'flight', + title: 'LH 716 Tokyo to Osaka', + reservation_time: `${FLIGHT_DAY}T10:00`, + reservation_end_time: `${FLIGHT_DAY}T11:15`, + confirmation_number: 'ABC123', + metadata: { flight_number: 'LH716' }, + endpoints: [ + endpoint('from', 'Tokyo Haneda', 'HND', 35.55, 139.78), + endpoint('to', 'Osaka Itami', 'ITM', 34.78, 135.44), + ], + source: { fileName: 'mail.eml', index: 0 }, +}; + +const bookingImport = { + readMailBookings: vi.fn(async (): Promise => [FLIGHT]), + mapAiReservations: vi.fn(async (): Promise => []), + externalKey: (item: BookingImportPreviewItem) => importExternalId(item), +}; +const reader = { walk: (raw: Buffer) => walkMail(raw), isAvailable: vi.fn(async () => false), read: vi.fn() }; + +function eml(id: string, to = 'trips@example.com'): Buffer { + return Buffer.from( + [ + 'From: Mia ', + `To: ${to}`, + 'Subject: Fwd: Your flight LH 716', + `Message-ID: <${id}@example.com>`, + 'Date: Mon, 1 Jan 2030 10:00:00 +0000', + 'Content-Type: text/plain; charset=utf-8', + '', + `Booking reference ABC123. Flight LH 716 from Tokyo to Osaka on ${FLIGHT_DAY}.`, + ].join('\r\n'), + ); +} + +describe('/api/mail-ingest e2e (real guards + snapshot SQLite)', () => { + let orm: TestOrm; + let server: Server; + let app: Awaited>; + let tripId: number; + + async function build() { + const moduleRef = await Test.createTestingModule({ + imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), MailIngestModule], + }) + .overrideProvider(BookingImportService) + .useValue(bookingImport) + .overrideProvider(MailReadService) + .useValue(reader) + .overrideProvider(FilesService) + .useValue({ createFileFromBytes: vi.fn(async () => ({ id: 77 })), softDeleteFile: vi.fn() }) + .overrideProvider(MapsService) + .useValue({ geocodeQuery: vi.fn(async () => null) }) + .overrideProvider(NotificationsService) + .useValue({ send: vi.fn(async () => ({ attempted: 0, delivered: 0 })), registerResponseAction: vi.fn() }) + .compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalFilters(new TrekExceptionFilter()); + nest.useGlobalPipes(new ZodValidationPipe()); + await nest.init(); + return nest; + } + + const as = (userId: number) => ({ Cookie: sessionCookie(userId) }); + + /** Runs the pipeline over what came in, the way its two-minute job does. */ + const readQueued = () => app.get(MailIngestProcessJob).tick(); + + beforeAll(async () => { + orm = await createTestOrm(db); + await makeUser(orm, { id: USER, username: 'mia', email: 'mia@example.com' }); + await makeUser(orm, { id: OTHER, username: 'noa', email: 'noa@example.com' }); + await makeAdmin(orm, { id: ADMIN, username: 'admin', email: 'admin@example.com' }); + const dates = { start_date: dayFromToday(20), end_date: dayFromToday(40) }; + tripId = (await makeTrip(orm, USER, { title: 'Japan', ...dates })).id; + await setAppSetting(orm, 'mail_ingest_enabled', 'true'); + await setAppSetting(orm, 'mail_ingest_address_template', TEMPLATE); + app = await build(); + server = app.getHttpServer(); + }); + + afterAll(async () => { + await app.close(); + await orm.close(); + }); + + it('MAIL-E2E-001: 401 without a session on every route a person or an admin uses', async () => { + for (const path of ['/api/mail-ingest/settings', '/api/mail-ingest/inbox', '/api/admin/mail-ingest/status']) { + expect((await request(server).get(path)).status, path).toBe(401); + } + expect((await request(server).post(`/api/trips/${tripId}/mail-ingest/address`)).status).toBe(401); + }); + + it('MAIL-E2E-002: the settings card, its switches and a renewed own address', async () => { + const card = await request(server).get('/api/mail-ingest/settings').set(as(USER)); + expect(card.status).toBe(200); + expect(card.body).toMatchObject({ available: true, address: null, ai_available: false, pending: 0 }); + + expect((await request(server).patch('/api/mail-ingest/settings').set(as(USER)).send({})).status).toBe(400); + const off = await request(server).patch('/api/mail-ingest/settings').set(as(USER)).send({ auto_add: false }); + expect(off.status).toBe(200); + expect(off.body.prefs).toEqual({ enabled: true, ai_enabled: true, auto_add: false }); + + const renewed = await request(server).post('/api/mail-ingest/address/renew').set(as(USER)); + expect(renewed.status).toBe(200); + expect(renewed.body.address.address).toMatch(/^trek\+[a-z2-7]{24}@mail\.example\.com$/); + }); + + it('MAIL-E2E-003: an uploaded mail waits in the inbox, is added by hand and undone, for its person', async () => { + const job = app.get(MailIngestProcessJob); + const tick = vi.spyOn(job, 'tick').mockResolvedValueOnce(0); + const upload = await request(server) + .post('/api/mail-ingest/upload') + .set(as(USER)) + .attach('file', eml('upload-1'), { filename: 'booking.eml', contentType: 'message/rfc822' }); + expect(upload.status).toBe(201); + expect(upload.body).toMatchObject({ duplicate: false, message: { transport: 'upload', status: 'queued' } }); + expect(tick).toHaveBeenCalledTimes(1); + tick.mockRestore(); + expect(await readQueued()).toBe(1); + + const inbox = await request(server).get('/api/mail-ingest/inbox').set(as(USER)); + expect(inbox.status).toBe(200); + expect(inbox.body.pending).toBe(1); + const [entry] = inbox.body.entries; + expect(entry).toMatchObject({ reason: 'ask_first', suggested_trip: { id: tripId, title: 'Japan' } }); + expect((await request(server).get('/api/mail-ingest/inbox/count').set(as(USER))).body).toEqual({ pending: 1 }); + + const apply = (userId: number) => + request(server).post(`/api/mail-ingest/candidates/${entry.id}/apply`).set(as(userId)).send({}); + expect((await apply(OTHER)).status).toBe(404); + const applied = await apply(USER); + expect(applied.status).toBe(200); + expect(applied.body.candidate).toMatchObject({ resolution: 'applied', resolved_trip_id: tripId }); + expect(await countRows(orm, Reservations, { trip: tripId })).toBe(1); + + const undone = await request(server).post(`/api/mail-ingest/candidates/${entry.id}/undo`).set(as(USER)).send({}); + expect(undone.status).toBe(200); + expect(undone.body.candidate.resolution).toBe('undone'); + expect(await countRows(orm, Reservations, { trip: tripId })).toBe(0); + + const preview = (userId: number) => + request(server).get(`/api/mail-ingest/messages/${entry.message_id}/preview`).set(as(userId)); + const shown = await preview(USER); + expect(shown.status).toBe(200); + expect(shown.body.text).toContain('Booking reference ABC123'); + expect((await preview(OTHER)).status).toBe(404); + }); + + it('MAIL-E2E-004: an upload that is no mail, and a closed body, are refused', async () => { + const pdf = await request(server) + .post('/api/mail-ingest/upload') + .set(as(USER)) + .attach('file', Buffer.from('%PDF-1.4'), { filename: 'ticket.pdf', contentType: 'application/pdf' }); + expect(pdf.status).toBe(400); + const extra = await request(server).post('/api/mail-ingest/candidates/1/undo').set(as(USER)).send({ token: 'x' }); + expect(extra.status).toBe(400); + }); + + it('MAIL-E2E-005: a trip address is made by a member who may add bookings, and hidden from others', async () => { + const path = `/api/trips/${tripId}/mail-ingest/address`; + const made = await request(server).post(path).set(as(USER)); + expect(made.status).toBe(200); + expect(made.body.address).toMatchObject({ trip_id: tripId }); + expect((await request(server).get(path).set(as(USER))).body).toEqual(made.body); + expect((await request(server).get(path).set(as(OTHER))).status).toBe(404); + expect((await request(server).delete(path).set(as(USER))).body).toEqual({ success: true }); + expect((await request(server).get(path).set(as(USER))).body).toEqual({ address: null }); + }); + + it('MAIL-E2E-006: the status card and the connection test are the admin’s, and hold counts only', async () => { + expect((await request(server).get('/api/admin/mail-ingest/status').set(as(USER))).status).toBe(403); + const status = await request(server).get('/api/admin/mail-ingest/status').set(as(ADMIN)); + expect(status.status).toBe(200); + expect(status.body).toMatchObject({ transport: 'off', last_day: { by_status: { processed: 1 } } }); + expect(JSON.stringify(status.body)).not.toContain('LH 716'); + const test = await request(server).post('/api/admin/mail-ingest/test').set(as(ADMIN)); + expect(test.status).toBe(200); + expect(test.body).toMatchObject({ transport: 'off', ok: false }); + }); + + it('MAIL-E2E-007: the webhook is a 404 until it is the transport, then takes signed mail only', async () => { + const own = await request(server).get('/api/mail-ingest/settings').set(as(USER)); + const address: string = own.body.address.address; + const raw = eml('hook-1', address); + const post = (headers: Record, body: Buffer = raw) => + request(server) + .post('/api/mail-ingest/inbound') + .set({ 'Content-Type': 'message/rfc822', ...headers }) + .send(body); + const signed = (timestamp = String(Math.floor(Date.now() / 1000))) => ({ + 'X-Trek-Timestamp': timestamp, + 'X-Trek-Recipient': address, + 'X-Trek-Signature': `sha256=${signWebhookMail(SECRET, timestamp, address, raw)}`, + }); + + expect((await post(signed())).status).toBe(404); + await setAppSetting(orm, 'mail_ingest_transport', 'webhook'); + await setAppSetting(orm, 'mail_ingest_webhook_secret', encrypt_api_key(SECRET)); + + expect((await post({})).status).toBe(401); + expect((await post(signed(String(Math.floor(Date.now() / 1000) - 3600)))).status).toBe(401); + const json = await request(server).post('/api/mail-ingest/inbound').set(signed()).send({ raw: 'x' }); + expect(json.status).toBe(415); + + const taken = await post(signed()); + expect(taken.status).toBe(200); + expect(taken.body).toEqual({ accepted: true, duplicate: false }); + expect((await post(signed())).body).toEqual({ accepted: true, duplicate: true }); + }); +}); diff --git a/server/tests/unit/nest/mail-ingest/mail-ingest.controller.test.ts b/server/tests/unit/nest/mail-ingest/mail-ingest.controller.test.ts new file mode 100644 index 000000000..6d8fbcc60 --- /dev/null +++ b/server/tests/unit/nest/mail-ingest/mail-ingest.controller.test.ts @@ -0,0 +1,303 @@ +/** + * The mail import's REST and MCP adapters over stub services: every route and + * its tool hand the same arguments to the same service method, answer the + * same shape, and refuse with the service's own error. The webhook route + * reads the raw body itself, up to the import's limit, after the transport + * said it is open. + */ +import type { MailIngestCandidateRow } from '../../../../src/db/repositories/MailIngestCandidates.repository'; +import type { McpContext } from '../../../../src/nest-mcp'; +import { DomainError } from '../../../../src/nest/common/domain-error'; +import type { MailIngestActionsService } from '../../../../src/nest/mail-ingest/mail-ingest-actions.service'; +import { MailIngestAdminController } from '../../../../src/nest/mail-ingest/mail-ingest-admin.controller'; +import { MailIngestInboundController } from '../../../../src/nest/mail-ingest/mail-ingest-inbound.controller'; +import type { MailIngestInboxService } from '../../../../src/nest/mail-ingest/mail-ingest-inbox.service'; +import type { MailIngestStatusService } from '../../../../src/nest/mail-ingest/mail-ingest-status.service'; +import { MailIngestTripController } from '../../../../src/nest/mail-ingest/mail-ingest-trip.controller'; +import type { MailIngestWebhookService } from '../../../../src/nest/mail-ingest/mail-ingest-webhook.service'; +import { MailIngestController } from '../../../../src/nest/mail-ingest/mail-ingest.controller'; +import { MailIngestMcp } from '../../../../src/nest/mail-ingest/mail-ingest.mcp'; +import type { User } from '../../../../src/types'; +import { HttpException } from '@nestjs/common'; +import { HTTP_CODE_METADATA } from '@nestjs/common/constants'; + +import type { Request } from 'express'; +import { PassThrough } from 'node:stream'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const user = { id: 7 } as User; +const ctx = { userId: 7 } as McpContext; +const row = { id: 3, user_id: 7 } as MailIngestCandidateRow; +const view = { id: 3, resolution: 'applied' }; + +const inbox = { + settingsOf: vi.fn(), + updatePrefs: vi.fn(), + renewOwnAddress: vi.fn(), + tripAddress: vi.fn(), + renewTripAddress: vi.fn(), + revokeTripAddress: vi.fn(), + inbox: vi.fn(), + count: vi.fn(), + preview: vi.fn(), + upload: vi.fn(), + entry: vi.fn(), +}; +const actions = { + applyToTrip: vi.fn(), + move: vi.fn(), + fileToTrip: vi.fn(), + undo: vi.fn(), + discard: vi.fn(), + prefill: vi.fn(), +}; + +const rest = () => + new MailIngestController(inbox as unknown as MailIngestInboxService, actions as unknown as MailIngestActionsService); +const mcp = () => + new MailIngestMcp(inbox as unknown as MailIngestInboxService, actions as unknown as MailIngestActionsService); + +/** The JSON a tool answered with. */ +function read(result: { content: Array<{ type: string; text: string }> }): unknown { + return JSON.parse(result.content[0].text); +} + +async function thrown(fn: () => Promise): Promise<{ status: number; body: unknown }> { + try { + await fn(); + } catch (err) { + expect(err).toBeInstanceOf(HttpException); + return { status: (err as HttpException).getStatus(), body: (err as HttpException).getResponse() }; + } + throw new Error('expected the handler to throw'); +} + +const statusOf = (proto: object, name: string): number | undefined => + Reflect.getMetadata(HTTP_CODE_METADATA, (proto as Record)[name] as object) as number | undefined; + +beforeEach(() => { + vi.clearAllMocks(); + inbox.entry.mockResolvedValue(view); + for (const action of [actions.applyToTrip, actions.move, actions.fileToTrip, actions.undo, actions.discard]) { + action.mockResolvedValue(row); + } +}); + +describe('the settings card and the inbox', () => { + it('answers each read from the inbox service for the signed-in person', async () => { + inbox.settingsOf.mockResolvedValue({ available: true }); + inbox.inbox.mockResolvedValue({ entries: [] }); + inbox.count.mockResolvedValue({ pending: 2 }); + inbox.preview.mockResolvedValue({ id: 9 }); + expect(await rest().settings(user)).toEqual({ available: true }); + expect(await rest().list(user, { status: 'open', limit: 50 })).toEqual({ entries: [] }); + expect(inbox.inbox).toHaveBeenCalledWith(7, { status: 'open', limit: 50 }); + expect(await rest().count(user)).toEqual({ pending: 2 }); + expect(await rest().preview(user, '9')).toEqual({ id: 9 }); + expect(inbox.preview).toHaveBeenCalledWith(7, 9); + }); + + it('writes the switches and renews the own address, the renewal answering 200', async () => { + inbox.updatePrefs.mockResolvedValue({ available: true }); + inbox.renewOwnAddress.mockResolvedValue({ address: null }); + await rest().updateSettings(user, { auto_add: false }); + expect(inbox.updatePrefs).toHaveBeenCalledWith(7, { auto_add: false }); + expect(await rest().renewAddress(user)).toEqual({ address: null }); + expect(statusOf(MailIngestController.prototype, 'renewAddress')).toBe(200); + }); + + it('answers 404 for a path id that is no id, without asking the services', async () => { + expect(await thrown(() => rest().preview(user, 'abc'))).toEqual({ status: 404, body: { error: 'Mail not found' } }); + expect(await thrown(() => rest().discard(user, '0'))).toEqual({ status: 404, body: { error: 'Entry not found' } }); + expect(inbox.preview).not.toHaveBeenCalled(); + expect(actions.discard).not.toHaveBeenCalled(); + }); +}); + +describe('acting on an entry, over REST and MCP alike', () => { + it('apply and add_mail_booking: the suggested trip without trip_id, the picked one with it', async () => { + expect(await rest().apply(user, '3', {})).toEqual({ candidate: view }); + expect(actions.applyToTrip).toHaveBeenLastCalledWith(3, 7, null); + await rest().apply(user, '3', { trip_id: 12 }); + expect(actions.applyToTrip).toHaveBeenLastCalledWith(3, 7, 12); + expect(read(await mcp().addMailBooking({ candidateId: 3, trip_id: 12 }, ctx))).toEqual({ candidate: view }); + expect(actions.applyToTrip).toHaveBeenLastCalledWith(3, 7, 12); + expect(inbox.entry).toHaveBeenCalledWith(row); + }); + + it('move, file, undo and discard reach the same action with the same arguments from both sides', async () => { + await rest().move(user, '3', { trip_id: 5 }); + await mcp().moveMailBooking({ candidateId: 3, trip_id: 5 }, ctx); + expect(actions.move.mock.calls).toEqual([ + [3, 7, 5, false], + [3, 7, 5, false], + ]); + await rest().file(user, '3', { trip_id: 5 }); + await mcp().fileMailBooking({ candidateId: 3, trip_id: 5 }, ctx); + expect(actions.fileToTrip.mock.calls).toEqual([ + [3, 7, 5], + [3, 7, 5], + ]); + await rest().undo(user, '3', { confirm: true }); + await mcp().undoMailBooking({ candidateId: 3, confirm: true }, ctx); + expect(actions.undo.mock.calls).toEqual([ + [3, 7, true], + [3, 7, true], + ]); + await rest().discard(user, '3'); + await mcp().discardMailBooking({ candidateId: 3 }, ctx); + expect(actions.discard.mock.calls).toEqual([ + [3, 7], + [3, 7], + ]); + for (const name of ['apply', 'move', 'file', 'undo', 'discard']) { + expect(statusOf(MailIngestController.prototype, name), name).toBe(200); + } + }); + + it('passes the service’s refusal on as it stands: the undo’s 409 with what it would touch', async () => { + const conflict = new DomainError(409, 'Undoing this booking changes more than the booking', { + details: { code: 'needs_confirmation', affects: ['balances'] }, + }); + actions.undo.mockRejectedValue(conflict); + expect(await thrown(() => rest().undo(user, '3', {}))).toEqual({ + status: 409, + body: { + error: 'Undoing this booking changes more than the booking', + code: 'needs_confirmation', + affects: ['balances'], + }, + }); + await expect(mcp().undoMailBooking({ candidateId: 3 }, ctx)).rejects.toBe(conflict); + }); + + it('prefills the form, and the MCP switches answer without the address and its token', async () => { + actions.prefill.mockResolvedValue({ trip_id: null, item: { type: 'flight' } }); + expect(await rest().prefill(user, '3')).toEqual({ trip_id: null, item: { type: 'flight' } }); + inbox.updatePrefs.mockResolvedValue({ + available: true, + address: { id: 1, address: 'trek+secrettoken@example.com' }, + prefs: { enabled: true, ai_enabled: false, auto_add: true }, + ai_available: false, + pending: 0, + }); + const answer = read(await mcp().updateMailImportSettings({ ai_enabled: false }, ctx)); + expect(answer).toEqual({ + available: true, + prefs: { enabled: true, ai_enabled: false, auto_add: true }, + ai_available: false, + pending: 0, + }); + expect(JSON.stringify(answer)).not.toContain('secrettoken'); + }); + + it('list_mail_inbox reads the same page the REST inbox reads', async () => { + inbox.inbox.mockResolvedValue({ entries: [], pending: 0, next_before: null }); + expect(read(await mcp().listMailInbox({ status: 'resolved', limit: 10 }, ctx))).toEqual({ + entries: [], + pending: 0, + next_before: null, + }); + expect(inbox.inbox).toHaveBeenCalledWith(7, { status: 'resolved', limit: 10 }); + }); +}); + +describe('POST /api/mail-ingest/upload', () => { + const file = (originalname: string, mimetype = 'application/octet-stream') => + ({ originalname, mimetype, buffer: Buffer.from('mail') }) as Express.Multer.File; + + it('takes an .eml (or a message/rfc822 file) and refuses anything else in the handler', async () => { + inbox.upload.mockResolvedValue({ duplicate: false }); + expect(await rest().upload(user, file('booking.EML'))).toEqual({ duplicate: false }); + expect(inbox.upload).toHaveBeenCalledWith(7, Buffer.from('mail')); + await rest().upload(user, file('mail', 'message/rfc822')); + expect(await thrown(() => rest().upload(user, undefined))).toEqual({ + status: 400, + body: { error: 'No file uploaded' }, + }); + expect(await thrown(() => rest().upload(user, file('ticket.pdf', 'application/pdf')))).toEqual({ + status: 400, + body: { error: 'Upload a mail saved as an .eml file' }, + }); + }); +}); + +describe('the trip address and the admin routes', () => { + it('reads, renews and revokes the person’s address of the trip', async () => { + inbox.tripAddress.mockResolvedValue({ address: null }); + inbox.renewTripAddress.mockResolvedValue({ address: null }); + const trips = new MailIngestTripController(inbox as unknown as MailIngestInboxService); + expect(await trips.get(user, '4')).toEqual({ address: null }); + expect(inbox.tripAddress).toHaveBeenCalledWith(7, 4); + await trips.renew(user, '4'); + expect(inbox.renewTripAddress).toHaveBeenCalledWith(7, 4); + expect(await trips.revoke(user, '4')).toEqual({ success: true }); + expect(inbox.revokeTripAddress).toHaveBeenCalledWith(7, 4); + expect(statusOf(MailIngestTripController.prototype, 'renew')).toBe(200); + }); + + it('answers the status card and the connection test from the status service', async () => { + const status = { status: vi.fn().mockResolvedValue({ transport: 'off' }), test: vi.fn() }; + status.test.mockResolvedValue({ transport: 'off', ok: false, error: 'No transport is set' }); + const admin = new MailIngestAdminController(status as unknown as MailIngestStatusService); + expect(await admin.read()).toEqual({ transport: 'off' }); + expect(await admin.test()).toEqual({ transport: 'off', ok: false, error: 'No transport is set' }); + expect(statusOf(MailIngestAdminController.prototype, 'test')).toBe(200); + }); +}); + +describe('POST /api/mail-ingest/inbound', () => { + const webhook = { + secret: vi.fn(), + maxBytes: vi.fn(() => 10), + receive: vi.fn(), + }; + const controller = () => new MailIngestInboundController(webhook as unknown as MailIngestWebhookService); + + /** A request whose body arrives as a stream, the way Express hands an unparsed one on. */ + function request(body: string, headers: Record): Request { + const stream = new PassThrough(); + const lower = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])); + Object.assign(stream, { + get: (name: string) => lower[name.toLowerCase()], + is: (type: string) => (lower['content-type'] === type ? type : false), + }); + setImmediate(() => stream.end(body)); + return stream as unknown as Request; + } + + beforeEach(() => { + webhook.secret.mockResolvedValue('s3cret'); + webhook.receive.mockResolvedValue({ accepted: true, duplicate: false }); + }); + + it('reads the raw mail and hands it with the signed headers to the webhook service', async () => { + const req = request('Subject: x', { + 'content-type': 'message/rfc822', + 'x-trek-timestamp': '1700000000', + 'x-trek-signature': 'sha256=ab', + 'x-trek-recipient': 'trek+t@example.com', + }); + expect(await controller().receive(req)).toEqual({ accepted: true, duplicate: false }); + expect(webhook.receive).toHaveBeenCalledWith('s3cret', Buffer.from('Subject: x'), { + timestamp: '1700000000', + signature: 'sha256=ab', + recipient: 'trek+t@example.com', + }); + }); + + it('is a 404 while the webhook is closed, and refuses a body that is no raw mail or too large', async () => { + webhook.secret.mockRejectedValueOnce(new DomainError(404, 'Not found')); + const closed = await thrown(() => controller().receive(request('x', { 'content-type': 'message/rfc822' }))); + expect(closed).toEqual({ status: 404, body: { error: 'Not found' } }); + const json = await thrown(() => controller().receive(request('{}', { 'content-type': 'application/json' }))); + expect(json.status).toBe(415); + const large = await thrown(() => + controller().receive(request('Subject: far too long', { 'content-type': 'message/rfc822' })), + ); + expect(large).toEqual({ status: 413, body: { error: 'This mail is larger than the import takes' } }); + const declared = request('x', { 'content-type': 'message/rfc822', 'content-length': '999' }); + expect((await thrown(() => controller().receive(declared))).status).toBe(413); + expect(webhook.receive).not.toHaveBeenCalled(); + }); +}); diff --git a/wiki/MCP-Tools-and-Resources.md b/wiki/MCP-Tools-and-Resources.md index 69fd5dcb2..e58943c45 100644 --- a/wiki/MCP-Tools-and-Resources.md +++ b/wiki/MCP-Tools-and-Resources.md @@ -272,6 +272,25 @@ Accepting an invite has no tool on purpose: joining somebody's trip is a human a | `list_airtrail_flights` | List the flights available to import from a connected AirTrail account. Requires the AirTrail addon. | | `import_airtrail_flights` | Import chosen flights into a trip as transport bookings. | +### Booking mail import + +The bookings read from mails the user forwarded to TREK ([Booking mail import](Booking-Mail-Import)). Requires +`reservations:read`, or `reservations:write` to act on an entry; the import must be switched on for the instance. No +tool reads or makes a forwarding address, since an address carries its secret token. + +| Tool | Description | +|---|---| +| `list_mail_inbox` | The entries waiting for a decision, each with why it waits, the booking as read, the trip it suggests and the mail's subject; `status: "resolved"` lists the decided ones. | +| `add_mail_booking` | Add a waiting booking to the trip it suggests, or to another (`trip_id`). Undoable for seven days. | +| `move_mail_booking` | Move a booking added from a mail into another trip while it can still be undone. | +| `file_mail_booking` | Put the mail into a trip as documents, without a booking. Needs the Documents addon. | +| `discard_mail_booking` | Set a waiting entry aside. | +| `undo_mail_booking` | Remove a booking added from a mail with its stay, cost and documents, within seven days. | +| `update_mail_import_settings` | The user's switches: `enabled`, `ai_enabled`, `auto_add` (add on its own or ask first). | + +A move or an undo of a booking whose cost was settled with, or that was edited since, answers `needs_confirmation` +until it is repeated with `confirm: true`. + ### Photos Requires `journey:read`, or `journey:write` to attach. Needs a configured Immich or Synology Photos provider.