Repository: adamgibbons/ics
Pull request #252 (2023-09-17 11:35:39)

Allow `organiser` to contain comma, ensure that new lines in values don't break output, and various other tweaks
Thanks for this great library!

We had a bug where we were providing an organiser where the name included a `,`, and the fix was to wrap the name in quotes. Essentially take the `quoted-string` option for `param-value` in [the spec](https://www.ietf.org/rfc/rfc5545.txt)

So the I decided to make the same change to the other `param-value`'s so that they don't have the same issue.

And then I added some logic to encode new lines to`\n` in a lot more places because otherwise it's possible for user input containing new lines to break things

And as I was going through I spotted a few other things so here's a list of all the tweaks/fixes:

- `exclusionDates` was missing in the schema so I don't think it could be used before? Now it can be and I changed it to be an array of date/time entries
- date time entries can now be a single number which is the unix time, which is easy to get from `date.getTime()`, or a string which is then just passed through
- the default values are now built with a fn at runtime as this means the default date will be the invoke time and not the time when the library was loaded, and also means the uuid is unique every time and `assignUniqueId` was no longer needed
- some of the regex patterns in the schema check weren't bounded. I.e. `oopsCANCELLED` would be allowed
- I split up the event into header/event/footer (the second commit). This makes it a bit cleaner when there are no events as don't need to do any string searching anymore. It still supports taking the calendar name etc from the first event object, ~but in the future it might be worth updating the public api to support passing the header fields separately to the event ones, so that then if you are creating a calendar feed where the are no events it's still possible to set the header fields and not get the defaults~
- Added an optional second param to `getEvents` which can take header attributes, so it's possible to still have the correct header attributes even when there are no events
- added `encodeParamValue` which is used to encode `param-value`'s. The spec doesn't appear to say that `"`'s are allowed in the string, but escaping them with `\` does seem to work when I tested in the apple calendar app

tjenkinson: Just realised I still need to update the d.ts file

Patch:
diff --git a/README.md b/README.md
index d77840f..e992877 100644
--- a/README.md
+++ b/README.md
@@ -121,18 +121,20 @@ console.log(value)
 // VERSION:2.0
 // CALSCALE:GREGORIAN
 // PRODID:adamgibbons/ics
+// METHOD:PUBLISH
+// X-PUBLISHED-TTL:PT1H
 // BEGIN:VEVENT
-// UID:mPfHOGi_sif_xO493Mgi6
+// UID:pP83XzQPo5RlvjDCMIINs
 // SUMMARY:Lunch
-// DTSTAMP:20180210T093900Z
-// DTSTART:20180115T191500Z
+// DTSTAMP:20230917T142209Z
+// DTSTART:20180115T121500Z
 // DURATION:PT45M
 // END:VEVENT
 // BEGIN:VEVENT
-// UID:ho-KcKyhNaQVDqJCcGfXD
+// UID:gy5vfUVv6wjyBeNkkFmBX
 // SUMMARY:Dinner
-// DTSTAMP:20180210T093900Z
-// DTSTART:20180115T191500Z
+// DTSTAMP:20230917T142209Z
+// DTSTART:20180115T121500Z
 // DURATION:PT1H30M
 // END:VEVENT
 // END:VCALENDAR
@@ -145,8 +147,8 @@ let moment = require("moment")
 let events = []
 let alarms = []
 
-let start = moment().format('YYYY-M-D-H-m').split("-")
-let end = moment().add({'hours':2, "minutes":30}).format("YYYY-M-D-H-m").split("-")
+let start = moment().format('YYYY-M-D-H-m').split("-").map((a) => parseInt(a))
+let end = moment().add({'hours':2, "minutes":30}).format("YYYY-M-D-H-m").split("-").map((a) => parseInt(a))
 
 alarms.push({
   action: 'audio',
@@ -167,34 +169,28 @@ let event = {
   alarms: alarms
 }
 events.push(event)
-console.log(ics.createEvents(events))
+console.log(ics.createEvents(events).value)
 
 // BEGIN:VCALENDAR
 // VERSION:2.0
 // CALSCALE:GREGORIAN
-// PRODID:MyCalendarId
+// PRODID:myCalendarId
 // METHOD:PUBLISH
 // X-PUBLISHED-TTL:PT1H
 // BEGIN:VEVENT
-// UID:123@MyCalendarIdics.com
+// UID:123@ics.com
 // SUMMARY:test here
-// DTSTAMP:20180409T072100Z
-// DTSTART:20180409
-// DTEND:20180409
-// BEGIN:VALARM
-// ACTION:DISPLAY
-// DESCRIPTION:Reminder
-// TRIGGER:-PT2H30M
-// END:VALARM
+// DTSTAMP:20230917T142621Z
+// DTSTART:20230917T152600
+// DTEND:20230917T175600
 // BEGIN:VALARM
 // ACTION:AUDIO
 // REPEAT:2
+// DESCRIPTION:Reminder
 // ATTACH;VALUE=URI:Glass
-// TRIGGER:PT2H
-// END:VALARM
+// TRIGGER:-PT2H30M\nEND:VALARM
 // END:VEVENT
 // END:VCALENDAR
-
 ```
 
 #### Using ESModules & in the browser
@@ -246,14 +242,17 @@ If a callback is provided, returns a Node-style callback.
 
 Object literal containing event information.
 Only the `start` property is required.
+
+Note all date/time fields can be the array form, or a number representing the unix timestamp in milliseconds (e.g. `getTime()` on a `Date`).
+
 The following properties are accepted:
 
 | Property      | Description   | Example  |
 | ------------- | ------------- | ----------
-| start         | **Required**. Date and time at which the event begins. | `[2000, 1, 5, 10, 0]` (January 5, 2000)
+| start         | **Required**. Date and time at which the event begins. | `[2000, 1, 5, 10, 0]` (January 5, 2000) or a `number`
 | startInputType | Type of the date/time data in `start`:<br>`local` (default): passed data is in local time.<br>`utc`: passed data is UTC |
 | startOutputType | Format of the start date/time in the output:<br>`utc` (default): the start date will be sent in UTC format.<br>`local`: the start date will be sent as "floating" (form #1 in [RFC 5545](https://tools.ietf.org/html/rfc5545#section-3.3.5)) |
-| end           | Time at which event ends. *Either* `end` or `duration` is required, but *not* both. | `[2000, 1, 5, 13, 5]` (January 5, 2000 at 1pm)
+| end           | Time at which event ends. *Either* `end` or `duration` is required, but *not* both. | `[2000, 1, 5, 13, 5]` (January 5, 2000 at 1pm) or a `number`
 | endInputType | Type of the date/time data in `end`:<br>`local`: passed data is in local time.<br>`utc`: passed data is UTC.<br>The default is the value of `startInputType` |
 | endOutputType | Format of the start date/time in the output:<br>`utc`: the start date will be sent in UTC format.<br>`local`: the start date will be sent as "floating" (form #1 in [RFC 5545](https://tools.ietf.org/html/rfc5545#section-3.3.5)).<br>The default is the value of `startOutputType` |
 | duration      | How long the event lasts. Object literal having form `{ weeks, days, hours, minutes, seconds }` *Either* `end` or `duration` is required, but *not* both. | `{ hours: 1, minutes: 45 }` (1 hour and 45 minutes)
@@ -271,13 +270,13 @@ The following properties are accepted:
 | uid           | Universal unique id for event, produced by default with `nanoid`.  **Warning:** This value must be **globally unique**.  It is recommended that it follow the [RFC 822 addr-spec](https://www.w3.org/Protocols/rfc822/) (i.e. `localpart@domain`).  Including the `@domain` half is a good way to ensure uniqueness. | `'LZfXLFzPPR4NNrgjlWDxn'`
 | method        | This property defines the iCalendar object method associated with the calendar object. When used in a MIME message entity, the value of this property MUST be the same as the Content-Type "method" parameter value.  If either the "METHOD" property or the Content-Type "method" parameter is specified, then the other MUST also be specified. | `PUBLISH`
 | recurrenceRule        | A recurrence rule, commonly referred to as an RRULE, defines the repeat pattern or rule for to-dos, journal entries and events. If specified, RRULE can be used to compute the recurrence set (the complete set of recurrence instances in a calendar component). You can use a generator like this [one](https://www.textmagic.com/free-tools/rrule-generator). | `FREQ=DAILY`
-| exclusionDates| This property defines the list of DATE-TIME exceptions for recurring events, to-dos, journal entries, or time zone definitions. Uses a comma-delimited list of [Date-Time](https://tools.ietf.org/html/rfc5545#section-3.3.5) values. See [EXDATE spec](https://tools.ietf.org/html/rfc5545#section-3.8.5.1).|`'20230620T131500Z,20230621T131500'` (June 20th, 2023 at 1:15pm UTC and June 21st, 2000 at 1:15pm LOCAL)
+| exclusionDates | Array of date-time exceptions for recurring events, to-dos, journal entries, or time zone definitions. | `[[2000, 1, 5, 10, 0], [2000, 2, 5, 10, 0]]` OR `[1694941727477, 1694945327477]`
 | sequence      | For sending an update for an event (with the same uid), defines the revision sequence number. | `2`
 | busyStatus    | Used to specify busy status for Microsoft applications, like Outlook. See [Microsoft spec](https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxcical/cd68eae7-ed65-4dd3-8ea7-ad585c76c736). | `'BUSY'` OR `'FREE'` OR `'TENTATIVE`' OR `'OOF'`
 | transp        | Used to specify event transparency (does event consume actual time of an individual). Used by Google Calendar to determine if event should change attendees availability to 'Busy' or not. | `'TRANSPARENT'` OR `'OPAQUE'`
 | classification    | This property defines the access classification for a calendar component. See [iCalender spec](https://icalendar.org/iCalendar-RFC-5545/3-8-1-3-classification.html). | `'PUBLIC'` OR `'PRIVATE'` OR `'CONFIDENTIAL`' OR any non-standard string
-| created | Date-time representing event's creation date. Provide a date-time in UTC | [2000, 1, 5, 10, 0] (January 5, 2000 GMT +00:00)
-| lastModified | Date-time representing date when event was last modified. Provide a date-time in UTC | [2000, 1, 5, 10, 0] (January 5, 2000 GMT +00:00)
+| created | Date-time representing event's creation date. Provide a date-time in local time | `[2000, 1, 5, 10, 0]` or a `number`
+| lastModified | Date-time representing date when event was last modified. Provide a date-time in local time | [2000, 1, 5, 10, 0] or a `number`
 | calName       |  Specifies the _calendar_ (not event) name. Used by Apple iCal and Microsoft Outlook; see [Open Specification](https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxcical/1da58449-b97e-46bd-b018-a1ce576f3e6d) | `'Example Calendar'` |
 | htmlContent       | Used to include HTML markup in an event's description. Standard DESCRIPTION tag should contain non-HTML version. | `<!DOCTYPE html><html><body><p>This is<br>test<br>html code.</p></body></html>` |
 
@@ -308,10 +307,12 @@ function (err, value) {
 }
 ```
 
-### `createEvents(events[, callback])`
+### `createEvents(events[, headerParams, callback])`
 
 Generates an iCal-compliant VCALENDAR string with multiple VEVENTS.
 
+`headerParams` may be omitted, and in this case they will be read from the first event.
+
 If a callback is not provided, returns an object having the form `{ error, value }`, where value is an iCal-compliant text string
 if `error` is `null`.
 
diff --git a/index.d.ts b/index.d.ts
index 8cba459..84a4d3e 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -1,3 +1,5 @@
+export type DateTime = DateArray | number | string;
+
 export type DateArray =
   | [number, number, number, number, number]
   | [number, number, number, number]
@@ -67,14 +69,20 @@ export type Alarm = {
   description?: string;
   summary?: string;
   duration?: DurationObject;
-  trigger?: DurationObject; // @todo DateArray | DurationObject;
+  trigger?: DurationObject | DateTime;
   repeat?: number;
   attachType?: string;
   attach?: string;
 };
 
+export type HeaderAttributes = {
+  productId?: string;
+  method?: string;
+  calName?: string;
+}
+
 export type EventAttributes = {
-  start: DateArray;
+  start: DateTime;
   startInputType?: 'local' | 'utc';
   startOutputType?: 'local' | 'utc';
 
@@ -100,18 +108,18 @@ export type EventAttributes = {
   categories?: string[];
   alarms?: Alarm[];
 
-  productId?: string;
+  productId?: HeaderAttributes['productId'];
   uid?: string;
-  method?: string;
+  method?: HeaderAttributes['method'];
   recurrenceRule?: string;
   exclusionDates?: string;
   sequence?: number;
-  calName?: string;
+  calName?: HeaderAttributes['calName'];
   classification?: classificationType;
-  created?: DateArray;
-  lastModified?: DateArray;
+  created?: DateTime;
+  lastModified?: DateTime;
   htmlContent?: string;
-} & ({ end: DateArray } | { duration: DurationObject });
+} & ({ end: DateTime } | { duration: DurationObject });
 
 export type ReturnObject = { error?: Error; value?: string };
 
@@ -122,7 +130,7 @@ export function createEvent(attributes: EventAttributes, callback: NodeCallback)
 export function createEvent(attributes: EventAttributes): ReturnObject;
 
 export function createEvents(events: EventAttributes[], callback: NodeCallback): void;
-
-export function createEvents(events: EventAttributes[]): ReturnObject;
+export function createEvents(events: EventAttributes[], headerAttributes?: HeaderAttributes): ReturnObject;
+export function createEvents(events: EventAttributes[], headerAttributes: HeaderAttributes, callback: NodeCallback): void;
 
 export function convertTimestampToArray(timestamp: Number, inputType: String): DateArray;
diff --git a/src/defaults.js b/src/defaults.js
index 7ee8f46..d453f09 100644
--- a/src/defaults.js
+++ b/src/defaults.js
@@ -1,13 +1,12 @@
 import { nanoid } from 'nanoid'
-import { formatDate } from './utils'
 
-const defaults = {
-  title: 'Untitled event',
+export const headerDefaults = () => ({
   productId: 'adamgibbons/ics',
-  method: 'PUBLISH',
-  uid: nanoid(),
-  timestamp: formatDate(null, 'utc'),
-  start: formatDate(null, 'utc')
-}
+  method: 'PUBLISH'
+})
 
-export default defaults
+export const eventDefaults = () => ({
+  title: 'Untitled event',
+  uid: nanoid(),
+  timestamp: Date.now()
+})
diff --git a/src/index.js b/src/index.js
index 8462e4f..df23c17 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,56 +1,19 @@
-import { nanoid }  from 'nanoid'
 import {
+  buildHeader,
   buildEvent,
-  validateEvent,
-  formatEvent
+  validateHeader,
+  validateHeaderAndEvent,
+  formatHeader,
+  formatEvent,
+  formatFooter,
 } from './pipeline'
 
-function assignUniqueId(event) {
-  event.uid = event.uid || nanoid()
-  return event
+function buildHeaderAndValidate(header) {
+  return validateHeader(buildHeader(header))
 }
-function validateAndBuildEvent(event) {
-  return validateEvent(buildEvent(event))
-}
-
-function applyInitialFormatting({ error, value }) {
-  if (error) {
-    return { error, value: null }
-  }
-
-  return { error: null, value: formatEvent(value) }
-}
-
-function reformatEventsByPosition({ error, value }, idx, list) {
-  if (error) return { error, value }
-
-  if (idx === 0) {
-    // beginning of list
-    return { value: value.slice(0, value.indexOf('END:VCALENDAR')), error: null }
-  }
-
-  if (idx === list.length - 1) {
-    // end of list
-    return { value: value.slice(value.indexOf('BEGIN:VEVENT')), error: null}
-  }
-
-  return { error: null, value: value.slice(value.indexOf('BEGIN:VEVENT'), value.indexOf('END:VEVENT') + 12) }
-}
-
-function catenateEvents(accumulator, { error, value }, idx) {
-  if (error) {
-    accumulator.error = error
-    accumulator.value = null
-    return accumulator
-  }
-
-  if (accumulator.value) {
-    accumulator.value = accumulator.value.concat(value)
-    return accumulator
-  }
 
-  accumulator.value = value
-  return accumulator
+function buildHeaderAndEventAndValidate(event) {
+  return validateHeaderAndEvent({...buildHeader(event), ...buildEvent(event) })
 }
 
 export function convertTimestampToArray(timestamp, inputType = 'local') {
@@ -65,69 +28,51 @@ export function convertTimestampToArray(timestamp, inputType = 'local') {
 }
 
 export function createEvent (attributes, cb) {
-  if (!attributes) { Error('Attributes argument is required') }
-
-  assignUniqueId(attributes)
+  return createEvents([attributes], cb)
+}
 
-  if (!cb) {
-    // No callback, so return error or value in an object
-    const { error, value } = validateAndBuildEvent(attributes)
+export function createEvents (events, headerAttributesOrCb, cb) {
+  const resolvedHeaderAttributes = typeof headerAttributesOrCb === 'object' ? headerAttributesOrCb : {};
+  const resolvedCb = arguments.length === 3 ? cb : (typeof headerAttributesOrCb === 'function' ? headerAttributesOrCb : null);
 
-    if (error) return { error, value }
+  const run = () => {
+    if (!events) {
+      return { error: new Error('one argument is required'), value: null }
+    }
 
-    let event = ''
+    const { error: headerError, value: headerValue } = events.length === 0
+      ? buildHeaderAndValidate(resolvedHeaderAttributes)
+      : buildHeaderAndEventAndValidate({...events[0], ...resolvedHeaderAttributes});
 
-    try {
-      event = formatEvent(value)
-    } catch(error) {
-      return { error, value: null }
+    if (headerError) {
+      return {error: headerError, value: null}
     }
 
-    return { error: null, value: event }
-  }
+    let value = ''
+    value += formatHeader(headerValue)
 
-  // Return a node-style callback
-  const { error, value } = validateAndBuildEvent(attributes)
+    for (let i = 0; i < events.length; i++) {
+      const { error: eventError, value: eventValue } = buildHeaderAndEventAndValidate(events[i])
+      if (eventError) return {error: eventError, value: null}
 
-  if (error) return cb(error)
-
-  return cb(null, formatEvent(value))
-}
+      value += formatEvent(eventValue);
+    }
 
-export function createEvents (events, cb) {
-  if (!events) {
-    return { error: Error('one argument is required'), value: null }
-  }
+    value += formatFooter();
 
-  if (events.length === 0) {
-    const {error, value: dummy} = createEvent({
-      start: [2000, 10, 5, 5, 0],
-      duration: { hours: 1 }
-    })
-    if (error) return {error, value: null}
-
-    return {
-      error: null,
-      value: (
-        dummy.slice(0, dummy.indexOf('BEGIN:VEVENT')) +
-        dummy.slice(dummy.indexOf('END:VEVENT') + 10 + 2)
-      )
-    }
+    return { error: null, value }
   }
 
-  if (events.length === 1) {
-    return createEvent(events[0], cb)
+  let returnValue;
+  try {
+    returnValue = run();
+  } catch (e) {
+    returnValue = { error: e, value: null }
   }
 
-  const { error, value } = events.map(assignUniqueId)
-    .map(validateAndBuildEvent)
-    .map(applyInitialFormatting)
-    .map(reformatEventsByPosition)
-    .reduce(catenateEvents, { error: null, value: null })
-
-  if (!cb) {
-    return { error, value }
+  if (!resolvedCb) {
+    return returnValue
   }
 
-  return cb(error, value)
+  return resolvedCb(returnValue.error, returnValue.value)
 }
diff --git a/src/pipeline/build.js b/src/pipeline/build.js
index 1039c7c..a9b4ef3 100644
--- a/src/pipeline/build.js
+++ b/src/pipeline/build.js
@@ -1,38 +1,22 @@
-import defaultAttributes from "../defaults";
+import { headerDefaults, eventDefaults } from "../defaults";
 
-export default function buildEvent(attributes = {}) {
-  const {
-    title,
-    productId,
-    method,
-    uid,
-    sequence,
-    start,
-    startType,
-    duration,
-    end,
-    description,
-    url,
-    geo,
-    location,
-    status,
-    categories,
-    organizer,
-    attendees,
-    alarms,
-    recurrenceRule,
-    created,
-    lastModified,
-    calName,
-    htmlContent
-  } = attributes;
+function removeUndefined(input) {
+  return Object.entries(input).reduce(
+    (clean, entry) => typeof entry[1] !== 'undefined' ? Object.assign(clean, {[entry[0]]: entry[1]}) : clean,
+    {}
+  )
+}
 
+export function buildHeader(attributes = {}) {
   // fill in default values where necessary
-  const output = Object.assign({}, defaultAttributes, attributes);
+  const output = Object.assign({}, headerDefaults(), attributes);
 
-  // remove undefined values
-  return Object.entries(output).reduce(
-      (clean, entry) => typeof entry[1] !== 'undefined' ? Object.assign(clean, {[entry[0]]: entry[1]}) : clean,
-      {}
-  )
+  return removeUndefined(output)
+}
+
+export function buildEvent(attributes = {}) {
+  // fill in default values where necessary
+  const output = Object.assign({}, eventDefaults(), attributes);
+
+  return removeUndefined(output)
 }
diff --git a/src/pipeline/format.js b/src/pipeline/format.js
index 92d2fa9..02349b9 100644
--- a/src/pipeline/format.js
+++ b/src/pipeline/format.js
@@ -10,12 +10,34 @@ import {
     formatDuration,
     foldLine
 } from '../utils'
+import encodeNewLines from '../utils/encode-new-lines'
 
-export default function formatEvent(attributes = {}) {
+export function formatHeader(attributes = {}) {
   const {
-    title,
     productId,
     method,
+    calName,
+  } = attributes
+
+  let icsFormat = ''
+  icsFormat += 'BEGIN:VCALENDAR\r\n'
+  icsFormat += 'VERSION:2.0\r\n'
+  icsFormat += 'CALSCALE:GREGORIAN\r\n'
+  icsFormat += foldLine(`PRODID:${encodeNewLines(productId)}`) + '\r\n'
+  icsFormat += foldLine(`METHOD:${encodeNewLines(method)}`) + '\r\n'
+  icsFormat += calName ? (foldLine(`X-WR-CALNAME:${encodeNewLines(calName)}`) + '\r\n') : ''
+  icsFormat += `X-PUBLISHED-TTL:PT1H\r\n`
+
+  return icsFormat
+}
+
+export function formatFooter() {
+  return `END:VCALENDAR\r\n`
+}
+
+export function formatEvent(attributes = {}) {
+  const {
+    title,
     uid,
     sequence,
     timestamp,
@@ -43,62 +65,53 @@ export default function formatEvent(attributes = {}) {
     classification,
     created,
     lastModified,
-    calName,
     htmlContent
   } = attributes
 
   let icsFormat = ''
-  icsFormat += 'BEGIN:VCALENDAR\r\n'
-  icsFormat += 'VERSION:2.0\r\n'
-  icsFormat += 'CALSCALE:GREGORIAN\r\n'
-  icsFormat += foldLine(`PRODID:${productId}`) + '\r\n'
-  icsFormat += foldLine(`METHOD:${method}`) + '\r\n'
-  icsFormat += calName ? (foldLine(`X-WR-CALNAME:${calName}`) + '\r\n') : ''
-  icsFormat += `X-PUBLISHED-TTL:PT1H\r\n`
   icsFormat += 'BEGIN:VEVENT\r\n'
-  icsFormat += `UID:${uid}\r\n`
-  icsFormat +=  foldLine(`SUMMARY:${title ? setSummary(title) : title}`) + '\r\n'
-  icsFormat += `DTSTAMP:${timestamp}\r\n`
+  icsFormat += foldLine(`UID:${encodeNewLines(uid)}`) + '\r\n'
+  icsFormat += title ? foldLine(`SUMMARY:${encodeNewLines(setSummary(title))}`) + '\r\n' : ''
+  icsFormat += foldLine(`DTSTAMP:${encodeNewLines(formatDate(timestamp))}`) + '\r\n'
 
   // All day events like anniversaries must be specified as VALUE type DATE
-  icsFormat += `DTSTART${start && start.length == 3 ? ";VALUE=DATE" : ""}:${formatDate(start, startOutputType || startType, startInputType)}\r\n`
+  icsFormat += foldLine(`DTSTART${start && start.length == 3 ? ";VALUE=DATE" : ""}:${encodeNewLines(formatDate(start, startOutputType || startType, startInputType))}`) + '\r\n'
 
   // End is not required for all day events on single days (like anniversaries)
   if (!end || end.length !== 3 || start.length !== end.length || start.some((val, i) => val !== end[i])) {
     if (end) {
-      icsFormat += `DTEND${end.length === 3 ? ";VALUE=DATE" : ""}:${formatDate(end, endOutputType || startOutputType || startType, endInputType || startInputType)}\r\n`;
+      icsFormat += foldLine(`DTEND${end.length === 3 ? ";VALUE=DATE" : ""}:${encodeNewLines(formatDate(end, endOutputType || startOutputType || startType, endInputType || startInputType))}`) + '\r\n'
     }
   }
 
   icsFormat += typeof sequence !== 'undefined' ? (`SEQUENCE:${sequence}\r\n`) : ''
-  icsFormat += description ? (foldLine(`DESCRIPTION:${setDescription(description)}`) + '\r\n') : ''
-  icsFormat += url ? (foldLine(`URL:${url}`) + '\r\n') : ''
+  icsFormat += description ? (foldLine(`DESCRIPTION:${encodeNewLines(setDescription(description))}`) + '\r\n') : ''
+  icsFormat += url ? (foldLine(`URL:${encodeNewLines(url)}`) + '\r\n') : ''
   icsFormat += geo ? (foldLine(`GEO:${setGeolocation(geo)}`) + '\r\n') : ''
-  icsFormat += location ? (foldLine(`LOCATION:${setLocation(location)}`) + '\r\n') : ''
-  icsFormat += status ? (foldLine(`STATUS:${status}`) + '\r\n') : ''
-  icsFormat += categories ? (foldLine(`CATEGORIES:${categories}`) + '\r\n') : ''
+  icsFormat += location ? (foldLine(`LOCATION:${encodeNewLines(setLocation(location))}`) + '\r\n') : ''
+  icsFormat += status ? (foldLine(`STATUS:${encodeNewLines(status)}`) + '\r\n') : ''
+  icsFormat += categories ? (foldLine(`CATEGORIES:${encodeNewLines(categories.join(','))}`) + '\r\n') : ''
   icsFormat += organizer ? (foldLine(`ORGANIZER;${setOrganizer(organizer)}`) + '\r\n') : ''
-  icsFormat += busyStatus ? (foldLine(`X-MICROSOFT-CDO-BUSYSTATUS:${busyStatus}`) + '\r\n') : ''
-  icsFormat += transp ? (foldLine(`TRANSP:${transp}`) + '\r\n') : ''
-  icsFormat += classification ? (foldLine(`CLASS:${classification}`) + '\r\n') : ''
-  icsFormat += created ? ('CREATED:' + formatDate(created) + '\r\n') : ''
-  icsFormat += lastModified ? ('LAST-MODIFIED:' + formatDate(lastModified) + '\r\n') : ''
-  icsFormat += htmlContent ? (foldLine(`X-ALT-DESC;FMTTYPE=text/html:${htmlContent}`) + '\r\n') : ''
+  icsFormat += busyStatus ? (foldLine(`X-MICROSOFT-CDO-BUSYSTATUS:${encodeNewLines(busyStatus)}`) + '\r\n') : ''
+  icsFormat += transp ? (foldLine(`TRANSP:${encodeNewLines(transp)}`) + '\r\n') : ''
+  icsFormat += classification ? (foldLine(`CLASS:${encodeNewLines(classification)}`) + '\r\n') : ''
+  icsFormat += created ? ('CREATED:' + encodeNewLines(formatDate(created)) + '\r\n') : ''
+  icsFormat += lastModified ? ('LAST-MODIFIED:' + encodeNewLines(formatDate(lastModified)) + '\r\n') : ''
+  icsFormat += htmlContent ? (foldLine(`X-ALT-DESC;FMTTYPE=text/html:${encodeNewLines(htmlContent)}`) + '\r\n') : ''
   if (attendees) {
-    attendees.map(function (attendee) {
-      icsFormat += foldLine(`ATTENDEE;${setContact(attendee)}`) + '\r\n'
+    attendees.forEach((attendee) => {
+      icsFormat += foldLine(`ATTENDEE;${encodeNewLines(setContact(attendee))}`) + '\r\n'
     })
   }
-  icsFormat += recurrenceRule ? `RRULE:${recurrenceRule}\r\n` : ''
-  icsFormat += exclusionDates ? `EXDATE:${exclusionDates}\r\n` : ''
-  icsFormat += duration ? `DURATION:${formatDuration(duration)}\r\n` : ''
+  icsFormat += recurrenceRule ? foldLine(`RRULE:${encodeNewLines(recurrenceRule)}`) + '\r\n' : ''
+  icsFormat += exclusionDates ? foldLine(`EXDATE:${encodeNewLines(exclusionDates.map((a) => formatDate(a)).join(','))}`) + '\r\n': ''
+  icsFormat += duration ? foldLine(`DURATION:${formatDuration(duration)}`) + '\r\n' : ''
   if (alarms) {
-    alarms.map(function (alarm) {
+    alarms.forEach((alarm) => {
       icsFormat += setAlarm(alarm)
     })
   }
   icsFormat += `END:VEVENT\r\n`
-  icsFormat += `END:VCALENDAR\r\n`
 
   return icsFormat
 }
diff --git a/src/pipeline/index.js b/src/pipeline/index.js
index 7c0e31f..a20e5e1 100644
--- a/src/pipeline/index.js
+++ b/src/pipeline/index.js
@@ -1,9 +1,13 @@
-import buildEvent from './build'
-import formatEvent from './format'
-import validateEvent from './validate'
+import { buildHeader, buildEvent } from './build'
+import { formatHeader, formatEvent, formatFooter } from './format'
+import { validateHeader, validateHeaderAndEvent } from './validate'
 
 export {
+  buildHeader,
   buildEvent,
+  formatHeader,
   formatEvent,
-  validateEvent
+  formatFooter,
+  validateHeader,
+  validateHeaderAndEvent
 }
diff --git a/src/pipeline/validate.js b/src/pipeline/validate.js
index e169acc..50d0dba 100644
--- a/src/pipeline/validate.js
+++ b/src/pipeline/validate.js
@@ -1,3 +1,1 @@
-import validate from '../schema'
-
-export default validate
+export * from '../schema'
diff --git a/src/schema/index.js b/src/schema/index.js
index 70458a1..6f8c64e 100644
--- a/src/schema/index.js
+++ b/src/schema/index.js
@@ -6,7 +6,18 @@ import * as yup from 'yup'
 // "url must match the following: ...." as opposed to "url must be a valid URL"
 const urlRegex = /^(?:([a-z0-9+.-]+):\/\/)(?:\S+(?::\S*)?@)?(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/
 
-const dateTimeSchema = yup.array().min(3).max(7).of(yup.lazy((item, options) => {
+const dateTimeSchema = ({ required }) => yup.lazy((value) => {
+  if (typeof value === 'number') {
+    return yup.number().integer().min(0)
+  }
+  if (typeof value === 'string') {
+    return yup.string().required()
+  }
+  if (!required && typeof value === 'undefined') {
+    return yup.mixed().oneOf([undefined])
+  }
+
+  return yup.array().required().min(3).max(7).of(yup.lazy((item, options) => {
     const itemIndex = options.parent.indexOf(options.value)
     return [
       yup.number().integer(),
@@ -16,7 +27,8 @@ const dateTimeSchema = yup.array().min(3).max(7).of(yup.lazy((item, options) =>
       yup.number().integer().min(0).max(60),
       yup.number().integer().min(0).max(60)
     ][itemIndex]
-  })
+  }))
+  }
 )
 
 const durationSchema = yup.object().shape({
@@ -47,7 +59,7 @@ const organizerSchema = yup.object().shape({
 }).noUnknown()
 
 const alarmSchema = yup.object().shape({
-  action: yup.string().matches(/audio|display|email/).required(),
+  action: yup.string().matches(/^(audio|display|email)$/).required(),
   trigger: yup.mixed().required(),
   description: yup.string(),
   duration: durationSchema,
@@ -60,49 +72,66 @@ const alarmSchema = yup.object().shape({
   'iana-prop': yup.mixed()
 }).noUnknown()
 
-const schema = yup.object().shape({
-  summary: yup.string(),
-  timestamp: yup.mixed(),
-  title: yup.string(),
+const headerShape = {
   productId: yup.string(),
   method: yup.string(),
-  uid: yup.string().required(),
+  calName: yup.string()
+}
+
+const headerSchema = yup.object().shape(headerShape).noUnknown()
+
+const eventShape = {
+  summary: yup.string(),
+  timestamp: dateTimeSchema({ required: false }),
+  title: yup.string(),
+  uid: yup.string(),
   sequence: yup.number().integer().max(2_147_483_647),
-  start: dateTimeSchema.required(),
+  start: dateTimeSchema({ required: true }),
   duration: durationSchema,
-  startType: yup.string().matches(/utc|local/),
-  startInputType: yup.string().matches(/utc|local/),
-  startOutputType: yup.string().matches(/utc|local/),
-  end: dateTimeSchema,
-  endInputType: yup.string().matches(/utc|local/),
-  endOutputType: yup.string().matches(/utc|local/),
+  startType: yup.string().matches(/^(utc|local)$/),
+  startInputType: yup.string().matches(/^(utc|local)$/),
+  startOutputType: yup.string().matches(/^(utc|local)$/),
+  end: dateTimeSchema({ required: false }),
+  endInputType: yup.string().matches(/^(utc|local)$/),
+  endOutputType: yup.string().matches(/^(utc|local)$/),
   description: yup.string(),
   url: yup.string().matches(urlRegex),
   geo: yup.object().shape({lat: yup.number(), lon: yup.number()}),
   location: yup.string(),
-  status: yup.string().matches(/TENTATIVE|CANCELLED|CONFIRMED/i),
+  status: yup.string().matches(/^(TENTATIVE|CANCELLED|CONFIRMED)$/i),
   categories: yup.array().of(yup.string()),
   organizer: organizerSchema,
   attendees: yup.array().of(contactSchema),
   alarms: yup.array().of(alarmSchema),
   recurrenceRule: yup.string(),
-  busyStatus: yup.string().matches(/TENTATIVE|FREE|BUSY|OOF/i),
-  transp: yup.string().matches(/TRANSPARENT|OPAQUE/i),
+  busyStatus: yup.string().matches(/^(TENTATIVE|FREE|BUSY|OOF)$/i),
+  transp: yup.string().matches(/^(TRANSPARENT|OPAQUE)$/i),
   classification: yup.string(),
-  created: dateTimeSchema,
-  lastModified: dateTimeSchema,
-  calName: yup.string(),
+  created: dateTimeSchema({ required: false }),
+  lastModified: dateTimeSchema({ required: false }),
+  exclusionDates: yup.array().of(dateTimeSchema({ required: true })),
   htmlContent: yup.string()
-}).test('xor', `object should have end or duration (but not both)`, val => {
+}
+
+const headerAndEventSchema = yup.object().shape({ ...headerShape, ...eventShape }).test('xor', `object should have end or duration (but not both)`, val => {
   const hasEnd = !!val.end
   const hasDuration = !!val.duration
   return ((hasEnd && !hasDuration) || (!hasEnd && hasDuration) || (!hasEnd && !hasDuration))
 }).noUnknown()
 
-export default function validateEvent (candidate) {
 
+export function validateHeader (candidate) {
+  try {
+    const value = headerSchema.validateSync(candidate, {abortEarly: false, strict: true})
+    return {error: null, value}
+  } catch (error) {
+    return {error: Object.assign({}, error), value: undefined}
+  }
+}
+
+export function validateHeaderAndEvent (candidate) {
   try {
-    const value = schema.validateSync(candidate, {abortEarly: false, strict: true})
+    const value = headerAndEventSchema.validateSync(candidate, {abortEarly: false, strict: true})
     return {error: null, value}
   } catch (error) {
     return {error: Object.assign({}, error), value: undefined}
diff --git a/src/utils/encode-new-lines.js b/src/utils/encode-new-lines.js
new file mode 100644
index 0000000..f846d6b
--- /dev/null
+++ b/src/utils/encode-new-lines.js
@@ -0,0 +1,3 @@
+export default function encodeNewLines (text) {
+  return text.replace(/\r?\n/gm, "\\n")
+}
diff --git a/src/utils/encode-param-value.js b/src/utils/encode-param-value.js
new file mode 100644
index 0000000..2233f57
--- /dev/null
+++ b/src/utils/encode-param-value.js
@@ -0,0 +1,3 @@
+export default function encodeParamValue (value) {
+  return `"${value.replaceAll('"', '\\"')}"`
+}
diff --git a/src/utils/format-date.js b/src/utils/format-date.js
index 8afdf5c..75cc874 100644
--- a/src/utils/format-date.js
+++ b/src/utils/format-date.js
@@ -1,6 +1,10 @@
 const pad = n => n < 10 ? `0${n}` : `${n}`
 
 export default function formatDate(args = [], outputType = 'utc', inputType = 'local') {
+  if (typeof args === 'string') {
+    return args;
+  }
+  
   if (Array.isArray(args) && args.length === 3) {
     const [year, month, date] = args
     return `${year}${pad(month)}${pad(date)}`
@@ -14,6 +18,9 @@ export default function formatDate(args = [], outputType = 'utc', inputType = 'l
     } else {
       outDate = new Date(Date.UTC(year, month - 1, date, hours, minutes, seconds))
     }
+  } else if (!Array.isArray(args)) {
+    // it's a unix time stamp (ms)
+    outDate = new Date(args);
   }
 
   if (outputType === 'local') {
diff --git a/src/utils/format-text.js b/src/utils/format-text.js
index 99b2d9c..a2d5aba 100644
--- a/src/utils/format-text.js
+++ b/src/utils/format-text.js
@@ -4,4 +4,4 @@ export default function formatText (text) {
     .replace(/\r?\n/gm, "\\n")
     .replace(/;/gm, "\\;")
     .replace(/,/gm, "\\,")
-}
\ No newline at end of file
+}
diff --git a/src/utils/index.js b/src/utils/index.js
index 0ecdd04..964d4bb 100644
--- a/src/utils/index.js
+++ b/src/utils/index.js
@@ -8,6 +8,7 @@ import setSummary from './set-summary'
 import formatDuration from './format-duration'
 import foldLine from './fold-line'
 import setLocation from './set-location'
+import encodeParamValue from './encode-param-value'
 
 export {
   formatDate,
@@ -19,5 +20,6 @@ export {
   setSummary,
   setDescription,
   foldLine,
-  setLocation
+  setLocation,
+  encodeParamValue
 }
diff --git a/src/utils/set-alarm.js b/src/utils/set-alarm.js
index 81f5d8c..526f4ea 100644
--- a/src/utils/set-alarm.js
+++ b/src/utils/set-alarm.js
@@ -1,5 +1,6 @@
 import formatDate from './format-date'
 import foldLine from './fold-line'
+import encodeNewLines from './encode-new-lines'
 
 function setDuration ({
   weeks,
@@ -21,11 +22,11 @@ function setDuration ({
 
 function setTrigger (trigger) {
   let formattedString = ''
-  if(Array.isArray(trigger)) {
-    formattedString = `TRIGGER;VALUE=DATE-TIME:${formatDate(trigger)}\r\n`
+  if(Array.isArray(trigger) || typeof trigger === 'number' || typeof trigger === 'string') {
+    formattedString = `TRIGGER;VALUE=DATE-TIME:${encodeNewLines(formatDate(trigger))}\r\n`
   } else {
     let alert = trigger.before ? '-' : ''
-    formattedString = `TRIGGER:${alert+setDuration(trigger)}\r\n`
+    formattedString = `TRIGGER:${encodeNewLines(alert+setDuration(trigger))}\r\n`
   }
 
   return formattedString
@@ -48,14 +49,14 @@ export default function setAlarm(attributes = {}) {
   } = attributes
 
   let formattedString = 'BEGIN:VALARM\r\n'
-  formattedString += foldLine(`ACTION:${setAction(action)}`) + '\r\n'
+  formattedString += foldLine(`ACTION:${encodeNewLines(setAction(action))}`) + '\r\n'
   formattedString += repeat ? foldLine(`REPEAT:${repeat}`) + '\r\n' : ''
-  formattedString += description ? foldLine(`DESCRIPTION:${description}`) + '\r\n' : ''
+  formattedString += description ? foldLine(`DESCRIPTION:${encodeNewLines(description)}`) + '\r\n' : ''
   formattedString += duration ? foldLine(`DURATION:${setDuration(duration)}`) + '\r\n' : ''
   let attachInfo = attachType ? attachType : 'FMTTYPE=audio/basic'
-  formattedString += attach ? foldLine(`ATTACH;${attachInfo}:${attach}`) + '\r\n' : ''
-  formattedString += trigger ? setTrigger(trigger) : ''
-  formattedString += summary ? (foldLine(`SUMMARY:${summary}`) + '\r\n') : ''
+  formattedString += attach ? foldLine(encodeNewLines(`ATTACH;${attachInfo}:${attach}`)) + '\r\n' : ''
+  formattedString += trigger ? encodeNewLines(setTrigger(trigger)) : ''
+  formattedString += summary ? (foldLine(`SUMMARY:${encodeNewLines(summary)}`) + '\r\n') : ''
   formattedString += 'END:VALARM\r\n'
 
   return formattedString
diff --git a/src/utils/set-contact.js b/src/utils/set-contact.js
index cee2e09..9f8edea 100644
--- a/src/utils/set-contact.js
+++ b/src/utils/set-contact.js
@@ -1,3 +1,5 @@
+import encodeParamValue from "./encode-param-value";
+
 export default function setContact({ name, email, rsvp, dir, partstat, role, cutype, xNumGuests }) {
   let formattedParts = [];
 
@@ -5,21 +7,21 @@ export default function setContact({ name, email, rsvp, dir, partstat, role, cut
     formattedParts.push(rsvp ? 'RSVP=TRUE' : 'RSVP=FALSE');
   }
   if(cutype){
-    formattedParts.push("CUTYPE=".concat(cutype));
+    formattedParts.push("CUTYPE=".concat(encodeParamValue(cutype)));
   }
   if(xNumGuests !== undefined){
     formattedParts.push(`X-NUM-GUESTS=${xNumGuests}`);
   }
   if(role){
-    formattedParts.push("ROLE=".concat(role));
+    formattedParts.push("ROLE=".concat(encodeParamValue(role)));
   }
   if(partstat){
-    formattedParts.push("PARTSTAT=".concat(partstat));
+    formattedParts.push("PARTSTAT=".concat(encodeParamValue(partstat)));
   }
   if(dir){
-    formattedParts.push("DIR=".concat(dir));
+    formattedParts.push("DIR=".concat(encodeParamValue(dir)));
   }
-  formattedParts.push('CN='.concat((name || 'Unnamed attendee')));
+  formattedParts.push('CN='.concat((encodeParamValue(name || 'Unnamed attendee'))));
 
   var formattedAttendee = formattedParts.join(';').concat(email ? ":mailto:".concat(email) : '');
 
diff --git a/src/utils/set-organizer.js b/src/utils/set-organizer.js
index 26adf00..01a6f45 100644
--- a/src/utils/set-organizer.js
+++ b/src/utils/set-organizer.js
@@ -1,9 +1,11 @@
+import encodeParamValue from "./encode-param-value"
+
 export default function setOrganizer({ name, email, dir, sentBy }) {
   let formattedOrganizer = ''
-  formattedOrganizer += dir ? `DIR="${dir}";` : ''
-  formattedOrganizer += sentBy ? `SENT-BY="MAILTO:${sentBy}";` : ''
+  formattedOrganizer += dir ? `DIR=${encodeParamValue(dir)};` : ''
+  formattedOrganizer += sentBy ? `SENT-BY=${encodeParamValue(`MAILTO:${sentBy}`)};` : ''
   formattedOrganizer += 'CN='
-  formattedOrganizer += name || 'Organizer'
+  formattedOrganizer += encodeParamValue(name || 'Organizer')
   formattedOrganizer += email ? `:MAILTO:${email}` : ''
 
   return formattedOrganizer


Test patch:
diff --git a/test/index.spec.js b/test/index.spec.js
index 2738a46..6cbec95 100644
--- a/test/index.spec.js
+++ b/test/index.spec.js
@@ -75,6 +75,12 @@ describe('ics', () => {
         expect(error).to.be.null
         expect(value).to.contain('BEGIN:VCALENDAR')
       })
+
+      it('support header params', () => {
+        const { error, value } = createEvents([], { calName: 'test' })
+        expect(error).to.be.null
+        expect(value).to.contain('X-WR-CALNAME:test')
+      })
     })
 
     describe('when a callback is provided', () => {
@@ -94,13 +100,21 @@ describe('ics', () => {
         })
       })
 
-      it('returns an iCal string when passed 0 events', () => {
+      it('returns an iCal string when passed 0 events', (done) => {
         createEvents([], (error, value) => {
           expect(error).to.be.null
           expect(value).to.contain('BEGIN:VCALENDAR')
           done()
         })
       })
+
+      it('support header params', (done) => {
+        createEvents([], { calName: 'test' }, (error, value) => {
+          expect(error).to.be.null
+          expect(value).to.contain('X-WR-CALNAME:test')
+          done()
+        })
+      })
     })
   })
-})
\ No newline at end of file
+})
diff --git a/test/pipeline/build.spec.js b/test/pipeline/build.spec.js
index cead401..6ef2d28 100644
--- a/test/pipeline/build.spec.js
+++ b/test/pipeline/build.spec.js
@@ -1,35 +1,38 @@
 import { expect } from 'chai'
-import { buildEvent } from '../../src/pipeline'
+import { buildHeader, buildEvent } from '../../src/pipeline'
 
-describe('pipeline.build properties', () => {
-  describe('title', () => {
+describe('pipeline.buildHeader properties', () => {
+  describe('productId', () => {
     it('sets a default', () => {
-      const event = buildEvent()
-      expect(event.title).to.equal('Untitled event')
+      const header = buildHeader()
+      expect(header.productId).to.equal('adamgibbons/ics')
     })
-    it('sets a title', () => {
-      const event = buildEvent({ title: 'Hello event!' })
-      expect(event.title).to.equal('Hello event!')
+    it('sets a productId', () => {
+      const header = buildHeader({ productId: 'productId' })
+      expect(header.productId).to.equal('productId')
     })
   })
-  describe('productId', () => {
+  describe('method', () => {
     it('sets a default', () => {
-      const event = buildEvent()
-      expect(event.productId).to.equal('adamgibbons/ics')
+      const header = buildHeader()
+      expect(header.method).to.equal('PUBLISH')
     })
-    it('sets a product id', () => {
-      const event = buildEvent({ productId: 'myProductId' })
-      expect(event.productId).to.equal('myProductId')
+    it('sets a method', () => {
+      const header = buildHeader({ method: 'method' })
+      expect(header.method).to.equal('method')
     })
   })
-  describe('method', () => {
+})
+
+describe('pipeline.buildEvent properties', () => {
+  describe('title', () => {
     it('sets a default', () => {
       const event = buildEvent()
-      expect(event.method).to.equal('PUBLISH')
+      expect(event.title).to.equal('Untitled event')
     })
-    it('sets a method', () => {
-      const event = buildEvent({ method: 'REQUEST' })
-      expect(event.method).to.equal('REQUEST')
+    it('sets a title', () => {
+      const event = buildEvent({ title: 'Hello event!' })
+      expect(event.title).to.equal('Hello event!')
     })
   })
   describe('uid', () => {
diff --git a/test/pipeline/format.spec.js b/test/pipeline/format.spec.js
index 5bf9928..06cb2b6 100644
--- a/test/pipeline/format.spec.js
+++ b/test/pipeline/format.spec.js
@@ -2,17 +2,41 @@ import dayjs from 'dayjs';
 import { expect } from 'chai'
 import {
   formatEvent,
-  buildEvent
+  buildEvent,
+  formatHeader,
+  buildHeader
 } from '../../src/pipeline'
 import {foldLine} from "../../src/utils";
 
+describe('pipeline.formatHeader', () => {
+  it('writes default values when no attributes passed', () => {
+    const header = buildHeader()
+    const formattedHeader = formatHeader(header)
+    expect(formattedHeader).to.contain('BEGIN:VCALENDAR')
+    expect(formattedHeader).to.contain('VERSION:2.0')
+    expect(formattedHeader).to.contain('PRODID:adamgibbons/ics')
+  })
+  it('writes a product id', () => {
+    const header = buildHeader({ productId: 'productId'})
+    const formattedHeader = formatHeader(header)
+    expect(formattedHeader).to.contain('PRODID:productId')
+  })
+  it('writes a method', () => {
+    const header = buildHeader({ method: 'method'})
+    const formattedHeader = formatHeader(header)
+    expect(formattedHeader).to.contain('METHOD:method')
+  })
+  it('writes a calName', () => {
+    const header = buildHeader({ calName: 'calName'})
+    const formattedHeader = formatHeader(header)
+    expect(formattedHeader).to.contain('X-WR-CALNAME:calName')
+  })
+})
+
 describe('pipeline.formatEvent', () => {
   it('writes default values when no attributes passed', () => {
     const event = buildEvent()
     const formattedEvent = formatEvent(event)
-    expect(formattedEvent).to.contain('BEGIN:VCALENDAR')
-    expect(formattedEvent).to.contain('VERSION:2.0')
-    expect(formattedEvent).to.contain('PRODID:adamgibbons/ics')
     expect(formattedEvent).to.contain('BEGIN:VEVENT')
     expect(formattedEvent).to.contain('SUMMARY:Untitled event')
     expect(formattedEvent).to.contain('UID:')
@@ -20,7 +44,6 @@ describe('pipeline.formatEvent', () => {
     expect(formattedEvent).to.contain('DTSTART:')
     expect(formattedEvent).to.contain('DTSTAMP:20')
     expect(formattedEvent).to.contain('END:VEVENT')
-    expect(formattedEvent).to.contain('END:VCALENDAR')
   })
   it('writes a title', () => {
     const event = buildEvent({ title: 'foo bar' })
@@ -100,11 +123,6 @@ describe('pipeline.formatEvent', () => {
     const formattedEvent = formatEvent(event)
     expect(formattedEvent).to.contain('LAST-MODIFIED:20170515')
   })
-  it('writes a cal name', () => {
-    const event = buildEvent({ calName: 'John\'s Calendar' })
-    const formattedEvent = formatEvent(event)
-    expect(formattedEvent).to.contain('X-WR-CALNAME:John\'s Calendar')
-  })
   it('writes a html content and folds correctly', () => {
     const event = buildEvent({ htmlContent: '<!DOCTYPE html><html><body><p>This is<br>test<br>html code.</p></body></html>' })
     const formattedEvent = formatEvent(event)
@@ -175,8 +193,8 @@ describe('pipeline.formatEvent', () => {
       {name: 'Brittany Seaton', email: 'brittany@example.com', rsvp: true }
     ]})
     const formattedEvent = formatEvent(event)
-    expect(formattedEvent).to.contain('ATTENDEE;CN=Adam Gibbons:mailto:adam@example.com')
-    expect(formattedEvent).to.contain('ATTENDEE;RSVP=TRUE;CN=Brittany Seaton:mailto:brittany@example.com')
+    expect(formattedEvent).to.contain('ATTENDEE;CN="Adam Gibbons":mailto:adam@example.com')
+    expect(formattedEvent).to.contain('ATTENDEE;RSVP=TRUE;CN="Brittany Seaton":mailto:brittany@example.com')
   })
   it('writes a busystatus', () => {
     const eventFree = buildEvent({ busyStatus: "FREE" })
@@ -215,22 +233,34 @@ describe('pipeline.formatEvent', () => {
     expect(formattedEventAnyClass).to.contain('CLASS:non-standard-property')
   })
   it('writes an organizer', () => {
-    const formattedEvent = formatEvent({ organizer: {
+    const formattedEvent = formatEvent({
+      productId: 'productId',
+      method: 'method',
+      uid: 'uid',
+      timestamp: 'timestamp',
+      organizer: {
         name: 'Adam Gibbons',
         email: 'adam@example.com',
         dir: 'test-dir-value',
-        sentBy: 'test@example.com'
-      }})
-    expect(formattedEvent).to.contain(foldLine('ORGANIZER;DIR="test-dir-value";SENT-BY="MAILTO:test@example.com";CN=Adam Gibbons:MAILTO:adam@example.com'))
+        sentBy: 'test@example.com',
+      }
+    })
+    expect(formattedEvent).to.contain(foldLine('ORGANIZER;DIR="test-dir-value";SENT-BY="MAILTO:test@example.com";CN="Adam Gibbons":MAILTO:adam@example.com'))
   })
   it('writes an alarm', () => {
-    const formattedEvent = formatEvent({ alarms: [{
-      action: 'audio',
-      trigger: [1997, 2, 17, 1, 30],
-      repeat: 4,
-      duration: { minutes: 15 },
-      attach: 'ftp://example.com/pub/sounds/bell-01.aud'
-    }]})
+    const formattedEvent = formatEvent({
+      productId: 'productId',
+      method: 'method',
+      uid: 'uid',
+      timestamp: 'timestamp',
+      alarms: [{
+        action: 'audio',
+        trigger: [1997, 2, 17, 1, 30],
+        repeat: 4,
+        duration: { minutes: 15 },
+        attach: 'ftp://example.com/pub/sounds/bell-01.aud'
+      }]
+    })
 
     expect(formattedEvent).to.contain('BEGIN:VALARM')
     expect(formattedEvent).to.contain('TRIGGER;VALUE=DATE-TIME:199702')
@@ -244,13 +274,15 @@ describe('pipeline.formatEvent', () => {
     const formattedEvent = formatEvent({
       productId: '*'.repeat(1000),
       method: '*'.repeat(1000),
+      timestamp: '*'.repeat(1000),
+      uid: '*'.repeat(1000),
       title: '*'.repeat(1000),
       description: '*'.repeat(1000),
       url: '*'.repeat(1000),
       geo: '*'.repeat(1000),
       location: '*'.repeat(1000),
       status: '*'.repeat(1000),
-      categories: '*'.repeat(1000),
+      categories: ['*'.repeat(1000)],
       organizer: '*'.repeat(1000),
       attendees: [
         {name: '*'.repeat(1000), email: '*'.repeat(1000)},
@@ -261,13 +293,38 @@ describe('pipeline.formatEvent', () => {
     expect(max).to.be.at.most(75)
   })
   it('writes a recurrence rule', () => {
-    const formattedEvent = formatEvent({ recurrenceRule: 'FREQ=DAILY' })
+    const formattedEvent = formatEvent({
+      productId: 'productId',
+      method: 'method',
+      uid: 'uid',
+      timestamp: 'timestamp',
+      recurrenceRule: 'FREQ=DAILY'
+    })
 
     expect(formattedEvent).to.contain('RRULE:FREQ=DAILY')
   })
   it('writes exception date-time', () => {
-    const formattedEvent = formatEvent({ exclusionDates: '20000620T010000Z,20000621T010000Z' })
+    const date1 = new Date(0);
+    date1.setUTCFullYear(2000);
+    date1.setUTCMonth(6);
+    date1.setUTCDate(20);
+    date1.setUTCHours(2);
+    date1.setUTCMinutes(0);
+    date1.setUTCSeconds(0);
 
+    const date2 = new Date(date1);
+    date2.setUTCDate(21);
+
+    const formattedEvent = formatEvent({
+      productId: 'productId',
+      method: 'method',
+      uid: 'uid',
+      timestamp: 'timestamp',
+      exclusionDates: [
+        [date1.getUTCFullYear(), date1.getUTCMonth(), date1.getUTCDate(), date1.getUTCHours(), date1.getUTCMinutes(), date1.getUTCSeconds()],
+        [date2.getUTCFullYear(), date2.getUTCMonth(), date2.getUTCDate(), date2.getUTCHours(), date2.getUTCMinutes(), date2.getUTCSeconds()]
+      ]
+    })
     expect(formattedEvent).to.contain('EXDATE:20000620T010000Z,20000621T010000Z')
   })
 })
diff --git a/test/pipeline/validate.spec.js b/test/pipeline/validate.spec.js
index 6820428..665299f 100644
--- a/test/pipeline/validate.spec.js
+++ b/test/pipeline/validate.spec.js
@@ -1,9 +1,9 @@
 import { expect } from 'chai'
-import { validateEvent } from '../../src/pipeline'
+import { validateHeaderAndEvent } from '../../src/pipeline'
 
-describe('pipeline.validate', () => {
+describe('pipeline.validateHeaderAndEvent', () => {
   it('validates an event', () => {
-    const { error, value } = validateEvent({
+    const { error, value } = validateHeaderAndEvent({
       uid: '1',
       start: [1997, 10, 1, 22, 30],
       duration: { hours: 1 }
@@ -12,7 +12,7 @@ describe('pipeline.validate', () => {
     expect(value.uid).to.equal('1')
   })
   it('returns an error if the sequence number is too long', () => {
-    const { error, value } = validateEvent({
+    const { error, value } = validateHeaderAndEvent({
       uid: '1',
       start: [1997, 10, 1, 22, 30],
       duration: { hours: 1 },
@@ -21,13 +21,13 @@ describe('pipeline.validate', () => {
     expect(error).to.exist
   })
   it('returns undefined when passed no event', () => {
-    const { error, value } = validateEvent()
+    const { error, value } = validateHeaderAndEvent()
     expect(value).to.be.undefined
   })
   it('returns an error when invalid data passed', () => {
-    expect(validateEvent(null).error).to.exist
-    expect(validateEvent(1).error).to.exist
-    expect(validateEvent('foo').error).to.exist
-    expect(validateEvent({}).error).to.exist
+    expect(validateHeaderAndEvent(null).error).to.exist
+    expect(validateHeaderAndEvent(1).error).to.exist
+    expect(validateHeaderAndEvent('foo').error).to.exist
+    expect(validateHeaderAndEvent({}).error).to.exist
   })
-})
\ No newline at end of file
+})
diff --git a/test/schema/index.spec.js b/test/schema/index.spec.js
index deea7ee..e1d68f8 100644
--- a/test/schema/index.spec.js
+++ b/test/schema/index.spec.js
@@ -1,22 +1,17 @@
 import { expect } from 'chai'
-import validateEvent from '../../src/schema'
+import { validateHeaderAndEvent } from '../../src/schema'
 
-describe('.validateEvent', () => {
+describe('.validateHeaderAndEvent', () => {
   describe('must have one and only one occurrence of', () => {
-    it('uid', () => {
-      const {error} = validateEvent({title: 'foo'})
-      expect(error.errors.some(p => p === 'uid is a required field')).to.be.true
-    })
-
     it('start', () => {
-      const {error} = validateEvent({title: 'foo', uid: 'foo'})
+      const {error} = validateHeaderAndEvent({title: 'foo', uid: 'foo'})
       expect(error.errors.some(p => p === 'start is a required field')).to.be.true
     })
   })
 
   describe('must have duration XOR end', () => {
     it('duration and end are not allowed together', () => {
-      const {error, value} = validateEvent({
+      const {error, value} = validateHeaderAndEvent({
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
         duration: {hours: 1},
@@ -28,7 +23,7 @@ describe('.validateEvent', () => {
 
   describe('may have one and only one occurrence of', () => {
     it('summary', () => {
-      const {errors} = validateEvent({
+      const {errors} = validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
@@ -37,7 +32,7 @@ describe('.validateEvent', () => {
 
       expect(errors.some(p => p.match(/summary must be a `string` type/))).to.be.true
 
-      expect(validateEvent({
+      expect(validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
@@ -46,7 +41,7 @@ describe('.validateEvent', () => {
     })
 
     it('description', () => {
-      const {errors} = validateEvent({
+      const {errors} = validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
@@ -54,7 +49,7 @@ describe('.validateEvent', () => {
       }).error
       expect(errors.some(p => p.match(/description must be a `string` type/))).to.be.true
 
-      expect(validateEvent({
+      expect(validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
@@ -63,14 +58,14 @@ describe('.validateEvent', () => {
 
     })
     it('url', () => {
-      const {errors} = validateEvent({
+      const {errors} = validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
         url: 'abc'
       }).error
       expect(errors.some(p => p.match(/url must/))).to.be.true
-      expect(validateEvent({
+      expect(validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
@@ -79,21 +74,21 @@ describe('.validateEvent', () => {
     })
 
     it('geo', () => {
-      expect(validateEvent({
+      expect(validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
         geo: 'abc'
       }).error.name === 'ValidationError')
 
-      expect(validateEvent({
+      expect(validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
         geo: {lat: 'thing', lon: 32.1},
       }).error.name === 'ValidationError')
 
-      expect(validateEvent({
+      expect(validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
@@ -101,7 +96,7 @@ describe('.validateEvent', () => {
       }).value.geo).to.exist
     })
     it('location', () => {
-      const {errors} = validateEvent({
+      const {errors} = validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
@@ -109,7 +104,7 @@ describe('.validateEvent', () => {
       }).error
       expect(errors.some(p => p.match(/location must be a `string` type/))).to.be.true
 
-      expect(validateEvent({
+      expect(validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
@@ -118,25 +113,25 @@ describe('.validateEvent', () => {
     })
 
     it('status', () => {
-      expect(validateEvent({
+      expect(validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
         status: 'tentativo'
       }).error).to.exist
-      expect(validateEvent({
+      expect(validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
         status: 'tentative'
       }).value.status).to.equal('tentative')
-      expect(validateEvent({
+      expect(validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
         status: 'cancelled'
       }).value.status).to.equal('cancelled')
-      expect(validateEvent({
+      expect(validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
@@ -145,7 +140,7 @@ describe('.validateEvent', () => {
     })
 
     it('categories', () => {
-      const {errors} = validateEvent({
+      const {errors} = validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
@@ -154,7 +149,7 @@ describe('.validateEvent', () => {
 
       expect(errors.some(p => p.match(/categories\[0] must be a `string` type/))).to.be.true
 
-      expect(validateEvent({
+      expect(validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
@@ -163,14 +158,14 @@ describe('.validateEvent', () => {
     })
 
     it('organizer', () => {
-      expect(validateEvent({
+      expect(validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
         organizer: {name: 'Adam', email: 'adam@example.com'}
       }).value.organizer).to.include({name: 'Adam', email: 'adam@example.com'})
 
-      const {errors} = validateEvent({
+      const {errors} = validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
@@ -180,7 +175,7 @@ describe('.validateEvent', () => {
     })
 
     it('attendees', () => {
-      expect(validateEvent({
+      expect(validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
@@ -189,7 +184,7 @@ describe('.validateEvent', () => {
           {name: 'Brittany', email: 'brittany@example.com'}]
       }).value.attendees).to.be.an('array').that.is.not.empty
 
-      const {errors} = validateEvent({
+      const {errors} = validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
@@ -199,7 +194,7 @@ describe('.validateEvent', () => {
       }).error
       expect(errors.some(p => p === 'attendees[0] field has unspecified keys: foo')).to.be.true
 
-      const res = validateEvent({
+      const res = validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
@@ -212,7 +207,7 @@ describe('.validateEvent', () => {
     })
 
     it('created', () => {
-      expect(validateEvent({
+      expect(validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
@@ -221,7 +216,7 @@ describe('.validateEvent', () => {
     })
 
     it('transp', () => {
-      expect(validateEvent({
+      expect(validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
@@ -230,7 +225,7 @@ describe('.validateEvent', () => {
     })
 
     it('lastModified', () => {
-      expect(validateEvent({
+      expect(validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
@@ -239,7 +234,7 @@ describe('.validateEvent', () => {
     })
 
     it('calName', () => {
-      expect(validateEvent({
+      expect(validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         calName: 'John\'s Calendar',
@@ -248,7 +243,7 @@ describe('.validateEvent', () => {
     })
 
     it('htmlContent', () => {
-      expect(validateEvent({
+      expect(validateHeaderAndEvent({
         title: 'foo',
         uid: 'foo',
         htmlContent: '<!DOCTYPE html><html><body><p>This is<br>test<br>html code.</p></body></html>',
@@ -259,7 +254,7 @@ describe('.validateEvent', () => {
 
   describe('may have one or more occurrences of', () => {
     it('alarm component', () => {
-      const event = validateEvent({
+      const event = validateHeaderAndEvent({
         uid: 'foo',
         start: [2018, 12, 1, 10, 30],
         duration: {hours: 1},
diff --git a/test/utils/encode-param-value.spec.js b/test/utils/encode-param-value.spec.js
new file mode 100644
index 0000000..d3b5eff
--- /dev/null
+++ b/test/utils/encode-param-value.spec.js
@@ -0,0 +1,9 @@
+import { expect } from 'chai';
+import { encodeParamValue } from '../../src/utils'
+
+describe('utils.encodeParamValue', () => {
+  it('encodes correctly', () => {
+    expect(encodeParamValue('test')).to.equal(`"test"`)
+    expect(encodeParamValue('a"b"c')).to.equal(`"a\\"b\\"c"`)
+  });
+})
diff --git a/test/utils/format-date.spec.js b/test/utils/format-date.spec.js
index e8e09c1..0a1dbc5 100644
--- a/test/utils/format-date.spec.js
+++ b/test/utils/format-date.spec.js
@@ -33,4 +33,10 @@ describe('utils.formatDate', () => {
     expect(formatDate([1998, 1, 18, 23, 9, 59], 'local', 'local'))
       .to.equal('19980118T230959')
   })
+  it('sets a UTC date-time when passed a unix timestamp', () => {
+    expect(formatDate(1694940441442)).to.equal('20230917T084721Z')
+  })
+  it('returns a string as is', () => {
+    expect(formatDate('20230917T084721Z')).to.equal('20230917T084721Z')
+  })
 })
diff --git a/test/utils/set-contact.spec.js b/test/utils/set-contact.spec.js
index 3e9fb8d..d683074 100644
--- a/test/utils/set-contact.spec.js
+++ b/test/utils/set-contact.spec.js
@@ -5,23 +5,23 @@ describe('utils.setContact', () => {
   it('set a contact with role', () => {
     const contact = { name: 'm-vinc', email: 'vinc@example.com' }
     expect(setContact(contact))
-    .to.equal(`CN=m-vinc:mailto:vinc@example.com`)
+    .to.equal(`CN="m-vinc":mailto:vinc@example.com`)
 
     const contactChair = Object.assign({role: 'CHAIR'}, contact)
     expect(setContact(contactChair))
-    .to.equal(`ROLE=CHAIR;CN=m-vinc:mailto:vinc@example.com`)
+    .to.equal(`ROLE="CHAIR";CN="m-vinc":mailto:vinc@example.com`)
 
     const contactRequired = Object.assign({role: 'REQ-PARTICIPANT', rsvp: true }, contact)
     expect(setContact(contactRequired))
-    .to.equal(`RSVP=TRUE;ROLE=REQ-PARTICIPANT;CN=m-vinc:mailto:vinc@example.com`)
+    .to.equal(`RSVP=TRUE;ROLE="REQ-PARTICIPANT";CN="m-vinc":mailto:vinc@example.com`)
 
     const contactOptional = Object.assign({role: 'OPT-PARTICIPANT', rsvp: false }, contact)
     expect(setContact(contactOptional))
-    .to.equal(`RSVP=FALSE;ROLE=OPT-PARTICIPANT;CN=m-vinc:mailto:vinc@example.com`)
+    .to.equal(`RSVP=FALSE;ROLE="OPT-PARTICIPANT";CN="m-vinc":mailto:vinc@example.com`)
 
     const contactNon = Object.assign({role: 'NON-PARTICIPANT' }, contact)
     expect(setContact(contactNon))
-    .to.equal(`ROLE=NON-PARTICIPANT;CN=m-vinc:mailto:vinc@example.com`)
+    .to.equal(`ROLE="NON-PARTICIPANT";CN="m-vinc":mailto:vinc@example.com`)
   })
   it('set a contact with partstat', () => {
     const contact = { name: 'm-vinc', email: 'vinc@example.com' }
@@ -32,19 +32,19 @@ describe('utils.setContact', () => {
     const contactTentative = Object.assign({contact, partstat: 'TENTATIVE'}, contact)
 
     expect(setContact(contactUndefined))
-    .to.equal('CN=m-vinc:mailto:vinc@example.com')
+    .to.equal('CN="m-vinc":mailto:vinc@example.com')
 
     expect(setContact(contactNeedsAction))
-    .to.equal('PARTSTAT=NEEDS-ACTION;CN=m-vinc:mailto:vinc@example.com')
+    .to.equal('PARTSTAT="NEEDS-ACTION";CN="m-vinc":mailto:vinc@example.com')
 
     expect(setContact(contactDeclined))
-    .to.equal('PARTSTAT=DECLINED;CN=m-vinc:mailto:vinc@example.com')
+    .to.equal('PARTSTAT="DECLINED";CN="m-vinc":mailto:vinc@example.com')
 
     expect(setContact(contactTentative))
-    .to.equal('PARTSTAT=TENTATIVE;CN=m-vinc:mailto:vinc@example.com')
+    .to.equal('PARTSTAT="TENTATIVE";CN="m-vinc":mailto:vinc@example.com')
 
     expect(setContact(contactAccepted))
-    .to.equal('PARTSTAT=ACCEPTED;CN=m-vinc:mailto:vinc@example.com')
+    .to.equal('PARTSTAT="ACCEPTED";CN="m-vinc":mailto:vinc@example.com')
   })
   it('sets a contact and only sets RSVP if specified', () => {
     const contact1 = {
@@ -60,17 +60,17 @@ describe('utils.setContact', () => {
     }
 
     expect(setContact(contact1))
-    .to.equal('CN=Adam Gibbons:mailto:adam@example.com')
+    .to.equal('CN="Adam Gibbons":mailto:adam@example.com')
 
     expect(setContact(contact2))
-    .to.equal('RSVP=TRUE;DIR=https://example.com/contacts/adam;CN=Adam Gibbons:mailto:adam@example.com')
+    .to.equal('RSVP=TRUE;DIR="https://example.com/contacts/adam";CN="Adam Gibbons":mailto:adam@example.com')
   })
   it('set a contact with cutype and guests', () => {
     const contact = { name: 'm-vinc', email: 'vinc@example.com' }
     const contactCuGuests = Object.assign({ cutype: 'INDIVIDUAL', xNumGuests: 0 }, contact)
     const contactString = setContact(contactCuGuests)
 
-    expect(contactString).to.contain('CUTYPE=INDIVIDUAL')
+    expect(contactString).to.contain('CUTYPE="INDIVIDUAL"')
     expect(contactString).to.contain('X-NUM-GUESTS=0')
   })
 })