Skip to content

(feat): Make it possible to upload emails as communication #703

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions crm/api/communication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import frappe
import email
from email import policy
from frappe.utils.file_manager import save_file

@frappe.whitelist()
def create_communication_from_eml(file_url: str, reference_name: str, reference_doctype: str) -> dict:
try:
file_doc = frappe.get_doc("File", {"file_url": file_url})
file_content = file_doc.get_content()
if isinstance(file_content, str):
file_content = file_content.encode('utf-8')

msg = email.message_from_bytes(file_content, policy=policy.default)
comm = frappe.new_doc("Communication")
email_body = _extract_email_body(msg)

comm.update({
"subject": msg["subject"] or "No Subject",
"communication_medium": "Email",
"content": email_body,
"sender": msg["from"] or "",
"recipients": msg["to"] or "",
"communication_date": email.utils.parsedate_to_datetime(msg["date"]).replace(tzinfo=None) if msg["date"] else frappe.utils.now_datetime(),
"reference_doctype": reference_doctype,
"reference_name": reference_name,
"timeline_links": [{
"link_doctype": reference_doctype,
"link_name": reference_name
}]
})
comm.insert(ignore_permissions=True)

_save_attachments(msg, comm.name)
return {"success": True, "communication": comm.name}
except Exception as e:
frappe.log_error(f"Failed to create communication from EML: {str(e)}")
return {"success": False, "error": str(e)}

def _extract_email_body(msg):
if body_part := msg.get_body(preferencelist=("html", "plain")):
try:
return body_part.get_content()
except:
pass

for part in msg.walk():
content_type = part.get_content_type()
if content_type in ["text/plain", "text/html"]:
try:
return part.get_payload(decode=True).decode('utf-8', errors='replace')
except:
continue
return ""

