15

I'm trying to send .ics calendar invites through SendGrid (from Node server) so that it renders in clients like Outlook or Gmail as an actual invitation (with accept/decline buttons) and not just as an attachment file.

I've spent days researching this (dozens of Stackoverflow questions, RFC-5545, RFC-2446, iCalendar Specification Excerpts, Sendgrid's GitHub issues threads: 1, 2, 3, SendGrid docs, sources etc).

However, there just doesn't seem to be an answer for this (or am I missing something out?).


What I've found so far is that Content-Type for the attachment is very important here, especially, method=REQUEST part. And that even the order of properties in the file makes difference.

Despite a lot of questions here on SO, most of them remain unanswered for some reason.


Here's how I set up my attachment object:

const SendGrid = require("@sendgrid/mail");

  const attachment = {
    filename: 'invite.ics',
    name: 'invite.ics',
    content: Buffer.from(data).toString('base64'),
    disposition: 'attachment',
    contentId: uuid(),
    type: 'application/ics'
  };

SendGrid.send({
      attachments: [attachment],
      templateId,
      from: {
        email: config.emailSender,
        name: config.emailName,
      },
      to: user.email,
      dynamicTemplateData: {
        ...rest,
        user,
      },
      headers: {
        'List-Unsubscribe': `<mailto:unsubscribe.link`,
      },
    });

As for type property, I've tried the following variants:

1. type: 'text/calendar; method=REQUEST'
2. type: 'application/ics'
3. type: 'text/calendar;method=REQUEST;name=\"invite.ics\"'
4. type: 'text/calendar; method=REQUEST; charset=UTF-8; component=vevent'
5. type: 'text/calendar'

