Skip to content

Commit

Permalink
added send test email form
Browse files Browse the repository at this point in the history
fomalhautb committed Dec 19, 2024
1 parent ab5dcd1 commit 09399ff
Showing 6 changed files with 116 additions and 17 deletions.
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { isSecureEmailPort, sendEmail } from "@/lib/emails";
import { isSecureEmailPort, sendEmailWithKnownErrorTypes } from "@/lib/emails";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { apiKeysCreateOutputSchema } from "@stackframe/stack-shared/dist/interface/crud/api-keys";
import * as schemaFields from "@stackframe/stack-shared/dist/schema-fields";
import { adaptSchema, adminAuthTypeSchema, emailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { adaptSchema, adminAuthTypeSchema, emailSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";

export const POST = createSmartRouteHandler({
metadata: {
@@ -29,10 +28,13 @@ export const POST = createSmartRouteHandler({
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: apiKeysCreateOutputSchema.defined(),
body: yupObject({
success: yupBoolean().defined(),
error_message: yupString().optional(),
}).defined(),
}),
handler: async ({ body }) => {
await sendEmail({
const result = await sendEmailWithKnownErrorTypes({
emailConfig: {
type: 'standard',
host: body.email_config.host,
@@ -51,7 +53,10 @@ export const POST = createSmartRouteHandler({
return {
statusCode: 200,
bodyType: 'json',
body: {},
body: {
success: result.status === 'ok',
error_message: result.status === 'error' ? result.error.message : undefined,
},
};
},
});
8 changes: 4 additions & 4 deletions apps/backend/src/lib/emails.tsx
Original file line number Diff line number Diff line change
@@ -111,7 +111,7 @@ export async function sendEmailWithKnownErrorTypes(options: SendEmailOptions): P
rawError: error,
errorType: 'HOST_NOT_FOUND',
retryable: false,
message: 'The email host is not found. Please make sure the host exists.'
message: 'The email host is not found. Please make sure the email host configuration is correct.'
});
}

@@ -120,7 +120,7 @@ export async function sendEmailWithKnownErrorTypes(options: SendEmailOptions): P
rawError: error,
errorType: 'AUTH_FAILED',
retryable: false,
message: 'The email server authentication failed. Please check your email credentials.',
message: 'The email server authentication failed. Please check your email credentials configuration.',
});
}

@@ -138,7 +138,7 @@ export async function sendEmailWithKnownErrorTypes(options: SendEmailOptions): P
rawError: error,
errorType: 'INVALID_EMAIL_ADDRESS',
retryable: false,
message: 'The email address provided is invalid. Please verify both the recipient and sender email addresses are correct.' + getServerResponse(error),
message: 'The email address provided is invalid. Please verify both the recipient and sender email addresses configuration are correct.' + getServerResponse(error),
});
}

@@ -147,7 +147,7 @@ export async function sendEmailWithKnownErrorTypes(options: SendEmailOptions): P
rawError: error,
errorType: 'SOCKET_CLOSED',
retryable: false,
message: 'Connection to email server was lost unexpectedly. This could be due to incorrect port configuration or a temporary network issue. Please verify your email settings and try again.',
message: 'Connection to email server was lost unexpectedly. This could be due to incorrect email server port configuration or a temporary network issue. Please verify your configuration and try again.',
});
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { FormDialog, SmartFormDialog } from "@/components/form-dialog";
import { FormDialog } from "@/components/form-dialog";
import { InputField, SelectField } from "@/components/form-fields";
import { useRouter } from "@/components/router";
import { SettingCard, SettingText } from "@/components/settings";
@@ -9,7 +9,8 @@ import { Reader } from "@stackframe/stack-emails/dist/editor/email-builder/index
import { EMAIL_TEMPLATES_METADATA, convertEmailSubjectVariables, convertEmailTemplateMetadataExampleValues, convertEmailTemplateVariables, validateEmailTemplateContent } from "@stackframe/stack-emails/dist/utils";
import { EmailTemplateType } from "@stackframe/stack-shared/dist/interface/crud/email-templates";
import { strictEmailSchema } from "@stackframe/stack-shared/dist/schema-fields";
import { ActionCell, ActionDialog, Button, Card, SimpleTooltip, Typography, useToast } from "@stackframe/stack-ui";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { ActionCell, ActionDialog, Alert, Button, Card, SimpleTooltip, Typography, useToast } from "@stackframe/stack-ui";
import { useMemo, useState } from "react";
import * as yup from "yup";
import { PageLayout } from "../page-layout";
@@ -241,22 +242,47 @@ function TestSendingDialog(props: {
const stackAdminApp = useAdminApp();
const project = stackAdminApp.useProject();
const { toast } = useToast();
const [error, setError] = useState<string | null>(null);

return <SmartFormDialog
return <FormDialog
trigger={props.trigger}
title="Send A Test Email"
formSchema={yup.object({
email: yup.string().email().defined().label("Recipient email address")
})}
okButton={{ label: "Send" }}
onSubmit={async (values) => {
toast({
title: "Email sent",
description: `The test email has been sent to ${values.email}. Please check your inbox.`,
variant: 'success',
const emailConfig = project.config.emailConfig || throwErr("Email config is not set");
if (emailConfig.type === 'shared') throwErr("Shared email server cannot be used for testing");

const result = await stackAdminApp.sendTestEmail({
recipientEmail: values.email,
emailConfig: emailConfig,
});

if (result.status === 'ok') {
toast({
title: "Email sent",
description: `The test email has been sent to ${values.email}. Please check your inbox.`,
variant: 'success',
});
} else {
setError(result.error.errorMessage);
return 'prevent-close';
}
}}
cancelButton
onFormChange={(form) => {
if (form.getValues('email')) {
setError(null);
}
}}
render={(form) => (
<>
<InputField label="Email" name="email" control={form.control} type="email" required/>
{error && <Alert variant="destructive">{error}</Alert>}
</>
)}
/>;
}

5 changes: 5 additions & 0 deletions apps/dashboard/src/components/form-dialog.tsx
Original file line number Diff line number Diff line change
@@ -56,6 +56,7 @@ export function FormDialog<F extends FieldValues>(
onSubmit: (values: F) => Promise<void | 'prevent-close'> | void | 'prevent-close',
render: (form: ReturnType<typeof useForm<F>>) => React.ReactNode,
formSchema: yup.ObjectSchema<F>,
onFormChange?: (form: ReturnType<typeof useForm<F>>) => void,
}
) {
const formId = useId();
@@ -87,6 +88,10 @@ export function FormDialog<F extends FieldValues>(
form.reset(props.defaultValues);
}, [props.defaultValues, form]);

useEffect(() => {
props.onFormChange?.(form);
}, [form, props, form.watch]);

return (
<ActionDialog
{...props}
21 changes: 21 additions & 0 deletions packages/stack-shared/src/interface/adminInterface.ts
Original file line number Diff line number Diff line change
@@ -215,4 +215,25 @@ export class StackAdminInterface extends StackServerInterface {
null,
);
}

async sendTestEmail(data: {
recipient_email: string,
email_config: {
host: string,
port: number,
username: string,
password: string,
sender_email: string,
sender_name: string,
},
}): Promise<{ success: boolean, error_message?: string }> {
const response = await this.sendAdminRequest(`/internal/send-test-email`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(data),
}, null);
return await response.json();
}
}
42 changes: 42 additions & 0 deletions packages/stack/src/lib/stack-app.ts
Original file line number Diff line number Diff line change
@@ -2518,6 +2518,36 @@ class _StackAdminAppImpl<HasTokenStore extends boolean, ProjectId extends string
protected async _refreshApiKeys() {
await this._apiKeysCache.refresh([]);
}

