From a35b1ca7d488aa54d8f540c9625f6fa2b927d05a Mon Sep 17 00:00:00 2001 From: Drew Harris Date: Tue, 26 May 2026 16:04:55 -0700 Subject: [PATCH 1/6] email cli --- .../cli/src/commands/auth/email/pull.ts | 144 ++++++++++++++++ .../cli/src/commands/auth/email/push.ts | 85 ++++++++++ .../cli/src/commands/auth/email/resend.ts | 8 + .../cli/src/commands/auth/email/reset.ts | 35 ++++ .../cli/src/commands/auth/email/status.ts | 154 ++++++++++++++++++ .../cli/src/commands/auth/email/verify.ts | 20 +++ client/packages/cli/src/commands/info.ts | 2 +- client/packages/cli/src/commands/pull.ts | 3 +- client/packages/cli/src/index.ts | 152 ++++++++++++++++- client/packages/cli/src/lib/email.ts | 93 +++++++++++ client/packages/cli/src/old.js | 12 ++ .../cli/src/util/findConfigCandidates.ts | 62 +++++++ client/packages/cli/src/util/getAppName.ts | 14 ++ 13 files changed, 780 insertions(+), 4 deletions(-) create mode 100644 client/packages/cli/src/commands/auth/email/pull.ts create mode 100644 client/packages/cli/src/commands/auth/email/push.ts create mode 100644 client/packages/cli/src/commands/auth/email/resend.ts create mode 100644 client/packages/cli/src/commands/auth/email/reset.ts create mode 100644 client/packages/cli/src/commands/auth/email/status.ts create mode 100644 client/packages/cli/src/commands/auth/email/verify.ts create mode 100644 client/packages/cli/src/lib/email.ts create mode 100644 client/packages/cli/src/util/getAppName.ts diff --git a/client/packages/cli/src/commands/auth/email/pull.ts b/client/packages/cli/src/commands/auth/email/pull.ts new file mode 100644 index 0000000000..1649072097 --- /dev/null +++ b/client/packages/cli/src/commands/auth/email/pull.ts @@ -0,0 +1,144 @@ +import { HttpClientResponse, Path } from '@effect/platform'; +import { defaultMagicCodeEmailConfig } from '@instantdb/platform'; +import { Effect, Schema } from 'effect'; +import { ProjectInfo } from '../../../context/projectInfo.ts'; +import { BadArgsError } from '../../../errors.ts'; +import { readLocalEmailFile } from '../../../old.js'; +import { UI } from '../../../ui/index.ts'; +import { getEmailPathToWrite } from '../../../util/findConfigCandidates.ts'; +import { writeTypescript } from '../../../lib/pullSchema.ts'; +import { promptOk } from '../../../lib/ui.ts'; +import { getEmailTemplateStatus, type EmailTemplateInfo } from './status.ts'; +import type { authEmailPullDef, OptsFromCommand } from '../../../index.ts'; +import { InstantHttp } from '../../../lib/http.ts'; +import { getAppName } from '../../../util/getAppName.ts'; +import type { EmailConfig } from '../../../lib/email.ts'; + +export const authEmailPullCmd = Effect.fn(function* ( + opts: OptsFromCommand, +) { + yield* pullEmail(opts.file); +}); + +export type EmailConfig = (typeof EmailConfig.Type)['authEmail']; + +type WriteEmailTemplateOpts = { + emailPath?: string; + confirmOverwrite?: boolean; +}; + +const pullEmail = (emailPath?: string) => + Effect.gen(function* () { + yield* Effect.log('Pulling email template...'); + + const info = yield* getEmailTemplateStatus; + + const emailConfig = info + ? infoToEmailConfig(info) + : yield* getDefaultEmailTemplate; + + yield* writeEmailTemplate(emailConfig, { emailPath }); + }); + +export const writeEmailTemplate = ( + emailConfig: EmailConfig, + { emailPath, confirmOverwrite = true }: WriteEmailTemplateOpts = {}, +) => + Effect.gen(function* () { + const prevEmailFile = yield* Effect.tryPromise({ + try: () => readLocalEmailFile(emailPath), + catch: (e) => + BadArgsError.make({ + message: `Error reading local email file: ${e}`, + }), + }); + + const shortEmailPath = + prevEmailFile?.path ?? emailPath ?? getEmailPathToWrite(); + + if (prevEmailFile && confirmOverwrite) { + const shouldContinue = yield* promptOk({ + promptText: `This will overwrite your local ${shortEmailPath} file, OK to proceed?`, + modifyOutput: UI.modifiers.yPadding, + inline: true, + }); + if (!shouldContinue) { + yield* Effect.log('Cancelled email pull'); + return; + } + } + + const path = yield* Path.Path; + const { pkgDir } = yield* ProjectInfo; + const fullEmailPath = shortEmailPath.startsWith('/') + ? shortEmailPath + : path.join(pkgDir, shortEmailPath); + + const typescriptFile = yield* generateEmailTypescriptFile(emailConfig); + + yield* writeTypescript(fullEmailPath, typescriptFile); + yield* Effect.log('Wrote email template to ' + shortEmailPath); + }); + +const DefaultEmailTemplateSchema = Schema.Struct({ + subject: Schema.String, + body: Schema.String, + email: Schema.String.pipe(Schema.NullishOr), +}).pipe(Schema.NullishOr); + +export const getDefaultEmailTemplate = Effect.gen(function* () { + const http = yield* InstantHttp; + const template = yield* http + .get('/dash/default-email-template') + .pipe( + Effect.flatMap( + HttpClientResponse.schemaBodyJson(DefaultEmailTemplateSchema), + ), + ); + + const appName = yield* getAppName; + + return { + subject: template?.subject ?? defaultMagicCodeEmailConfig.authEmail.subject, + senderName: appName, + senderEmail: template?.email ?? undefined, + body: template?.body ?? defaultMagicCodeEmailConfig.authEmail.body, + }; +}); + +const infoToEmailConfig = (info: EmailTemplateInfo) => ({ + subject: info.subject, + senderName: info.name, + senderEmail: info.email ?? undefined, + body: info.body, +}); + +const generateEmailTypescriptFile = Effect.fn(function* ( + emailConfig: EmailConfig, +) { + const senderEmail = emailConfig.senderEmail + ? JSON.stringify(emailConfig.senderEmail) + : 'undefined'; + + return ` + // We provide a few dynamic variables for you to use in your email: + // {code}, the magic code e.g. 123456 + // {app_title}, your app's title, i.e. test-fresh + // {user_email}, the user's email address, e.g. happyuser@gmail.com + // {expiration}, the magic code expiration, e.g. 10 minutes + // Note: {code} is required in both the subject and body. + const email = { + authEmail: { + subject: ${JSON.stringify(emailConfig.subject)}, + senderName: ${JSON.stringify(emailConfig.senderName)}, + senderEmail: ${senderEmail}, + body: ${toTemplateLiteral(emailConfig.body)}, + }, +}; + +export default email; +`; +}); + +const toTemplateLiteral = (value: string) => + `\`${value.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${')}\``; diff --git a/client/packages/cli/src/commands/auth/email/push.ts b/client/packages/cli/src/commands/auth/email/push.ts new file mode 100644 index 0000000000..b6160ca151 --- /dev/null +++ b/client/packages/cli/src/commands/auth/email/push.ts @@ -0,0 +1,85 @@ +import { Effect, Schema } from 'effect'; +import type { authEmailPushDef, OptsFromCommand } from '../../../index.ts'; +import { + getVerification, + readEmailConfig, + sendSenderVerification, +} from '../../../lib/email.ts'; +import { CurrentApp } from '../../../context/currentApp.ts'; +import { InstantHttpAuthed, withCommand } from '../../../lib/http.ts'; +import { + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from '@effect/platform'; +import boxen from 'boxen'; +import { + formatSenderVerificationDnsRecords, + getEmailTemplateStatus, +} from './status.ts'; +import chalk from 'chalk'; + +export const authEmailPushCmd = Effect.fn(function* ( + opts: OptsFromCommand, +) { + const emailConfig = yield* readEmailConfig(opts.file); + const { appId } = yield* CurrentApp; + const http = yield* InstantHttpAuthed; + const authEmail = emailConfig.authEmail; + const senderEmail = authEmail.senderEmail; + + yield* http + .pipe( + withCommand('auth email push'), + HttpClient.mapRequestInputEffect( + HttpClientRequest.bodyJson({ + 'email-type': 'magic-code', + subject: authEmail.subject, + body: authEmail.body, + 'sender-email': senderEmail, + 'sender-name': authEmail.senderName, + }), + ), + ) + .post(`/dash/apps/${appId}/email_templates`) + .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Schema.Any))); + + const info = yield* getEmailTemplateStatus; + + yield* Effect.log( + [ + chalk.green('Email template saved!'), + '', + chalk.bold('Pushed fields:'), + ` Email type: ${chalk.cyan('magic-code')}`, + ` Subject: ${chalk.cyan(authEmail.subject)}`, + ` Sender name: ${chalk.cyan(authEmail.senderName)}`, + ` Sender email: ${chalk.cyan(senderEmail || '(default)')}`, + ` Body: ${chalk.cyan(`${authEmail.body.length} characters`)}`, + ].join('\n'), + ); + + // Check if verification email needs to be sent + + if (info?.verification_verified === false) { + yield* sendSenderVerification; + yield* Effect.log( + boxen( + "We've sent a confirmation email containing a six digit code to verify the sender email address.\nUse instant-cli auth email verify to complete verification.", + { + borderColor: 'yellow', + padding: { right: 1, left: 1 }, + }, + ), + ); + } + + if (emailConfig.authEmail.senderEmail) { + const verification = yield* getVerification; + if (verification.verification) { + yield* Effect.log( + formatSenderVerificationDnsRecords(verification.verification), + ); + } + } +}); diff --git a/client/packages/cli/src/commands/auth/email/resend.ts b/client/packages/cli/src/commands/auth/email/resend.ts new file mode 100644 index 0000000000..006e3fb425 --- /dev/null +++ b/client/packages/cli/src/commands/auth/email/resend.ts @@ -0,0 +1,8 @@ +import { Effect } from 'effect'; +import { sendSenderVerification } from '../../../lib/email.ts'; + +export const resendEmailCmd = Effect.gen(function* () { + yield* sendSenderVerification; + + yield* Effect.log('Verification email re-sent!'); +}); diff --git a/client/packages/cli/src/commands/auth/email/reset.ts b/client/packages/cli/src/commands/auth/email/reset.ts new file mode 100644 index 0000000000..f13f350281 --- /dev/null +++ b/client/packages/cli/src/commands/auth/email/reset.ts @@ -0,0 +1,35 @@ +import { HttpClientRequest, HttpClientResponse } from '@effect/platform'; +import { Effect, Schema } from 'effect'; +import { CurrentApp } from '../../../context/currentApp.ts'; +import { InstantHttpAuthed, withCommand } from '../../../lib/http.ts'; +import { getDefaultEmailTemplate, writeEmailTemplate } from './pull.ts'; +import { getEmailTemplateStatus } from './status.ts'; + +export const authEmailResetCmd = Effect.fn(function* () { + const { appId } = yield* CurrentApp; + const http = (yield* InstantHttpAuthed).pipe(withCommand('auth email reset')); + + const status = yield* getEmailTemplateStatus; + + if (!status) { + yield* Effect.log( + 'No email template configured. Resetting local email template.', + ); + } else { + yield* http + .execute( + HttpClientRequest.del( + `/dash/apps/${appId}/email_templates/${status.id}`, + ), + ) + .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Schema.Any))); + } + + const defaultConfig = yield* getDefaultEmailTemplate; + + yield* writeEmailTemplate(defaultConfig, { + confirmOverwrite: false, + }); + + yield* Effect.log('Email template reset.'); +}); diff --git a/client/packages/cli/src/commands/auth/email/status.ts b/client/packages/cli/src/commands/auth/email/status.ts new file mode 100644 index 0000000000..6d3d6902df --- /dev/null +++ b/client/packages/cli/src/commands/auth/email/status.ts @@ -0,0 +1,154 @@ +import boxen from 'boxen'; +import chalk from 'chalk'; +import { Effect, Option, Schema } from 'effect'; +import { CurrentApp } from '../../../context/currentApp.ts'; +import type { authEmailStatusDef, OptsFromCommand } from '../../../index.ts'; +import { InstantHttpAuthed } from '../../../lib/http.ts'; +import { HttpClientResponse } from '@effect/platform'; +import { getVerification } from '../../../lib/email.ts'; + +const formatValue = (value: string | number | null | undefined) => + value ?? 'n/a'; + +const formatVerified = (value: boolean | null | undefined) => { + if (value === true) { + return chalk.green('verified'); + } + if (value === false) { + return chalk.yellow('pending'); + } + return 'n/a'; +}; + +const formatDnsRecord = (type: string, name: string, value: string) => + [chalk.bold(type), `Name: ${name}`, `Value: ${value}`].join('\n'); + +export const formatSenderVerificationDnsRecords = (verification: { + Confirmed: boolean; + DKIMPendingHost: string; + DKIMPendingTextValue: string; + ReturnPathDomain: string; + ReturnPathDomainCNAMEValue: string; +}) => + boxen( + [ + chalk.bold('Add these DNS records to verify your sender email:'), + '', + formatDnsRecord( + 'TXT', + verification.DKIMPendingHost, + verification.DKIMPendingTextValue, + ), + '', + formatDnsRecord( + 'CNAME', + verification.ReturnPathDomain, + verification.ReturnPathDomainCNAMEValue, + ), + ].join('\n'), + { + borderColor: verification.Confirmed ? 'green' : 'yellow', + padding: { right: 1, left: 1 }, + }, + ); + +export const EmailTemplateInfoSchema = Schema.Struct({ + id: Schema.String, + email: Schema.String.pipe(Schema.NullishOr), + name: Schema.String, + sender_id: Schema.String.pipe(Schema.NullishOr), + app_id: Schema.String, + postmark_id: Schema.Number.pipe(Schema.NullishOr), + verification_verified: Schema.Boolean.pipe(Schema.NullishOr), + verification_id: Schema.String.pipe(Schema.NullishOr), + email_type: Schema.String, + body: Schema.String, + subject: Schema.String, +}); + +export type EmailTemplateInfo = typeof EmailTemplateInfoSchema.Type; + +export const EmailTemplateSchema = Schema.Union( + Schema.Struct({ + info: EmailTemplateInfoSchema.pipe(Schema.NullishOr), + }), + Schema.Null, +); + +export const getEmailTemplateStatus = Effect.gen(function* () { + const { appId } = yield* CurrentApp; + const http = yield* InstantHttpAuthed; + + const app = yield* http + .get(`/dash/apps/${appId}/email_status`) + .pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(EmailTemplateSchema)), + ); + return app?.info; +}); + +export const emailStatusCmd = Effect.fn(function* ( + opts: OptsFromCommand, +) { + const info = yield* getEmailTemplateStatus; + + if (!info) { + yield* Effect.log( + "No custom magic code email associated with this app.\nTo add one, run 'instant-cli auth email pull', edit the file, then run 'instant-cli auth email push'", + ); + return; + } + + const verification = yield* Option.match( + Option.fromNullable(info.sender_id), + { + onNone: () => Effect.succeed(null), + onSome: () => getVerification, + }, + ); + + if (opts.json) { + const fullInfo = verification + ? { + ...info, + verification: verification, + } + : info; + + yield* Effect.log(JSON.stringify(fullInfo, null, 2)); + return; + } + + yield* Effect.log(chalk.cyan('Custom Magic Code Email')); + yield* Effect.log(` Sender name: ${info.name}`); + yield* Effect.log(` Sender email: ${formatValue(info.email)}`); + yield* Effect.log(` Subject: ${info.subject}`); + yield* Effect.log(` Body: ${info.body}`); + + if (verification) { + yield* Effect.log( + '\n' + + boxen( + [ + `Instant verified: ${formatVerified(verification.instant['verified?'])}`, + `Postmark verified: ${formatVerified(verification.verification?.Confirmed)}`, + ].join('\n'), + { + title: 'Custom Sender Verification', + borderColor: + verification.instant['verified?'] && + verification.verification?.Confirmed + ? 'green' + : 'yellow', + padding: { right: 1, left: 1 }, + }, + ), + ); + + if (verification.verification) { + yield* Effect.log( + '\n' + formatSenderVerificationDnsRecords(verification.verification), + ); + } + } +}); diff --git a/client/packages/cli/src/commands/auth/email/verify.ts b/client/packages/cli/src/commands/auth/email/verify.ts new file mode 100644 index 0000000000..75044ca1d3 --- /dev/null +++ b/client/packages/cli/src/commands/auth/email/verify.ts @@ -0,0 +1,20 @@ +import { Effect } from 'effect'; +import type { authEmailVerifyDef, OptsFromCommand } from '../../../index.ts'; +import { getVerification, submitVerification } from '../../../lib/email.ts'; + +export const verifyCmd = Effect.fn(function* ( + code: string, + _opts: OptsFromCommand, +) { + yield* submitVerification(code); + + // get verification status + const verificationInfo = yield* getVerification; + + if ( + verificationInfo.instant['verified?'] && + verificationInfo.verification.Confirmed + ) { + yield* Effect.log('Verification successful for both Postmark and Instant!'); + } +}); diff --git a/client/packages/cli/src/commands/info.ts b/client/packages/cli/src/commands/info.ts index cea53feaab..d091fb0fc2 100644 --- a/client/packages/cli/src/commands/info.ts +++ b/client/packages/cli/src/commands/info.ts @@ -12,7 +12,7 @@ const DashMeResponse = Schema.Struct({ }), }); -const DashAppResponse = Schema.Struct({ +export const DashAppResponse = Schema.Struct({ app: Schema.Struct({ id: Schema.String, title: Schema.String, diff --git a/client/packages/cli/src/commands/pull.ts b/client/packages/cli/src/commands/pull.ts index aed59cbf82..148897491e 100644 --- a/client/packages/cli/src/commands/pull.ts +++ b/client/packages/cli/src/commands/pull.ts @@ -1,6 +1,5 @@ import { Effect } from 'effect'; -import { pullDef } from '../index.ts'; -import type { OptsFromCommand } from '../index.ts'; +import type { OptsFromCommand, pullDef } from '../index.ts'; import { pullSchema } from '../lib/pullSchema.ts'; import { pullPerms } from '../lib/pullPerms.ts'; diff --git a/client/packages/cli/src/index.ts b/client/packages/cli/src/index.ts index a335533e54..be6c5a622d 100644 --- a/client/packages/cli/src/index.ts +++ b/client/packages/cli/src/index.ts @@ -33,6 +33,9 @@ import { authClientUpdateCmd } from './commands/auth/client/update.ts'; import { authOriginListCmd } from './commands/auth/origin/list.ts'; import { authOriginDeleteCmd } from './commands/auth/origin/delete.ts'; import { authOriginAddCmd } from './commands/auth/origin/add.ts'; +import { authEmailPushCmd } from './commands/auth/email/push.ts'; +import { authEmailPullCmd } from './commands/auth/email/pull.ts'; +import { authEmailResetCmd } from './commands/auth/email/reset.ts'; import { link } from './logging.ts'; import { appListCommand } from './commands/app/list.ts'; import { appDeleteCommand } from './commands/app/delete.ts'; @@ -45,6 +48,9 @@ import { webhooksDisableCmd } from './commands/webhooks/disable.ts'; import { webhooksEventsListCmd } from './commands/webhooks/events/list.ts'; import { webhooksEventsPayloadCmd } from './commands/webhooks/events/payload.ts'; import { webhooksEventsResendCmd } from './commands/webhooks/events/resend.ts'; +import { emailStatusCmd } from './commands/auth/email/status.ts'; +import { verifyCmd } from './commands/auth/email/verify.ts'; +import { resendEmailCmd } from './commands/auth/email/resend.ts'; export type OptsFromCommand = C extends Command ? R : never; @@ -634,6 +640,150 @@ export const webhooksEventsPayloadDef = webhooksEvents ); }); +const authEmail = auth + .command('email') + .description('Manage custom magic code email templates'); + +export const authEmailStatusDef = authEmail + .command('status') + .description('Get status for the custom magic code email template') + .option( + '-a --app ', + 'App ID to push email settings to. Defaults to *_INSTANT_APP_ID in .env', + ) + .option('--json', 'Output email status as JSON') + .action((opts) => { + runCommandEffect( + emailStatusCmd(opts).pipe( + Effect.provide( + WithAppLayer({ + appId: opts.app, + coerce: false, + allowAdminToken: true, + }), + ), + ), + ); + }); + +export const authEmailPushDef = authEmail + .command('push') + .description('Push the custom magic code email template.') + .option( + '-a --app ', + 'App ID to push email settings to. Defaults to *_INSTANT_APP_ID in .env', + ) + .option( + '-f --file ', + 'Path to instant.email.ts. Defaults to INSTANT_EMAIL_FILE_PATH or auto-discovery.', + ) + .action((opts) => + runCommandEffect( + authEmailPushCmd({ file: opts.file }).pipe( + Effect.provide( + WithAppLayer({ + coerce: false, + coerceAuth: false, + appId: opts.app, + allowAdminToken: true, + }), + ), + ), + ), + ); + +export const authEmailPullDef = authEmail + .command('pull') + .description('Pull the custom magic code email template.') + .option( + '-a --app ', + 'App ID to pull email settings from. Defaults to *_INSTANT_APP_ID in .env', + ) + .option( + '-f --file ', + 'Path to instant.email.ts. Defaults to INSTANT_EMAIL_FILE_PATH or auto-discovery.', + ) + .action((opts) => + runCommandEffect( + authEmailPullCmd({ file: opts.file }).pipe( + Effect.provide( + WithAppLayer({ + coerce: false, + coerceAuth: false, + appId: opts.app, + allowAdminToken: true, + }), + ), + ), + ), + ); + +export const authEmailResetDef = authEmail + .command('reset') + .description('Delete the custom magic code email template.') + .option( + '-a --app ', + 'App ID to reset email settings for. Defaults to *_INSTANT_APP_ID in .env', + ) + .action((opts) => + runCommandEffect( + authEmailResetCmd().pipe( + Effect.provide( + WithAppLayer({ + coerce: false, + coerceAuth: false, + appId: opts.app, + allowAdminToken: true, + }), + ), + ), + ), + ); + +export const authEmailResendDef = authEmail + .command('resend') + .description('Resend the verification email') + .option( + '-a --app ', + 'App ID to reset email settings for. Defaults to *_INSTANT_APP_ID in .env', + ) + .action(() => { + runCommandEffect( + resendEmailCmd.pipe( + Effect.provide( + WithAppLayer({ + coerce: false, + coerceAuth: false, + allowAdminToken: true, + }), + ), + ), + ); + }); + +export const authEmailVerifyDef = authEmail + .command('verify') + .description('Verify a custom email sender with a magic code') + .argument('', 'The magic code to verify') + .option( + '-a --app ', + 'App ID to reset email settings for. Defaults to *_INSTANT_APP_ID in .env', + ) + .action((code, opts) => { + runCommandEffect( + verifyCmd(code, opts).pipe( + Effect.provide( + WithAppLayer({ + coerce: false, + coerceAuth: false, + appId: opts.app, + allowAdminToken: true, + }), + ), + ), + ); + }); + export const initWithoutFilesDef = program .command('init-without-files') .description('Generate a new app id and admin token pair without any files.') @@ -677,7 +827,7 @@ program export const infoDef = program .command('info') - .description('Display CLI version and login status') + .description('Display CLI version, login status, and app info') .action(async () => { const authLayer = AuthLayerLive({ coerce: false, diff --git a/client/packages/cli/src/lib/email.ts b/client/packages/cli/src/lib/email.ts new file mode 100644 index 0000000000..060313ee03 --- /dev/null +++ b/client/packages/cli/src/lib/email.ts @@ -0,0 +1,93 @@ +import { HttpBody, HttpClientResponse } from '@effect/platform'; +import { Effect, Schema } from 'effect'; +import { CurrentApp } from '../context/currentApp.ts'; +import { BadArgsError } from '../errors.ts'; +import { readLocalEmailFile } from '../old.js'; +import { InstantHttpAuthed } from './http.ts'; + +export const EmailConfig = Schema.Struct({ + authEmail: Schema.Struct({ + subject: Schema.String, + senderName: Schema.String, + senderEmail: Schema.optional(Schema.String), + body: Schema.String, + }), +}); + +export class NoEmailFileFound extends Schema.TaggedError( + 'NoEmailFileFound', +)('NoEmailFileFound', { + message: Schema.String, +}) {} + +export const readEmailConfig = (emailPath?: string) => + Effect.gen(function* () { + const emailFile = yield* Effect.tryPromise({ + try: () => readLocalEmailFile(emailPath), + catch: (e) => + BadArgsError.make({ + message: `Error reading instant.email.ts file: ${e}`, + }), + }); + + if (!emailFile) { + return yield* NoEmailFileFound.make({ + message: + "We couldn't find your `instant.email.ts` file. Make sure it's in the root directory. (Hint: You can use an INSTANT_EMAIL_FILE_PATH environment variable to specify it.)", + }); + } + + return yield* Schema.decodeUnknown(EmailConfig)(emailFile.email).pipe( + Effect.catchTag('ParseError', (e) => + BadArgsError.make({ + message: `Invalid instant.email.ts file: ${e.message}`, + }), + ), + ); + }); + +export const sendSenderVerification = Effect.gen(function* () { + const http = yield* InstantHttpAuthed; + const { appId } = yield* CurrentApp; + yield* http.post(`/dash/apps/${appId}/sender-verification/send-magic-code`); +}); + +export const submitVerification = Effect.fn(function* (code: string) { + const http = yield* InstantHttpAuthed; + const { appId } = yield* CurrentApp; + yield* http.post( + `/dash/apps/${appId}/sender-verification/verify-magic-code`, + { + body: HttpBody.unsafeJson({ code }), + }, + ); +}); + +const VerificationSchema = Schema.Struct({ + instant: Schema.Struct({ + 'verified?': Schema.Boolean, + }), + verification: Schema.Struct({ + Confirmed: Schema.Boolean, + ID: Schema.Number, + EmailAddress: Schema.String, + DKIMHost: Schema.String, + ReturnPathDomain: Schema.String, + DKIMPendingTextValue: Schema.String, + ReturnPathDomainCNAMEValue: Schema.String, + DKIMTextValue: Schema.String, + DKIMPendingHost: Schema.String, + }).pipe(Schema.NullishOr), +}); + +export const getVerification = Effect.gen(function* () { + const http = yield* InstantHttpAuthed; + const { appId } = yield* CurrentApp; + + const response = yield* http + .get(`/dash/apps/${appId}/sender-verification`) + .pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(VerificationSchema)), + ); + return response; +}); diff --git a/client/packages/cli/src/old.js b/client/packages/cli/src/old.js index 258e414492..7f8cd4c84b 100644 --- a/client/packages/cli/src/old.js +++ b/client/packages/cli/src/old.js @@ -6,6 +6,7 @@ import path from 'node:path'; import { UI } from './ui/index.ts'; import { deferred, renderUnwrap } from './ui/lib.ts'; import { + getEmailReadCandidates, getPermsReadCandidates, getSchemaReadCandidates, } from './util/findConfigCandidates.ts'; @@ -420,6 +421,17 @@ export async function readLocalSchemaFile() { return { path: relativePath, schema: res.config }; } +export async function readLocalEmailFile(emailPath) { + const readCandidates = getEmailReadCandidates(emailPath); + const res = await loadConfig({ + sources: readCandidates, + merge: false, + }); + if (!res.config) return; + const relativePath = path.relative(process.cwd(), res.sources[0]); + return { path: relativePath, email: res.config }; +} + export async function readInstantConfigFile() { return ( await loadConfig({ diff --git a/client/packages/cli/src/util/findConfigCandidates.ts b/client/packages/cli/src/util/findConfigCandidates.ts index 7a3fcdb4f2..7ee1578235 100644 --- a/client/packages/cli/src/util/findConfigCandidates.ts +++ b/client/packages/cli/src/util/findConfigCandidates.ts @@ -86,6 +86,16 @@ function getEnvPermsPathWithLogging(): string | undefined { return path; } +function getEnvEmailPathWithLogging(): string | undefined { + const path = process.env.INSTANT_EMAIL_FILE_PATH; + if (path) { + console.log( + `Using INSTANT_EMAIL_FILE_PATH=${chalk.green(process.env.INSTANT_EMAIL_FILE_PATH)}`, + ); + } + return path; +} + export function getSchemaReadCandidates(): ConfigCandidate[] { const existing = getEnvSchemaPathWithLogging(); @@ -177,6 +187,48 @@ export function getPermsReadCandidates(): ConfigCandidate[] { return candidates; } +export function getEmailReadCandidates(emailPath?: string): ConfigCandidate[] { + const existing = emailPath ?? getEnvEmailPathWithLogging(); + if (existing) { + return [{ files: existing, transform: transformImports }]; + } + const extensions = ['ts', 'mts', 'cts', 'js', 'mjs', 'cjs']; + + const candidates: ConfigCandidate[] = []; + + candidates.push({ + files: 'instant.email', + extensions, + transform: transformImports, + }); + + const srcPaths = findPathsRecursive('src', 3, 'instant.email'); + for (const srcPath of srcPaths) { + candidates.push({ + files: srcPath, + extensions, + transform: transformImports, + }); + } + + const libPaths = findPathsRecursive('lib', 2, 'instant.email'); + for (const libPath of libPaths) { + candidates.push({ + files: libPath, + extensions, + transform: transformImports, + }); + } + + candidates.push({ + files: 'app/instant.email', + extensions, + transform: transformImports, + }); + + return candidates; +} + export function getSchemaPathToWrite(existingPath?: string): string { if (existingPath) return existingPath; if (process.env.INSTANT_SCHEMA_FILE_PATH) { @@ -198,3 +250,13 @@ export function getPermsPathToWrite(): string { } return 'instant.perms.ts'; } + +export function getEmailPathToWrite(): string { + if (process.env.INSTANT_EMAIL_FILE_PATH) { + return process.env.INSTANT_EMAIL_FILE_PATH; + } + if (existsSync(join(process.cwd(), 'src'))) { + return join('src', 'instant.email.ts'); + } + return 'instant.email.ts'; +} diff --git a/client/packages/cli/src/util/getAppName.ts b/client/packages/cli/src/util/getAppName.ts new file mode 100644 index 0000000000..5ddd9b3059 --- /dev/null +++ b/client/packages/cli/src/util/getAppName.ts @@ -0,0 +1,14 @@ +import { Effect } from 'effect'; +import { InstantHttpAuthed } from '../lib/http.ts'; +import { CurrentApp } from '../context/currentApp.ts'; +import { HttpClientResponse } from '@effect/platform'; +import { DashAppResponse } from '../commands/info.ts'; + +export const getAppName = Effect.gen(function* () { + const authedHttp = yield* InstantHttpAuthed; + const { appId } = yield* CurrentApp; + const appInfo = yield* authedHttp + .get(`/dash/apps/${appId}`) + .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(DashAppResponse))); + return appInfo.app.title; +}); From 1f092f7b912a1d3e3d28ae18ca6fdc048b0fd169 Mon Sep 17 00:00:00 2001 From: Drew Harris Date: Mon, 1 Jun 2026 13:05:18 -0700 Subject: [PATCH 2/6] fixed --- .../cli/src/commands/auth/email/pull.ts | 22 +++++++++++++------ .../cli/src/commands/auth/email/status.ts | 2 +- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/client/packages/cli/src/commands/auth/email/pull.ts b/client/packages/cli/src/commands/auth/email/pull.ts index 1649072097..53aefed3ad 100644 --- a/client/packages/cli/src/commands/auth/email/pull.ts +++ b/client/packages/cli/src/commands/auth/email/pull.ts @@ -1,5 +1,4 @@ import { HttpClientResponse, Path } from '@effect/platform'; -import { defaultMagicCodeEmailConfig } from '@instantdb/platform'; import { Effect, Schema } from 'effect'; import { ProjectInfo } from '../../../context/projectInfo.ts'; import { BadArgsError } from '../../../errors.ts'; @@ -13,6 +12,7 @@ import type { authEmailPullDef, OptsFromCommand } from '../../../index.ts'; import { InstantHttp } from '../../../lib/http.ts'; import { getAppName } from '../../../util/getAppName.ts'; import type { EmailConfig } from '../../../lib/email.ts'; +import { TaggedError } from 'effect/Schema'; export const authEmailPullCmd = Effect.fn(function* ( opts: OptsFromCommand, @@ -83,8 +83,12 @@ export const writeEmailTemplate = ( const DefaultEmailTemplateSchema = Schema.Struct({ subject: Schema.String, body: Schema.String, - email: Schema.String.pipe(Schema.NullishOr), -}).pipe(Schema.NullishOr); + 'sender-email': Schema.String.pipe(Schema.optional), +}); + +class MissingDefaultTemplateError extends TaggedError( + 'MissingDefaultTemplateError', +)('MissingDefaultTemplateError', {}) {} export const getDefaultEmailTemplate = Effect.gen(function* () { const http = yield* InstantHttp; @@ -98,18 +102,22 @@ export const getDefaultEmailTemplate = Effect.gen(function* () { const appName = yield* getAppName; + if (!template) { + return yield* MissingDefaultTemplateError.make({}); + } + return { - subject: template?.subject ?? defaultMagicCodeEmailConfig.authEmail.subject, + subject: template.subject, senderName: appName, - senderEmail: template?.email ?? undefined, - body: template?.body ?? defaultMagicCodeEmailConfig.authEmail.body, + senderEmail: template['sender-email'], + body: template.body, }; }); const infoToEmailConfig = (info: EmailTemplateInfo) => ({ subject: info.subject, senderName: info.name, - senderEmail: info.email ?? undefined, + senderEmail: info.email, body: info.body, }); diff --git a/client/packages/cli/src/commands/auth/email/status.ts b/client/packages/cli/src/commands/auth/email/status.ts index 6d3d6902df..02f1d022e0 100644 --- a/client/packages/cli/src/commands/auth/email/status.ts +++ b/client/packages/cli/src/commands/auth/email/status.ts @@ -54,7 +54,7 @@ export const formatSenderVerificationDnsRecords = (verification: { export const EmailTemplateInfoSchema = Schema.Struct({ id: Schema.String, - email: Schema.String.pipe(Schema.NullishOr), + email: Schema.String.pipe(Schema.optional), name: Schema.String, sender_id: Schema.String.pipe(Schema.NullishOr), app_id: Schema.String, From b65aa3243ccbd978c546fee7b911ebe20ad7686d Mon Sep 17 00:00:00 2001 From: Drew Harris Date: Mon, 1 Jun 2026 13:41:33 -0700 Subject: [PATCH 3/6] fix error --- client/packages/cli/src/commands/auth/email/pull.ts | 8 -------- client/packages/cli/src/commands/auth/email/verify.ts | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/client/packages/cli/src/commands/auth/email/pull.ts b/client/packages/cli/src/commands/auth/email/pull.ts index 53aefed3ad..9450fe02df 100644 --- a/client/packages/cli/src/commands/auth/email/pull.ts +++ b/client/packages/cli/src/commands/auth/email/pull.ts @@ -86,10 +86,6 @@ const DefaultEmailTemplateSchema = Schema.Struct({ 'sender-email': Schema.String.pipe(Schema.optional), }); -class MissingDefaultTemplateError extends TaggedError( - 'MissingDefaultTemplateError', -)('MissingDefaultTemplateError', {}) {} - export const getDefaultEmailTemplate = Effect.gen(function* () { const http = yield* InstantHttp; const template = yield* http @@ -102,10 +98,6 @@ export const getDefaultEmailTemplate = Effect.gen(function* () { const appName = yield* getAppName; - if (!template) { - return yield* MissingDefaultTemplateError.make({}); - } - return { subject: template.subject, senderName: appName, diff --git a/client/packages/cli/src/commands/auth/email/verify.ts b/client/packages/cli/src/commands/auth/email/verify.ts index 75044ca1d3..3cdb1cc253 100644 --- a/client/packages/cli/src/commands/auth/email/verify.ts +++ b/client/packages/cli/src/commands/auth/email/verify.ts @@ -13,7 +13,7 @@ export const verifyCmd = Effect.fn(function* ( if ( verificationInfo.instant['verified?'] && - verificationInfo.verification.Confirmed + verificationInfo.verification?.Confirmed ) { yield* Effect.log('Verification successful for both Postmark and Instant!'); } From ececd440a4a8aec9a0235ba3e23bfdeef9ac569b Mon Sep 17 00:00:00 2001 From: Drew Harris Date: Mon, 1 Jun 2026 14:01:24 -0700 Subject: [PATCH 4/6] remove unused import --- client/packages/cli/src/commands/auth/email/pull.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/client/packages/cli/src/commands/auth/email/pull.ts b/client/packages/cli/src/commands/auth/email/pull.ts index 9450fe02df..837581a40b 100644 --- a/client/packages/cli/src/commands/auth/email/pull.ts +++ b/client/packages/cli/src/commands/auth/email/pull.ts @@ -12,7 +12,6 @@ import type { authEmailPullDef, OptsFromCommand } from '../../../index.ts'; import { InstantHttp } from '../../../lib/http.ts'; import { getAppName } from '../../../util/getAppName.ts'; import type { EmailConfig } from '../../../lib/email.ts'; -import { TaggedError } from 'effect/Schema'; export const authEmailPullCmd = Effect.fn(function* ( opts: OptsFromCommand, From 80f08e3dbc6053235ae8b5f6e2b18a38fa1a7b92 Mon Sep 17 00:00:00 2001 From: Drew Harris Date: Mon, 1 Jun 2026 15:17:30 -0700 Subject: [PATCH 5/6] message to push cleared template --- client/packages/cli/src/commands/auth/email/reset.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/packages/cli/src/commands/auth/email/reset.ts b/client/packages/cli/src/commands/auth/email/reset.ts index f13f350281..9d588963fd 100644 --- a/client/packages/cli/src/commands/auth/email/reset.ts +++ b/client/packages/cli/src/commands/auth/email/reset.ts @@ -31,5 +31,7 @@ export const authEmailResetCmd = Effect.fn(function* () { confirmOverwrite: false, }); - yield* Effect.log('Email template reset.'); + yield* Effect.log( + 'instant.email.ts file reset to default. To apply this change, run instant-cli auth email push', + ); }); From a0983a8829317b5ac06e06868552d146feea4420 Mon Sep 17 00:00:00 2001 From: Drew Harris Date: Wed, 10 Jun 2026 11:35:09 -0700 Subject: [PATCH 6/6] bump version to v1.0.47 --- client/packages/version/src/version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/packages/version/src/version.ts b/client/packages/version/src/version.ts index d067925fbc..b327cda086 100644 --- a/client/packages/version/src/version.ts +++ b/client/packages/version/src/version.ts @@ -2,6 +2,6 @@ // Update the version here and merge your code to main to // publish a new version of all of the packages to npm. -const version = 'v1.0.46'; +const version = 'v1.0.47'; export { version };