auto unsubscribe on bounce and compaints (#117)
* auto unsubscribe on bounce and compaints * console
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "UnsubscribeReason" AS ENUM ('BOUNCED', 'COMPLAINED', 'UNSUBSCRIBED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Contact" ADD COLUMN "unsubscribeReason" "UnsubscribeReason";
|
@@ -1,3 +1,3 @@
|
|||||||
# Please do not edit this file manually
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (i.e. Git)
|
# It should be added in your version-control system (e.g., Git)
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
@@ -235,16 +235,23 @@ model ContactBook {
|
|||||||
@@index([teamId])
|
@@index([teamId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum UnsubscribeReason {
|
||||||
|
BOUNCED
|
||||||
|
COMPLAINED
|
||||||
|
UNSUBSCRIBED
|
||||||
|
}
|
||||||
|
|
||||||
model Contact {
|
model Contact {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
firstName String?
|
firstName String?
|
||||||
lastName String?
|
lastName String?
|
||||||
email String
|
email String
|
||||||
subscribed Boolean @default(true)
|
subscribed Boolean @default(true)
|
||||||
properties Json
|
unsubscribeReason UnsubscribeReason?
|
||||||
contactBookId String
|
properties Json
|
||||||
createdAt DateTime @default(now())
|
contactBookId String
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
contactBook ContactBook @relation(fields: [contactBookId], references: [id], onDelete: Cascade)
|
contactBook ContactBook @relation(fields: [contactBookId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@ -25,6 +25,27 @@ import DeleteContact from "./delete-contact";
|
|||||||
import EditContact from "./edit-contact";
|
import EditContact from "./edit-contact";
|
||||||
import { Input } from "@unsend/ui/src/input";
|
import { Input } from "@unsend/ui/src/input";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@unsend/ui/src/tooltip";
|
||||||
|
import { UnsubscribeReason } from "@prisma/client";
|
||||||
|
|
||||||
|
function getUnsubscribeReason(reason: UnsubscribeReason) {
|
||||||
|
switch (reason) {
|
||||||
|
case UnsubscribeReason.BOUNCED:
|
||||||
|
return "Email bounced";
|
||||||
|
case UnsubscribeReason.COMPLAINED:
|
||||||
|
return "User complained";
|
||||||
|
case UnsubscribeReason.UNSUBSCRIBED:
|
||||||
|
return "User unsubscribed";
|
||||||
|
default:
|
||||||
|
return "User unsubscribed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContactList({
|
export default function ContactList({
|
||||||
contactBookId,
|
contactBookId,
|
||||||
}: {
|
}: {
|
||||||
@@ -53,131 +74,145 @@ export default function ContactList({
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-10 flex flex-col gap-4">
|
<TooltipProvider>
|
||||||
<div className="flex justify-between">
|
<div className="mt-10 flex flex-col gap-4">
|
||||||
<div>
|
<div className="flex justify-between">
|
||||||
<Input
|
<div>
|
||||||
placeholder="Search by email or name"
|
<Input
|
||||||
className="w-[350px] mr-4"
|
placeholder="Search by email or name"
|
||||||
defaultValue={search ?? ""}
|
className="w-[350px] mr-4"
|
||||||
onChange={(e) => debouncedSearch(e.target.value)}
|
defaultValue={search ?? ""}
|
||||||
/>
|
onChange={(e) => debouncedSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={status ?? "All"}
|
||||||
|
onValueChange={(val) => setStatus(val === "All" ? null : val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px] capitalize">
|
||||||
|
{status || "All statuses"}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="All" className=" capitalize">
|
||||||
|
All statuses
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="Subscribed" className=" capitalize">
|
||||||
|
Subscribed
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="Unsubscribed" className=" capitalize">
|
||||||
|
Unsubscribed
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<div className="flex flex-col rounded-xl border border-broder shadow">
|
||||||
value={status ?? "All"}
|
<Table className="">
|
||||||
onValueChange={(val) => setStatus(val === "All" ? null : val)}
|
<TableHeader className="">
|
||||||
>
|
<TableRow className=" bg-muted/30">
|
||||||
<SelectTrigger className="w-[180px] capitalize">
|
<TableHead className="rounded-tl-xl">Email</TableHead>
|
||||||
{status || "All statuses"}
|
<TableHead>Status</TableHead>
|
||||||
</SelectTrigger>
|
<TableHead className="">Created At</TableHead>
|
||||||
<SelectContent>
|
<TableHead className="rounded-tr-xl">Actions</TableHead>
|
||||||
<SelectItem value="All" className=" capitalize">
|
|
||||||
All statuses
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="Subscribed" className=" capitalize">
|
|
||||||
Subscribed
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="Unsubscribed" className=" capitalize">
|
|
||||||
Unsubscribed
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col rounded-xl border border-broder shadow">
|
|
||||||
<Table className="">
|
|
||||||
<TableHeader className="">
|
|
||||||
<TableRow className=" bg-muted/30">
|
|
||||||
<TableHead className="rounded-tl-xl">Email</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead className="">Created At</TableHead>
|
|
||||||
<TableHead className="rounded-tr-xl">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{contactsQuery.isLoading ? (
|
|
||||||
<TableRow className="h-32">
|
|
||||||
<TableCell colSpan={4} className="text-center py-4">
|
|
||||||
<Spinner
|
|
||||||
className="w-6 h-6 mx-auto"
|
|
||||||
innerSvgClass="stroke-primary"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : contactsQuery.data?.contacts.length ? (
|
</TableHeader>
|
||||||
contactsQuery.data?.contacts.map((contact) => (
|
<TableBody>
|
||||||
<TableRow key={contact.id} className="">
|
{contactsQuery.isLoading ? (
|
||||||
<TableCell className="font-medium">
|
<TableRow className="h-32">
|
||||||
<div className="flex items-center gap-2">
|
<TableCell colSpan={4} className="text-center py-4">
|
||||||
<Image
|
<Spinner
|
||||||
src={getGravatarUrl(contact.email, {
|
className="w-6 h-6 mx-auto"
|
||||||
size: 75,
|
innerSvgClass="stroke-primary"
|
||||||
defaultImage: "robohash",
|
/>
|
||||||
})}
|
|
||||||
alt={contact.email + "'s gravatar"}
|
|
||||||
width={35}
|
|
||||||
height={35}
|
|
||||||
className="rounded-full"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{contact.email}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{contact.firstName} {contact.lastName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div
|
|
||||||
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
|
|
||||||
contact.subscribed
|
|
||||||
? "bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25"
|
|
||||||
: "bg-red-500/10 text-red-600 dark:text-red-700/90 border border-red-600/10"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{contact.subscribed ? "Subscribed" : "Unsubscribed"}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="">
|
|
||||||
{formatDistanceToNow(new Date(contact.createdAt), {
|
|
||||||
addSuffix: true,
|
|
||||||
})}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<EditContact contact={contact} />
|
|
||||||
<DeleteContact contact={contact} />
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
) : contactsQuery.data?.contacts.length ? (
|
||||||
) : (
|
contactsQuery.data?.contacts.map((contact) => (
|
||||||
<TableRow className="h-32">
|
<TableRow key={contact.id} className="">
|
||||||
<TableCell colSpan={4} className="text-center py-4">
|
<TableCell className="font-medium">
|
||||||
No contacts found
|
<div className="flex items-center gap-2">
|
||||||
</TableCell>
|
<Image
|
||||||
</TableRow>
|
src={getGravatarUrl(contact.email, {
|
||||||
)}
|
size: 75,
|
||||||
</TableBody>
|
defaultImage: "robohash",
|
||||||
</Table>
|
})}
|
||||||
|
alt={contact.email + "'s gravatar"}
|
||||||
|
width={35}
|
||||||
|
height={35}
|
||||||
|
className="rounded-full"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{contact.email}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{contact.firstName} {contact.lastName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{contact.subscribed ? (
|
||||||
|
<div className="text-center w-[130px] rounded capitalize py-1 text-xs bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25">
|
||||||
|
Subscribed
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<div className="text-center w-[130px] rounded capitalize py-1 text-xs bg-red-500/10 text-red-600 dark:text-red-700/90 border border-red-600/10">
|
||||||
|
Unsubscribed
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
{getUnsubscribeReason(
|
||||||
|
contact.unsubscribeReason ??
|
||||||
|
UnsubscribeReason.UNSUBSCRIBED
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="">
|
||||||
|
{formatDistanceToNow(new Date(contact.createdAt), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<EditContact contact={contact} />
|
||||||
|
<DeleteContact contact={contact} />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow className="h-32">
|
||||||
|
<TableCell colSpan={4} className="text-center py-4">
|
||||||
|
No contacts found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((pageNumber - 1).toString())}
|
||||||
|
disabled={pageNumber === 1}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((pageNumber + 1).toString())}
|
||||||
|
disabled={pageNumber >= (contactsQuery.data?.totalPage ?? 0)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 justify-end">
|
</TooltipProvider>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage((pageNumber - 1).toString())}
|
|
||||||
disabled={pageNumber === 1}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage((pageNumber + 1).toString())}
|
|
||||||
disabled={pageNumber >= (contactsQuery.data?.totalPage ?? 0)}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -35,9 +35,8 @@ export async function POST(req: Request) {
|
|||||||
event: message,
|
event: message,
|
||||||
messageId: data.MessageId,
|
messageId: data.MessageId,
|
||||||
});
|
});
|
||||||
console.log("Error is parsing hook", !status);
|
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return Response.json({ data: "Error is parsing hook" });
|
return Response.json({ data: "Error in parsing hook" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json({ data: "Success" });
|
return Response.json({ data: "Success" });
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import { unsubscribeContactFromLink } from "~/server/service/campaign-service";
|
||||||
unsubscribeContact,
|
|
||||||
} from "~/server/service/campaign-service";
|
|
||||||
import ReSubscribe from "./re-subscribe";
|
import ReSubscribe from "./re-subscribe";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
async function UnsubscribePage({
|
async function UnsubscribePage({
|
||||||
@@ -27,7 +26,7 @@ async function UnsubscribePage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const contact = await unsubscribeContact(id, hash);
|
const contact = await unsubscribeContactFromLink(id, hash);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center ">
|
<div className="min-h-screen flex items-center justify-center ">
|
||||||
|
@@ -141,6 +141,7 @@ export const contactsRouter = createTRPCRouter({
|
|||||||
subscribed: true,
|
subscribed: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
contactBookId: true,
|
contactBookId: true,
|
||||||
|
unsubscribeReason: true,
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
createdAt: "desc",
|
createdAt: "desc",
|
||||||
|
@@ -2,7 +2,12 @@ import { EmailRenderer } from "@unsend/email-editor/src/renderer";
|
|||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { Campaign, Contact, EmailStatus } from "@prisma/client";
|
import {
|
||||||
|
Campaign,
|
||||||
|
Contact,
|
||||||
|
EmailStatus,
|
||||||
|
UnsubscribeReason,
|
||||||
|
} from "@prisma/client";
|
||||||
import { validateDomainFromEmail } from "./domain-service";
|
import { validateDomainFromEmail } from "./domain-service";
|
||||||
import { EmailQueueService } from "./email-queue-service";
|
import { EmailQueueService } from "./email-queue-service";
|
||||||
import { Queue, Worker } from "bullmq";
|
import { Queue, Worker } from "bullmq";
|
||||||
@@ -91,7 +96,7 @@ export function createUnsubUrl(contactId: string, campaignId: string) {
|
|||||||
return `${env.NEXTAUTH_URL}/unsubscribe?id=${unsubId}&hash=${unsubHash}`;
|
return `${env.NEXTAUTH_URL}/unsubscribe?id=${unsubId}&hash=${unsubHash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unsubscribeContact(id: string, hash: string) {
|
export async function unsubscribeContactFromLink(id: string, hash: string) {
|
||||||
const [contactId, campaignId] = id.split("-");
|
const [contactId, campaignId] = id.split("-");
|
||||||
|
|
||||||
if (!contactId || !campaignId) {
|
if (!contactId || !campaignId) {
|
||||||
@@ -107,6 +112,18 @@ export async function unsubscribeContact(id: string, hash: string) {
|
|||||||
throw new Error("Invalid unsubscribe link");
|
throw new Error("Invalid unsubscribe link");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return await unsubscribeContact(
|
||||||
|
contactId,
|
||||||
|
campaignId,
|
||||||
|
UnsubscribeReason.UNSUBSCRIBED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unsubscribeContact(
|
||||||
|
contactId: string,
|
||||||
|
campaignId: string,
|
||||||
|
reason: UnsubscribeReason
|
||||||
|
) {
|
||||||
// Update the contact's subscription status
|
// Update the contact's subscription status
|
||||||
try {
|
try {
|
||||||
const contact = await db.contact.findUnique({
|
const contact = await db.contact.findUnique({
|
||||||
@@ -120,7 +137,7 @@ export async function unsubscribeContact(id: string, hash: string) {
|
|||||||
if (contact.subscribed) {
|
if (contact.subscribed) {
|
||||||
await db.contact.update({
|
await db.contact.update({
|
||||||
where: { id: contactId },
|
where: { id: contactId },
|
||||||
data: { subscribed: false },
|
data: { subscribed: false, unsubscribeReason: reason },
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.campaign.update({
|
await db.campaign.update({
|
||||||
|
@@ -50,6 +50,8 @@ export async function createDomain(
|
|||||||
) {
|
) {
|
||||||
const domainStr = tldts.getDomain(name);
|
const domainStr = tldts.getDomain(name);
|
||||||
|
|
||||||
|
console.log("Creating domain", { domainStr, name, region });
|
||||||
|
|
||||||
if (!domainStr) {
|
if (!domainStr) {
|
||||||
throw new Error("Invalid domain");
|
throw new Error("Invalid domain");
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,10 @@
|
|||||||
import { EmailStatus, Prisma } from "@prisma/client";
|
import { EmailStatus, Prisma, UnsubscribeReason } from "@prisma/client";
|
||||||
import { SesClick, SesEvent, SesEventDataKey } from "~/types/aws-types";
|
import { SesClick, SesEvent, SesEventDataKey } from "~/types/aws-types";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { updateCampaignAnalytics } from "./campaign-service";
|
import {
|
||||||
|
unsubscribeContact,
|
||||||
|
updateCampaignAnalytics,
|
||||||
|
} from "./campaign-service";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { getRedis } from "../redis";
|
import { getRedis } from "../redis";
|
||||||
import { Queue, Worker } from "bullmq";
|
import { Queue, Worker } from "bullmq";
|
||||||
@@ -100,6 +103,8 @@ export async function parseSesHook(data: SesEvent) {
|
|||||||
mailStatus !== "CLICKED" ||
|
mailStatus !== "CLICKED" ||
|
||||||
!(mailData as SesClick).link.startsWith(`${env.NEXTAUTH_URL}/unsubscribe`)
|
!(mailData as SesClick).link.startsWith(`${env.NEXTAUTH_URL}/unsubscribe`)
|
||||||
) {
|
) {
|
||||||
|
await checkUnsubscribe(email.campaignId, email.contactId!, mailStatus);
|
||||||
|
|
||||||
const mailEvent = await db.emailEvent.findFirst({
|
const mailEvent = await db.emailEvent.findFirst({
|
||||||
where: {
|
where: {
|
||||||
emailId: email.id,
|
emailId: email.id,
|
||||||
@@ -124,6 +129,22 @@ export async function parseSesHook(data: SesEvent) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkUnsubscribe(
|
||||||
|
campaignId: string,
|
||||||
|
contactId: string,
|
||||||
|
event: EmailStatus
|
||||||
|
) {
|
||||||
|
if (event === EmailStatus.BOUNCED || event === EmailStatus.COMPLAINED) {
|
||||||
|
return unsubscribeContact(
|
||||||
|
contactId,
|
||||||
|
campaignId,
|
||||||
|
event === EmailStatus.BOUNCED
|
||||||
|
? UnsubscribeReason.BOUNCED
|
||||||
|
: UnsubscribeReason.COMPLAINED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getEmailStatus(data: SesEvent) {
|
function getEmailStatus(data: SesEvent) {
|
||||||
const { eventType } = data;
|
const { eventType } = data;
|
||||||
|
|
||||||
|
@@ -31,7 +31,9 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5350:3000"
|
- "5350:3000"
|
||||||
environment:
|
environment:
|
||||||
WEBHOOK_URL: http://localhost:3000/api/ses_callback
|
WEBHOOK_URL: http://host.docker.internal:3000/api/ses_callback
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio
|
image: minio/minio
|
||||||
|
Reference in New Issue
Block a user