However, nothing works except 'text/calendar' and 'application/ics' (and there doesn't seem to be any difference between them).

Content-Type is a reserved header according to the SendGrid docs, so it's not possible to set it somehow through headers property or smth.

The disposition: 'inline' option also doesn't work at all (only disposition: 'attachment').


Here's how the .ics file I generate looks like:

BEGIN:VCALENDAR
PRODID:-//Organization//Organization App//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:REQUEST
BEGIN:VEVENT
DTSTART:20210426T160000Z
DTEND:20210426T170000Z
DTSTAMP:20210418T134622Z
ORGANIZER;CN=John Smith:MAILTO:john.smith+test1@gmail.com
UID:dcfd5905-be85-4c8f-8a27-475b0ec67d8b
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN=John Smith;X-NUM-GUESTS=0:MAILTO:john.smith+test1@gmail.com
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN=John Test;X-NUM-GUESTS=0:MAILTO:john.smith+test2@gmail.com
CREATED:20210418T134622Z
DESCRIPTION:my description
LAST-MODIFIED:20210418T134622Z
LOCATION:https://location.url
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:my summary
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR

The file is perfectly valid and opens seamlessly in iCalendar.

But why doesn't this get rendered in Outlook or Gmail?

Currently, the only way to add an event to calendar is to click "download" on the attachment invite.ics, then open it and only after that does the Calendar app get opened and you can confirm the invitation.


PS: What I mean by rendering the .ics invite is when Outlook or Gmail automatically recognise .ics attachment and display it like on the image below (sorry for the red lines): enter image description here

enter image description here


If it makes any difference, I'm using @sendgrid/mail v6.3.1


Could you please help me to somehow fix my problem? What am I doing wrong?

How to make email clients recognise my .ics files and allow users to accept/decline these invitations in the email client itself without the need to manually download the file and open it?

Community
  • 1
  • 1
Denis Yakovenko
  • 3,241
  • 6
  • 48
  • 82

4 Answers4

17

Okay, so after a lot of trial and error I finally got this working. I hope the code will be helpful to others.

So, firstly, what I did was send an actual event invite from iCalendar and receive this .ics invite (which actually got rendered in both Outlook and Gmail). I looked at how this file was different from what I was generating and found a curious thing:

the key to get this working was...

MAGIC STRINGS

Yeah, totally random, weird magic strings.

Below I'm posting the .ics file content that worked for me.

TOTTALLY-RANDOM-MAGIC-STRING - is a placeholder for a totally random strings like uuids or maybe your organisation emails or anything else.

The key is: with these strings in the file Outlook and Gmail render the invite correctly, and without them - don't. Weird, but working.

I wasn't able to find anything meaningful about this in the docs or RFCs, so I guess it's safe for now to call these magic strings.

The first magic string is TOTTALLY-RANDOM-MAGIC-STRING@imip.me.com.

And the second magic string is /TOTTALLY-RANDOM-MAGIC-STRING/principal/.

BEGIN:VCALENDAR
PRODID:-//Organisation//Organisation App//EN
METHOD:REQUEST
VERSION:2.0
BEGIN:VEVENT
DTEND:20210427T160000Z
ORGANIZER;CN=Organization Name;EMAIL=admin@organisation.com:mailto:TOTTALLY-RANDOM-MAGIC-STRING@imip.me.com
UID:D670DA52-3E7F-4F61-97E2-CB8878954504
DTSTAMP:20210419T181455Z
LOCATION:virtual.event.location.com
DESCRIPTION:description
URL;VALUE=URI:http://organization.com/invite
SEQUENCE:0
SUMMARY:my summary
LAST-MODIFIED:20210419T181455Z
DTSTART:20210427T150000Z
CREATED:20210419T181455Z
ATTENDEE;CUTYPE=INDIVIDUAL;EMAIL=my.email1@gmail.com:mailto:my.email1@gmail.com
ATTENDEE;CUTYPE=INDIVIDUAL;EMAIL=my.email2@gmail.com:mailto:my.email2@gmail.com
ATTENDEE;CN=Organisation Name;CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED;ROLE=CHAIR;EMAIL=admin@organisation.com:/TOTTALLY-RANDOM-MAGIC-STRING/principal/
END:VEVENT
END:VCALENDAR

And the code:

  const SendGrid = require("@sendgrid/mail");

  const attachment = {
    filename: 'invite.ics',
    name: 'invite.ics',
    content: Buffer.from(data).toString('base64'),
    disposition: 'attachment',
    contentId: uuid(),
    type: 'text/calendar; method=REQUEST',
  };

    await SendGrid.send({
      attachments: [attachment],
      templateId,
      from: {
        email: config.emailSender,
        name: config.emailName,
      },
      to: user.email,
      dynamicTemplateData: templateData
   });

I hope this will save some time for people trying to get this .ics stuff working.

Denis Yakovenko
  • 3,241
  • 6
  • 48
  • 82
  • Wow, I don't know how you found that. But thank you! Been struggling with this for so long. – Brian Weinreich Jun 25 '21 at 15:27
  • I'm still having a problem with displaying this ICS widget in the Outlook =( – Armalong Sep 30 '21 at 15:38
  • I tried your code snippet and also made my own. Gmail displays widget correctly, but Outlook doesn't. It seems to me that it's not only about ICS file and its headers, but also the mail message headers itself. For example if you set the account on Outlook client with example1@outlook.com domain and also another account in Outlook on some private domain like example2@email.com. And you send two identical emails to these addresses and open them in Outlook. You'll see a widget for example1@outlook.com and won't see for example2@email.com. – Armalong Sep 30 '21 at 15:44
  • And if you check these email sources you will see, that the one on outlook.com domain has added special headers like "X-MS-*" to that email so the widget shown properly. And these is no such headers for email.com domain were added and the widget isn't shown. I didn't figure this logic out, but what I mentioned is a fact. I believe Gmails does the same and also adds special headers to the email message so the ICS file catched and displayed properly. – Armalong Sep 30 '21 at 15:47
  • I did not need any magic strings besides the UID, but I put a few of these answers together to get the invite prompt to show up. The ATTENDEE parts are clutch – dustbuster Feb 03 '22 at 19:35
8

So, after a lot of trial, I finally got this working, here is a comprehensive explanation and notes on I got it working with likely issues that can arise.

First, I used ics to generate my calendar file. So you will define your events like

const event = {
  start: [2018, 5, 30, 6, 30],
  duration: { hours: 1, minutes: 30 },
  title,
  description,
  location: 'Folsom Field, University of Colorado (finish line)', // you can use a link here if it is online
  status: 'CONFIRMED',
  organizer: { name: 'Admin', email: 'Race@BolderBOULDER.com' },
  attendees: [
    { name: 'Adam Gibbons', email: 'adam@example.com', rsvp: true, partstat: 'NEED-ACTIONS', role: 'REQ-PARTICIPANT' },
    { name: 'Brittany Seaton', email: 'brittany@example2.org', rsvp: true, partstat: 'NEED-ACTIONS', role: 'OPT-PARTICIPANT' }
  ],
 method: "REQUEST",
recurrence: "FREQ=WEEKLY;INTERVAL=2",   //weekly
}

You can add several other key-value pairs here, check ics for full list.

Few things that you should note here

  1. The property method 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. So, it must match the content method when sending mail like this (except you are using a dynamic template, which is not really necessary to get the work done):
content:[
    {
        type: 'text/calendar; method=REQUEST',
        value
    }
]
  1. You can use this for recurrent rule generator if you are not familiar with it and if you even need recurrence at all.

  2. Make sure for each attendant; rsvp, role, and partstat are specified.

  3. As the organizer's email has been specified here, you should not send the invite mail to the organizer as it will not render well nor automatically add to their calendar,the issue is explained in details in this answer.

So, if you plan to send the email to the organizer too so it can be automatically added to his calendar, you should consider making him an attendee and making your company's details as organizer eg

{
...
organizer: { name: 'Company Name', email: 'mail@company.com' },
  attendees: [
    { name: 'Admin', email: 'Race@BolderBOULDER.com', rsvp: true, partstat: 'ACCEPTED', role: 'REQ-PARTICIPANT'  },
    { name: 'Adam Gibbons', email: 'adam@example.com', rsvp: true, partstat: 'NEED-ACTIONS', role: 'REQ-PARTICIPANT' },
    { name: 'Brittany Seaton', email: 'brittany@example2.org', rsvp: true, partstat: 'NEED-ACTIONS', role: 'OPT-PARTICIPANT' }
  ]
...
}

So the real organizer has been added as guest and automatically specified his partstat as accepted. This way, you can send the email to both organizer and the guests so it can be automatically added to their calendar.

The, go ahead and createEvent from this,

const {value} = ics.createEvent(event)

Then, finally send the emails out

await sgMail.sendMultiple({
    to: attendees,
    subject,
    from: { name, email},
content:[
    {
        type: 'text/calendar; method=REQUEST',
        value // from ics createEvent
    }
],
    attachments: [
        {
            content: Buffer.from(value).toString("base64"),
            type: "application/ics",
            namw: "invite.ics",
            filename: "invite.ics",
            disposition: "attachment",
        },
    ],
})

Here, I am using sendMultiple to fire events to all attendees at once and also an attachment for the ics file in addition to the content as a fall back in some email clients (so user can click, open in and add to calendar themselves if need be).

Again, remember you should not add the real organizer to the receivers of the email; so if the real organizer is in the attendee list, then you should slice it out or as I did - add him as guest and use company's details as standard host always then you can fire the email to all.

If all done correctly, each person will get this email with the rsvp and all the beautiful rendering depending on their individual email client, gmail does it very awesome, then it will also be automatically added to their calendar.

2

For me, I was just missing the organizer mailto attribute, without METHOD:REQUEST.

From this answer, it explains that having METHOD:REQUEST means you need to have a valid attendee as well. This might be the reason why the accepted answer works.

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//sebbo.net//ical-generator//EN
TIMEZONE-ID:Asia/Hong_Kong
X-WR-TIMEZONE:Asia/Hong_Kong
BEGIN:VEVENT
UID:some-uuid
SEQUENCE:0
DTSTAMP:20210626T073540
DTSTART;TZID=Asia/Hong_Kong:20210626T004100
DTEND;TZID=Asia/Hong_Kong:20220625T181200
SUMMARY:Test Event
ORGANIZER;CN="Test Organizer":mailto:somerandomemail@gmail.com
URL;VALUE=URI:http://localhost:3000
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR
thwonghin
  • 21
  • 1
2

Try using the same email address for .ics mailto and SendGrid from.

Check how info@tinywhitebird.com is used below:

js part

const message = {
    to: email,
    from: "info@tinywhitebird.com",
    subject: emailSubject,
    text: textContent
}
    
await SendGrid.send({
    ...message,
    html: htmlContent,
    attachments: [attachment]
})

.ics part

ORGANIZER;CN=info@tinywhitebird.com:mailto:info@tinywhitebird.com
Arda Basoglu
  • 1,420
  • 1
  • 12
  • 6