Skip to content

Commit 36006d6

Browse files
committed
continue to improve testing infra
1 parent 7f63aa1 commit 36006d6

2 files changed

Lines changed: 144 additions & 0 deletions

File tree

‎docs/ADMIN_TEST_EMAIL_RESEARCH.md‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Admin Test Email: Research Notes
2+
3+
Goal: be able to send exactly one real APOD email, to the admin only, from production, to check how it renders in an inbox. Chosen approach: cron-style route triggered from `gcloud`, not a public endpoint.
4+
5+
**Status: not implemented.** The edit was blocked by the auto-mode classifier in the session where this was researched. Nothing below has been applied.
6+
7+
## What exists today
8+
9+
- `src/routes.js`
10+
- `GET /dailyemail/:year/:startMonth/:endMonth` is guarded by `if (!req.get('X-AppEngine-Cron')) return 403`. App Engine sets that header only for its own cron requests and strips it from external traffic. The handler calls `emailService.enqueueEmails(...)`.
11+
- `POST /emailqueue` is guarded by `X-AppEngine-TaskName`. It is the Cloud Tasks worker.
12+
- `GET /testapod?email=` returns 403 when `NODE_ENV === 'production'`, and the code that would enqueue a task is commented out. It can only render a preview, so it is no help in prod.
13+
- `src/services/emailService.js`: `enqueueEmails(workerUrlBase, year, startMonth, endMonth)` fetches the APOD, adds UTM params to links, then loops over `db.getUsersByDateRange(...)`. For each user it personalizes `{{email}}`, appends the GA4 open-tracking pixel, sets the `List-Unsubscribe` header, and enqueues a task for the `mailer` service (`/emailqueue`).
14+
- `app.yaml` already sets `ADMIN_EMAIL: ${ADMIN_EMAIL}` in `env_variables`. `routes.js` already uses it for unsubscribe-feedback mail.
15+
- `cron.yaml` has one entry per date range for the daily send (~10:00 to 12:04 America/Chicago).
16+
17+
## Why not a normal "back door"
18+
19+
An unauthenticated or token-in-URL route that sends mail in production is an attack surface (spam relay, abuse of the mailer quota). The classifier flagged it for the same reason. The design below avoids that.
20+
21+
## Proposed design
22+
23+
1. **`emailService.enqueueEmails`**: add an optional 5th parameter `options = {}`. When `options.recipients` (array of emails) is set, skip `db.getUsersByDateRange` and use `recipients.map(email => ({ email }))`. Everything else (UTM links, tracking pixel, unsubscribe header, task creation) stays identical, so the test send exercises the real pipeline.
24+
2. **`routes.js`**: add
25+
```js
26+
router.get('/dailyemail/test', async (req, res) => {
27+
if (!req.get('X-AppEngine-Cron')) return res.status(403).send('Forbidden');
28+
const adminEmail = process.env.ADMIN_EMAIL;
29+
if (!adminEmail || !validator.isEmail(adminEmail)) return res.status(404).send('Not found');
30+
const count = await emailService.enqueueEmails(null, null, null, null, { recipients: [adminEmail] });
31+
res.send(`Enqueued ${count} test task.`);
32+
});
33+
```
34+
Register it **before** `/dailyemail/:year/:startMonth/:endMonth`. `test` has only one path segment so it would not match the 3-segment route anyway, but keeping the order explicit is safer.
35+
3. **Safety properties**
36+
- Same guard as the existing cron routes, so it is unreachable from the public internet.
37+
- The recipient is never read from the request, so it cannot mail anyone else.
38+
- 404 if `ADMIN_EMAIL` is unset.
39+
4. **Tests** (vitest + supertest are already set up): 403 without the cron header; 200 with the header and a single enqueued task addressed to `ADMIN_EMAIL`; 404 when `ADMIN_EMAIL` is unset; query/body `email` is ignored; `enqueueEmails` with `recipients` does not call `db.getUsersByDateRange`.
40+
41+
## Triggering from gcloud
42+
43+
`curl` will not work because the cron header cannot be forged from outside. Options:
44+
- **Cloud Scheduler job with an App Engine HTTP target** pointed at `/dailyemail/test`, left paused, then run on demand:
45+
```bash
46+
gcloud scheduler jobs create app-engine apod-admin-test \
47+
--schedule="0 0 1 1 *" --relative-url="/dailyemail/test" --http-method=GET \
48+
--service=default --location=<region>
49+
gcloud scheduler jobs pause apod-admin-test
50+
gcloud scheduler jobs run apod-admin-test
51+
```
52+
(Verify the flags against the current `gcloud scheduler jobs create app-engine` docs and the project's region/service name before relying on this.)
53+
- Or add a `/dailyemail/test` entry to `cron.yaml` and run it from the App Engine cron console. This is simpler, but `cron.yaml` entries are scheduled, so pick a rarely-hit schedule.
54+
55+
## Alternative with no production code change
56+
57+
A one-off local script that runs `fetchAPOD()` and creates a single Cloud Task for the `mailer` service addressed to the admin, using your own gcloud credentials. It does not touch the request path.
58+
59+
## Local preview (no email)
60+
61+
```bash
62+
node -e "require('./src/services/apodService').fetchAPOD().then(r=>require('fs').writeFileSync('email_preview.html', r.html.replace('{{email}}','test@example.com')))" && open email_preview.html
63+
```
64+
`preview_apod_email.js` fetches past dates, which no longer works now that NASA only serves today's APOD. Use the saved pages in `test/fixtures/` for video-day previews.
65+
66+
## Related work in the same session (uncommitted)
67+
68+
NASA moved APOD: `apod.nasa.gov/apod/apYYMMDD.html` now 301-redirects to `https://science.nasa.gov/apod/`, which broke the scraper. `src/services/apodScraper.js` gained `parseScienceNasaLayout` for the new page (with the old parser as fallback), plus tests and fixtures (`test/fixtures/science-nasa-apod*.html`). Past dates cannot be fetched from the new site, so only today's APOD is scraped.

‎src/services/apodScraper.js‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,65 @@ function findImageElement($) {
7979
return { el, metaSrc };
8080
}
8181

82+
/**
83+
* Parses the current science.nasa.gov/apod layout. apod.nasa.gov now
84+
* 301-redirects (for every apYYMMDD.html URL) to https://science.nasa.gov/apod/,
85+
* which embeds today's APOD in a ".smd-embed-post__article" block. Returns
86+
* null when that block is absent so the caller can fall back to the legacy
87+
* apod.nasa.gov parser.
88+
*/
89+
function parseScienceNasaLayout($) {
90+
// The /apod/ landing page wraps the hero in an embed article (with an
91+
// extra "Astronomy Picture of the Day" h1); per-day article pages use the
92+
// bare hero block with the title as its h1.
93+
const embed = $('.smd-embed-post__article').first();
94+
const root = embed.length ? embed : $('.hds-media-detail-hero').first();
95+
const media = root.find('.media-detail-hero__media').first();
96+
const desc = root.find('.media-detail-hero__description').first();
97+
if (!root.length || !media.length || !desc.length) return null;
98+
99+
const title = (embed.length ? root.find('h2') : root.find('h1')).first().text().trim();
100+
101+
// Explanation: drop the "Explanation:" label and the trailing
102+
// "Your Sky Surprise / Tomorrow's picture" boilerplate.
103+
let explanation = (desc.html() || '')
104+
.replace(/<br\s*\/?>\s*<br\s*\/?>\s*<strong>\s*Your Sky Surprise[\s\S]*$/i, '')
105+
.replace(/^\s*<strong>\s*Explanation:\s*<\/strong>\s*/i, '')
106+
.trim();
107+
explanation = explanation
108+
.replace(/href="\/(?!\/)/g, 'href="https://science.nasa.gov/')
109+
// bare relative archive links (ap120209.html) -> absolute
110+
.replace(/href="(?!https?:|mailto:|\/|#)/gi, 'href="https://apod.nasa.gov/apod/');
111+
112+
// Meta table rows: Date / Credit & Copyright / ...
113+
const meta = {};
114+
root.find('.media-detail-hero__meta-row').each((_, row) => {
115+
const key = $(row).find('th').text().trim().toLowerCase();
116+
meta[key.startsWith('credit') ? 'credit' : key] = $(row).find('td').text().replace(/\s+/g, ' ').trim();
117+
});
118+
const parsedDate = meta['date'] ? DateTime.fromFormat(meta['date'], 'LLLL d, yyyy') : null;
119+
120+
const imageUrl = absolutizeApodUrl(media.find('img').first().attr('src'))
121+
|| absolutizeApodUrl($('meta[property="og:image"]').attr('content'));
122+
123+
const rawVideo = media.find('iframe').first().attr('src')
124+
|| media.find('video source').first().attr('src')
125+
|| media.find('video').first().attr('src')
126+
|| media.find('embed').first().attr('src');
127+
const videoUrl = rawVideo && isKnownVideoSource(absolutizeApodUrl(rawVideo))
128+
? absolutizeApodUrl(rawVideo)
129+
: undefined;
130+
131+
return {
132+
title,
133+
explanation,
134+
date: parsedDate && parsedDate.isValid ? parsedDate.toISODate() : undefined,
135+
imageUrl,
136+
videoUrl,
137+
copyright: meta['credit'] || undefined
138+
};
139+
}
140+
82141
/**
83142
* Fetches and parses APOD data for a specific date.
84143
* @param {Date|string} dateObj - The date to fetch.
@@ -92,6 +151,7 @@ async function getDataByDate(dateObj) {
92151
console.log(`fetching ${url}`);
93152

94153
try {
154+
// apod.nasa.gov redirects to science.nasa.gov/apod/ (axios follows it)
95155
const response = await axios.get(url, { responseType: 'arraybuffer' });
96156
const buffer = response.data;
97157

@@ -106,6 +166,22 @@ async function getDataByDate(dateObj) {
106166
}
107167

108168
const $ = cheerio.load(html);
169+
170+
const modern = parseScienceNasaLayout($);
171+
if (modern) {
172+
const media_type = modern.videoUrl ? 'video' : (modern.imageUrl ? 'image' : 'other');
173+
return {
174+
title: modern.title,
175+
explanation: modern.explanation,
176+
date: modern.date || date.toISODate(),
177+
hdurl: media_type === 'video' ? modern.videoUrl : modern.imageUrl,
178+
url: media_type === 'video' ? modern.videoUrl : modern.imageUrl,
179+
media_type,
180+
copyright: modern.copyright,
181+
service_version: 'v1'
182+
};
183+
}
184+
109185
const body = $('body').text(); // For regex searches on full text if needed
110186

111187
// Title extraction logic based on reference

0 commit comments

Comments
 (0)