Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 13 additions & 3 deletions src/bit_by_mail/server/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")

Expand All @@ -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()
Expand Down
30 changes: 30 additions & 0 deletions src/bit_by_mail/server/handlers/websocket_handler.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import os
import logging
import smtplib
import tornado.websocket
from urllib.parse import urlparse

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 8 additions & 2 deletions src/bit_by_mail/server/services/campaign_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"<h1>Email for {name}</h1>\n<p>Hello {{{{Name}}}}</p>" 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", "")))
Expand Down
11 changes: 9 additions & 2 deletions src/bit_by_mail/server/services/database_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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("")
Expand Down Expand Up @@ -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()

11 changes: 9 additions & 2 deletions src/bit_by_mail/server/services/global_template_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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()

39 changes: 34 additions & 5 deletions src/bit_by_mail/server/services/preflight_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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

8 changes: 4 additions & 4 deletions web/src/features/assets/components/AssetDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -35,7 +35,7 @@ export const AssetDetailModal: React.FC<{
<Modal
isOpen={true}
onClose={onClose}
title="Asset Details"
title="Image Details"
maxWidth="max-w-3xl"
>
<div className="flex flex-col md:flex-row gap-6">
Expand All @@ -50,7 +50,7 @@ export const AssetDetailModal: React.FC<{
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Asset Name
Image Name
</label>
<input
type="text"
Expand Down
6 changes: 3 additions & 3 deletions web/src/features/assets/components/AssetPickerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ export const AssetPickerModal: React.FC<{
<Modal
isOpen={true}
onClose={onClose}
title="Select Asset"
title="Select Image"
maxWidth="max-w-4xl"
>
<div className="space-y-4">
<input
type="text"
placeholder="Search assets..."
placeholder="Search images..."
value={search}
onChange={(e) => 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"
Expand All @@ -39,7 +39,7 @@ export const AssetPickerModal: React.FC<{
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-h-[60vh] overflow-y-auto custom-scrollbar p-1">
{filteredAssets.length === 0 ? (
<div className="col-span-full py-8 text-center text-text-tertiary">
No assets found. Add some in the Asset Library.
No images found. Add some in the Image Library.
</div>
) : (
filteredAssets.map((asset) => (
Expand Down
Loading
Loading