diff --git a/example.env b/example.env index 7ec5098..8b14ee4 100644 --- a/example.env +++ b/example.env @@ -14,6 +14,11 @@ DLA_CONFIG_BASE_DIR=./config DLA_DB_CONNECTION_URL=./data/db.sqlite DLA_DB_AUTO_MIGRATE=true +# Maximum combined size of the attachments of a single mail, in MB. +# Attachments are held in memory only while the message is composed, so keep this +# in line with what the mail provider accepts. Defaults to 25 when unset. +DLA_MAX_ATTACHMENT_SIZE_MB=25 + # Outbound SMTP — used for system mail such as password-reset emails. # All optional; leave unset to disable outbound system mail. DLA_SMTP_HOST= diff --git a/src/api/versions/v1/routes/mail-accounts/mailboxes/mails/index.ts b/src/api/versions/v1/routes/mail-accounts/mailboxes/mails/index.ts index c2dffb9..cc1197f 100644 --- a/src/api/versions/v1/routes/mail-accounts/mailboxes/mails/index.ts +++ b/src/api/versions/v1/routes/mail-accounts/mailboxes/mails/index.ts @@ -1,10 +1,9 @@ -import { Hono } from "hono"; +import { Hono, type Context } from "hono"; import { MailsModel } from "./model"; import { APIResponse } from "../../../../../../utils/api-res"; import { APIResponseSpec, APIRouteSpec } from "../../../../../../utils/specHelpers"; import { DOCS_TAGS } from "../../../../docs"; -import { z } from "zod"; -import { validator } from "hono-openapi"; +import { resolver, validator } from "hono-openapi"; import { MailAccountsModel } from "../../model"; import { router as attachmentsRouter } from "./attachments"; import { MailClientsCache } from "../../../../../../../utils/mails/mail-clients-cache"; @@ -16,6 +15,8 @@ import { SMTPAccount } from "../../../../../../../utils/mails/backends/smtp"; import { MailRessource } from "../../../../../../../utils/mails/ressources/mail"; import MailComposer from "nodemailer/lib/mail-composer"; import { MailParser } from "../../../../../../../utils/mails/parser"; +import { ConfigHandler } from "../../../../../../../utils/config"; +import type { OpenAPIV3_1 } from "openapi-types"; @@ -23,6 +24,98 @@ function formatEmailAddress(addr: { name?: string; address: string }): string { return addr.name ? `"${addr.name}" <${addr.address}>` : addr.address; } +/** Attachment handed to `MailComposer`, held in memory only while composing. */ +type ComposerAttachment = { + filename: string; + content: Buffer; + contentType?: string; +}; + +const DEFAULT_MAX_ATTACHMENT_SIZE_MB = 25; + +/** Combined attachment size allowed on a single mail, in bytes. */ +function maxAttachmentSize(): number { + const configured = Number(ConfigHandler.getConfig()?.DLA_MAX_ATTACHMENT_SIZE_MB); + const megabytes = Number.isFinite(configured) && configured > 0 + ? configured + : DEFAULT_MAX_ATTACHMENT_SIZE_MB; + + return megabytes * 1024 * 1024; +} + +/** + * Read the create-mail payload from either a JSON body or a `multipart/form-data` + * body carrying attachments. + * + * In the multipart case the mail itself arrives as a JSON string in the `mail` + * field and each file as an `attachments` entry. Files are read into memory only + * for as long as it takes to compose the message — nothing is written to disk. + * + * @returns The validated body plus attachments, or an error message to return as a 400 + */ +async function readCreatePayload(c: Context): Promise< + { ok: true; body: MailsModel.Create.Body; attachments: ComposerAttachment[] } | + { ok: false; error: string } +> { + const contentType = c.req.header('content-type') ?? ''; + const isMultipart = contentType.toLowerCase().includes('multipart/form-data'); + + let rawBody: unknown; + const attachments: ComposerAttachment[] = []; + + if (isMultipart) { + let form: FormData; + try { + form = await c.req.formData(); + } catch { + return { ok: false, error: "Malformed multipart/form-data body" }; + } + + const mailField = form.get('mail'); + if (typeof mailField !== 'string') { + return { ok: false, error: "Missing 'mail' field in multipart body" }; + } + + try { + rawBody = JSON.parse(mailField); + } catch { + return { ok: false, error: "The 'mail' field is not valid JSON" }; + } + + const files = form.getAll('attachments').filter((entry): entry is File => entry instanceof File); + + const limit = maxAttachmentSize(); + const totalSize = files.reduce((sum, file) => sum + file.size, 0); + if (totalSize > limit) { + return { + ok: false, + error: `Attachments exceed the maximum combined size of ${Math.floor(limit / (1024 * 1024))} MB` + }; + } + + for (const file of files) { + attachments.push({ + filename: file.name || 'attachment', + content: Buffer.from(await file.arrayBuffer()), + contentType: file.type || undefined + }); + } + } else { + try { + rawBody = await c.req.json(); + } catch { + return { ok: false, error: "Malformed JSON body" }; + } + } + + const parsed = MailsModel.Create.Body.safeParse(rawBody); + if (!parsed.success) { + return { ok: false, error: "Bad Request: Syntax or validation error in request" }; + } + + return { ok: true, body: parsed.data, attachments }; +} + export const router = new Hono(); @@ -74,8 +167,15 @@ router.post('/', APIRouteSpec.authenticated({ summary: "Create Mail", - description: "Create a new mail in the current mailbox (e.g., a draft).", + description: "Create a new mail in the current mailbox (e.g., a draft). Supports JSON bodies and multipart bodies with attachments.", tags: [DOCS_TAGS.MAIL_ACCOUNTS.MAILBOXES_MAILS], + requestBody: { + required: true, + content: { + "application/json": { schema: resolver(MailsModel.Create.Body).toJSONSchema() as OpenAPIV3_1.SchemaObject }, + "multipart/form-data": { schema: MailsModel.Create.MultipartSchema } + } + }, responses: APIResponseSpec.describeWithWrongInputs( APIResponseSpec.success("Mail created successfully", MailsModel.Create.Response), @@ -83,15 +183,16 @@ router.post('/', ) }), - validator('json', MailsModel.Create.Body), - async (c) => { // @ts-ignore const mailAccount = c.get("mailAccount") as MailAccountsModel.BASE; // @ts-ignore const mailbox = c.get("mailboxData") as MailboxesModel.BASE; - const body = c.req.valid('json'); + const payload = await readCreatePayload(c); + if (!payload.ok) return APIResponse.badRequest(c, payload.error); + + const { body, attachments } = payload; const imap = MailClientsCache.createOrGetClientData(mailAccount).imap; @@ -107,7 +208,8 @@ router.post('/', subject: body.subject, text: body.body?.text, html: body.body?.html, - priority: body.priority + priority: body.priority, + attachments }); const message = await composer.compile().build(); @@ -287,11 +389,14 @@ router.post('/:mailUID/send', const imap = MailClientsCache.createOrGetClientData(mailAccount).imap; try { - // Send the mail via SMTP - const result = await smtp.sendMail(mailData); - await imap.connect(); + // Send the exact stored source so MIME attachments and inline parts survive. + const source = await imap.getMailSource(mailbox.path, mailData.uid); + if (!source) return APIResponse.notFound(c, "Mail with specified UID not found"); + const result = await smtp.sendRaw(source, mailData); + if (!result) return APIResponse.badRequest(c, "Mail must include a sender and at least one recipient"); + // Move original mail to Sent folder (default behavior) if (body.moveToSent) { await imap.moveToMailbox(mailbox.path, [mailData.uid], 'Sent'); diff --git a/src/api/versions/v1/routes/mail-accounts/mailboxes/mails/model.ts b/src/api/versions/v1/routes/mail-accounts/mailboxes/mails/model.ts index a68aa9d..93058e6 100644 --- a/src/api/versions/v1/routes/mail-accounts/mailboxes/mails/model.ts +++ b/src/api/versions/v1/routes/mail-accounts/mailboxes/mails/model.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import type { OpenAPIV3_1 } from "openapi-types"; import { MailRessource } from "../../../../../../../utils/mails/ressources/mail"; import type { Utils } from "../../../../../../../utils"; import { ApiHelperModels } from "../../../../../../utils/shared-models/api-helper-models"; @@ -107,6 +108,27 @@ export namespace MailsModel.Create { export type Body = z.infer; + /** + * `multipart/form-data` variant of {@link Body}, used when the mail carries + * attachments. The mail itself is sent as a JSON string in the `mail` field; + * each file is appended as a separate `attachments` entry. + */ + export const MultipartSchema = { + type: "object", + properties: { + mail: { + type: "string", + description: "The mail as a JSON string, using the same shape as the `application/json` body." + }, + attachments: { + type: "array", + items: { type: "string", format: "binary" }, + description: "Files to attach. Repeat the field once per file." + } + }, + required: ["mail"] + } satisfies OpenAPIV3_1.SchemaObject; + export const Response = z.object({ uid: z.number() }); diff --git a/src/utils/config.ts b/src/utils/config.ts index afd9e72..9b22aec 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -120,6 +120,8 @@ export class ConfigHandler { .add("DLA_DB_CONNECTION_URL", false) .add("DLA_DB_AUTO_MIGRATE", false, [true, false]) + .add("DLA_MAX_ATTACHMENT_SIZE_MB", false) + .add("DLA_SMTP_HOST", false) .add("DLA_SMTP_PORT", false) .add("DLA_SMTP_USERNAME", false) diff --git a/src/utils/mails/backends/imap.ts b/src/utils/mails/backends/imap.ts index 87388a7..d9155d5 100644 --- a/src/utils/mails/backends/imap.ts +++ b/src/utils/mails/backends/imap.ts @@ -247,6 +247,32 @@ export class IMAPAccount { } } + /** + * Fetch the raw RFC822 source of a single message. + * + * Used to send a stored draft byte-for-byte, which preserves the parts that the + * metadata-only parse drops — most importantly attachment content. As with + * {@link IMAPAccount.getAttachmentContent}, nothing is written to disk or cached; + * the buffer is handed straight back to the caller. + * + * @param mailbox - Mailbox path + * @param uid - Message UID + * @returns The raw message source, or `null` if the message does not exist + */ + async getMailSource(mailbox: string, uid: number): Promise { + let lock = await this.client.getMailboxLock(mailbox); + try { + const message = await this.client.fetchOne(uid, { + source: true + }, { uid: true }); + + if (!message || !message.source) return null; + return message.source; + } finally { + lock.release(); + } + } + /** * Fetch a single attachment's decoded content on demand. * diff --git a/src/utils/mails/backends/smtp.ts b/src/utils/mails/backends/smtp.ts index e8ebb57..b2cd49e 100644 --- a/src/utils/mails/backends/smtp.ts +++ b/src/utils/mails/backends/smtp.ts @@ -72,6 +72,47 @@ export class SMTPAccount { }); } + /** + * Send a message from its raw RFC822 source. + * + * Preferred over {@link SMTPAccount.sendMail} when the message already exists + * (e.g. a stored draft): the source is relayed byte-for-byte, so attachments, + * inline parts and the original MIME structure survive — none of which are + * recoverable from the metadata-only parsed representation. + * + * The envelope is passed explicitly because it cannot be derived from `raw`. + * + * @param source - The raw message source + * @param mail - The parsed message, used only to build the SMTP envelope + * @returns The nodemailer result, or `null` if the message has no sender or no recipients + */ + async sendRaw(source: Buffer | string, mail: MailRessource.IMail) { + const sender = mail.from; + if (!sender) { + return null; + } + + // Bcc recipients live in the envelope only — the header is intentionally + // not relied upon here, as it may legitimately be absent from the source. + const recipients = [ + ...(mail.to ?? []), + ...(mail.cc ?? []), + ...(mail.bcc ?? []) + ].map(addr => addr.address); + + if (recipients.length === 0) { + return null; + } + + return await this.client.sendMail({ + envelope: { + from: sender.address, + to: recipients + }, + raw: source + }); + } + protected static formatAddress(addr: MailRessource.EmailAddress) { return addr.name ? `"${addr.name}" <${addr.address}>` : addr.address; } diff --git a/tests/api.routes.test.ts b/tests/api.routes.test.ts index 58a7ab0..6144f80 100644 --- a/tests/api.routes.test.ts +++ b/tests/api.routes.test.ts @@ -1637,6 +1637,59 @@ describe("Mail Mailbox Mails Routes", async () => { createdMailUID = data.uid; }); + test("POST /v1/mail-accounts/:mailAccountID/mailboxes/:mailboxPath/mails stores multipart attachments", async () => { + const form = new FormData(); + form.set("mail", JSON.stringify({ + from: { name: "Test Sender", address: "sender@test.com" }, + to: [{ name: "Test Receiver", address: "receiver@test.com" }], + cc: [], + bcc: [], + subject: "Draft with attachment", + body: { text: "See attachment" }, + flags: { draft: true } + })); + form.append("attachments", new File(["attachment body"], "note.txt", { type: "text/plain" })); + + const response = await API.getApp().request( + `/v1/mail-accounts/${mailAccountID}/mailboxes/INBOX/mails`, + { method: "POST", headers: { Authorization: `Bearer ${session_token}` }, body: form } + ); + expect(response.status).toBe(200); + + const created = await response.json() as { data: { uid: number } }; + const attachmentData = await makeAPIRequest( + `/v1/mail-accounts/${mailAccountID}/mailboxes/INBOX/mails/${created.data.uid}/attachments`, + { authToken: session_token, expectedBodySchema: AttachmentsModel.GetAll.Response } + ); + + expect(attachmentData).toHaveLength(1); + expect(attachmentData[0]).toMatchObject({ filename: "note.txt", contentType: "text/plain" }); + }); + + test("POST /v1/mail-accounts/:mailAccountID/mailboxes/:mailboxPath/mails rejects attachments above the combined size limit", async () => { + const form = new FormData(); + form.set("mail", JSON.stringify({ + from: { name: "Test Sender", address: "sender@test.com" }, + to: [{ name: "Test Receiver", address: "receiver@test.com" }], + subject: "Oversized attachment", + body: { text: "This request must be rejected" }, + flags: { draft: true } + })); + form.append("attachments", new File([new Uint8Array(26 * 1024 * 1024)], "too-large.bin", { + type: "application/octet-stream" + })); + + const response = await API.getApp().request( + `/v1/mail-accounts/${mailAccountID}/mailboxes/INBOX/mails`, + { method: "POST", headers: { Authorization: `Bearer ${session_token}` }, body: form } + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + message: "Attachments exceed the maximum combined size of 25 MB" + }); + }); + test("POST /v1/mail-accounts/:mailAccountID/mailboxes/:mailboxPath/mails with invalid mailbox fails", async () => { const mailData = { diff --git a/tests/helpers/preload.ts b/tests/helpers/preload.ts index 5cf081e..5b8301a 100644 --- a/tests/helpers/preload.ts +++ b/tests/helpers/preload.ts @@ -30,6 +30,7 @@ function setTestEnv(rootDir: string) { DLA_DB_CONNECTION_URL: path.join(rootDir, "db.sqlite"), DLA_DB_AUTO_MIGRATE: true, + DLA_MAX_ATTACHMENT_SIZE_MB: "25", DLA_SMTP_HOST: "127.0.0.1", DLA_SMTP_PORT: "12587",