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.
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
  [![Gem Version](https://badge.fury.io/rb/cal-invite.svg)](https://badge.fury.io/rb/cal-invite)
6
- ![Build Status](https://github.com/the-pew-inc/cal-invite/actions/workflows/main.yml/badge.svg)
7
- [![License](https://img.shields.io/github/license/the-pew-inc/cal-invite.svg)]
6
+ ![Build Status](https://github.com/DashAPI-ai/cal-invite/actions/workflows/main.yml/badge.svg)
7
+
8
+ ![License](https://img.shields.io/github/license/DashAPI-ai/cal-invite.svg)
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/the-pew-inc/cal-invite/blob/master/CACHING.md)
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/the-pew-inc/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/the-pew-inc/cal-invite/blob/master/CODE_OF_CONDUCT.md).
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 accross the README, CAHCING and the doc folder.
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/the-pew-inc/cal-invite/blob/master/CODE_OF_CONDUCT.md).
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.
@@ -3,6 +3,9 @@ source "https://rubygems.org"
3
3
 
4
4
  group :development, :test do
5
5
  gem "bundler"
6
+ gem "logger"
7
+ gem "mutex_m"
8
+ gem "bigdecimal"
6
9
  gem "rails", "~> 6.0.0"
7
10
  gem "actionview", "~> 6.0.0"
8
11
  gem "activesupport", "~> 6.0.0"
@@ -3,6 +3,9 @@ source "https://rubygems.org"
3
3
 
4
4
  group :development, :test do
5
5
  gem "bundler"
6
+ gem "logger"
7
+ gem "mutex_m"
8
+ gem "bigdecimal"
6
9
  gem "rails", "~> 7.0.0"
7
10
  gem "actionview", "~> 7.0.0"
8
11
  gem "activesupport", "~> 7.0.0"
@@ -3,6 +3,9 @@ source "https://rubygems.org"
3
3
 
4
4
  group :development, :test do
5
5
  gem "bundler"
6
+ gem "logger"
7
+ gem "mutex_m"
8
+ gem "bigdecimal"
6
9
  gem "rails", "~> 8.0.0"
7
10
  gem "actionview", "~> 8.0.0"
8
11
  gem "activesupport", "~> 8.0.0"