Follow-up to #167: an event made from a mail now opens with the sender and everyone it was addressed to already in the guest list, so a thread becomes a meeting without retyping the room. Two things are deliberately left out. The reader's own addresses, since they are the organiser and an organiser among their own guests is an invitation to your own appointment. And a blind copy, on a message the reader sent: a guest list is visible to every guest, so promoting a Bcc to a guest would tell the room about a copy the sender chose to hide. That is not something a menu item may do quietly. *Send invitation emails to guests* now starts off when the guests were inherited rather than typed, and on everywhere else -- which is every event whose guests somebody chose one at a time. The reason is the case the issue opened with: a reminder made out of a bill carries the biller and everyone else on the mail. Left on, the primary button reads Send invites and the first press mails all of them an invitation to what was meant as a note to self. The switch sits right there under the list and says what it does, so inviting them is one deliberate click. Un-sending is not.
98 lines
4.1 KiB
TypeScript
98 lines
4.1 KiB
TypeScript
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";
|
|
|
|
/**
|
|
* How much of a message body is copied into an event description.
|
|
*
|
|
* The reader is making a reminder out of a mail, and a newsletter is a mail
|
|
* too: whole bodies run to hundreds of kilobytes, which would be stored on the
|
|
* event, synced to every device, and shown in a three-row textarea. What is
|
|
* worth keeping is near the top -- the amount owed, the date, the address --
|
|
* so the tail is what gets dropped, and visibly, so nobody reads a truncated
|
|
* bill as the whole of it.
|
|
*/
|
|
const MAX_DESCRIPTION = 5000;
|
|
|
|
/**
|
|
* The next half-hour, which is when an appointment made now can start.
|
|
*
|
|
* Always forward, never the current instant: the reader still has a form to
|
|
* fill in, and a start time that is already in the past by the time they press
|
|
* Create is one they have to fix by hand.
|
|
*/
|
|
export function nextHalfHour(now: Date = new Date()): Date {
|
|
const d = new Date(now);
|
|
d.setSeconds(0, 0);
|
|
d.setMinutes(d.getMinutes() + (30 - (d.getMinutes() % 30)));
|
|
return d;
|
|
}
|
|
|
|
/** The message's body as plain text, however it was sent. */
|
|
function bodyText(email: Email): string {
|
|
const textPart = email.textBody?.[0];
|
|
const text = textPart?.partId ? (email.bodyValues?.[textPart.partId]?.value ?? "") : "";
|
|
if (text.trim()) return text;
|
|
const htmlPart = email.htmlBody?.[0];
|
|
const html = htmlPart?.partId ? (email.bodyValues?.[htmlPart.partId]?.value ?? "") : "";
|
|
return html ? htmlToText(html) : "";
|
|
}
|
|
|
|
/**
|
|
* An event seeded from a message: its subject, its body, and a time to fix.
|
|
*
|
|
* Deliberately nothing clever. The date is the one thing the message cannot
|
|
* supply -- "the 14th" in a bill is not a due date the parser could trust --
|
|
* so the editor opens with the reader's cursor on a form they finish, rather
|
|
* than a guess they have to check.
|
|
*/
|
|
/**
|
|
* 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 {
|
|
title: email.subject?.trim() ?? "",
|
|
description: body.length > MAX_DESCRIPTION ? `${body.slice(0, MAX_DESCRIPTION).trimEnd()}…` : body,
|
|
start,
|
|
end: new Date(start.getTime() + 3600_000),
|
|
allDay: false,
|
|
attendees: guests(email, ownEmails),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Open the calendar's event editor on a draft made from this message.
|
|
*
|
|
* The list holds a message without its body -- only a preview -- so the full
|
|
* one is fetched first; `getEmails` serves it from the cache when the message
|
|
* has already been read.
|
|
*/
|
|
export async function startAppointment(email: Email, navigate: (to: string) => void): Promise<void> {
|
|
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)}`);
|
|
}
|