rework V1
This commit is contained in:
parent
137b913360
commit
df55228f9b
19 changed files with 10324 additions and 0 deletions
139
Data/src/Components/AliasRow.tsx
Normal file
139
Data/src/Components/AliasRow.tsx
Normal file
|
@ -0,0 +1,139 @@
|
|||
import {
|
||||
ActionIcon,
|
||||
CopyButton,
|
||||
Group,
|
||||
Skeleton,
|
||||
Text,
|
||||
Tooltip
|
||||
} from "@mantine/core"
|
||||
import { notifications } from "@mantine/notifications"
|
||||
import dayjs from "dayjs"
|
||||
import relativeTime from "dayjs/plugin/relativeTime"
|
||||
import { type Dispatch, type SetStateAction, useState } from "react"
|
||||
import {
|
||||
ClipboardCheck,
|
||||
Copy,
|
||||
Inbox,
|
||||
InboxOff,
|
||||
Trash
|
||||
} from "tabler-icons-react"
|
||||
|
||||
import { type Alias, type Settings, deleteAlias, updateAlias } from "~utils"
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
interface AliasRowProps {
|
||||
settings: Settings
|
||||
alias: Alias
|
||||
setAliases: Dispatch<SetStateAction<Alias[]>>
|
||||
}
|
||||
|
||||
export function AliasRow({ settings, alias, setAliases }: AliasRowProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// TODO smooth animations for deletions and generating aliases
|
||||
// TODO possibly an undo button after delete that stays until you close the popup
|
||||
|
||||
return (
|
||||
<tr key={alias.id.toString()}>
|
||||
<td>
|
||||
<Text
|
||||
size="md"
|
||||
weight={500}
|
||||
strikethrough={!alias.active}
|
||||
c={alias.active ? "" : "dimmed"}>
|
||||
{alias.address}
|
||||
</Text>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<Text strikethrough={!alias.active} c={alias.active ? "" : "dimmed"}>
|
||||
{alias.targetAddress}
|
||||
</Text>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{/* <Text>{alias.modified.toLocaleString()}</Text> */}
|
||||
<Text>{dayjs(alias.created).fromNow()}</Text>
|
||||
</td>
|
||||
|
||||
{/* TODO: figure out why tooltips are under table header */}
|
||||
<td className="actions">
|
||||
<Skeleton visible={false}>
|
||||
<Group spacing={4} position="right">
|
||||
<CopyButton value={alias.address}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip sx={{ zIndex: 2 }} label={copied ? "Copied" : "Copy"}>
|
||||
<ActionIcon
|
||||
aria-label="Copy"
|
||||
onClick={copy}
|
||||
color={copied ? "lime" : "teal"}
|
||||
variant="light">
|
||||
{copied ? <ClipboardCheck size={16} /> : <Copy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
|
||||
<Tooltip label={alias.active ? "Disable" : "Enable"}>
|
||||
<ActionIcon
|
||||
aria-label={alias.active ? "Disable" : "Enable"}
|
||||
color={alias.active ? "yellow" : "lime"}
|
||||
variant="light"
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
setLoading(true)
|
||||
const update = await updateAlias(
|
||||
alias.id,
|
||||
settings,
|
||||
alias.active ? 0 : 1
|
||||
)
|
||||
if (!update) {
|
||||
setLoading(false)
|
||||
return notifications.show({
|
||||
title: "Error",
|
||||
message: `Failed to ${
|
||||
alias.active ? "disable" : "enable"
|
||||
} alias`,
|
||||
color: "red"
|
||||
})
|
||||
}
|
||||
|
||||
alias.active = !alias.active
|
||||
setLoading(false)
|
||||
}}>
|
||||
{alias.active ? <InboxOff size={16} /> : <Inbox size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete">
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="light"
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
setLoading(true)
|
||||
|
||||
const del = await deleteAlias(alias.id, settings)
|
||||
if (!del) {
|
||||
setLoading(false)
|
||||
return notifications.show({
|
||||
title: "Error",
|
||||
message: `Failed to delete alias`,
|
||||
color: "red"
|
||||
})
|
||||
}
|
||||
|
||||
setAliases((aliases) =>
|
||||
aliases.filter((a) => a.id !== alias.id)
|
||||
)
|
||||
setLoading(false)
|
||||
}}>
|
||||
<Trash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Skeleton>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
90
Data/src/Components/AliasTable.tsx
Normal file
90
Data/src/Components/AliasTable.tsx
Normal file
|
@ -0,0 +1,90 @@
|
|||
import {
|
||||
ActionIcon,
|
||||
ScrollArea,
|
||||
Table,
|
||||
Tooltip,
|
||||
createStyles
|
||||
} from "@mantine/core"
|
||||
import { type Dispatch, type SetStateAction, useState } from "react"
|
||||
import { Settings as SettingsIcon } from "tabler-icons-react"
|
||||
|
||||
import type { Alias, Settings } from "~utils"
|
||||
|
||||
import { AliasRow } from "./AliasRow"
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
header: {
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
backgroundColor: theme.colors.dark[7],
|
||||
transition: "box-shadow 150ms ease",
|
||||
zIndex: 1,
|
||||
"&::after": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
borderBottom: `1px solid ${theme.colors.dark[3]}`
|
||||
}
|
||||
},
|
||||
|
||||
scrolled: {
|
||||
boxShadow: theme.shadows.sm
|
||||
}
|
||||
}))
|
||||
|
||||
interface AliasTableProps {
|
||||
settings: Settings
|
||||
aliases: Alias[]
|
||||
setAliases: Dispatch<SetStateAction<Alias[]>>
|
||||
}
|
||||
//TODO possibily cache stuff
|
||||
export function AliasTable({ settings, aliases, setAliases }: AliasTableProps) {
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
|
||||
const { classes, cx } = useStyles()
|
||||
//TODO fix loading
|
||||
return (
|
||||
<ScrollArea
|
||||
sx={{ height: 300 }}
|
||||
onScrollPositionChange={({ y }) => setScrolled(y !== 0)}
|
||||
offsetScrollbars>
|
||||
<Table
|
||||
sx={{ maxHeight: 400, maxWidth: 800, isolation: "isolate" }}
|
||||
verticalSpacing="xs">
|
||||
<thead className={cx(classes.header, { [classes.scrolled]: scrolled })}>
|
||||
<tr>
|
||||
<th>Alias</th>
|
||||
<th>Target</th>
|
||||
<th>Created</th>
|
||||
<th style={{ float: "right" }}>
|
||||
<Tooltip label="Settings" position="left">
|
||||
<ActionIcon
|
||||
aria-label="Settings"
|
||||
component="a"
|
||||
href="/options.html"
|
||||
target="_blank">
|
||||
<SettingsIcon size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* TODO show current website aliases on top */}
|
||||
{aliases
|
||||
.sort((a, b) => b.created - a.created)
|
||||
.map((alias) => (
|
||||
<AliasRow
|
||||
settings={settings}
|
||||
alias={alias}
|
||||
setAliases={setAliases}
|
||||
key={alias.id.toString()}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
66
Data/src/background.ts
Normal file
66
Data/src/background.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
import { Storage } from "@plasmohq/storage"
|
||||
import { type Settings, generateAlias } from "~utils"
|
||||
|
||||
chrome.runtime.onInstalled.addListener(async (details) => {
|
||||
if (details.reason !== "install") return
|
||||
|
||||
// set initial settings
|
||||
const storage = new Storage()
|
||||
|
||||
await storage.set("settings", {
|
||||
host: "",
|
||||
apiKey: "",
|
||||
forwardAddress: "",
|
||||
aliasDomain: "",
|
||||
generationMethod: 1
|
||||
})
|
||||
await storage.set("initialSetup", true)
|
||||
|
||||
// open options page
|
||||
chrome.runtime.openOptionsPage()
|
||||
})
|
||||
|
||||
// this adds a menu item when you right click
|
||||
chrome.contextMenus.create({
|
||||
title: "Generate Alias",
|
||||
id: "generateAlias",
|
||||
contexts: ["all"]
|
||||
})
|
||||
|
||||
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
||||
if (info.menuItemId === "generateAlias") {
|
||||
const storage = new Storage()
|
||||
const settings: Settings = await storage.get("settings")
|
||||
|
||||
const configured =
|
||||
settings.host &&
|
||||
settings.apiKey &&
|
||||
settings.forwardAddress &&
|
||||
settings.aliasDomain
|
||||
|
||||
if (!configured) {
|
||||
// TODO error stuff (maybe create another message handler in content.ts for popups)
|
||||
console.log("not configured")
|
||||
return
|
||||
}
|
||||
|
||||
const hostname = new URL(tab!.url!).hostname
|
||||
|
||||
const alias = await generateAlias(settings as Required<Settings>, hostname)
|
||||
|
||||
// once service workers support the clipboard api, we won't have to do this messaging stuff
|
||||
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
|
||||
chrome.tabs.sendMessage(
|
||||
tabs[0].id,
|
||||
{
|
||||
message: "copyText",
|
||||
textToCopy: alias.address
|
||||
},
|
||||
function (response) {}
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export {}
|
19
Data/src/content.ts
Normal file
19
Data/src/content.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
export {}
|
||||
|
||||
chrome.runtime.onMessage.addListener(
|
||||
function(request) {
|
||||
if (request.message === "copyText"){
|
||||
copyToClipboard(request.textToCopy);
|
||||
//TODO (maybe) create popup modal notifying user that text was copied
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
const el = document.createElement("textarea")
|
||||
el.value = text
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
document.execCommand("copy")
|
||||
document.body.removeChild(el)
|
||||
}
|
350
Data/src/options.tsx
Normal file
350
Data/src/options.tsx
Normal file
|
@ -0,0 +1,350 @@
|
|||
import {
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Group,
|
||||
Modal,
|
||||
PasswordInput,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Skeleton,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
createStyles
|
||||
} from "@mantine/core"
|
||||
import { notifications } from "@mantine/notifications"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { useStorage } from "@plasmohq/storage/hook"
|
||||
import { Storage } from "@plasmohq/storage"
|
||||
import { ThemeProvider } from "~theme"
|
||||
import { type Settings, fetchDomains, generateEmail } from "~utils"
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
item: {
|
||||
"& + &": {
|
||||
paddingTop: theme.spacing.sm,
|
||||
marginTop: theme.spacing.sm,
|
||||
borderTop: `1px solid ${theme.colors.dark[4]}`
|
||||
}
|
||||
},
|
||||
collapse: {
|
||||
paddingTop: theme.spacing.sm,
|
||||
marginTop: theme.spacing.sm,
|
||||
borderTop: `1px solid ${theme.colors.dark[4]}`
|
||||
}
|
||||
}))
|
||||
|
||||
export default function IndexOptions() {
|
||||
const { classes } = useStyles()
|
||||
|
||||
const [settings, updateSettings] = useStorage<Settings | "loading">(
|
||||
"settings",
|
||||
"loading"
|
||||
)
|
||||
console.log(settings)
|
||||
const [initialSetup, setInitialSetup] = useStorage<boolean>(
|
||||
"initialSetup",
|
||||
false
|
||||
)
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// for temporary storage of config values. once they are valid, they are saved in storage
|
||||
const [host, setHost] = useState("")
|
||||
//TODO store apikey securely (maybe have an option for using the SecureStorage API and setting a password)
|
||||
const [apiKey, setApiKey] = useState("")
|
||||
const [forwardAddress, setForwardAddress] = useState("")
|
||||
|
||||
const [domains, setDomains] = useState<string[]>([])
|
||||
|
||||
const storage = new Storage()
|
||||
|
||||
storage.watch({
|
||||
settings: (s) => {
|
||||
console.log(s.newValue)
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// load settings from storage only after they have loaded
|
||||
if (settings === "loading") return
|
||||
|
||||
// set the state of the config values to the values in storage
|
||||
setHost((settings as Settings).host ?? "")
|
||||
setApiKey((settings as Settings).apiKey ?? "")
|
||||
setForwardAddress((settings as Settings).forwardAddress ?? "")
|
||||
}, [settings])
|
||||
|
||||
const changesSaved =
|
||||
settings !== "loading" &&
|
||||
settings.host === host &&
|
||||
settings.apiKey === apiKey &&
|
||||
settings.forwardAddress === forwardAddress
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Modal
|
||||
opened={initialSetup}
|
||||
centered
|
||||
withCloseButton={false}
|
||||
closeOnClickOutside={false}
|
||||
onClose={() => setInitialSetup(false)}
|
||||
title={<Text fw={700}>Thank you for installing!</Text>}>
|
||||
<Text c="dimmed">
|
||||
Please configure your Mailcow details to use the extension. You can
|
||||
change these settings later in the extension's settings.
|
||||
<br />
|
||||
To generate an alias, right click anywhere on a page and select the
|
||||
"Generate Alias" option. The alias will be automatically copied to
|
||||
your clipboard.
|
||||
<br />
|
||||
If you want to view all your aliases, click on the icon in the
|
||||
extension menu.
|
||||
</Text>
|
||||
<Button fullWidth mt={10} onClick={() => setInitialSetup(false)}>
|
||||
I Understand
|
||||
</Button>
|
||||
</Modal>
|
||||
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="xl"
|
||||
m="lg"
|
||||
sx={{ backgroundColor: "dark.7" }}>
|
||||
<Skeleton visible={settings === "loading"}>
|
||||
<Group position="apart">
|
||||
<div>
|
||||
<Text size="lg" sx={{ lineHeight: 1 }} weight={700}>
|
||||
Extension Configuration
|
||||
</Text>
|
||||
<Text size="xs" color="dimmed" mt={3} mb="xl">
|
||||
Configure your Mailcow details and alias generation method
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Text
|
||||
size="xs"
|
||||
color="dimmed"
|
||||
c={changesSaved ? "gray.4" : "red.8"}>
|
||||
{changesSaved ? "All changes saved" : "Unsaved changes"}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group position="apart" noWrap spacing="xl" className={classes.item}>
|
||||
<div>
|
||||
<Text>Host</Text>
|
||||
<Text size="xs" color="dimmed">
|
||||
URL of your Mailcow instance
|
||||
</Text>
|
||||
</div>
|
||||
<TextInput
|
||||
value={host}
|
||||
onChange={(event) => {
|
||||
event.currentTarget.value.match(
|
||||
/^(https?:\/\/)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/gi
|
||||
)
|
||||
? updateSettings({
|
||||
...(settings as Settings),
|
||||
host: event.currentTarget.value
|
||||
})
|
||||
: setHost(event.currentTarget.value)
|
||||
}}
|
||||
error={
|
||||
host === "" ||
|
||||
host.match(
|
||||
/^(https?:\/\/)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/gi
|
||||
)
|
||||
? null
|
||||
: "Invalid Host"
|
||||
}
|
||||
placeholder="https://mail.example.com"
|
||||
size="md"
|
||||
sx={{ width: "30%" }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group position="apart" noWrap spacing="xl" className={classes.item}>
|
||||
<div>
|
||||
<Text>API Key</Text>
|
||||
<Text size="xs" color="dimmed">
|
||||
API key of your Mailcow instance
|
||||
</Text>
|
||||
</div>
|
||||
<PasswordInput
|
||||
value={apiKey}
|
||||
onChange={(event) => {
|
||||
// replace any character that can't be part of an API key.
|
||||
// it is easy to accidentally copy a bunch of spaces or newlines when copying the API key
|
||||
const cleaned = event.currentTarget.value.replace(
|
||||
/[^\w\d-]+/g,
|
||||
""
|
||||
)
|
||||
cleaned.match(/^([\w]{6}-){4}[\w]{6}$/g)
|
||||
? updateSettings({
|
||||
...(settings as Settings),
|
||||
apiKey: cleaned
|
||||
})
|
||||
: setApiKey(cleaned)
|
||||
}}
|
||||
error={
|
||||
apiKey === "" || apiKey.match(/^([\w]{6}-){4}[\w]{6}$/g)
|
||||
? null
|
||||
: "Invalid API Key"
|
||||
}
|
||||
placeholder="XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX"
|
||||
size="md"
|
||||
sx={{ width: "30%" }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group position="apart" noWrap spacing="xl" className={classes.item}>
|
||||
<div>
|
||||
<Text>Forwarding Address</Text>
|
||||
<Text size="xs" color="dimmed">
|
||||
Default address for email to be forwarded to
|
||||
</Text>
|
||||
</div>
|
||||
<TextInput
|
||||
value={forwardAddress}
|
||||
onChange={(event) => {
|
||||
event.currentTarget.value.match(
|
||||
/^[\w-\.]+@([\w-]+\.)+[\w-]{2,6}$/g
|
||||
)
|
||||
? updateSettings({
|
||||
...(settings as Settings),
|
||||
forwardAddress: event.currentTarget.value
|
||||
})
|
||||
: setForwardAddress(event.currentTarget.value)
|
||||
}}
|
||||
error={
|
||||
forwardAddress === "" ||
|
||||
forwardAddress.match(/^[\w-\.]+@([\w-]+\.)+[\w-]{2,6}$/g)
|
||||
? null
|
||||
: "Invalid Email Address"
|
||||
}
|
||||
placeholder="example@example.com"
|
||||
size="md"
|
||||
sx={{ width: "30%" }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Collapse in={!!domains.length} className={classes.collapse}>
|
||||
<Group position="apart" noWrap spacing="xl">
|
||||
<div>
|
||||
<Text>Alias Domain</Text>
|
||||
<Text size="xs" color="dimmed">
|
||||
Default domain used for generating aliases
|
||||
</Text>
|
||||
</div>
|
||||
<Select
|
||||
size="md"
|
||||
sx={{ width: "30%" }}
|
||||
data={domains}
|
||||
value={(settings as Settings).aliasDomain}
|
||||
onChange={(value) =>
|
||||
updateSettings({
|
||||
...(settings as Settings),
|
||||
aliasDomain: value
|
||||
})
|
||||
}
|
||||
transitionProps={{
|
||||
transition: "pop-top-left",
|
||||
duration: 80,
|
||||
timingFunction: "ease"
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
position="apart"
|
||||
noWrap
|
||||
spacing="xl"
|
||||
className={classes.collapse}>
|
||||
<div>
|
||||
<Text>Generation Method</Text>
|
||||
<Text size="xs" color="dimmed">
|
||||
How the alias addresses should be generated
|
||||
</Text>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
size="md"
|
||||
sx={{ width: "30%" }}
|
||||
data={[
|
||||
{ label: "Random Characters", value: "0" },
|
||||
{ label: "Random Name", value: "1" }
|
||||
]}
|
||||
value={(settings as Settings).generationMethod?.toString()}
|
||||
onChange={(value) =>
|
||||
updateSettings({
|
||||
...(settings as Settings),
|
||||
generationMethod: parseInt(value)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
position="apart"
|
||||
noWrap
|
||||
spacing="xl"
|
||||
className={classes.collapse}>
|
||||
<div>
|
||||
<Title order={2}>Example Alias</Title>
|
||||
</div>
|
||||
<Text>
|
||||
{generateEmail(settings as Required<Settings>, "example.com")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Collapse>
|
||||
|
||||
<Group position="center" mt="sm" pt="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
size="xl"
|
||||
fullWidth
|
||||
loading={loading}
|
||||
disabled={
|
||||
!(
|
||||
settings !== "loading" &&
|
||||
settings.host &&
|
||||
settings.apiKey &&
|
||||
settings.forwardAddress
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const domains = await fetchDomains(
|
||||
settings as Required<Settings>
|
||||
)
|
||||
if (!domains.length)
|
||||
return notifications.show({
|
||||
title: "No Domains Found",
|
||||
message:
|
||||
"No valid domains were found on your Mailcow instance",
|
||||
color: "red"
|
||||
})
|
||||
setDomains(domains)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
notifications.show({
|
||||
title: "Error fetching Mailcow information",
|
||||
message: e.message,
|
||||
color: "red"
|
||||
})
|
||||
}
|
||||
setLoading(false)
|
||||
})()
|
||||
}}>
|
||||
{loading ? "Loading" : "Load"} Mailcow Information
|
||||
</Button>
|
||||
</Group>
|
||||
</Skeleton>
|
||||
</Card>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
73
Data/src/popup.tsx
Normal file
73
Data/src/popup.tsx
Normal file
|
@ -0,0 +1,73 @@
|
|||
import { Alert, Skeleton } from "@mantine/core"
|
||||
import { useEffect, useState } from "react"
|
||||
import { AlertCircle } from "tabler-icons-react"
|
||||
import { useStorage } from "@plasmohq/storage/hook"
|
||||
import { AliasTable } from "~Components/AliasTable"
|
||||
import { ThemeProvider } from "~theme"
|
||||
import { type Alias, type Settings, fetchAliases, generateHash } from "~utils"
|
||||
|
||||
export default function IndexPopup() {
|
||||
//TODO: rename because its no longer a hash
|
||||
const [siteHash, setSiteHash] = useState<string>()
|
||||
const [aliases, setAliases] = useState<Alias[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// initialize as loading and then set to the actual value
|
||||
const [settings] = useStorage<Settings | "loading">("settings", "loading")
|
||||
|
||||
// fetch the window data of current tab
|
||||
chrome.tabs.query(
|
||||
{ active: true, windowId: chrome.windows.WINDOW_ID_CURRENT },
|
||||
async (t) =>
|
||||
|
||||
setSiteHash(new URL(t[0].url!).hostname)
|
||||
)
|
||||
|
||||
const configured =
|
||||
settings !== "loading" &&
|
||||
settings.host &&
|
||||
settings.apiKey &&
|
||||
settings.forwardAddress &&
|
||||
settings.aliasDomain
|
||||
|
||||
useEffect(() => {
|
||||
if (!configured) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
; (async () => {
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
setAliases(await fetchAliases(settings as Required<Settings>))
|
||||
setLoading(false)
|
||||
})()
|
||||
}, [configured])
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Skeleton
|
||||
visible={settings === "loading" || loading}
|
||||
sx={{ minWidth: "600px", minHeight: "300px" }}>
|
||||
{configured ? (
|
||||
<AliasTable
|
||||
settings={settings}
|
||||
aliases={aliases}
|
||||
setAliases={setAliases}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
icon={<AlertCircle size={16} />}
|
||||
title="Warning"
|
||||
color="yellow">
|
||||
Initial setup is missing to begin using this extension. Please visit
|
||||
the{" "}
|
||||
<a href="/options.html" target="_blank">
|
||||
options
|
||||
</a>{" "}
|
||||
page and configure your Mailcow instance.
|
||||
</Alert>
|
||||
)}
|
||||
</Skeleton>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
38
Data/src/theme.tsx
Normal file
38
Data/src/theme.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { MantineProvider } from "@mantine/core"
|
||||
import type { EmotionCache } from "@mantine/core"
|
||||
import { Notifications } from "@mantine/notifications"
|
||||
import type { PropsWithChildren } from "react"
|
||||
|
||||
interface Props extends PropsWithChildren {
|
||||
emotionCache?: EmotionCache
|
||||
}
|
||||
|
||||
export function ThemeProvider({ emotionCache, children }: Props) {
|
||||
return (
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
theme={{
|
||||
colorScheme: "dark",
|
||||
colors: {
|
||||
moo: [
|
||||
"#fffcde",
|
||||
"#fbf5b2",
|
||||
"#f8ef85",
|
||||
"#f6e956",
|
||||
"#f3e228",
|
||||
"#dac912",
|
||||
"#a99c09",
|
||||
"#797004",
|
||||
"#494300",
|
||||
"#191600"
|
||||
]
|
||||
},
|
||||
primaryColor: "moo"
|
||||
}}
|
||||
emotionCache={emotionCache}>
|
||||
<Notifications />
|
||||
{children}
|
||||
</MantineProvider>
|
||||
)
|
||||
}
|
251
Data/src/utils.ts
Normal file
251
Data/src/utils.ts
Normal file
|
@ -0,0 +1,251 @@
|
|||
import { randAlphaNumeric, randEmail } from "@ngneat/falso"
|
||||
|
||||
// import { createHash } from "crypto";
|
||||
|
||||
// change the number depending on the version of the extension, this is to prevent conflicts with breaking changes
|
||||
const PREFIX = "aliasaddon_2"
|
||||
|
||||
export interface Alias {
|
||||
// id that mailcow assigns alias
|
||||
id: number
|
||||
// domain of alias
|
||||
domain: string
|
||||
targetAddress: string
|
||||
address: string
|
||||
active: boolean
|
||||
created: number
|
||||
// modified: Date
|
||||
// hash of site
|
||||
siteHash: string
|
||||
}
|
||||
|
||||
enum GenerationMethod {
|
||||
RandomCharacters = 0,
|
||||
RandomName = 1
|
||||
// WebsiteURL = 2
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
host?: string
|
||||
apiKey?: string
|
||||
forwardAddress?: string
|
||||
aliasDomain: string | null
|
||||
generationMethod: GenerationMethod
|
||||
}
|
||||
|
||||
interface FetchAliasData {
|
||||
id: number
|
||||
domain: string
|
||||
// private comment includes the site hash and will start with aliasextension_ if it was generated by this extension
|
||||
private_comment: string | null
|
||||
// target address
|
||||
goto: string
|
||||
// alias address
|
||||
address: string
|
||||
active: number
|
||||
active_int: number
|
||||
created: string
|
||||
// modified will be null if the alias has never been modified
|
||||
modified: string | null
|
||||
}
|
||||
|
||||
export async function fetchDomains(
|
||||
settings: Required<Settings>
|
||||
): Promise<string[]> {
|
||||
const controller = new AbortController()
|
||||
const id = setTimeout(() => controller.abort(), 5000)
|
||||
|
||||
const data: {
|
||||
domain_name: string
|
||||
aliases_left: number
|
||||
active: number
|
||||
active_int: number
|
||||
}[] = await (
|
||||
await fetch(`${settings.host}/api/v1/get/domain/all`, {
|
||||
headers: {
|
||||
"X-API-Key": settings.apiKey
|
||||
},
|
||||
signal: controller.signal
|
||||
})
|
||||
).json()
|
||||
clearTimeout(id)
|
||||
|
||||
return data
|
||||
.filter((domain) => domain.active === 1 && domain.aliases_left > 0)
|
||||
.map((domain) => domain.domain_name)
|
||||
}
|
||||
|
||||
export async function fetchAliases(
|
||||
settings: Required<Settings>
|
||||
): Promise<Alias[]> {
|
||||
const data: FetchAliasData[] = await (
|
||||
await fetch(`${settings.host}/api/v1/get/alias/all`, {
|
||||
headers: {
|
||||
"X-API-Key": settings.apiKey
|
||||
}
|
||||
})
|
||||
).json()
|
||||
|
||||
return data
|
||||
.filter(
|
||||
// make sure alias is active and was generated by this extension
|
||||
(alias) =>
|
||||
alias.private_comment && alias.private_comment.startsWith(PREFIX)
|
||||
)
|
||||
.map((alias) => {
|
||||
// format: prefix, version, hash, timestamp
|
||||
const info = alias.private_comment!.split("_")
|
||||
|
||||
return {
|
||||
id: alias.id,
|
||||
domain: alias.domain,
|
||||
targetAddress: alias.goto,
|
||||
address: alias.address,
|
||||
active: alias.active === 1,
|
||||
created: parseInt(info[3]),
|
||||
// modified: alias.modified ? new Date(`${alias.modified}.000Z`) : null,
|
||||
siteHash: info[2]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function generateAlias(
|
||||
settings: Required<Settings>,
|
||||
hostname?: string
|
||||
): Promise<Alias> {
|
||||
const address = generateEmail(settings, hostname)
|
||||
// const hash = await generateHash(hostname ?? "no")
|
||||
const hash = hostname ?? "nohostname"
|
||||
// although mailcow has its own date, the format they use sucks
|
||||
const createdAt = Date.now()
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000)
|
||||
|
||||
// first in msg array should be "alias_added", second is the address, third is the id as a string
|
||||
const data = await (
|
||||
await fetch(`${settings.host}/api/v1/add/alias`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": settings.apiKey
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
address,
|
||||
goto: settings.forwardAddress,
|
||||
active: "1",
|
||||
private_comment: `${PREFIX}_${hash}_${createdAt}`
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
).json()
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (data[0].type !== "success") {
|
||||
throw new Error("Failed to generate alias")
|
||||
}
|
||||
|
||||
return {
|
||||
id: parseInt(data[0].msg[2]),
|
||||
domain: settings.aliasDomain!,
|
||||
targetAddress: settings.apiKey,
|
||||
address,
|
||||
active: true,
|
||||
// mailcow returns dates in weird format so not using them (https://github.com/mailcow/mailcow-dockerized/issues/4876)
|
||||
created: createdAt,
|
||||
// modified: null,
|
||||
siteHash: hash
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAlias(
|
||||
id: number,
|
||||
settings: Settings,
|
||||
active: 0 | 1
|
||||
): Promise<boolean> {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000)
|
||||
|
||||
const data = await (
|
||||
await fetch(`${settings.host}/api/v1/edit/alias/${id}`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": settings.apiKey!
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
attr: { active: active.toString() },
|
||||
items: [id]
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
).json()
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
return data[0].type === "success"
|
||||
}
|
||||
|
||||
export async function deleteAlias(
|
||||
id: number,
|
||||
settings: Settings
|
||||
): Promise<boolean> {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000)
|
||||
|
||||
const data = await (
|
||||
await fetch(`${settings.host}/api/v1/delete/alias`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": settings.apiKey!
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify([id]),
|
||||
signal: controller.signal
|
||||
})
|
||||
).json()
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
return data[0].type === "success"
|
||||
}
|
||||
|
||||
// export function generateHash(data: string) {
|
||||
// return createHash("sha256").update(data).digest("hex");
|
||||
// }
|
||||
|
||||
export async function generateHash(data: string) {
|
||||
return Array.from(
|
||||
new Uint8Array(
|
||||
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(data))
|
||||
)
|
||||
)
|
||||
.map((bytes) => bytes.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
}
|
||||
|
||||
export function generateEmail(
|
||||
settings: Required<Settings>,
|
||||
hostname?: string
|
||||
): string {
|
||||
switch (settings.generationMethod) {
|
||||
case GenerationMethod.RandomCharacters:
|
||||
return `${randAlphaNumeric({ length: 16 }).join("")}@${
|
||||
settings.aliasDomain
|
||||
}`
|
||||
|
||||
case GenerationMethod.RandomName:
|
||||
console.log(settings)
|
||||
if (!settings.aliasDomain) {
|
||||
return randEmail({ provider: "example", suffix: "com" })
|
||||
}
|
||||
const domain = settings.aliasDomain.split(".")
|
||||
const suffix = domain.pop()
|
||||
const provider = domain.join(".")
|
||||
|
||||
return randEmail({ provider, suffix })
|
||||
|
||||
// case GenerationMethod.WebsiteURL:
|
||||
// return `${hostname.replace(".", "_")}_${faker.random.numeric(3)}@${
|
||||
// settings.aliasDomain
|
||||
// }`
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue