cal-invite 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rdoc_options +5 -1
- data/AGENTS.md +47 -0
- data/CHANGELOG.md +34 -1
- data/CLAUDE.md +49 -0
- data/CONFIGURATION.md +536 -0
- data/README.md +18 -7
- data/SECURITY.md +21 -0
- data/gemfiles/Gemfile.rails6 +3 -0
- data/gemfiles/Gemfile.rails7 +3 -0
- data/gemfiles/Gemfile.rails8 +3 -0
- data/lib/cal_invite/event.rb +103 -9
- data/lib/cal_invite/ical_timezone.rb +132 -0
- data/lib/cal_invite/providers/base_provider.rb +188 -4
- data/lib/cal_invite/providers/ical.rb +23 -24
- data/lib/cal_invite/providers/ics.rb +30 -18
- data/lib/cal_invite/providers/ics_content.rb +33 -22
- data/lib/cal_invite/providers/office365.rb +2 -2
- data/lib/cal_invite/providers/outlook.rb +2 -2
- data/lib/cal_invite/version.rb +1 -1
- data/lib/cal_invite.rb +4 -0
- metadata +69 -8
data/CONFIGURATION.md
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
# Configuration Reference
|
|
2
|
+
|
|
3
|
+
Complete reference for every configurable option in CalInvite: `Event` attributes, providers, the `generate_calendar_url` call, ICS/email-invite delivery, and global `CalInvite.configure` settings.
|
|
4
|
+
|
|
5
|
+
For caching-specific detail, see [CACHING.md](CACHING.md).
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
- [Event attributes](#event-attributes)
|
|
10
|
+
- [Providers](#providers)
|
|
11
|
+
- [`generate_calendar_url`](#generate_calendar_url)
|
|
12
|
+
- [ICS content and downloads](#ics-content-and-downloads)
|
|
13
|
+
- [Email meeting invites (RSVP-capable)](#email-meeting-invites-rsvp-capable)
|
|
14
|
+
- [Replicating a ticketing-platform confirmation email](#replicating-a-ticketing-platform-confirmation-email)
|
|
15
|
+
- [Updating and cancelling invites](#updating-and-cancelling-invites)
|
|
16
|
+
- [Attendee RSVP status and METHOD:REPLY](#attendee-rsvp-status-and-methodreply)
|
|
17
|
+
- [Tracking RSVPs: what the gem does and doesn't do](#tracking-rsvps-what-the-gem-does-and-doesnt-do)
|
|
18
|
+
- [Guest permissions (invite others, see guest list)](#guest-permissions-invite-others-see-guest-list)
|
|
19
|
+
- [Attendee-proposed reschedules (COUNTER)](#attendee-proposed-reschedules-counter)
|
|
20
|
+
- [Timezones and VTIMEZONE](#timezones-and-vtimezone)
|
|
21
|
+
- [Global configuration (`CalInvite.configure`)](#global-configuration-calinviteconfigure)
|
|
22
|
+
|
|
23
|
+
## Event attributes
|
|
24
|
+
|
|
25
|
+
All attributes are set via `CalInvite::Event.new(attributes)` or `event.update_attributes(attributes)`.
|
|
26
|
+
|
|
27
|
+
| Attribute | Type | Required | Default | Notes |
|
|
28
|
+
|------------------------|-----------------|----------------------------------------|---------|-------|
|
|
29
|
+
| `title` | `String` | yes | — | Non-blank. |
|
|
30
|
+
| `start_time` | `Time` | yes, unless `all_day` or using `multi_day_sessions` | — | Always pass UTC. |
|
|
31
|
+
| `end_time` | `Time` | yes, unless `all_day` or using `multi_day_sessions` | — | Always pass UTC. |
|
|
32
|
+
| `description` | `String` | no | `nil` | Plain text; combined with `notes` in provider output. |
|
|
33
|
+
| `location` | `String` | no | `nil` | Physical location only. Kept separate from `url` so each provider formats it correctly. |
|
|
34
|
+
| `url` | `String` | no | `nil` | Virtual meeting link (Zoom, Meet, Teams, etc). Kept separate from `location`. |
|
|
35
|
+
| `attendees` | `Array<String, Hash>` | no | `nil` | Email strings, or `{ email:, name:, partstat:, rsvp: }` hashes for a display name (`CN=`), a specific RSVP status, and/or an explicit `RSVP=` override. Only emitted if `show_attendees` is `true`. `partstat` is one of `:accepted`, `:declined`, `:tentative`, `:needs_action` (default), `:delegated`. `rsvp:` defaults to `true` for `:request`/`:cancel`/`:publish` and `false` for `:reply`/`:counter`/`:decline_counter`; set it explicitly to override (e.g. `rsvp: false` on an already-`:accepted` attendee in a registration-confirmation invite — see [Replicating a ticketing-platform confirmation email](#replicating-a-ticketing-platform-confirmation-email)). |
|
|
36
|
+
| `show_attendees` | `Boolean` | no | `false` | Gate for including `attendees` in generated output. |
|
|
37
|
+
| `organizer` | `Hash` | no, but required for `method: :request` | `nil` | `{ name: "Jane Doe", email: "jane@example.com" }`. `name` is optional. See [Email meeting invites](#email-meeting-invites-rsvp-capable). |
|
|
38
|
+
| `timezone` | `String` | no | `'UTC'` | Controls display/formatting only — does not affect how `start_time`/`end_time` are interpreted. Any IANA identifier (e.g. `'America/New_York'`) produces a correctly converted `DTSTART;TZID=...` plus a full `VTIMEZONE` block; see [Timezones and VTIMEZONE](#timezones-and-vtimezone). |
|
|
39
|
+
| `notes` | `String` | no | `nil` | Appended to the description. |
|
|
40
|
+
| `all_day` | `Boolean` | no | `false` | When `true`, `start_time`/`end_time` validation is skipped. |
|
|
41
|
+
| `multi_day_sessions` | `Array<Hash>` | no | `[]` | `[{ start_time:, end_time: }, ...]`. Used instead of `start_time`/`end_time` for multi-session events. |
|
|
42
|
+
| `uid` | `String` | no | randomly generated, memoized per `Event` instance | Stable RFC 5545 identifier. **Must** be reused across calls when you send an update (`:request`) or cancellation (`:cancel`) for a previously sent invite — see [Updating and cancelling invites](#updating-and-cancelling-invites). |
|
|
43
|
+
| `sequence` | `Integer` | no | `0` | RFC 5545 SEQUENCE. Increment it yourself each time you resend a `:request`/`:cancel` for the same `uid`. |
|
|
44
|
+
| `geo` | `Array<Float>`, `Hash` | no | `nil` | `[37.4595, -122.1418]` or `{ lat:, lng: }`. Emits `GEO:lat;lng` — lets Apple/Google Maps deep-link from the invite. |
|
|
45
|
+
| `reminders` | `Array<Integer>`| no | `nil` | Minutes-before-start values, e.g. `[30, 10]`. One `VALARM` (`ACTION:DISPLAY`) per entry. |
|
|
46
|
+
| `busy` | `Boolean` | no | `true` | `TRANSP:OPAQUE` (busy, default) vs `TRANSP:TRANSPARENT` (free) for free/busy lookups. |
|
|
47
|
+
| `visibility` | `Symbol, String`| no | `:public` | `:public`, `:private`, or `:confidential` → `CLASS:...`. |
|
|
48
|
+
| `rrule` | `String` | no | `nil` | Raw RFC 5545 recurrence rule value, e.g. `"FREQ=WEEKLY;COUNT=5"`. Emitted as `RRULE:...`; construct the value yourself per [RFC 5545 §3.3.10](https://www.rfc-editor.org/rfc/rfc5545#section-3.3.10) — CalInvite doesn't build recurrence rules for you. |
|
|
49
|
+
| `calendar_name` | `String` | no | `nil` | Calendar-level display name. Emitted as `X-WR-CALNAME` on the `VCALENDAR` (not per-event) when set. |
|
|
50
|
+
| `importance` | `Symbol, String`| no | `nil` | `:low`, `:normal`, or `:high`. Emits standard `PRIORITY:` plus Outlook's `X-MICROSOFT-CDO-IMPORTANCE:`. See [Guest permissions](#guest-permissions-invite-others-see-guest-list). |
|
|
51
|
+
| `allow_counter` | `Boolean` | no | `true` | `false` emits `X-MICROSOFT-DISALLOW-COUNTER:TRUE`, hiding Outlook's "Propose New Time" action. See [Guest permissions](#guest-permissions-invite-others-see-guest-list). |
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
event = CalInvite::Event.new(
|
|
55
|
+
title: "Team Meeting",
|
|
56
|
+
start_time: Time.current.utc,
|
|
57
|
+
end_time: Time.current.utc + 1.hour,
|
|
58
|
+
description: "Weekly sync",
|
|
59
|
+
location: "Conference Room A",
|
|
60
|
+
url: "https://zoom.us/j/123456789",
|
|
61
|
+
timezone: "America/New_York",
|
|
62
|
+
attendees: [
|
|
63
|
+
{ email: "person@example.com", name: "Alex Kim" },
|
|
64
|
+
"another@example.com"
|
|
65
|
+
],
|
|
66
|
+
show_attendees: true,
|
|
67
|
+
organizer: { name: "Jane Doe", email: "jane@example.com" },
|
|
68
|
+
notes: "Bring your laptop",
|
|
69
|
+
geo: [37.4595, -122.1418],
|
|
70
|
+
reminders: [30, 10],
|
|
71
|
+
visibility: :private,
|
|
72
|
+
rrule: "FREQ=WEEKLY;COUNT=8"
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Providers
|
|
77
|
+
|
|
78
|
+
Pass one of these symbols to `generate_calendar_url`:
|
|
79
|
+
|
|
80
|
+
| Symbol | Output | Notes |
|
|
81
|
+
|-------------|----------------------------------|-------|
|
|
82
|
+
| `:google` | Google Calendar URL | |
|
|
83
|
+
| `:outlook` | Outlook (outlook.live.com) URL | |
|
|
84
|
+
| `:office365`| Outlook 365 URL | |
|
|
85
|
+
| `:yahoo` | Yahoo Calendar URL | |
|
|
86
|
+
| `:ical` | `.ics` content (`METHOD:PUBLISH` or `:REQUEST`) | Apple iCal / any iCalendar-compatible app |
|
|
87
|
+
| `:ics` | `.ics` content (`METHOD:PUBLISH` or `:REQUEST`) | Generic RFC 5545 file |
|
|
88
|
+
|
|
89
|
+
`CalInvite::Providers::SUPPORTED_PROVIDERS` holds the full list programmatically.
|
|
90
|
+
|
|
91
|
+
`CalInvite::Providers::IcsContent` is also available directly (not via `generate_calendar_url`) if you want raw `.ics` content without going through `Event#generate_calendar_url`'s caching path — see [ICS content and downloads](#ics-content-and-downloads).
|
|
92
|
+
|
|
93
|
+
## `generate_calendar_url`
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
event.generate_calendar_url(provider, method: :publish)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
| Param | Type | Default | Notes |
|
|
100
|
+
|-----------|----------|------------|-------|
|
|
101
|
+
| `provider`| `Symbol` | required | One of the provider symbols above. |
|
|
102
|
+
| `method` | `Symbol` | `:publish` | `:publish`, `:request`, `:cancel`, `:reply`, `:counter`, or `:decline_counter`. Only honored by `:ics`/`:ical` — ignored by URL-based providers. `:request` requires `organizer` to be set on the event; see below. `:cancel` requires reusing the original `uid` — see [Updating and cancelling invites](#updating-and-cancelling-invites). `:reply`/`:counter`/`:decline_counter` omit `RSVP=TRUE` on `ATTENDEE` lines — see [Attendee RSVP status and METHOD:REPLY](#attendee-rsvp-status-and-methodreply) and [Attendee-proposed reschedules (COUNTER)](#attendee-proposed-reschedules-counter). Note: `:decline_counter` renders as `METHOD:DECLINECOUNTER` (one word, per RFC 5545). |
|
|
103
|
+
|
|
104
|
+
Results are cached (when caching is configured) keyed on all event attributes, `provider`, and `method` together — changing any of them produces a distinct cache entry.
|
|
105
|
+
|
|
106
|
+
## ICS content and downloads
|
|
107
|
+
|
|
108
|
+
Two ways to get raw `.ics` content:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
# Via Event (goes through validation + caching)
|
|
112
|
+
content = event.generate_calendar_url(:ics, method: :publish)
|
|
113
|
+
|
|
114
|
+
# Directly via the provider (bypasses Event caching)
|
|
115
|
+
content = CalInvite::Providers::Ics.new(event, method: :publish).generate
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
To serve it as a downloadable file, use `IcsDownload`:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
result = CalInvite::Providers::IcsDownload.wrap_for_download(content, event.title, method: :publish)
|
|
122
|
+
# => { content: "BEGIN:VCALENDAR...", headers: { "Content-Type" => "text/calendar; charset=UTF-8", "Content-Disposition" => "attachment; filename=..." } }
|
|
123
|
+
|
|
124
|
+
send_data(result[:content], filename: "invite.ics", type: result[:headers]["Content-Type"], disposition: "attachment")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Or build headers directly:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
CalInvite::Providers::IcsDownload.headers("invite.ics", method: :publish)
|
|
131
|
+
# => { "Content-Type" => "text/calendar; charset=UTF-8", "Content-Disposition" => "attachment; filename=invite.ics" }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`method:` on `IcsDownload.headers`/`.wrap_for_download` defaults to `nil` (no `method=` parameter on the Content-Type header) for backward compatibility. Pass the same `method:` you used to generate the content — see below for why this must match.
|
|
135
|
+
|
|
136
|
+
## Email meeting invites (RSVP-capable)
|
|
137
|
+
|
|
138
|
+
A plain `.ics` attachment opens as a file in most mail clients. To get the
|
|
139
|
+
behavior services like Luma or Google Calendar produce — Gmail, Outlook, and
|
|
140
|
+
Apple Mail rendering the message as an invite with Accept/Decline actions —
|
|
141
|
+
two things must both be true, and they must agree with each other:
|
|
142
|
+
|
|
143
|
+
1. The `.ics` content itself must use `METHOD:REQUEST` and include an `ORGANIZER`.
|
|
144
|
+
2. The `Content-Type` header on the attachment/part must carry a matching `method=REQUEST` parameter.
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
event = CalInvite::Event.new(
|
|
148
|
+
title: "Team Meeting",
|
|
149
|
+
start_time: Time.current.utc,
|
|
150
|
+
end_time: Time.current.utc + 1.hour,
|
|
151
|
+
timezone: "America/New_York",
|
|
152
|
+
organizer: { name: "Jane Doe", email: "jane@example.com" },
|
|
153
|
+
attendees: ["attendee@example.com"],
|
|
154
|
+
show_attendees: true
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
content = event.generate_calendar_url(:ics, method: :request)
|
|
158
|
+
|
|
159
|
+
headers = CalInvite::Providers::IcsDownload.headers("team-meeting.ics", method: :request)
|
|
160
|
+
# => { "Content-Type" => "text/calendar; charset=UTF-8; method=REQUEST",
|
|
161
|
+
# "Content-Disposition" => "attachment; filename=team-meeting.ics" }
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
With `method: :request`, the generated `.ics` also gets `SEQUENCE`, `STATUS:CONFIRMED`, and richer `ATTENDEE` lines (`CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=...;RSVP=TRUE`, `PARTSTAT` defaulting to `NEEDS-ACTION` unless you set `partstat:` per attendee — see [Attendee RSVP status and METHOD:REPLY](#attendee-rsvp-status-and-methodreply)) — all part of what RFC 5545 expects for a `REQUEST`.
|
|
165
|
+
|
|
166
|
+
If you're sending through ActionMailer, attach `content` with a matching `content_type` rather than `send_data`'s plain `text/calendar` type:
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
attachments["team-meeting.ics"] = {
|
|
170
|
+
mime_type: "text/calendar; method=REQUEST",
|
|
171
|
+
content: content
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Omit `method:` (or pass `method: :publish`) for a plain downloadable calendar file with no RSVP semantics.
|
|
176
|
+
|
|
177
|
+
## Replicating a ticketing-platform confirmation email
|
|
178
|
+
|
|
179
|
+
Event platforms send a specific style of email when you register: an HTML
|
|
180
|
+
body plus a `.ics` attachment that mail/calendar clients recognize as a real
|
|
181
|
+
invite, where you (the registrant) are already shown as `ACCEPTED` rather
|
|
182
|
+
than being asked to RSVP. That's fully reproducible with CalInvite; here's
|
|
183
|
+
the exact shape and how to build it with ActionMailer.
|
|
184
|
+
|
|
185
|
+
**The `.ics` part.** A registration confirmation isn't really requesting a
|
|
186
|
+
response — the registrant already confirmed by registering — so the
|
|
187
|
+
attendee's own `ATTENDEE` line is `PARTSTAT=ACCEPTED` with no `RSVP=TRUE`.
|
|
188
|
+
Set that via `partstat:` and `rsvp: false`:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
event = CalInvite::Event.new(
|
|
192
|
+
title: "Product Strategy Roundtable",
|
|
193
|
+
start_time: Time.current.utc,
|
|
194
|
+
end_time: Time.current.utc + 90.minutes,
|
|
195
|
+
description: "Join us for an evening of discussion...",
|
|
196
|
+
location: "Industrious, 1950 University Ave # 500, Palo Alto, CA 94303, USA",
|
|
197
|
+
geo: [37.4593509, -122.1417815],
|
|
198
|
+
organizer: { name: "Your Company Events", email: "calendar-invite@yourdomain.com" },
|
|
199
|
+
attendees: [
|
|
200
|
+
{ email: registrant.email, name: registrant.email, partstat: :accepted, rsvp: false }
|
|
201
|
+
],
|
|
202
|
+
show_attendees: true,
|
|
203
|
+
uid: "reg-#{registration.id}@yourdomain.com" # persist this — see "Updating and cancelling invites"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
ics_content = event.generate_calendar_url(:ics, method: :request)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`rsvp: false` on an attendee hash suppresses `RSVP=TRUE` for that attendee
|
|
210
|
+
regardless of `method:` — the one case it's needed is exactly this one, where
|
|
211
|
+
`method: :request` is still correct (it's what puts `METHOD:REQUEST` +
|
|
212
|
+
`ORGANIZER` in the file, which is what makes clients treat the whole thing as
|
|
213
|
+
a calendar entry at all) but nothing is actually being requested from a
|
|
214
|
+
recipient who already RSVP'd by registering.
|
|
215
|
+
|
|
216
|
+
**The email.** Attach `ics_content` with the same MIME type/params used in
|
|
217
|
+
the [Email meeting invites](#email-meeting-invites-rsvp-capable) section
|
|
218
|
+
above — the attachment needs `Content-Type: text/calendar; method=REQUEST`
|
|
219
|
+
(not `send_data`'s plain `text/calendar`) for clients to render it as an
|
|
220
|
+
invite rather than a generic file:
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
class RegistrationMailer < ApplicationMailer
|
|
224
|
+
def confirmation(registration)
|
|
225
|
+
@registration = registration
|
|
226
|
+
event = registration.to_cal_event # build as above
|
|
227
|
+
|
|
228
|
+
attachments["invite.ics"] = {
|
|
229
|
+
mime_type: "text/calendar; method=REQUEST; name=invite.ics",
|
|
230
|
+
content: event.generate_calendar_url(:ics, method: :request)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
mail(
|
|
234
|
+
to: registration.email,
|
|
235
|
+
from: "Your Company Events <events@yourdomain.com>",
|
|
236
|
+
reply_to: "your-team@yourdomain.com", # a human, doesn't have to be the ORGANIZER address
|
|
237
|
+
subject: "Registration confirmed for #{event.title}"
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Note the three addresses can legitimately differ, on purpose: `From` is the
|
|
244
|
+
branded sender identity, `Reply-To` is wherever a human should see replies,
|
|
245
|
+
and the `.ics`'s `ORGANIZER` `mailto:` is whatever mailbox should receive
|
|
246
|
+
iTIP `METHOD:REPLY` messages if you're doing [RSVP tracking](#tracking-rsvps-what-the-gem-does-and-doesnt-do)
|
|
247
|
+
— it doesn't have to match either header. If you don't have a mailbox
|
|
248
|
+
watching that address, that's fine too; the invite still works, you just
|
|
249
|
+
won't get the reply-tracking benefit.
|
|
250
|
+
|
|
251
|
+
**Apple Wallet passes (`.pkpass`) are unrelated and out of scope.** A
|
|
252
|
+
`.pkpass` file is Apple's PassKit format (tickets, boarding passes, loyalty
|
|
253
|
+
cards) — a signed archive requiring an Apple Developer "Pass Type ID"
|
|
254
|
+
certificate and its own generation/signing toolchain entirely separate from
|
|
255
|
+
iCalendar. It's not a calendar invite mechanism at all (the `.ics` in this
|
|
256
|
+
kind of email does the calendar part; the `.pkpass` is a separate, optional
|
|
257
|
+
attachment some platforms add for wallet/ticket display). CalInvite doesn't
|
|
258
|
+
produce these and won't — if you need them, look at a PassKit-specific gem
|
|
259
|
+
(e.g. `passbook`/`pkpass` on RubyGems) as a separate concern from anything
|
|
260
|
+
here.
|
|
261
|
+
|
|
262
|
+
## Updating and cancelling invites
|
|
263
|
+
|
|
264
|
+
Mail/calendar clients (Gmail, Outlook, Apple Mail) match a `REQUEST` update or a
|
|
265
|
+
`CANCEL` to an existing invite by **UID**, not by content — a new random UID is
|
|
266
|
+
indistinguishable from an unrelated new event. To send an update or cancellation
|
|
267
|
+
for an invite you sent earlier, reconstruct the `Event` with the **same `uid`**
|
|
268
|
+
and a **higher `sequence`** than what you sent originally:
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
# Original invite
|
|
272
|
+
event = CalInvite::Event.new(
|
|
273
|
+
title: "Team Meeting",
|
|
274
|
+
start_time: Time.current.utc,
|
|
275
|
+
end_time: Time.current.utc + 1.hour,
|
|
276
|
+
organizer: { name: "Jane Doe", email: "jane@example.com" }
|
|
277
|
+
)
|
|
278
|
+
event.uid # => e.g. "1786300000-abcdef0123456789@cal-invite" — persist this
|
|
279
|
+
original_content = event.generate_calendar_url(:ics, method: :request)
|
|
280
|
+
|
|
281
|
+
# ...later, rescheduling the same meeting...
|
|
282
|
+
event = CalInvite::Event.new(
|
|
283
|
+
title: "Team Meeting",
|
|
284
|
+
start_time: Time.current.utc + 1.day,
|
|
285
|
+
end_time: Time.current.utc + 1.day + 1.hour,
|
|
286
|
+
organizer: { name: "Jane Doe", email: "jane@example.com" },
|
|
287
|
+
uid: "1786300000-abcdef0123456789@cal-invite", # same as the original
|
|
288
|
+
sequence: 1 # incremented
|
|
289
|
+
)
|
|
290
|
+
updated_content = event.generate_calendar_url(:ics, method: :request)
|
|
291
|
+
|
|
292
|
+
# ...or cancelling it entirely...
|
|
293
|
+
event.sequence = 2
|
|
294
|
+
cancellation = event.generate_calendar_url(:ics, method: :cancel)
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## Attendee RSVP status and METHOD:REPLY
|
|
298
|
+
|
|
299
|
+
Pass attendees as hashes with `partstat:` to control each `ATTENDEE`'s status
|
|
300
|
+
(`PARTSTAT=`) directly — useful both for representing already-known RSVPs and
|
|
301
|
+
for building an attendee's `METHOD:REPLY` back to the organizer:
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
event = CalInvite::Event.new(
|
|
305
|
+
title: "Team Meeting",
|
|
306
|
+
start_time: Time.current.utc,
|
|
307
|
+
end_time: Time.current.utc + 1.hour,
|
|
308
|
+
organizer: { name: "Jane Doe", email: "jane@example.com" },
|
|
309
|
+
attendees: [{ email: "bob@example.com", name: "Bob Smith", partstat: :declined }],
|
|
310
|
+
show_attendees: true,
|
|
311
|
+
uid: "1786300000-abcdef0123456789@cal-invite" # the original invite's UID
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
reply = event.generate_calendar_url(:ics, method: :reply)
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
`partstat:` accepts `:accepted`, `:declined`, `:tentative`, `:needs_action`
|
|
318
|
+
(default), or `:delegated`. `RSVP=TRUE` is omitted from `ATTENDEE` lines for
|
|
319
|
+
`method: :reply`/`:counter`/`:decline_counter` (none of these are themselves
|
|
320
|
+
requesting a further response); every other method sets it.
|
|
321
|
+
|
|
322
|
+
If you don't pass `uid:` explicitly, `Event.new` generates one and memoizes it
|
|
323
|
+
on that instance — repeated `generate_calendar_url` calls on the *same* `Event`
|
|
324
|
+
object always reuse it, but a freshly constructed `Event` for the same
|
|
325
|
+
underlying meeting will not, unless you pass the original `uid` back in. In
|
|
326
|
+
practice this means: persist `event.uid` (e.g. alongside the meeting record in
|
|
327
|
+
your database) the first time you send a `:request`, and pass it back in on
|
|
328
|
+
every subsequent `:request`/`:cancel` for that meeting.
|
|
329
|
+
|
|
330
|
+
A working reference implementation of this whole pattern — a `Meeting` model
|
|
331
|
+
persisting `uid`/`sequence`, and controller actions that send/reschedule/cancel
|
|
332
|
+
— lives in [`Example/calendar_app`](Example/calendar_app): see
|
|
333
|
+
`app/models/meeting.rb` and `app/controllers/meetings_controller.rb`. It
|
|
334
|
+
depends on the attendee-hash/`uid`/`sequence` features documented here, so it
|
|
335
|
+
needs whatever gem version ships those (0.2.0+) — the example app's `Gemfile`
|
|
336
|
+
tracks the published gem, so point it at `path: "../.."` locally if you want
|
|
337
|
+
to run this demo before that release is out.
|
|
338
|
+
|
|
339
|
+
## Tracking RSVPs: what the gem does and doesn't do
|
|
340
|
+
|
|
341
|
+
CalInvite is **outbound-only**: it renders `.ics` content and calendar URLs.
|
|
342
|
+
It has no concept of a response arriving back. This matters because of how
|
|
343
|
+
iTIP (the RSVP protocol RFC 5545 invites use) actually works:
|
|
344
|
+
|
|
345
|
+
1. You send a `.ics` with `METHOD:REQUEST` to an attendee.
|
|
346
|
+
2. Their mail client shows Accept/Decline/Maybe because it recognizes the
|
|
347
|
+
`METHOD:REQUEST` + `ATTENDEE` structure.
|
|
348
|
+
3. When they click one, their mail client sends a **new email** back to the
|
|
349
|
+
`ORGANIZER` address, with a `.ics` attachment of its own using
|
|
350
|
+
`METHOD:REPLY` (see [Attendee RSVP status and METHOD:REPLY](#attendee-rsvp-status-and-methodreply)
|
|
351
|
+
for what that looks like).
|
|
352
|
+
4. That reply lands in the **organizer's mailbox** — not in your Rails app,
|
|
353
|
+
not anywhere CalInvite can see it. Nothing about steps 3–4 involves your
|
|
354
|
+
application unless you build something to intercept it.
|
|
355
|
+
|
|
356
|
+
So "who's coming" tracking is a feature you build on top, not something the
|
|
357
|
+
gem can hand you. Two ways to build it:
|
|
358
|
+
|
|
359
|
+
**A. Parse inbound `METHOD:REPLY` emails.** Point the `ORGANIZER` address at
|
|
360
|
+
an inbox your app can read — either via a transactional-email provider's
|
|
361
|
+
inbound-parse webhook (SendGrid Inbound Parse, Postmark Inbound, Mailgun
|
|
362
|
+
Routes, AWS SES + SNS + Lambda) or by polling a real mailbox over IMAP. When
|
|
363
|
+
a reply arrives, extract its `text/calendar; method=REPLY` part, read the
|
|
364
|
+
`UID` (to find your stored event) and each `ATTENDEE`'s `PARTSTAT`, and update
|
|
365
|
+
your own record. `Example/calendar_app/app/controllers/event_replies_controller.rb`
|
|
366
|
+
is a worked (but simplified — regex-based, not a full iCalendar parser)
|
|
367
|
+
example of this extraction.
|
|
368
|
+
|
|
369
|
+
**Caveat:** this only works reliably when the `ORGANIZER` address is a real
|
|
370
|
+
mailbox or calendar account. A significant chunk of real-world clients
|
|
371
|
+
(Gmail's own RSVP buttons among them) don't send a distinct, parseable
|
|
372
|
+
`METHOD:REPLY` email back to an arbitrary `From:`/`ORGANIZER` address the
|
|
373
|
+
way a desktop client talking to an Exchange server does — some only update
|
|
374
|
+
*their own* calendar and never notify the organizer by email at all. Don't
|
|
375
|
+
build a product around "we'll always get a REPLY email"; treat it as
|
|
376
|
+
best-effort.
|
|
377
|
+
|
|
378
|
+
**B. Don't send raw email invites at all — create the event via the
|
|
379
|
+
provider's API instead.** If reliable RSVP tracking matters to your product,
|
|
380
|
+
Google Calendar API's `events.insert` (with `sendUpdates: "all"`) and
|
|
381
|
+
Microsoft Graph's `/events` both give you structured attendee status
|
|
382
|
+
(`attendees[].responseStatus`) plus real push-notification webhooks
|
|
383
|
+
(Google Calendar push notifications / Graph change notifications) when it
|
|
384
|
+
changes — no email parsing involved. CalInvite doesn't do this (it has no
|
|
385
|
+
API client, only URL/`.ics` generation), so this path means calling those
|
|
386
|
+
APIs yourself alongside, or instead of, CalInvite. Reach for this over (A)
|
|
387
|
+
whenever you control which calendar system your organizers use.
|
|
388
|
+
|
|
389
|
+
## Guest permissions (invite others, see guest list)
|
|
390
|
+
|
|
391
|
+
Google Calendar's "guests can invite others" / "guests can see guest list" /
|
|
392
|
+
"guests can modify event" toggles are genuinely **API-only** — there is no
|
|
393
|
+
`.ics` property, and no parameter on Google's own "add to calendar" render
|
|
394
|
+
URL (`calendar.google.com/calendar/render`), that expresses them. They're
|
|
395
|
+
properties of a Google Calendar API event specifically
|
|
396
|
+
(`guestsCanInviteOthers`, `guestsCanSeeOtherGuests`, `guestsCanModify`),
|
|
397
|
+
enforced by Google's servers once the event actually lives in someone's
|
|
398
|
+
Google Calendar — not something an emailed file or a "click to add this to
|
|
399
|
+
your calendar" link can carry. Microsoft has its own, different equivalent
|
|
400
|
+
behind Graph API permissions, with the same constraint. If this matters to
|
|
401
|
+
your product, that's a vote for calling the provider's API directly (see
|
|
402
|
+
"Tracking RSVPs" approach **B** above) instead of, or alongside, CalInvite.
|
|
403
|
+
|
|
404
|
+
CalInvite *does* support the one **real**, protocol-level, provider-specific
|
|
405
|
+
control that exists for this family of "restrict what attendees can do"
|
|
406
|
+
requests — see the next section.
|
|
407
|
+
|
|
408
|
+
### What CalInvite adds for Outlook specifically
|
|
409
|
+
|
|
410
|
+
Outlook recognizes several non-standard `X-MICROSOFT-*` properties that
|
|
411
|
+
aren't part of RFC 5545. They're safe to always include — compliant parsers
|
|
412
|
+
(Google, Apple, everything else) are required to ignore properties they
|
|
413
|
+
don't recognize — so CalInvite emits them automatically from existing/new
|
|
414
|
+
`Event` attributes, no separate "generate for Outlook" step needed:
|
|
415
|
+
|
|
416
|
+
| `Event` attribute | Property emitted | Effect |
|
|
417
|
+
|---|---|---|
|
|
418
|
+
| `allow_counter` (default `true`) | `X-MICROSOFT-DISALLOW-COUNTER:TRUE` when `false` | Hides Outlook's "Propose New Time" button. **This is the real lever for "prevent attendee-initiated time changes"** — the closest equivalent to a Google guest-permission toggle that actually exists at the invite level. |
|
|
419
|
+
| `importance` (`:low`/`:normal`/`:high`) | `PRIORITY:` (standard RFC 5545, 1/5/9) + `X-MICROSOFT-CDO-IMPORTANCE:` (0/1/2) | Outlook's importance flag; the standard `PRIORITY` half is honored to some degree by other clients too. |
|
|
420
|
+
| `busy` (default `true`) | `X-MICROSOFT-CDO-BUSYSTATUS:BUSY`/`FREE`, alongside the standard `TRANSP:` | Some Outlook versions read this more reliably than `TRANSP` alone for free/busy display. |
|
|
421
|
+
|
|
422
|
+
```ruby
|
|
423
|
+
event = CalInvite::Event.new(
|
|
424
|
+
title: "Board Meeting",
|
|
425
|
+
start_time: Time.current.utc,
|
|
426
|
+
end_time: Time.current.utc + 1.hour,
|
|
427
|
+
organizer: { name: "Jane Doe", email: "jane@example.com" },
|
|
428
|
+
attendees: ["bob@example.com"],
|
|
429
|
+
show_attendees: true,
|
|
430
|
+
importance: :high,
|
|
431
|
+
allow_counter: false # attendees can't propose a new time in Outlook
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
event.generate_calendar_url(:ics, method: :request)
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
None of this is a "generate a different file per client" mechanism — it's
|
|
438
|
+
one `.ics`/`.ical` output with a few extra lines that only Outlook acts on.
|
|
439
|
+
There's no equivalent lever for Google/Apple Mail beyond `:publish` vs
|
|
440
|
+
`:request` (see [Email meeting invites](#email-meeting-invites-rsvp-capable)):
|
|
441
|
+
`:publish` renders as a read-only calendar entry with no RSVP UI at all,
|
|
442
|
+
`:request` invites interaction, and there's no middle ground (e.g. "can RSVP
|
|
443
|
+
but can't see other guests") at the `.ics` level for those clients.
|
|
444
|
+
|
|
445
|
+
## Attendee-proposed reschedules (COUNTER)
|
|
446
|
+
|
|
447
|
+
RFC 5545 defines `METHOD:COUNTER` (an attendee proposes a different time) and
|
|
448
|
+
`METHOD:DECLINECOUNTER` (the organizer rejects the proposal). CalInvite
|
|
449
|
+
generates both:
|
|
450
|
+
|
|
451
|
+
```ruby
|
|
452
|
+
# Attendee proposes a new time for an existing invite (same uid, same sequence —
|
|
453
|
+
# COUNTER doesn't advance sequence; the organizer decides whether to accept it)
|
|
454
|
+
counter_event = CalInvite::Event.new(
|
|
455
|
+
title: "Board Meeting",
|
|
456
|
+
start_time: proposed_start_time_utc,
|
|
457
|
+
end_time: proposed_end_time_utc,
|
|
458
|
+
organizer: { name: "Jane Doe", email: "jane@example.com" },
|
|
459
|
+
attendees: [{ email: "bob@example.com", partstat: :tentative }],
|
|
460
|
+
show_attendees: true,
|
|
461
|
+
uid: original_event.uid,
|
|
462
|
+
sequence: original_event.sequence
|
|
463
|
+
)
|
|
464
|
+
counter_event.generate_calendar_url(:ics, method: :counter)
|
|
465
|
+
|
|
466
|
+
# Organizer rejects the proposal
|
|
467
|
+
decline_event = CalInvite::Event.new(
|
|
468
|
+
title: "Board Meeting",
|
|
469
|
+
start_time: original_event.start_time,
|
|
470
|
+
end_time: original_event.end_time,
|
|
471
|
+
organizer: { name: "Jane Doe", email: "jane@example.com" },
|
|
472
|
+
uid: original_event.uid,
|
|
473
|
+
sequence: original_event.sequence
|
|
474
|
+
)
|
|
475
|
+
decline_event.generate_calendar_url(:ics, method: :decline_counter)
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
**Caveat that doesn't go away just because generation exists:** mainstream
|
|
479
|
+
mail clients (Gmail, Outlook.com web, Apple Mail) mostly don't expose a
|
|
480
|
+
"propose new time" *action* on a received `.ics` invite the way
|
|
481
|
+
Outlook desktop/Exchange does — so a `:counter` you send has nowhere reliable
|
|
482
|
+
to come *from* in the first place (an attendee can't easily trigger one from
|
|
483
|
+
their inbox), and where it does arrive, rendering is inconsistent. Use it when
|
|
484
|
+
you know your organizer side is Outlook/Exchange-based, or when you're
|
|
485
|
+
generating both sides of the exchange yourself (e.g. a scheduling tool
|
|
486
|
+
proposing times through its own UI, using `:counter`/`:decline_counter` as the
|
|
487
|
+
wire format). For the general case, the practical fallback remains: a real
|
|
488
|
+
`Reply-To`/`organizer` address for plain-language negotiation, then an updated
|
|
489
|
+
`:request` (see [Updating and cancelling invites](#updating-and-cancelling-invites))
|
|
490
|
+
once a new time is agreed. Also see `allow_counter` above if you want to shut
|
|
491
|
+
this off on Outlook entirely rather than support it.
|
|
492
|
+
|
|
493
|
+
## Timezones and VTIMEZONE
|
|
494
|
+
|
|
495
|
+
For any `timezone` other than `'UTC'` that's a recognized IANA/Olson identifier
|
|
496
|
+
(e.g. `'America/New_York'`, `'Europe/London'`), the `:ics`/`:ical` providers:
|
|
497
|
+
|
|
498
|
+
1. Convert `start_time`/`end_time` (always supplied in UTC) to that zone's local
|
|
499
|
+
wall-clock time for the `DTSTART;TZID=...`/`DTEND;TZID=...` properties.
|
|
500
|
+
2. Emit a full `VTIMEZONE` component with real `STANDARD`/`DAYLIGHT` observances
|
|
501
|
+
(offsets, names, and `RRULE`s) derived from the timezone's actual transition
|
|
502
|
+
rules via [TZInfo](https://github.com/tzinfo/tzinfo) — not just a bare `TZID`
|
|
503
|
+
line — as RFC 5545 requires.
|
|
504
|
+
|
|
505
|
+
`'UTC'` needs neither: `DTSTART`/`DTEND` are emitted directly as `Z`-suffixed UTC
|
|
506
|
+
timestamps with no `VTIMEZONE` block, which every calendar client understands
|
|
507
|
+
unambiguously.
|
|
508
|
+
|
|
509
|
+
If `timezone` isn't a recognized IANA identifier (e.g. a raw offset string like
|
|
510
|
+
`'+01:00'`), both providers fall back to treating `start_time`/`end_time` as
|
|
511
|
+
already being in that zone's wall-clock time — the pre-existing behavior — since
|
|
512
|
+
there's no timezone database entry to convert against or build a `VTIMEZONE`
|
|
513
|
+
from.
|
|
514
|
+
|
|
515
|
+
## Global configuration (`CalInvite.configure`)
|
|
516
|
+
|
|
517
|
+
```ruby
|
|
518
|
+
# config/initializers/cal_invite.rb
|
|
519
|
+
CalInvite.configure do |config|
|
|
520
|
+
config.cache_store = Rails.cache # or :memory_store, :null_store, or a custom read/write/delete object
|
|
521
|
+
config.cache_prefix = 'my_app_cal_invite'
|
|
522
|
+
config.cache_expires_in = 3600 # seconds
|
|
523
|
+
config.webhook_secret = ENV['CAL_INVITE_WEBHOOK_SECRET']
|
|
524
|
+
config.timezone = 'UTC'
|
|
525
|
+
end
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
| Option | Type | Default | Notes |
|
|
529
|
+
|---------------------|-----------------------------------------|----------------|-------|
|
|
530
|
+
| `cache_store` | `:memory_store`, `:null_store`, or any object implementing `read`/`write`/`delete` | `nil` (caching disabled) | See [CACHING.md](CACHING.md) for full detail. |
|
|
531
|
+
| `cache_prefix` | `String` | `'cal_invite'` | Namespaces cache keys. |
|
|
532
|
+
| `cache_expires_in` | `Integer` (seconds) | `86400` (24h) | |
|
|
533
|
+
| `webhook_secret` | `String` | `nil` | Reserved for webhook signature verification in consuming apps. |
|
|
534
|
+
| `timezone` | `String` | `'UTC'` | Configuration-level default; per-event `timezone` attribute takes precedence for that event's display formatting. |
|
|
535
|
+
|
|
536
|
+
Caching is disabled unless `cache_store` is set — without it, `generate_calendar_url` regenerates output on every call.
|
data/README.md
CHANGED
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
A Ruby gem for generating calendar invitations across multiple calendar platforms with caching and webhook support.
|
|
4
4
|
|
|
5
5
|
[](https://badge.fury.io/rb/cal-invite)
|
|
6
|
-

|
|
7
|
+
|
|
8
|
+

|
|
8
9
|
|
|
9
10
|
## Compatibility
|
|
10
11
|
|
|
11
12
|
- Ruby >= 3.0.0
|
|
12
|
-
- Rails 6.0, 6.1, 7.0, 7.1, 8.0
|
|
13
|
+
- Rails 6.0, 6.1, 7.0, 7.1, 8.0, 8.1
|
|
13
14
|
|
|
14
15
|
## Supported Calendar Platforms
|
|
15
16
|
|
|
@@ -56,6 +57,8 @@ Important notes:
|
|
|
56
57
|
- Use the timezone parameter to specify the display timezone
|
|
57
58
|
- Location and URL are handled separately for better calendar integration
|
|
58
59
|
|
|
60
|
+
For the full list of `Event` attributes, providers, and configuration options, see [CONFIGURATION.md](CONFIGURATION.md).
|
|
61
|
+
|
|
59
62
|
```ruby
|
|
60
63
|
# Create an event with physical location
|
|
61
64
|
event = CalInvite::Event.new(
|
|
@@ -149,6 +152,14 @@ end
|
|
|
149
152
|
|
|
150
153
|
You can implement this in any controller and route that makes sense for your application's architecture.
|
|
151
154
|
|
|
155
|
+
### Sending Email Meeting Invites (RSVP-capable)
|
|
156
|
+
|
|
157
|
+
By default, a `.ics` attachment opens as a file in most mail clients. To get
|
|
158
|
+
Gmail/Outlook/Apple Mail to render the message as an invite with Accept/Decline
|
|
159
|
+
actions (like Luma or Google Calendar invites do), set an `organizer` on the
|
|
160
|
+
event and pass `method: :request` when generating. Full walkthrough and all
|
|
161
|
+
options in [CONFIGURATION.md](CONFIGURATION.md#email-meeting-invites-rsvp-capable).
|
|
162
|
+
|
|
152
163
|
### ICS File Generation
|
|
153
164
|
|
|
154
165
|
The gem provides two ways to generate ICS files:
|
|
@@ -224,7 +235,7 @@ CalInvite.configure do |config|
|
|
|
224
235
|
end
|
|
225
236
|
```
|
|
226
237
|
|
|
227
|
-
For detailed information about configuring caching in Rails applications and available options, see our [Caching Guide](https://github.com/
|
|
238
|
+
For detailed information about configuring caching in Rails applications and available options, see our [Caching Guide](https://github.com/DashAPI-ai/cal-invite/blob/master/CACHING.md). For every other configuration option (`Event` attributes, providers, email invites), see [CONFIGURATION.md](CONFIGURATION.md).
|
|
228
239
|
|
|
229
240
|
## Development
|
|
230
241
|
|
|
@@ -240,11 +251,11 @@ Run all the tests before submitting: `bundle exec rake test`
|
|
|
240
251
|
|
|
241
252
|
## Contributing
|
|
242
253
|
|
|
243
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/
|
|
254
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/DashAPI-ai/cal-invite. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/DashAPI-ai/cal-invite/blob/master/CODE_OF_CONDUCT.md).
|
|
244
255
|
|
|
245
256
|
## Documentation
|
|
246
257
|
|
|
247
|
-
The documentation is spread
|
|
258
|
+
The documentation is spread across the README, [CONFIGURATION.md](CONFIGURATION.md), [CACHING.md](CACHING.md), and the doc folder.
|
|
248
259
|
|
|
249
260
|
The documentation can be generated using `bundle exec rake rdoc`
|
|
250
261
|
|
|
@@ -254,4 +265,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
|
|
|
254
265
|
|
|
255
266
|
## Code of Conduct
|
|
256
267
|
|
|
257
|
-
Everyone interacting in the Cal::Invite project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/
|
|
268
|
+
Everyone interacting in the Cal::Invite project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/DashAPI-ai/cal-invite/blob/master/CODE_OF_CONDUCT.md).
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported Versions
|
|
4
|
+
|
|
5
|
+
Use this section to tell people about which versions of your project are
|
|
6
|
+
currently being supported with security updates.
|
|
7
|
+
|
|
8
|
+
| Version | Supported |
|
|
9
|
+
| ------- | ------------------ |
|
|
10
|
+
| 5.1.x | :white_check_mark: |
|
|
11
|
+
| 5.0.x | :x: |
|
|
12
|
+
| 4.0.x | :white_check_mark: |
|
|
13
|
+
| < 4.0 | :x: |
|
|
14
|
+
|
|
15
|
+
## Reporting a Vulnerability
|
|
16
|
+
|
|
17
|
+
Use this section to tell people how to report a vulnerability.
|
|
18
|
+
|
|
19
|
+
Tell them where to go, how often they can expect to get an update on a
|
|
20
|
+
reported vulnerability, what to expect if the vulnerability is accepted or
|
|
21
|
+
declined, etc.
|
data/gemfiles/Gemfile.rails6
CHANGED
data/gemfiles/Gemfile.rails7
CHANGED