Skip to content

Commit bc097e9

Browse files
committed
Add PATCH /api/e2/emails/:id endpoint for updating received email metadata
Adds a new PATCH endpoint to mark received emails as read/unread and archived/unarchived via the public API. Previously this was only possible through frontend server actions. Also improves DELETE /api/e2/emails/:id error messages to distinguish between received, sent, and non-existent emails instead of the generic 'Scheduled email not found' response.
1 parent 6e0e837 commit bc097e9

3 files changed

Lines changed: 265 additions & 115 deletions

File tree

app/api/e2/[[...slugs]]/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { replyToEmail } from "../emails/reply";
1818
import { retryEmail } from "../emails/retry";
1919
// Email routes
2020
import { sendEmail } from "../emails/send";
21+
import { updateEmail } from "../emails/update";
2122
import { createEndpoint } from "../endpoints/create";
2223
import { deleteEndpoint } from "../endpoints/delete";
2324
import { getEndpoint } from "../endpoints/get";
@@ -459,6 +460,7 @@ https://inbound.new/api/e2
459460
.use(sendEmail)
460461
.use(listEmails)
461462
.use(getEmail)
463+
.use(updateEmail)
462464
.use(cancelEmail)
463465
.use(replyToEmail)
464466
.use(retryEmail)

app/api/e2/emails/cancel.ts

Lines changed: 155 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,167 @@
1+
import { Client as QStashClient } from "@upstash/qstash";
2+
import { and, eq } from "drizzle-orm";
13
import { Elysia, t } from "elysia";
2-
import { validateAndRateLimit } from "../lib/auth";
34
import { db } from "@/lib/db";
4-
import { scheduledEmails, SCHEDULED_EMAIL_STATUS } from "@/lib/db/schema";
5-
import { eq, and } from "drizzle-orm";
6-
import { Client as QStashClient } from "@upstash/qstash";
5+
import {
6+
SCHEDULED_EMAIL_STATUS,
7+
scheduledEmails,
8+
sentEmails,
9+
structuredEmails,
10+
} from "@/lib/db/schema";
11+
import { validateAndRateLimit } from "../lib/auth";
712

813
// Response schemas
914
const CancelEmailSuccessResponse = t.Object({
10-
success: t.Boolean(),
11-
message: t.String(),
12-
id: t.String(),
15+
success: t.Boolean(),
16+
message: t.String(),
17+
id: t.String(),
1318
});
1419

1520
const CancelEmailErrorResponse = t.Object({
16-
error: t.String(),
21+
error: t.String(),
1722
});
1823

