diff --git a/dashboard/lib/email.js b/dashboard/lib/email.js new file mode 100644 index 0000000..875b5ce --- /dev/null +++ b/dashboard/lib/email.js @@ -0,0 +1,27 @@ +/** + * Helpers for building Gmail API raw messages (base64url-encoded RFC 2822). + * Used by scripts/send-test-email.js and testable without I/O. + */ + +/** + * Build a raw MIME message string and return it base64url-encoded for Gmail API. + * @param {string} to - Recipient email address + * @param {string} subject - Subject line + * @param {string} body - Plain text body + * @returns {string} base64url-encoded message for requestBody.raw + */ +export function buildRawMessage(to, subject, body) { + const lines = [ + `To: ${to}`, + `Subject: ${subject}`, + 'Content-Type: text/plain; charset=UTF-8', + '', + body, + ]; + const message = lines.join('\n'); + return Buffer.from(message) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 2d61d35..6c695c2 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -13,6 +13,7 @@ "express": "^4.18.2", "googleapis": "^128.0.0", "http-proxy-middleware": "^2.0.6", + "open": "^10.0.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -1920,6 +1921,20 @@ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2301,6 +2316,43 @@ "node": ">=4.0.0" } }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3123,6 +3175,20 @@ "node": ">=8" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3151,6 +3217,23 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3187,6 +3270,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3681,6 +3778,23 @@ "wrappy": "1" } }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", @@ -4040,6 +4154,17 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -5493,6 +5618,20 @@ } } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 7da1ad1..ba159d8 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -11,10 +11,12 @@ "preview": "vite preview", "serve": "nodemon server.js", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "send-test-email": "node scripts/send-test-email.js" }, "dependencies": { "better-sqlite3": "^12.6.2", + "open": "^10.0.0", "dotenv": "^16.3.1", "express": "^4.18.2", "googleapis": "^128.0.0", diff --git a/dashboard/scripts/send-test-email.js b/dashboard/scripts/send-test-email.js new file mode 100644 index 0000000..60a28d2 --- /dev/null +++ b/dashboard/scripts/send-test-email.js @@ -0,0 +1,65 @@ +/** + * Send a test email via Gmail API using OAuth2 (refresh token in .env). + * Requires: dashboard/oauth-credentials.json and GOOGLE_OAUTH_REFRESH_TOKEN in .env. + * Gmail API must be enabled in the Google Cloud project; OAuth consent must include gmail.send. + * + * Run from dashboard: npm run send-test-email + */ + +import { google } from 'googleapis'; +import { readFileSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import dotenv from 'dotenv'; +import { buildRawMessage } from '../lib/email.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Load .env from dashboard root +dotenv.config({ path: path.join(__dirname, '..', '.env') }); + +const TO_EMAIL = 'benjamintmanley@gmail.com'; +const SUBJECT = "Hey there!"; +const BODY = "How's it going?"; + +function loadOAuthCredentials() { + const credPath = path.join(__dirname, '..', 'oauth-credentials.json'); + const raw = readFileSync(credPath, 'utf8'); + const json = JSON.parse(raw); + const client = json.installed || json.web; + if (!client || !client.client_id || !client.client_secret) { + throw new Error('oauth-credentials.json must contain installed or web with client_id and client_secret'); + } + return { + clientId: client.client_id, + clientSecret: client.client_secret, + redirectUri: client.redirect_uris?.[0] || 'http://localhost:3000/oauth2callback', + }; +} + +async function main() { + const refreshToken = process.env.GOOGLE_OAUTH_REFRESH_TOKEN; + if (!refreshToken) { + console.error('Missing GOOGLE_OAUTH_REFRESH_TOKEN in .env'); + process.exit(1); + } + + const { clientId, clientSecret, redirectUri } = loadOAuthCredentials(); + const oauth2Client = new google.auth.OAuth2(clientId, clientSecret, redirectUri); + oauth2Client.setCredentials({ refresh_token: refreshToken }); + + const gmail = google.gmail({ version: 'v1', auth: oauth2Client }); + const raw = buildRawMessage(TO_EMAIL, SUBJECT, BODY); + + await gmail.users.messages.send({ + userId: 'me', + requestBody: { raw }, + }); + + console.log(`Test email sent to ${TO_EMAIL} (subject: "${SUBJECT}").`); +} + +main().catch((err) => { + console.error(err.message || err); + process.exit(1); +}); diff --git a/dashboard/src/email.test.ts b/dashboard/src/email.test.ts new file mode 100644 index 0000000..da7ecca --- /dev/null +++ b/dashboard/src/email.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { buildRawMessage } from '../lib/email.js'; + +describe('buildRawMessage', () => { + it('returns base64url-encoded string (no + / or trailing =)', () => { + const raw = buildRawMessage('a@b.com', 'Sub', 'Body'); + expect(raw).not.toContain('+'); + expect(raw).not.toContain('/'); + expect(raw).not.toMatch(/=+$/); + expect(typeof raw).toBe('string'); + expect(raw.length).toBeGreaterThan(0); + }); + + it('decodes to RFC 2822 with To, Subject, Content-Type and body', () => { + const raw = buildRawMessage('ben@example.com', 'Hey there!', "How's it going?"); + const decoded = Buffer.from(raw.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); + expect(decoded).toContain('To: ben@example.com'); + expect(decoded).toContain('Subject: Hey there!'); + expect(decoded).toContain('Content-Type: text/plain; charset=UTF-8'); + expect(decoded).toContain("How's it going?"); + }); + + it('handles empty body', () => { + const raw = buildRawMessage('x@y.z', 'No body', ''); + const decoded = Buffer.from(raw.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); + expect(decoded).toContain('To: x@y.z'); + expect(decoded).toContain('Subject: No body'); + expect(decoded.endsWith('\n\n')).toBe(true); + }); + + it('handles multiline body', () => { + const body = 'Line 1\nLine 2'; + const raw = buildRawMessage('a@b.c', 'Sub', body); + const decoded = Buffer.from(raw.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); + expect(decoded).toContain('Line 1\nLine 2'); + }); +}); diff --git a/feature-list/feature-list.md b/feature-list/feature-list.md index 12c76f6..493ef18 100644 --- a/feature-list/feature-list.md +++ b/feature-list/feature-list.md @@ -61,10 +61,9 @@ A combination of **Home Assistant**–style smart home control and **Skylight** - [ ] **Sleep/wake times** — dim or switch to slideshow (see clarifications) - [ ] **Photo slideshow** — starts after a configurable delay with no interaction, or as sleep mode - - [ ] **Primary source:** **Google Photos** — users share albums with the Canopy Gmail account; slideshow uses those albums via Photos Library API (see Input & tradeoffs). No Cloudinary work yet. - - [ ] **Configurable album/folder selection** — choose which shared albums (or later, folders) feed the slideshow. - - [ ] **Cloudinary** — on the back burner. Not written off: fast, responsive, 10GB more storage than Google; may be revisited for uploads, transforms, or as an alternative source. - - [ ] Starter set can be provided (e.g. in a Google Photos album shared with Canopy, or local until Google is wired up). + - [ ] **Primary source:** **Cloudinary** — use Cloudinary for slideshow media (see Input & tradeoffs). Google Photos was considered but would require device registration, user linking in Google Photos app, and API limits; Cloudinary is simpler to integrate. **CLOUDINARY_URL** is already in `dashboard/.env` for when we wire this up. + - [ ] **Configurable album/folder selection** — choose which folders or collections feed the slideshow (Cloudinary folders/tags or similar). + - [ ] Starter set can be provided (e.g. local assets or a default Cloudinary folder until configured). ### Task list (nav) @@ -139,15 +138,17 @@ A combination of **Home Assistant**–style smart home control and **Skylight** ### Canopy service Gmail account -- [ ] **Dedicated Gmail** — A Gmail account has been created for the Canopy service. Potential uses (no code yet; ideas for the roadmap): +- [x] **Send email** — We can send email via Gmail API using the Canopy Gmail account (**mackinaw.canopy@gmail.com**) and OAuth (refresh token in `.env`). Test script: `npm run send-test-email` from dashboard. Use this as we add reminders, announcements, and other outbound features. +- [ ] **Dedicated Gmail (other uses)** — Potential uses to build on the above: - **Outbound:** Event or task reminders, family announcements (“Dinner’s ready”), daily agenda digest, or “what’s on the panel today” emails. - **Inbound:** “Email to add” flows (e.g. forward an email to create a task or quick event), or invite/guest-access links sent by email. - - **Auth:** Sending from the panel via OAuth or app password for the above. Notifications could be sent through this account so they come from a consistent “Canopy” identity. + - Notifications from this account so they come from a consistent “Canopy” identity. +- [ ] **Stretch: Consolidate Calendar to Canopy Gmail** — Calendar currently uses a different Gmail account and `dashboard/SERVICE_ACCOUNT.json`. Stretch goal: move Calendar over to **mackinaw.canopy@gmail.com** as well (e.g. same project, new service account for that account, or OAuth) so one identity backs Calendar and Gmail. ### Other - [ ] **Energy / utility** — small widget or view for energy/solar (e.g. today’s production/usage) when using HA energy -- [ ] **Photo source options** — **Google Photos (shared albums with Canopy)** first; **Cloudinary on the back burner** (fast, responsive, more storage; future option). Local/NAS path or combination also on the roadmap. +- [ ] **Photo source options** — **Cloudinary** as primary for slideshow (see Input & tradeoffs). Local/NAS path or combination also on the roadmap. - [ ] **Vacation / away mode** — pause or simplify slideshow; minimal screen or reduced HA sensitivity when “away” - [ ] **Multi-device / naming** — device name and optional role (e.g. kitchen vs bedroom) for settings and backups - [ ] **Responsive design** for mobile app usage @@ -156,8 +157,8 @@ A combination of **Home Assistant**–style smart home control and **Skylight** ## Input & tradeoffs (no code action) -### Photo source: Google Photos first, Cloudinary on the back burner +### Photo source: Cloudinary (Google Photos removed) -- **Current plan:** Use **Google Photos** for the slideshow: family shares albums with the Canopy Gmail account; the app uses the Photos Library API to list and display those albums. Lower friction when the family already uses Google Photos. -- **Cloudinary** is **on the back burner**, not written off: it's fast, responsive, and offers ~10GB more storage than Google. Could be revisited later for uploads, transforms, configurable folders, or as an alternative/extra source. No code planned for Cloudinary until after Google Photos is in place. -- **Summary:** Google Photos first (shared albums → slideshow). Cloudinary remains a future option for more control, storage, and performance. +- **Decision:** Use **Cloudinary** for the slideshow instead of Google Photos. Google Photos would have required the Ambient API (device registration, user linking in the Google Photos app, 240 requests/device/day limit) or other workarounds after the Library API was restricted in 2025 — too many hoops for the goal of “users easily share albums with the calendar.” +- **Cloudinary** is the primary photo source: simpler integration, fast, responsive, and sufficient storage. Configurable folders/collections can feed the slideshow. +- **Summary:** Slideshow is backed by Cloudinary. Google Photos–related scripts and setup docs have been removed from the repo.