Take in invitations that arrive by mail

A watched mailbox is a trust boundary this app did not have: anyone who knows
the address can hand the server an iCalendar file. So a mail can do exactly one
thing — create a row in event_invites. No calendar read path touches that
table, and an invitation becomes an event only when a person accepts it.

Routing is by the ATTENDEE address, never the From header, which a sender can
claim freely. Addresses are matched against users.email plus a dedicated table,
so forwarded mail still finds its owner: the invitation names the original
address, not the mailbox it landed in.

An accepted invitation gets a fresh UID; the organiser's goes to external_uid.
LocalEvent.uid is globally unique, so reusing it would let a stranger's mail
collide with an event already there. That case has a test, because it is the
one that would be a real problem.

parse_ics now reads METHOD, SEQUENCE, the organiser address, the ATTENDEE list
and RECURRENCE-ID. All additive — existing callers read the keys they knew.
METHOD sits on the VCALENDAR, so it is read outside the component loop; that is
the only reliable way to tell an invitation from a plain calendar file. A VEVENT
carrying RECURRENCE-ID is skipped rather than applied, since treating one
occurrence as the series would move every other date.

The mailbox job registers only when IMAP_HOST is set: an unconditional job would
log every few minutes that it has nothing to do, and test_scheduler asserts the
exact job set. Credentials come from the environment, which keeps the one secret
that opens a whole mailbox out of a table an endpoint could return.

Replies to the organiser are the next step; accepting and declining work without
them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-29 17:16:25 +01:00
co-authored by Claude Opus 5
parent aa795fde76
commit 9bf7d3797d
8 changed files with 840 additions and 3 deletions
+68
View File
@@ -398,6 +398,74 @@ class HomeAssistantCalendar(Base):
# ── Collaboration: sharing & groups (local calendars only) ────────────────
class UserEmailAddress(Base):
"""An address under which this user appears in an ATTENDEE list.
A list rather than a column: the mail arrives at a forwarding mailbox while
the invitation still names the original address, and people hand out more
than one. Globally unique so nobody can claim someone else's address and
push events into their calendar.
"""
__tablename__ = "user_email_addresses"
__table_args__ = (UniqueConstraint("address", name="uq_user_email_address"),)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
# Always lowercase and without the "mailto:" prefix.
address = Column(String(255), nullable=False)
user = relationship("User")
class EventInvite(Base):
"""An iTIP invitation (METHOD:REQUEST) that arrived by mail.
Raw *and* parsed. ``raw_ics`` is the only thing the organiser actually sent,
and the reply is built from it — a rebuilt DTSTART is what strict
counterparts reject. The parsed columns exist so clients can render a list
without parsing iCalendar, and use the same formats as LocalEvent.
Nothing here is visible in any calendar read path: an invitation becomes an
event only when a human accepts it.
"""
__tablename__ = "event_invites"
__table_args__ = (UniqueConstraint("user_id", "uid", name="uq_event_invite_user_uid"),)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
# The organiser's UID. Only ever matched together with user_id, so two
# users invited to the same meeting never collide.
uid = Column(String(255), nullable=False)
sequence = Column(Integer, default=0, nullable=False)
# pending | accepted | declined | cancelled
status = Column(String(12), default="pending", nullable=False)
title = Column(String(255), nullable=False)
start = Column(String(50), nullable=False)
end = Column(String(50), nullable=False)
all_day = Column(Boolean, default=False)
location = Column(String(500), nullable=True)
description = Column(Text, nullable=True)
rrule = Column(Text, nullable=True)
organizer_email = Column(String(255), nullable=True)
organizer_name = Column(String(255), nullable=True)
# The ATTENDEE address that matched this user. The reply must name exactly
# this one or the organiser will not recognise the answer.
attendee_email = Column(String(255), nullable=False)
raw_ics = Column(Text, nullable=False)
# The event created on acceptance, so an update or a CANCEL can find it.
event_id = Column(Integer, ForeignKey("local_events.id"), nullable=True)
received_at = Column(DateTime, default=datetime.utcnow)
# None | sent | failed | skipped
reply_state = Column(String(12), nullable=True)
user = relationship("User")
class CalendarShare(Base):
"""A local calendar shared with another Calendarr user."""