async sendTestEmail(options: {
recipientEmail: string,
emailConfig: {
host: string,
port: number,
username: string,
password: string,
senderEmail: string,
senderName: string,
},
}): Promise<Result<undefined, { errorMessage: string }>> {
const response = await this._interface.sendTestEmail({
recipient_email: options.recipientEmail,
email_config: {
host: options.emailConfig.host,
port: options.emailConfig.port,
username: options.emailConfig.username,
password: options.emailConfig.password,
sender_email: options.emailConfig.senderEmail,
sender_name: options.emailConfig.senderName,
},
});

if (response.success) {
return Result.ok(undefined);
} else {
return Result.error({ errorMessage: response.error_message ?? throwErr("Email test error not specified") });
}
}
}

type _______________CONTACT_CHANNEL_______________ = never; // this is a marker for VSCode's outline view
@@ -3410,6 +3440,18 @@ export type StackAdminApp<HasTokenStore extends boolean = boolean, ProjectId ext
deleteTeamPermissionDefinition(permissionId: string): Promise<void>,

useSvixToken(): string,

sendTestEmail(options: {
recipientEmail: string,
emailConfig: {
host: string,
port: number,
username: string,
password: string,
senderEmail: string,
senderName: string,
},
}): Promise<Result<undefined, { errorMessage: string }>>,
}
& StackServerApp<HasTokenStore, ProjectId>
);

0 comments on commit 09399ff

Please sign in to comment.