AI-built workflow software

Turn how your business runs into software.

JuneAI studies your actual process, the spreadsheets, the approvals, the manual back-and-forth, and builds the tool that runs it. Quote generators, process automators, dashboards. Working in days.

Start a build See live builds
3–5
days to delivery
12+
industries served
100%
in-house deployment
juneai · workflow.pySHIPPING IT
import os
import re
import sys
import json
import traceback
from dataclasses import dataclass, field
from datetime import datetime, time
from pathlib import Path
from typing import Any, Dict, List, Optional
 
from flask import Flask, request, render_template_string
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment
from openpyxl.worksheet.table import Table, TableStyleInfo
 
 
try:
import pythoncom
import win32com.client
except ImportError:
pythoncom = None
win32com = None
 
 
APP_PORT = 5055
WORKFLOW_FILE = "saved_workflows.json"
 
 
# -----------------------------
# Utilities
# -----------------------------
 
def ensure_windows_outlook():
if os.name != "nt":
raise RuntimeError("This app only works on Windows with Outlook Desktop installed.")
 
if pythoncom is None or win32com is None:
raise RuntimeError(
"Missing pywin32. Install with: pip install pywin32"
)
 
 
def safe_filename(value: str, max_len: int = 120) -> str:
value = value or "No Subject"
value = re.sub(r'[<>:"/\\|?*\r\n\t]+', "_", value)
value = re.sub(r"\s+", " ", value).strip()
return value[:max_len].strip(" ._") or "No Subject"
 
 
def unique_path(path: Path) -> Path:
if not path.exists():
return path
 
stem = path.stem
suffix = path.suffix
parent = path.parent
 
counter = 2
while True:
new_path = parent / f"{stem}_{counter}{suffix}"
if not new_path.exists():
return new_path
counter += 1
 
 
def parse_date(value: str, end_of_day: bool = False) -> Optional[datetime]:
value = (value or "").strip()
if not value:
return None
 
d = datetime.strptime(value, "%Y-%m-%d").date()
if end_of_day:
return datetime.combine(d, time(23, 59, 59))
return datetime.combine(d, time(0, 0, 0))
 
 
def com_time_to_datetime(value: Any) -> Optional[datetime]:
if not value:
return None
 
if isinstance(value, datetime):
return value.replace(tzinfo=None)
 
try:
return datetime.fromtimestamp(value.timestamp()).replace(tzinfo=None)
except Exception:
pass
 
try:
text = str(value)
return datetime.strptime(text[:19], "%Y-%m-%d %H:%M:%S")
except Exception:
return None
 
 
def get_smtp_sender(mail_item) -> str:
try:
sender_email = str(getattr(mail_item, "SenderEmailAddress", "") or "")
except Exception:
sender_email = ""
 
if sender_email and not sender_email.startswith("/O="):
return sender_email
 
try:
sender = mail_item.Sender
exchange_user = sender.GetExchangeUser()
if exchange_user:
smtp = exchange_user.PrimarySmtpAddress
if smtp:
return smtp
except Exception:
pass
 
return sender_email
 
 
def get_outlook_namespace():
ensure_windows_outlook()
pythoncom.CoInitialize()
outlook = win32com.client.Dispatch("Outlook.Application")
namespace = outlook.GetNamespace("MAPI")
return namespace
 
 
def get_child_folder(parent_folder, child_name: str):
child_name_clean = child_name.strip().lower()
 
for i in range(1, parent_folder.Folders.Count + 1):
folder = parent_folder.Folders.Item(i)
if folder.Name.strip().lower() == child_name_clean:
return folder
 
available = [
parent_folder.Folders.Item(i).Name
for i in range(1, parent_folder.Folders.Count + 1)
]
 
raise RuntimeError(
f"Folder '{child_name}' not found under '{parent_folder.Name}'. "
f"Available folders: {available}"
)
 
 
def get_outlook_folder(namespace, folder_path: str):
folder_path = (folder_path or "Inbox").strip()
parts = [p.strip() for p in re.split(r"[\\/]+", folder_path) if p.strip()]
 
if not parts:
return namespace.GetDefaultFolder(6)
 
