diff --git a/FEATURES.md b/FEATURES.md index 3deaaaa..2c97e61 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -400,8 +400,12 @@ status (confirmed / tentative / cancelled), show-as (busy / free), visibility - **Duplicate** an event from the context menu. - **Create event…** from a message, in its context menu and its ⋮ menu (and, on a phone, in the ⋮ of a held row). The subject becomes the title and the - body the description; the editor opens on the next half hour for an hour, - because when it happens is the one thing the message cannot say. + body the description; the sender and everyone the message was addressed to + become guests, minus your own addresses and never a blind copy. The editor + opens on the next half hour for an hour, because when it happens is the one + thing the message cannot say — and with *Send invitation emails* off, since + a guest list you inherited rather than typed should not mail itself on the + first press. - **Popover** on click with the detail and quick actions; the editor on *Edit…*. diff --git a/web/src/lib/__tests__/appointment.test.ts b/web/src/lib/__tests__/appointment.test.ts index 9cf1ebf..1cd582a 100644 --- a/web/src/lib/__tests__/appointment.test.ts +++ b/web/src/lib/__tests__/appointment.test.ts @@ -68,3 +68,42 @@ describe("what is copied from the message", () => { expect(d.description.endsWith("…")).toBe(true); }); }); + +const between = (parts: Partial) => email({ subject: "Kickoff", ...parts }); +const addr = (email: string, name: string | null = null) => ({ name, email }); + +describe("who is invited", () => { + it("carries the sender and everyone it was addressed to", () => { + const d = appointmentDraft( + between({ from: [addr("grace@example.org", "Grace")], to: [addr("me@example.com"), addr("alan@example.org")], cc: [addr("ada@example.org")] }), + new Date(), + ["me@example.com"], + ); + expect(d.attendees.map((a) => a.email)).toEqual(["grace@example.org", "alan@example.org", "ada@example.org"]); + expect(d.attendees[0]?.name).toBe("Grace"); + }); + + it("leaves the reader out, whatever case their address was written in", () => { + const d = appointmentDraft(between({ from: [addr("grace@example.org")], to: [addr("Me@Example.com")] }), new Date(), ["me@example.com"]); + expect(d.attendees.map((a) => a.email)).toEqual(["grace@example.org"]); + }); + + it("counts someone once, however many headers they appear in", () => { + const d = appointmentDraft(between({ from: [addr("grace@example.org")], to: [addr("grace@example.org")], cc: [addr("GRACE@example.org")] })); + expect(d.attendees).toHaveLength(1); + }); + + /* + * On a message the reader sent, a blind copy is still a recipient — and + * putting one on a guest list shows them to every other guest. Turning a + * hidden copy into a visible one is not something a menu item may do. + */ + it("never turns a blind copy into a guest", () => { + const d = appointmentDraft(between({ from: [addr("me@example.com")], to: [addr("alan@example.org")], bcc: [addr("secret@example.org")] }), new Date(), ["me@example.com"]); + expect(d.attendees.map((a) => a.email)).toEqual(["alan@example.org"]); + }); + + it("invites nobody when the message has no addresses at all", () => { + expect(appointmentDraft(between({})).attendees).toEqual([]); + }); +}); diff --git a/web/src/lib/appointment.ts b/web/src/lib/appointment.ts index 47a53ff..1b949ad 100644 --- a/web/src/lib/appointment.ts +++ b/web/src/lib/appointment.ts @@ -1,6 +1,7 @@ -import type { Email } from "@/jmap/types"; +import type { Email, EmailAddress } from "@/jmap/types"; import { useCalendar, type EventDraft } from "@/store/calendar"; import { useMail } from "@/store/mail"; +import { uniqueAddresses } from "./address"; import { toLocalDateOnly } from "./dates"; import { htmlToText } from "./text"; @@ -48,7 +49,23 @@ function bodyText(email: Email): string { * so the editor opens with the reader's cursor on a form they finish, rather * than a guess they have to check. */ -export function appointmentDraft(email: Email, now: Date = new Date()): EventDraft { +/** + * Everyone the message was between, as guests: the sender and the people it + * was addressed to. + * + * The reader's own addresses come out -- they are the organiser, and an + * organiser listed among their own guests is an event that invites you to your + * own appointment. Bcc stays out too, on a message the reader sent themselves: + * a blind recipient added to a guest list is visible to every other guest, and + * turning a hidden copy into a public one is not something a menu item should + * do quietly. + */ +function guests(email: Email, ownEmails: string[]): EmailAddress[] { + const own = new Set(ownEmails.map((e) => e.toLowerCase())); + return uniqueAddresses([...(email.from ?? []), ...(email.to ?? []), ...(email.cc ?? [])]).filter((a) => !own.has(a.email.trim().toLowerCase())); +} + +export function appointmentDraft(email: Email, now: Date = new Date(), ownEmails: string[] = []): EventDraft { const start = nextHalfHour(now); const body = bodyText(email).trim(); return { @@ -57,6 +74,7 @@ export function appointmentDraft(email: Email, now: Date = new Date()): EventDra start, end: new Date(start.getTime() + 3600_000), allDay: false, + attendees: guests(email, ownEmails), }; } @@ -68,8 +86,12 @@ export function appointmentDraft(email: Email, now: Date = new Date()): EventDra * has already been read. */ export async function startAppointment(email: Email, navigate: (to: string) => void): Promise { - const full = (await useMail.getState().getEmails([email.id], true))[0] ?? email; - const draft = appointmentDraft(full); + const mail = useMail.getState(); + const full = (await mail.getEmails([email.id], true))[0] ?? email; + // Which addresses are the reader's own decides who is a guest, so they are + // worth a round trip when the session has not loaded them yet. + const identities = mail.identities.length ? mail.identities : await mail.loadIdentities(); + const draft = appointmentDraft(full, new Date(), identities.map((i) => i.email)); useCalendar.getState().setDraft(draft); navigate(`/calendar/day/${toLocalDateOnly(draft.start)}`); } diff --git a/web/src/store/calendar.ts b/web/src/store/calendar.ts index 32738d4..da05dec 100644 --- a/web/src/store/calendar.ts +++ b/web/src/store/calendar.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import { CAP, client, setErrorMessage } from "@/jmap/client"; -import type { BusyPeriod, Calendar, CalendarEvent, GetResponse, Id, JSCalendarParticipant, JSCalendarRecurrenceRule, ParticipantIdentity, QueryResponse, SetResponse } from "@/jmap/types"; +import type { BusyPeriod, Calendar, CalendarEvent, EmailAddress, GetResponse, Id, JSCalendarParticipant, JSCalendarRecurrenceRule, ParticipantIdentity, QueryResponse, SetResponse } from "@/jmap/types"; import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browserTimeZone } from "@/lib/dates"; import { settings, useSettings } from "./settings"; import { useSession } from "./session"; @@ -238,6 +238,7 @@ export interface EventDraft { start: Date; end: Date; allDay: boolean; + attendees: EmailAddress[]; } interface CalendarState { diff --git a/web/src/views/calendar/CalendarView.tsx b/web/src/views/calendar/CalendarView.tsx index 4cdd49c..dcc323e 100644 --- a/web/src/views/calendar/CalendarView.tsx +++ b/web/src/views/calendar/CalendarView.tsx @@ -77,8 +77,8 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?: */ useEffect(() => { if (!cal.draft) return; - const { title, description, ...when } = cal.draft; - setEditor({ ...when, seed: { title, description } }); + const { title, description, attendees, ...when } = cal.draft; + setEditor({ ...when, seed: { title, description, attendees } }); cal.setDraft(null); }, [cal.draft]); diff --git a/web/src/views/calendar/EventEditor.tsx b/web/src/views/calendar/EventEditor.tsx index bb19e32..cf4b65a 100644 --- a/web/src/views/calendar/EventEditor.tsx +++ b/web/src/views/calendar/EventEditor.tsx @@ -27,7 +27,7 @@ export interface EditorInit { * so far. Not an event: this is still a form the reader has to finish, so * `editing` stays false and the dialog says New event / Create. */ - seed?: { title?: string; description?: string }; + seed?: { title?: string; description?: string; attendees?: EmailAddress[] }; } const ALERT_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880, 10080]; @@ -127,12 +127,25 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle }); const myKeys = ev ? myParticipantKeys(ev, cal.identities) : []; const [attendees, setAttendees] = useState(() => - Object.entries(ev?.participants ?? {}) - .filter(([k, p]) => !myKeys.includes(k) && !(p.roles?.owner && !p.roles?.attendee)) - .map(([, p]) => ({ name: p.name ?? null, email: participantEmail(p) })) - .filter((a) => a.email), + ev + ? Object.entries(ev.participants ?? {}) + .filter(([k, p]) => !myKeys.includes(k) && !(p.roles?.owner && !p.roles?.attendee)) + .map(([, p]) => ({ name: p.name ?? null, email: participantEmail(p) })) + .filter((a) => a.email) + : (init.seed?.attendees ?? []), ); - const [sendInvites, setSendInvites] = useState(true); + /* + * Off when the guest list was not typed but inherited -- from a message, so + * far -- and on everywhere else, which is every event whose guests somebody + * chose one at a time. + * + * A reminder made out of a bill carries the biller and everyone else the + * mail went to. Left on, the primary button reads Send invites, and the + * first press mails all of them an invitation to the reader's private note + * to self. The switch is right there and says what it does, so inviting them + * is one deliberate click; un-sending is not. + */ + const [sendInvites, setSendInvites] = useState(!init.seed?.attendees?.length); const [busy, setBusy] = useState(false); const [fb, setFb] = useState>({}); const [showMore, setShowMore] = useState(Boolean(ev && (ev.privacy !== "public" || ev.freeBusyStatus === "free" || ev.color || ev.status !== "confirmed" || Object.keys(ev.categories ?? {}).length)));