feat: email api wip (#3391)

This commit is contained in:
Daniel Salazar
2026-07-15 11:30:16 -07:00
committed by GitHub
parent cf34f9fea9
commit 861cf5efb8
10 changed files with 194 additions and 16 deletions
+11
View File
@@ -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",
+30 -12
View File
@@ -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<unknown>;
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> | boolean;
@@ -100,7 +109,8 @@ const DOMAIN_ALIASES: Record<string, string> = {
* - Policy + extensible validation (via `validate`)
*/
export class EmailClient extends PuterClient {
private transport: NodemailerTransport | null = null;
private transport: ReturnType<typeof nodemailer.createTransport> | null =
null;
private compiledTemplates: Partial<
Record<EmailTemplateName, CompiledTemplate>
> = {};
@@ -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<void> {
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 ---------------------------------
/**
+4 -2
View File
@@ -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). */
+1
View File
@@ -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",
+6 -2
View File
@@ -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,
};
}
}
+5
View File
@@ -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,
+2
View File
@@ -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);
+80
View File
@@ -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;
+53
View File
@@ -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<EmailSendResult>;
send (options: EmailSendOptions): Promise<EmailSendResult>;
}
+2
View File
@@ -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;