Test Email Sending: Save email content to local .wrangler directory instead of system tmp directory
#13809
Replies: 4 comments 1 reply
|
Thanks for the feature request! Just to understand this more, do you want:
or both? |
|
Same. There's nothing I can listen to right now that deterministically gives me access to emails for a particular wrangler process. Here is my current watcher code, in case it inspires solutions. This is basically watching the If wrangler had more of a first class interface for watching emails, that would be helpful. Websocket, or queue accessible by HTTP. /**
* Service watcher for integration tests.
* Uses EventEmitter pattern with FSWatcher to observe email files.
* Assumes services are started via wireit dependencies.
*/
import {EventEmitter} from 'node:events'
import {readFile, readdir, mkdir, stat} from 'fs/promises'
import {watch, type FSWatcher} from 'node:fs'
import {setTimeout, clearTimeout} from 'node:timers'
import {SERVICE_REGISTRY} from '../generated-service-map.js'
// Locate the running worker's send_email directory. miniflare persists each
// `wrangler dev` under /tmp/miniflare-<hex>, and the send_email binding writes to
// `<dir>/email/email/<uuid>.eml` — the plugin mkdir's `<dir>/email` at startup and
// its worker PUTs under a further `email/` prefix. A single dev spins up several
// miniflare dirs (workflows, etc.), so pick the one carrying the eagerly-created
// `email/` marker rather than the newest by mtime; break ties by mtime.
async function findEmailDir(): Promise<string | null> {
const entries = await readdir('/tmp', {withFileTypes: true})
const miniflareDirs = entries.filter(e => e.isDirectory() && e.name.startsWith('miniflare-'))
const withEmail: Array<{path: string; mtime: number}> = []
for (const entry of miniflareDirs) {
const root = `/tmp/${entry.name}`
try {
const stats = await stat(`${root}/email`)
if (stats.isDirectory()) withEmail.push({path: root, mtime: stats.mtime.getTime()})
} catch {
// Not the send_email instance — skip
}
}
if (withEmail.length === 0) return null
withEmail.sort((a, b) => b.mtime - a.mtime)
return `${withEmail[0].path}/email/email`
}
export interface Email {
path: string
content: string
}
const instances = new Map<string, ServiceWatcher>()
/**
* Get or create a ServiceWatcher for the given service.
* Returns singleton instance per service name.
*/
export function watchService(serviceName: string): ServiceWatcher {
if (!instances.has(serviceName)) {
instances.set(serviceName, new ServiceWatcher(serviceName))
}
return instances.get(serviceName)!
}
export class ServiceWatcher extends EventEmitter {
private serviceName: string
private baseUrl: string
private isReady = false
private isStarting = false
private isWatching = false
private waitingForWatcher = false
private emailDir: string | null = null
private watcher: FSWatcher | null = null
constructor(serviceName: string) {
super()
this.serviceName = serviceName
const env = process.env.NODE_ENV || 'development'
const registry = SERVICE_REGISTRY[env]
if (!registry || !registry[serviceName]) {
throw new Error(`Service '${serviceName}' not found in registry for env '${env}'`)
}
this.baseUrl = registry[serviceName]
}
/**
* Wait for service HTTP endpoint to be ready.
* Safe to call multiple times - returns immediately if already ready.
*/
async ready(timeoutMs = 30000, intervalMs = 100): Promise<void> {
if (this.isReady) return
if (this.isStarting) return new Promise(
resolve => this.once('ready', resolve)
)
this.isStarting = true
const start = Date.now()
while (Date.now() - start < timeoutMs) {
try {
const response = await fetch(this.baseUrl)
if (response) {
this.isReady = true
this.emit('ready')
return
}
} catch {
// Connection refused, keep trying
}
await new Promise(resolve => setTimeout(resolve, intervalMs))
}
throw new Error(`Service '${this.serviceName}' did not become ready within ${timeoutMs}ms`)
}
/**
* Find newest miniflare directory and start watching its email folder.
* Lazily initialized on first call, cached for subsequent calls.
*/
private async getEmailWatcher(): Promise<void> {
if (this.isWatching) return
if (this.waitingForWatcher) return new Promise(
resolve => this.once('watching', resolve)
)
this.waitingForWatcher = true
const emailDir = await findEmailDir()
if (!emailDir) throw new Error('No miniflare send_email directory found')
this.emailDir = emailDir
// Ensure email directory exists
await mkdir(this.emailDir, {recursive: true})
// eslint-disable-next-line @typescript-eslint/no-misused-promises
this.watcher = watch(this.emailDir, async (eventType, filename) => {
if (eventType === 'rename' && filename?.endsWith('.eml')) {
const fullPath = `${this.emailDir}/${filename}`
try {
const content = await readFile(fullPath, 'utf-8')
this.emit('email', {path: fullPath, content})
} catch {
// File might have been deleted or not ready yet
}
}
})
// Don't keep process alive just for email watching
this.watcher.unref()
this.isWatching = true
this.emit('watching')
}
/**
* Wait for the next email. Auto-unsubscribes after receiving one.
*/
async nextEmail(timeoutMs = 10000): Promise<Email> {
await this.getEmailWatcher()
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.off('email', handler)
reject(new Error(`No email received within ${timeoutMs}ms`))
}, timeoutMs)
const handler = (email: Email) => {
clearTimeout(timeout)
resolve(email)
}
this.once('email', handler)
})
}
/**
* Subscribe to all emails. Returns unsubscribe function.
*/
async eachEmail(callback: (email: Email) => void): Promise<() => void> {
await this.getEmailWatcher()
this.on('email', callback)
return () => this.off('email', callback)
}
/**
* Stop watching for emails.
*/
stop(): void {
this.watcher?.close()
this.watcher = null
}
/**
* Get the base URL for this service
*/
getBaseUrl(): string {
return this.baseUrl
}
} |
|
Hey! In the short/immediate term we're going to move this to the .wrangler/tmp folder to make this a bit easier, and then over the next few weeks we're going to be adding emails to our local explorer, so you'll have a nice UI to see what emails have been sent, but also a local API endpoint at /cdn-cgi/explorer/api/something that you can call to essentially watch this directory. if you've got suggestions/requests for this, please let us know! |
|
With the next release, emails will be stored to a temporary directory in the project root (.wrangler/tmp/... for wrangler and Vite plugin-spawned Miniflare processes), and the console logs will reflect this. For now, the files will also be stored in the system's temp directory as before so pre-existing tools will still work. If you are using a different tool to launch Miniflare, the files will be stored - as before - in the system's temp directory, or you can set the Miniflare option |
Uh oh!
There was an error while loading. Please reload this page.
When testing Email Sending locally (
remote: false) we are wanting to save emails to the project's.wranglerdirectory instead of the systemtmpdirectory where the path is quite obscure (macOS here).This would unlock automated testing, allowing us to check email contents for auth links/etc., and it would replace a few other tools we use like Nodemailer, InBucket, Mailpit.
Currently documented here:
https://developers.cloudflare.com/email-service/local-development/sending/#testing-locally
Related request for Local Explorer:
#13648
All reactions