Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions dashboard/lib/email.js
Original file line number Diff line number Diff line change
@@ -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(/=+$/, '');
}
139 changes: 139 additions & 0 deletions dashboard/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
65 changes: 65 additions & 0 deletions dashboard/scripts/send-test-email.js
Original file line number Diff line number Diff line change
@@ -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);
});
37 changes: 37 additions & 0 deletions dashboard/src/email.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
23 changes: 12 additions & 11 deletions feature-list/feature-list.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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.