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