diff --git a/server/src/email_extract.rs b/server/src/email_extract.rs index 65cf10c..b53292b 100644 --- a/server/src/email_extract.rs +++ b/server/src/email_extract.rs @@ -1,3 +1,312 @@ +// --- TESTS --- +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn google_calendar_email_renders_ical_summary() { + use mailparse::parse_mail; + let raw_email = include_str!("../testdata/google-calendar-example.eml"); + let parsed = parse_mail(raw_email.as_bytes()).expect("parse_mail"); + let mut part_addr = vec![]; + let body = extract_body(&parsed, &mut part_addr).expect("extract_body"); + let meta = extract_calendar_metadata_from_mail(&parsed, &body); + // Assert detection as Google Calendar + assert!(meta.is_google_calendar_event); + // Assert metadata extraction + assert_eq!(meta.summary, Some("Tamara and Scout in Alaska".to_string())); + assert_eq!(meta.organizer, Some("tconvertino@gmail.com".to_string())); + assert_eq!(meta.start_date, Some("20250624".to_string())); + assert_eq!(meta.end_date, Some("20250701".to_string())); + // Debug: print the rendered HTML for inspection + if let Some(ref html) = meta.body_html { + println!("Rendered HTML: {}", html); + } else { + println!("No body_html rendered"); + } + // Assert ical summary is rendered and prepended (look for 'ical-flex' class) + assert!(meta + .body_html + .as_ref() + .map(|h| h.contains("ical-flex")) + .unwrap_or(false)); + } +} +#[derive(Debug, PartialEq)] +pub struct ExtractedCalendarMetadata { + pub is_google_calendar_event: bool, + pub summary: Option, + pub organizer: Option, + pub start_date: Option, + pub end_date: Option, + pub body_html: Option, +} + +/// Helper to extract Google Calendar event metadata from a ParsedMail (for tests and features) +pub fn extract_calendar_metadata_from_mail( + m: &ParsedMail, + body: &Body, +) -> ExtractedCalendarMetadata { + // Detect Google Calendar by sender or headers + let mut is_google = false; + let mut summary = None; + let mut organizer = None; + let mut start_date = None; + let mut end_date = None; + let mut body_html = None; + + // Check sender + if let Some(from) = m.headers.get_first_value("Sender") { + if from.contains("calendar-notification@google.com") { + is_google = true; + } + } + // Check for Google Calendar subject + if let Some(subject) = m.headers.get_first_value("Subject") { + if subject.contains("New event:") || subject.contains("Google Calendar") { + is_google = true; + } + } + + // Try to extract from text/calendar part if present + fn find_ical<'a>(m: &'a ParsedMail) -> Option { + if m.ctype.mimetype == TEXT_CALENDAR { + m.get_body().ok() + } else { + for sp in &m.subparts { + if let Some(b) = find_ical(sp) { + return Some(b); + } + } + None + } + } + let ical_opt = find_ical(m); + if let Some(ical) = ical_opt { + // Use existing render_ical_summary to extract fields + if let Ok(rendered) = render_ical_summary(&ical) { + // Try to extract summary, organizer, start/end from the ical + // (This is a hack: parse the ical again for fields) + use ical::IcalParser; + let mut parser = IcalParser::new(ical.as_bytes()); + if let Some(Ok(calendar)) = parser.next() { + for event in calendar.events { + for prop in &event.properties { + match prop.name.as_str() { + "SUMMARY" => summary = prop.value.clone(), + "ORGANIZER" => organizer = prop.value.clone(), + "DTSTART" => { + if let Some(dt) = &prop.value { + if dt.len() >= 8 { + start_date = Some(dt[0..8].to_string()); + } + } + } + "DTEND" => { + if let Some(dt) = &prop.value { + if dt.len() >= 8 { + end_date = Some(dt[0..8].to_string()); + } + } + } + _ => {} + } + } + } + } + body_html = Some(rendered); + } + } else { + // Fallback: try to extract summary and organizer from headers if this is a Google Calendar event + if is_google { + if let Some(subject) = m.headers.get_first_value("Subject") { + // Try to extract event summary from subject, e.g. "New event: Tamara and Scout in Alaska @ ..." + let summary_guess = subject + .splitn(2, ':') + .nth(1) + .and_then(|s| s.split('@').next()) + .map(|s| s.trim().to_string()); + if let Some(s) = summary_guess { + summary = Some(s); + } + // Try to extract start/end dates from subject, e.g. "@ Tue Jun 24 - Mon Jun 30, 2025" + if let Some(at_idx) = subject.find('@') { + let after_at = &subject[at_idx + 1..]; + // Look for a date range like "Tue Jun 24 - Mon Jun 30, 2025" + let date_re = regex::Regex::new( + r"(\w{3}) (\w{3}) (\d{1,2}) - (\w{3}) (\w{3}) (\d{1,2}), (\d{4})", + ) + .ok(); + if let Some(re) = &date_re { + if let Some(caps) = re.captures(after_at) { + // e.g. Tue Jun 24 - Mon Jun 30, 2025 + let start_month = &caps[2]; + let start_day = &caps[3]; + let end_month = &caps[5]; + let end_day = &caps[6]; + let year = &caps[7]; + // Try to parse months as numbers + let month_map = [ + ("Jan", "01"), + ("Feb", "02"), + ("Mar", "03"), + ("Apr", "04"), + ("May", "05"), + ("Jun", "06"), + ("Jul", "07"), + ("Aug", "08"), + ("Sep", "09"), + ("Oct", "10"), + ("Nov", "11"), + ("Dec", "12"), + ]; + let start_month_num = month_map + .iter() + .find(|(m, _)| *m == start_month) + .map(|(_, n)| *n) + .unwrap_or("01"); + let end_month_num = month_map + .iter() + .find(|(m, _)| *m == end_month) + .map(|(_, n)| *n) + .unwrap_or("01"); + let start_date_str = format!( + "{}{}{}", + year, + start_month_num, + format!("{:0>2}", start_day) + ); + let end_date_str = + format!("{}{}{}", year, end_month_num, format!("{:0>2}", end_day)); + // Increment end date by one day to match iCalendar exclusive end date + let end_date_exclusive = + chrono::NaiveDate::parse_from_str(&end_date_str, "%Y%m%d") + .ok() + .and_then(|d| d.succ_opt()) + .map(|d| d.format("%Y%m%d").to_string()) + .unwrap_or(end_date_str); + start_date = Some(start_date_str); + end_date = Some(end_date_exclusive); + } + } + } + } + // Try to extract organizer from From header + if organizer.is_none() { + if let Some(from) = m.headers.get_first_value("From") { + // Try to extract email address from From header + let email = from + .split('<') + .nth(1) + .and_then(|s| s.split('>').next()) + .map(|s| s.trim().to_string()) + .or_else(|| Some(from.trim().to_string())); + organizer = email; + } + } + + // Render the ical-summary template using the extracted metadata if we have enough info + if summary.is_some() && start_date.is_some() && end_date.is_some() { + use chrono::NaiveDate; + let summary_val = summary.as_deref().unwrap_or(""); + let organizer_val = organizer.as_deref().unwrap_or(""); + let local_fmt_start = start_date + .as_ref() + .and_then(|d| NaiveDate::parse_from_str(d, "%Y%m%d").ok()) + .map(|d| d.format("%a %b %e, %Y").to_string()) + .unwrap_or_default(); + let local_fmt_end = end_date + .as_ref() + .and_then(|d| NaiveDate::parse_from_str(d, "%Y%m%d").ok()) + .map(|d| d.format("%a %b %e, %Y").to_string()) + .unwrap_or_default(); + let mut event_days = vec![]; + if let (Some(start), Some(end)) = (start_date.as_ref(), end_date.as_ref()) { + if let (Ok(start), Ok(end)) = ( + NaiveDate::parse_from_str(start, "%Y%m%d"), + NaiveDate::parse_from_str(end, "%Y%m%d"), + ) { + let mut d = start; + while d < end { + // end is exclusive + event_days.push(d); + d = d.succ_opt().unwrap(); + } + } + } + // Compute calendar grid for template rendering + let (all_days, caption) = if !event_days.is_empty() { + let first_event = event_days.first().unwrap(); + let last_event = event_days.last().unwrap(); + let first_of_month = + NaiveDate::from_ymd_opt(first_event.year(), first_event.month(), 1) + .unwrap(); + let last_of_month = { + let next_month = if last_event.month() == 12 { + NaiveDate::from_ymd_opt(last_event.year() + 1, 1, 1).unwrap() + } else { + NaiveDate::from_ymd_opt(last_event.year(), last_event.month() + 1, 1) + .unwrap() + }; + next_month.pred_opt().unwrap() + }; + let mut cal_start = first_of_month; + while cal_start.weekday() != chrono::Weekday::Sun { + cal_start = cal_start.pred_opt().unwrap(); + } + let mut cal_end = last_of_month; + while cal_end.weekday() != chrono::Weekday::Sat { + cal_end = cal_end.succ_opt().unwrap(); + } + let mut all_days = vec![]; + let mut d = cal_start; + while d <= cal_end { + all_days.push(d); + d = d.succ_opt().unwrap(); + } + let start_month = first_event.format("%B %Y"); + let end_month = last_event.format("%B %Y"); + let caption = if start_month.to_string() == end_month.to_string() { + start_month.to_string() + } else { + format!("{} – {}", start_month, end_month) + }; + (all_days, caption) + } else { + (vec![], String::new()) + }; + let description_paragraphs: Vec = Vec::new(); + let template = IcalSummaryTemplate { + summary: summary_val, + local_fmt_start: &local_fmt_start, + local_fmt_end: &local_fmt_end, + organizer: organizer_val, + organizer_cn: "", + all_days, + event_days: event_days.clone(), + caption, + description_paragraphs: &description_paragraphs, + }; + if let Ok(rendered) = template.render() { + body_html = Some(rendered); + } + } + } + } + // Fallback: try to extract from HTML body if present + if body_html.is_none() { + if let Body::Html(h) = body { + body_html = Some(h.html.clone()); + } + } + ExtractedCalendarMetadata { + is_google_calendar_event: is_google, + summary, + organizer, + start_date, + end_date, + body_html, + } +} // Inline Askama filters module for template use mod filters { // Usage: {{ items|batch(7) }} @@ -356,6 +665,7 @@ pub fn extract_alternative( } } let mut ical_summary: Option = None; + // Try to find a text/calendar part as before for sp in &m.subparts { if sp.ctype.mimetype.as_str() == TEXT_CALENDAR { let body = sp.get_body()?; @@ -364,6 +674,15 @@ pub fn extract_alternative( break; } } + // If not found, try to detect Google Calendar event and render summary from metadata + if ical_summary.is_none() { + let meta = extract_calendar_metadata_from_mail(m, &Body::text(String::new())); + if meta.is_google_calendar_event { + if let Some(rendered) = meta.body_html { + ical_summary = Some(rendered); + } + } + } for sp in &m.subparts { if sp.ctype.mimetype.as_str() == TEXT_HTML { let body = sp.get_body()?; diff --git a/server/testdata/google-calendar-example.eml b/server/testdata/google-calendar-example.eml new file mode 100644 index 0000000..d27d7b0 --- /dev/null +++ b/server/testdata/google-calendar-example.eml @@ -0,0 +1,169 @@ +Return-Path: +Delivered-To: bill@xinu.tv +Received: from phx.xinu.tv [74.207.253.222] + by nixos-01.h.xinu.tv with IMAP (fetchmail-6.4.39) + for (single-drop); Mon, 02 Jun 2025 07:06:34 -0700 (PDT) +Received: from phx.xinu.tv + by phx.xinu.tv with LMTP + id qDo+FuqvPWh51xIAJR8clQ + (envelope-from ) + for ; Mon, 02 Jun 2025 07:06:34 -0700 +X-Original-To: gmail@xinu.tv +Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=2a00:1450:4864:20::130; helo=mail-lf1-x130.google.com; envelope-from=couchmoney+caf_=gmail=xinu.tv@gmail.com; receiver=xinu.tv +Authentication-Results: phx.xinu.tv; + dkim=pass (2048-bit key; unprotected) header.d=google.com header.i=@google.com header.a=rsa-sha256 header.s=20230601 header.b=zT2yUtVH; + dkim=pass (2048-bit key; unprotected) header.d=gmail.com header.i=@gmail.com header.a=rsa-sha256 header.s=20230601 header.b=nmJW8N67 +Received: from mail-lf1-x130.google.com (mail-lf1-x130.google.com [IPv6:2a00:1450:4864:20::130]) + by phx.xinu.tv (Postfix) with ESMTPS id 912AC80034 + for ; Mon, 02 Jun 2025 07:06:32 -0700 (PDT) +Received: by mail-lf1-x130.google.com with SMTP id 2adb3069b0e04-54e7967cf67so5267078e87.0 + for ; Mon, 02 Jun 2025 07:06:32 -0700 (PDT) +ARC-Seal: i=2; a=rsa-sha256; t=1748873190; cv=pass; + d=google.com; s=arc-20240605; + b=W3s0wT+CV1W21AldY9lfxPlKRbc7XMoorEnilNq5iGjlw18vDM6eFPb+btqaGAPOPe + CMyGeinsFPuql+S7u6HgjZcf9ZFH71sKoFoQytm30hAXB76GO06qi1jRW6o0miuGt/j/ + bb8qWAiAsGr34mHIbE5fBdkNOGcqW85oI78GolLqpROgn/42boEYxiGAQjybPtO4L84J + wP2RBkHiQQGXUjL6b02tozCji1w2XdfYqtW8RteUs1pqYdXl4GUilMLt5C0d2bhSGksS + 3tMTFjuycbaj+F6QFCkQfEsHx/I7GjuD4mToLcYpzrNnmZZUidAoKuh+uin0cEVvnQ1j + V8aA== +ARC-Message-Signature: i=2; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20240605; + h=to:from:subject:date:message-id:auto-submitted:sender:reply-to + :mime-version:dkim-signature:dkim-signature:delivered-to; + bh=dgRmOj3aABlB3SNw+xxlI8L9ugJFZ1WMJrtLw/W8tnA=; + fh=5zy5Gi9ngAea7dC9ZKKPh/BZlFmotJq74g9KHrEIwaE=; + b=QTAjqit0gYnuGa1lbO9RUXOVpyutliNo+tG6irWFsjGhnvMkis2KdLb6saYPnLCG7F + rSRXvw0HwuaJfXAV3XvIT0pxTg3PXYnc8kt/F8OtG+LiakJbMV1soj8OJ+5lZPKFmvna + i2T5mJjEknZsc9qWYmaAEVqIg71jhPH5CjJyehNhsIJ1/O9CH4VF8L0yv9KUMAA4tzog + LfI+SpOE2z/wYuMDxi2Ld3FgaVCQgkMM2Tlys8P0DjCaewWeaZFmZKIEEZUbKWbrivTa + RSO+Us+9yrt8hDdJuvtf9eXsGvuZtdj/2APRts/0cd7SFAQqRd0DnhGIHoXR74YVHaqi + U7IQ==; + darn=xinu.tv +ARC-Authentication-Results: i=2; mx.google.com; + dkim=pass header.i=@google.com header.s=20230601 header.b=zT2yUtVH; + dkim=pass header.i=@gmail.com header.s=20230601 header.b=nmJW8N67; + spf=pass (google.com: domain of tconvertino@gmail.com designates 209.85.220.73 as permitted sender) smtp.mailfrom=tconvertino@gmail.com; + dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com; + dara=pass header.i=@gmail.com +X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; + d=1e100.net; s=20230601; t=1748873190; x=1749477990; + h=to:from:subject:date:message-id:auto-submitted:sender:reply-to + :mime-version:dkim-signature:dkim-signature:delivered-to + :x-forwarded-for:x-forwarded-to:x-gm-message-state:from:to:cc + :subject:date:message-id:reply-to; + bh=dgRmOj3aABlB3SNw+xxlI8L9ugJFZ1WMJrtLw/W8tnA=; + b=dBjp6JdmFUj0jKPDo9r2/xvfVSvxKaF15UYwYU7itdM18qpCnrgQdHMP2ST7EQBxou + 58yZfVjrx84gg9phedpVSg4SaBaPIhXsLuUeVQZtPd7J3WYiH4+OGcecjV+cD0dG0TUi + o/FbZULNl3REysvoAj+AwUL/ny2FnNU4PIhkeSq+d6iNztkexIKLS8qWqHosenPlVX+E + Z7OGQZpK6m1LB5UbCsaODQq5wbNIxlOxqTP1rCHe/hHk53ljiNegzaOS31mVvp1n8/g1 + pWIZltyZORs0zi6U9+mNd9ZbaeQjHqBrcb2bsTxCD+u0DBuF2RjLguS/feaB25TG8LAg + szYg== +X-Forwarded-Encrypted: i=2; AJvYcCXfGRAIDqrPsT1vzTMSiuMrlTj/DbRrr+8w7X+iLRH2XK/n8MZhV3UaT0Zia6c6jMrf3s3eHA==@xinu.tv +X-Gm-Message-State: AOJu0YxOQEmNiUg4NKf4NM1BgQMqTJaFM6txPnL6u74ff1dZvoSgTC4d + TtJJqfdHsajxloSGDsSPqIQ/M/Se/sfymEExFQxDXYA/XasA6+sdye/Ihl9QekGJK9jet1VtQ3r + dcg89xnFcxezg3ji6xH8jnSULlp350K9K7LR0LfTQqg6e/BEKEF8XDaNgmJC+RQ== +X-Received: by 2002:a05:6512:2246:b0:553:35bb:f7b7 with SMTP id 2adb3069b0e04-55342f92776mr2472199e87.32.1748873190333; + Mon, 02 Jun 2025 07:06:30 -0700 (PDT) +X-Forwarded-To: gmail@xinu.tv +X-Forwarded-For: couchmoney@gmail.com gmail@xinu.tv +Delivered-To: couchmoney@gmail.com +Received: by 2002:ab3:7457:0:b0:2b1:14e:dc2b with SMTP id g23csp2818972lti; + Mon, 2 Jun 2025 07:06:29 -0700 (PDT) +X-Received: by 2002:a05:6602:6a8b:b0:86c:f898:74b8 with SMTP id ca18e2360f4ac-86d0521552emr1082401939f.10.1748873188734; + Mon, 02 Jun 2025 07:06:28 -0700 (PDT) +ARC-Seal: i=1; a=rsa-sha256; t=1748873188; cv=none; + d=google.com; s=arc-20240605; + b=d2PNXrTE3VYjml3FmbC5rBW6XnsyuyVO3lPyM6VoVKFcvZ7a8tDRB+sh1ibo0D5Nvg + 3i/Qon0RV401WFb9NQf5P048wpj19G8bOGPZUKMioBZcSxkr1RwH/GW6GBvGS+d+iqbW + 43KWc6Px7RGOEeYfp8D88CuJ/5kMcsLMfDV1FRHo6T+chVY6c9fQkHjRreSGQcFXglt5 + yaCpFKkAODO7rSHl2OW2kQ6eGgR0tUjb95+jdZXoU0GS3119CBYK9n9UhNaeXHIk/Zyy + f08r4Ce/m3Y6ISr4ovXxDeYNpeeUN1HT3XVyCVQJHjfWrHypKTiOt4q6yBhCgOgZTXJq + pL5A==; +ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20240605; + h=to:from:subject:date:message-id:auto-submitted:sender:reply-to + :mime-version:dkim-signature:dkim-signature; + bh=dgRmOj3aABlB3SNw+xxlI8L9ugJFZ1WMJrtLw/W8tnA=; + fh=mbzrMIWIgWMC0ni1xEx+ViW4J0RLAdLdPT2cX81nTlk=; + b=YiMakYeE05UctWy9sW90/a3l1Hk1pAPv0+fpk5vmWrADcMwwI8cHVqBp+Nxds5psWa + a/zrw9UlxV4HgjLUP+ella/pK8XxK+sitKg0IhPOntwKbq1KfTNheufh4HtWj5yWedHE + sO/dVs6z/EW/gWrfBK/3JMgsnz3HrHmaoJ6caCaGI6t5jHxEXI+eJc5zILY+n0MdivkX + tJOo0L1s/k6MAdyLr4/IVqpxdhXbUPq44twCBNheHd8T5w1DC9ZXcr54X79fW8Vzbm8/ + A++H3gnZRGtOayRySYQl04LFLk4YsisdhsKuaJV+WKYCW58wQqJT04mrVkx+m96qr1q0 + BQtw==; + dara=google.com +ARC-Authentication-Results: i=1; mx.google.com; + dkim=pass header.i=@google.com header.s=20230601 header.b=zT2yUtVH; + dkim=pass header.i=@gmail.com header.s=20230601 header.b=nmJW8N67; + spf=pass (google.com: domain of tconvertino@gmail.com designates 209.85.220.73 as permitted sender) smtp.mailfrom=tconvertino@gmail.com; + dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com; + dara=pass header.i=@gmail.com +Received: from mail-sor-f73.google.com (mail-sor-f73.google.com. [209.85.220.73]) + by mx.google.com with SMTPS id ca18e2360f4ac-86d0213d491sor465078439f.8.2025.06.02.07.06.28 + for + (Google Transport Security); + Mon, 02 Jun 2025 07:06:28 -0700 (PDT) +Received-SPF: pass (google.com: domain of tconvertino@gmail.com designates 209.85.220.73 as permitted sender) client-ip=209.85.220.73; +Authentication-Results: mx.google.com; + dkim=pass header.i=@google.com header.s=20230601 header.b=zT2yUtVH; + dkim=pass header.i=@gmail.com header.s=20230601 header.b=nmJW8N67; + spf=pass (google.com: domain of tconvertino@gmail.com designates 209.85.220.73 as permitted sender) smtp.mailfrom=tconvertino@gmail.com; + dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com; + dara=pass header.i=@gmail.com +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; + d=google.com; s=20230601; t=1748873188; x=1749477988; dara=google.com; + h=to:from:subject:date:message-id:auto-submitted:sender:reply-to + :mime-version:from:to:cc:subject:date:message-id:reply-to; + bh=dgRmOj3aABlB3SNw+xxlI8L9ugJFZ1WMJrtLw/W8tnA=; + b=zT2yUtVHhNy5fFiy6YKzfYCQPlCnufAEoWmbvjvj7mFNYUlLJHZ5FUeNnDs06Z1icR + bSVtejKixrz4hjFh9KeKvV9EQNGU7UFgySwqdy6szm+sHZQj+iJAXy85A1QaL6+0Swup + 2y8QsjVJ96uugM0SaAYZqe+lmLBk6zFWqkg0U37vgwOupAcNsNBd7tos7cxO5eK6Aops + FJjr9JAD+ddX03ngH9zfnvlNV/+qbmiP6Hs8OmaJtZof2GLucpHgqUpIdolCh7F72v4p + DibO4RShI/IQCw9ejZxhRPBPWQwIdOYLjD/sDunX63M4NCS/63jZfhwqsAVgtmN/cUGq + spHQ== +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; + d=gmail.com; s=20230601; t=1748873188; x=1749477988; dara=google.com; + h=to:from:subject:date:message-id:auto-submitted:sender:reply-to + :mime-version:from:to:cc:subject:date:message-id:reply-to; + bh=dgRmOj3aABlB3SNw+xxlI8L9ugJFZ1WMJrtLw/W8tnA=; + b=nmJW8N67IylgMNprzzf/IC7V2r7xeY0+8Bl0KcAak6Xly+IhVv3nyccvgdKsp+8Ccd + NcikfVOtCsE3gTqviReUbTAKy7PyClAbBTEHC0Ne71549BN+v8zX64RpGDFJGX5pJMG5 + r0Ak88nxzjWkvDLhlnHmWdt/NggdQEI6T7oP4VZo0f0/Ym7g1WJhSItfdIhSRDNzK3ed + WPRXUIb1sW3+N0My4Os6L4IA9kdRk5z0qpQxtsIL9N0dzv4q18q6eH3KfTzVPr59PsYT + uSgkWoLQZdfA70MMlIRU5CnGbVDRH4TO/ib433vIblOmtLTkQ4EaOTzncbs0tovVes4z + evsQ== +X-Google-Smtp-Source: AGHT+IETNpLvkLm7t8VAdDcEcVtxFCttPh/uVZhoQCRlhUNlx9bmg67olJiD9EOND8g0z43NnM8iK4FxezZondExIawx +MIME-Version: 1.0 +X-Received: by 2002:a05:6602:4183:b0:864:4a1b:dfc5 with SMTP id + ca18e2360f4ac-86d052154eamr1431889339f.9.1748873188195; Mon, 02 Jun 2025 + 07:06:28 -0700 (PDT) +Reply-To: tconvertino@gmail.com +Sender: Google Calendar +Auto-Submitted: auto-generated +Message-ID: +Date: Mon, 02 Jun 2025 14:06:28 +0000 +Subject: New event: Tamara and Scout in Alaska @ Tue Jun 24 - Mon Jun 30, 2025 (tconvertino@gmail.com) +From: tconvertino@gmail.com +To: couchmoney@gmail.com +Content-Type: multipart/alternative; boundary="00000000000023c70606369745e9" + +--00000000000023c70606369745e9 +Content-Type: text/plain; charset="UTF-8"; format=flowed; delsp=yes +Content-Transfer-Encoding: base64 + +VGFtYXJhIGFuZCBTY291dCBpbiBBbGFza2ENClR1ZXNkYXkgSnVuIDI0IOKAkyBNb25kYXkgSnVu +IDMwLCAyMDI1DQoNCg0KDQpPcmdhbml6ZXINCnRjb252ZXJ0aW5vQGdtYWlsLmNvbQ0KdGNvbnZl +cnRpbm9AZ21haWwuY29tDQoNCn5+Ly9+fg0KSW52aXRhdGlvbiBmcm9tIEdvb2dsZSBDYWxlbmRh +cjogaHR0cHM6Ly9jYWxlbmRhci5nb29nbGUuY29tL2NhbGVuZGFyLw0KDQpZb3UgYXJlIHJlY2Vp +dmluZyB0aGlzIGVtYWlsIGJlY2F1c2UgeW91IGFyZSBzdWJzY3JpYmVkIHRvIGNhbGVuZGFyICAN +Cm5vdGlmaWNhdGlvbnMuIFRvIHN0b3AgcmVjZWl2aW5nIHRoZXNlIGVtYWlscywgZ28gdG8gIA0K +aHR0cHM6Ly9jYWxlbmRhci5nb29nbGUuY29tL2NhbGVuZGFyL3Ivc2V0dGluZ3MsIHNlbGVjdCB0 +aGlzIGNhbGVuZGFyLCBhbmQgIA0KY2hhbmdlICJPdGhlciBub3RpZmljYXRpb25zIi4NCg0KRm9y +d2FyZGluZyB0aGlzIGludml0YXRpb24gY291bGQgYWxsb3cgYW55IHJlY2lwaWVudCB0byBzZW5k +IGEgcmVzcG9uc2UgdG8gIA0KdGhlIG9yZ2FuaXplciwgYmUgYWRkZWQgdG8gdGhlIGd1ZXN0IGxp +c3QsIGludml0ZSBvdGhlcnMgcmVnYXJkbGVzcyBvZiAgDQp0aGVpciBvd24gaW52aXRhdGlvbiBz +dGF0dXMsIG9yIG1vZGlmeSB5b3VyIFJTVlAuDQoNCkxlYXJuIG1vcmUgaHR0cHM6Ly9zdXBwb3J0 +Lmdvb2dsZS5jb20vY2FsZW5kYXIvYW5zd2VyLzM3MTM1I2ZvcndhcmRpbmcNCg== +--00000000000023c70606369745e9 +Content-Type: text/html; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +