diff --git a/CHANGES.md b/CHANGES.md index 75c8653..8b8862d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -18,8 +18,11 @@ To be released. cancellation. The method is read from the object's own `METHOD` property; passing `method` asserts what that property says rather than supplying it, and content that is not a single well-formed `VCALENDAR` object declaring - exactly one supported method is rejected with a `TypeError`. Line endings - are normalized to CRLF. [[#63], [#69]] + exactly one supported method is rejected with a `TypeError`. Every content + line is checked for valid names, parameters, and value characters; + property-specific value syntax and scheduling requirements remain the + caller's responsibility. Line endings are normalized to CRLF. + [[#63], [#69], [#70], [#71]] - Added the `@upyo/core/calendar` module, with the `CalendarMethod`, `CalendarContent`, and `CalendarConstructor` types and the `parseCalendarMethod()`, `resolveCalendarContent()`, and @@ -52,6 +55,8 @@ To be released. [#61]: https://github.com/dahlia/upyo/pull/61 [#63]: https://github.com/dahlia/upyo/issues/63 [#69]: https://github.com/dahlia/upyo/pull/69 +[#70]: https://github.com/dahlia/upyo/issues/70 +[#71]: https://github.com/dahlia/upyo/pull/71 ### @upyo/jmap diff --git a/changes.d/core/calendar-invitations.md b/changes.d/core/calendar-invitations.md index 960a390..f80c85c 100644 --- a/changes.d/core/calendar-invitations.md +++ b/changes.d/core/calendar-invitations.md @@ -2,6 +2,8 @@ links: '#63': https://github.com/dahlia/upyo/issues/63 '#69': https://github.com/dahlia/upyo/pull/69 + '#70': https://github.com/dahlia/upyo/issues/70 + '#71': https://github.com/dahlia/upyo/pull/71 --- - Added a `calendar` field to `Message` and `createMessage()`, which carries an iCalendar object so that a transport can compose the `text/calendar` @@ -9,8 +11,11 @@ links: cancellation. The method is read from the object's own `METHOD` property; passing `method` asserts what that property says rather than supplying it, and content that is not a single well-formed `VCALENDAR` object declaring - exactly one supported method is rejected with a `TypeError`. Line endings - are normalized to CRLF. [[#63], [#69]] + exactly one supported method is rejected with a `TypeError`. Every content + line is checked for valid names, parameters, and value characters; + property-specific value syntax and scheduling requirements remain the + caller's responsibility. Line endings are normalized to CRLF. + [[#63], [#69], [#70], [#71]] - Added the `@upyo/core/calendar` module, with the `CalendarMethod`, `CalendarContent`, and `CalendarConstructor` types and the diff --git a/changes.d/opentelemetry/calendar-invitations.md b/changes.d/opentelemetry/calendar-invitations.md index 3633185..2b17e12 100644 --- a/changes.d/opentelemetry/calendar-invitations.md +++ b/changes.d/opentelemetry/calendar-invitations.md @@ -1,6 +1,7 @@ --- links: '#63': https://github.com/dahlia/upyo/issues/63 + '#69': https://github.com/dahlia/upyo/pull/69 --- - The estimated message size reported on a span now includes the calendar - payload. [[#63]] + payload. [[#63], [#69]] diff --git a/docs/messages/calendar.md b/docs/messages/calendar.md index 2f45ca3..51fd408 100644 --- a/docs/messages/calendar.md +++ b/docs/messages/calendar.md @@ -43,8 +43,8 @@ Upyo does not generate iCalendar objects The `content` is an iCalendar object your application already has, whether from a template, from your own code, or from a library such as [ical-generator]. -Upyo reads it only far enough to compose the message around it, and everything -about the event itself stays yours: +Upyo checks the syntax of every content line before composing the message. +The event's meaning and scheduling requirements stay yours: - `ORGANIZER` and `ATTENDEE`, which decide who the invitation is from and who may reply to it. These are separate fields from the message's `from` and @@ -113,9 +113,17 @@ An object declaring a different method, or none at all, is rejected with a are normalized to the CRLF [RFC 5545] requires, so an object held with plain newlines works as it is. -Upyo checks that the content is a single well-formed `VCALENDAR` object -declaring exactly one supported method. It is not a full iCalendar validator: -whether the event itself makes sense is still yours to get right. +Upyo checks every content line against the grammar in [RFC 5545] §3.1, +including property names, parameters, and the characters allowed in values. +For example, `SUMMARY;BROKEN:Lunch` is refused because a parameter needs an +`=` and a value, which may be empty. A double quote inside an unquoted +parameter value or a forbidden control character is refused too. Correct +these errors in the template or generator that produced the object. + +The content must also be one `VCALENDAR` object with balanced components and +exactly one supported top-level `METHOD`. This is not full iCalendar or iTIP +validation: Upyo does not check property-specific value syntax, required event +fields, or whether the event itself makes sense. [RFC 5545]: https://www.rfc-editor.org/rfc/rfc5545 diff --git a/packages/core/src/calendar.test.ts b/packages/core/src/calendar.test.ts index c72bef9..b29400f 100644 --- a/packages/core/src/calendar.test.ts +++ b/packages/core/src/calendar.test.ts @@ -110,21 +110,6 @@ describe("parseCalendarMethod()", () => { assert.equal(parseCalendarMethod(content), "REQUEST"); }); - it("should not validate lines whose meaning it does not use", () => { - // Only BEGIN, END and METHOD decide what gets composed, so a malformed - // parameter elsewhere is the caller's business, not a reason to refuse - // a payload a calendar client would read. - const content = ics( - "BEGIN:VCALENDAR", - "METHOD:REQUEST", - "BEGIN:VEVENT", - "SUMMARY;BROKEN:Lunch", - "END:VEVENT", - "END:VCALENDAR", - ); - assert.equal(parseCalendarMethod(content), "REQUEST"); - }); - it("should ignore a colon inside a quoted parameter value", () => { const content = ics( "BEGIN:VCALENDAR", @@ -507,3 +492,190 @@ describe("createCalendarAttachment() charset", () => { assert.ok(attachment.contentType.includes("charset=utf-8")); }); }); + +describe("calendar content line grammar", () => { + const invalidLines: Readonly> = { + "spaces in a property name": ["SUM MARY:x"], + "quoted property name": ['"SUMMARY":x'], + "empty property name": [":x"], + "non-ASCII property name": ["SUMMÁRY:x"], + "punctuation in a property name": ["SUM_MARY:x"], + "bare parameter": ["SUMMARY;BROKEN:Lunch"], + "unnamed parameter": ["SUMMARY;=x:y"], + "empty trailing parameter": ["SUMMARY;X=a;:y"], + "invalid parameter name": ["SUMMARY;X_A=x:y"], + "non-ASCII parameter name": ["SUMMARY;X-Á=x:y"], + "space in a parameter name": ["SUMMARY;X A=x:y"], + "quoted parameter name": ['SUMMARY;"X"=x:y'], + "embedded balanced quotes": ['METHOD;X=a"b":REQUEST'], + "junk after a quoted value": ['METHOD;X="a"b:REQUEST'], + "adjacent quoted values": ['METHOD;X="a""b":REQUEST'], + "space after a quoted value": ['METHOD;X="a" :REQUEST'], + "unterminated quoted value": ['SUMMARY;X="a:b'], + "missing colon after quoted value": ['SUMMARY;X="a"'], + "missing colon": ["SUMMARY"], + "backslash before an embedded quote": ['METHOD;X="a\\"b":REQUEST'], + "malformed nested METHOD": [ + "BEGIN:VEVENT", + "METHOD;BROKEN:REQUEST", + "END:VEVENT", + ], + "leading method whitespace": ["METHOD: REQUEST"], + "trailing method whitespace": ["METHOD:REQUEST "], + "lone high surrogate": ["SUMMARY:\uD83D"], + "lone low surrogate": ["SUMMARY:\uDE00"], + "surrogate pair split by folding": ["SUMMARY:\uD83D", " \uDE00"], + }; + + // Only the METHOD-specific cases replace the valid top-level method. + function objectWith(lines: readonly string[]): string { + return ics( + "BEGIN:VCALENDAR", + ...(lines[0].startsWith("METHOD") ? [] : ["METHOD:REQUEST"]), + ...lines, + "END:VCALENDAR", + ); + } + + for (const [description, lines] of Object.entries(invalidLines)) { + it(`should reject ${description} through both public functions`, () => { + const content = objectWith(lines); + assert.equal(parseCalendarMethod(content), undefined); + assert.throws(() => resolveCalendarContent({ content }), TypeError); + }); + } + + for (let code = 0; code <= 0x7f; code++) { + if ( + code === 9 || code === 10 || code === 13 || (code >= 32 && code < 127) + ) { + continue; + } + const character = String.fromCharCode(code); + for ( + const line of [ + `SUMMARY:a${character}b`, + `METHOD;X=a${character}b:REQUEST`, + `METHOD;X="a${character}b":REQUEST`, + ] + ) { + it(`should reject control U+${code.toString(16)} in ${JSON.stringify(line)}`, () => { + const content = objectWith([line]); + assert.equal(parseCalendarMethod(content), undefined); + assert.throws(() => resolveCalendarContent({ content }), TypeError); + }); + } + } + + for (const value of ["\uD800", "\uDC00"]) { + for (const parameter of [value, `"${value}"`]) { + it(`should reject a lone surrogate in parameter ${JSON.stringify(parameter)}`, () => { + const content = objectWith([`METHOD;X=${parameter}:REQUEST`]); + assert.equal(parseCalendarMethod(content), undefined); + assert.throws(() => resolveCalendarContent({ content }), TypeError); + }); + } + } + + it("should reject a byte order mark before the envelope", () => { + const content = "\uFEFF" + request; + assert.equal(parseCalendarMethod(content), undefined); + assert.throws(() => resolveCalendarContent({ content }), TypeError); + }); + + const validLines: Readonly> = { + "empty parameter values and list members": ['METHOD;X=,"",a,,b,:REQUEST'], + "mixed quoted and unquoted values": ['METHOD;X="a:b;c,d",plain,"":REQUEST'], + "equals, backslash and caret in parameters": [ + "METHOD;X=a=b\\c^n^'^^:REQUEST", + ], + "spaces and tabs in parameters": ['METHOD;X= a\tb ;Y=" a\tb ":REQUEST'], + "Unicode parameter and property values": [ + 'ATTENDEE;CN="점심 🍜";X=é\u0080\u0085\u009F:mailto:a@example.com', + "SUMMARY:점심 🍜\uD7FF\uE000\uFFFF\u{10FFFF}", + ], + "unknown token names": ["X-ABC-THING;X-ABC-PARAM=x:y", "1;-=x:y", "X-:x"], + "empty property value": ["SUMMARY:"], + "realistic attendee parameters": [ + "ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN=Jane Doe;X-NUM-GUESTS=0:mailto:jane@example.net", + 'ATTENDEE;CN="Doe, Jane":mailto:jane@example.net', + ], + "structured values": [ + "DTSTART;TZID=America/New_York:20260902T100000", + "RRULE:FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=20261231T000000Z", + "GEO:37.386013;-122.082932", + 'X-ALT-DESC;FMTTYPE=text/html:', + "ATTACH;FMTTYPE=text/plain;ENCODING=BASE64;VALUE=BINARY:SGVsbG8=", + "DESCRIPTION:Line one\\nLine two\\, with comma", + ], + "property name folding": ["ME", " THOD:REQUEST"], + "parameter name folding": ["METHOD;X-", " A=1:REQUEST"], + "quoted parameter folding": [ + 'ATTENDEE;CN="Doe,', + ' Jane":mailto:jane@example.net', + ], + "fold immediately after colon": ["METHOD:", " REQUEST"], + "fold after a closing quote": ['METHOD;X="a"', " ,b:REQUEST"], + "tab-led continuation": ["METHOD:REQ", "\tUEST"], + "long unfolded line": ["SUMMARY:" + "x".repeat(100)], + "parameterized nested METHOD": [ + "BEGIN:VEVENT", + "METHOD;X-A=1:CANCEL", + "END:VEVENT", + ], + }; + for (const [description, lines] of Object.entries(validLines)) { + it(`should preserve ${description}`, () => { + // A folded property name does not begin with METHOD until unfolded. + const content = description === "property name folding" + ? ics("BEGIN:VCALENDAR", ...lines, "END:VCALENDAR") + : objectWith(lines); + assert.equal(parseCalendarMethod(content), "REQUEST"); + assert.deepEqual(resolveCalendarContent({ content }), { + method: "REQUEST", + content, + }); + }); + } + + it("should normalize a bare LF fold without removing it from output", () => { + const content = + "BEGIN:VCALENDAR\nMETHOD:REQUEST\nSUMMARY:a\n b\nEND:VCALENDAR\n"; + assert.equal(parseCalendarMethod(content), "REQUEST"); + assert.equal( + resolveCalendarContent({ content }).content, + content.replaceAll("\n", "\r\n"), + ); + }); + + it("should accept the documented invitation", () => { + const content = ics( + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Example//Booking//EN", + "METHOD:REQUEST", + "BEGIN:VEVENT", + "UID:booking-42@example.com", + "SEQUENCE:0", + "DTSTAMP:20260901T090000Z", + "DTSTART:20260902T120000Z", + "DTEND:20260902T130000Z", + "ORGANIZER:mailto:organizer@example.com", + "ATTENDEE;RSVP=TRUE:mailto:attendee@example.net", + "SUMMARY:Lunch", + "END:VEVENT", + "END:VCALENDAR", + ); + assert.deepEqual(resolveCalendarContent({ content }), { + method: "REQUEST", + content, + }); + }); + + it("should refuse malformed lines when creating the attachment fallback", () => { + assert.throws(() => + createCalendarAttachment({ + content: objectWith(["SUMMARY;BROKEN:Lunch"]), + }), TypeError); + }); +}); diff --git a/packages/core/src/calendar.ts b/packages/core/src/calendar.ts index f2910e0..22fc693 100644 --- a/packages/core/src/calendar.ts +++ b/packages/core/src/calendar.ts @@ -107,8 +107,8 @@ export interface CalendarConstructor { * does. It answers one question—which iTIP method does this object declare?— * and answers `undefined` whenever it cannot answer with confidence: no * `METHOD` at the top level, more than one, a method Upyo does not support, or - * an envelope it cannot make sense of. It never salvages a plausible token - * from malformed input. + * malformed content line syntax or component structure. It never salvages a + * plausible token from malformed input. * * Use {@link resolveCalendarContent} instead when you want to know *why* a * payload was refused. @@ -145,11 +145,12 @@ export function parseCalendarMethod( * before its method reaches a `Content-Type` parameter. * * This is deliberately not a full iCalendar validator. It checks what MIME - * composition depends on—that there is one calendar object, that its components - * balance, and that it declares exactly one supported method—and leaves the - * rest to the caller. Whether the object names an `ORGANIZER`, carries a - * stable `UID`, bumps `SEQUENCE` on an update, or satisfies the iTIP - * constraints for its method is not checked here. + * composition depends on: the syntax of every content line, one calendar + * object with balanced components, and exactly one supported method. It + * leaves property-specific value syntax and scheduling semantics to the caller. + * Whether the object names an `ORGANIZER`, carries a stable `UID`, bumps + * `SEQUENCE` on an update, or satisfies the iTIP constraints for its method is + * not checked here. * * @example * ```ts @@ -161,8 +162,8 @@ export function parseCalendarMethod( * @param calendar The payload to check. * @returns The payload with CRLF line endings and a resolved method. * @throws {TypeError} If the content is not a single well-formed calendar - * object, if it does not declare exactly one supported method, or if it - * declares a method other than the one asserted. + * object with valid content line syntax, if it does not declare exactly one + * supported method, or if it declares a method other than the one asserted. * @since 0.6.0 */ export function resolveCalendarContent( @@ -262,30 +263,35 @@ function normalizeLineEndings(content: string): string { * object. */ function readMethod(content: string): CalendarMethod | undefined { + // Check the transmitted text before unfolding: a fold between two lone + // surrogates must not rescue them into a pair only in the parsed view. + for (const character of content) { + const code = character.codePointAt(0)!; + if (code >= 0xd800 && code <= 0xdfff) { + throw new TypeError( + "The calendar content contains an unpaired UTF-16 surrogate.", + ); + } + } const stack: string[] = []; let closed = false; let method: CalendarMethod | undefined; let methods = 0; for (const line of unfold(content)) { - const { name, value, parameters } = splitProperty(line); + const parsed = parseContentLine(line); + const { name } = parsed; + const value = name === "BEGIN" || name === "END" || + (name === "METHOD" && stack.length === 1) + ? asciiUpperCase(parsed.value) + : parsed.value; - // Only these three lines decide what gets composed, so only these three - // are held to the grammar. A malformed parameter on a SUMMARY says - // nothing about the method this module claims in a `Content-Type`, and - // refusing the payload over one would reject objects a calendar client - // reads perfectly well. - if (name === "BEGIN" || name === "END") { - // RFC 5545 §3.4 spells a delimiter `BEGIN:`, with no parameters at - // all. A parameterized pair balances, so the component scan below would - // not notice that the object it is walking is malformed. - if (parameters !== "") { - throw new TypeError( - `The calendar content carries parameters on a ${name} delimiter.`, - ); - } - } else if (name === "METHOD" && stack.length === 1) { - checkParameters(parameters); + // Every line has passed the generic grammar. Component delimiters also + // have the stricter BEGIN: / END: shape from RFC 5545 §3.4. + if ((name === "BEGIN" || name === "END") && parsed.hasParameters) { + throw new TypeError( + `The calendar content carries parameters on a ${name} delimiter.`, + ); } if (name === "BEGIN") { @@ -359,66 +365,6 @@ function readMethod(content: string): CalendarMethod | undefined { return methods === 1 ? method : undefined; } -/** - * Checks the parameter section of a content line against RFC 5545 §3.1. - * - * The grammar is `*(";" param-name "=" param-value *("," param-value))`, where - * a name is an `iana-token` or an `x-name` and a value is either a quoted - * string or unquoted text holding no `;`, `:`, `,` or double quote. A section - * that does not fit it, such as the bare `;BROKEN` of `METHOD;BROKEN:REQUEST`, - * is not a parameter list, and the content line carrying it is one a calendar - * client may refuse; composing a `method` from it would claim something the - * payload does not reliably say. - * - * @param parameters The raw section between the property name and the colon, - * empty when the line has no parameters. - * @throws {TypeError} If the section is not a well-formed parameter list. - */ -function checkParameters(parameters: string): void { - if (parameters === "") return; - for (const parameter of splitParameters(parameters)) { - const separator = parameter.indexOf("="); - if (separator < 1) { - throw new TypeError( - `The calendar content carries a malformed METHOD parameter: ${ - JSON.stringify(parameter) - }`, - ); - } - const name = parameter.slice(0, separator); - if (!componentNamePattern.test(asciiUpperCase(name))) { - throw new TypeError( - `The calendar content carries a METHOD parameter named ${ - JSON.stringify(name) - }, which is not a parameter name.`, - ); - } - } -} - -/** - * Splits a parameter section into its parameters, at the semicolons that are - * not inside a quoted value. - * - * @param parameters The raw section, which begins with a semicolon. - * @returns The parameters, without their leading semicolons. - */ -function splitParameters(parameters: string): string[] { - const split: string[] = []; - let quoted = false; - let start = 1; - for (let i = 1; i < parameters.length; i++) { - const character = parameters[i]; - if (character === '"') quoted = !quoted; - else if (character === ";" && !quoted) { - split.push(parameters.slice(start, i)); - start = i + 1; - } - } - split.push(parameters.slice(start)); - return split; -} - /** * Upper-cases the ASCII letters of a token, leaving everything else alone. * @@ -441,47 +387,87 @@ function asciiUpperCase(token: string): string { } /** - * Splits a content line into its property name and value. + * Parses the generic content line grammar of RFC 5545 §3.1. * - * The value begins after the first colon that is not inside a quoted parameter - * value, so `METHOD;X-FOO="a:b":REQUEST` yields `REQUEST` rather than `b"`. - * iCalendar parameter syntax has no backslash escaping, so none is recognized. - * Both halves are upper-cased: RFC 5545 property names are case-insensitive, - * and every value compared here is a token. The folding is ASCII-only; see - * {@link asciiUpperCase}. + * VALUE-CHAR allows HTAB, printable ASCII and non-ASCII characters. QSAFE-CHAR + * further excludes quotes, and SAFE-CHAR also excludes commas, semicolons and + * colons. After checking VALUE-CHAR across the line, the cursor recognizes + * those delimiters only where the parameter grammar permits them. Backslashes + * and caret sequences are data; property-specific value syntax is not checked. * - * @param line One unfolded content line. - * @returns The upper-cased name and value, and the raw parameter section - * between them, which is empty when there is none. - * @throws {TypeError} If the line has no value separator, or leaves a parameter - * value quoted open. + * @param line An unfolded line from already well-formed UTF-16 content. + * @returns The ASCII-folded name, unchanged value and parameter presence. + * @throws {TypeError} If the line does not match the content line grammar. */ -function splitProperty( - line: string, -): { name: string; value: string; parameters: string } { - let quoted = false; - for (let i = 0; i < line.length; i++) { - const character = line[i]; - if (character === '"') quoted = !quoted; - else if (character === ":" && !quoted) { - const field = line.slice(0, i); - const name = field.split(";", 1)[0]; - return { - name: asciiUpperCase(name), - value: asciiUpperCase(line.slice(i + 1)), - parameters: field.slice(name.length), - }; +function parseContentLine(line: string): { + readonly name: string; + readonly value: string; + readonly hasParameters: boolean; +} { + function fail(reason: string): never { + throw new TypeError( + `The calendar content has ${reason}: ${JSON.stringify(line)}`, + ); + } + + for (const character of line) { + const code = character.codePointAt(0)!; + if ((code < 0x20 && code !== 0x09) || code === 0x7f) { + fail("a forbidden control character in a content line"); } } - throw new TypeError( - quoted - ? `The calendar content leaves a parameter value quoted open: ${ - JSON.stringify(line) - }` - : `The calendar content holds a line with no value: ${ - JSON.stringify(line) - }`, - ); + + let cursor = 0; + function readName(): string { + const start = cursor; + while (isTokenCharacter(line.charCodeAt(cursor))) cursor++; + if (cursor === start) fail("an invalid property or parameter name"); + return line.slice(start, cursor); + } + + const name = asciiUpperCase(readName()); + const hasParameters = line[cursor] === ";"; + while (line[cursor] === ";") { + cursor++; + readName(); + if (line[cursor] !== "=") fail("a parameter without an equals sign"); + cursor++; + + // Both paramtext and quoted-string may be empty. A comma therefore + // always starts another value, even immediately before ';' or ':'. + while (true) { + if (line[cursor] === '"') { + cursor++; + while (cursor < line.length && line[cursor] !== '"') cursor++; + if (cursor === line.length) fail("an unterminated quoted parameter"); + cursor++; + } else { + while ( + cursor < line.length && line[cursor] !== "," && + line[cursor] !== ";" && line[cursor] !== ":" + ) { + if (line[cursor] === '"') { + fail("a quote inside an unquoted parameter"); + } + cursor++; + } + } + if (line[cursor] !== ",") break; + cursor++; + } + if (line[cursor] !== ";" && line[cursor] !== ":") { + fail("an invalid delimiter after a parameter value"); + } + } + if (line[cursor] !== ":") fail("a missing content line value separator"); + return { name, value: line.slice(cursor + 1), hasParameters }; +} + +/** Whether a code unit belongs to the iana-token / x-name character set. */ +function isTokenCharacter(code: number): boolean { + return (code >= 0x41 && code <= 0x5a) || + (code >= 0x61 && code <= 0x7a) || + (code >= 0x30 && code <= 0x39) || code === 0x2d; } /** diff --git a/packages/core/src/message.test.ts b/packages/core/src/message.test.ts index c5f0950..fa3126c 100644 --- a/packages/core/src/message.test.ts +++ b/packages/core/src/message.test.ts @@ -602,6 +602,17 @@ describe("createMessage() calendar", () => { ); }); + it("should reject malformed calendar property syntax", () => { + assert.throws(() => + createMessage({ + ...base, + calendar: { + content: + "BEGIN:VCALENDAR\r\nMETHOD:REQUEST\r\nSUMMARY;BROKEN:Lunch\r\nEND:VCALENDAR\r\n", + }, + }), TypeError); + }); + it("should reject content that is not a calendar object", () => { assert.throws( () => createMessage({ ...base, calendar: { content: "METHOD:REQUEST" } }), diff --git a/packages/jmap/src/message-converter.test.ts b/packages/jmap/src/message-converter.test.ts index c52d72c..9f24164 100644 --- a/packages/jmap/src/message-converter.test.ts +++ b/packages/jmap/src/message-converter.test.ts @@ -201,6 +201,21 @@ describe("convertMessage", () => { assert.equal(result.bodyStructure?.partId, "text"); }); + it("should reject malformed calendar property syntax", () => { + const message: Message = { + ...baseMessage, + calendar: { + method: "REQUEST", + content: + "BEGIN:VCALENDAR\r\nMETHOD:REQUEST\r\nSUMMARY;BROKEN:Lunch\r\nEND:VCALENDAR\r\n", + }, + }; + assert.throws( + () => convertMessage(message, "drafts-123", new Map()), + TypeError, + ); + }); + it("should reject calendar content that never passed createMessage()", () => { const message: Message = { ...baseMessage, diff --git a/packages/mailgun/src/message-converter.test.ts b/packages/mailgun/src/message-converter.test.ts index 03c038c..f9b5a7e 100644 --- a/packages/mailgun/src/message-converter.test.ts +++ b/packages/mailgun/src/message-converter.test.ts @@ -413,6 +413,31 @@ describe("convertMessage() calendar", () => { ); }); + it("should reject malformed calendar property syntax", async () => { + const message: Message = { + sender: { address: "organizer@example.com" }, + recipients: [{ address: "attendee@example.net" }], + ccRecipients: [], + bccRecipients: [], + replyRecipients: [], + subject: "Lunch", + content: { text: "Lunch." }, + attachments: [], + priority: "normal", + tags: [], + headers: new Headers(), + calendar: { + method: "REQUEST", + content: + "BEGIN:VCALENDAR\r\nMETHOD:REQUEST\r\nSUMMARY;BROKEN:Lunch\r\nEND:VCALENDAR\r\n", + }, + }; + + await assert.rejects(() => convertMessage(message, config), { + name: "TypeError", + }); + }); + it("should reject calendar content that never passed createMessage()", async () => { const message: Message = { sender: { address: "organizer@example.com" }, diff --git a/packages/smtp/src/message-converter.integration.test.ts b/packages/smtp/src/message-converter.integration.test.ts index 2658ece..6cc4049 100644 --- a/packages/smtp/src/message-converter.integration.test.ts +++ b/packages/smtp/src/message-converter.integration.test.ts @@ -1572,6 +1572,17 @@ describe("Message Converter Integration Tests", () => { assert.equal(decodeCalendarPart(result.raw), request); }); + test("should reject malformed calendar property syntax", async () => { + const message = createTestMessage({ + calendar: { + method: "REQUEST", + content: + "BEGIN:VCALENDAR\r\nMETHOD:REQUEST\r\nSUMMARY;BROKEN:Lunch\r\nEND:VCALENDAR\r\n", + }, + }); + await assert.rejects(() => convertMessage(message), TypeError); + }); + test("should reject calendar content that never passed createMessage()", async () => { const message = createTestMessage({ calendar: {