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}

\n

Hello {{{{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<{

- Add Asset + Add Image
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}) -

- + + + )} + + Delete + +
- {!showAttachments && contextType === 'campaign' && ( -

- Attachments disabled. -

- )}
- -
- {contextType === 'campaign' && ( - <> - - - - )} + + +
+ {!showAttachments && contextType === 'campaign' && ( +

+ Attachments disabled. +

+ )} +
-
- - {showMoreMenu && ( -
-
- - {recipients.length > 0 && ( - <> - -
-
- - Columns - +
+ {contextType === 'campaign' && ( + <> + + + + )} + +
+ + {showMoreMenu && ( +
+
+ + {recipients.length > 0 && ( + <> -
-
- {table.getAllLeafColumns().map((col) => { - if ( - col.id === 'index' || - col.id === 'select' || - col.id === 'preview' || - col.id === 'Status' - ) - return null - return ( - - ) - })} -
- - )} +
+
+ + Columns + + +
+
+ {table.getAllLeafColumns().map((col) => { + if ( + col.id === 'index' || + col.id === 'select' || + col.id === 'preview' || + col.id === 'Status' + ) + return null + return ( + + ) + })} +
+ + )} +
-
- )} -
+ )} +
- + +
-
+ )}
@@ -610,6 +725,16 @@ const RecipientTableContent: React.FC<{
+ setShowConfirmDelete(false)} + /> + {renamingCol && ( { const clearLogs = useAppStore((state) => state.clearLogs) const setConnectionStatus = useAppStore((state) => state.setConnectionStatus) const setProgress = useAppStore((state) => state.setProgress) + const incrementStatusCount = useAppStore( + (state) => state.incrementStatusCount, + ) const setRecipientIssues = useAppStore((state) => state.setRecipientIssues) const clearRecipientIssues = useAppStore( (state) => state.clearRecipientIssues, @@ -25,6 +28,10 @@ export const useWebSocket = () => { const setShowCampaignSummaryModal = useAppStore( (state) => state.setShowCampaignSummaryModal, ) + const setPreflightResult = useAppStore((state) => state.setPreflightResult) + const setShowPreflightModal = useAppStore( + (state) => state.setShowPreflightModal, + ) const connect = useCallback(() => { if (ws.current?.readyState === WebSocket.OPEN) return @@ -102,7 +109,6 @@ export const useWebSocket = () => { ) apiService.handleResponse('campaign_data', payload) clearLogs() - apiService.runPreflightCheck(payload.campaign_id) break case 'recipients_updated': { @@ -119,7 +125,6 @@ export const useWebSocket = () => { (old) => (old ? { ...old, recipients: payload } : old), ) toast.success(`${payload.length} recipients loaded successfully`) - apiService.runPreflightCheck(campaignId) } } break @@ -141,6 +146,13 @@ export const useWebSocket = () => { message: `To: ${payload.email} - ${payload.details}`, }) + if (payload.status.toUpperCase() === 'SENT') + incrementStatusCount('SENT') + else if (payload.status.toUpperCase() === 'ERROR') + incrementStatusCount('ERROR') + else if (payload.status.toUpperCase() === 'SKIPPED') + incrementStatusCount('SKIPPED') + const matchesStatus = router.state.matches const currentMatchStatus = matchesStatus[matchesStatus.length - 1] const paramsStatus = currentMatchStatus?.params as Record< @@ -167,21 +179,21 @@ export const useWebSocket = () => { break case 'preflight_result': clearRecipientIssues() + setPreflightResult(payload) + setShowPreflightModal(true) + addLog({ level: 'info', message: '--- PREFLIGHT CHECK RESULTS ---' }) if (payload.successes && payload.successes.length > 0) { - addLog({ level: 'info', message: 'CHECKS PASSED:' }) payload.successes.forEach((msg: string) => addLog({ level: 'success', message: `+ ${msg}` }), ) } if (payload.errors && payload.errors.length > 0) { - addLog({ level: 'info', message: 'ERRORS:' }) payload.errors.forEach((err: string) => addLog({ level: 'error', message: `- ${err}` }), ) } if (payload.warnings && payload.warnings.length > 0) { - addLog({ level: 'info', message: 'WARNINGS:' }) payload.warnings.forEach((warn: string) => addLog({ level: 'warn', message: `! ${warn}` }), ) @@ -192,13 +204,13 @@ export const useWebSocket = () => { message: 'Preflight complete. System is ready.', }) } else { - toast.error('Preflight failed. See logs for details.') addLog({ level: 'error', message: 'Preflight failed. Please resolve the errors above.', }) } addLog({ level: 'info', message: '-----------------------------' }) + const issues: Record = {} if ( payload.recipient_issues && @@ -313,10 +325,13 @@ export const useWebSocket = () => { setIsStopping, clearLogs, setProgress, + incrementStatusCount, setRecipientIssues, clearRecipientIssues, setCampaignSummary, setShowCampaignSummaryModal, + setPreflightResult, + setShowPreflightModal, setConnectionStatus, router, ]) diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx index 1a36c1b..55ea1ef 100644 --- a/web/src/layouts/MainLayout.tsx +++ b/web/src/layouts/MainLayout.tsx @@ -1,7 +1,6 @@ import React from 'react' import { Sidebar } from './Sidebar' import { useWebSocket } from '@/hooks/useWebSocket' -import { RecipientActionPopup } from '@/features/campaigns/components/RecipientActionPopup' import { useAppStore } from '@/store/useAppStore' import { WifiOff } from 'lucide-react' @@ -15,7 +14,6 @@ const MainLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
{children}
- {connectionStatus === 'closed' && (
diff --git a/web/src/layouts/Sidebar.tsx b/web/src/layouts/Sidebar.tsx index ed1bbb2..d16d97e 100644 --- a/web/src/layouts/Sidebar.tsx +++ b/web/src/layouts/Sidebar.tsx @@ -124,8 +124,8 @@ export const Sidebar: React.FC = () => { className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${ routerState.location.pathname === '/campaigns' || routerState.location.pathname === '/' - ? 'bg-accent-blue/10 text-accent-blue' - : 'text-text-secondary hover:bg-surface-element hover:text-text-primary' + ? 'bg-accent-blue/10 text-accent-blue border-l-4 border-accent-blue' + : 'text-text-secondary hover:bg-surface-element hover:text-text-primary border-l-4 border-transparent' } ${!isVisuallyExpanded ? 'justify-center' : ''}`} title="Campaigns" > @@ -138,8 +138,8 @@ export const Sidebar: React.FC = () => { to="/databases" className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${ routerState.location.pathname.startsWith('/databases') - ? 'bg-accent-blue/10 text-accent-blue' - : 'text-text-secondary hover:bg-surface-element hover:text-text-primary' + ? 'bg-accent-blue/10 text-accent-blue border-l-4 border-accent-blue' + : 'text-text-secondary hover:bg-surface-element hover:text-text-primary border-l-4 border-transparent' } ${!isVisuallyExpanded ? 'justify-center' : ''}`} title="Databases" > @@ -152,8 +152,8 @@ export const Sidebar: React.FC = () => { to="/templates" className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${ routerState.location.pathname.startsWith('/templates') - ? 'bg-accent-blue/10 text-accent-blue' - : 'text-text-secondary hover:bg-surface-element hover:text-text-primary' + ? 'bg-accent-blue/10 text-accent-blue border-l-4 border-accent-blue' + : 'text-text-secondary hover:bg-surface-element hover:text-text-primary border-l-4 border-transparent' } ${!isVisuallyExpanded ? 'justify-center' : ''}`} title="Templates" > @@ -166,20 +166,22 @@ export const Sidebar: React.FC = () => { to="/assets" className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${ routerState.location.pathname.startsWith('/assets') - ? 'bg-accent-blue/10 text-accent-blue' - : 'text-text-secondary hover:bg-surface-element hover:text-text-primary' + ? 'bg-accent-blue/10 text-accent-blue border-l-4 border-accent-blue' + : 'text-text-secondary hover:bg-surface-element hover:text-text-primary border-l-4 border-transparent' } ${!isVisuallyExpanded ? 'justify-center' : ''}`} - title="Assets" + title="Image Library" > - {isVisuallyExpanded && Assets ({assets.length})} + {isVisuallyExpanded && ( + Image Library ({assets.length}) + )} @@ -266,10 +268,10 @@ export const Sidebar: React.FC = () => { diff --git a/web/src/layouts/StatusBar.tsx b/web/src/layouts/StatusBar.tsx index 12dbe4c..fc54911 100644 --- a/web/src/layouts/StatusBar.tsx +++ b/web/src/layouts/StatusBar.tsx @@ -6,6 +6,9 @@ import { Maximize, Minimize, Trash2, + CheckCircle2, + AlertCircle, + FastForward, } from 'lucide-react' import type { LogEntry } from '@/types' import { motion, AnimatePresence } from 'framer-motion' @@ -47,6 +50,7 @@ const LogViewerContent: React.FC<{ const logs = useAppStore((state) => state.logs) const isSending = useAppStore((state) => state.isSending) const progress = useAppStore((state) => state.progress) + const statusCounts = useAppStore((state) => state.statusCounts) const isLogCollapsed = useAppStore((state) => state.isLogCollapsed) const setIsLogCollapsed = useAppStore((state) => state.setIsLogCollapsed) const clearLogs = useAppStore((state) => state.clearLogs) @@ -67,21 +71,65 @@ const LogViewerContent: React.FC<{ const percentage = progress.total > 0 ? (progress.sent / progress.total) * 100 : 0 - const lastLog = logs.length > 0 ? logs[logs.length - 1] : null return (
-
-
!isMaximized && setIsLogCollapsed(!isLogCollapsed)} - > -

- Live Logs -

+
!isMaximized && setIsLogCollapsed(!isLogCollapsed)} + > +
+
+ + + + + + {Math.round(percentage)}% + +
+ +
+
+ + + Sent: {statusCounts.sent} + +
+
+ + + Failed: {statusCounts.error} + +
+
+ + + Skipped: {statusCounts.skipped} + +
+
{isLogCollapsed && !isMaximized && lastLog && !isSending && (
@@ -97,24 +145,19 @@ const LogViewerContent: React.FC<{
)} - - {isSending && progress.total > 0 && ( -
-
-
-
- - {progress.sent} / {progress.total} - -
- )}
+
+ {isSending && ( + + Processing... + + )} {!isMaximized && ( )}
+ {!isLogCollapsed && (
-

Asset Library

+

Image Library

setSearch(e.target.value)} className="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" />
@@ -57,18 +57,18 @@ function AssetsList() { {isLoading ? (
-

Loading assets...

+

Loading images...

) : !assets || assets.length === 0 ? (
-

No assets yet

+

No images yet

Store URLs of images to use them directly in your email templates.

) : ( diff --git a/web/src/routes/campaigns/$campaignId.tsx b/web/src/routes/campaigns/$campaignId.tsx index 41ee212..b1586be 100644 --- a/web/src/routes/campaigns/$campaignId.tsx +++ b/web/src/routes/campaigns/$campaignId.tsx @@ -10,6 +10,7 @@ import { EmailPreviewModal } from '@/features/campaigns/components/EmailPreviewM import { AddRecipientModal } from '@/features/campaigns/components/AddRecipientModal' import { CampaignSettingsModal } from '@/features/campaigns/components/CampaignSettingsModal' import { ActivityDashboard } from '@/features/campaigns/components/ActivityDashboard' +import { PreflightResultsModal } from '@/features/campaigns/components/PreflightResultsModal' import { useAppStore } from '@/store/useAppStore' import { useEffect } from 'react' import { apiService } from '@/services/apiService' @@ -109,6 +110,7 @@ function CampaignDetail() {
+ {showCampaignSummaryModal && } {showCampaignSettingsModal && ( diff --git a/web/src/routes/settings.tsx b/web/src/routes/settings.tsx index ea1f19b..9e55a18 100644 --- a/web/src/routes/settings.tsx +++ b/web/src/routes/settings.tsx @@ -19,6 +19,8 @@ import { ExternalLink, RotateCcw, AlertTriangle, + Eye, + EyeOff, } from 'lucide-react' import { toast } from 'sonner' import { ConfirmDialog } from '@/components/common/ConfirmDialog' @@ -34,6 +36,9 @@ const DetailRow = ({ value, valueClass = 'text-text-primary', isCode = false, + isPassword = false, + isVisible = false, + onToggle = () => {}, }: any) => (
@@ -41,10 +46,24 @@ const DetailRow = ({ {label}
- {isCode ? ( + {isPassword ? ( + <> + + {isVisible ? value : '••••••••••••••••'} + + + + ) : isCode ? ( @@ -64,9 +83,14 @@ function SettingsPage() { const [showClearConfirm, setShowClearConfirm] = useState(false) const [showFactoryResetConfirm, setShowFactoryResetConfirm] = useState(false) const [eraseAccounts, setEraseAccounts] = useState(false) + const [resetInput, setResetInput] = useState('') const [editingAccount, setEditingAccount] = useState | null>( null, ) + const [showPassword, setShowPassword] = useState(false) + const [visiblePasswords, setVisiblePasswords] = useState>( + new Set(), + ) const handleClear = () => { apiService.clearConfig() @@ -81,6 +105,7 @@ function SettingsPage() { const handleEditAccount = (acc: Account) => { setEditingAccount({ ...acc }) + setShowPassword(false) } const handleCreateAccount = () => { @@ -93,6 +118,7 @@ function SettingsPage() { use_ssl: false, is_default: accounts.length === 0, }) + setShowPassword(false) } const handleDeleteAccount = (id: string) => { @@ -113,8 +139,8 @@ function SettingsPage() { toast.success('Default account updated.') } - const handleSaveAccount = (e: React.FormEvent) => { - e.preventDefault() + const handleSaveAccount = (e?: React.FormEvent) => { + if (e) e.preventDefault() if (!editingAccount) return let newAccounts = [...accounts] @@ -140,6 +166,30 @@ function SettingsPage() { setEditingAccount(null) } + const handleTestConnection = () => { + if (!editingAccount) return + toast.promise( + apiService.testSmtpConnection(editingAccount).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}`, + }, + ) + } + + const togglePasswordVisibility = (id: string) => { + setVisiblePasswords((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + const isGmail = editingAccount?.smtp_server?.toLowerCase().includes('gmail.com') || editingAccount?.sender_email?.toLowerCase().includes('@gmail.com') @@ -170,116 +220,331 @@ function SettingsPage() {

Sender Accounts

- + {!editingAccount && ( + + )}
-
- {accounts.map((acc) => ( -
-
-
-
-

- {acc.name || acc.sender_email} -

- {acc.is_default && ( - - Default - - )} -
+ {editingAccount ? ( +
+
+

+ {editingAccount.id ? 'Edit Account' : 'New Account'} +

+
+
+
+
+ + + setEditingAccount({ + ...editingAccount, + name: e.target.value, + }) + } + placeholder="e.g., Marketing Team" + className="w-full h-11 px-4 bg-surface-element border border-borders-primary rounded-lg text-text-primary focus:ring-2 focus:ring-accent-blue" + required + /> +
+
+ + + setEditingAccount({ + ...editingAccount, + smtp_server: e.target.value, + }) + } + placeholder="smtp.gmail.com" + className="w-full h-11 px-4 bg-surface-element border border-borders-primary rounded-lg text-text-primary focus:ring-2 focus:ring-accent-blue" + required + /> +
+
+ + + setEditingAccount({ + ...editingAccount, + smtp_port: Number(e.target.value), + }) + } + placeholder="587" + className="w-full h-11 px-4 bg-surface-element border border-borders-primary rounded-lg text-text-primary focus:ring-2 focus:ring-accent-blue" + required + /> +
+
+ + + setEditingAccount({ + ...editingAccount, + sender_email: e.target.value, + }) + } + placeholder="you@example.com" + className="w-full h-11 px-4 bg-surface-element border border-borders-primary rounded-lg text-text-primary focus:ring-2 focus:ring-accent-blue" + required + />
-
- {!acc.is_default && ( +
+ +
+ + setEditingAccount({ + ...editingAccount, + sender_password: e.target.value, + }) + } + placeholder="Enter password" + className="w-full h-11 pl-4 pr-12 bg-surface-element border border-borders-primary rounded-lg text-text-primary focus:ring-2 focus:ring-accent-blue font-mono text-sm" + required={!editingAccount.has_password} + /> +
+ + {isGmail && ( +
+ +
+

+ Using Gmail? +

+

+ Google blocks SMTP access with your normal account + password. You must generate a 16-character{' '} + App Password and paste it here + instead. +

+ + Generate Google App Password{' '} + + +
+
)} - -
+
+
+ + setEditingAccount({ + ...editingAccount, + use_ssl: e.target.checked, + }) + } + className="custom-checkbox" + /> + +
+
+ + setEditingAccount({ + ...editingAccount, + is_default: e.target.checked, + }) + } + className="custom-checkbox" + /> + +
+
+
+
+ +
+ + Cancel + +
+ +
+ ) : ( +
+ {accounts.map((acc) => ( +
+
+
+
+

+ {acc.name || acc.sender_email} +

+ {acc.is_default && ( + + Default + + )} +
+
+
+ {!acc.is_default && ( + + )} + + +
+
-
-
- } - label="Email Address" - value={acc.sender_email} - /> - } - label="SMTP Server" - value={`${acc.smtp_server}:${acc.smtp_port}`} - isCode - /> - } - label="SSL/TLS" - value={acc.use_ssl ? 'Enabled' : 'Disabled'} - valueClass={ - acc.use_ssl - ? 'text-status-success-text' - : 'text-text-secondary' - } - /> - } - label="Password" - value={acc.sender_password || 'Not configured'} - valueClass={ - !acc.sender_password - ? 'text-status-danger-text' - : 'text-text-primary' - } - isCode={!!acc.sender_password} - /> +
+
+ } + label="Email Address" + value={acc.sender_email} + /> + } + label="SMTP Server" + value={`${acc.smtp_server}:${acc.smtp_port}`} + isCode + /> + } + label="SSL/TLS" + value={acc.use_ssl ? 'Enabled' : 'Disabled'} + valueClass={ + acc.use_ssl + ? 'text-status-success-text' + : 'text-text-secondary' + } + /> + } + label="Password" + value={acc.sender_password || 'Not configured'} + valueClass={ + !acc.sender_password + ? 'text-status-danger-text' + : 'text-text-primary' + } + isCode={false} + isPassword={!!acc.sender_password} + isVisible={visiblePasswords.has(acc.id)} + onToggle={() => togglePasswordVisibility(acc.id)} + /> +
-
- ))} + ))} - {accounts.length === 0 && ( -
- -

- No accounts configured -

-

- Add an SMTP sender account to start sending emails. You can - configure multiple accounts and set a default. -

- -
- )} -
+ {accounts.length === 0 && ( +
+ +

+ No accounts configured +

+

+ Add an SMTP sender account to start sending emails. You can + configure multiple accounts and set a default. +

+ +
+ )} +
+ )}
@@ -299,7 +564,10 @@ function SettingsPage() {
+
+ + setResetInput(e.target.value)} + className="w-full h-11 px-4 bg-surface-element border border-borders-primary rounded-lg text-text-primary focus:ring-2 focus:ring-accent-blue" + /> +
-
- - {editingAccount && ( - setEditingAccount(null)} - title={editingAccount.id ? 'Edit Account' : 'New Account'} - maxWidth="max-w-2xl" - > -
-
-
- - - setEditingAccount({ - ...editingAccount, - name: e.target.value, - }) - } - placeholder="e.g., Marketing Team" - className="w-full h-11 px-4 bg-surface-element border border-borders-primary rounded-lg text-text-primary focus:ring-2 focus:ring-accent-blue" - required - /> -
-
- - - setEditingAccount({ - ...editingAccount, - smtp_server: e.target.value, - }) - } - placeholder="smtp.gmail.com" - className="w-full h-11 px-4 bg-surface-element border border-borders-primary rounded-lg text-text-primary focus:ring-2 focus:ring-accent-blue" - required - /> -
-
- - - setEditingAccount({ - ...editingAccount, - smtp_port: Number(e.target.value), - }) - } - placeholder="587" - className="w-full h-11 px-4 bg-surface-element border border-borders-primary rounded-lg text-text-primary focus:ring-2 focus:ring-accent-blue" - required - /> -
-
- - - setEditingAccount({ - ...editingAccount, - sender_email: e.target.value, - }) - } - placeholder="you@example.com" - className="w-full h-11 px-4 bg-surface-element border border-borders-primary rounded-lg text-text-primary focus:ring-2 focus:ring-accent-blue" - required - /> -
-
- - - setEditingAccount({ - ...editingAccount, - sender_password: e.target.value, - }) - } - placeholder="Enter password" - className="w-full h-11 px-4 bg-surface-element border border-borders-primary rounded-lg text-text-primary focus:ring-2 focus:ring-accent-blue font-mono text-sm" - required={!editingAccount.has_password} - /> - - {isGmail && ( -
- -
-

- Using Gmail? -

-

- Google blocks SMTP access with your normal account - password. You must generate a 16-character{' '} - App Password and paste it here instead. -

- - Generate Google App Password - -
-
- )} -
-
-
- - setEditingAccount({ - ...editingAccount, - use_ssl: e.target.checked, - }) - } - className="custom-checkbox" - /> - -
-
- - setEditingAccount({ - ...editingAccount, - is_default: e.target.checked, - }) - } - className="custom-checkbox" - /> - -
-
-
-
- - -
-
-
- )}
) } - diff --git a/web/src/services/apiService.ts b/web/src/services/apiService.ts index 08bf6fe..86e43d9 100644 --- a/web/src/services/apiService.ts +++ b/web/src/services/apiService.ts @@ -156,6 +156,14 @@ class ApiService { this.sendMessage('clear_config') } + testSmtpConnection(account: any) { + return this.request( + 'test_smtp_connection', + account, + 'test_smtp_connection_result', + ) + } + factoryReset(eraseAccounts: boolean) { this.sendMessage('factory_reset', { erase_accounts: eraseAccounts }) } diff --git a/web/src/store/useAppStore.ts b/web/src/store/useAppStore.ts index 60f0395..675f244 100644 --- a/web/src/store/useAppStore.ts +++ b/web/src/store/useAppStore.ts @@ -5,6 +5,7 @@ import type { LogEntry, CampaignSummary, RecipientIssue, + PreflightResult, } from '@/types' type ConnectionStatus = 'connecting' | 'open' | 'closed' @@ -18,10 +19,13 @@ interface AppActions { setSaveStatus: (status: 'saved' | 'saving' | 'error') => void setPreviewRecipient: (recipient: Recipient | null) => void setProgress: (sent: number, total: number) => void + incrementStatusCount: (status: 'SENT' | 'ERROR' | 'SKIPPED') => void setRecipientIssues: (issues: Record) => void clearRecipientIssues: () => void setShowCampaignSummaryModal: (show: boolean) => void setCampaignSummary: (summary: CampaignSummary | null) => void + setShowPreflightModal: (show: boolean) => void + setPreflightResult: (result: PreflightResult | null) => void setSelectedCampaignIds: (ids: Set) => void clearCampaignSelection: () => void toggleCampaignSelection: (id: string) => void @@ -59,9 +63,12 @@ export const useAppStore = create((set) => ({ saveStatus: 'saved', previewRecipient: null, progress: { sent: 0, total: 0 }, + statusCounts: { sent: 0, error: 0, skipped: 0 }, recipientIssues: {}, showCampaignSummaryModal: false, campaignSummary: null, + showPreflightModal: false, + preflightResult: null, selectedCampaignIds: new Set(), selectedDatabaseIds: new Set(), selectedTemplateIds: new Set(), @@ -79,7 +86,11 @@ export const useAppStore = create((set) => ({ setIsSending: (isSending) => { set({ isSending }) if (isSending) { - set({ progress: { sent: 0, total: 0 }, recipientIssues: {} }) + set({ + progress: { sent: 0, total: 0 }, + statusCounts: { sent: 0, error: 0, skipped: 0 }, + recipientIssues: {}, + }) } }, setIsStopping: (isStopping) => set({ isStopping }), @@ -87,11 +98,23 @@ export const useAppStore = create((set) => ({ setSaveStatus: (status) => set({ saveStatus: status }), setPreviewRecipient: (recipient) => set({ previewRecipient: recipient }), setProgress: (sent, total) => set({ progress: { sent, total } }), + incrementStatusCount: (status) => + set((state) => { + const key = status.toLowerCase() as 'sent' | 'error' | 'skipped' + return { + statusCounts: { + ...state.statusCounts, + [key]: state.statusCounts[key] + 1, + }, + } + }), setRecipientIssues: (issues) => set({ recipientIssues: issues }), clearRecipientIssues: () => set({ recipientIssues: {} }), setShowCampaignSummaryModal: (show) => set({ showCampaignSummaryModal: show }), setCampaignSummary: (summary) => set({ campaignSummary: summary }), + setShowPreflightModal: (show) => set({ showPreflightModal: show }), + setPreflightResult: (result) => set({ preflightResult: result }), setSelectedCampaignIds: (ids) => set({ selectedCampaignIds: ids }), clearCampaignSelection: () => set({ selectedCampaignIds: new Set() }), toggleCampaignSelection: (id) => diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 7f898c3..7f91846 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -130,9 +130,16 @@ export interface AppState { sent: number total: number } + statusCounts: { + sent: number + error: number + skipped: number + } recipientIssues: Record showCampaignSummaryModal: boolean campaignSummary: CampaignSummary | null + showPreflightModal: boolean + preflightResult: PreflightResult | null selectedCampaignIds: Set selectedDatabaseIds: Set selectedTemplateIds: Set