def _save_attachments(msg, comm_name):
for part in msg.iter_attachments():
if file_name := part.get_filename():
try:
attachment_data = part.get_payload(decode=True)
if isinstance(attachment_data, str):
attachment_data = attachment_data.encode('utf-8')
save_file(
file_name,
attachment_data,
"Communication",
comm_name,
"Home/Attachments"
)
except Exception as e:
frappe.log_error(f"Failed to save attachment {file_name}: {str(e)}")
1 change: 1 addition & 0 deletions frontend/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ declare module 'vue' {
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
EmailTemplateSelectorModal: typeof import('./src/components/Modals/EmailTemplateSelectorModal.vue')['default']
EmailTemplatesListView: typeof import('./src/components/ListViews/EmailTemplatesListView.vue')['default']
EmailUploadModal: typeof import('./src/components/Modals/EmailUploadModal.vue')['default']
ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default']
ERPNextSettings: typeof import('./src/components/Settings/ERPNextSettings.vue')['default']
ExotelCallUI: typeof import('./src/components/Telephony/ExotelCallUI.vue')['default']
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/Activities/Activities.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
v-model:showFilesUploader="showFilesUploader"
:tabs="tabs"
:title="title"
:doctype="doctype"
:doc="doc"
:emailBox="emailBox"
:whatsappBox="whatsappBox"
:modalRef="modalRef"
@reload="all_activities.reload()"
/>
<FadedScrollableDiv
:maskHeight="30"
Expand Down
42 changes: 31 additions & 11 deletions frontend/src/components/Activities/ActivityHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,27 @@
<div class="flex h-8 items-center text-xl font-semibold text-ink-gray-8">
{{ __(title) }}
</div>
<Button
v-if="title == 'Emails'"
variant="solid"
@click="emailBox.show = true"
>
<template #prefix>
<FeatherIcon name="plus" class="h-4 w-4" />
</template>
<span>{{ __('New Email') }}</span>
</Button>
<div v-if="title == 'Emails'">
<Button
variant="solid"
@click="showEmailUpload = true"
>
<template #prefix>
<FeatherIcon name="upload" class="h-4 w-4" />
</template>
<span>{{ __('Upload Email') }}</span>
</Button>
<Button
variant="solid"
@click="emailBox.show = true"
class="mr-2"
>
<template #prefix>
<FeatherIcon name="plus" class="h-4 w-4" />
</template>
<span>{{ __('New Email') }}</span>
</Button>
</div>
<Button
v-else-if="title == 'Comments'"
variant="solid"
Expand Down Expand Up @@ -95,6 +106,12 @@
</template>
</Dropdown>
</div>
<EmailUploadModal
v-model="showEmailUpload"
:doctype="doctype"
:docname="doc.data.name"
@uploaded="$emit('reload')"
/>
</template>
<script setup>
import Email2Icon from '@/components/Icons/Email2Icon.vue'
Expand All @@ -107,11 +124,13 @@ import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
import { globalStore } from '@/stores/global'
import { whatsappEnabled, callEnabled } from '@/composables/settings'
import { Dropdown } from 'frappe-ui'
import { computed, h } from 'vue'
import { computed, h, ref } from 'vue'
import EmailUploadModal from '@/components/Modals/EmailUploadModal.vue'

const props = defineProps({
tabs: Array,
title: String,
doctype: String,
doc: Object,
modalRef: Object,
emailBox: Object,
Expand All @@ -123,6 +142,7 @@ const { makeCall } = globalStore()
const tabIndex = defineModel()
const showWhatsappTemplates = defineModel('showWhatsappTemplates')
const showFilesUploader = defineModel('showFilesUploader')
const showEmailUpload = ref(false)

const defaultActions = computed(() => {
let actions = [
Expand Down
162 changes: 162 additions & 0 deletions frontend/src/components/Modals/EmailUploadModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<script setup>
import { ref } from 'vue'
import { Dialog, call } from 'frappe-ui'
import FilesUploadHandler from '../FilesUploader/filesUploaderHandler'

const props = defineProps({
doctype: { type: String, required: true },
docname: { type: String, required: true },
})

const emit = defineEmits(['uploaded'])
const show = defineModel()
const dialog = ref(null)
const dragActive = ref(false)
const file = ref(null)
const error = ref('')
const isUploading = ref(false)

const handleDragEnter = (e) => {
e.preventDefault()
dragActive.value = true
}

const handleDragLeave = (e) => {
e.preventDefault()
dragActive.value = false
}

const handleDrop = (e) => {
e.preventDefault()
dragActive.value = false
const droppedFile = e.dataTransfer.files[0]
if (droppedFile?.name.endsWith('.eml')) {
file.value = droppedFile
error.value = ''
} else {
error.value = __('Please upload a valid .eml file')
}
}

const handleFileSelect = (e) => {
const selectedFile = e.target.files[0]
if (selectedFile?.name.endsWith('.eml')) {
file.value = selectedFile
error.value = ''
} else {
error.value = __('Please upload a valid .eml file')
}
}

const uploadFile = async () => {
if (!file.value) return

isUploading.value = true
error.value = ''

try {
const uploader = new FilesUploadHandler()
const result = await uploader.upload(file.value, {
fileObj: file.value,
private: true,
doctype: props.doctype,
docname: props.docname
})

await call('crm.api.communication.create_communication_from_eml', {
file_url: result.file_url,
reference_name: props.docname,
reference_doctype: props.doctype
})

show.value = false
emit('uploaded')
} catch (err) {
error.value = err?.message || err?.exc || 'Error uploading file'
} finally {
isUploading.value = false
}
}

const handleClose = () => {
file.value = null
error.value = ''
show.value = false
}

defineExpose({ dialog })
</script>

<template>
<Dialog v-model="show" :options="{ size: 'sm' }">
<template #body>
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
<div class="mb-5 flex items-center justify-between">
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
{{ __('Upload Email') }}
</h3>
<Button variant="ghost" class="w-7" @click="handleClose">
<FeatherIcon name="x" class="h-4 w-4" />
</Button>
</div>

<div class="flex flex-col gap-4">
<div
class="flex flex-col items-center justify-center p-8 border-2 border-dashed rounded-lg bg-gray-50 transition-colors"
:class="{
'border-black bg-black': dragActive,
'border-gray-300': !dragActive && !file,
'border-solid': file,
}"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
@dragover.prevent
@drop="handleDrop"
>
<div v-if="!file" class="flex flex-col items-center gap-2 text-center">
<div class="text-gray-400">
<FeatherIcon name="upload" class="h-6 w-6" />
</div>
<div class="text-gray-700 text-base">
{{ __('Drag and drop your .eml file here') }}
</div>
<div class="text-gray-500 text-sm">{{ __('or') }}</div>
<label class="inline-block px-4 py-2 rounded bg-black text-white text-sm cursor-pointer hover:bg-surface-gray-6 transition-colors">
{{ __('Browse Files') }}
<input
type="file"
accept=".eml"
class="hidden"
@change="handleFileSelect"
/>
</label>
</div>
<div v-else class="flex items-center gap-2 w-full p-2">
<div class="flex-1 text-gray-700 text-sm truncate">{{ file.name }}</div>
<button
class="flex items-center justify-center p-1 rounded text-gray-500 hover:text-gray-700 transition-colors"
@click="file = null"
>
<FeatherIcon name="x" class="h-4 w-4" />
</button>
</div>
</div>

<div v-if="error" class="text-red-500 text-sm">{{ error }}</div>
</div>
</div>

<div class="px-4 pb-7 pt-4 sm:px-6">
<div class="flex flex-row-reverse gap-2">
<Button
variant="solid"
:label="__('Upload')"
:loading="isUploading"
:disabled="!file || isUploading"
@click="uploadFile"
/>
</div>
</div>
</template>
</Dialog>
</template>