diff --git a/.sqlx/query-41bc2a929838b6becc9b3062d64d763abfc267a7804c3c1766f1ea498937dc4f.json b/.sqlx/query-2164e9389cf2fe0a949600f45b9d82a33d58bf0c345ac61b65c1e278ad5307e6.json
similarity index 65%
rename from .sqlx/query-41bc2a929838b6becc9b3062d64d763abfc267a7804c3c1766f1ea498937dc4f.json
rename to .sqlx/query-2164e9389cf2fe0a949600f45b9d82a33d58bf0c345ac61b65c1e278ad5307e6.json
index 22945c2a..2610288f 100644
--- a/.sqlx/query-41bc2a929838b6becc9b3062d64d763abfc267a7804c3c1766f1ea498937dc4f.json
+++ b/.sqlx/query-2164e9389cf2fe0a949600f45b9d82a33d58bf0c345ac61b65c1e278ad5307e6.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT DISTINCT label AS \"label!:Label\"\n FROM messages\n WHERE organization_id = $1\n AND project_id = $2\n AND label IS NOT NULL\n ORDER BY label\n ",
+ "query": "\n SELECT DISTINCT label AS \"label!:Label\"\n FROM messages\n WHERE organization_id = $1 AND label IS NOT NULL\n ORDER BY label\n ",
"describe": {
"columns": [
{
@@ -11,7 +11,6 @@
],
"parameters": {
"Left": [
- "Uuid",
"Uuid"
]
},
@@ -19,5 +18,5 @@
true
]
},
- "hash": "41bc2a929838b6becc9b3062d64d763abfc267a7804c3c1766f1ea498937dc4f"
+ "hash": "2164e9389cf2fe0a949600f45b9d82a33d58bf0c345ac61b65c1e278ad5307e6"
}
diff --git a/.sqlx/query-627c5920d7e5416c50f4ec71a8ae6ec034612dca650e047d359d4ec7d1a9246c.json b/.sqlx/query-2daf4bc3a73109dfbd4602d8a1b5b98a3afeaa8a360cc6b975d5169c5624be53.json
similarity index 88%
rename from .sqlx/query-627c5920d7e5416c50f4ec71a8ae6ec034612dca650e047d359d4ec7d1a9246c.json
rename to .sqlx/query-2daf4bc3a73109dfbd4602d8a1b5b98a3afeaa8a360cc6b975d5169c5624be53.json
index 0a803bca..5c2432c8 100644
--- a/.sqlx/query-627c5920d7e5416c50f4ec71a8ae6ec034612dca650e047d359d4ec7d1a9246c.json
+++ b/.sqlx/query-2daf4bc3a73109dfbd4602d8a1b5b98a3afeaa8a360cc6b975d5169c5624be53.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT\n id,\n organization_id,\n project_id,\n smtp_credential_id,\n api_key_id,\n status AS \"status: _\",\n reason,\n delivery_details,\n from_email,\n recipients,\n ''::bytea AS \"raw_data!\",\n NULL::jsonb AS \"message_data\",\n octet_length(raw_data) AS \"raw_size!\",\n message_id_header,\n created_at,\n updated_at,\n retry_after,\n attempts,\n max_attempts,\n label AS \"label:Label\"\n FROM messages m\n WHERE organization_id = $1\n AND project_id = $2\n AND ($4::message_status[] IS NULL OR status = ANY($4))\n AND ($5::timestamptz IS NULL OR created_at <= $5)\n AND ($6::text[] IS NULL OR label = ANY($6))\n AND octet_length(raw_data) > 0 -- don't show deleted messages\n ORDER BY created_at DESC\n LIMIT $3\n ",
+ "query": "\n SELECT\n id,\n organization_id,\n project_id,\n smtp_credential_id,\n api_key_id,\n status AS \"status: _\",\n reason,\n delivery_details,\n from_email,\n recipients,\n ''::bytea AS \"raw_data!\",\n NULL::jsonb AS \"message_data\",\n octet_length(raw_data) AS \"raw_size!\",\n message_id_header,\n created_at,\n updated_at,\n retry_after,\n attempts,\n max_attempts,\n label AS \"label:Label\"\n FROM messages m\n WHERE organization_id = $1\n AND ($2::uuid IS NULL OR project_id = $2)\n AND ($3::message_status[] IS NULL OR status = ANY($3))\n AND ($4::timestamptz IS NULL OR created_at <= $4)\n AND ($5::text[] IS NULL OR label = ANY($5))\n AND octet_length(raw_data) > 0 -- don't show deleted messages\n ORDER BY created_at DESC\n LIMIT $6\n ",
"describe": {
"columns": [
{
@@ -123,7 +123,6 @@
"Left": [
"Uuid",
"Uuid",
- "Int8",
{
"Custom": {
"name": "message_status[]",
@@ -148,7 +147,8 @@
}
},
"Timestamptz",
- "TextArray"
+ "TextArray",
+ "Int8"
]
},
"nullable": [
@@ -174,5 +174,5 @@
true
]
},
- "hash": "627c5920d7e5416c50f4ec71a8ae6ec034612dca650e047d359d4ec7d1a9246c"
+ "hash": "2daf4bc3a73109dfbd4602d8a1b5b98a3afeaa8a360cc6b975d5169c5624be53"
}
diff --git a/.sqlx/query-83ff89816add414b2b8cd70ddd5ecc4a0f531ada0c6b222912ab4baab9f1d813.json b/.sqlx/query-61f1a536b2f384a880a1a80183eb495fd812a2d61331310c8a7f12fc7e1674d3.json
similarity index 92%
rename from .sqlx/query-83ff89816add414b2b8cd70ddd5ecc4a0f531ada0c6b222912ab4baab9f1d813.json
rename to .sqlx/query-61f1a536b2f384a880a1a80183eb495fd812a2d61331310c8a7f12fc7e1674d3.json
index 94dc4a23..5233dbb0 100644
--- a/.sqlx/query-83ff89816add414b2b8cd70ddd5ecc4a0f531ada0c6b222912ab4baab9f1d813.json
+++ b/.sqlx/query-61f1a536b2f384a880a1a80183eb495fd812a2d61331310c8a7f12fc7e1674d3.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT\n m.id,\n m.organization_id,\n m.project_id,\n m.smtp_credential_id,\n m.api_key_id,\n m.status as \"status: _\",\n m.reason,\n m.delivery_details,\n m.from_email,\n m.recipients,\n -- Only return the first API_RAW_TRUNCATE_LENGTH bytes/ASCII-characters of the raw data.\n substring(m.raw_data FOR $4) as \"raw_data!\",\n octet_length(m.raw_data) as \"raw_size!\",\n m.message_data,\n m.message_id_header,\n m.created_at,\n m.updated_at,\n m.retry_after,\n m.attempts,\n m.max_attempts,\n m.label AS \"label:Label\"\n FROM messages m\n WHERE m.id = $1\n AND m.organization_id = $2\n AND m.project_id = $3\n AND octet_length(m.raw_data) > 0 -- don't show deleted messages\n ",
+ "query": "\n SELECT\n m.id,\n m.organization_id,\n m.project_id,\n m.smtp_credential_id,\n m.api_key_id,\n m.status as \"status: _\",\n m.reason,\n m.delivery_details,\n m.from_email,\n m.recipients,\n -- Only return the first API_RAW_TRUNCATE_LENGTH bytes/ASCII-characters of the raw data.\n substring(m.raw_data FOR $3) as \"raw_data!\",\n octet_length(m.raw_data) as \"raw_size!\",\n m.message_data,\n m.message_id_header,\n m.created_at,\n m.updated_at,\n m.retry_after,\n m.attempts,\n m.max_attempts,\n m.label AS \"label:Label\"\n FROM messages m\n WHERE m.id = $1\n AND m.organization_id = $2\n AND octet_length(m.raw_data) > 0 -- don't show deleted messages\n ",
"describe": {
"columns": [
{
@@ -121,7 +121,6 @@
],
"parameters": {
"Left": [
- "Uuid",
"Uuid",
"Uuid",
"Int4"
@@ -150,5 +149,5 @@
true
]
},
- "hash": "83ff89816add414b2b8cd70ddd5ecc4a0f531ada0c6b222912ab4baab9f1d813"
+ "hash": "61f1a536b2f384a880a1a80183eb495fd812a2d61331310c8a7f12fc7e1674d3"
}
diff --git a/.sqlx/query-14a62a3a727307e357841676756ecff016d7f9f1958a20d0f35edac7043e271d.json b/.sqlx/query-72d09375994accc07bf4d65dc2c6dd4ae73180f2c3c6646298f66a82d398dad0.json
similarity index 66%
rename from .sqlx/query-14a62a3a727307e357841676756ecff016d7f9f1958a20d0f35edac7043e271d.json
rename to .sqlx/query-72d09375994accc07bf4d65dc2c6dd4ae73180f2c3c6646298f66a82d398dad0.json
index b29cc671..00119096 100644
--- a/.sqlx/query-14a62a3a727307e357841676756ecff016d7f9f1958a20d0f35edac7043e271d.json
+++ b/.sqlx/query-72d09375994accc07bf4d65dc2c6dd4ae73180f2c3c6646298f66a82d398dad0.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n UPDATE messages\n SET raw_data = '',\n message_data = NULL,\n recipients = '{}',\n delivery_details = '{}'\n WHERE id = $1\n AND organization_id = $2\n AND project_id = $3\n RETURNING id\n ",
+ "query": "\n UPDATE messages\n SET raw_data = '',\n message_data = NULL,\n recipients = '{}',\n delivery_details = '{}'\n WHERE id = $1 AND organization_id = $2\n RETURNING id\n ",
"describe": {
"columns": [
{
@@ -11,7 +11,6 @@
],
"parameters": {
"Left": [
- "Uuid",
"Uuid",
"Uuid"
]
@@ -20,5 +19,5 @@
false
]
},
- "hash": "14a62a3a727307e357841676756ecff016d7f9f1958a20d0f35edac7043e271d"
+ "hash": "72d09375994accc07bf4d65dc2c6dd4ae73180f2c3c6646298f66a82d398dad0"
}
diff --git a/.sqlx/query-2b8067e776808e7cda898bfbd9b9e6b2c023ad2ae85ddc84cac5eca371309d57.json b/.sqlx/query-99f9ced07e12f98012dfb179ce8c240b888faa743875fd376362566efe9a5c58.json
similarity index 80%
rename from .sqlx/query-2b8067e776808e7cda898bfbd9b9e6b2c023ad2ae85ddc84cac5eca371309d57.json
rename to .sqlx/query-99f9ced07e12f98012dfb179ce8c240b888faa743875fd376362566efe9a5c58.json
index e92e1394..9859dd88 100644
--- a/.sqlx/query-2b8067e776808e7cda898bfbd9b9e6b2c023ad2ae85ddc84cac5eca371309d57.json
+++ b/.sqlx/query-99f9ced07e12f98012dfb179ce8c240b888faa743875fd376362566efe9a5c58.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT m.status AS \"status:MessageStatus\"\n FROM messages m\n WHERE m.organization_id = $1\n AND m.project_id = $2\n AND m.id = $3\n ",
+ "query": "\n SELECT m.status AS \"status:MessageStatus\"\n FROM messages m\n WHERE m.organization_id = $1 AND m.id = $2\n ",
"describe": {
"columns": [
{
@@ -26,7 +26,6 @@
],
"parameters": {
"Left": [
- "Uuid",
"Uuid",
"Uuid"
]
@@ -35,5 +34,5 @@
false
]
},
- "hash": "2b8067e776808e7cda898bfbd9b9e6b2c023ad2ae85ddc84cac5eca371309d57"
+ "hash": "99f9ced07e12f98012dfb179ce8c240b888faa743875fd376362566efe9a5c58"
}
diff --git a/.sqlx/query-e8015b31af95d79f02d3435c5de23a4f6bac81c2f279b6ac197e3ec81720af57.json b/.sqlx/query-e8015b31af95d79f02d3435c5de23a4f6bac81c2f279b6ac197e3ec81720af57.json
deleted file mode 100644
index e2e89e9b..00000000
--- a/.sqlx/query-e8015b31af95d79f02d3435c5de23a4f6bac81c2f279b6ac197e3ec81720af57.json
+++ /dev/null
@@ -1,81 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n SELECT d.id,\n d.domain,\n d.organization_id,\n d.project_id,\n d.dkim_key_type as \"dkim_key_type: DkimKeyType\",\n d.dkim_pkcs8_der,\n d.verification_status,\n d.created_at,\n d.updated_at\n FROM domains d\n LEFT JOIN projects p ON d.project_id = p.id\n WHERE d.id = $2 AND (d.organization_id = $1 OR p.organization_id = $1)\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "id",
- "type_info": "Uuid"
- },
- {
- "ordinal": 1,
- "name": "domain",
- "type_info": "Varchar"
- },
- {
- "ordinal": 2,
- "name": "organization_id",
- "type_info": "Uuid"
- },
- {
- "ordinal": 3,
- "name": "project_id",
- "type_info": "Uuid"
- },
- {
- "ordinal": 4,
- "name": "dkim_key_type: DkimKeyType",
- "type_info": {
- "Custom": {
- "name": "dkim_key_type",
- "kind": {
- "Enum": [
- "rsa_sha256",
- "ed25519"
- ]
- }
- }
- }
- },
- {
- "ordinal": 5,
- "name": "dkim_pkcs8_der",
- "type_info": "Bytea"
- },
- {
- "ordinal": 6,
- "name": "verification_status",
- "type_info": "Jsonb"
- },
- {
- "ordinal": 7,
- "name": "created_at",
- "type_info": "Timestamptz"
- },
- {
- "ordinal": 8,
- "name": "updated_at",
- "type_info": "Timestamptz"
- }
- ],
- "parameters": {
- "Left": [
- "Uuid",
- "Uuid"
- ]
- },
- "nullable": [
- false,
- false,
- false,
- true,
- false,
- false,
- false,
- false,
- false
- ]
- },
- "hash": "e8015b31af95d79f02d3435c5de23a4f6bac81c2f279b6ac197e3ec81720af57"
-}
diff --git a/frontend/src/Pages.tsx b/frontend/src/Pages.tsx
index ea1fd0b8..77c02c33 100644
--- a/frontend/src/Pages.tsx
+++ b/frontend/src/Pages.tsx
@@ -21,8 +21,11 @@ import ApiKeyDetails from "./components/apiKeys/ApiKeyDetails.tsx";
import DomainDetails from "./components/domains/DomainDetails.tsx";
import GlobalAdmin from "./components/admin/GlobalAdmin.tsx";
import PasswordReset from "./PasswordReset.tsx";
+import { EmailOverview } from "./components/emails/EmailOverview.tsx";
const PageContent: { [key in RouteName]: JSX.Element | null } = {
+ emails: ,
+ "emails.email": ,
projects: ,
"projects.project": ,
"projects.project.emails": ,
diff --git a/frontend/src/apiMiddleware.ts b/frontend/src/apiMiddleware.ts
index 2bd6e4da..66cadcc1 100644
--- a/frontend/src/apiMiddleware.ts
+++ b/frontend/src/apiMiddleware.ts
@@ -104,6 +104,10 @@ export default async function apiMiddleware(
type: "set_domains",
domains: await get(`/api/organizations/${newOrgId}/domains`),
});
+ dispatch({
+ type: "set_labels",
+ labels: await get(`/api/organizations/${newOrgId}/emails/labels`),
+ });
}
if (navState.to.name == "statistics") {
@@ -115,23 +119,23 @@ export default async function apiMiddleware(
type: "set_credentials",
credentials: await get(`/api/organizations/${newOrgId}/projects/${newProjId}/smtp_credentials`),
});
- dispatch({
- type: "set_labels",
- labels: await get(`/api/organizations/${newOrgId}/projects/${newProjId}/labels`),
- });
}
let emailFilterChanged = false;
const emailFilter = new URLSearchParams();
- for (const param of ["limit", "status", "before", "labels"]) {
+ for (const param of ["limit", "status", "before", "labels", "project"]) {
const value = navState.to.params[param];
if (value != navState.from.params[param]) emailFilterChanged = true;
if (value) emailFilter.append(param, value);
}
- if ((projChanged || emailFilterChanged || navState.to.params.force == "reload") && newProjId) {
+ if (navState.to.params.proj_id) {
+ // force project filter when path contains a project id (for viewing emails within a project)
+ emailFilter.set("project", navState.to.params.proj_id);
+ }
+ if (projChanged || emailFilterChanged || navState.to.name == "emails" || navState.to.params.force == "reload") {
dispatch({
type: "set_emails",
- emailMetadata: await get(`/api/organizations/${newOrgId}/projects/${newProjId}/emails?${emailFilter.toString()}`),
+ emailMetadata: await get(`/api/organizations/${newOrgId}/emails?${emailFilter.toString()}`),
});
}
diff --git a/frontend/src/components/ProjectLink.tsx b/frontend/src/components/ProjectLink.tsx
new file mode 100644
index 00000000..7cdc57ca
--- /dev/null
+++ b/frontend/src/components/ProjectLink.tsx
@@ -0,0 +1,21 @@
+import { Group } from "@mantine/core";
+import { useProjectWithId } from "../hooks/useProjects";
+import { Link } from "../Link";
+import { IconServer } from "@tabler/icons-react";
+
+interface ProjectLinkProps {
+ project_id: string;
+ size?: "md" | "sm";
+}
+
+export default function ProjectLink({ project_id, size }: ProjectLinkProps) {
+ const project_name = useProjectWithId(project_id)?.name;
+
+ return (
+
+
+ {project_name ?? project_id}
+
+
+ );
+}
diff --git a/frontend/src/components/domains/DomainsOverview.tsx b/frontend/src/components/domains/DomainsOverview.tsx
index 92c6e67a..41c71c1e 100644
--- a/frontend/src/components/domains/DomainsOverview.tsx
+++ b/frontend/src/components/domains/DomainsOverview.tsx
@@ -1,8 +1,8 @@
import { useDomains } from "../../hooks/useDomains.ts";
import { Loader } from "../../Loader.tsx";
-import { Flex, Group, Pagination, Stack, Table, Text } from "@mantine/core";
+import { Flex, Pagination, Stack, Table, Text } from "@mantine/core";
import { formatDateTime } from "../../util.ts";
-import { IconPlus, IconServer } from "@tabler/icons-react";
+import { IconPlus } from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import { NewDomain } from "./NewDomain.tsx";
import { Link } from "../../Link.tsx";
@@ -12,18 +12,16 @@ import VerificationBadge from "./VerificationBadge.tsx";
import EditButton from "../EditButton.tsx";
import OrganizationHeader from "../organizations/OrganizationHeader.tsx";
import { MaintainerButton } from "../RoleButtons.tsx";
-import { useProjectWithId } from "../../hooks/useProjects.ts";
import { Domain } from "../../types.ts";
import { useRemails } from "../../hooks/useRemails.ts";
import { useState } from "react";
import SearchInput from "../SearchInput.tsx";
+import ProjectLink from "../ProjectLink.tsx";
const PER_PAGE = 20;
const SHOW_SEARCH = 10;
function DomainRow({ domain }: { domain: Domain }) {
- const project_name = useProjectWithId(domain.project_id)?.name;
-
return (
@@ -38,11 +36,7 @@ function DomainRow({ domain }: { domain: Domain }) {
{domain.project_id ? (
-
-
- {project_name ?? domain.project_id}
-
-
+
) : (
any project
diff --git a/frontend/src/components/emails/EmailDeleteButton.tsx b/frontend/src/components/emails/EmailDeleteButton.tsx
index b017e9dd..292d87b1 100644
--- a/frontend/src/components/emails/EmailDeleteButton.tsx
+++ b/frontend/src/components/emails/EmailDeleteButton.tsx
@@ -3,7 +3,6 @@ import { IconTrash } from "@tabler/icons-react";
import { EmailMetadata } from "../../types.ts";
import { modals } from "@mantine/modals";
import { useOrganizations } from "../../hooks/useOrganizations.ts";
-import { useProjects } from "../../hooks/useProjects.ts";
import { notifications } from "@mantine/notifications";
import { useRemails } from "../../hooks/useRemails.ts";
import { errorNotification } from "../../notify.tsx";
@@ -11,20 +10,16 @@ import { MaintainerActionIcon, MaintainerButton } from "../RoleButtons.tsx";
export default function EmailDeleteButton({ email, small }: { email: EmailMetadata; small?: boolean }) {
const { currentOrganization } = useOrganizations();
- const { currentProject } = useProjects();
const { navigate, dispatch } = useRemails();
- if (!currentOrganization || !currentProject) {
+ if (!currentOrganization) {
return null;
}
const deleteEmail = async () => {
- const res = await fetch(
- `/api/organizations/${currentOrganization.id}/projects/${currentProject.id}/emails/${email.id}`,
- {
- method: "DELETE",
- }
- );
+ const res = await fetch(`/api/organizations/${currentOrganization.id}/emails/${email.id}`, {
+ method: "DELETE",
+ });
if (res.status === 200) {
notifications.show({
title: "Email deleted",
diff --git a/frontend/src/components/emails/EmailDetails.tsx b/frontend/src/components/emails/EmailDetails.tsx
index 6be81f0c..45e9e871 100644
--- a/frontend/src/components/emails/EmailDetails.tsx
+++ b/frontend/src/components/emails/EmailDetails.tsx
@@ -4,12 +4,13 @@ import { useState } from "react";
import { Loader } from "../../Loader.tsx";
import { Email, EmailMetadata } from "../../types.ts";
import { formatDateTime, is_in_the_future } from "../../util.ts";
-import { IconHelp, IconMessage, IconPaperclip } from "@tabler/icons-react";
+import { IconHelp, IconMail, IconPaperclip } from "@tabler/icons-react";
import EmailRetryButton from "./EmailRetryButton.tsx";
import EmailDeleteButton from "./EmailDeleteButton.tsx";
import { Recipients } from "./Recipients.tsx";
import Header from "../Header.tsx";
import Label from "./Label.tsx";
+import ProjectLink from "../ProjectLink.tsx";
export function getFullStatusDescription(email: EmailMetadata) {
if (email.status == "delivered") {
@@ -60,6 +61,7 @@ export default function EmailDetails() {
),
},
{ header: "From", value: fullEmail.from_email },
+ { header: "Project", value: },
{
header: "Recipients",
info: 'The recipients who will receive this email based on the "RCPT TO" SMTP header',
@@ -125,7 +127,7 @@ export default function EmailDetails() {
: null}
/>
diff --git a/frontend/src/components/emails/EmailOverview.tsx b/frontend/src/components/emails/EmailOverview.tsx
index bce9fc40..4dcea78b 100644
--- a/frontend/src/components/emails/EmailOverview.tsx
+++ b/frontend/src/components/emails/EmailOverview.tsx
@@ -36,6 +36,8 @@ import { Recipients } from "./Recipients.tsx";
import InfoAlert from "../InfoAlert.tsx";
import Label from "./Label.tsx";
import { EmailStatus } from "../../types.ts";
+import OrganizationHeader from "../organizations/OrganizationHeader.tsx";
+import ProjectLink from "../ProjectLink.tsx";
function statusIcons(status: EmailStatus) {
if (status == "processing" || status == "accepted") {
@@ -127,6 +129,11 @@ export function EmailOverview() {
Status: {getFullStatusDescription(email)}
+ {routerState.name == "emails" && (
+
+ Project:
+
+ )}
Message ID: {{email.message_id_header}}
@@ -138,7 +145,11 @@ export function EmailOverview() {
leftSection={}
variant="light"
size="xs"
- onClick={() => navigate("projects.project.emails.email", { email_id: email.id })}
+ onClick={() =>
+ navigate(routerState.name == "emails" ? "emails.email" : "projects.project.emails.email", {
+ email_id: email.id,
+ })
+ }
>
View email
@@ -171,10 +182,21 @@ export function EmailOverview() {
return (
<>
-
- This page shows a list of all emails sent in this project. Use it to check delivery status, inspect metadata,
- and troubleshoot issues. You’ll see timestamps, recipient addresses, and SMTP-level details for each message.
-
+ {routerState.name == "emails" && }
+
+ {routerState.name == "emails" ? (
+
+ This page shows a list of all emails sent in this organization. Use it to check delivery status, inspect
+ metadata, and troubleshoot issues. You’ll see timestamps, recipient addresses, and SMTP-level details for each
+ message.
+
+ ) : (
+
+ This page shows a list of all emails sent in this project. Use it to check delivery status, inspect metadata,
+ and troubleshoot issues. You’ll see timestamps, recipient addresses, and SMTP-level details for each message.
+
+ )}
+
,
+ icon: ,
content: ,
},
{
diff --git a/frontend/src/hooks/useEmails.ts b/frontend/src/hooks/useEmails.ts
index 2b119015..786bcf41 100644
--- a/frontend/src/hooks/useEmails.ts
+++ b/frontend/src/hooks/useEmails.ts
@@ -1,14 +1,12 @@
import { useRemails } from "./useRemails.ts";
import { useEffect, useState } from "react";
import { useOrganizations } from "./useOrganizations.ts";
-import { useProjects } from "./useProjects.ts";
import { Email, EmailMetadata } from "../types.ts";
import { RemailsError } from "../error/error.ts";
import { useSelector } from "./useSelector.ts";
export function useEmails() {
const { currentOrganization } = useOrganizations();
- const { currentProject } = useProjects();
const labels = useSelector((s) => s.labels || []);
const [currentEmail, setCurrentEmail] = useState(null);
const {
@@ -20,10 +18,8 @@ export function useEmails() {
if (routerState.params.email_id) {
const partialEmail = emails?.find((m) => m.id === routerState.params.email_id) || null;
setCurrentEmail(partialEmail);
- if (currentOrganization && currentProject) {
- fetch(
- `/api/organizations/${currentOrganization.id}/projects/${currentProject.id}/emails/${routerState.params.email_id}`
- )
+ if (currentOrganization) {
+ fetch(`/api/organizations/${currentOrganization.id}/emails/${routerState.params.email_id}`)
.then((res) => {
if (res.ok) {
return res.json();
@@ -45,7 +41,7 @@ export function useEmails() {
setCurrentEmail(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [currentOrganization, currentProject, routerState.params.email_id]); // don't update on `emails`
+ }, [currentOrganization, routerState.params.email_id]); // don't update on `emails`
function updateEmail(email_id: string, update: Partial) {
if (currentEmail?.id == email_id) {
diff --git a/frontend/src/layout/NavBar.tsx b/frontend/src/layout/NavBar.tsx
index 68d3cae8..9a41ea9c 100644
--- a/frontend/src/layout/NavBar.tsx
+++ b/frontend/src/layout/NavBar.tsx
@@ -1,5 +1,5 @@
import { BoxProps, NavLink as MantineNavLink } from "@mantine/core";
-import { IconChartBar, IconGavel, IconServer, IconSettings, IconWorldWww } from "@tabler/icons-react";
+import { IconChartBar, IconGavel, IconMail, IconServer, IconSettings, IconWorldWww } from "@tabler/icons-react";
import { useRemails } from "../hooks/useRemails.ts";
import { useDisclosure } from "@mantine/hooks";
import { NewOrganization } from "../components/organizations/NewOrganization.tsx";
@@ -87,6 +87,13 @@ export function NavBar({ close }: { close: () => void }) {
active={routerState.name.startsWith("domains")}
leftSection={}
/>
+ }
+ />
= deserialize_body(response.into_body()).await;
- assert_eq!(messages.len(), 5);
+ assert_eq!(messages.len(), 8);
// get a specific message
let message_1 = "e165562a-fb6d-423b-b318-fd26f4610634".parse().unwrap();
let response = server
- .get(format!(
- "/api/organizations/{org_1}/projects/{proj_1}/emails/{message_1}"
- ))
+ .get(format!("/api/organizations/{org_1}/emails/{message_1}"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
@@ -441,18 +437,14 @@ mod tests {
// delete a message
let response = server
- .delete(format!(
- "/api/organizations/{org_1}/projects/{proj_1}/emails/{message_1}"
- ))
+ .delete(format!("/api/organizations/{org_1}/emails/{message_1}"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// check that is was removed
let response = server
- .get(format!(
- "/api/organizations/{org_1}/projects/{proj_1}/emails/{message_1}"
- ))
+ .get(format!("/api/organizations/{org_1}/emails/{message_1}"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
@@ -611,9 +603,7 @@ mod tests {
// Read-only API keys should not be able to delete messages
let message_1 = "e165562a-fb6d-423b-b318-fd26f4610634";
let response = server
- .delete(format!(
- "/api/organizations/{org_1}/projects/{proj_1}/emails/{message_1}"
- ))
+ .delete(format!("/api/organizations/{org_1}/emails/{message_1}"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
@@ -627,18 +617,14 @@ mod tests {
// Read-only API keys are able to list messages
let response = server
- .get(format!(
- "/api/organizations/{org_1}/projects/{proj_1}/emails"
- ))
+ .get(format!("/api/organizations/{org_1}/emails"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// Read-only API keys are able to view messages
let response = server
- .get(format!(
- "/api/organizations/{org_1}/projects/{proj_1}/emails/{message_1}"
- ))
+ .get(format!("/api/organizations/{org_1}/emails/{message_1}"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
diff --git a/src/api/messages.rs b/src/api/messages.rs
index aa5c021a..aac1f8c5 100644
--- a/src/api/messages.rs
+++ b/src/api/messages.rs
@@ -273,13 +273,13 @@ pub async fn create_message(
/// List all email messages
///
-/// By default, the 10 most recently created messages are returned. To retrieve more on a single request, please set
+/// By default, the 10 most recently created messages are returned. To retrieve more on a single request, set
/// the query parameter `limit` between 1 and 100. Pagination is achieved via the `before` query
/// parameter, i.e., to get older messages, please set the `before` param to the oldest `created_at`
/// of the previous request.
#[utoipa::path(
get,
- path = "/organizations/{org_id}/projects/{project_id}/emails",
+ path = "/organizations/{org_id}/emails",
params(MessageFilter),
tags = ["Emails"],
responses(
@@ -289,20 +289,17 @@ pub async fn create_message(
)]
pub async fn list_messages(
State(repo): State,
- Path((org_id, project_id)): Path<(OrganizationId, ProjectId)>,
+ Path(org_id): Path,
ValidatedQuery(filter): ValidatedQuery,
user: Box,
) -> ApiResult> {
user.has_org_read_access(&org_id)?;
- let messages = repo
- .list_message_metadata(org_id, project_id, filter)
- .await?;
+ let messages = repo.list_message_metadata(org_id, filter).await?;
debug!(
user_id = user.log_id(),
organization_id = org_id.to_string(),
- project_id = project_id.to_string(),
"listed {} messages",
messages.len()
);
@@ -317,7 +314,7 @@ pub async fn list_messages(
/// was actually truncated or did fit into the 10,000-character limit.
#[utoipa::path(
get,
- path = "/organizations/{org_id}/projects/{project_id}/emails/{message_id}",
+ path = "/organizations/{org_id}/emails/{message_id}",
tags = ["Emails"],
responses(
(status = 200, description = "Successfully fetched message", body = ApiMessage),
@@ -326,17 +323,16 @@ pub async fn list_messages(
)]
pub async fn get_message(
State(repo): State,
- Path((org_id, project_id, message_id)): Path<(OrganizationId, ProjectId, MessageId)>,
+ Path((org_id, message_id)): Path<(OrganizationId, MessageId)>,
user: Box,
) -> ApiResult {
user.has_org_read_access(&org_id)?;
- let message = repo.find_by_id(org_id, project_id, message_id).await?;
+ let message = repo.find_by_id(org_id, message_id).await?;
debug!(
user_id = user.log_id(),
organization_id = org_id.to_string(),
- project_id = project_id.to_string(),
message_id = message_id.to_string(),
"retrieved message",
);
@@ -347,7 +343,7 @@ pub async fn get_message(
/// Delete email message
#[utoipa::path(
delete,
- path = "/organizations/{org_id}/projects/{project_id}/emails/{message_id}",
+ path = "/organizations/{org_id}/emails/{message_id}",
tags = ["Emails"],
responses(
(status = 200, description = "Successfully deleted message", body = MessageId),
@@ -356,17 +352,16 @@ pub async fn get_message(
)]
pub async fn remove_message(
State(repo): State,
- Path((org_id, project_id, message_id)): Path<(OrganizationId, ProjectId, MessageId)>,
+ Path((org_id, message_id)): Path<(OrganizationId, MessageId)>,
user: Box,
) -> ApiResult {
user.has_org_write_access(&org_id)?;
- let id = repo.remove(org_id, project_id, message_id).await?;
+ let id = repo.remove(org_id, message_id).await?;
debug!(
user_id = user.log_id(),
organization_id = org_id.to_string(),
- project_id = project_id.to_string(),
message_id = message_id.to_string(),
"removed message",
);
@@ -381,7 +376,7 @@ pub async fn remove_message(
/// who have not previously generated a permanent failure response.
#[utoipa::path(
put,
- path = "/organizations/{org_id}/projects/{project_id}/emails/{message_id}/retry",
+ path = "/organizations/{org_id}/emails/{message_id}/retry",
tags = ["Emails"],
responses(
(status = 200, description = "Successfully initiated retry"),
@@ -391,12 +386,12 @@ pub async fn remove_message(
pub async fn retry_now(
State(repo): State,
State(bus_client): State>,
- Path((org_id, project_id, message_id)): Path<(OrganizationId, ProjectId, MessageId)>,
+ Path((org_id, message_id)): Path<(OrganizationId, MessageId)>,
user: Box,
) -> Result<(), AppError> {
user.has_org_write_access(&org_id)?;
- let status = repo.message_status(org_id, project_id, message_id).await?;
+ let status = repo.message_status(org_id, message_id).await?;
if status == MessageStatus::Delivered {
warn!(
@@ -421,7 +416,6 @@ pub async fn retry_now(
debug!(
user_id = user.log_id(),
organization_id = org_id.to_string(),
- project_id = project_id.to_string(),
message_id = message_id.to_string(),
"requested message retry",
);
@@ -434,7 +428,7 @@ pub async fn retry_now(
/// Lists all labels that exist on at least one email message within that project.
#[utoipa::path(
get,
- path = "/organizations/{org_id}/projects/{project_id}/labels",
+ path = "/organizations/{org_id}/emails/labels",
tags = ["Emails"],
responses(
(status = 200, description = "Successfully fetched labels", body = [Label]),
@@ -443,11 +437,11 @@ pub async fn retry_now(
)]
pub async fn list_labels(
State(repo): State,
- Path((org_id, project_id)): Path<(OrganizationId, ProjectId)>,
+ Path(org_id): Path,
user: Box,
) -> ApiResult> {
user.has_org_read_access(&org_id)?;
- let labels = repo.list_labels(org_id, project_id).await?;
+ let labels = repo.list_labels(org_id).await?;
Ok(Json(labels))
}
@@ -494,69 +488,67 @@ mod tests {
// list messages
let response = server
- .get(format!(
- "/api/organizations/{org_1}/projects/{proj_1}/emails"
- ))
+ .get(format!("/api/organizations/{org_1}/emails"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let messages: Vec = deserialize_body(response.into_body()).await;
- let messages_in_fixture = 5;
+ let messages_in_fixture = 8;
assert_eq!(messages.len(), messages_in_fixture);
- // filter by single label
+ // filter by project
let response = server
.get(format!(
- "/api/organizations/{org_1}/projects/{proj_1}/emails?labels=label-1"
+ "/api/organizations/{org_1}/emails?project={proj_1}"
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let messages: Vec = deserialize_body(response.into_body()).await;
- assert_eq!(messages.len(), 3);
+ assert_eq!(messages.len(), 5);
- // filter by multiple labels
+ // filter by single label
let response = server
.get(format!(
- "/api/organizations/{org_1}/projects/{proj_1}/emails?labels=label-1,label-2"
+ "/api/organizations/{org_1}/emails?project={proj_1}&labels=label-1"
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let messages: Vec = deserialize_body(response.into_body()).await;
- assert_eq!(messages.len(), 4);
+ assert_eq!(messages.len(), 3);
- // filter without labels
+ // filter by multiple labels
let response = server
.get(format!(
- "/api/organizations/{org_1}/projects/{proj_1}/emails"
+ "/api/organizations/{org_1}/emails?project={proj_1}&labels=label-1,label-2"
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let messages: Vec = deserialize_body(response.into_body()).await;
- assert_eq!(messages.len(), 5);
+ assert_eq!(messages.len(), 4);
// list labels
let response = server
- .get(format!(
- "/api/organizations/{org_1}/projects/{proj_1}/labels"
- ))
+ .get(format!("/api/organizations/{org_1}/emails/labels"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let messages: Vec