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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
127 changes: 116 additions & 11 deletions src/api/versions/v1/routes/mail-accounts/mailboxes/mails/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,13 +15,107 @@ 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";
Comment on lines +18 to +19



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,
Comment on lines +87 to +91

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add the additional test cases @bastigfz

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();
Expand Down Expand Up @@ -74,24 +167,32 @@ 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),
APIResponseSpec.notFound("Mailbox with specified path not found")
)
}),

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;

Expand All @@ -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();
Expand Down Expand Up @@ -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');
Expand Down
22 changes: 22 additions & 0 deletions src/api/versions/v1/routes/mail-accounts/mailboxes/mails/model.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -107,6 +108,27 @@ export namespace MailsModel.Create {

export type Body = z.infer<typeof Body>;

/**
* `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()
});
Expand Down
2 changes: 2 additions & 0 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions src/utils/mails/backends/imap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Buffer | null> {
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.
*
Expand Down
41 changes: 41 additions & 0 deletions src/utils/mails/backends/smtp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
53 changes: 53 additions & 0 deletions tests/api.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
1 change: 1 addition & 0 deletions tests/helpers/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down