rework V1

This commit is contained in:
Kaby_Kun 2025-02-20 00:29:13 +01:00
parent 137b913360
commit df55228f9b
19 changed files with 10324 additions and 0 deletions

View 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>
)
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
// }`
}
}