Bug 1847658 - Account for organizer as attendee of sent invite in iTIP. r=mkmelin

Differential Revision: https://phabricator.services.mozilla.com/D189023

--HG--
extra : amend_source : 0b68acfae8702e1e669b68faf4845b5ac30b1474
This commit is contained in:
Sean Burke 2023-09-30 12:49:13 +03:00
Родитель 62fdc7790c
Коммит 38b9c6416a
4 изменённых файлов: 291 добавлений и 89 удалений

Просмотреть файл

@ -835,8 +835,21 @@ export var itip = {
* to ask the user whether to send) * to ask the user whether to send)
*/ */
checkAndSend(aOpType, aItem, aOriginalItem, aExtResponse = null) { checkAndSend(aOpType, aItem, aOriginalItem, aExtResponse = null) {
let sender = new lazy.CalItipMessageSender(aOriginalItem, itip.getInvitedAttendee(aItem)); // `CalItipMessageSender` uses the presence of an "invited attendee"
if (sender.detectChanges(aOpType, aItem, aExtResponse)) { // (representation of the current user) as an indication that this is an
// incoming invitation, so we need to avoid passing it if the current user
// is the event organizer.
let currentUserAsAttendee = null;
const itemCalendar = aItem.calendar;
if (
itemCalendar?.supportsScheduling &&
itemCalendar.getSchedulingSupport().isInvitation(aItem)
) {
currentUserAsAttendee = this.getInvitedAttendee(aItem, itemCalendar);
}
const sender = new lazy.CalItipMessageSender(aOriginalItem, currentUserAsAttendee);
if (sender.buildOutgoingMessages(aOpType, aItem, aExtResponse)) {
sender.send(itip.getImipTransport(aItem)); sender.send(itip.getImipTransport(aItem));
} }
}, },

Просмотреть файл

