diff --git a/README.md b/README.md index f74a65d..bfc4643 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A self-hosted scheduling application inspired by YouCanBook.me and Calendly, run - **Meeting type selection** — configurable via YAML (office hours, check-ins, project meetings, custom durations) - **Calendar availability** — reads availability windows from Google Calendar, checks configured calendars for conflicts - **Booking form** — collects visitor details, supports in-person and virtual formats +- **Email verification** — bookings are finalized only after the visitor clicks a link sent to their address, which filters out non-legitimate bookings - **Email notifications** — sends confirmation, cancellation, and reschedule emails with iCal attachments - **Cancel & reschedule** — secure token-based links in every confirmation email - **Zero cost** — GitHub Pages (free) + Google Apps Script (free) + Google Sheets (free) @@ -39,6 +40,7 @@ See the [full setup guide](https://cdl-scheduler.readthedocs.io/en/latest/setup. ``` index.html Main booking flow (4-step wizard) +confirm.html Email-verification page cancel.html Cancellation page reschedule.html Reschedule page config/ YAML configuration files diff --git a/backend/Booking.gs b/backend/Booking.gs index ae5a968..5d47be2 100644 --- a/backend/Booking.gs +++ b/backend/Booking.gs @@ -103,6 +103,8 @@ var BookingStore = (function () { /** * Find a confirmed booking whose time range overlaps [startISO, endISO]. + * Only 'confirmed' rows count: a 'pending' row (awaiting email verification) + * deliberately does not hold its slot, so unverified requests can't squat times. * * This is the authoritative, immediately-consistent double-booking guard: * unlike the calendar (read back through the eventually-consistent diff --git a/backend/Calendar.gs b/backend/Calendar.gs index e8f7ab6..4180371 100644 --- a/backend/Calendar.gs +++ b/backend/Calendar.gs @@ -80,12 +80,18 @@ var CalendarService = (function () { * match, so any interval offset from that grid — e.g. a client-merged 45-min * slot at 10:30 — was wrongly rejected as SLOT_TAKEN.) */ - function isRangeAvailable(startDate, endDate) { + function isRangeAvailable(startDate, endDate, requestedAt) { var minNoticeHours = parseInt(Config.get('MIN_NOTICE_HOURS'), 10) || 12; var maxAdvanceDays = parseInt(Config.get('MAX_ADVANCE_DAYS'), 10) || 90; - var earliest = new Date(Date.now() + minNoticeHours * 60 * 60 * 1000); - var maxDate = new Date(Date.now() + maxAdvanceDays * 24 * 60 * 60 * 1000); + // Notice and advance limits are measured from when the visitor REQUESTED the + // slot (defaults to now). Confirming an emailed verification link minutes + // later must not fail a slot that satisfied min-notice when it was chosen — + // though it must still be in the future. + var from = requestedAt ? new Date(requestedAt).getTime() : Date.now(); + if (isNaN(from)) from = Date.now(); + var earliest = new Date(Math.max(from + minNoticeHours * 60 * 60 * 1000, Date.now())); + var maxDate = new Date(from + maxAdvanceDays * 24 * 60 * 60 * 1000); if (startDate >= endDate) return false; if (startDate < earliest) return false; diff --git a/backend/Code.gs b/backend/Code.gs index ff423da..3e8cc7f 100644 --- a/backend/Code.gs +++ b/backend/Code.gs @@ -83,6 +83,8 @@ function doPost(e) { return handleGetAvailableSlots(requestData); case 'createBooking': return handleCreateBooking(requestData); + case 'confirmBooking': + return handleConfirmBooking(requestData); case 'cancelBooking': return handleCancelBooking(requestData); case 'getBooking': @@ -170,30 +172,11 @@ function handleCreateBooking(data) { return jsonResponse({ success: false, error: 'SLOT_TAKEN', message: 'This time slot is no longer available. Please select another time.' }); } - // Generate token first so it's available for the event description - var token = TokenService.generateToken(); - var pagesUrl = Config.get('GITHUB_PAGES_URL'); - data.token = token; - - // Create calendar event - var ownerName = Config.get('OWNER_NAME') || 'Jeremy'; - var ownerFirst = ownerName.split(' ')[0]; - var eventTitle = data.eventLabel - ? data.firstName + '/' + ownerFirst + ' ' + data.eventLabel - : data.firstName + '/' + ownerFirst + ': ' + data.meetingTypeName; - var description = buildEventDescription(data); - var calendar = CalendarApp.getCalendarById(Config.get('CALENDAR_ID')); - var event = calendar.createEvent(eventTitle, startDate, endDate, { - guests: data.email, - sendInvites: true, - location: data.location, - description: description, - }); var bookingRecord = { - token: token, + token: TokenService.generateToken(), tokenExpiresAt: TokenService.getExpiryDate(), - eventId: event.getId(), - status: 'confirmed', + eventId: '', + status: 'pending', meetingTypeId: data.meetingTypeId, meetingTypeName: data.meetingTypeName, eventLabel: data.eventLabel || '', @@ -211,22 +194,39 @@ function handleCreateBooking(data) { rescheduledTo: '', }; - BookingStore.create(bookingRecord); - - // Send emails - EmailService.sendBookingConfirmation(bookingRecord); + // Email verification: store a PENDING row and email the visitor a link to + // confirm. Nothing is put on the calendar, no invite goes out, and the owner + // isn't notified until the address is proven to belong to the booker — this + // is what stops throwaway/non-legitimate bookings (and stops the form being + // used to make the owner's account mail invites to arbitrary addresses). + // A pending row deliberately does NOT hold the slot (only 'confirmed' rows + // block in findOverlappingConfirmed), so unverified requests can't squat + // times; availability is re-checked when the link is confirmed. + if (verificationRequired()) { + // The visitor can only finish via this email, so never report "check your + // email" when it can't be (or wasn't) sent — e.g. the MailApp quota is spent. + if (MailApp.getRemainingDailyQuota() < 1) { + return jsonResponse({ success: false, error: 'EMAIL_UNAVAILABLE', message: 'Bookings are temporarily unavailable. Please try again tomorrow or contact us by email.' }); + } + BookingStore.create(bookingRecord); + if (!EmailService.sendVerificationEmail(bookingRecord)) { + BookingStore.updateStatus(bookingRecord.token, 'cancelled', { cancelledAt: new Date().toISOString() }); + return jsonResponse({ success: false, error: 'EMAIL_UNAVAILABLE', message: 'We could not send the confirmation email. Please check the address and try again.' }); + } + return jsonResponse({ + success: true, + pending: true, + booking: { + start: data.start, + end: data.end, + email: data.email, + verifyWithinMinutes: verifyLinkMinutes(), + }, + }); + } - return jsonResponse({ - success: true, - booking: { - token: token, - eventId: event.getId(), - start: data.start, - end: data.end, - cancelUrl: pagesUrl + '/cancel.html?token=' + token, - rescheduleUrl: pagesUrl + '/reschedule.html?token=' + token, - }, - }); + finalizeBooking(bookingRecord, function () { BookingStore.create(bookingRecord); }); + return jsonResponse({ success: true, booking: bookingResponse(bookingRecord) }); } finally { // Persist pending sheet writes BEFORE releasing the lock, so the next request // to take the lock sees this booking's row. Without the flush the row might @@ -237,6 +237,65 @@ function handleCreateBooking(data) { } } +/** + * Finalize a pending booking once the visitor clicks the link in their + * verification email (confirm.html POSTs this after an explicit button click, so + * email link-scanners can't confirm on the visitor's behalf). + * + * Idempotent: confirming an already-confirmed booking returns it again rather + * than creating a second event. + */ +function handleConfirmBooking(data) { + if (!data.token) { + return jsonResponse({ success: false, error: 'VALIDATION_ERROR', message: 'Token is required' }); + } + + var lock = LockService.getScriptLock(); + if (!lock.tryLock(10000)) { + return jsonResponse({ success: false, error: 'LOCK_TIMEOUT', message: 'Server is busy. Please try again in a moment.' }); + } + + try { + var booking = BookingStore.getByToken(data.token); + if (!booking) { + return jsonResponse({ success: false, error: 'NOT_FOUND', message: 'Booking not found' }); + } + if (booking.status === 'confirmed') { + return jsonResponse({ success: true, alreadyConfirmed: true, booking: bookingResponse(booking) }); + } + if (booking.status !== 'pending') { + return jsonResponse({ success: false, error: 'ALREADY_CANCELLED', message: 'This booking request is no longer active.' }); + } + if (isVerificationExpired(booking)) { + BookingStore.updateStatus(data.token, 'expired'); + return jsonResponse({ success: false, error: 'EXPIRED', message: 'This confirmation link has expired. Please book again.' }); + } + + // The slot was never held while pending, so re-run both availability checks + // exactly as createBooking does — someone else may have confirmed first. + if (BookingStore.findOverlappingConfirmed(booking.startTime, booking.endTime, null, isStaleGhost) || + !CalendarService.isRangeAvailable(new Date(booking.startTime), new Date(booking.endTime), booking.createdAt)) { + BookingStore.updateStatus(data.token, 'expired'); + return jsonResponse({ success: false, error: 'SLOT_TAKEN', message: 'Sorry, this time was booked by someone else before you confirmed. Please choose another time.' }); + } + + finalizeBooking(booking, function () { + // createdAt is reset to the moment the event is created: the ghost guard + // (isStaleGhost) and reconcileBookings trust a row only while createdAt is + // inside GHOST_GRACE_MINUTES, to cover calendar read-after-write lag. Left + // at the request time, a link confirmed >10 min later would produce a row + // whose brand-new (not yet visible) event reads as "deleted" — letting a + // second booking take the slot, or reconcile cancel a live booking. + booking.createdAt = new Date().toISOString(); + BookingStore.updateStatus(data.token, 'confirmed', { eventId: booking.eventId, createdAt: booking.createdAt }); + }); + return jsonResponse({ success: true, booking: bookingResponse(booking) }); + } finally { + try { SpreadsheetApp.flush(); } catch (e) { /* nothing pending */ } + lock.releaseLock(); + } +} + function handleCancelBooking(data) { if (!data.token) { return jsonResponse({ success: false, error: 'VALIDATION_ERROR', message: 'Token is required' }); @@ -257,6 +316,12 @@ function handleCancelBooking(data) { return jsonResponse({ success: false, error: 'ALREADY_CANCELLED', message: 'This booking has been rescheduled' }); } + if (booking.status === 'pending' || booking.status === 'expired') { + // Never finalized: no calendar event exists and nobody was notified. + BookingStore.updateStatus(data.token, 'cancelled', { cancelledAt: new Date().toISOString() }); + return jsonResponse({ success: true, message: 'Booking request cancelled' }); + } + // Delete calendar event try { var calendar = CalendarApp.getCalendarById(Config.get('CALENDAR_ID')); @@ -324,21 +389,6 @@ function handleRescheduleBooking(data) { return jsonResponse({ success: false, error: 'ALREADY_CANCELLED', message: 'This booking cannot be rescheduled' }); } - // Create new booking with reschedule - var newBookingData = { - meetingTypeId: oldBooking.meetingTypeId, - meetingTypeName: oldBooking.meetingTypeName || oldBooking.meetingTypeId, - start: data.newStart, - end: data.newEnd, - firstName: oldBooking.firstName, - lastName: oldBooking.lastName, - email: oldBooking.email, - format: oldBooking.format, - location: oldBooking.location, - purpose: oldBooking.purpose, - notes: oldBooking.notes, - }; - var lock = LockService.getScriptLock(); if (!lock.tryLock(10000)) { return jsonResponse({ success: false, error: 'LOCK_TIMEOUT', message: 'Server is busy. Please try again in a moment.' }); @@ -367,37 +417,11 @@ function handleRescheduleBooking(data) { Logger.log('Error deleting old event during reschedule: ' + err.message); } - // Generate new token first so it's available for the event description var newToken = TokenService.generateToken(); - var pagesUrl = Config.get('GITHUB_PAGES_URL'); - - // Create new calendar event with the new token in the description - var descData = { - token: newToken, - firstName: oldBooking.firstName, - lastName: oldBooking.lastName, - email: oldBooking.email, - purpose: oldBooking.purpose, - notes: oldBooking.notes, - }; - var ownerName = Config.get('OWNER_NAME') || 'Jeremy'; - var ownerFirst = ownerName.split(' ')[0]; - var eventTitle = oldBooking.eventLabel - ? oldBooking.firstName + '/' + ownerFirst + ' ' + oldBooking.eventLabel - : oldBooking.firstName + '/' + ownerFirst + ': ' + (oldBooking.meetingTypeName || oldBooking.meetingTypeId); - var description = buildEventDescription(descData); - var calendar = CalendarApp.getCalendarById(Config.get('CALENDAR_ID')); - var newEvent = calendar.createEvent(eventTitle, newStart, newEnd, { - guests: oldBooking.email, - sendInvites: true, - location: oldBooking.location, - description: description, - }); - var newBookingRecord = { token: newToken, tokenExpiresAt: TokenService.getExpiryDate(), - eventId: newEvent.getId(), + eventId: '', status: 'confirmed', meetingTypeId: oldBooking.meetingTypeId, meetingTypeName: oldBooking.meetingTypeName || oldBooking.meetingTypeId, @@ -415,6 +439,8 @@ function handleRescheduleBooking(data) { cancelledAt: '', rescheduledTo: '', }; + // The address was already verified when the original booking was confirmed. + newBookingRecord.eventId = createBookingEvent(newBookingRecord); BookingStore.create(newBookingRecord); BookingStore.updateStatus(data.oldToken, 'rescheduled', { rescheduledTo: newToken }); @@ -422,17 +448,7 @@ function handleRescheduleBooking(data) { // Send reschedule emails EmailService.sendRescheduleEmail(oldBooking, newBookingRecord); - return jsonResponse({ - success: true, - booking: { - token: newToken, - eventId: newEvent.getId(), - start: data.newStart, - end: data.newEnd, - cancelUrl: pagesUrl + '/cancel.html?token=' + newToken, - rescheduleUrl: pagesUrl + '/reschedule.html?token=' + newToken, - }, - }); + return jsonResponse({ success: true, booking: bookingResponse(newBookingRecord) }); } finally { // Persist pending sheet writes BEFORE releasing the lock, so the next request // to take the lock sees this booking's row. Without the flush the row might @@ -445,6 +461,70 @@ function handleRescheduleBooking(data) { // --- Helper Functions --- +/** Whether new bookings must be confirmed via an emailed link (default: yes). */ +function verificationRequired() { + return String(Config.get('REQUIRE_EMAIL_VERIFICATION') || 'true').toLowerCase() !== 'false'; +} + +function verifyLinkMinutes() { + return parseInt(Config.get('VERIFY_LINK_MINUTES'), 10) || 60; +} + +/** A pending booking whose confirmation link is older than VERIFY_LINK_MINUTES. */ +function isVerificationExpired(booking) { + var createdAt = new Date(booking.createdAt).getTime(); + if (isNaN(createdAt)) return true; // can't tell when it was requested -> don't honour it + return (Date.now() - createdAt) > verifyLinkMinutes() * 60 * 1000; +} + +/** + * Create the Google Calendar event (and native invite to the visitor) for a + * booking record. Returns the new event id. + */ +function createBookingEvent(record) { + var ownerName = Config.get('OWNER_NAME') || 'Jeremy'; + var ownerFirst = ownerName.split(' ')[0]; + var eventTitle = record.eventLabel + ? record.firstName + '/' + ownerFirst + ' ' + record.eventLabel + : record.firstName + '/' + ownerFirst + ': ' + (record.meetingTypeName || record.meetingTypeId); + var calendar = CalendarApp.getCalendarById(Config.get('CALENDAR_ID')); + var event = calendar.createEvent(eventTitle, new Date(record.startTime), new Date(record.endTime), { + guests: record.email, + sendInvites: true, + location: record.location, + description: buildEventDescription(record), + }); + return event.getId(); +} + +/** + * Turn a booking record into a real meeting: calendar event + invite, persist + * it via `persist()` (create for a new row, updateStatus for a verified pending + * row), and only then send the visitor confirmation and owner notification — so + * nobody is emailed cancel/reschedule links for a row that failed to save. + * Mutates `record` (eventId, status). Call inside the script lock after + * availability checks. + */ +function finalizeBooking(record, persist) { + record.eventId = createBookingEvent(record); + record.status = 'confirmed'; + persist(); + EmailService.sendBookingConfirmation(record); +} + +/** Public shape of a confirmed booking returned to the frontend. */ +function bookingResponse(record) { + var pagesUrl = Config.get('GITHUB_PAGES_URL'); + return { + token: record.token, + eventId: record.eventId, + start: record.startTime, + end: record.endTime, + cancelUrl: pagesUrl + '/cancel.html?token=' + record.token, + rescheduleUrl: pagesUrl + '/reschedule.html?token=' + record.token, + }; +} + /** * Key-gated maintenance actions (reconcile, cleanup), shared by doGet and doPost. * The key is compared against CLEANUP_KEY. Prefer calling these via POST with the @@ -499,7 +579,7 @@ function rateBump(cache, key, limit, windowSec) { * @return {boolean} true if the request is allowed, false if it should be throttled. */ function rateLimitOk(action, requestData) { - var WRITE_ACTIONS = { createBooking: true, cancelBooking: true, rescheduleBooking: true }; + var WRITE_ACTIONS = { createBooking: true, confirmBooking: true, cancelBooking: true, rescheduleBooking: true }; var cache = CacheService.getScriptCache(); var clientId = requestData && requestData.clientId; var cid = clientId ? String(clientId).slice(0, 64) : 'anon'; diff --git a/backend/Config.gs b/backend/Config.gs index 892fae7..95fee31 100644 --- a/backend/Config.gs +++ b/backend/Config.gs @@ -30,6 +30,12 @@ var Config = (function () { // just-created event may not yet be visible via getEventById, so a fresh row // must never be mistaken for a "ghost" (deleted-out-of-band) row. GHOST_GRACE_MINUTES: '10', + // Email verification: a new booking is only a pending request until the + // visitor clicks the link emailed to them. Until then there is no calendar + // event, no invite, no owner notification, and the slot is NOT held. Set to + // 'false' to finalize bookings immediately (the pre-verification behaviour). + REQUIRE_EMAIL_VERIFICATION: 'true', + VERIFY_LINK_MINUTES: '60', // how long a confirmation link stays valid }; function get(key) { diff --git a/backend/Email.gs b/backend/Email.gs index baf5755..25a71cb 100644 --- a/backend/Email.gs +++ b/backend/Email.gs @@ -79,6 +79,51 @@ var EmailService = (function () { } } + /** + * Send the visitor a link to confirm a pending booking request. This is the + * ONLY email a new request triggers: no invite, no owner notification, no + * cancel/reschedule links — those follow once the visitor proves they own the + * address by clicking through (see handleConfirmBooking). + * + * @return {boolean} whether the email was handed to MailApp successfully. + */ + function sendVerificationEmail(booking) { + var ownerName = Config.get('OWNER_NAME'); + var pagesUrl = Config.get('GITHUB_PAGES_URL'); + var minutes = parseInt(Config.get('VERIFY_LINK_MINUTES'), 10) || 60; + + var startDate = new Date(booking.startTime); + var endDate = new Date(booking.endTime); + var dateStr = formatDateForEmail(startDate); + var timeStr = formatTimeRange(startDate, endDate); + + var subject = 'Action needed: confirm your ' + booking.meetingTypeId + ' booking on ' + dateStr; + var body = 'Hi ' + booking.firstName + ',\n\n' + + 'Please confirm your meeting request by opening this link and clicking "Confirm booking":\n\n' + + pagesUrl + '/confirm.html?token=' + booking.token + '\n\n' + + 'Requested meeting:\n' + + ' Type: ' + booking.meetingTypeId + '\n' + + ' Date: ' + dateStr + '\n' + + ' Time: ' + timeStr + '\n\n' + + 'Your booking is NOT final until you confirm, and the time is not held for you in the meantime. ' + + 'This link expires in ' + minutes + ' minutes.\n\n' + + 'If you didn\'t request this meeting, ignore this email and nothing will be booked.' + + '\n\nBest,\n' + ownerName; + + try { + MailApp.sendEmail({ + to: booking.email, + subject: subject, + body: body, + name: ownerName, + }); + return true; + } catch (e) { + Logger.log('Failed to send verification email: ' + e.message); + return false; + } + } + /** * Send cancellation emails to both visitor and owner. */ @@ -288,6 +333,7 @@ var EmailService = (function () { return { sendBookingConfirmation: sendBookingConfirmation, + sendVerificationEmail: sendVerificationEmail, sendCancellationEmail: sendCancellationEmail, sendRescheduleEmail: sendRescheduleEmail, buildICalContent: buildICalContent, diff --git a/cancel.html b/cancel.html index 76199a4..ffcc920 100644 --- a/cancel.html +++ b/cancel.html @@ -30,6 +30,7 @@

Cancel Booking

+ + + +
+
+

Confirm Booking

+
+ +
+
+

Loading booking details…

+
+
+ + + + +
+ + + + + + + + + diff --git a/css/styles.css b/css/styles.css index 508c695..cb9e800 100644 --- a/css/styles.css +++ b/css/styles.css @@ -519,6 +519,17 @@ a:hover { margin-top: 8px; } +/* Action buttons beside link-buttons (confirm/cancel pages): size to the label + and match the link-button metrics so the row lines up. */ +.confirmation-links .btn-primary { + width: auto; + margin-top: 0; + padding: 10px 24px; + font-size: 13px; + letter-spacing: 1.5px; + line-height: inherit; /*