Skip to content

Commit 1e8d9b5

Browse files
Copilottommoor
andauthored
fix: Support mailbox format for SMTP_FROM_EMAIL and SMTP_REPLY_EMAIL (outline#11784)
* Initial plan * fix: Handle SMTP_FROM_EMAIL/SMTP_REPLY_EMAIL in mailbox format Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
1 parent 6138777 commit 1e8d9b5

4 files changed

Lines changed: 145 additions & 4 deletions

File tree

server/emails/templates/BaseEmail.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,10 @@ export default abstract class BaseEmail<
201201
);
202202

203203
const parsedFrom = addressparser(env.SMTP_FROM_EMAIL)[0];
204+
invariant(
205+
parsedFrom?.address?.includes("@"),
206+
`SMTP_FROM_EMAIL is not a valid email address: "${env.SMTP_FROM_EMAIL}"`
207+
);
204208
const domain = parsedFrom.address.split("@")[1];
205209
const customFromName = this.fromName?.(props);
206210

server/env.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
Length,
1313
IsNumber,
1414
IsIn,
15-
IsEmail,
1615
IsBoolean,
1716
} from "class-validator";
1817
import uniq from "lodash/uniq";
@@ -24,6 +23,7 @@ import {
2423
CannotUseWithAny,
2524
IsInCaseInsensitive,
2625
IsDatabaseUrl,
26+
IsMailboxAddress,
2727
} from "@server/utils/validators";
2828
import Deprecated from "./models/decorators/Deprecated";
2929
import { getArg } from "./utils/args";
@@ -405,15 +405,15 @@ export class Environment {
405405
/**
406406
* The email address from which emails are sent.
407407
*/
408-
@IsEmail({ allow_display_name: true, allow_ip_domain: true })
408+
@IsMailboxAddress()
409409
@IsOptional()
410410
public SMTP_FROM_EMAIL = this.toOptionalString(environment.SMTP_FROM_EMAIL);
411411

412412
/**
413413
* The reply-to address for emails sent from Outline. If unset the from
414414
* address is used by default.
415415
*/
416-
@IsEmail({ allow_display_name: true, allow_ip_domain: true })
416+
@IsMailboxAddress()
417417
@IsOptional()
418418
public SMTP_REPLY_EMAIL = this.toOptionalString(environment.SMTP_REPLY_EMAIL);
419419

server/utils/validators.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "@jest/globals";
2-
import { isDatabaseUrl } from "./validators";
2+
import { isDatabaseUrl, isMailboxAddress } from "./validators";
33

44
describe("isDatabaseUrl", () => {
55
const defaultOptions = {
@@ -249,3 +249,70 @@ describe("isDatabaseUrl", () => {
249249
});
250250
});
251251
});
252+
253+
describe("isMailboxAddress", () => {
254+
describe("plain email addresses", () => {
255+
it("should accept a plain email address", () => {
256+
expect(isMailboxAddress("user@example.com")).toBe(true);
257+
});
258+
259+
it("should accept an email with IP domain", () => {
260+
expect(isMailboxAddress("user@192.168.1.1")).toBe(true);
261+
});
262+
263+
it("should reject an invalid email address", () => {
264+
expect(isMailboxAddress("notanemail")).toBe(false);
265+
});
266+
267+
it("should reject an email without domain", () => {
268+
expect(isMailboxAddress("user@")).toBe(false);
269+
});
270+
});
271+
272+
describe("mailbox format addresses", () => {
273+
it("should accept a simple mailbox format", () => {
274+
expect(isMailboxAddress("Outline <user@example.com>")).toBe(true);
275+
});
276+
277+
it("should accept a mailbox format with a period in the display name", () => {
278+
expect(isMailboxAddress("My App v1.0 <user@example.com>")).toBe(true);
279+
});
280+
281+
it("should accept a mailbox format with quoted display name containing a comma", () => {
282+
expect(
283+
isMailboxAddress('"Company, Inc." <user@example.com>')
284+
).toBe(true);
285+
});
286+
287+
it("should accept a mailbox format with a quoted display name", () => {
288+
expect(isMailboxAddress('"Outline" <user@example.com>')).toBe(true);
289+
});
290+
291+
it("should reject a mailbox format with an unquoted comma in the display name", () => {
292+
// addressparser splits on commas, so this creates two addresses
293+
expect(isMailboxAddress("Company, Inc. <user@example.com>")).toBe(false);
294+
});
295+
296+
it("should reject a mailbox format with an empty email address", () => {
297+
expect(isMailboxAddress("Outline <>")).toBe(false);
298+
});
299+
300+
it("should reject a mailbox format with an invalid email address", () => {
301+
expect(isMailboxAddress("Outline <notanemail>")).toBe(false);
302+
});
303+
});
304+
305+
describe("edge cases", () => {
306+
it("should reject an empty string", () => {
307+
expect(isMailboxAddress("")).toBe(false);
308+
});
309+
310+
it("should reject a string with only spaces", () => {
311+
expect(isMailboxAddress(" ")).toBe(false);
312+
});
313+
314+
it("should reject a group address", () => {
315+
expect(isMailboxAddress("Group: user@example.com;")).toBe(false);
316+
});
317+
});
318+
});

