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}