default_folders = {
"inbox": 6,
"sent": 5,
"sent items": 5,
"drafts": 16,
"deleted": 3,
"deleted items": 3,
"outbox": 4,
"junk": 23,
"junk email": 23,
}
 
first = parts[0].lower()
 
if first in default_folders:
current = namespace.GetDefaultFolder(default_folders[first])
parts = parts[1:]
else:
current = None
 
for i in range(1, namespace.Folders.Count + 1):
root = namespace.Folders.Item(i)
if root.Name.strip().lower() == first:
current = root
parts = parts[1:]
break
 
if current is None:
current = namespace.GetDefaultFolder(6)
 
for part in parts:
current = get_child_folder(current, part)
 
return current
 
 
# -----------------------------
# Data models
# -----------------------------
 
@dataclass
class WorkflowConfig:
outlook_folder: str
output_dir: str
date_from: Optional[str] = None
date_to: Optional[str] = None
subject_contains: str = ""
sender_contains: str = ""
max_results: int = 100
max_scan: int = 1000
save_attachments: bool = False
 
 
@dataclass
class EmailRecord:
subject: str
sender_name: str
sender_email: str
to: str
cc: str
received_time: Optional[datetime]
sent_time: Optional[datetime]
size: int
categories: str
importance: str
has_attachments: bool
attachment_count: int
conversation_id: str
entry_id: str
body_preview: str
msg_path: str = ""
attachments_dir: str = ""
outlook_item: Any = field(default=None, repr=False)
 
 
@dataclass
class WorkflowResult:
started_at: datetime
completed_at: Optional[datetime] = None
total_found: int = 0
msg_folder: str = ""
excel_path: str = ""
errors: List[str] = field(default_factory=list)
 
 
# -----------------------------
# Workflow Nodes
# -----------------------------
 
class Node:
name = "Base Node"
 
