Files
FEDEO/frontend/pages/email/new.vue

628 lines
19 KiB
Vue

<script setup>
const route = useRoute()
const router = useRouter()
const toast = useToast()
const auth = useAuthStore()
const emailData = ref({
to:"",
cc:null,
bcc: null,
subject: "",
html: "",
text: "",
account: "",
})
const emailAccounts = ref([])
const preloadedContent = ref("")
const loadedDocuments = ref([])
const loaded = ref(false)
const noAccountsPresent = ref(false)
const sourceMessage = ref(null)
const recipientOptions = ref([])
const selectedRecipient = ref(null)
const loadingRecipients = ref(false)
const preferredCustomerId = ref(null)
const composeMode = computed(() => {
const mode = String(route.query.mode || "")
return ["reply", "replyAll", "forward"].includes(mode) ? mode : "new"
})
const pageTitle = computed(() => ({
reply: "Antworten",
replyAll: "Allen antworten",
forward: "E-Mail weiterleiten",
}[composeMode.value] || "Neue E-Mail"))
const escapeHtml = (value = "") => String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
const formatAddress = (address) => {
if (!address?.address) return ""
return address.name ? `"${String(address.name).replace(/"/g, "\\\"")}" <${address.address}>` : address.address
}
const formatAddressList = (addresses = []) => addresses.map(formatAddress).filter(Boolean).join(", ")
const normalizeEmail = (value = "") => {
const text = String(value).trim()
const bracketMatch = text.match(/<([^<>]+)>/)
return String(bracketMatch?.[1] || text.split(/[;,]/)[0] || "").trim().toLowerCase()
}
const parseInfoData = (value) => {
if (!value) return {}
if (typeof value === "object") return value
try {
return JSON.parse(value)
} catch {
return {}
}
}
const addRecipientOption = (target, option) => {
const email = normalizeEmail(option.email)
if (!email) return
const existing = target.find(item => item.email === email)
if (existing) {
if (option.priority < existing.priority) Object.assign(existing, option, { email })
return
}
target.push({ ...option, email })
}
const syncSelectedRecipient = () => {
const currentValue = String(emailData.value.to || "").trim()
if (!currentValue) {
selectedRecipient.value = null
return
}
const currentEmail = normalizeEmail(currentValue)
const matchingOption = recipientOptions.value.find(option => option.email === currentEmail)
if (matchingOption && (currentValue.match(/@/g) || []).length === 1) {
selectedRecipient.value = matchingOption.value
return
}
const currentOption = {
label: `Aktuelle Empfänger · ${currentValue}`,
value: currentValue,
email: currentEmail,
priority: -2,
}
recipientOptions.value = [
currentOption,
...recipientOptions.value.filter(option => option.value !== currentValue),
]
selectedRecipient.value = currentValue
}
const loadRecipientOptions = async () => {
loadingRecipients.value = true
try {
const [customers, contacts] = await Promise.all([
useEntities("customers").select("*", "name", true),
useEntities("contacts").select("*, customer(id,name)", "fullName", true),
])
const options = []
const customerNames = new Map(customers.map(customer => [Number(customer.id), customer.name]))
customers
.filter(customer => customer.active !== false)
.forEach(customer => {
const infoData = parseInfoData(customer.infoData)
const isPreferred = Number(customer.id) === Number(preferredCustomerId.value)
const priority = isPreferred ? 0 : 20
addRecipientOption(options, {
email: infoData.email,
value: infoData.email,
label: `${isPreferred ? "Passender Kunde" : "Kunde"} · ${customer.name} · ${infoData.email || ""}`,
priority,
customerId: Number(customer.id),
kind: "customer",
})
addRecipientOption(options, {
email: infoData.invoiceEmail,
value: infoData.invoiceEmail,
label: `${isPreferred ? "Passender Kunde" : "Kunde"} · ${customer.name} · Rechnung · ${infoData.invoiceEmail || ""}`,
priority: priority + 1,
customerId: Number(customer.id),
kind: "invoice",
})
})
contacts
.filter(contact => contact.active !== false && contact.email)
.forEach(contact => {
const customerId = Number(contact.customer?.id || contact.customer || 0)
const customerName = contact.customer?.name || customerNames.get(customerId)
const contactName = contact.fullName
|| [contact.firstName, contact.lastName].filter(Boolean).join(" ")
|| contact.email
const isPreferred = customerId && customerId === Number(preferredCustomerId.value)
addRecipientOption(options, {
email: contact.email,
value: formatAddress({ name: contactName, address: contact.email }),
label: [
isPreferred ? "Passender Ansprechpartner" : "Ansprechpartner",
contactName,
customerName,
contact.email,
].filter(Boolean).join(" · "),
priority: isPreferred ? 2 : 30,
customerId,
kind: "contact",
})
})
if (!preferredCustomerId.value) {
const currentEmail = normalizeEmail(emailData.value.to)
preferredCustomerId.value = options.find(option => option.email === currentEmail)?.customerId || null
}
if (preferredCustomerId.value) {
options.forEach(option => {
if (Number(option.customerId) !== Number(preferredCustomerId.value)) return
option.priority = option.kind === "customer" ? 0 : option.kind === "invoice" ? 1 : 2
option.label = option.label
.replace(/^Kunde ·/, "Passender Kunde ·")
.replace(/^Ansprechpartner ·/, "Passender Ansprechpartner ·")
})
}
recipientOptions.value = options.sort((first, second) =>
first.priority - second.priority || first.label.localeCompare(second.label, "de")
)
syncSelectedRecipient()
} catch (err) {
recipientOptions.value = []
syncSelectedRecipient()
toast.add({
title: "Adressbuch konnte nicht geladen werden",
description: err?.data?.error || err?.message,
color: "warning",
})
} finally {
loadingRecipients.value = false
}
}
const selectRecipient = (value) => {
if (!value) return
emailData.value.to = value
}
const prefixedSubject = (prefix, subject = "") => {
const cleanSubject = String(subject || "")
return cleanSubject.toLowerCase().startsWith(prefix.toLowerCase()) ? cleanSubject : `${prefix} ${cleanSubject}`.trim()
}
const originalMessageBlock = (message, mode) => {
const sentAt = message.sentAt || message.receivedAt
const date = sentAt ? new Intl.DateTimeFormat("de-DE", {
dateStyle: "full",
timeStyle: "short",
}).format(new Date(sentAt)) : ""
const originalText = escapeHtml(message.body?.text || message.preview || "").replace(/\n/g, "<br>")
if (mode === "forward") {
return `<hr><p><strong>Weitergeleitete Nachricht</strong><br>`
+ `Von: ${escapeHtml(formatAddressList(message.from))}<br>`
+ `Datum: ${escapeHtml(date)}<br>`
+ `Betreff: ${escapeHtml(message.subject || "(kein Betreff)")}<br>`
+ `An: ${escapeHtml(formatAddressList(message.to))}</p><blockquote>${originalText}</blockquote>`
}
return `<hr><blockquote><p>Am ${escapeHtml(date)} schrieb ${escapeHtml(formatAddressList(message.from))}:</p>${originalText}</blockquote>`
}
const setupPage = async () => {
//emailAccounts.value = await useEntities("emailAccounts").select()
emailAccounts.value = await useNuxtApp().$api("/api/email/accounts")
if(emailAccounts.value.length === 0) {
noAccountsPresent.value = true
} else {
emailData.value.account = emailAccounts.value[0].id
let initialContent = `<p></p><p></p><p></p>${auth.profile.email_signature || ""}`
if (route.query.source && composeMode.value !== "new") {
sourceMessage.value = await useNuxtApp().$api(`/api/email/messages/${route.query.source}`)
const sourceAccount = emailAccounts.value.find(account => account.id === sourceMessage.value.accountId)
if (sourceAccount) emailData.value.account = sourceAccount.id
if (composeMode.value === "forward") {
emailData.value.subject = prefixedSubject("Fw:", sourceMessage.value.subject)
} else {
const ownAddress = String(sourceAccount?.email || "").toLowerCase()
const replyRecipients = sourceMessage.value.replyTo?.length
? sourceMessage.value.replyTo
: sourceMessage.value.from || []
if (composeMode.value === "replyAll") {
const allRecipients = [
...replyRecipients,
...(sourceMessage.value.to || []),
...(sourceMessage.value.cc || []),
].filter(address => address?.address && address.address.toLowerCase() !== ownAddress)
const uniqueRecipients = Array.from(new Map(
allRecipients.map(address => [address.address.toLowerCase(), address])
).values())
emailData.value.to = formatAddressList(uniqueRecipients)
} else {
emailData.value.to = formatAddressList(replyRecipients)
}
emailData.value.subject = prefixedSubject("Re:", sourceMessage.value.subject)
}
initialContent += originalMessageBlock(sourceMessage.value, composeMode.value)
}
preloadedContent.value = initialContent
//Check Query
if(route.query.to) emailData.value.to = route.query.to
if(route.query.cc) emailData.value.cc = route.query.cc
if(route.query.bcc) emailData.value.bcc = route.query.bcc
if(route.query.subject) emailData.value.subject = route.query.subject
if(route.query.loadDocuments) {
const data = await useFiles().selectSomeDocuments(JSON.parse(route.query.loadDocuments))
if(data) loadedDocuments.value = data
loadedDocuments.value = await Promise.all(loadedDocuments.value.map(async doc => {
const document = await useEntities("createddocuments").selectSingle(doc.createddocument)
return {
...doc,
createddocument: document
}}))
if(loadedDocuments.value.length > 0) {
const createdDocument = loadedDocuments.value[0].createddocument
const customerId = Number(createdDocument.customer?.id || createdDocument.customer || 0) || null
const contactId = Number(createdDocument.contact?.id || createdDocument.contact || 0) || null
const customer = createdDocument.customer && typeof createdDocument.customer === "object"
? createdDocument.customer
: customerId
? await useEntities("customers").selectSingle(customerId)
: null
const contact = createdDocument.contact && typeof createdDocument.contact === "object"
? createdDocument.contact
: contactId
? await useEntities("contacts").selectSingle(contactId)
: null
preferredCustomerId.value = customerId || Number(contact?.customer?.id || contact?.customer || 0) || null
emailData.value.subject = `${createdDocument.title} von ${auth.activeTenantData.businessInfo.name}`
if(contact?.email) {
emailData.value.to = formatAddress({
name: contact.fullName,
address: contact.email,
})
} else if(parseInfoData(customer?.infoData).invoiceEmail) {
emailData.value.to = parseInfoData(customer.infoData).invoiceEmail
} else if(parseInfoData(customer?.infoData).email) {
emailData.value.to = parseInfoData(customer.infoData).email
}
}
}
await loadRecipientOptions()
loaded.value = true
}
}
setupPage()
const contentChanged = (content) => {
emailData.value.html = content.html
emailData.value.text = content.text
}
const selectedAttachments = ref([])
const renderAttachments = () => {
selectedAttachments.value = Array.from(document.getElementById("inputAttachments").files).map(i => {
return {
filename: i.name,
type: i.type
}})
}
const toBase64 = file => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result.split(",")[1]);
reader.onerror = reject;
});
function blobToBase64(blob) {
return new Promise((resolve, _) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result.split(",")[1]);
reader.readAsDataURL(blob);
});
}
const sendEmail = async () => {
loaded.value = false
let body = {
...emailData.value,
attachments: [],
sourceMessageId: sourceMessage.value?.id,
composeMode: composeMode.value === "new" ? undefined : composeMode.value,
}
for await (const file of Array.from(document.getElementById("inputAttachments").files)) {
body.attachments.push({
filename: file.name,
content: await toBase64(file),
contentType: file.type,
encoding: "base64",
contentDisposition: "attachment"
})
}
for await (const doc of loadedDocuments.value) {
const res = await useFiles().downloadFile(doc.id, null, true)
body.attachments.push({
filename: doc.path.split("/")[doc.path.split("/").length -1],
content: await blobToBase64(res),
contentType: res.type,
encoding: "base64",
contentDisposition: "attachment"
})
}
try {
const res = await useNuxtApp().$api("/api/email/send",{
method: "POST",
body: body,
})
if(!res.success) {
toast.add({title: "Fehler beim Absenden der E-Mail", color: "error"})
} else {
await navigateTo("/email")
toast.add({title: "E-Mail gesendet", color: "success"})
}
} catch (err) {
toast.add({
title: "Fehler beim Absenden der E-Mail",
description: err?.data?.error || err?.message,
color: "error",
})
} finally {
loaded.value = true
}
}
</script>
<template>
<div v-if="noAccountsPresent" class="mx-auto mt-5 flex flex-col justify-center">
<span class="font-bold text-2xl">Keine E-Mail Konten vorhanden</span>
<UButton
@click="router.push(`/settings/emailaccounts`)"
class="mx-auto mt-5"
>
+ E-Mail Konto
</UButton>
</div>
<div v-else>
<UProgress animation="carousel" v-if="!loaded" class="mt-5 w-2/3 mx-auto"/>
<div v-else>
<UDashboardNavbar
:title="pageTitle"
>
<template #right>
<UButton
@click="sendEmail"
:disabled="!emailData.to || !emailData.subject"
>
Senden
</UButton>
</template>
</UDashboardNavbar>
<div class="scrollContainer mt-3">
<div class="flex-col flex w-full">
<UFormField
label="Absender"
>
<USelectMenu
:items="emailAccounts"
label-key="displayName"
value-key="id"
v-model="emailData.account"
/>
</UFormField>
<USeparator class="my-3"/>
<UFormField
label="Empfänger"
>
<div class="grid gap-2 md:grid-cols-[minmax(260px,0.9fr)_minmax(320px,1.1fr)]">
<USelectMenu
v-model="selectedRecipient"
:items="recipientOptions"
value-key="value"
label-key="label"
:loading="loadingRecipients"
:search-input="{ placeholder: 'Kunde, Ansprechpartner oder E-Mail suchen' }"
placeholder="Aus Kunden und Ansprechpartnern wählen"
class="w-full"
@update:model-value="selectRecipient"
/>
<UInput
class="w-full"
v-model="emailData.to"
placeholder="E-Mail-Adresse oder mehrere Adressen"
@update:model-value="selectedRecipient = null"
/>
</div>
<p class="mt-1 text-xs text-dimmed">
Passende Adressen zum vorausgefüllten Kunden werden zuerst angezeigt. Freie Eingaben bleiben möglich.
</p>
</UFormField>
<UFormField
label="Kopie"
>
<UInput
class="w-full my-1"
v-model="emailData.cc"
/>
</UFormField>
<UFormField
label="Blindkopie"
>
<UInput
class="w-full my-1"
placeholder=""
v-model="emailData.bcc"
/>
</UFormField>
<UFormField
label="Betreff"
>
<UInput
class="w-full my-1"
v-model="emailData.subject"
/>
</UFormField>
</div>
<USeparator class="my-3"/>
<div id="parentAttachments" class="flex flex-col justify-center mt-3">
<span class="font-medium mb-2 text-xl">Anhänge</span>
<!-- <UIcon
name="i-heroicons-paper-clip"
class="mx-auto w-10 h-10"
/>
<span class="text-center text-2xl">Anhänge hochladen</span>-->
<UInput
id="inputAttachments"
type="file"
multiple
@change="renderAttachments"
/>
<ul class="mx-5 mt-3">
<li
class="list-disc"
v-for="file in selectedAttachments"
> Datei - {{file.filename}}</li>
<li
class="list-disc"
v-for="doc in loadedDocuments"
>
<span v-if="doc.createddocument">Dokument - {{doc.createddocument.documentNumber}}</span>
</li>
<li
v-if="composeMode === 'forward' && sourceMessage?.attachments?.length"
class="list-disc"
>
{{ sourceMessage.attachments.length }} Originalanhang/Originalanhänge werden übernommen
</li>
</ul>
</div>
<EmailTiptapEditor
class="mt-3"
@updateContent="contentChanged"
:preloadedContent="preloadedContent"
/>
</div>
</div>
</div>
</template>
<style scoped>
#parentAttachments {
border: 1px dashed #69c350;
border-radius: 10px;
padding: 1em;
}
#inputAttachments {
/*
display: none;
*/
display: inline-block;
cursor: pointer;
opacity: 100;/*
width: 100%;
height: 5%;
position: relative;
top: 0;
bottom: 0;
left: 0;
right: 0;*/
}
#inputAttachments::file-selector-button {
background-color: white;
border: 1px solid #69c350;
border-radius: 5px;
padding: 5px 10px 5px 10px;
}
.fileListItem {
border: 1px solid #69c350;
border-radius: 5px;
padding: .5rem;
}
.scrollContainer {
overflow-y: scroll;
padding-left: 1em;
padding-right: 1em;
height: 90vh;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
</style>