diff --git a/pyproject.toml b/pyproject.toml
index 7849fb7..3dc22c3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "bit-by-mail"
-version = "2.4.1"
+version = "2.5.0"
description = "A simple, self-hosted bulk mailing application with a web-based UI."
readme = "README.md"
requires-python = ">=3.9"
diff --git a/src/bit_by_mail/server/db.py b/src/bit_by_mail/server/db.py
index 57bbb4f..e4ec548 100644
--- a/src/bit_by_mail/server/db.py
+++ b/src/bit_by_mail/server/db.py
@@ -5,11 +5,11 @@ def init_db(db_path):
c = conn.cursor()
c.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)")
c.execute("CREATE TABLE IF NOT EXISTS accounts (id TEXT PRIMARY KEY, name TEXT, smtp_server TEXT, smtp_port INTEGER, sender_email TEXT, sender_password TEXT, use_ssl BOOLEAN, is_default BOOLEAN)")
- c.execute("CREATE TABLE IF NOT EXISTS campaigns (id TEXT PRIMARY KEY, name TEXT, subject TEXT, attachment_folder TEXT, send_attachments BOOLEAN, sender_account_id TEXT, is_html BOOLEAN, delay INTEGER, created_at TEXT, source_db_id TEXT, body TEXT, source_template_id TEXT)")
+ c.execute("CREATE TABLE IF NOT EXISTS campaigns (id TEXT PRIMARY KEY, name TEXT, subject TEXT, attachment_folder TEXT, send_attachments BOOLEAN, sender_account_id TEXT, is_html BOOLEAN, delay INTEGER, created_at TEXT, source_db_id TEXT, body TEXT, source_template_id TEXT, last_accessed_at TEXT)")
c.execute("CREATE TABLE IF NOT EXISTS campaign_recipients (id INTEGER PRIMARY KEY AUTOINCREMENT, campaign_id TEXT, name TEXT, email TEXT, attachment_file TEXT, status TEXT, sent_timestamp TEXT)")
- c.execute("CREATE TABLE IF NOT EXISTS databases (id TEXT PRIMARY KEY, name TEXT, created_at TEXT)")
+ c.execute("CREATE TABLE IF NOT EXISTS databases (id TEXT PRIMARY KEY, name TEXT, created_at TEXT, last_accessed_at TEXT)")
c.execute("CREATE TABLE IF NOT EXISTS database_recipients (id INTEGER PRIMARY KEY AUTOINCREMENT, db_id TEXT, name TEXT, email TEXT, attachment_file TEXT)")
- c.execute("CREATE TABLE IF NOT EXISTS global_templates (id TEXT PRIMARY KEY, name TEXT, category TEXT, subject TEXT, body TEXT, is_html BOOLEAN, created_at TEXT)")
+ c.execute("CREATE TABLE IF NOT EXISTS global_templates (id TEXT PRIMARY KEY, name TEXT, category TEXT, subject TEXT, body TEXT, is_html BOOLEAN, created_at TEXT, last_accessed_at TEXT)")
c.execute("CREATE TABLE IF NOT EXISTS assets (id TEXT PRIMARY KEY, name TEXT, url TEXT, is_gdrive BOOLEAN, created_at TEXT)")
c.execute("CREATE TABLE IF NOT EXISTS campaign_events (id INTEGER PRIMARY KEY AUTOINCREMENT, campaign_id TEXT, recipient_email TEXT, event_type TEXT, event_data TEXT, created_at TEXT)")
@@ -21,6 +21,16 @@ def init_db(db_path):
cols_campaigns = [r[1] for r in c.execute("PRAGMA table_info(campaigns)").fetchall()]
if "source_template_id" not in cols_campaigns:
c.execute("ALTER TABLE campaigns ADD COLUMN source_template_id TEXT")
+ if "last_accessed_at" not in cols_campaigns:
+ c.execute("ALTER TABLE campaigns ADD COLUMN last_accessed_at TEXT")
+
+ cols_db = [r[1] for r in c.execute("PRAGMA table_info(databases)").fetchall()]
+ if "last_accessed_at" not in cols_db:
+ c.execute("ALTER TABLE databases ADD COLUMN last_accessed_at TEXT")
+
+ cols_tmpl = [r[1] for r in c.execute("PRAGMA table_info(global_templates)").fetchall()]
+ if "last_accessed_at" not in cols_tmpl:
+ c.execute("ALTER TABLE global_templates ADD COLUMN last_accessed_at TEXT")
conn.commit()
conn.close()
diff --git a/src/bit_by_mail/server/handlers/websocket_handler.py b/src/bit_by_mail/server/handlers/websocket_handler.py
index 28ec60e..129ea6a 100644
--- a/src/bit_by_mail/server/handlers/websocket_handler.py
+++ b/src/bit_by_mail/server/handlers/websocket_handler.py
@@ -1,6 +1,7 @@
import json
import os
import logging
+import smtplib
import tornado.websocket
from urllib.parse import urlparse
@@ -29,6 +30,7 @@ def open(self):
"delete_campaigns": self._handle_delete_campaigns,
"save_config": self._handle_save_config,
"clear_config": self._handle_clear_config,
+ "test_smtp_connection": self._handle_test_smtp_connection,
"save_template": self._handle_save_template,
"upload_recipients": self._handle_upload_recipients,
"save_recipients": self._handle_save_recipients,
@@ -156,6 +158,7 @@ async def _handle_get_campaign_data(self, payload):
if not campaign_id:
return
+ await self.campaign_service.touch(campaign_id)
name = await self._get_campaign_name(campaign_id)
logger.info(f"Opened campaign data for: {name}")
recipients = await self.recipient_service.get_recipients(campaign_id)
@@ -268,6 +271,31 @@ async def _handle_clear_config(self, _):
{"action": "config_cleared", "payload": config}
)
+ async def _handle_test_smtp_connection(self, payload):
+ try:
+ host = payload.get("smtp_server")
+ port = int(payload.get("smtp_port", 587))
+ use_ssl = payload.get("use_ssl", False)
+ sender_email = payload.get("sender_email")
+ password = payload.get("sender_password")
+
+ if use_ssl:
+ server = smtplib.SMTP_SSL(host, port, timeout=10)
+ else:
+ server = smtplib.SMTP(host, port, timeout=10)
+ server.starttls()
+ server.login(sender_email, password)
+ server.quit()
+ await self.safe_write_message(json.dumps({
+ "action": "test_smtp_connection_result",
+ "payload": {"success": True, "message": "Connection successful!"}
+ }))
+ except Exception as e:
+ await self.safe_write_message(json.dumps({
+ "action": "test_smtp_connection_result",
+ "payload": {"success": False, "message": str(e)}
+ }))
+
async def _handle_save_template(self, payload):
campaign_id = payload.get("campaign_id")
content = payload.get("content")
@@ -383,6 +411,7 @@ async def _handle_get_database_data(self, payload):
if not db_id:
return
+ await self.database_service.touch(db_id)
name = await self._get_db_name(db_id)
logger.info(f"Opened database data for: {name}")
recipients = await self.database_service.get_database_data(db_id)
@@ -465,6 +494,7 @@ async def _handle_get_global_template_data(self, payload):
if not template_id:
return
+ await self.global_template_service.touch(template_id)
name = await self._get_template_name(template_id)
logger.info(f"Opened global template data for: {name}")
data = await self.global_template_service.get_template_data(template_id)
diff --git a/src/bit_by_mail/server/services/campaign_service.py b/src/bit_by_mail/server/services/campaign_service.py
index 8a4c9d8..f353672 100644
--- a/src/bit_by_mail/server/services/campaign_service.py
+++ b/src/bit_by_mail/server/services/campaign_service.py
@@ -10,7 +10,7 @@ def __init__(self, db_path, base_dir):
async def get_campaigns(self):
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
- async with db.execute("SELECT * FROM campaigns ORDER BY created_at DESC") as cursor:
+ async with db.execute("SELECT * FROM campaigns ORDER BY COALESCE(last_accessed_at, created_at) DESC") as cursor:
rows = await cursor.fetchall()
campaigns = []
for r in rows:
@@ -27,13 +27,19 @@ async def get_campaigns(self):
campaigns.append(c)
return campaigns
+ async def touch(self, campaign_id):
+ updated_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+ async with aiosqlite.connect(self.db_path) as db:
+ await db.execute("UPDATE campaigns SET last_accessed_at = ? WHERE id = ?", (updated_at, campaign_id))
+ await db.commit()
+
async def create_campaign(self, name, subject=None, body=None, recipients=None, source_db_id=None, sender_account_id="", is_html=True, source_template_id=None):
new_id = str(uuid.uuid4())
subject = subject if subject is not None else f"Subject for {name}"
created_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
template_content = body if body is not None else (f"
Email for {name}
\nHello {{{{Name}}}}
" if is_html else f"Email for {name}\nHello {{{{Name}}}}")
async with aiosqlite.connect(self.db_path) as db:
- await db.execute("INSERT INTO campaigns (id, name, subject, attachment_folder, send_attachments, sender_account_id, is_html, delay, created_at, source_db_id, body, source_template_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (new_id, name, subject, self.base_dir, False, sender_account_id, is_html, 0, created_at, source_db_id, template_content, source_template_id))
+ await db.execute("INSERT INTO campaigns (id, name, subject, attachment_folder, send_attachments, sender_account_id, is_html, delay, created_at, last_accessed_at, source_db_id, body, source_template_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (new_id, name, subject, self.base_dir, False, sender_account_id, is_html, 0, created_at, created_at, source_db_id, template_content, source_template_id))
if recipients:
for r in recipients:
await db.execute("INSERT INTO campaign_recipients (campaign_id, name, email, attachment_file, status, sent_timestamp) VALUES (?, ?, ?, ?, ?, ?)", (new_id, r.get("Name", ""), r.get("Email", ""), r.get("AttachmentFile", ""), r.get("Status", "PENDING"), r.get("SentTimestamp", "")))
diff --git a/src/bit_by_mail/server/services/database_service.py b/src/bit_by_mail/server/services/database_service.py
index 67640f5..949fec3 100644
--- a/src/bit_by_mail/server/services/database_service.py
+++ b/src/bit_by_mail/server/services/database_service.py
@@ -12,7 +12,7 @@ def __init__(self, db_path):
async def get_databases(self):
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
- async with db.execute("SELECT * FROM databases ORDER BY created_at DESC") as cursor:
+ async with db.execute("SELECT * FROM databases ORDER BY COALESCE(last_accessed_at, created_at) DESC") as cursor:
rows = await cursor.fetchall()
dbs = []
for r in rows:
@@ -24,11 +24,17 @@ async def get_databases(self):
dbs.append(d)
return dbs
+ async def touch(self, db_id):
+ updated_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+ async with aiosqlite.connect(self.db_path) as db:
+ await db.execute("UPDATE databases SET last_accessed_at = ? WHERE id = ?", (updated_at, db_id))
+ await db.commit()
+
async def create_database(self, name, base64_content=None):
new_id = str(uuid.uuid4())
created_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
async with aiosqlite.connect(self.db_path) as db:
- await db.execute("INSERT INTO databases (id, name, created_at) VALUES (?, ?, ?)", (new_id, name, created_at))
+ await db.execute("INSERT INTO databases (id, name, created_at, last_accessed_at) VALUES (?, ?, ?, ?)", (new_id, name, created_at, created_at))
if base64_content:
content_str = base64.b64decode(base64_content).decode("utf-8")
df = pd.read_csv(io.StringIO(content_str)).fillna("")
@@ -77,3 +83,4 @@ async def import_csv_to_database(self, db_id, base64_content, mode):
for _, r in df.iterrows():
await db.execute("INSERT INTO database_recipients (db_id, name, email, attachment_file) VALUES (?, ?, ?, ?)", (db_id, r.get("Name", ""), r.get("Email", ""), r.get("AttachmentFile", "")))
await db.commit()
+
diff --git a/src/bit_by_mail/server/services/global_template_service.py b/src/bit_by_mail/server/services/global_template_service.py
index e0cf2e8..e5411b9 100644
--- a/src/bit_by_mail/server/services/global_template_service.py
+++ b/src/bit_by_mail/server/services/global_template_service.py
@@ -9,10 +9,16 @@ def __init__(self, db_path):
async def get_templates(self):
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
- async with db.execute("SELECT id, name, category, subject, body, is_html, created_at FROM global_templates ORDER BY created_at DESC") as cursor:
+ async with db.execute("SELECT id, name, category, subject, body, is_html, created_at FROM global_templates ORDER BY COALESCE(last_accessed_at, created_at) DESC") as cursor:
rows = await cursor.fetchall()
return [{"id": r["id"], "name": r["name"], "category": r["category"], "subject": r["subject"], "body": r["body"], "is_html": bool(r["is_html"]), "createdAt": r["created_at"]} for r in rows]
+ async def touch(self, template_id):
+ updated_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+ async with aiosqlite.connect(self.db_path) as db:
+ await db.execute("UPDATE global_templates SET last_accessed_at = ? WHERE id = ?", (updated_at, template_id))
+ await db.commit()
+
async def get_template_data(self, template_id):
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
@@ -26,7 +32,7 @@ async def create_template(self, name, category="", subject="", body="", is_html=
new_id = str(uuid.uuid4())
created_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
async with aiosqlite.connect(self.db_path) as db:
- await db.execute("INSERT INTO global_templates (id, name, category, subject, body, is_html, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", (new_id, name, category, subject, body, is_html, created_at))
+ await db.execute("INSERT INTO global_templates (id, name, category, subject, body, is_html, created_at, last_accessed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", (new_id, name, category, subject, body, is_html, created_at, created_at))
await db.commit()
t = await self.get_template_data(new_id)
return t, await self.get_templates()
@@ -53,3 +59,4 @@ async def duplicate_template(self, template_id):
if src:
await self.create_template(src["name"] + " (Copy)", src["category"], src["subject"], src["body"], src["is_html"])
return await self.get_templates()
+
diff --git a/src/bit_by_mail/server/services/preflight_service.py b/src/bit_by_mail/server/services/preflight_service.py
index 902b2a1..5cb6e91 100644
--- a/src/bit_by_mail/server/services/preflight_service.py
+++ b/src/bit_by_mail/server/services/preflight_service.py
@@ -89,26 +89,52 @@ async def run_checks(self, campaign_id, config, subject_template):
if not acc:
r.errors.append("No sender account configured or selected.")
else:
+ acc_valid = True
for v in ["smtp_server", "sender_email", "sender_password", "smtp_port"]:
if not acc.get(v):
r.errors.append(f"Sender account is missing required field: {v}")
+ acc_valid = False
+ if acc_valid:
+ r.successes.append(f"Sender account '{acc.get('name') or acc.get('sender_email')}' is properly configured.")
html_template = await self.template_service.get_template(campaign_id)
if not html_template:
r.errors.append("Email template is empty.")
if not subject_template:
r.errors.append("Email subject is empty.")
+ if html_template and subject_template:
+ r.successes.append("Email subject and template content are present.")
- if c.get("send_attachments", False) and not os.path.isdir(c.get("attachment_folder", "")):
- r.errors.append(f"Attachment folder is invalid or does not exist: {c.get('attachment_folder', '')}")
+ if c.get("send_attachments", False):
+ if not os.path.isdir(c.get("attachment_folder", "")):
+ r.errors.append(f"Attachment folder is invalid or does not exist: {c.get('attachment_folder', '')}")
+ else:
+ r.successes.append(f"Attachment folder verified: {c.get('attachment_folder', '')}")
df = pd.DataFrame(await self.recipient_service.get_recipients(campaign_id)).fillna("")
+
+ ph_subject = self._extract_ph(subject_template) if subject_template else set()
+ ph_body = self._extract_ph(html_template) if html_template else set()
+ req_cols = ph_subject | ph_body
+
+ if req_cols:
+ r.successes.append(f"Found {len(req_cols)} placeholders in email: {', '.join(['{{'+x+'}}' for x in req_cols])}")
+ else:
+ r.warnings.append("No placeholders found in the email subject or body.")
+
if df.empty:
r.warnings.append("No recipients found for this campaign.")
else:
- req_cols = self._extract_ph(subject_template) | self._extract_ph(html_template)
- for col in req_cols - set(df.columns):
- r.errors.append(f"Missing required column for placeholder: {{{{{col}}}}}")
+ csv_cols = list(df.columns)
+ r.successes.append(f"Found {len(csv_cols)} columns in recipient data: {', '.join(csv_cols)}")
+ r.successes.append(f"Loaded {len(df)} total recipients.")
+
+ missing = req_cols - set(df.columns)
+ if missing:
+ for col in missing:
+ r.errors.append(f"Missing required column for placeholder: {{{{{col}}}}}")
+ elif req_cols:
+ r.successes.append("All placeholders match CSV columns perfectly.")
for idx, (_, row) in enumerate(df.iterrows()):
name = str(row.get("Name", "")).strip()
@@ -128,7 +154,10 @@ async def run_checks(self, campaign_id, config, subject_template):
if c.get("send_attachments", False):
files = [x.strip() for x in str(row.get("AttachmentFile", "")).split(";") if x.strip()]
+ if not files:
+ r.warnings.append(f"Row {idx+2} ({identifier}): No attachment file specified but attachments are enabled.")
for f in files:
if not os.path.exists(os.path.join(c.get("attachment_folder", ""), f)):
r.errors.append(f"Row {idx+2} ({identifier}): Attachment file '{f}' not found")
return r
+
diff --git a/web/src/features/assets/components/AssetDetailModal.tsx b/web/src/features/assets/components/AssetDetailModal.tsx
index 0b03d7f..dc4c8b5 100644
--- a/web/src/features/assets/components/AssetDetailModal.tsx
+++ b/web/src/features/assets/components/AssetDetailModal.tsx
@@ -19,14 +19,14 @@ export const AssetDetailModal: React.FC<{
const handleDelete = () => {
apiService.deleteAssets([asset.id])
- toast.success('Asset deleted')
+ toast.success('Image deleted')
onClose()
}
const handleSave = () => {
if (name.trim() && name !== asset.name) {
apiService.updateAsset(asset.id, { name: name.trim() })
- toast.success('Asset updated')
+ toast.success('Image updated')
}
onClose()
}
@@ -35,7 +35,7 @@ export const AssetDetailModal: React.FC<{
@@ -50,7 +50,7 @@ export const AssetDetailModal: React.FC<{
setSearch(e.target.value)}
className="w-full h-10 px-3 bg-surface-element border border-borders-primary rounded-lg text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-blue"
@@ -39,7 +39,7 @@ export const AssetPickerModal: React.FC<{
{filteredAssets.length === 0 ? (
- No assets found. Add some in the Asset Library.
+ No images found. Add some in the Image Library.
) : (
filteredAssets.map((asset) => (
diff --git a/web/src/features/assets/components/AssetSelectionPopup.tsx b/web/src/features/assets/components/AssetSelectionPopup.tsx
index 63339c1..13bb30d 100644
--- a/web/src/features/assets/components/AssetSelectionPopup.tsx
+++ b/web/src/features/assets/components/AssetSelectionPopup.tsx
@@ -23,8 +23,8 @@ export const AssetSelectionPopup: React.FC = () => {
apiService.deleteAssets(Array.from(selectedAssetIds))
toast.success(
selectedCount === 1
- ? `Asset deleted successfully`
- : `${selectedCount} assets deleted successfully`,
+ ? `Image deleted successfully`
+ : `${selectedCount} images deleted successfully`,
)
clearAssetSelection()
}
@@ -47,7 +47,7 @@ export const AssetSelectionPopup: React.FC = () => {
>
- {selectedCount} {selectedCount === 1 ? 'asset' : 'assets'}{' '}
+ {selectedCount} {selectedCount === 1 ? 'image' : 'images'}{' '}
selected
@@ -63,11 +63,11 @@ export const AssetSelectionPopup: React.FC = () => {
{
setName(
- `Asset-${Math.floor(Date.now() / 1000)
+ `Image-${Math.floor(Date.now() / 1000)
.toString()
.slice(-6)}`,
)
@@ -40,12 +40,12 @@ export const AssetUploadModal: React.FC<{
}
setIsUploading(true)
apiService.createAsset(name.trim(), url.trim(), isGDrive)
- toast.success('Asset added successfully')
+ toast.success('Image added successfully')
onClose()
}
return (
-
+
To send images in your emails, they must first be hosted online. You
@@ -56,7 +56,7 @@ export const AssetUploadModal: React.FC<{
diff --git a/web/src/features/campaigns/components/CampaignSummaryModal.tsx b/web/src/features/campaigns/components/CampaignSummaryModal.tsx
index dde2bfa..0dca399 100644
--- a/web/src/features/campaigns/components/CampaignSummaryModal.tsx
+++ b/web/src/features/campaigns/components/CampaignSummaryModal.tsx
@@ -1,12 +1,12 @@
import { useParams } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
-import { AlertTriangle, FileStack, Send, Users } from 'lucide-react'
+import { AlertTriangle, FileStack, Send, Users, Server } from 'lucide-react'
import React, { useMemo } from 'react'
import { apiService } from '@/services/apiService'
import { useAppStore } from '@/store/useAppStore'
import { Button } from '@/components/common/Button'
import { Modal } from '@/components/common/Modal'
-import type { CampaignData } from '@/types'
+import type { CampaignData, Config, Campaign } from '@/types'
const formatBytes = (bytes: number, decimals = 2) => {
if (!bytes || bytes === 0) return '0 Bytes'
@@ -55,6 +55,16 @@ export const CampaignSummaryModal: React.FC = () => {
enabled: !!campaignId,
})
+ const { data: config } = useQuery
({ queryKey: ['config'] })
+ const { data: campaigns } = useQuery({ queryKey: ['campaigns'] })
+
+ const activeCampaign = campaigns?.find((c) => c.id === campaignId)
+ const accounts = config?.accounts || []
+ const senderAccount =
+ accounts.find((a) => a.id === activeCampaign?.sender_account_id) ||
+ accounts.find((a) => a.is_default) ||
+ accounts[0]
+
const recipientsToSend = useMemo(() => {
const allRecipients = campaignData?.recipients || []
if (selectedRecipientIndices.size > 0) {
@@ -94,6 +104,13 @@ export const CampaignSummaryModal: React.FC = () => {
Summary
+
}
+ label="Sending From"
+ value={
+ senderAccount ? senderAccount.sender_email : 'Not Configured'
+ }
+ />
}
label="Total Recipients"
diff --git a/web/src/features/campaigns/components/Editor.tsx b/web/src/features/campaigns/components/Editor.tsx
index cdd16b9..4f2c42b 100644
--- a/web/src/features/campaigns/components/Editor.tsx
+++ b/web/src/features/campaigns/components/Editor.tsx
@@ -8,6 +8,7 @@ import {
Image as ImageIcon,
PanelRightClose,
PanelRightOpen,
+ ChevronDown,
} from 'lucide-react'
import React, { useEffect, useState, useRef, useMemo } from 'react'
import { toast } from 'sonner'
@@ -26,7 +27,7 @@ import { useAppStore } from '@/store/useAppStore'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import js_beautify from 'js-beautify'
-const PlaceholderList: React.FC<{
+const PlaceholderDropdown: React.FC<{
entityId: string
onInsert: (placeholderTag: string) => void
}> = ({ entityId, onInsert }) => {
@@ -36,6 +37,12 @@ const PlaceholderList: React.FC<{
const recipients = data?.recipients ?? []
const availablePlaceholders =
recipients.length > 0 ? Object.keys(recipients[0]) : []
+ const [isOpen, setIsOpen] = useState(false)
+ const [search, setSearch] = useState('')
+
+ const filtered = availablePlaceholders.filter((p) =>
+ p.toLowerCase().includes(search.toLowerCase()),
+ )
if (availablePlaceholders.length === 0)
return (
@@ -43,18 +50,44 @@ const PlaceholderList: React.FC<{
No recipients uploaded yet.
)
+
return (
-
- {availablePlaceholders.map((placeholder) => (
-
onInsert(`{{${placeholder}}}`)}
- >
- {`{{${placeholder}}}`}
-
- ))}
+
+
+ {isOpen && (
+
+
+ setSearch(e.target.value)}
+ className="w-full bg-surface-element border border-borders-primary rounded px-2 py-1 text-xs outline-none focus:border-accent-blue"
+ />
+
+
+ {filtered.map((p) => (
+
+ ))}
+
+
+ )}
)
}
@@ -413,7 +446,7 @@ const EditorContent: React.FC<{
onClick={() => setShowAssetPicker(true)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-surface-element hover:bg-surface-element-hover text-text-secondary hover:text-text-primary transition-colors border border-borders-primary"
>
-
Insert Asset
+
Insert Image
{type === 'campaign' && (
<>
@@ -442,8 +475,8 @@ const EditorContent: React.FC<{
-
-
+
+
-
-
-
+
+ {type === 'campaign' && (
+
+ )}
+
+
+
+
@@ -530,17 +571,6 @@ const EditorContent: React.FC<{
)}
- {type === 'campaign' && (
-
-
- Available Placeholders
-
-
-
- )}
= ({
+ campaignId,
+}) => {
+ const showPreflightModal = useAppStore((state) => state.showPreflightModal)
+ const setShowPreflightModal = useAppStore(
+ (state) => state.setShowPreflightModal,
+ )
+ const preflightResult = useAppStore((state) => state.preflightResult)
+ const isSending = useAppStore((state) => state.isSending)
+
+ const { data: config } = useQuery
({ queryKey: ['config'] })
+ const { data: campaigns } = useQuery({ queryKey: ['campaigns'] })
+
+ const activeCampaign = campaigns?.find((c) => c.id === campaignId)
+ const accounts = config?.accounts || []
+ const senderAccount =
+ accounts.find((a) => a.id === activeCampaign?.sender_account_id) ||
+ accounts.find((a) => a.is_default) ||
+ accounts[0]
+
+ if (!preflightResult) return null
+
+ const handleClose = () => setShowPreflightModal(false)
+
+ const handleProceed = () => {
+ handleClose()
+ if (!isSending) {
+ apiService.flushQueue()
+ apiService.getCampaignSummary(campaignId)
+ }
+ }
+
+ const handleTestSmtp = () => {
+ if (senderAccount) {
+ toast.promise(
+ apiService.testSmtpConnection(senderAccount).then((res: any) => {
+ if (!res.success) throw new Error(res.message)
+ return res
+ }),
+ {
+ loading: 'Testing connection...',
+ success: (data) => data.message,
+ error: (err) => `Connection failed: ${err.message}`,
+ },
+ )
+ }
+ }
+
+ return (
+
+
+ {preflightResult.ok ? (
+
+
+
+
+ All Checks Passed
+
+
+ Your campaign is ready to be sent.
+
+
+
+ ) : (
+
+
+
+
+ Errors Detected
+
+
+ Please resolve the issues below before sending.
+
+
+
+ )}
+
+ {preflightResult.successes.length > 0 && (
+
+
+ {' '}
+ Successes
+
+
+ {preflightResult.successes.map((msg, i) => (
+ - {msg}
+ ))}
+
+
+ )}
+
+ {preflightResult.warnings.length > 0 && (
+
+
+ {' '}
+ Warnings
+
+
+ {preflightResult.warnings.map((msg, i) => (
+ - {msg}
+ ))}
+
+
+ )}
+
+ {preflightResult.errors.length > 0 && (
+
+
+ Errors
+
+
+ {preflightResult.errors.map((msg, i) => (
+ - {msg}
+ ))}
+
+
+ )}
+
+
+
+
+ {preflightResult.ok && (
+
+ )}
+
+
+
+ )
+}
diff --git a/web/src/features/campaigns/components/RecipientTable.tsx b/web/src/features/campaigns/components/RecipientTable.tsx
index 5464263..520c4ba 100644
--- a/web/src/features/campaigns/components/RecipientTable.tsx
+++ b/web/src/features/campaigns/components/RecipientTable.tsx
@@ -17,6 +17,10 @@ import {
Pencil,
Upload,
MoreHorizontal,
+ CheckCircle,
+ XCircle,
+ Trash2,
+ X,
} from 'lucide-react'
import React, { useState, useMemo } from 'react'
import { useDebouncedEffect } from '@/hooks/useDebouncedEffect'
@@ -29,6 +33,7 @@ import { MaximizableView } from '@/components/common/MaximizableView'
import { Modal } from '@/components/common/Modal'
import { Button } from '@/components/common/Button'
import { toast } from 'sonner'
+import { ConfirmDialog } from '@/components/common/ConfirmDialog'
const EMPTY_ARRAY: any[] = []
@@ -78,6 +83,7 @@ const RecipientTableContent: React.FC<{
const [showMoreMenu, setShowMoreMenu] = useState(false)
const [renamingCol, setRenamingCol] = useState(null)
const [newColName, setNewColName] = useState('')
+ const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const isAllSelected =
recipients.length > 0 && selectedRecipientIndices.size === recipients.length
@@ -205,6 +211,73 @@ const RecipientTableContent: React.FC<{
if (event.target) event.target.value = ''
}
+ const confirmDelete = () => {
+ if (!contextId) return
+ const selectedCount = selectedRecipientIndices.size
+ if (contextType === 'campaign') {
+ const data = queryClient.getQueryData([
+ 'campaignData',
+ contextId,
+ ])
+ if (data) {
+ const newRecipients = data.recipients.filter(
+ (_, i) => !selectedRecipientIndices.has(i),
+ )
+ queryClient.setQueryData(['campaignData', contextId], {
+ ...data,
+ recipients: newRecipients,
+ })
+ apiService.saveRecipients(contextId, newRecipients)
+ }
+ } else {
+ const data = queryClient.getQueryData([
+ 'databaseData',
+ contextId,
+ ])
+ if (data) {
+ const newRecipients = data.recipients.filter(
+ (_, i) => !selectedRecipientIndices.has(i),
+ )
+ queryClient.setQueryData(['databaseData', contextId], {
+ ...data,
+ recipients: newRecipients,
+ })
+ apiService.saveDatabaseData(contextId, newRecipients)
+ }
+ }
+ clearRecipientSelection()
+ toast.success(`${selectedCount} recipient(s) deleted`)
+ }
+
+ const updateStatus = (status: 'SENT' | 'PENDING') => {
+ if (contextType !== 'campaign' || !contextId) return
+ const data = queryClient.getQueryData([
+ 'campaignData',
+ contextId,
+ ])
+ if (data) {
+ const newRecipients = data.recipients.map((r, i) => {
+ if (selectedRecipientIndices.has(i)) {
+ const updated = { ...r, Status: status }
+ if (status === 'SENT' && r.Status !== 'SENT')
+ updated.SentTimestamp = new Date().toISOString()
+ else if (status === 'PENDING') updated.SentTimestamp = undefined
+ return updated
+ }
+ return r
+ })
+ queryClient.setQueryData(['campaignData', contextId], {
+ ...data,
+ recipients: newRecipients,
+ })
+ apiService.saveRecipients(contextId, newRecipients)
+ }
+ clearRecipientSelection()
+ toast.success(
+ `${selectedRecipientIndices.size} recipient(s) marked as ${status}`,
+ )
+ }
+
const columns = useMemo(() => {
if (recipients.length === 0) return EMPTY_ARRAY
const keys = Object.keys(recipients[0]).filter(
@@ -371,141 +444,183 @@ const RecipientTableContent: React.FC<{
return (
-
-
-
-
- Recipients ({recipients.length})
-
-