From 861cf5efb8926db282bc585f891d27faf8dc8ac1 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Wed, 15 Jul 2026 14:30:16 -0400 Subject: [PATCH] feat: email api wip (#3391) --- package-lock.json | 11 ++++ src/backend/clients/email/EmailClient.ts | 42 +++++++++---- src/backend/core/actor.ts | 6 +- src/backend/package.json | 1 + src/backend/services/auth/AuthService.ts | 8 ++- src/puter-js/index.d.ts | 5 ++ src/puter-js/src/index.js | 2 + src/puter-js/src/modules/Email.js | 80 ++++++++++++++++++++++++ src/puter-js/types/modules/email.d.ts | 53 ++++++++++++++++ src/puter-js/types/puter.d.ts | 2 + 10 files changed, 194 insertions(+), 16 deletions(-) create mode 100644 src/puter-js/src/modules/Email.js create mode 100644 src/puter-js/types/modules/email.d.ts diff --git a/package-lock.json b/package-lock.json index ebfb14497..f389e2728 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6482,6 +6482,16 @@ "form-data": "^4.0.4" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/oracledb": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/@types/oracledb/-/oracledb-6.5.2.tgz", @@ -18202,6 +18212,7 @@ "@types/busboy": "^1.5.4", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.0.0", + "@types/nodemailer": "^8.0.1", "@types/pg": "^8.6.1", "@types/validator": "^13.15.10", "chai": "^4.3.7", diff --git a/src/backend/clients/email/EmailClient.ts b/src/backend/clients/email/EmailClient.ts index 2de554741..60faf2116 100644 --- a/src/backend/clients/email/EmailClient.ts +++ b/src/backend/clients/email/EmailClient.ts @@ -24,12 +24,20 @@ import type { IConfig } from '../../types'; import { PuterClient } from '../types'; import { EMAIL_TEMPLATES, type EmailTemplateName } from './templates'; -// -- Types ------------------------------------------------------------ +/** Attachment shape passed through to the underlying transport. */ +export interface EmailAttachment { + filename: string; + content: Buffer | string; + contentType?: string; + encoding?: string; +} -// nodemailer doesn't ship TS types, so declare the subset we use. -interface NodemailerTransport { - sendMail: (options: SendMailOptions) => Promise; - close?: () => void; +/** Subset of the transport's send result callers may care about. */ +export interface SentMessageInfo { + messageId?: string; + accepted?: string[]; + rejected?: string[]; + [key: string]: unknown; } export interface SendMailOptions { @@ -41,6 +49,7 @@ export interface SendMailOptions { html?: string; text?: string; replyTo?: string; + attachments?: EmailAttachment[]; } export type EmailValidator = (email: string) => Promise | boolean; @@ -100,7 +109,8 @@ const DOMAIN_ALIASES: Record = { * - Policy + extensible validation (via `validate`) */ export class EmailClient extends PuterClient { - private transport: NodemailerTransport | null = null; + private transport: ReturnType | null = + null; private compiledTemplates: Partial< Record > = {}; @@ -123,8 +133,7 @@ export class EmailClient extends PuterClient { return; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.transport = nodemailer.createTransport(emailConf as any); + this.transport = nodemailer.createTransport(emailConf); console.log('[email] transport configured'); } @@ -159,21 +168,30 @@ export class EmailClient extends PuterClient { /** * Raw send — bypasses the template system. Useful for one-off * admin emails that don't warrant a named template. + * + * Returns the transport's send result, or `null` when no transport + * is configured (the send is a no-op in that case — callers that + * must not silently drop mail should check `isConfigured` first). */ - async sendRaw(options: SendMailOptions): Promise { + async sendRaw(options: SendMailOptions) { if (!this.transport) { console.warn( '[email] attempted to send email without transport. If you need to send email, configure an SMTP transport in your config file (see docs for details). Email content:', options, ); - return; + return null; } - await this.transport.sendMail({ - from: options.from ?? this.defaultFrom(), + return await this.transport.sendMail({ ...options, + from: options.from ?? this.defaultFrom(), }); } + /** Whether an SMTP transport is configured (sends are no-ops otherwise). */ + get isConfigured(): boolean { + return this.transport !== null; + } + // -- Public API: clean / validate --------------------------------- /** diff --git a/src/backend/core/actor.ts b/src/backend/core/actor.ts index d37c38d65..2eb317010 100644 --- a/src/backend/core/actor.ts +++ b/src/backend/core/actor.ts @@ -54,9 +54,11 @@ export interface Actor { * Session reference when authenticated via a session token (user actors) * or an app-under-user token that carries a session. Absent for system, * raw-app, and pure access-token actors. Used for session introspection - * and targeted logout. + * and targeted logout. `kind` mirrors the session row's kind (e.g. + * 'web', 'app', 'worker') so callers can gate on how the credential + * was minted without an extra session lookup. */ - session?: { uid: string } | null; + session?: { uid: string; kind?: string | null } | null; } /** UUID of the baked-in system user (see 0025 seed migration). */ diff --git a/src/backend/package.json b/src/backend/package.json index 9b09f0741..93b16222f 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -79,6 +79,7 @@ "@types/busboy": "^1.5.4", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.0.0", + "@types/nodemailer": "^8.0.1", "@types/pg": "^8.6.1", "@types/validator": "^13.15.10", "chai": "^4.3.7", diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index 58eeb3cc0..6b4d784fe 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -1967,7 +1967,9 @@ export class AuthService extends PuterService { #buildUserActor(user: UserRow, session: SessionRow | null): Actor { return { user: this.#actorUserFromRow(user), - session: session ? { uid: session.uuid } : null, + session: session + ? { uid: session.uuid, kind: session.kind ?? null } + : null, }; } @@ -1982,7 +1984,9 @@ export class AuthService extends PuterService { uid: app.uid, id: app.id, }, - session: session ? { uid: session.uuid } : null, + session: session + ? { uid: session.uuid, kind: session.kind ?? null } + : null, }; } } diff --git a/src/puter-js/index.d.ts b/src/puter-js/index.d.ts index 81305a798..d30853469 100644 --- a/src/puter-js/index.d.ts +++ b/src/puter-js/index.d.ts @@ -4,6 +4,7 @@ import type { Apps, AppListOptions, AppRecord, CreateAppOptions, UpdateAppAttrib import type { Auth, APIUsage, AllowanceInfo, AppUsage, AuthUser, DetailedAppUsage, MonthlyUsage } from './types/modules/auth.d.ts'; import type { Debug } from './types/modules/debug.d.ts'; import type { Driver, DriverDescriptor, Drivers } from './types/modules/drivers.d.ts'; +import type { Email, EmailAttachment, EmailSendOptions, EmailSendResult } from './types/modules/email.d.ts'; import type { FS, CopyOptions, DeleteOptions, MkdirOptions, MoveOptions, ReadOptions, ReaddirOptions, SignResult, SpaceInfo, UploadOptions, WriteOptions } from './types/modules/filesystem.d.ts'; import type { FSItem, FileSignatureInfo, InternalFSProperties } from './types/modules/fs-item.d.ts'; import type { Hosting, Subdomain } from './types/modules/hosting.d.ts'; @@ -59,6 +60,10 @@ export type { Driver, DriverDescriptor, Drivers, + Email, + EmailAttachment, + EmailSendOptions, + EmailSendResult, FSItem, FilePickerOptions, FileSignatureInfo, diff --git a/src/puter-js/src/index.js b/src/puter-js/src/index.js index 6d69508e2..a8146947d 100644 --- a/src/puter-js/src/index.js +++ b/src/puter-js/src/index.js @@ -10,6 +10,7 @@ import Apps from './modules/Apps.js'; import Auth from './modules/Auth.js'; import { Debug } from './modules/Debug.js'; import Drivers from './modules/Drivers.js'; +import Email from './modules/Email.js'; import { PuterJSFileSystemModule } from './modules/FileSystem/index.js'; import FSItem from './modules/FSItem.js'; import Hosting from './modules/Hosting.js'; @@ -202,6 +203,7 @@ const puterInit = function () { this.registerModule('apps', Apps); this.registerModule('ai', AI); this.registerModule('kv', KV); + this.registerModule('email', Email); this.registerModule('perms', Perms); this.registerModule('drivers', Drivers); this.registerModule('debug', Debug); diff --git a/src/puter-js/src/modules/Email.js b/src/puter-js/src/modules/Email.js new file mode 100644 index 000000000..833abcf86 --- /dev/null +++ b/src/puter-js/src/modules/Email.js @@ -0,0 +1,80 @@ +import * as utils from '../lib/utils.js'; + +/** + * Restricted outbound email (the `puter-email` driver interface). + * + * Every send must be authorized by a worker: either the worker calls + * directly (`me.puter.email.send(...)`), or a user calls with their own + * token and passes the worker's token as `emailAccessToken` — the caller is + * the one billed and rate-limited. In a worker handler: + * + * router.post('/notify', async ({ request, user }) => { + * const { to, subject, text } = await request.json(); + * return await user.puter.email.send({ + * to, subject, text, + * emailAccessToken: me.puter.authToken, + * // Inline or Puter-FS attachments: + * attachments: [ + * { filename, content, contentType }, // content = base64 + * { path: '~/Documents/report.pdf' }, // read server-side + * ], + * }); + * }); + * + * Positional form: `await puter.email.send(to, subject, body)`. + */ +class Email { + /** + * @class + * @param {object} puter - The parent puter instance. + */ + constructor(puter) { + this.puter = puter; + this.authToken = puter.authToken; + this.APIOrigin = puter.APIOrigin; + this.appID = puter.appID; + } + + /** + * Sets a new authentication token. + * + * @param {string} authToken - The new authentication token. + * @returns {void} + */ + setAuthToken(authToken) { + this.authToken = authToken; + } + + /** + * Sets the API origin. + * + * @param {string} APIOrigin - The new API origin. + * @returns {void} + */ + setAPIOrigin(APIOrigin) { + this.APIOrigin = APIOrigin; + } + + send = utils.make_driver_method( + ['to', 'subject', 'body'], + 'puter-email', + undefined, + 'send', + { + preprocess: (args) => { + // `body` is positional-call sugar for `text`. + if ( + args.body !== undefined && + args.text === undefined && + args.html === undefined + ) { + args.text = args.body; + } + delete args.body; + return args; + }, + }, + ); +} + +export default Email; diff --git a/src/puter-js/types/modules/email.d.ts b/src/puter-js/types/modules/email.d.ts new file mode 100644 index 000000000..50435a763 --- /dev/null +++ b/src/puter-js/types/modules/email.d.ts @@ -0,0 +1,53 @@ +/** + * One attachment: either inline base64 `content`, or a Puter FS reference + * (`path`/`uid`) read server-side with the caller's — falling back to the + * authorizing worker's — file permissions. + */ +export interface EmailAttachment { + /** Required with `content`; defaults to the file's name for FS refs. */ + filename?: string; + /** Base64 file body. Mutually exclusive with `path`/`uid`. */ + content?: string; + /** Puter FS path (supports `~/`). Mutually exclusive with `content`. */ + path?: string; + /** Puter FS entry uid. Mutually exclusive with `content`. */ + uid?: string; + contentType?: string; +} + +export interface EmailSendOptions { + /** Recipient address(es). */ + to: string | string[]; + subject: string; + /** Plain-text body. At least one of `text` / `html` is required. */ + text?: string; + /** HTML body. */ + html?: string; + cc?: string | string[]; + bcc?: string | string[]; + replyTo?: string; + /** + * A worker's auth token authorizing the send when the caller is not + * itself a worker (inside a worker: `me.puter.authToken`). The caller + * stays the billed and rate-limited identity. + */ + emailAccessToken?: string; + attachments?: EmailAttachment[]; +} + +export interface EmailSendResult { + /** Transport message id, when the mail server reports one. */ + messageId: string | null; + /** Total charge for this send, in microcents. */ + cost: number; +} + +/** + * Restricted outbound email. Sending is limited server-side to trusted + * callers (Puter workers owned by allowlisted or permitted users). + */ +export class Email { + /** Sends an email with a plain-text body. */ + send (to: string | string[], subject: string, body: string): Promise; + send (options: EmailSendOptions): Promise; +} diff --git a/src/puter-js/types/puter.d.ts b/src/puter-js/types/puter.d.ts index 61939ea4f..051a075ba 100644 --- a/src/puter-js/types/puter.d.ts +++ b/src/puter-js/types/puter.d.ts @@ -3,6 +3,7 @@ import type { Apps } from './modules/apps.d.ts'; import type { Auth } from './modules/auth.d.ts'; import type { Debug } from './modules/debug.d.ts'; import type { Drivers } from './modules/drivers.d.ts'; +import type { Email } from './modules/email.d.ts'; import type { FS } from './modules/filesystem.d.ts'; import type { FSItem } from './modules/fs-item.d.ts'; import type { Hosting } from './modules/hosting.d.ts'; @@ -53,6 +54,7 @@ export class Puter { ui: UI; hosting: Hosting; kv: KV; + email: Email; perms: Perms; drivers: Drivers; debug: Debug;