Skip to main content

mailrs_ical/
lib.rs

1#![deny(missing_docs)]
2#![deny(rustdoc::broken_intra_doc_links)]
3
4//! RFC 5545 (iCalendar) + RFC 5546 (iTIP) parser, serializer, and typed
5//! semantics — hand-rolled, zero I/O.
6//!
7//! Built for Rust MTAs that need to read an `iCalendar` invite off the wire
8//! (typically a `text/calendar` MIME part) and emit a `REPLY` back. The
9//! parser is byte-by-byte with no parser-combinator dependencies — the same
10//! style as [`mailrs-smtp-proto`] and [`mailrs-imap-proto`] — keeping the
11//! dependency footprint small and the error surface predictable.
12//!
13//! # Quick start
14//!
15//! ```
16//! use mailrs_ical::{parse_invite, Method};
17//!
18//! let ics = b"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nMETHOD:REQUEST\r\n\
19//!             PRODID:-//Example//Cal//EN\r\nBEGIN:VEVENT\r\n\
20//!             UID:abc\r\nDTSTAMP:20260101T000000Z\r\n\
21//!             DTSTART:20260102T100000Z\r\nSUMMARY:Lunch\r\n\
22//!             END:VEVENT\r\nEND:VCALENDAR\r\n";
23//!
24//! let invite = parse_invite(ics).unwrap();
25//! assert_eq!(invite.method, Method::Request);
26//! assert_eq!(invite.uid, "abc");
27//! assert_eq!(invite.summary, "Lunch");
28//! ```
29//!
30//! # Module layout
31//!
32//! - [`parse`]      — RFC 5545 §3.1 text → raw AST (line folding, property tree, BEGIN/END nesting).
33//! - [`semantics`]  — AST → [`ParsedInvite`] (typed METHOD / ATTENDEE / ORGANIZER / SEQUENCE / RRULE / …).
34//! - [`vtimezone`]  — Inline VTIMEZONE handling with `chrono-tz` IANA fallback.
35//! - [`serialize`]  — [`ParsedInvite`] → RFC 5545 text (for iTIP `REPLY`).
36//!
37//! Top-level entry point [`parse_invite`] takes raw `text/calendar` bytes and
38//! returns a fully-typed [`ParsedInvite`].
39//!
40//! # What this crate does NOT do
41//!
42//! - No MIME parsing — extract the `text/calendar` part upstream (e.g. with
43//!   [`mail-parser`](https://crates.io/crates/mail-parser)).
44//! - No SMTP. See [`mailrs-smtp-proto`] / [`mailrs-smtp-client`].
45//! - No calendar storage or CalDAV. This is the wire-format layer only.
46//!
47//! [`mailrs-smtp-proto`]: https://crates.io/crates/mailrs-smtp-proto
48//! [`mailrs-smtp-client`]: https://crates.io/crates/mailrs-smtp-client
49//! [`mailrs-imap-proto`]: https://crates.io/crates/mailrs-imap-proto
50
51pub mod parse;
52pub mod semantics;
53#[allow(clippy::module_inception)]
54pub mod serialize;
55pub mod vtimezone;
56
57#[cfg(test)]
58mod tests;
59
60use chrono::{DateTime, Utc};
61use compact_str::CompactString;
62use serde::Serialize;
63
64/// iTIP method (RFC 5546 §1.4 + §3).
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
66pub enum Method {
67    /// `REQUEST` — invitation or update.
68    Request,
69    /// `REPLY` — attendee response (accept/decline/etc).
70    Reply,
71    /// `CANCEL` — organizer cancels the event.
72    Cancel,
73    /// `UPDATE` — non-significant update (no re-RSVP needed).
74    Update,
75    /// `COUNTER` — attendee proposes a change.
76    Counter,
77    /// `REFRESH` — attendee requests latest state.
78    Refresh,
79    /// `ADD` — add an occurrence to a recurring event.
80    Add,
81    /// `PUBLISH` — publish a non-interactive event (newsletter feed).
82    Publish,
83    /// `DECLINECOUNTER` — organizer rejects an attendee's COUNTER.
84    DeclineCounter,
85}
86
87/// Calendar date-time tri-state (RFC 5545 §3.3.5).
88#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
89pub enum CalDateTime {
90    /// Floating local time — no timezone attached. e.g. `DTSTART:19980118T230000`.
91    Floating(chrono::NaiveDateTime),
92    /// UTC. e.g. `DTSTART:19980119T070000Z`.
93    Utc(DateTime<Utc>),
94    /// TZID-qualified. e.g. `DTSTART;TZID=America/New_York:19980119T020000`.
95    /// `tz_name` is the raw TZID string; resolved at evaluation time via
96    /// [`vtimezone`] (handles both IANA names and inline VTIMEZONE blocks).
97    Zoned {
98        /// IANA timezone identifier or inline VTIMEZONE id.
99        ///
100        /// **v2 change**: `CompactString` — IANA TZIDs fit inline.
101        tz_name: CompactString,
102        /// Local civil time in that zone.
103        local: chrono::NaiveDateTime,
104    },
105    /// Date-only (RFC 5545 §3.3.4). e.g. `DTSTART;VALUE=DATE:19980118`.
106    Date(chrono::NaiveDate),
107}
108
109/// PARTSTAT parameter (RFC 5545 §3.2.12).
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
111pub enum PartStat {
112    /// `NEEDS-ACTION` — not yet responded.
113    NeedsAction,
114    /// `ACCEPTED` — will attend.
115    Accepted,
116    /// `DECLINED` — will not attend.
117    Declined,
118    /// `TENTATIVE` — may attend.
119    Tentative,
120    /// `DELEGATED` — passed to another attendee.
121    Delegated,
122    /// `COMPLETED` — VTODO only.
123    Completed,
124    /// `IN-PROCESS` — VTODO only.
125    InProcess,
126}
127
128/// ROLE parameter (RFC 5545 §3.2.16).
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
130pub enum Role {
131    /// `CHAIR` — meeting chair.
132    Chair,
133    /// `REQ-PARTICIPANT` — required attendance.
134    ReqParticipant,
135    /// `OPT-PARTICIPANT` — optional attendance.
136    OptParticipant,
137    /// `NON-PARTICIPANT` — for-information-only.
138    NonParticipant,
139}
140
141/// One ATTENDEE row from a VEVENT.
142#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
143pub struct Attendee {
144    /// Mailto address (stripped of the `mailto:` prefix).
145    pub email: String,
146    /// Common name (`CN=` parameter), if present.
147    ///
148    /// **v2 change**: `CompactString` — display names typically fit inline.
149    pub cn: Option<CompactString>,
150    /// Response status.
151    pub partstat: PartStat,
152    /// Participation role.
153    pub role: Role,
154    /// `RSVP=TRUE` if the organizer wants an explicit response.
155    pub rsvp: bool,
156}
157
158/// ORGANIZER or any other CAL-ADDRESS-shaped property.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
160pub struct Person {
161    /// Mailto address.
162    pub email: String,
163    /// Common name (`CN=` parameter).
164    ///
165    /// **v2 change**: `CompactString` — display names typically fit inline.
166    pub cn: Option<CompactString>,
167}
168
169/// STATUS property values for a VEVENT (RFC 5545 §3.8.1.11).
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
171pub enum EventStatus {
172    /// `CONFIRMED` — event is confirmed.
173    Confirmed,
174    /// `TENTATIVE` — event is tentative.
175    Tentative,
176    /// `CANCELLED` — event is cancelled.
177    Cancelled,
178}
179
180/// VTIMEZONE component (RFC 5545 §3.6.5).
181///
182/// Self-built: STANDARD / DAYLIGHT children captured raw; conversion to a
183/// usable offset function lives in [`vtimezone`].
184#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
185pub struct VTimezone {
186    /// TZID property — the timezone identifier this block defines.
187    ///
188    /// **v2 change**: `CompactString` — IANA TZIDs ("America/New_York",
189    /// "Asia/Tokyo", "Europe/London") all fit inline.
190    pub tzid: CompactString,
191    /// Raw STANDARD / DAYLIGHT subcomponents. Resolution to chrono-tz or
192    /// custom offset happens lazily at evaluation time.
193    pub raw_subs: Vec<RawComponent>,
194}
195
196/// Generic raw component captured by the AST parser before semantic typing.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
198pub struct RawComponent {
199    /// Component name (e.g. `VEVENT`, `VALARM`, `STANDARD`).
200    ///
201    /// **v2 change**: `CompactString` — standard iCal component names
202    /// are 6-12 bytes, all inline (≤24 B) with zero heap alloc.
203    pub name: CompactString,
204    /// Properties on this component.
205    pub properties: Vec<RawProperty>,
206    /// Nested subcomponents (e.g. `VALARM` inside `VEVENT`).
207    pub children: Vec<RawComponent>,
208}
209
210/// Single iCalendar property with its value + parameters.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
212pub struct RawProperty {
213    /// Property name (e.g. `DTSTART`, `SUMMARY`, `ATTENDEE`).
214    ///
215    /// **v2 change**: `CompactString` — all standard property names fit inline.
216    pub name: CompactString,
217    /// Parameter list (e.g. `TZID=America/New_York`).
218    ///
219    /// **v2.0.1**: both name and value moved to `CompactString` — RFC 5545
220    /// param names are short (`TZID`, `VALUE`, `CN`, `ROLE`, `PARTSTAT`)
221    /// and typical values (`America/New_York`, `REQ-PARTICIPANT`) fit
222    /// the 24-byte inline buffer.
223    pub params: Vec<(CompactString, CompactString)>,
224    /// Property value string (un-unfolded).
225    pub value: String,
226}
227
228/// Fully-typed iTIP invite, the boundary between this module and the rest of
229/// the server (MRS-3..MRS-9 all consume this).
230#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
231pub struct ParsedInvite {
232    /// iTIP method (REQUEST/REPLY/CANCEL/...).
233    pub method: Method,
234    /// `UID` — RFC 5545 §3.8.4.7.
235    pub uid: String,
236    /// `SEQUENCE` — incremented on each update.
237    pub sequence: i32,
238    /// `DTSTAMP` — when the iTIP message was created.
239    pub dtstamp: DateTime<Utc>,
240    /// `DTSTART` — event start.
241    pub dtstart: CalDateTime,
242    /// `DTEND` — event end (mutually exclusive with `duration`).
243    pub dtend: Option<CalDateTime>,
244    /// `DURATION` — alternative to `DTEND`.
245    pub duration: Option<chrono::Duration>,
246    /// `ORGANIZER` — event chair.
247    pub organizer: Option<Person>,
248    /// `ATTENDEE` list.
249    pub attendees: Vec<Attendee>,
250    /// Raw RRULE string (e.g. `FREQ=WEEKLY;BYDAY=MO,WE,FR`). Expansion is
251    /// delegated to the `rrule` crate at MRS-9 time, not done here.
252    pub rrule: Option<String>,
253    /// `EXDATE` — explicit exclusions from the recurrence rule.
254    pub exdate: Vec<CalDateTime>,
255    /// `RDATE` — explicit additions to the recurrence set.
256    pub rdate: Vec<CalDateTime>,
257    /// `RECURRENCE-ID` — this iTIP message modifies a specific occurrence.
258    pub recurrence_id: Option<CalDateTime>,
259    /// `STATUS` — CONFIRMED / TENTATIVE / CANCELLED.
260    pub status: Option<EventStatus>,
261    /// `SUMMARY` — short title shown in calendar UIs.
262    pub summary: String,
263    /// `LOCATION` — free-form location text.
264    pub location: Option<String>,
265    /// `DESCRIPTION` — long-form body / notes.
266    pub description: Option<String>,
267    /// `VTIMEZONE` blocks attached to the calendar; referenced by `TZID` in
268    /// other properties.
269    pub vtimezones: Vec<VTimezone>,
270}
271
272/// Errors returned by [`parse_invite`].
273#[derive(Debug, PartialEq, Eq)]
274pub enum IcalError {
275    /// Input bytes were not valid UTF-8 (RFC 5545 §3.1.4 mandates UTF-8).
276    NotUtf8,
277    /// Lexer / property-tree level failure.
278    InvalidSyntax(String),
279    /// AST is well-formed but semantic typing failed (e.g. missing UID, bad METHOD).
280    InvalidSemantics(String),
281    /// No VEVENT component found in the VCALENDAR.
282    NoEvent,
283}
284
285/// Top-level entry: raw `text/calendar` bytes → fully-typed invite.
286///
287/// Pipeline: bytes → UTF-8 → [`parse::parse_calendar`] (AST) → [`semantics::lift`] (ParsedInvite).
288pub fn parse_invite(bytes: &[u8]) -> Result<ParsedInvite, IcalError> {
289    let text = std::str::from_utf8(bytes).map_err(|_| IcalError::NotUtf8)?;
290    let calendar = parse::parse_calendar(text)?;
291    semantics::lift(&calendar)
292}