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}