diff --git a/apps/web/app/api/messages/send/route.test.ts b/apps/web/app/api/messages/send/route.test.ts index 0f06af7d25..e81382b772 100644 --- a/apps/web/app/api/messages/send/route.test.ts +++ b/apps/web/app/api/messages/send/route.test.ts @@ -1,38 +1,58 @@ import { NextRequest } from "next/server"; +import { EMAIL_ATTACHMENT_LIMITS } from "@inboxzero/email-editor/core"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + DURABLE_MULTIPART_ATTACHMENT_LIMIT_MESSAGE, + DURABLE_MULTIPART_EMAIL_SEND_LIMITS, +} from "@/utils/email/durable-email-send.validation"; +import { EMAIL_SEND_LIMITS } from "@/utils/types/mail"; import { POST } from "./route"; const executeDurableEmailSend = vi.hoisted(() => vi.fn()); +const findEmailAccount = vi.hoisted(() => vi.fn()); +const createEmailProvider = vi.hoisted(() => vi.fn()); const emailProvider = vi.hoisted(() => ({ name: "google" as const, sendEmailWithHtml: vi.fn(), })); type MockedRequest = NextRequest & { - auth: { emailAccountId: string }; - emailProvider: typeof emailProvider; + auth: { emailAccountId: string; userId: string }; logger: { error: () => void }; }; vi.mock("@/utils/email/durable-email-send", () => ({ executeDurableEmailSend, })); +vi.mock("@/utils/email/provider", () => ({ + createEmailProvider, +})); +vi.mock("@/utils/prisma", () => ({ + default: { emailAccount: { findUnique: findEmailAccount } }, +})); vi.mock("@/utils/middleware", () => ({ - withEmailProvider: + withEmailAccount: (_name: string, handler: (request: MockedRequest) => Promise) => (request: NextRequest) => handler( Object.assign(request, { - auth: { emailAccountId: "account-1" }, - emailProvider, + auth: { emailAccountId: "account-1", userId: "user-1" }, logger: { error: vi.fn() }, }) as MockedRequest, ), })); describe("POST /api/messages/send", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + createEmailProvider.mockResolvedValue(emailProvider); + findEmailAccount.mockResolvedValue({ account: { provider: "google" } }); + executeDurableEmailSend.mockResolvedValue({ + status: "applied", + result: { messageId: "message-1", threadId: "thread-1" }, + }); + }); it("keeps accepting the legacy direct-send payload", async () => { emailProvider.sendEmailWithHtml.mockResolvedValue({ @@ -52,14 +72,29 @@ describe("POST /api/messages/send", () => { threadId: "thread-1", }); expect(emailProvider.sendEmailWithHtml).toHaveBeenCalledOnce(); + expect(createEmailProvider).toHaveBeenCalledOnce(); expect(executeDurableEmailSend).not.toHaveBeenCalled(); }); - it("routes mutation-wrapped sends through the durable operation", async () => { - executeDurableEmailSend.mockResolvedValue({ - status: "applied", - result: { messageId: "message-1", threadId: "thread-1" }, + it("scopes provider lookup to the authenticated account owner", async () => { + emailProvider.sendEmailWithHtml.mockResolvedValue({ + messageId: "message-1", + threadId: "thread-1", }); + + await post({ + to: "recipient@example.com", + subject: "Hello", + messageHtml: "

Hello

", + }); + + expect(findEmailAccount).toHaveBeenCalledWith({ + where: { id: "account-1", userId: "user-1" }, + select: { account: { select: { provider: true } } }, + }); + }); + + it("routes mutation-wrapped sends through the durable operation", async () => { const input = { mutationId: "41ec6d2b-d0e8-4f75-924a-f6f4e5bab4cf", queuedAt: 1_788_000_000_000, @@ -119,8 +154,318 @@ describe("POST /api/messages/send", () => { ).rejects.toThrow(); expect(executeDurableEmailSend).not.toHaveBeenCalled(); }); + + it("assembles multipart attachments before the durable send", async () => { + const input = durableInput([ + { + id: "attachment-1", + filename: "notes.txt", + mimeType: "text/plain", + size: 5, + disposition: "attachment", + }, + ]); + + const response = await postMultipart(input, [ + new File(["hello"], "notes.txt", { type: "text/plain" }), + ]); + + await expect(response.json()).resolves.toEqual({ + status: "applied", + result: { messageId: "message-1", threadId: "thread-1" }, + }); + expect(executeDurableEmailSend).toHaveBeenCalledWith({ + emailAccountId: "account-1", + getEmailProvider: expect.any(Function), + input: { + ...input, + email: { + ...input.email, + attachments: [ + { + id: "attachment-1", + filename: "notes.txt", + contentType: "text/plain", + content: "aGVsbG8=", + size: 5, + disposition: "attachment", + }, + ], + }, + }, + provider: "google", + }); + expect(emailProvider.sendEmailWithHtml).not.toHaveBeenCalled(); + }); + + it("preserves validated inline attachment metadata", async () => { + const png = Buffer.from(PNG_BASE64, "base64"); + const input = durableInput([ + { + id: "inline-1", + filename: "pixel.png", + mimeType: "image/png", + size: png.byteLength, + disposition: "inline", + contentId: "pixel@example", + }, + ]); + + await postMultipart(input, [ + new File([png], "pixel.png", { type: "image/png" }), + ]); + + expect(executeDurableEmailSend).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + email: expect.objectContaining({ + attachments: [ + expect.objectContaining({ + content: PNG_BASE64, + contentId: "pixel@example", + disposition: "inline", + }), + ], + }), + }), + }), + ); + }); + + it("accepts case-insensitive multipart media types", async () => { + const input = durableInput([]); + const encoded = new Request("http://localhost/api/messages/send", { + method: "POST", + body: multipartForm(input, []), + }); + const contentType = encoded.headers + .get("content-type") + ?.replace("multipart/form-data", "Multipart/Form-Data "); + + const response = await POST( + new NextRequest("http://localhost/api/messages/send", { + method: "POST", + body: await encoded.arrayBuffer(), + headers: { "content-type": String(contentType) }, + }), + ); + + await expect(response.json()).resolves.toEqual({ + status: "applied", + result: { messageId: "message-1", threadId: "thread-1" }, + }); + }); + + it.each([ + { + name: "a missing file", + metadata: [attachmentMetadata()], + files: [], + }, + { + name: "an extra file", + metadata: [], + files: [textFile()], + }, + { + name: "a file in the wrong order", + metadata: [ + attachmentMetadata({ filename: "first.txt" }), + attachmentMetadata({ id: "attachment-2", filename: "second.txt" }), + ], + files: [ + new File(["hello"], "second.txt", { type: "text/plain" }), + new File(["hello"], "first.txt", { type: "text/plain" }), + ], + }, + { + name: "a mismatched size", + metadata: [attachmentMetadata({ size: 4 })], + files: [textFile()], + }, + { + name: "a mismatched MIME type", + metadata: [attachmentMetadata({ mimeType: "application/pdf" })], + files: [textFile()], + }, + ])("rejects multipart sends with $name", async ({ metadata, files }) => { + await expect( + postMultipart(durableInput(metadata), files), + ).rejects.toThrow(); + + expect(executeDurableEmailSend).not.toHaveBeenCalled(); + expect(emailProvider.sendEmailWithHtml).not.toHaveBeenCalled(); + }); + + it("rejects invalid inline metadata before sending", async () => { + const input = durableInput([ + attachmentMetadata({ + mimeType: "image/png", + disposition: "inline", + contentId: "unsafe content id", + }), + ]); + + await expect( + postMultipart(input, [ + new File(["hello"], "notes.txt", { type: "image/png" }), + ]), + ).rejects.toThrow("Inline images require a valid Content-ID."); + expect(executeDurableEmailSend).not.toHaveBeenCalled(); + }); + + it("rejects spoofed inline image bytes before sending", async () => { + const input = durableInput([ + attachmentMetadata({ + mimeType: "image/png", + disposition: "inline", + contentId: "pixel@example", + }), + ]); + + await expect( + postMultipart(input, [ + new File(["hello"], "notes.txt", { type: "image/png" }), + ]), + ).rejects.toThrow("Inline image content does not match its file type."); + expect(executeDurableEmailSend).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: "embedded content", + overrides: { content: "aGVsbG8=" }, + }, + { + name: "an invalid disposition", + overrides: { disposition: "preview" }, + }, + { + name: "an invalid MIME type", + overrides: { mimeType: "not-a-mime-type" }, + }, + ])("rejects multipart metadata with $name", async ({ overrides }) => { + await expect( + postMultipart(durableInput([attachmentMetadata(overrides)]), [ + textFile(), + ]), + ).rejects.toThrow(); + expect(executeDurableEmailSend).not.toHaveBeenCalled(); + }); + + it("rejects multipart attachments over the shared total limit", async () => { + const size = Math.floor(EMAIL_ATTACHMENT_LIMITS.maxTotalBytes / 2) + 1; + const input = durableInput([ + attachmentMetadata({ filename: "first.bin", size }), + attachmentMetadata({ id: "attachment-2", filename: "second.bin", size }), + ]); + + await expect(postMultipart(input, [])).rejects.toThrow( + "Attachments must total 15 MB or less.", + ); + expect(executeDurableEmailSend).not.toHaveBeenCalled(); + }); + + it("rejects attachment metadata over the direct mobile limit before provider work", async () => { + const size = + Math.floor(DURABLE_MULTIPART_EMAIL_SEND_LIMITS.maxAttachmentBytes / 2) + + 1; + const input = durableInput([ + attachmentMetadata({ filename: "first.bin", size }), + attachmentMetadata({ id: "attachment-2", filename: "second.bin", size }), + ]); + + await expect(postMultipart(input, [])).rejects.toThrow( + DURABLE_MULTIPART_ATTACHMENT_LIMIT_MESSAGE, + ); + expect(findEmailAccount).not.toHaveBeenCalled(); + expect(createEmailProvider).not.toHaveBeenCalled(); + expect(executeDurableEmailSend).not.toHaveBeenCalled(); + }); + + it("rejects actual attachment bytes over the direct mobile limit before provider work", async () => { + const size = DURABLE_MULTIPART_EMAIL_SEND_LIMITS.maxAttachmentBytes + 1; + const input = durableInput([attachmentMetadata({ size: 1 })]); + + await expect( + postMultipart(input, [ + new File([new Uint8Array(size)], "notes.txt", { + type: "text/plain", + }), + ]), + ).rejects.toThrow(DURABLE_MULTIPART_ATTACHMENT_LIMIT_MESSAGE); + expect(findEmailAccount).not.toHaveBeenCalled(); + expect(createEmailProvider).not.toHaveBeenCalled(); + expect(executeDurableEmailSend).not.toHaveBeenCalled(); + }); + + it("rejects an oversized multipart file before sending", async () => { + const size = EMAIL_ATTACHMENT_LIMITS.maxFileBytes + 1; + const input = durableInput([attachmentMetadata({ size })]); + + await expect( + postMultipart(input, [ + new File([new Uint8Array(size)], "notes.txt", { type: "text/plain" }), + ]), + ).rejects.toThrow("Attachments must be 10 MB or smaller."); + expect(executeDurableEmailSend).not.toHaveBeenCalled(); + }); + + it("rejects multipart requests whose declared request size is too large", async () => { + const formData = multipartForm(durableInput([]), []); + + await expect( + POST( + new NextRequest("http://localhost/api/messages/send", { + method: "POST", + body: formData, + headers: { "content-length": String(25 * 1024 * 1024 + 1) }, + }), + ), + ).rejects.toThrow(); + expect(executeDurableEmailSend).not.toHaveBeenCalled(); + }); + + it("bounds and cancels an oversized multipart body without Content-Length", async () => { + const cancel = vi.fn(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + new Uint8Array(EMAIL_SEND_LIMITS.maxSerializedPayloadBytes + 1), + ); + }, + cancel, + }); + const request = new NextRequest("http://localhost/api/messages/send", { + method: "POST", + body, + duplex: "half", + headers: { + "content-type": "multipart/form-data; boundary=attachment-boundary", + }, + } satisfies RequestInit & { duplex: "half" }); + + await expect(POST(request)).rejects.toThrow( + "The multipart request is too large.", + ); + expect(cancel).toHaveBeenCalledOnce(); + expect(executeDurableEmailSend).not.toHaveBeenCalled(); + }); + + it("bounds the multipart payload form field even without Content-Length", async () => { + const input = durableInput([]); + input.email.to = "a".repeat( + DURABLE_MULTIPART_EMAIL_SEND_LIMITS.maxPayloadBytes + 1, + ); + + await expect(postMultipart(input, [])).rejects.toThrow(); + expect(executeDurableEmailSend).not.toHaveBeenCalled(); + }); }); +const PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + function post(body: unknown) { return POST( new NextRequest("http://localhost/api/messages/send", { @@ -130,3 +475,49 @@ function post(body: unknown) { }), ); } + +function postMultipart(payload: unknown, files: File[]) { + return POST( + new NextRequest("http://localhost/api/messages/send", { + method: "POST", + body: multipartForm(payload, files), + }), + ); +} + +function multipartForm(payload: unknown, files: File[]) { + const formData = new FormData(); + formData.append("payload", JSON.stringify(payload)); + for (const file of files) formData.append("attachment", file); + return formData; +} + +function durableInput(attachments: Record[]) { + return { + mutationId: "41ec6d2b-d0e8-4f75-924a-f6f4e5bab4cf", + queuedAt: 1_788_000_000_000, + threadId: "thread-1", + messageIds: ["message-1"], + email: { + to: "recipient@example.com", + subject: "Re: Hello", + messageHtml: '

Reply

', + attachments, + }, + }; +} + +function attachmentMetadata(overrides: Record = {}) { + return { + id: "attachment-1", + filename: "notes.txt", + mimeType: "text/plain", + size: 5, + disposition: "attachment", + ...overrides, + }; +} + +function textFile() { + return new File(["hello"], "notes.txt", { type: "text/plain" }); +} diff --git a/apps/web/app/api/messages/send/route.ts b/apps/web/app/api/messages/send/route.ts index e5c30084c9..a0e76e3df1 100644 --- a/apps/web/app/api/messages/send/route.ts +++ b/apps/web/app/api/messages/send/route.ts @@ -1,8 +1,17 @@ +import { Buffer } from "node:buffer"; import { NextResponse } from "next/server"; -import { withEmailProvider } from "@/utils/middleware"; -import { sendEmailBody } from "@/utils/types/mail"; +import { z } from "zod"; +import { createEmailProvider } from "@/utils/email/provider"; +import { withEmailAccount } from "@/utils/middleware"; +import prisma from "@/utils/prisma"; +import { EMAIL_SEND_LIMITS, sendEmailBody } from "@/utils/types/mail"; import { executeDurableEmailSend } from "@/utils/email/durable-email-send"; -import { durableEmailSendBody } from "@/utils/email/durable-email-send.validation"; +import { + DURABLE_MULTIPART_ATTACHMENT_LIMIT_MESSAGE, + DURABLE_MULTIPART_EMAIL_SEND_LIMITS, + durableEmailSendBody, + durableMultipartEmailSendPayload, +} from "@/utils/email/durable-email-send.validation"; export type SendMessageResponse = { success: true; @@ -16,26 +25,44 @@ export type DurableSendMessageResponse = Awaited< /** * REST equivalent of `sendEmailAction` for clients that cannot call server - * actions (e.g. the mobile app). Accepts the same `sendEmailBody` payload: - * pass `replyToEmail` (threadId + headerMessageId + references) to reply on - * an existing thread, or omit it to send a new email. + * actions. JSON requests accept either `sendEmailBody` or its durable envelope. + * Durable clients can instead send multipart attachment bytes alongside a + * content-free envelope in the `payload` field. */ -export const POST = withEmailProvider("messages/send", async (request) => { +export const POST = withEmailAccount("messages/send", async (request) => { + if (isMultipartRequest(request)) { + validateMultipartContentLength(request.headers.get("content-length")); + const input = await parseDurableMultipartRequest(request); + const { getEmailProvider, providerName } = + await getProviderContext(request); + const result = await executeDurableEmailSend({ + emailAccountId: request.auth.emailAccountId, + getEmailProvider, + input, + provider: providerName, + }); + return NextResponse.json(result); + } + const json: unknown = await request.json(); if (isDurableSend(json)) { const input = durableEmailSendBody.parse(json); + const { getEmailProvider, providerName } = + await getProviderContext(request); const result = await executeDurableEmailSend({ emailAccountId: request.auth.emailAccountId, - getEmailProvider: async () => request.emailProvider, + getEmailProvider, input, - provider: request.emailProvider.name, + provider: providerName, }); return NextResponse.json(result); } const body = sendEmailBody.parse(json); try { - const result = await request.emailProvider.sendEmailWithHtml(body); + const { getEmailProvider } = await getProviderContext(request); + const emailProvider = await getEmailProvider(); + const result = await emailProvider.sendEmailWithHtml(body); return NextResponse.json({ success: true, @@ -55,6 +82,169 @@ export const POST = withEmailProvider("messages/send", async (request) => { } }); +async function getProviderContext(request: { + auth: { emailAccountId: string; userId: string }; + logger: Parameters[0]["logger"]; +}) { + const emailAccount = await prisma.emailAccount.findUnique({ + where: { + id: request.auth.emailAccountId, + userId: request.auth.userId, + }, + select: { account: { select: { provider: true } } }, + }); + const providerName = z.string().min(1).parse(emailAccount?.account.provider); + let emailProviderPromise: ReturnType | undefined; + + return { + providerName, + getEmailProvider: () => { + emailProviderPromise ??= createEmailProvider({ + emailAccountId: request.auth.emailAccountId, + provider: providerName, + logger: request.logger, + }); + return emailProviderPromise; + }, + }; +} + function isDurableSend(value: unknown): value is { mutationId: unknown } { return typeof value === "object" && value !== null && "mutationId" in value; } + +function isMultipartRequest(request: Request) { + return ( + request.headers + .get("content-type") + ?.split(";", 1)[0] + ?.trim() + .toLowerCase() === "multipart/form-data" + ); +} + +function validateMultipartContentLength(value: string | null) { + if (value === null) return; + const contentLength = z + .string() + .regex(/^\d+$/) + .transform(Number) + .refine(Number.isSafeInteger) + .parse(value); + validateMultipartRequestSize(contentLength); +} + +async function parseDurableMultipartRequest(request: Request) { + const formData = await readBoundedMultipartFormData(request); + z.array(z.enum(["payload", "attachment"])).parse([...formData.keys()]); + + const [payload] = z.tuple([z.string()]).parse(formData.getAll("payload")); + const input = durableMultipartEmailSendPayload.parse(payload); + const files = z + .array(z.instanceof(File)) + .parse(formData.getAll("attachment")); + const metadata = input.email.attachments ?? []; + + validateDirectAttachmentTotalBytes( + files.reduce((total, file) => total + file.size, 0), + ); + z.number() + .refine((count) => count === metadata.length) + .parse(files.length); + for (const [index, file] of files.entries()) { + const attachment = metadata[index]; + z.object({ + filename: z.literal(attachment.filename), + mimeType: z.literal(attachment.mimeType.toLowerCase()), + size: z.literal(attachment.size), + }).parse({ + filename: file.name, + mimeType: file.type.toLowerCase(), + size: file.size, + }); + } + + const estimatedSerializedBytes = + new TextEncoder().encode(payload).byteLength + + metadata.reduce( + (total, attachment) => total + 4 * Math.ceil(attachment.size / 3), + 0, + ); + z.number() + .max(EMAIL_SEND_LIMITS.maxSerializedPayloadBytes) + .parse(estimatedSerializedBytes); + + const attachments = []; + for (const [index, { mimeType, ...attachment }] of metadata.entries()) { + attachments.push({ + ...attachment, + content: Buffer.from(await files[index].arrayBuffer()).toString("base64"), + contentType: mimeType, + }); + } + + return durableEmailSendBody.parse({ + ...input, + email: { + ...input.email, + attachments: + input.email.attachments === undefined ? undefined : attachments, + }, + }); +} + +async function readBoundedMultipartFormData(request: Request) { + const body = z + .custom>((value) => Boolean(value)) + .parse(request.body); + const reader = body.getReader(); + let receivedBytes = 0; + const boundedBody = new ReadableStream({ + async pull(controller) { + const { done, value } = await reader.read(); + if (done) { + reader.releaseLock(); + controller.close(); + return; + } + try { + receivedBytes += value.byteLength; + validateMultipartRequestSize(receivedBytes); + controller.enqueue(value); + } catch (error) { + try { + await reader.cancel(error); + } finally { + controller.error(error); + } + } + }, + async cancel(reason) { + await reader.cancel(reason); + }, + }); + + const contentType = z.string().parse(request.headers.get("content-type")); + const multipartResponse = new Response(boundedBody, { + headers: { "content-type": contentType }, + }); + return multipartResponse.formData(); +} + +function validateMultipartRequestSize(length: number) { + z.number() + .refine( + (value) => value <= EMAIL_SEND_LIMITS.maxSerializedPayloadBytes, + "The multipart request is too large.", + ) + .parse(length); +} + +function validateDirectAttachmentTotalBytes(length: number) { + z.number() + .max( + DURABLE_MULTIPART_EMAIL_SEND_LIMITS.maxAttachmentBytes, + DURABLE_MULTIPART_ATTACHMENT_LIMIT_MESSAGE, + ) + .parse(length); +} diff --git a/apps/web/utils/email/durable-email-send.validation.ts b/apps/web/utils/email/durable-email-send.validation.ts index 7e8547c6d6..5199e9be24 100644 --- a/apps/web/utils/email/durable-email-send.validation.ts +++ b/apps/web/utils/email/durable-email-send.validation.ts @@ -1,4 +1,8 @@ import { z } from "zod"; +import { + type EmailAttachmentMetadata, + validateEmailAttachmentMetadata, +} from "@inboxzero/email-editor/core"; import { sendEmailBody } from "@/utils/types/mail"; export const durableEmailSendBody = z.object({ @@ -10,3 +14,73 @@ export const durableEmailSendBody = z.object({ }); export type DurableEmailSendBody = z.infer; + +export const DURABLE_MULTIPART_EMAIL_SEND_LIMITS = { + maxAttachmentBytes: 3 * 1024 * 1024, + maxPayloadBytes: 5 * 1024 * 1024, +} as const; +export const DURABLE_MULTIPART_ATTACHMENT_LIMIT_MESSAGE = + "Attachments must total 3 MB or less. Use Gmail or Outlook for larger files."; + +const multipartAttachmentMetadata = z.strictObject({ + id: z.string().min(1).max(512), + filename: z.string().min(1).max(1024), + mimeType: z + .string() + .min(3) + .max(255) + .regex(/^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/), + size: z.number().int().nonnegative(), + disposition: z.enum(["attachment", "inline"]), + contentId: z.string().min(1).max(512).optional(), +}); + +const multipartAttachments = z + .array(multipartAttachmentMetadata) + .superRefine((attachments, context) => { + const validation = validateEmailAttachmentMetadata( + attachments satisfies EmailAttachmentMetadata[], + ); + if (!validation.valid) { + context.addIssue({ + code: "custom", + message: validation.error, + }); + } + + const totalBytes = attachments.reduce( + (total, attachment) => total + attachment.size, + 0, + ); + if (totalBytes > DURABLE_MULTIPART_EMAIL_SEND_LIMITS.maxAttachmentBytes) { + context.addIssue({ + code: "custom", + message: DURABLE_MULTIPART_ATTACHMENT_LIMIT_MESSAGE, + }); + } + }); + +export const durableMultipartEmailSendBody = durableEmailSendBody.extend({ + email: z.object({ + ...sendEmailBody.shape, + attachments: multipartAttachments.optional(), + }), +}); + +export const durableMultipartEmailSendPayload = z + .string() + .refine( + (value) => + new TextEncoder().encode(value).byteLength <= + DURABLE_MULTIPART_EMAIL_SEND_LIMITS.maxPayloadBytes, + "The multipart payload is too large.", + ) + .transform((value, context): unknown => { + try { + return JSON.parse(value); + } catch { + context.addIssue({ code: "custom", message: "Invalid payload JSON." }); + return z.NEVER; + } + }) + .pipe(durableMultipartEmailSendBody);