1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5var EXPORTED_SYMBOLS = ["CalItipEmailTransport", "CalItipDefaultEmailTransport"];
6
7var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
8var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
9var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
10
11/**
12 * CalItipEmailTransport is used to send iTIP messages via email. Outside
13 * callers should use the static `createInstance()` method instead of this
14 * constructor directly.
15 */
16class CalItipEmailTransport {
17  wrappedJSObject = this;
18  QueryInterface = ChromeUtils.generateQI(["calIItipTransport"]);
19  classID = Components.ID("{d4d7b59e-c9e0-4a7a-b5e8-5958f85515f0}");
20
21  mSenderAddress = null;
22
23  constructor(defaultAccount, defaultIdentity) {
24    this.mDefaultAccount = defaultAccount;
25    this.mDefaultIdentity = defaultIdentity;
26  }
27
28  get scheme() {
29    return "mailto";
30  }
31
32  get type() {
33    return "email";
34  }
35
36  get senderAddress() {
37    return this.mSenderAddress;
38  }
39
40  set senderAddress(aValue) {
41    this.mSenderAddress = aValue;
42  }
43
44  /**
45   * Creates a new calIItipTransport instance configured with the default
46   * account and identity if available. If not available or an error occurs, an
47   * instance that cannot send any items out is returned.
48   */
49  static createInstance() {
50    try {
51      let defaultAccount = MailServices.accounts.defaultAccount;
52      let defaultIdentity = defaultAccount ? defaultAccount.defaultIdentity : null;
53
54      if (!defaultIdentity) {
55        // If there isn't a default identity (i.e Local Folders is your
56        // default identity, then go ahead and use the first available
57        // identity.
58        let allIdentities = MailServices.accounts.allIdentities;
59        if (allIdentities.length > 0) {
60          defaultIdentity = allIdentities[0];
61        }
62      }
63
64      if (defaultAccount && defaultIdentity) {
65        return new CalItipEmailTransport(defaultAccount, defaultIdentity);
66      }
67    } catch (ex) {
68      // Fall through to below.
69    }
70
71    cal.LOG("CalITipEmailTransport.createInstance: No XPCOM Mail available.");
72    return new CalItipNoEmailTransport();
73  }
74
75  _prepareItems(aItipItem, aFromAttendee) {
76    let item = aItipItem.getItemList()[0];
77
78    // Get ourselves some default text - when we handle organizer properly
79    // We'll need a way to configure the Common Name attribute and we should
80    // use it here rather than the email address
81
82    let summary = item.getProperty("SUMMARY") || "";
83    let subject = "";
84    let body = "";
85    switch (aItipItem.responseMethod) {
86      case "REQUEST": {
87        let usePrefixes = Services.prefs.getBoolPref(
88          "calendar.itip.useInvitationSubjectPrefixes",
89          true
90        );
91        if (usePrefixes) {
92          let seq = item.getProperty("SEQUENCE");
93          let subjectKey = seq && seq > 0 ? "itipRequestUpdatedSubject2" : "itipRequestSubject2";
94          subject = cal.l10n.getLtnString(subjectKey, [summary]);
95        } else {
96          subject = summary;
97        }
98        body = cal.l10n.getLtnString("itipRequestBody", [
99          item.organizer ? item.organizer.toString() : "",
100          summary,
101        ]);
102        break;
103      }
104      case "CANCEL": {
105        subject = cal.l10n.getLtnString("itipCancelSubject2", [summary]);
106        body = cal.l10n.getLtnString("itipCancelBody", [
107          item.organizer ? item.organizer.toString() : "",
108          summary,
109        ]);
110        break;
111      }
112      case "DECLINECOUNTER": {
113        subject = cal.l10n.getLtnString("itipDeclineCounterSubject", [summary]);
114        body = cal.l10n.getLtnString("itipDeclineCounterBody", [
115          item.organizer ? item.organizer.toString() : "",
116          summary,
117        ]);
118        break;
119      }
120      case "REPLY": {
121        // Get my participation status
122        if (!aFromAttendee && aItipItem.identity) {
123          aFromAttendee = item.getAttendeeById(cal.email.prependMailTo(aItipItem.identity));
124        }
125        if (!aFromAttendee) {
126          // should not happen anymore
127          return false;
128        }
129
130        // work around BUG 351589, the below just removes RSVP:
131        aItipItem.setAttendeeStatus(aFromAttendee.id, aFromAttendee.participationStatus);
132        let myPartStat = aFromAttendee.participationStatus;
133        let name = aFromAttendee.toString();
134
135        // Generate proper body from my participation status
136        let subjectKey, bodyKey;
137        switch (myPartStat) {
138          case "ACCEPTED":
139            subjectKey = "itipReplySubjectAccept2";
140            bodyKey = "itipReplyBodyAccept";
141            break;
142          case "TENTATIVE":
143            subjectKey = "itipReplySubjectTentative2";
144            bodyKey = "itipReplyBodyAccept";
145            break;
146          case "DECLINED":
147            subjectKey = "itipReplySubjectDecline2";
148            bodyKey = "itipReplyBodyDecline";
149            break;
150          default:
151            subjectKey = "itipReplySubject2";
152            bodyKey = "itipReplyBodyAccept";
153            break;
154        }
155        subject = cal.l10n.getLtnString(subjectKey, [summary]);
156        body = cal.l10n.getLtnString(bodyKey, [name]);
157        break;
158      }
159    }
160
161    return {
162      subject,
163      body,
164    };
165  }
166
167  _sendXpcomMail(aToList, aSubject, aBody, aItipItem) {
168    let { identity, account } = this.getIdentityAndAccount(aItipItem);
169
170    switch (aItipItem.autoResponse) {
171      case Ci.calIItipItem.USER: {
172        cal.LOG("sendXpcomMail: Found USER autoResponse type.");
173        // We still need this as a last resort if a user just deletes or
174        //  drags an invitation related event
175        let parent = Services.wm.getMostRecentWindow(null);
176        if (parent.closed) {
177          parent = cal.window.getCalendarWindow();
178        }
179        let cancelled = Services.prompt.confirmEx(
180          parent,
181          cal.l10n.getLtnString("imipSendMail.title"),
182          cal.l10n.getLtnString("imipSendMail.text"),
183          Services.prompt.STD_YES_NO_BUTTONS,
184          null,
185          null,
186          null,
187          null,
188          {}
189        );
190        if (cancelled) {
191          cal.LOG("sendXpcomMail: Sending of invitation email aborted by user!");
192          break;
193        } // else go on with auto sending for now
194      }
195      // falls through intended
196      case Ci.calIItipItem.AUTO: {
197        // don't show log message in case of falling through
198        if (aItipItem.autoResponse == Ci.calIItipItem.AUTO) {
199          cal.LOG("sendXpcomMail: Found AUTO autoResponse type.");
200        }
201        let cbEmail = function(aVal, aInd, aArr) {
202          let email = cal.email.getAttendeeEmail(aVal, true);
203          if (!email.length) {
204            cal.LOG("sendXpcomMail: Invalid recipient for email transport: " + aVal.toString());
205          }
206          return email;
207        };
208        let toMap = aToList.map(cbEmail).filter(value => value.length);
209        if (toMap.length < aToList.length) {
210          // at least one invalid recipient, so we skip sending for this message
211          return false;
212        }
213        let toList = toMap.join(", ");
214        let composeUtils = Cc["@mozilla.org/messengercompose/computils;1"].createInstance(
215          Ci.nsIMsgCompUtils
216        );
217        let messageId = composeUtils.msgGenerateMessageId(identity);
218        let mailFile = this._createTempImipFile(
219          toList,
220          aSubject,
221          aBody,
222          aItipItem,
223          identity,
224          messageId
225        );
226        if (mailFile) {
227          // compose fields for message: from/to etc need to be specified both here and in the file
228          let composeFields = Cc["@mozilla.org/messengercompose/composefields;1"].createInstance(
229            Ci.nsIMsgCompFields
230          );
231          composeFields.to = toList;
232          let mailfrom = identity.fullName.length
233            ? identity.fullName + " <" + identity.email + ">"
234            : identity.email;
235          composeFields.from =
236            cal.email.validateRecipientList(mailfrom) == mailfrom ? mailfrom : identity.email;
237          composeFields.replyTo = identity.replyTo;
238          composeFields.organization = identity.organization;
239          composeFields.messageId = messageId;
240          let validRecipients;
241          if (identity.doCc) {
242            validRecipients = cal.email.validateRecipientList(identity.doCcList);
243            if (validRecipients != "") {
244              // eslint-disable-next-line id-length
245              composeFields.cc = validRecipients;
246            }
247          }
248          if (identity.doBcc) {
249            validRecipients = cal.email.validateRecipientList(identity.doBccList);
250            if (validRecipients != "") {
251              composeFields.bcc = validRecipients;
252            }
253          }
254
255          // xxx todo: add send/progress UI, maybe recycle
256          //           "@mozilla.org/messengercompose/composesendlistener;1"
257          //           and/or "chrome://messenger/content/messengercompose/sendProgress.xhtml"
258          // i.e. bug 432662
259          let msgSend = Cc["@mozilla.org/messengercompose/send;1"].createInstance(Ci.nsIMsgSend);
260          msgSend.sendMessageFile(
261            identity,
262            account.key,
263            composeFields,
264            mailFile,
265            true, // deleteSendFileOnCompletion
266            false, // digest_p
267            Services.io.offline ? Ci.nsIMsgSend.nsMsgQueueForLater : Ci.nsIMsgSend.nsMsgDeliverNow,
268            null, // nsIMsgDBHdr msgToReplace
269            null, // nsIMsgSendListener aListener
270            null, // nsIMsgStatusFeedback aStatusFeedback
271            ""
272          ); // password
273          return true;
274        }
275        break;
276      }
277      case Ci.calIItipItem.NONE: {
278        // we shouldn't get here, as we stopped processing in this case
279        // earlier in checkAndSend in calItipUtils.jsm
280        cal.LOG("sendXpcomMail: Found NONE autoResponse type.");
281        break;
282      }
283      default: {
284        // Also of this case should have been taken care at the same place
285        throw new Error("sendXpcomMail: Unknown autoResponse type: " + aItipItem.autoResponse);
286      }
287    }
288    return false;
289  }
290
291  _createTempImipFile(aToList, aSubject, aBody, aItipItem, aIdentity, aMessageId) {
292    try {
293      let itemList = aItipItem.getItemList();
294      let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
295        Ci.calIIcsSerializer
296      );
297      serializer.addItems(itemList);
298      let methodProp = cal.getIcsService().createIcalProperty("METHOD");
299      methodProp.value = aItipItem.responseMethod;
300      serializer.addProperty(methodProp);
301      let calText = serializer.serializeToString();
302      let utf8CalText = cal.invitation.encodeUTF8(calText);
303
304      // Home-grown mail composition; I'd love to use nsIMimeEmitter, but it's not clear to me whether
305      // it can cope with nested attachments,
306      // like multipart/alternative with enclosed text/calendar and text/plain.
307      let mailText = cal.invitation.getHeaderSection(aMessageId, aIdentity, aToList, aSubject);
308      mailText +=
309        'Content-type: multipart/mixed; boundary="Boundary_(ID_qyG4ZdjoAsiZ+Jo19dCbWQ)"\r\n' +
310        "\r\n\r\n" +
311        "--Boundary_(ID_qyG4ZdjoAsiZ+Jo19dCbWQ)\r\n" +
312        "Content-type: multipart/alternative;\r\n" +
313        ' boundary="Boundary_(ID_ryU4ZdJoASiZ+Jo21dCbwA)"\r\n' +
314        "\r\n\r\n" +
315        "--Boundary_(ID_ryU4ZdJoASiZ+Jo21dCbwA)\r\n" +
316        "Content-type: text/plain; charset=UTF-8\r\n" +
317        "Content-transfer-encoding: 8BIT\r\n" +
318        "\r\n" +
319        cal.invitation.encodeUTF8(aBody) +
320        "\r\n\r\n\r\n" +
321        "--Boundary_(ID_ryU4ZdJoASiZ+Jo21dCbwA)\r\n" +
322        "Content-type: text/calendar; method=" +
323        aItipItem.responseMethod +
324        "; charset=UTF-8\r\n" +
325        "Content-transfer-encoding: 8BIT\r\n" +
326        "\r\n" +
327        utf8CalText +
328        "\r\n\r\n" +
329        "--Boundary_(ID_ryU4ZdJoASiZ+Jo21dCbwA)--\r\n" +
330        "\r\n" +
331        "--Boundary_(ID_qyG4ZdjoAsiZ+Jo19dCbWQ)\r\n" +
332        "Content-type: application/ics; name=invite.ics\r\n" +
333        "Content-transfer-encoding: 8BIT\r\n" +
334        "Content-disposition: attachment; filename=invite.ics\r\n" +
335        "\r\n" +
336        utf8CalText +
337        "\r\n\r\n" +
338        "--Boundary_(ID_qyG4ZdjoAsiZ+Jo19dCbWQ)--\r\n";
339      cal.LOG("mail text:\n" + mailText);
340
341      let tempFile = Services.dirsvc.get("TmpD", Ci.nsIFile);
342      tempFile.append("itipTemp");
343      tempFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("0600", 8));
344
345      let outputStream = Cc["@mozilla.org/network/file-output-stream;1"].createInstance(
346        Ci.nsIFileOutputStream
347      );
348      // Let's write the file - constants from file-utils.js
349      const MODE_WRONLY = 0x02;
350      const MODE_CREATE = 0x08;
351      const MODE_TRUNCATE = 0x20;
352      outputStream.init(
353        tempFile,
354        MODE_WRONLY | MODE_CREATE | MODE_TRUNCATE,
355        parseInt("0600", 8),
356        0
357      );
358      outputStream.write(mailText, mailText.length);
359      outputStream.close();
360
361      cal.LOG("_createTempImipFile path: " + tempFile.path);
362      return tempFile;
363    } catch (exc) {
364      cal.ASSERT(false, exc);
365      return null;
366    }
367  }
368
369  /**
370   * Provides the identity and account to use when sending iTIP emails. By
371   * default prefers whatever the item's calendar is configured to use or the
372   * default configuration when not set. This method can be overriden to change
373   * that behaviour.
374   *
375   * @param {calIItipItem} aItipItem
376   * @returns {object} - An object containing a property for the identity and
377   *  one for the account.
378   */
379  getIdentityAndAccount(aItipItem) {
380    let identity;
381    let account;
382    if (aItipItem.targetCalendar) {
383      identity = aItipItem.targetCalendar.getProperty("imip.identity");
384      if (identity) {
385        identity = identity.QueryInterface(Ci.nsIMsgIdentity);
386        account = aItipItem.targetCalendar
387          .getProperty("imip.account")
388          .QueryInterface(Ci.nsIMsgAccount);
389      } else {
390        cal.WARN("No email identity configured for calendar " + aItipItem.targetCalendar.name);
391      }
392    }
393    if (!identity) {
394      // use some default identity/account:
395      identity = this.mDefaultIdentity;
396      account = this.mDefaultAccount;
397    }
398    return { identity, account };
399  }
400
401  sendItems(aRecipients, aItipItem, aFromAttendee) {
402    cal.LOG("sendItems: Preparing to send an invitation email...");
403    let items = this._prepareItems(aItipItem, aFromAttendee);
404    if (items === false) {
405      return false;
406    }
407
408    return this._sendXpcomMail(aRecipients, items.subject, items.body, aItipItem);
409  }
410}
411
412/**
413 * CalItipNoEmailTransport is a transport used in place of CalItipEmaiTransport
414 * when we are unable to send messages due to missing configuration.
415 */
416class CalItipNoEmailTransport extends CalItipEmailTransport {
417  wrappedJSObject = this;
418  QueryInterface = ChromeUtils.generateQI(["calIItipTransport"]);
419
420  sendItems(aRecipients, aItipItem, aFromAttendee) {
421    return false;
422  }
423}
424
425/**
426 * CalItipDefaultEmailTransport always uses the identity and account provided
427 * as default instead of the one configured for the calendar.
428 */
429class CalItipDefaultEmailTransport extends CalItipEmailTransport {
430  getIdentityAndAccount() {
431    return { identity: this.mDefaultIdentity, account: this.mDefaultAccount };
432  }
433}
434