Module: Pikuri::Thunderbird::IcsEvent

Defined in:
lib/pikuri/thunderbird/ics_event.rb

Overview

Builds the iCalendar (+.ics+) text that CalendarCreate stages for Thunderbird's import wizard. The text is the injection surface — the same role MailtoUri plays for compose — because a raw newline in a text field would let an attacker-authored description close DESCRIPTION and inject its own ATTENDEE line or a second VEVENT. So every text value is RFC-5545-escaped (++, ;, ,, newline) and control bytes are stripped before it joins a property line:

IcsEvent.build(title: 'Q2 review', start: Time.parse('2026-07-20 14:00'))
# BEGIN:VCALENDAR … BEGIN:VEVENT
#   SUMMARY:Q2 review
#   DTSTART:20260720T120000Z      ← host-local 14:00 emitted as UTC
#   DTEND:20260720T130000Z        ← default +1h
# END:VEVENT END:VCALENDAR

Time & all-day encoding

Timed events emit +DTSTART+/+DTEND+ as UTC (+…Z+): the caller's Time is taken at face value and converted with #utc, so a bare "2pm" the user typed is 2pm in the host zone — the same "host zone == user zone" heuristic Calendar/DateHelpers already read events back with. An explicit offset in the input (e.g. "15:00 +0900") is honored too — DateHelpers.parse_time keeps it and #utc maps it to the right instant, so start and finish may legitimately sit in different zones. All-day events emit VALUE=DATE with an iCal exclusive DTEND (a single-day event's DTEND is the next day), so finish is the user-intuitive last day.

Immutable.

Constant Summary collapse

PRODID =

Returns product id line value; identifies pikuri as the writer.

Returns:

  • (String)

    product id line value; identifies pikuri as the writer.

'-//pikuri//thunderbird//EN'
CONTROL =

C0/C1 control bytes (except the ones escape turns into \n) — invalid in an ICS value and a structure-injection vector; stripped outright.

A String#delete byte set, not a regexp: escape strips these from the binary copy of the value, keeping the strip byte-wise (every byte here is ASCII, so it can never bite into a multibyte sequence) without a +/n+-flagged regexp meeting a UTF-8 string. Ruby warns on that pairing ("historical binary regexp match"), and an interpreter warning goes to the raw $stderr, bypassing Pikuri.log_io and painting over a TUI's screen — one accented character in a title was enough. Dropping the /n instead would have made the strip character-wise and raise ArgumentError on invalid UTF-8, which CalendarCreate then reports as a bad date.

Returns:

  • (String)

    a String#delete character set.

"\x00-\x08\x0b\x0c\x0e-\x1f\x7f"

Class Method Summary collapse

Class Method Details

.build(title:, start:, finish: nil, all_day: false, location: nil, description: nil, uid: "#{SecureRandom.uuid}@pikuri", dtstamp: Time.now) ⇒ String

Build the .ics document for one event.

Parameters:

  • title (String)

    event summary (SUMMARY).

  • start (Time)

    start instant (timed) or day (all-day, date part used).

  • finish (Time, nil) (defaults to: nil)

    end instant / last all-day date; nil defaults to +start + 1h+ (timed) or the same single day (all-day).

  • all_day (Boolean) (defaults to: false)

    emit VALUE=DATE instead of a UTC datetime.

  • location (String, nil) (defaults to: nil)

    LOCATION, or nil to omit.

  • description (String, nil) (defaults to: nil)

    DESCRIPTION, or nil to omit.

  • uid (String) (defaults to: "#{SecureRandom.uuid}@pikuri")

    event UID; defaults to a fresh random one.

  • dtstamp (Time) (defaults to: Time.now)

    the DTSTAMP instant; defaults to now.

Returns:

  • (String)

    a complete VCALENDAR with one VEVENT, CRLF-joined.



68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/pikuri/thunderbird/ics_event.rb', line 68

def self.build(title:, start:, finish: nil, all_day: false, location: nil, description: nil,
               uid: "#{SecureRandom.uuid}@pikuri", dtstamp: Time.now)
  lines = [
    'BEGIN:VCALENDAR', 'VERSION:2.0', "PRODID:#{PRODID}", 'METHOD:PUBLISH',
    'BEGIN:VEVENT', "UID:#{escape(uid)}", "DTSTAMP:#{utc_stamp(dtstamp)}",
    *(start, finish, all_day),
    "SUMMARY:#{escape(title)}"
  ]
  lines << "LOCATION:#{escape(location)}" if present?(location)
  lines << "DESCRIPTION:#{escape(description)}" if present?(description)
  lines += %w[END:VEVENT END:VCALENDAR]
  # CRLF line endings + a trailing CRLF, per RFC 5545 §3.1.
  "#{lines.join("\r\n")}\r\n"
end