This commit is contained in:
parent
773fe1a3ee
commit
42e58fc2fb
5 changed files with 253 additions and 60 deletions
11
.env.example
11
.env.example
|
@ -1,7 +1,4 @@
|
||||||
SMTP_HOST=
|
JMAP_ACCOUNT_ID=""
|
||||||
SMTP_PORT=587
|
JMAP_ACCESS_TOKEN=""
|
||||||
SMTP_SECURE=true
|
FROM_EMAIL=""
|
||||||
SMTP_USER=
|
TO_EMAIL=""
|
||||||
SMTP_PASS=
|
|
||||||
FROM_EMAIL=
|
|
||||||
TO_EMAIL=
|
|
||||||
|
|
|
@ -13,7 +13,6 @@
|
||||||
"@astrojs/solid-js": "^5.0.4",
|
"@astrojs/solid-js": "^5.0.4",
|
||||||
"@astrojs/tailwind": "^5.1.5",
|
"@astrojs/tailwind": "^5.1.5",
|
||||||
"astro": "^5.1.8",
|
"astro": "^5.1.8",
|
||||||
"nodemailer": "^6.9.16",
|
|
||||||
"solid-js": "^1.9.4",
|
"solid-js": "^1.9.4",
|
||||||
"tailwindcss": "^3.0.24"
|
"tailwindcss": "^3.0.24"
|
||||||
},
|
},
|
||||||
|
|
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
|
@ -20,9 +20,6 @@ importers:
|
||||||
astro:
|
astro:
|
||||||
specifier: ^5.1.8
|
specifier: ^5.1.8
|
||||||
version: 5.1.8(jiti@1.21.7)(rollup@4.31.0)(typescript@5.7.3)(yaml@2.7.0)
|
version: 5.1.8(jiti@1.21.7)(rollup@4.31.0)(typescript@5.7.3)(yaml@2.7.0)
|
||||||
nodemailer:
|
|
||||||
specifier: ^6.9.16
|
|
||||||
version: 6.9.16
|
|
||||||
solid-js:
|
solid-js:
|
||||||
specifier: ^1.9.4
|
specifier: ^1.9.4
|
||||||
version: 1.9.4
|
version: 1.9.4
|
||||||
|
@ -1403,10 +1400,6 @@ packages:
|
||||||
node-releases@2.0.19:
|
node-releases@2.0.19:
|
||||||
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
|
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
|
||||||
|
|
||||||
nodemailer@6.9.16:
|
|
||||||
resolution: {integrity: sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==}
|
|
||||||
engines: {node: '>=6.0.0'}
|
|
||||||
|
|
||||||
normalize-path@3.0.0:
|
normalize-path@3.0.0:
|
||||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
@ -3672,8 +3665,6 @@ snapshots:
|
||||||
|
|
||||||
node-releases@2.0.19: {}
|
node-releases@2.0.19: {}
|
||||||
|
|
||||||
nodemailer@6.9.16: {}
|
|
||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
|
|
||||||
normalize-range@0.1.2: {}
|
normalize-range@0.1.2: {}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { Show } from "solid-js/web";
|
||||||
const ContactForm: Component = () => {
|
const ContactForm: Component = () => {
|
||||||
const [email, setEmail] = createSignal("");
|
const [email, setEmail] = createSignal("");
|
||||||
const [message, setMessage] = createSignal("");
|
const [message, setMessage] = createSignal("");
|
||||||
|
const [name, setName] = createSignal("");
|
||||||
const [status, setStatus] = createSignal<
|
const [status, setStatus] = createSignal<
|
||||||
"idle" | "sending" | "success" | "error"
|
"idle" | "sending" | "success" | "error"
|
||||||
>("idle");
|
>("idle");
|
||||||
|
@ -17,35 +18,51 @@ const ContactForm: Component = () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/contact", {
|
const response = await fetch("/api/contact", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: email(),
|
subject: `New Contact Form Message from ${name()}`,
|
||||||
message: message(),
|
message: `From: ${name()}\nEmail: ${email()}\n\nMessage:\n${message()}`,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to send message");
|
throw new Error(data.error || data.message || "Failed to send message");
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
|
setName("");
|
||||||
setEmail("");
|
setEmail("");
|
||||||
setMessage("");
|
setMessage("");
|
||||||
|
|
||||||
// Reset success status after 3 seconds
|
|
||||||
setTimeout(() => setStatus("idle"), 3000);
|
setTimeout(() => setStatus("idle"), 3000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
error instanceof Error ? error.message : "Failed to send message",
|
error instanceof Error ? error.message : "Failed to send message",
|
||||||
);
|
);
|
||||||
|
console.error("Submission error:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form class="space-y-6" onSubmit={handleSubmit}>
|
<form class="space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label for="name" class="label">
|
||||||
|
<span class="label-text text-neutral-content">Name</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
required
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="Your Name"
|
||||||
|
value={name()}
|
||||||
|
onInput={(e) => setName(e.currentTarget.value)}
|
||||||
|
disabled={status() === "sending"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-control w-full">
|
<div class="form-control w-full">
|
||||||
<label for="email" class="label">
|
<label for="email" class="label">
|
||||||
<span class="label-text text-neutral-content">Email</span>
|
<span class="label-text text-neutral-content">Email</span>
|
||||||
|
|
|
@ -1,58 +1,247 @@
|
||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import nodemailer from "nodemailer";
|
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const sendEmailViaJMAP = async ({
|
||||||
host: import.meta.env.SMTP_HOST,
|
subject,
|
||||||
port: parseInt(import.meta.env.SMTP_PORT),
|
message,
|
||||||
secure: import.meta.env.SMTP_SECURE,
|
}: {
|
||||||
auth: {
|
subject: string;
|
||||||
user: import.meta.env.SMTP_USER,
|
message: string;
|
||||||
pass: import.meta.env.SMTP_PASS,
|
}) => {
|
||||||
},
|
const accessToken = import.meta.env.JMAP_ACCESS_TOKEN;
|
||||||
});
|
const accountId = import.meta.env.JMAP_ACCOUNT_ID;
|
||||||
|
const fromEmail = import.meta.env.FROM_EMAIL;
|
||||||
|
const toEmail = import.meta.env.TO_EMAIL;
|
||||||
|
const apiUrl = "https://api.fastmail.com/jmap/api/";
|
||||||
|
|
||||||
|
if (!accessToken || !accountId || !fromEmail || !toEmail) {
|
||||||
|
throw new Error("Missing environment variables configuration");
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = new Headers({
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get email identities
|
||||||
|
const identityRequest = {
|
||||||
|
using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:submission"],
|
||||||
|
methodCalls: [
|
||||||
|
[
|
||||||
|
"Identity/get",
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
properties: ["id", "email", "name"],
|
||||||
|
},
|
||||||
|
"0",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const identityResponse = await fetch(apiUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(identityRequest),
|
||||||
|
});
|
||||||
|
|
||||||
|
const identityData = await identityResponse.json();
|
||||||
|
const identity = identityData.methodResponses[0][1].list.find(
|
||||||
|
(id: any) => id.email === fromEmail,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!identity) {
|
||||||
|
throw new Error(`No identity found for email: ${fromEmail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get drafts mailbox
|
||||||
|
const mailboxRequest = {
|
||||||
|
using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
||||||
|
methodCalls: [
|
||||||
|
[
|
||||||
|
"Mailbox/get",
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
properties: ["id", "role"],
|
||||||
|
},
|
||||||
|
"0",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mailboxResponse = await fetch(apiUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(mailboxRequest),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mailboxData = await mailboxResponse.json();
|
||||||
|
const drafts = mailboxData.methodResponses[0][1].list.find(
|
||||||
|
(box: any) => box.role === "drafts",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!drafts) {
|
||||||
|
throw new Error("Could not find Drafts folder");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create email draft
|
||||||
|
const emailRequest = {
|
||||||
|
using: [
|
||||||
|
"urn:ietf:params:jmap:core",
|
||||||
|
"urn:ietf:params:jmap:mail",
|
||||||
|
"urn:ietf:params:jmap:submission",
|
||||||
|
],
|
||||||
|
methodCalls: [
|
||||||
|
[
|
||||||
|
"Email/set",
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
create: {
|
||||||
|
"draft-1": {
|
||||||
|
mailboxIds: { [drafts.id]: true },
|
||||||
|
from: [{ email: fromEmail, name: identity.name }],
|
||||||
|
to: [{ email: toEmail }],
|
||||||
|
subject,
|
||||||
|
bodyValues: {
|
||||||
|
body1: {
|
||||||
|
value: message,
|
||||||
|
charset: "utf-8",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
textBody: [
|
||||||
|
{
|
||||||
|
partId: "body1",
|
||||||
|
type: "text/plain",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"0",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const emailResponse = await fetch(apiUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(emailRequest),
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailResult = await emailResponse.json();
|
||||||
|
const createdId = emailResult.methodResponses[0][1].created["draft-1"].id;
|
||||||
|
|
||||||
|
// Submit email with identity
|
||||||
|
const submitRequest = {
|
||||||
|
using: [
|
||||||
|
"urn:ietf:params:jmap:core",
|
||||||
|
"urn:ietf:params:jmap:mail",
|
||||||
|
"urn:ietf:params:jmap:submission",
|
||||||
|
],
|
||||||
|
methodCalls: [
|
||||||
|
[
|
||||||
|
"EmailSubmission/set",
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
create: {
|
||||||
|
"submit-1": {
|
||||||
|
emailId: createdId,
|
||||||
|
identityId: identity.id,
|
||||||
|
envelope: {
|
||||||
|
mailFrom: {
|
||||||
|
email: fromEmail,
|
||||||
|
},
|
||||||
|
rcptTo: [{ email: toEmail }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"0",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitResponse = await fetch(apiUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(submitRequest),
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitResult = await submitResponse.json();
|
||||||
|
|
||||||
|
if (!submitResult.methodResponses[0][1].created?.["submit-1"]) {
|
||||||
|
throw new Error(`Submission failed: ${JSON.stringify(submitResult)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteRequest = {
|
||||||
|
using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
||||||
|
methodCalls: [
|
||||||
|
[
|
||||||
|
"Email/set",
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
destroy: [createdId],
|
||||||
|
},
|
||||||
|
"0",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteResponse = await fetch(apiUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(deleteRequest),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteResult = await deleteResponse.json();
|
||||||
|
if (!deleteResult.methodResponses[0][1].destroyed?.includes(createdId)) {
|
||||||
|
console.warn("Failed to delete draft email", deleteResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("JMAP Error:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
const data = await request.json();
|
const data = await request.json();
|
||||||
const { email, message } = data;
|
const { subject, message } = data;
|
||||||
|
|
||||||
if (!email || !message) {
|
if (!subject?.trim() || !message?.trim()) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({ error: "Subject and message are required" }),
|
||||||
message: "Email and message are required",
|
{
|
||||||
}),
|
status: 400,
|
||||||
{ status: 400 },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await transporter.sendMail({
|
await sendEmailViaJMAP({ subject, message });
|
||||||
from: import.meta.env.FROM_EMAIL,
|
|
||||||
to: [import.meta.env.TO_EMAIL],
|
|
||||||
subject: `New Contact Form Submission from ${email}`,
|
|
||||||
text: message,
|
|
||||||
html: `
|
|
||||||
<h2>New Contact Form Submission</h2>
|
|
||||||
<p><strong>From:</strong> ${email}</p>
|
|
||||||
<p><strong>Message:</strong></p>
|
|
||||||
<p>${message.replace(/\n/g, "<br>")}</p>
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({ success: true, message: "Email sent successfully" }),
|
||||||
message: "Email sent successfully",
|
{
|
||||||
}),
|
status: 200,
|
||||||
{ status: 200 },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error sending email:", error);
|
console.error("API Error:", error);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
message: "Error sending email",
|
error: "Failed to send message",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
}),
|
}),
|
||||||
{ status: 500 },
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Reference in a new issue