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 serde::Serialize;
62
63/// iTIP method (RFC 5546 §1.4 + §3).
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
65pub enum Method {
66    /// `REQUEST` — invitation or update.
67    Request,
68    /// `REPLY` — attendee response (accept/decline/etc).
69    Reply,
70    /// `CANCEL` — organizer cancels the event.
71    Cancel,
72    /// `UPDATE` — non-significant update (no re-RSVP needed).
73    Update,
74    /// `COUNTER` — attendee proposes a change.
75    Counter,
76    /// `REFRESH` — attendee requests latest state.
77    Refresh,
78    /// `ADD` — add an occurrence to a recurring event.
79    Add,
80    /// `PUBLISH` — publish a non-interactive event (newsletter feed).
81    Publish,
82    /// `DECLINECOUNTER` — organizer rejects an attendee's COUNTER.
83    DeclineCounter,
84}
85
86/// Calendar date-time tri-state (RFC 5545 §3.3.5).
87#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
88pub enum CalDateTime {
89    /// Floating local time — no timezone attached. e.g. `DTSTART:19980118T230000`.
90    Floating(chrono::NaiveDateTime),
91    /// UTC. e.g. `DTSTART:19980119T070000Z`.
92    Utc(DateTime<Utc>),
93    /// TZID-qualified. e.g. `DTSTART;TZID=America/New_York:19980119T020000`.
94    /// `tz_name` is the raw TZID string; resolved at evaluation time via
95    /// [`vtimezone`] (handles both IANA names and inline VTIMEZONE blocks).
96    Zoned {
97        /// IANA timezone identifier or inline VTIMEZONE id.
98        tz_name: String,
99        /// Local civil time in that zone.
100        local: chrono::NaiveDateTime,
101    },
102    /// Date-only (RFC 5545 §3.3.4). e.g. `DTSTART;VALUE=DATE:19980118`.
103    Date(chrono::NaiveDate),
104}
105
106/// PARTSTAT parameter (RFC 5545 §3.2.12).
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
108pub enum PartStat {
109    /// `NEEDS-ACTION` — not yet responded.
110    NeedsAction,
111    /// `ACCEPTED` — will attend.
112    Accepted,
113    /// `DECLINED` — will not attend.
114    Declined,
115    /// `TENTATIVE` — may attend.
116    Tentative,
117    /// `DELEGATED` — passed to another attendee.
118    Delegated,
119    /// `COMPLETED` — VTODO only.
120    Completed,
121    /// `IN-PROCESS` — VTODO only.
122    InProcess,
123}
124
125/// ROLE parameter (RFC 5545 §3.2.16).
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
127pub enum Role {
128    /// `CHAIR` — meeting chair.
129    Chair,
130    /// `REQ-PARTICIPANT` — required attendance.
131    ReqParticipant,
132    /// `OPT-PARTICIPANT` — optional attendance.
133    OptParticipant,
134    /// `NON-PARTICIPANT` — for-information-only.
135    NonParticipant,
136}
137
138/// One ATTENDEE row from a VEVENT.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
140pub struct Attendee {
141    /// Mailto address (stripped of the `mailto:` prefix).
142    pub email: String,
143    /// Common name (`CN=` parameter), if present.
144    pub cn: Option<String>,
145    /// Response status.
146    pub partstat: PartStat,
147    /// Participation role.
148    pub role: Role,
149    /// `RSVP=TRUE` if the organizer wants an explicit response.
150    pub rsvp: bool,
151}
152
153/// ORGANIZER or any other CAL-ADDRESS-shaped property.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
155pub struct Person {
156    /// Mailto address.
157    pub email: String,
158    /// Common name (`CN=` parameter).
159    pub cn: Option<String>,
160}
161
162/// STATUS property values for a VEVENT (RFC 5545 §3.8.1.11).
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
164pub enum EventStatus {
165    /// `CONFIRMED` — event is confirmed.
166    Confirmed,
167    /// `TENTATIVE` — event is tentative.
168    Tentative,
169    /// `CANCELLED` — event is cancelled.
170    Cancelled,
171}
172
173/// VTIMEZONE component (RFC 5545 §3.6.5).
174///
175/// Self-built: STANDARD / DAYLIGHT children captured raw; conversion to a
176/// usable offset function lives in [`vtimezone`].
177#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
178pub struct VTimezone {
179    /// TZID property — the timezone identifier this block defines.
180    pub tzid: String,
181    /// Raw STANDARD / DAYLIGHT subcomponents. Resolution to chrono-tz or
182    /// custom offset happens lazily at evaluation time.
183    pub raw_subs: Vec<RawComponent>,
184}
185
186/// Generic raw component captured by the AST parser before semantic typing.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
188pub struct RawComponent {
189    /// Component name (e.g. `VEVENT`, `VALARM`, `STANDARD`).
190    pub name: String,
191    /// Properties on this component.
192    pub properties: Vec<RawProperty>,
193    /// Nested subcomponents (e.g. `VALARM` inside `VEVENT`).
194    pub children: Vec<RawComponent>,
195}
196
197/// Single iCalendar property with its value + parameters.
198#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
199pub struct RawProperty {
200    /// Property name (e.g. `DTSTART`, `SUMMARY`, `ATTENDEE`).
201    pub name: String,
202    /// Parameter list (e.g. `TZID=America/New_York`).
203    pub params: Vec<(String, String)>,
204    /// Property value string (un-unfolded).
205    pub value: String,
206}
207
208/// Fully-typed iTIP invite, the boundary between this module and the rest of
209/// the server (MRS-3..MRS-9 all consume this).
210#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
211pub struct ParsedInvite {
212    /// iTIP method (REQUEST/REPLY/CANCEL/...).
213    pub method: Method,
214    /// `UID` — RFC 5545 §3.8.4.7.
215    pub uid: String,
216    /// `SEQUENCE` — incremented on each update.
217    pub sequence: i32,
218    /// `DTSTAMP` — when the iTIP message was created.
219    pub dtstamp: DateTime<Utc>,
220    /// `DTSTART` — event start.
221    pub dtstart: CalDateTime,
222    /// `DTEND` — event end (mutually exclusive with `duration`).
223    pub dtend: Option<CalDateTime>,
224    /// `DURATION` — alternative to `DTEND`.
225    pub duration: Option<chrono::Duration>,
226    /// `ORGANIZER` — event chair.
227    pub organizer: Option<Person>,
228    /// `ATTENDEE` list.
229    pub attendees: Vec<Attendee>,
230    /// Raw RRULE string (e.g. `FREQ=WEEKLY;BYDAY=MO,WE,FR`). Expansion is
231    /// delegated to the `rrule` crate at MRS-9 time, not done here.
232    pub rrule: Option<String>,
233    /// `EXDATE` — explicit exclusions from the recurrence rule.
234    pub exdate: Vec<CalDateTime>,
235    /// `RDATE` — explicit additions to the recurrence set.
236    pub rdate: Vec<CalDateTime>,
237    /// `RECURRENCE-ID` — this iTIP message modifies a specific occurrence.
238    pub recurrence_id: Option<CalDateTime>,
239    /// `STATUS` — CONFIRMED / TENTATIVE / CANCELLED.
240    pub status: Option<EventStatus>,
241    /// `SUMMARY` — short title shown in calendar UIs.
242    pub summary: String,
243    /// `LOCATION` — free-form location text.
244    pub location: Option<String>,
245    /// `DESCRIPTION` — long-form body / notes.
246    pub description: Option<String>,
247    /// `VTIMEZONE` blocks attached to the calendar; referenced by `TZID` in
248    /// other properties.
249    pub vtimezones: Vec<VTimezone>,
250}
251
252/// Errors returned by [`parse_invite`].
253#[derive(Debug, PartialEq, Eq)]
254pub enum IcalError {
255    /// Input bytes were not valid UTF-8 (RFC 5545 §3.1.4 mandates UTF-8).
256    NotUtf8,
257    /// Lexer / property-tree level failure.
258    InvalidSyntax(String),
259    /// AST is well-formed but semantic typing failed (e.g. missing UID, bad METHOD).
260    InvalidSemantics(String),
261    /// No VEVENT component found in the VCALENDAR.
262    NoEvent,
263}
264
265/// Top-level entry: raw `text/calendar` bytes → fully-typed invite.
266///
267/// Pipeline: bytes → UTF-8 → [`parse::parse_calendar`] (AST) → [`semantics::lift`] (ParsedInvite).
268pub fn parse_invite(bytes: &[u8]) -> Result<ParsedInvite, IcalError> {
269    let text = std::str::from_utf8(bytes).map_err(|_| IcalError::NotUtf8)?;
270    let calendar = parse::parse_calendar(text)?;
271    semantics::lift(&calendar)
272}