1924
export const cancelEmail = new Elysia().delete(
20-
"/emails/:id",
21-
async ({ request, params, set }) => {
22-
console.log(
23-
"🗑️ DELETE /api/e2/emails/:id - Starting request for:",
24-
params.id
25-
);
26-
27-
// Auth & rate limit validation
28-
const userId = await validateAndRateLimit(request, set);
29-
console.log("✅ Authentication successful for userId:", userId);
30-
31-
const emailId = params.id;
32-
console.log("🗑️ Cancelling scheduled email:", emailId);
33-
34-
// Fetch the scheduled email
35-
const [scheduledEmail] = await db
36-
.select()
37-
.from(scheduledEmails)
38-
.where(
39-
and(eq(scheduledEmails.id, emailId), eq(scheduledEmails.userId, userId))
40-
)
41-
.limit(1);
42-
43-
if (!scheduledEmail) {
44-
console.log("❌ Scheduled email not found:", emailId);
45-
set.status = 404;
46-
return { error: "Scheduled email not found" };
47-
}
48-
49-
// Check if already sent
50-
if (scheduledEmail.status === SCHEDULED_EMAIL_STATUS.SENT) {
51-
console.log("⚠️ Email already sent, cannot cancel:", emailId);
52-
set.status = 400;
53-
return { error: "Cannot cancel an email that has already been sent" };
54-
}
55-
56-
// Check if already cancelled
57-
if (scheduledEmail.status === SCHEDULED_EMAIL_STATUS.CANCELLED) {
58-
console.log("✅ Email already cancelled:", emailId);
59-
return {
60-
success: true,
61-
message: "Email already cancelled",
62-
id: emailId,
63-
};
64-
}
65-
66-
// Cancel in QStash if we have a schedule ID
67-
if (scheduledEmail.qstashScheduleId) {
68-
try {
69-
const qstashClient = new QStashClient({
70-
token: process.env.QSTASH_TOKEN!,
71-
});
72-
73-
console.log(
74-
"🗑️ Deleting from QStash, messageId:",
75-
scheduledEmail.qstashScheduleId
76-
);
77-
78-
// QStash uses messages.delete for scheduled messages
79-
await qstashClient.messages.delete(scheduledEmail.qstashScheduleId);
80-
81-
console.log("✅ Deleted from QStash successfully");
82-
} catch (qstashError) {
83-
console.error(
84-
"⚠️ Failed to delete from QStash (continuing anyway):",
85-
qstashError
86-
);
87-
// Continue with database cancellation even if QStash deletion fails
88-
// The webhook will handle the case where the email is already cancelled
89-
}
90-
}
91-
92-
// Update database record to cancelled
93-
await db
94-
.update(scheduledEmails)
95-
.set({
96-
status: SCHEDULED_EMAIL_STATUS.CANCELLED,
97-
updatedAt: new Date(),
98-
})
99-
.where(eq(scheduledEmails.id, emailId));
100-
101-
console.log("✅ Scheduled email cancelled successfully:", emailId);
102-
103-
return {
104-
success: true,
105-
message: "Scheduled email cancelled successfully",
106-
id: emailId,
107-
};
108-
},
109-
{
110-
params: t.Object({
111-
id: t.String(),
112-
}),
113-
response: {
114-
200: CancelEmailSuccessResponse,
115-
400: CancelEmailErrorResponse,
116-
401: CancelEmailErrorResponse,
117-
404: CancelEmailErrorResponse,
118-
500: CancelEmailErrorResponse,
119-
},
120-
detail: {
121-
tags: ["Emails"],
122-
summary: "Cancel scheduled email",
123-
description:
124-
"Cancel a scheduled email by ID. Only works for emails that haven't been sent yet.",
125-
},
126-
}
25+
"/emails/:id",
26+
async ({ request, params, set }) => {
27+
console.log(
28+
"🗑️ DELETE /api/e2/emails/:id - Starting request for:",
29+
params.id,
30+
);
31+
32+
// Auth & rate limit validation
33+
const userId = await validateAndRateLimit(request, set);
34+
console.log("✅ Authentication successful for userId:", userId);
35+
36+
const emailId = params.id;
37+
console.log("🗑️ Cancelling scheduled email:", emailId);
38+
39+
// Fetch the scheduled email
40+
const [scheduledEmail] = await db
41+
.select()
42+
.from(scheduledEmails)
43+
.where(
44+
and(
45+
eq(scheduledEmails.id, emailId),
46+
eq(scheduledEmails.userId, userId),
47+
),
48+
)
49+
.limit(1);
50+
51+
if (!scheduledEmail) {
52+
// Check if it's a received or sent email to provide a better error message
53+
const [receivedEmail] = await db
54+
.select({ id: structuredEmails.id })
55+
.from(structuredEmails)
56+
.where(
57+
and(
58+
eq(structuredEmails.id, emailId),
59+
eq(structuredEmails.userId, userId),
60+
),
61+
)
62+
.limit(1);
63+
64+
if (receivedEmail) {
65+
set.status = 400;
66+
return {
67+
error:
68+
"Cannot delete a received email. Use PATCH /emails/:id to update email metadata (is_read, is_archived).",
69+
};
70+
}
71+
72+
const [sent] = await db
73+
.select({ id: sentEmails.id })
74+
.from(sentEmails)
75+
.where(and(eq(sentEmails.id, emailId), eq(sentEmails.userId, userId)))
76+
.limit(1);
77+
78+
if (sent) {
79+
set.status = 400;
80+
return {
81+
error: "Cannot delete a sent email.",
82+
};
83+
}
84+
85+
set.status = 404;
86+
return { error: "Email not found" };
87+
}
88+
89+
// Check if already sent
90+
if (scheduledEmail.status === SCHEDULED_EMAIL_STATUS.SENT) {
91+
console.log("⚠️ Email already sent, cannot cancel:", emailId);
92+
set.status = 400;
93+
return { error: "Cannot cancel an email that has already been sent" };
94+
}
95+
96+
// Check if already cancelled
97+
if (scheduledEmail.status === SCHEDULED_EMAIL_STATUS.CANCELLED) {
98+
console.log("✅ Email already cancelled:", emailId);
99+
return {
100+
success: true,
101+
message: "Email already cancelled",
102+
id: emailId,
103+
};
104+
}
105+
106+
// Cancel in QStash if we have a schedule ID
107+
if (scheduledEmail.qstashScheduleId) {
108+
try {
109+
const qstashClient = new QStashClient({
110+
token: process.env.QSTASH_TOKEN!,
111+
});
112+
113+
console.log(
114+
"🗑️ Deleting from QStash, messageId:",
115+
scheduledEmail.qstashScheduleId,
116+
);
117+
118+
// QStash uses messages.delete for scheduled messages
119+
await qstashClient.messages.delete(scheduledEmail.qstashScheduleId);
120+
121+
console.log("✅ Deleted from QStash successfully");
122+
} catch (qstashError) {
123+
console.error(
124+
"⚠️ Failed to delete from QStash (continuing anyway):",
125+
qstashError,
126+
);
127+
// Continue with database cancellation even if QStash deletion fails
128+
// The webhook will handle the case where the email is already cancelled
129+
}
130+
}
131+
132+
// Update database record to cancelled
133+
await db
134+
.update(scheduledEmails)
135+
.set({
136+
status: SCHEDULED_EMAIL_STATUS.CANCELLED,
137+
updatedAt: new Date(),
138+
})
139+
.where(eq(scheduledEmails.id, emailId));
140+
141+
console.log("✅ Scheduled email cancelled successfully:", emailId);
142+
143+
return {
144+
success: true,
145+
message: "Scheduled email cancelled successfully",
146+
id: emailId,
147+
};
148+
},
149+
{
150+
params: t.Object({
151+
id: t.String(),
152+
}),
153+
response: {
154+
200: CancelEmailSuccessResponse,
155+
400: CancelEmailErrorResponse,
156+
401: CancelEmailErrorResponse,
157+
404: CancelEmailErrorResponse,
158+
500: CancelEmailErrorResponse,
159+
},
160+
detail: {
161+
tags: ["Emails"],
162+
summary: "Cancel scheduled email",
163+
description:
164+
"Cancel a scheduled email by ID. Only works for emails that haven't been sent yet.",
165+
},
166+
},
127167
);

0 commit comments

Comments
 (0)