server/utils/validators.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import addressparser from "addressparser";
12
import type { ValidationArguments, ValidationOptions } from "class-validator";
23
import { registerDecorator } from "class-validator";
4+
import { isEmail } from "validator";
35

46
/**
57
* Validates a PostgreSQL database connection URL, including support for
@@ -264,3 +266,71 @@ export function IsDatabaseUrl(
264266
});
265267
};
266268
}
269+
270+
/**
271+
* Validates an email address in either plain format (email@domain.com) or
272+
* mailbox format (Display Name <email@domain.com>). Uses addressparser to
273+
* extract the email address before validation, which provides correct handling
274+
* of display names that may contain characters like periods or other special
275+
* characters.
276+
*
277+
* @param value the email address string to validate.
278+
* @returns true if the value is a valid email or valid mailbox address, false otherwise.
279+
*/
280+
export function isMailboxAddress(value: string): boolean {
281+
try {
282+
const parsed = addressparser(value);
283+
// If parsing results in multiple addresses (e.g., comma in unquoted display
284+
// name), the input is malformed and should be rejected.
285+
if (parsed.length !== 1) {
286+
return false;
287+
}
288+
const [{ address }] = parsed;
289+
if (!address?.includes("@")) {
290+
return false;
291+
}
292+
return isEmail(address, { allow_ip_domain: true });
293+
} catch {
294+
return false;
295+
}
296+
}
297+
298+
/**
299+
* Decorator that validates an email address in either plain format
300+
* (email@domain.com) or mailbox format (Display Name <email@domain.com>).
301+
*
302+
* Unlike @IsEmail, this decorator supports display names containing characters
303+
* such as periods, which are commonly used in application names
304+
* (e.g., "App v1.0 <noreply@example.com>").
305+
*
306+
* Note: Display names containing commas must be quoted, e.g.:
307+
* "Company, Inc." <email@example.com>
308+
*
309+
* @param validationOptions additional validation options.
310+
* @returns decorator function.
311+
*/
312+
export function IsMailboxAddress(validationOptions?: ValidationOptions) {
313+
return function (object: object, propertyName: string) {
314+
registerDecorator({
315+
name: "isMailboxAddress",
316+
target: object.constructor,
317+
propertyName,
318+
constraints: [],
319+
options: validationOptions,
320+
validator: {
321+
validate(value: unknown) {
322+
if (value === undefined || value === null) {
323+
return true;
324+
}
325+
if (typeof value !== "string") {
326+
return false;
327+
}
328+
return isMailboxAddress(value);
329+
},
330+
defaultMessage() {
331+
return `${propertyName} must be a valid email address or use mailbox format (e.g., "Display Name <email@example.com>"). Note: display names containing commas must be quoted (e.g., '"Company, Inc." <email@example.com>').`;
332+
},
333+
},
334+
});
335+
};
336+
}

0 commit comments

Comments
 (0)