def run(self, context: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError
 
 
class OutlookReadNode(Node):
name = "Read Outlook Emails"
 
def run(self, context: Dict[str, Any]) -> Dict[str, Any]:
config: WorkflowConfig = context["config"]
 
date_from = parse_date(config.date_from, end_of_day=False)
date_to = parse_date(config.date_to, end_of_day=True)
 
subject_filter = (config.subject_contains or "").lower().strip()
sender_filter = (config.sender_contains or "").lower().strip()
 
namespace = get_outlook_namespace()
folder = get_outlook_folder(namespace, config.outlook_folder)
 
items = folder.Items
items.Sort("[ReceivedTime]", True)
 
records: List[EmailRecord] = []
 
scanned = 0
 
for item in items:
scanned += 1
 
if scanned > config.max_scan:
break
 
try:
if getattr(item, "Class", None) != 43:
continue
 
received = com_time_to_datetime(getattr(item, "ReceivedTime", None))
 
if date_from and received and received < date_from:
continue
 
if date_to and received and received > date_to:
continue
 
subject = str(getattr(item, "Subject", "") or "")
sender_name = str(getattr(item, "SenderName", "") or "")
sender_email = get_smtp_sender(item)
 
if subject_filter and subject_filter not in subject.lower():
continue
 
sender_match_text = f"{sender_name} {sender_email}".lower()
if sender_filter and sender_filter not in sender_match_text:
continue
 
attachments = getattr(item, "Attachments", None)
attachment_count = 0
 
try:
attachment_count = attachments.Count if attachments else 0
except Exception:
attachment_count = 0
 
body = str(getattr(item, "Body", "") or "")
body_preview = body.replace("\r", " ").replace("\n", " ")
body_preview = re.sub(r"\s+", " ", body_preview).strip()[:500]
 
record = EmailRecord(
subject=subject,
sender_name=sender_name,
sender_email=sender_email,
to=str(getattr(item, "To", "") or ""),
cc=str(getattr(item, "CC", "") or ""),
received_time=received,
sent_time=com_time_to_datetime(getattr(item, "SentOn", None)),
size=int(getattr(item, "Size", 0) or 0),
categories=str(getattr(item, "Categories", "") or ""),
importance=str(getattr(item, "Importance", "") or ""),
has_attachments=attachment_count > 0,
attachment_count=attachment_count,
conversation_id=str(getattr(item, "ConversationID", "") or ""),
entry_id=str(getattr(item, "EntryID", "") or ""),
body_preview=body_preview,
outlook_item=item,
)
 
records.append(record)
 
if len(records) >= config.max_results:
break
 
except Exception as e:
context["result"].errors.append(f"Skipped one email because of error: {e}")
 
context["emails"] = records
context["result"].total_found = len(records)
return context
 
 
class SaveMessagesNode(Node):
name = "Save Emails as MSG"
 
def run(self, context: Dict[str, Any]) -> Dict[str, Any]:
config: WorkflowConfig = context["config"]
records: List[EmailRecord] = context["emails"]
 
output_dir = Path(config.output_dir).expanduser().resolve()
run_stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
 
msg_folder = output_dir / f"outlook_email_export_{run_stamp}" / "emails_msg"
attachments_root = output_dir / f"outlook_email_export_{run_stamp}" / "attachments"
 
msg_folder.mkdir(parents=True, exist_ok=True)
 
if config.save_attachments:
attachments_root.mkdir(parents=True, exist_ok=True)
 
for idx, record in enumerate(records, start=1):
try:
received_label = (
record.received_time.strftime("%Y-%m-%d_%H-%M-%S")
if record.received_time
else "NoDate"
)
 
base_name = safe_filename(
f"{idx:04d}_{received_label}_{record.sender_name}_{record.subject}",
max_len=180,
)
 
msg_path = unique_path(msg_folder / f"{base_name}.msg")
 
record.outlook_item.SaveAs(str(msg_path), 9)
record.msg_path = str(msg_path)
 
if config.save_attachments and record.attachment_count > 0:
email_attachment_dir = attachments_root / base_name
email_attachment_dir.mkdir(parents=True, exist_ok=True)
 
attachments = record.outlook_item.Attachments
 
for a_idx in range(1, attachments.Count + 1):
attachment = attachments.Item(a_idx)
original_name = str(attachment.FileName or f"attachment_{a_idx}")
attachment_name = safe_filename(original_name, max_len=160)
attachment_path = unique_path(email_attachment_dir / attachment_name)
attachment.SaveAsFile(str(attachment_path))
 
record.attachments_dir = str(email_attachment_dir)
 
except Exception as e:
context["result"].errors.append(
f"Could not save email '{record.subject}': {e}"
)
 
context["result"].msg_folder = str(msg_folder)
return context
 
 
class ExcelExportNode(Node):
name = "Create Excel Report"
 
def run(self, context: Dict[str, Any]) -> Dict[str, Any]:
config: WorkflowConfig = context["config"]
records: List[EmailRecord] = context["emails"]
 
output_dir = Path(config.output_dir).expanduser().resolve()
run_folder = Path(context["result"].msg_folder).parent
 
excel_path = unique_path(run_folder / "outlook_emails_export.xlsx")
 
wb = Workbook()
ws = wb.active
ws.title = "Outlook Emails"
 
headers = [
"Subject", "Sender Name", "Sender Email", "To", "CC",
"Received Time", "Sent Time", "Size Bytes", "Categories",
"Importance", "Has Attachments", "Attachment Count",
"Conversation ID", "Entry ID", "Saved MSG Path",
"Attachments Folder", "Body Preview",
]
 
ws.append(headers)
 
for cell in ws[1]:
cell.font = Font(bold=True)
cell.alignment = Alignment(vertical="center")
 
for record in records:
ws.append([
record.subject, record.sender_name, record.sender_email,
record.to, record.cc,
record.received_time.strftime("%Y-%m-%d %H:%M:%S") if record.received_time else "",
record.sent_time.strftime("%Y-%m-%d %H:%M:%S") if record.sent_time else "",
record.size, record.categories, record.importance,
"Yes" if record.has_attachments else "No",
record.attachment_count, record.conversation_id,
record.entry_id, record.msg_path, record.attachments_dir,
record.body_preview,
])
 
ws.freeze_panes = "A2"
ws.auto_filter.ref = ws.dimensions
 
if ws.max_row > 1:
table_ref = f"A1:Q{ws.max_row}"
table = Table(displayName="OutlookEmailExport", ref=table_ref)
style = TableStyleInfo(
name="TableStyleMedium2",
showRowStripes=True,
)
table.tableStyleInfo = style
ws.add_table(table)
 
wb.save(excel_path)
 
context["result"].excel_path = str(excel_path)
return context
 
 
# -----------------------------
# Workflow Engine
# -----------------------------
 
class Workflow:
def __init__(self, config: WorkflowConfig):
self.config = config
self.nodes = [
OutlookReadNode(),
SaveMessagesNode(),
ExcelExportNode(),
]
 
def run(self) -> WorkflowResult:
result = WorkflowResult(started_at=datetime.now())
 
context = {
"config": self.config,
"emails": [],
"result": result,
}
 
try:
for node in self.nodes:
context = node.run(context)
except Exception:
result.errors.append(traceback.format_exc())
 
result.completed_at = datetime.now()
return result
 
 
app = Flask(__name__)
 
 
@app.route("/", methods=["GET"])
def index():
return render_template_string(HTML, form=default_form(), result=None)
 
 
@app.route("/run", methods=["POST"])
def run_workflow():
config = form_to_config(request.form)
workflow = Workflow(config)
result = workflow.run()
return render_template_string(HTML, form=default_form(), result=result)
 
 
if __name__ == "__main__":
print(f"Starting Outlook Workflow App on http://127.0.0.1:{APP_PORT}")
app.run(host="127.0.0.1", port=APP_PORT, debug=False)
import os
import re
import sys
import json
import traceback
from dataclasses import dataclass, field
from datetime import datetime, time
from pathlib import Path
from typing import Any, Dict, List, Optional
 
from flask import Flask, request, render_template_string
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment
from openpyxl.worksheet.table import Table, TableStyleInfo
 
 
try:
import pythoncom
import win32com.client
except ImportError:
pythoncom = None
win32com = None
 
 
APP_PORT = 5055
WORKFLOW_FILE = "saved_workflows.json"
 
 
# -----------------------------
# Utilities
# -----------------------------
 
def ensure_windows_outlook():
if os.name != "nt":
raise RuntimeError("This app only works on Windows with Outlook Desktop installed.")
 
if pythoncom is None or win32com is None:
raise RuntimeError(
"Missing pywin32. Install with: pip install pywin32"
)
 
 
def safe_filename(value: str, max_len: int = 120) -> str:
value = value or "No Subject"
value = re.sub(r'[<>:"/\\|?*\r\n\t]+', "_", value)
value = re.sub(r"\s+", " ", value).strip()
return value[:max_len].strip(" ._") or "No Subject"
 
 
def unique_path(path: Path) -> Path:
if not path.exists():
return path
 
stem = path.stem
suffix = path.suffix
parent = path.parent
 
counter = 2
while True:
new_path = parent / f"{stem}_{counter}{suffix}"
if not new_path.exists():
return new_path
counter += 1
 
 
def parse_date(value: str, end_of_day: bool = False) -> Optional[datetime]:
value = (value or "").strip()
if not value:
return None
 
d = datetime.strptime(value, "%Y-%m-%d").date()
if end_of_day:
return datetime.combine(d, time(23, 59, 59))
return datetime.combine(d, time(0, 0, 0))
 
 
def com_time_to_datetime(value: Any) -> Optional[datetime]:
if not value:
return None
 
if isinstance(value, datetime):
return value.replace(tzinfo=None)
 
try:
return datetime.fromtimestamp(value.timestamp()).replace(tzinfo=None)
except Exception:
pass
 
try:
text = str(value)
return datetime.strptime(text[:19], "%Y-%m-%d %H:%M:%S")
except Exception:
return None
 
 
def get_smtp_sender(mail_item) -> str:
try:
sender_email = str(getattr(mail_item, "SenderEmailAddress", "") or "")
except Exception:
sender_email = ""
 
if sender_email and not sender_email.startswith("/O="):
return sender_email
 
try:
sender = mail_item.Sender
exchange_user = sender.GetExchangeUser()
if exchange_user:
smtp = exchange_user.PrimarySmtpAddress
if smtp:
return smtp
except Exception:
pass
 
return sender_email
 
 
def get_outlook_namespace():
ensure_windows_outlook()
pythoncom.CoInitialize()
outlook = win32com.client.Dispatch("Outlook.Application")
namespace = outlook.GetNamespace("MAPI")
return namespace
 
 
def get_child_folder(parent_folder, child_name: str):
child_name_clean = child_name.strip().lower()
 
for i in range(1, parent_folder.Folders.Count + 1):
folder = parent_folder.Folders.Item(i)
if folder.Name.strip().lower() == child_name_clean:
return folder
 
available = [
parent_folder.Folders.Item(i).Name
for i in range(1, parent_folder.Folders.Count + 1)
]
 
raise RuntimeError(
f"Folder '{child_name}' not found under '{parent_folder.Name}'. "
f"Available folders: {available}"
)
 
 
def get_outlook_folder(namespace, folder_path: str):
folder_path = (folder_path or "Inbox").strip()
parts = [p.strip() for p in re.split(r"[\\/]+", folder_path) if p.strip()]
 
if not parts:
return namespace.GetDefaultFolder(6)
 
default_folders = {
"inbox": 6,
"sent": 5,
"sent items": 5,
"drafts": 16,
"deleted": 3,
"deleted items": 3,
"outbox": 4,
"junk": 23,
"junk email": 23,
}
 
first = parts[0].lower()
 
if first in default_folders:
current = namespace.GetDefaultFolder(default_folders[first])
parts = parts[1:]
else:
current = None
 
for i in range(1, namespace.Folders.Count + 1):
root = namespace.Folders.Item(i)
if root.Name.strip().lower() == first:
current = root
parts = parts[1:]
break
 
if current is None:
current = namespace.GetDefaultFolder(6)
 
for part in parts:
current = get_child_folder(current, part)
 
return current
 
 
# -----------------------------
# Data models
# -----------------------------
 
@dataclass
class WorkflowConfig:
outlook_folder: str
output_dir: str
date_from: Optional[str] = None
date_to: Optional[str] = None
subject_contains: str = ""
sender_contains: str = ""
max_results: int = 100
max_scan: int = 1000
save_attachments: bool = False
 
 
@dataclass
class EmailRecord:
subject: str
sender_name: str
sender_email: str
to: str
cc: str
received_time: Optional[datetime]
sent_time: Optional[datetime]
size: int
categories: str
importance: str
has_attachments: bool
attachment_count: int
conversation_id: str
entry_id: str
body_preview: str
msg_path: str = ""
attachments_dir: str = ""
outlook_item: Any = field(default=None, repr=False)
 
 
@dataclass
class WorkflowResult:
started_at: datetime
completed_at: Optional[datetime] = None
total_found: int = 0
msg_folder: str = ""
excel_path: str = ""
errors: List[str] = field(default_factory=list)
 
 
# -----------------------------
# Workflow Nodes
# -----------------------------
 
class Node:
name = "Base Node"
 
def run(self, context: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError
 
 
class OutlookReadNode(Node):
name = "Read Outlook Emails"
 
def run(self, context: Dict[str, Any]) -> Dict[str, Any]:
config: WorkflowConfig = context["config"]
 
date_from = parse_date(config.date_from, end_of_day=False)
date_to = parse_date(config.date_to, end_of_day=True)
 
subject_filter = (config.subject_contains or "").lower().strip()
sender_filter = (config.sender_contains or "").lower().strip()
 
namespace = get_outlook_namespace()
folder = get_outlook_folder(namespace, config.outlook_folder)
 
items = folder.Items
items.Sort("[ReceivedTime]", True)
 
records: List[EmailRecord] = []
 
scanned = 0
 
for item in items:
scanned += 1
 
if scanned > config.max_scan:
break
 
try:
if getattr(item, "Class", None) != 43:
continue
 
received = com_time_to_datetime(getattr(item, "ReceivedTime", None))
 
if date_from and received and received < date_from:
continue
 
if date_to and received and received > date_to:
continue
 
subject = str(getattr(item, "Subject", "") or "")
sender_name = str(getattr(item, "SenderName", "") or "")
sender_email = get_smtp_sender(item)
 
if subject_filter and subject_filter not in subject.lower():
continue
 
sender_match_text = f"{sender_name} {sender_email}".lower()
if sender_filter and sender_filter not in sender_match_text:
continue
 
attachments = getattr(item, "Attachments", None)
attachment_count = 0
 
try:
attachment_count = attachments.Count if attachments else 0
except Exception:
attachment_count = 0
 
body = str(getattr(item, "Body", "") or "")
body_preview = body.replace("\r", " ").replace("\n", " ")
body_preview = re.sub(r"\s+", " ", body_preview).strip()[:500]
 
record = EmailRecord(
subject=subject,
sender_name=sender_name,
sender_email=sender_email,
to=str(getattr(item, "To", "") or ""),
cc=str(getattr(item, "CC", "") or ""),
received_time=received,
sent_time=com_time_to_datetime(getattr(item, "SentOn", None)),
size=int(getattr(item, "Size", 0) or 0),
categories=str(getattr(item, "Categories", "") or ""),
importance=str(getattr(item, "Importance", "") or ""),
has_attachments=attachment_count > 0,
attachment_count=attachment_count,
conversation_id=str(getattr(item, "ConversationID", "") or ""),
entry_id=str(getattr(item, "EntryID", "") or ""),
body_preview=body_preview,
outlook_item=item,
)
 
records.append(record)
 
if len(records) >= config.max_results:
break
 
except Exception as e:
context["result"].errors.append(f"Skipped one email because of error: {e}")
 
context["emails"] = records
context["result"].total_found = len(records)
return context
 
 
class SaveMessagesNode(Node):
name = "Save Emails as MSG"
 
def run(self, context: Dict[str, Any]) -> Dict[str, Any]:
config: WorkflowConfig = context["config"]
records: List[EmailRecord] = context["emails"]
 
output_dir = Path(config.output_dir).expanduser().resolve()
run_stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
 
msg_folder = output_dir / f"outlook_email_export_{run_stamp}" / "emails_msg"
attachments_root = output_dir / f"outlook_email_export_{run_stamp}" / "attachments"
 
msg_folder.mkdir(parents=True, exist_ok=True)
 
if config.save_attachments:
attachments_root.mkdir(parents=True, exist_ok=True)
 
for idx, record in enumerate(records, start=1):
try:
received_label = (
record.received_time.strftime("%Y-%m-%d_%H-%M-%S")
if record.received_time
else "NoDate"
)
 
base_name = safe_filename(
f"{idx:04d}_{received_label}_{record.sender_name}_{record.subject}",
max_len=180,
)
 
msg_path = unique_path(msg_folder / f"{base_name}.msg")
 
record.outlook_item.SaveAs(str(msg_path), 9)
record.msg_path = str(msg_path)
 
if config.save_attachments and record.attachment_count > 0:
email_attachment_dir = attachments_root / base_name
email_attachment_dir.mkdir(parents=True, exist_ok=True)
 
attachments = record.outlook_item.Attachments
 
for a_idx in range(1, attachments.Count + 1):
attachment = attachments.Item(a_idx)
original_name = str(attachment.FileName or f"attachment_{a_idx}")
attachment_name = safe_filename(original_name, max_len=160)
attachment_path = unique_path(email_attachment_dir / attachment_name)
attachment.SaveAsFile(str(attachment_path))
 
record.attachments_dir = str(email_attachment_dir)
 
except Exception as e:
context["result"].errors.append(
f"Could not save email '{record.subject}': {e}"
)
 
context["result"].msg_folder = str(msg_folder)
return context
 
 
class ExcelExportNode(Node):
name = "Create Excel Report"
 
def run(self, context: Dict[str, Any]) -> Dict[str, Any]:
config: WorkflowConfig = context["config"]
records: List[EmailRecord] = context["emails"]
 
output_dir = Path(config.output_dir).expanduser().resolve()
run_folder = Path(context["result"].msg_folder).parent
 
excel_path = unique_path(run_folder / "outlook_emails_export.xlsx")
 
wb = Workbook()
ws = wb.active
ws.title = "Outlook Emails"
 
headers = [
"Subject", "Sender Name", "Sender Email", "To", "CC",
"Received Time", "Sent Time", "Size Bytes", "Categories",
"Importance", "Has Attachments", "Attachment Count",
"Conversation ID", "Entry ID", "Saved MSG Path",
"Attachments Folder", "Body Preview",
]
 
ws.append(headers)
 
for cell in ws[1]:
cell.font = Font(bold=True)
cell.alignment = Alignment(vertical="center")
 
for record in records:
ws.append([
record.subject, record.sender_name, record.sender_email,
record.to, record.cc,
record.received_time.strftime("%Y-%m-%d %H:%M:%S") if record.received_time else "",
record.sent_time.strftime("%Y-%m-%d %H:%M:%S") if record.sent_time else "",
record.size, record.categories, record.importance,
"Yes" if record.has_attachments else "No",
record.attachment_count, record.conversation_id,
record.entry_id, record.msg_path, record.attachments_dir,
record.body_preview,
])
 
ws.freeze_panes = "A2"
ws.auto_filter.ref = ws.dimensions
 
if ws.max_row > 1:
table_ref = f"A1:Q{ws.max_row}"
table = Table(displayName="OutlookEmailExport", ref=table_ref)
style = TableStyleInfo(
name="TableStyleMedium2",
showRowStripes=True,
)
table.tableStyleInfo = style
ws.add_table(table)
 
wb.save(excel_path)
 
context["result"].excel_path = str(excel_path)
return context
 
 
# -----------------------------
# Workflow Engine
# -----------------------------
 
class Workflow:
def __init__(self, config: WorkflowConfig):
self.config = config
self.nodes = [
OutlookReadNode(),
SaveMessagesNode(),
ExcelExportNode(),
]
 
def run(self) -> WorkflowResult:
result = WorkflowResult(started_at=datetime.now())
 
context = {
"config": self.config,
"emails": [],
"result": result,
}
 
try:
for node in self.nodes:
context = node.run(context)
except Exception:
result.errors.append(traceback.format_exc())
 
result.completed_at = datetime.now()
return result
 
 
app = Flask(__name__)
 
 
@app.route("/", methods=["GET"])
def index():
return render_template_string(HTML, form=default_form(), result=None)
 
 
@app.route("/run", methods=["POST"])
def run_workflow():
config = form_to_config(request.form)
workflow = Workflow(config)
result = workflow.run()
return render_template_string(HTML, form=default_form(), result=result)
 
 
if __name__ == "__main__":
print(f"Starting Outlook Workflow App on http://127.0.0.1:{APP_PORT}")
app.run(host="127.0.0.1", port=APP_PORT, debug=False)
In live businessesConstructionLogisticsRetail & TradeManufacturingProfessional servicesField servicesBookkeepingBeauty & wellnessCase studiesQuoting cut from 45 to 5 minIn-house dispatch deskOffline inventory & schedulingClient intake & documentsDaily floor reportingJob sheets & compliance certsAutomated month-end booksWhat we buildQuote generatorsProcess automatorsLive dashboardsDocument automationRuns offlineYour data stays local
In live businessesConstructionLogisticsRetail & TradeManufacturingProfessional servicesField servicesBookkeepingBeauty & wellnessCase studiesQuoting cut from 45 to 5 minIn-house dispatch deskOffline inventory & schedulingClient intake & documentsDaily floor reportingJob sheets & compliance certsAutomated month-end booksWhat we buildQuote generatorsProcess automatorsLive dashboardsDocument automationRuns offlineYour data stays local
What you get

Built for real operations, not demos.

Every capability ships inside the tool we deliver. Nothing theoretical, nothing you have to wire up yourself.

01

Process analysis

We map your existing workflow, forms, approvals, calculations, exceptions, and pin down exactly what to automate.

02

Custom workflow design

A workflow built around your business, not a template. Your terminology, your approval chain, your edge cases.

03

Document automation

Quotes, reports, work orders and invoices generated straight from workflow data. Formatted, branded, ready to send.

04

In-house deployment

Runs inside your own environment. No cloud dependency, no data leaving your network. Your process stays yours.

05

Support in the license

Bug fixes and updates as your process changes, plus direct support, included. Your tool never goes stale.

The process

Conversation to working software, in four steps.

01

Describe your process

Walk us through how the work happens today, in spreadsheets, on paper, or a legacy system.

02

AI designs the workflow

JuneAI maps your process, finds the inefficiencies, and designs the optimal software flow.

03

The tool gets built

A custom tool is built around your exact workflow, web, desktop, or automation script.

04

Manage it in your workspace

Your tool and license live in your JuneAI workspace. Track status, get updates, request changes.

Client deliverables

Real workflows. Running today.

Hover any build to watch it run.

Quote Generator
juneai · python3
Quoting & POsConstruction
Quote Generator
Dispatch Desk
juneai · python3
Live operationsLogistics & Transport
Dispatch Desk
Roster & Inventory
juneai · python3
Branch planningRetail & Trade
Roster & Inventory
Floor Dashboard
juneai · python3
ReportingManufacturing
Floor Dashboard
How it fits

Works with the stack you already have.

JuneAI reads from the tools your business already runs on, builds the workflow in the middle, and delivers the outputs back where you need them. No rip-and-replace.

01 · Reads from
Spreadsheets & databases
ExcelGoogle SheetsAccessSQLAirtable
Email & inboxes
OutlookGmailShared inboxesAttachments
Accounting & books
QuickBooksXeroSageInvoices
Commerce & payments
ShopifyStripeWooCommercePOS exports
Files & documents
PDFsScansWordDriveSharePointDropbox
Calendars & forms
Google CalendarWeb formsPaper → scanSMS
intake →
JuneAI engine
02 · What the AI does
i
Ingest
Watches inboxes, folders, sheets & webhooks around the clock
u
Understand
AI + OCR extract names, line items & totals from PDFs, scans, email bodies
d
Decide
Your pricing rules, validation, de-duplication & approval chains
g
Generate
Quotes, POs, invoices, rosters, reports - branded & formatted
Deliver
Writes back to your stack, emails out, updates dashboards live
● on your machinesevery action audit-logged
output →
03 · Delivers to
Your team's tool
Web app, desktop tool or mobile app - built around your workflow
Documents out
Branded PDFs, .docx quotes, .xlsx exports - ready to send
Live dashboards & alerts
KPIs on the wall, escalations to the right person, same day
Back into your stack
Two-way sync: sheets updated, inbox filed, books posted
Industries

For the businesses that keep things running.

Construction & Manufacturing

-Quote generation & estimation
-Job scheduling & resource allocation
-Supplier purchase orders
-Compliance document automation

Logistics & Transport

-Dispatch routing & fleet coordination
-Customer verification workflows
-Incident reporting & escalation
-Delivery tracking integrations

Retail & Trade

-Inventory management systems
-Purchase order & supplier automation
-Staff scheduling & workload tools
-Sales reporting dashboards

Professional Services

-Client onboarding & intake
-Contract & document automation
-Time tracking & billing
-Project status dashboards
Licensing

One license. Full access.

Covers the tool, all updates as your workflow evolves, and direct support. Choose the commitment that suits you.

Limited-time deallaunch pricing, for a limited time
Monthly
$79$39/ month

Launch price

Full access to your JuneAI-built tool with updates and support. Cancel anytime.

Full tool access
Software updates
Email support
Most popular
Extended
$869$399/ year

Most popular, lowest yet

For businesses that rely on their JuneAI tool daily. Priority support + custom update requests.

Full tool access
Software updates
Priority support
Custom update requests
Multi-tool discount
Enterprise
Workflow scoping

Multiple departments or a larger rollout? We map your workload and workflows first; licensing for the resulting tools is worked out once we know what's needed.

Start a project
Multiple workflow tools
Organisation-wide deployment
Volume licence pricing
Dedicated account support

Got a process that's eating your time?

Tell us how your business actually runs, the spreadsheets, the manual back-and-forth. We'll build the tool that takes it off your plate.

Start a build Talk to us