@ -25,6 +25,7 @@ class CalItipMessageSender {
* *
* @param {?calIAttendee} invitedAttendee - For incoming invitations, this is * @param {?calIAttendee} invitedAttendee - For incoming invitations, this is
* the attendee that was invited (corresponding to an installed identity). * the attendee that was invited (corresponding to an installed identity).
* For outgoing invitations, this should be `null`.
*/ */
constructor(originalItem, invitedAttendee) { constructor(originalItem, invitedAttendee) {
this.originalItem = originalItem; this.originalItem = originalItem;
@ -39,9 +40,9 @@ class CalItipMessageSender {
} }
/** /**
* Detects whether the passed invitation item has been modified from the * Builds a list of iTIP messages to be sent as a result of operations on a
* original (attendees added/removed, item deleted etc.) thus requiring iTIP * calendar item, based on the current user's role and any modifications to
* messages to be sent. * the item.
* *
* This method should be called before send(). * This method should be called before send().
* *
@ -58,7 +59,7 @@ class CalItipMessageSender {
* *
* @returns {number} - The number of messages to be sent. * @returns {number} - The number of messages to be sent.
*/ */
detectChanges(opType, item, extResponse = null) { buildOutgoingMessages(opType, item, extResponse = null) {
let { originalItem, invitedAttendee } = this; let { originalItem, invitedAttendee } = this;
// balance out parts of the modification vs delete confusion, deletion of occurrences // balance out parts of the modification vs delete confusion, deletion of occurrences
@ -146,8 +147,10 @@ class CalItipMessageSender {
return this.pendingMessageCount; return this.pendingMessageCount;
} }
// If an "invited attendee" (i.e., the current user) is present, we assume
// that this is an incoming invite and that we should send only a REPLY if
// needed.
if (invitedAttendee) { if (invitedAttendee) {
// actually is an invitation copy, fix attendee list to send REPLY
/* We check if the attendee id matches one of of the /* We check if the attendee id matches one of of the
* userAddresses. If they aren't equal, it means that * userAddresses. If they aren't equal, it means that
* someone is accepting invitations on behalf of an other user. */ * someone is accepting invitations on behalf of an other user. */
@ -158,9 +161,10 @@ class CalItipMessageSender {
!cal.email.attendeeMatchesAddresses(invitedAttendee, userAddresses) !cal.email.attendeeMatchesAddresses(invitedAttendee, userAddresses)
) { ) {
invitedAttendee = invitedAttendee.clone(); invitedAttendee = invitedAttendee.clone();
invitedAttendee.setProperty("SENT-BY", "mailto:" + userAddresses[0]); invitedAttendee.setProperty("SENT-BY", cal.email.prependMailTo(userAddresses[0]));
} }
} }
if (item.organizer) { if (item.organizer) {
let origInvitedAttendee = originalItem && originalItem.getAttendeeById(invitedAttendee.id); let origInvitedAttendee = originalItem && originalItem.getAttendeeById(invitedAttendee.id);
@ -312,6 +316,7 @@ class CalItipMessageSender {
attendee.rsvp = "TRUE"; attendee.rsvp = "TRUE";
requestItem.addAttendee(attendee); requestItem.addAttendee(attendee);
} }
recipients.push(attendee); recipients.push(attendee);
} }
@ -328,6 +333,14 @@ class CalItipMessageSender {
recipients = addedAttendees; recipients = addedAttendees;
} }
// Since this is a REQUEST, it is being sent from the event creator to
// attendees. We do not need to send a message to the creator, even
// though they may also be an attendee.
const calendarEmail = cal.provider.getEmailIdentityOfCalendar(item.calendar)?.email;
recipients = recipients.filter(
attendee => cal.email.removeMailTo(attendee.id) != calendarEmail
);
if (recipients.length > 0) { if (recipients.length > 0) {
this.pendingMessages.push( this.pendingMessages.push(
new CalItipOutgoingMessage("REQUEST", recipients, requestItem, null, autoResponse) new CalItipOutgoingMessage("REQUEST", recipients, requestItem, null, autoResponse)
@ -352,7 +365,7 @@ class CalItipMessageSender {
/** /**
* Sends the iTIP message using the item's calendar transport. This method * Sends the iTIP message using the item's calendar transport. This method
* should be called after detectChanges(). * should be called after buildOutgoingMessages().
* *
* @param {calIItipTransport} [transport] - An optional transport to use * @param {calIItipTransport} [transport] - An optional transport to use
* instead of the one provided by the item's calendar. * instead of the one provided by the item's calendar.

Просмотреть файл

@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
var { cal } = ChromeUtils.importESModule("resource:///modules/calendar/calUtils.sys.mjs"); var { cal } = ChromeUtils.importESModule("resource:///modules/calendar/calUtils.sys.mjs");
const { CalAttendee } = ChromeUtils.import("resource:///modules/CalAttendee.jsm");
var { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm"); var { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
var { CalItipMessageSender } = ChromeUtils.import("resource:///modules/CalItipMessageSender.jsm"); var { CalItipMessageSender } = ChromeUtils.import("resource:///modules/CalItipMessageSender.jsm");
var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
@ -12,9 +13,21 @@ var { CalendarTestUtils } = ChromeUtils.import(
); );
const identityEmail = "user@example.com"; const identityEmail = "user@example.com";
const calendarOrganizerId = "mailto:user@example.com";
const eventOrganizerEmail = "eventorganizer@example.com"; const eventOrganizerEmail = "eventorganizer@example.com";
const eventOrganizerId = `mailto:${eventOrganizerEmail}`;
/**
* Creates a calendar event mimicking an event to which we have received an
* invitation.
*
* @param {string} organizerEmail - The email address of the event organizer.
* @param {string} attendeeEmail - The email address of an attendee who has
* accepted the invitation.
* @returns {calIItemBase} - The new calendar event.
*/
function createIncomingEvent(organizerEmail, attendeeEmail) {
const organizerId = cal.email.prependMailTo(organizerEmail);
const attendeeId = cal.email.prependMailTo(attendeeEmail);
const icalString = CalendarTestUtils.dedent` const icalString = CalendarTestUtils.dedent`
BEGIN:VEVENT BEGIN:VEVENT
CREATED:20210105T000000Z CREATED:20210105T000000Z
@ -25,19 +38,21 @@ const icalString = CalendarTestUtils.dedent`
DTEND:20210105T100000Z DTEND:20210105T100000Z
STATUS:CONFIRMED STATUS:CONFIRMED
SUMMARY:Test Event SUMMARY:Test Event
ORGANIZER;CN=${eventOrganizerEmail}:${eventOrganizerId} ORGANIZER;CN=${organizerEmail}:${organizerId}
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED; ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;
RSVP=TRUE;CN=other@example.com;:mailto:other@example.com RSVP=TRUE;CN=other@example.com;:mailto:other@example.com
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED; ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;
RSVP=TRUE;CN=${identityEmail};:${calendarOrganizerId} RSVP=TRUE;CN=${attendeeEmail};:${attendeeId}
X-MOZ-RECEIVED-SEQUENCE:0 X-MOZ-RECEIVED-SEQUENCE:0
X-MOZ-RECEIVED-DTSTAMP:20210501T000000Z X-MOZ-RECEIVED-DTSTAMP:20210501T000000Z
X-MOZ-GENERATION:0 X-MOZ-GENERATION:0
END:VEVENT END:VEVENT
`; `;
return new CalEvent(icalString);
}
let calendar; let calendar;
let identity;
/** /**
* Ensure the calendar manager is available, initialize the calendar and * Ensure the calendar manager is available, initialize the calendar and
@ -46,113 +61,273 @@ let identity;
add_setup(async function () { add_setup(async function () {
await new Promise(resolve => do_load_calmgr(resolve)); await new Promise(resolve => do_load_calmgr(resolve));
calendar = CalendarTestUtils.createCalendar("Test", "memory"); calendar = CalendarTestUtils.createCalendar("Test", "memory");
identity = MailServices.accounts.createIdentity();
const identity = MailServices.accounts.createIdentity();
identity.email = identityEmail; identity.email = identityEmail;
calendar.setProperty("imip.identity.key", identity.key);
calendar.setProperty("organizerId", calendarOrganizerId);
});
/** const account = MailServices.accounts.createAccount();
* Test receiving a new invitation queues a "REPLY" message. account.incomingServer = MailServices.accounts.createIncomingServer(
*/ `${account.key}user`,
add_task(async function testInvitationReceived() { "localhost",
const item = new CalEvent(icalString); "none"
const savedItem = await calendar.addItem(item);
const invitedAttendee = savedItem.getAttendeeById(calendarOrganizerId);
const sender = new CalItipMessageSender(null, invitedAttendee);
const result = sender.detectChanges(Ci.calIOperationListener.ADD, savedItem);
Assert.equal(result, 1, "result indicates 1 pending message queued");
Assert.equal(sender.pendingMessageCount, 1, "pendingMessageCount is 1");
const [msg] = sender.pendingMessages;
Assert.equal(msg.method, "REPLY", "message method is 'REPLY'");
Assert.equal(msg.recipients.length, 1, "message has 1 recipient");
const [recipient] = msg.recipients;
Assert.equal(recipient.id, eventOrganizerId, "recipient is the event organizer");
const attendeeList = msg.item.getAttendees();
Assert.equal(attendeeList.length, 1, "reply attendees list has 1 attendee");
const [attendee] = attendeeList;
Assert.equal(attendee.id, calendarOrganizerId, "invited attendee is on the reply attendees list");
Assert.equal(
attendee.participationStatus,
"ACCEPTED",
"invited attendee participation status is 'ACCEPTED'"
); );
account.addIdentity(identity);
await calendar.deleteItem(savedItem); registerCleanupFunction(() => {
MailServices.accounts.removeIncomingServer(account.incomingServer, false);
MailServices.accounts.removeAccount(account);
}); });
/** calendar.setProperty("imip.identity.key", identity.key);
* Test updating the invited attendee's participation status queues a "REPLY" calendar.setProperty("organizerId", cal.email.prependMailTo(identityEmail));
* message. });
*/
add_task(async function testParticipationStatusUpdated() { add_task(async function testAddAttendeesToOwnEvent() {
const icalString = CalendarTestUtils.dedent`
BEGIN:VEVENT
CREATED:20210105T000000Z
DTSTAMP:20210501T000000Z
UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
SUMMARY:Test Invitation
DTSTART:20210105T000000Z
DTEND:20210105T100000Z
STATUS:CONFIRMED
SUMMARY:Test Event
X-MOZ-SEND-INVITATIONS:TRUE
END:VEVENT
`;
const item = new CalEvent(icalString); const item = new CalEvent(icalString);
const savedItem = await calendar.addItem(item); const savedItem = await calendar.addItem(item);
// Modify the event to include an attendee not in the original, as well as the
// organizer. As of the writing of this test, this is the expected behavior
// for adding an attendee to an event which previously had none.
const newAttendeeEmail = "foo@example.com";
const newAttendee = new CalAttendee();
newAttendee.id = newAttendeeEmail;
const organizer = new CalAttendee();
organizer.isOrganizer = true;
organizer.id = identityEmail;
const organizerAsAttendee = new CalAttendee();
organizerAsAttendee.id = identityEmail;
const targetItem = savedItem.clone(); const targetItem = savedItem.clone();
const invitedAttendee = targetItem.getAttendeeById(calendarOrganizerId); targetItem.addAttendee(newAttendee);
invitedAttendee.participationStatus = "TENTATIVE"; targetItem.addAttendee(organizer);
targetItem.addAttendee(organizerAsAttendee);
const modifiedItem = await calendar.modifyItem(targetItem, savedItem); const modifiedItem = await calendar.modifyItem(targetItem, savedItem);
const sender = new CalItipMessageSender(savedItem, invitedAttendee);
const result = sender.detectChanges(Ci.calIOperationListener.MODIFY, modifiedItem); // Test that a sender with an original item and for which the current user is
Assert.equal(result, 1, "result indicates 1 pending message queued"); // both an attendee and the organizer will generate a REQUEST, but not send a
Assert.equal(sender.pendingMessageCount, 1, "pendingMessageCount is 1"); // message to the organizer.
const sender = new CalItipMessageSender(savedItem, null);
const result = sender.buildOutgoingMessages(Ci.calIOperationListener.MODIFY, modifiedItem);
Assert.equal(result, 1, "return value should indicate there are pending messages");
Assert.equal(sender.pendingMessageCount, 1, "there should be one pending message");
const [msg] = sender.pendingMessages; const [msg] = sender.pendingMessages;
Assert.equal(msg.method, "REPLY", "message method is 'REPLY'"); Assert.equal(msg.method, "REQUEST", "message method should be 'REQUEST'");
Assert.equal(msg.recipients.length, 1, "message has 1 recipient"); Assert.equal(msg.recipients.length, 1, "message should have one recipient");
const [recipient] = msg.recipients; const [recipient] = msg.recipients;
Assert.equal(recipient.id, eventOrganizerId, "recipient is the event organizer");
const attendeeList = msg.item.getAttendees();
Assert.equal(attendeeList.length, 1, "reply attendees list has 1 attendee");
const [attendee] = attendeeList;
Assert.equal(attendee.id, calendarOrganizerId, "invited attendee is on the reply attendees list");
Assert.equal( Assert.equal(
attendee.participationStatus, recipient.id,
"TENTATIVE", cal.email.prependMailTo(newAttendeeEmail),
"invited attendee participation status is 'TENTATIVE'" "recipient should be the non-organizer attendee"
); );
await calendar.deleteItem(modifiedItem); await calendar.deleteItem(modifiedItem);
}); });
/** add_task(async function testAddAdditionalAttendee() {
* Test deleting an event queues a "CANCEL" message. const icalString = CalendarTestUtils.dedent`
*/ BEGIN:VEVENT
add_task(async function testEventDeleted() { CREATED:20210105T000000Z
DTSTAMP:20210501T000000Z
UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
SUMMARY:Test Invitation
DTSTART:20210105T000000Z
DTEND:20210105T100000Z
STATUS:CONFIRMED
SUMMARY:Test Event
ORGANIZER;CN=${identityEmail}:${cal.email.prependMailTo(identityEmail)}
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;
RSVP=TRUE;CN=other@example.com;:mailto:other@example.com
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;
RSVP=TRUE;CN=${identityEmail};:${cal.email.prependMailTo(identityEmail)}
X-MOZ-SEND-INVITATIONS:TRUE
END:VEVENT
`;
const item = new CalEvent(icalString); const item = new CalEvent(icalString);
const savedItem = await calendar.addItem(item); const savedItem = await calendar.addItem(item);
await calendar.deleteItem(savedItem); // Modify the event to include an attendee not in the original.
const invitedAttendee = savedItem.getAttendeeById(calendarOrganizerId); const newAttendeeEmail = "bar@example.com";
const sender = new CalItipMessageSender(null, invitedAttendee); const newAttendee = new CalAttendee();
const result = sender.detectChanges(Ci.calIOperationListener.DELETE, savedItem); newAttendee.id = newAttendeeEmail;
Assert.equal(result, 1, "result indicates 1 pending message queued");
Assert.equal(sender.pendingMessageCount, 1, "pendingMessageCount is 1"); const organizer = new CalAttendee();
organizer.isOrganizer = true;
organizer.id = identityEmail;
const organizerAsAttendee = new CalAttendee();
organizerAsAttendee.id = identityEmail;
const targetItem = savedItem.clone();
targetItem.addAttendee(newAttendee);
const modifiedItem = await calendar.modifyItem(targetItem, savedItem);
// Test that adding an attendee won't cause messages to be sent to the
// existing attendees.
const sender = new CalItipMessageSender(savedItem, null);
const result = sender.buildOutgoingMessages(Ci.calIOperationListener.MODIFY, modifiedItem);
Assert.equal(result, 1, "return value should indicate there are pending messages");
Assert.equal(sender.pendingMessageCount, 1, "there should be one pending message");
const [msg] = sender.pendingMessages; const [msg] = sender.pendingMessages;
Assert.equal(msg.method, "REPLY", "message method is 'REPLY'"); Assert.equal(msg.method, "REQUEST", "message method should be 'REQUEST'");
Assert.equal(msg.recipients.length, 1, "message has 1 recipient"); Assert.equal(msg.recipients.length, 1, "message should have one recipient");
const [recipient] = msg.recipients; const [recipient] = msg.recipients;
Assert.equal(recipient.id, eventOrganizerId, "recipient is the event organizer"); Assert.equal(
recipient.id,
cal.email.prependMailTo(newAttendeeEmail),
"recipient should be the new attendee"
);
await calendar.deleteItem(modifiedItem);
});
add_task(async function testInvitationReceived() {
const item = createIncomingEvent(eventOrganizerEmail, identityEmail);
const savedItem = await calendar.addItem(item);
const attendeeId = cal.email.prependMailTo(identityEmail);
// Test that a sender with no original item and for which the current user is
// an attendee but not the organizer (representing a new incoming invitation)
// generates a single pending REPLY message on ADD.
const currentUserAsAttendee = savedItem.getAttendeeById(attendeeId);
const sender = new CalItipMessageSender(null, currentUserAsAttendee);
const result = sender.buildOutgoingMessages(Ci.calIOperationListener.ADD, savedItem);
Assert.equal(result, 1, "return value should indicate there are pending messages");
Assert.equal(sender.pendingMessageCount, 1, "there should be one pending message");
const [msg] = sender.pendingMessages;
Assert.equal(msg.method, "REPLY", "message method should be 'REPLY'");
Assert.equal(msg.recipients.length, 1, "message should have one recipient");
const [recipient] = msg.recipients;
Assert.equal(
recipient.id,
cal.email.prependMailTo(eventOrganizerEmail),
"recipient should be the event organizer"
);
const attendeeList = msg.item.getAttendees(); const attendeeList = msg.item.getAttendees();
Assert.equal(attendeeList.length, 1, "reply attendees list has 1 attendee"); Assert.equal(attendeeList.length, 1, "there should be one attendee listed in the message");
const [attendee] = attendeeList; const [attendee] = attendeeList;
Assert.equal(attendee.id, calendarOrganizerId, "invited attendee is on the reply attendees list"); Assert.equal(attendee.id, attendeeId, "listed attendee should be the current user");
Assert.equal(
attendee.participationStatus,
"ACCEPTED",
"current user's participation status should be 'ACCEPTED'"
);
await calendar.deleteItem(savedItem);
});
add_task(async function testParticipationStatusUpdated() {
const item = createIncomingEvent(eventOrganizerEmail, identityEmail);
const savedItem = await calendar.addItem(item);
const attendeeId = cal.email.prependMailTo(identityEmail);
// Modify the event to update the user's participation status.
const targetItem = savedItem.clone();
const currentUserAsAttendee = targetItem.getAttendeeById(attendeeId);
currentUserAsAttendee.participationStatus = "TENTATIVE";
const modifiedItem = await calendar.modifyItem(targetItem, savedItem);
// Test that a sender for which the current user is an attendee but not the
// organizer will generate a pending REPLY message on MODIFY.
const sender = new CalItipMessageSender(savedItem, currentUserAsAttendee);
const result = sender.buildOutgoingMessages(Ci.calIOperationListener.MODIFY, modifiedItem);
Assert.equal(result, 1, "return value should indicate there are pending messages");
Assert.equal(sender.pendingMessageCount, 1, "there should be one pending message");
const [msg] = sender.pendingMessages;
Assert.equal(msg.method, "REPLY", "message method should be 'REPLY'");
Assert.equal(msg.recipients.length, 1, "message should have one recipient");
const [recipient] = msg.recipients;
Assert.equal(
recipient.id,
cal.email.prependMailTo(eventOrganizerEmail),
"recipient should be the event organizer"
);
const attendeeList = msg.item.getAttendees();
Assert.equal(attendeeList.length, 1, "there should be one attendee listed in the message");
const [attendee] = attendeeList;
Assert.equal(attendee.id, attendeeId, "listed attendee should be the current user");
Assert.equal(
attendee.participationStatus,
"TENTATIVE",
"current user's participation status should be 'TENTATIVE'"
);
await calendar.deleteItem(modifiedItem);
});
add_task(async function testEventDeleted() {
const item = createIncomingEvent(eventOrganizerEmail, identityEmail);
const savedItem = await calendar.addItem(item);
const attendeeId = cal.email.prependMailTo(identityEmail);
await calendar.deleteItem(savedItem);
const currentUserAsAttendee = savedItem.getAttendeeById(attendeeId);
// Test that a sender with no original item and for which the current user is
// an attendee but not the organizer (representing the user deleting an event
// from their calendar) generates a single REPLY message to the organizer on
// DELETE.
const sender = new CalItipMessageSender(null, currentUserAsAttendee);
const result = sender.buildOutgoingMessages(Ci.calIOperationListener.DELETE, savedItem);
Assert.equal(result, 1, "return value should indicate there are pending messages");
Assert.equal(sender.pendingMessageCount, 1, "there should be one pending message");
const [msg] = sender.pendingMessages;
Assert.equal(msg.method, "REPLY", "message method should be 'REPLY'");
Assert.equal(msg.recipients.length, 1, "message should have one recipient");
const [recipient] = msg.recipients;
Assert.equal(
recipient.id,
cal.email.prependMailTo(eventOrganizerEmail),
"recipient should be the event organizer"
);
const attendeeList = msg.item.getAttendees();
Assert.equal(attendeeList.length, 1, "there should be one attendee listed in the message");
const [attendee] = attendeeList;
Assert.equal(attendee.id, attendeeId, "listed attendee should be the current user");
Assert.equal( Assert.equal(
attendee.participationStatus, attendee.participationStatus,
"DECLINED", "DECLINED",
"invited attendee status changed to 'DECLINED'" "current user's participation status should be 'DECLINED'"
); );
}); });

Просмотреть файл

@ -3,6 +3,7 @@ head = head.js
prefs = prefs =
calendar.timezone.local=UTC calendar.timezone.local=UTC
calendar.timezone.useSystemTimezone=false calendar.timezone.useSystemTimezone=false
calendar.itip.updateInvitationForNewAttendeesOnly=true
support-files = data/** support-files = data/**
tags = calendar tags = calendar