diff options
| author | Calvin Morrison <calvin@pobox.com> | 2026-08-22 14:14:59 -0400 |
|---|---|---|
| committer | Calvin Morrison <calvin@pobox.com> | 2026-08-22 14:14:59 -0400 |
| commit | ebfe721eb065be1d33a13e03e40426c4b3adfad0 (patch) | |
| tree | 7873899b4935373e06150465dc217362b28c344b | |
| parent | 08155ead7bf80308b4cb67063cc91e3e3679ce16 (diff) | |
pim: answer an invitation by writing to a file
echo ACCEPTED >events/uuid/<uid>/partstat now does it, on a backend
that can. caldav/fs fetches the object, rewrites only our own ATTENDEE
line and PUTs it back with If-Match; fastmail advertises
calendar-auto-schedule, so that single PUT both updates the copy and
sends the iMIP to the organiser. caps says read rsvp schedule and means
all three.
Two things that cost an hour between them. go-webdav strips the quotes
off an ETag when it parses the header, but If-Match wants an
entity-tag: a bare hex string gets 412, which reads exactly like losing
a race to another client. And the tree kept serving the old answer
right after a successful write, because RSVP resynced the backend
without rebuilding anything; a file that lies immediately after you
wrote it is worse than one that is slow.
-R uid:PARTSTAT answers one invitation without serving, which is what a
pim/rsvp wrapper wants to call.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| -rw-r--r-- | pim/cmd/caldavfs/main.go | 90 | ||||
| -rw-r--r-- | pim/cmd/caldavfs/rsvp.go | 151 | ||||
| -rw-r--r-- | pim/lib/cal/tree.go | 6 |
3 files changed, 232 insertions, 15 deletions
diff --git a/pim/cmd/caldavfs/main.go b/pim/cmd/caldavfs/main.go index c3306de..7a0a4d3 100644 --- a/pim/cmd/caldavfs/main.go +++ b/pim/cmd/caldavfs/main.go @@ -33,20 +33,33 @@ func fatal(format string, a ...interface{}) { } type backend struct { - name string - client *caldav.Client - path string // the calendar collection - window time.Duration - refresh time.Duration - sched bool // server does the scheduling for us + name string + endpoint string + client *caldav.Client + path string // the calendar collection + window time.Duration + refresh time.Duration + sched bool // server does the scheduling for us + + user string + pass string + me []string mu sync.Mutex evs []*cal.Event sig string + obj map[string]objref // uid -> where it lives on the server last time.Time err string } +// objref is what a write needs: the path to PUT back to, and the etag +// that says nobody else has touched it since we looked. +type objref struct { + path string + etag string +} + func (b *backend) Name() string { return b.name } // autoschedule is reported so a dry run shows it; it decides whether @@ -54,12 +67,18 @@ func (b *backend) Name() string { return b.name } func (b *backend) Sched() bool { return b.sched } func (b *backend) Caps() string { - // Writing is not implemented yet, so say so rather than promise it. - // When it is: "read write rsvp" plus "schedule" when the server - // advertises calendar-auto-schedule. - return "read" + c := "read rsvp" + if b.sched { + // the server sends the iMIP for us; one PUT does both halves + c += " schedule" + } + return c } +// Me is whose calendar this is. Without it nothing can tell which of an +// event's attendees to change. +func (b *backend) Me() []string { return b.me } + func (b *backend) Refresh() time.Duration { return b.refresh } func (b *backend) Status() (time.Time, string) { @@ -120,15 +139,22 @@ func (b *backend) Sync() (bool, error) { } cals := make([]*ical.Calendar, 0, len(objs)) + ref := make(map[string]objref, len(objs)) for _, o := range objs { - if o.Data != nil { - cals = append(cals, o.Data) + if o.Data == nil { + continue + } + cals = append(cals, o.Data) + for _, e := range o.Data.Events() { + if uid, err := e.Props.Text(ical.PropUID); err == nil && uid != "" { + ref[uid] = objref{o.Path, o.ETag} + } } } evs := cal.FromCalendars(cals) b.mu.Lock() - b.evs, b.sig = evs, sig + b.evs, b.sig, b.obj = evs, sig, ref b.mu.Unlock() cal.Warnf("%s: %d objects", b.name, len(objs)) return true, nil @@ -182,11 +208,13 @@ func main() { srv = flag.String("s", "", "service name to post in /srv (default caldav.$user.$pid)") days = flag.Int("w", 400, "expansion window in days") poll = flag.Duration("r", 15*time.Minute, "how often to re-query") + me = flag.String("m", "", "your addresses on this calendar, comma separated (default: -u)") dry = flag.Bool("n", false, "load and report, do not serve") + once = flag.String("R", "", "answer one invitation: uid:ACCEPTED|DECLINED|TENTATIVE, then exit") ) flag.Usage = func() { fmt.Fprintf(os.Stderr, - "usage: caldav/fs -e endpoint -u user -p pwfile [-C calendar] [-N name] [-s srv] [-w days] [-r poll] [-n]\n") + "usage: caldav/fs -e endpoint -u user -p pwfile [-C calendar] [-N name] [-m me] [-s srv] [-w days] [-r poll] [-n]\n") os.Exit(2) } flag.Parse() @@ -218,7 +246,9 @@ func main() { } be := &backend{ - name: label, client: client, path: coll.Path, sched: sched, + name: label, endpoint: *endpoint, client: client, + path: coll.Path, sched: sched, + user: *user, pass: pass, me: mefrom(*me, *user), window: time.Duration(*days) * 24 * time.Hour, refresh: *poll, } @@ -227,6 +257,20 @@ func main() { User: who, Srv: *srv, Conf: *pwfile, Window: time.Duration(*days) * 24 * time.Hour, }) + if *once != "" { + uid, want, ok := strings.Cut(*once, ":") + if !ok { + fatal("-R wants uid:PARTSTAT") + } + if _, err := be.Sync(); err != nil { + fatal("%v", err) + } + if err := be.RSVP(uid, time.Time{}, strings.ToUpper(want)); err != nil { + fatal("%v", err) + } + cal.Warnf("%s: %s", uid, strings.ToUpper(want)) + return + } if *dry { if err := s.Report(); err != nil { fatal("%v", err) @@ -237,3 +281,19 @@ func main() { fatal("%v", err) } } + +// mefrom decides which addresses count as us. The account name is the +// obvious default, but you may be invited at an alias and have to be +// told about it. +func mefrom(list, user string) []string { + if list == "" { + return []string{user} + } + var out []string + for _, a := range strings.Split(list, ",") { + if a = strings.TrimSpace(a); a != "" { + out = append(out, a) + } + } + return out +} diff --git a/pim/cmd/caldavfs/rsvp.go b/pim/cmd/caldavfs/rsvp.go new file mode 100644 index 0000000..47d7425 --- /dev/null +++ b/pim/cmd/caldavfs/rsvp.go @@ -0,0 +1,151 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/emersion/go-ical" + + "pim/lib/cal" +) + +// RSVP answers an invitation. +// +// Accepting is two writes -- update your own copy, and tell the +// organiser -- and a server advertising calendar-auto-schedule does +// both from this one PUT. Without that we would have to send the iMIP +// ourselves, which is why Caps only claims schedule when the server +// said so. +// +// The etag goes back as If-Match. If somebody else has touched the +// event since we synced, the server refuses and we say so, rather than +// overwriting a change we never saw. +func (b *backend) RSVP(uid string, recurID time.Time, partstat string) error { + b.mu.Lock() + ref, ok := b.obj[uid] + me := b.me + b.mu.Unlock() + if !ok { + return fmt.Errorf("rsvp: no object for %s; try refresh", uid) + } + if len(me) == 0 { + return fmt.Errorf("rsvp: no identity for this calendar") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + obj, err := b.client.GetCalendarObject(ctx, ref.path) + if err != nil { + return fmt.Errorf("rsvp: fetch: %v", err) + } + if obj.Data == nil { + return fmt.Errorf("rsvp: %s is empty", ref.path) + } + // Match against the version we just read, not the one the last + // report mentioned: those can differ, and the point of If-Match is + // to guard the edit we actually made. + if obj.ETag != "" { + ref.etag = obj.ETag + } + + n := 0 + for _, ev := range obj.Data.Events() { + if id, err := ev.Props.Text(ical.PropUID); err != nil || id != uid { + continue + } + if !recurID.IsZero() { + // answering one occurrence, not the series + got, err := ev.Props.DateTime(ical.PropRecurrenceID, time.Local) + if err != nil || !got.Equal(recurID) { + continue + } + } + n += setPartstat(ev.Props, me, partstat) + } + if n == 0 { + return fmt.Errorf("rsvp: %s does not list any of %s as an attendee", + uid, strings.Join(me, ", ")) + } + + if err := b.put(ref, obj.Data); err != nil { + return err + } + // our copy is stale the moment the server accepts it + if _, err := b.Sync(); err != nil { + cal.Warnf("rsvp: resync: %v", err) + } + return nil +} + +// setPartstat rewrites our own ATTENDEE line and leaves every other one +// alone, which is what iTIP wants in a REPLY. +func setPartstat(props ical.Props, me []string, want string) int { + n := 0 + for _, p := range props.Values(ical.PropAttendee) { + addr := strings.TrimPrefix(p.Value, "mailto:") + cn := p.Params.Get(ical.ParamCommonName) + mine := false + for _, m := range me { + if strings.EqualFold(addr, m) || strings.EqualFold(cn, m) { + mine = true + } + } + if !mine { + continue + } + q := p + q.Params.Set(ical.ParamParticipationStatus, want) + props.Set(&q) + n++ + } + return n +} + +// put writes the object back, refusing to clobber a newer version. +func (b *backend) put(ref objref, c *ical.Calendar) error { + var buf bytes.Buffer + if err := ical.NewEncoder(&buf).Encode(c); err != nil { + return fmt.Errorf("rsvp: encode: %v", err) + } + req, err := http.NewRequest("PUT", urlJoin(b.endpoint, ref.path), bytes.NewReader(buf.Bytes())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "text/calendar; charset=utf-8") + if e := quoteETag(ref.etag); e != "" { + req.Header.Set("If-Match", e) + } + req.SetBasicAuth(b.user, b.pass) + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK, http.StatusCreated, http.StatusNoContent: + return nil + case http.StatusPreconditionFailed: + return fmt.Errorf("rsvp: the event changed on the server; refresh and try again") + default: + return fmt.Errorf("rsvp: put: %s", resp.Status) + } +} + +// quoteETag puts back the quotes go-webdav strips when it parses the +// header. If-Match wants an entity-tag, and a bare hex string is not +// one: the server answers 412 and it reads exactly like a lost race. +func quoteETag(s string) string { + if s == "" { + return "" + } + if strings.HasPrefix(s, `"`) || strings.HasPrefix(s, "W/") { + return s + } + return `"` + s + `"` +} diff --git a/pim/lib/cal/tree.go b/pim/lib/cal/tree.go index f78f2eb..4c65877 100644 --- a/pim/lib/cal/tree.go +++ b/pim/lib/cal/tree.go @@ -269,6 +269,12 @@ func (s *Server) addPartstat(dir *fs.StaticDir, e *Event) { if err := rsvp.RSVP(uid, time.Time{}, want); err != nil { return 0, err } + // The tree still holds the old answer until it is rebuilt, + // and a file that lies right after you wrote it is worse + // than one that is slow. + if err := s.Reload(); err != nil { + Warnf("rsvp: reload: %v", err) + } return uint32(len(data)), nil }, }) |
