import sys import os import json import re import tkinter as tk from tkinter import ttk, messagebox, filedialog import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from datetime import datetime, timedelta import threading # Import US Spellchecker with fallback try: from spellchecker import SpellChecker SPELLCHECK_AVAILABLE = True except ImportError: SPELLCHECK_AVAILABLE = False DB_FILE = "contacts_db.json" DEFAULT_DB = { "smtp_settings": { "server": "smtp.gmail.com", "port": 587, "email": "", "password": "" }, "contacts": [ { "callsign": "Glenn", "name": "Glenn", "email": "glenn@example.com", "phone_gateway": "1234567890@vtext.com", "tags": "ARES, RACES, LEADER", "receive_email": True, "receive_text": True } ] } class DispatchApp: def __init__(self, root): self.root = root self.root.title("CCAR Alert Portal") self.root.geometry("900x750") # Initialize US English Spell Checker if SPELLCHECK_AVAILABLE: self.spell = SpellChecker(language='en') # Add common domain/amateur radio terms so they aren't marked as misspelled self.spell.word_frequency.load_words([ "ares", "races", "skywarn", "callsign", "repeater", "simplex", "vhf", "uhf", "hail", "qrz", "smtp", "vtext" ]) else: self.spell = None self.spellcheck_timer = None self.all_individuals = [] self.active_schedulers = {} self.task_counter = 0 self.ignore_trace = False self.load_database() self.create_widgets() self.refresh_contact_list() self.update_dispatch_selectors() # --- SPELL CHECKING UTILITIES --- def queue_live_spellcheck(self, event=None): if not self.spell: return if self.spellcheck_timer: self.root.after_cancel(self.spellcheck_timer) self.spellcheck_timer = self.root.after(400, self.highlight_misspelled_words) def highlight_misspelled_words(self): if not self.spell: return self.txt_message.tag_remove("misspelled", "1.0", tk.END) content = self.txt_message.get("1.0", tk.END) # Regex to capture individual words for match in re.finditer(r"\b[A-Za-z']+\b", content): word = match.group() # Clean apostrophes for dictionary lookup cleaned = word.strip("'") if cleaned and cleaned.lower() not in self.spell: start_index = f"1.0 + {match.start()} chars" end_index = f"1.0 + {match.end()} chars" self.txt_message.tag_add("misspelled", start_index, end_index) def replace_word(self, start_idx, end_idx, new_word): self.txt_message.delete(start_idx, end_idx) self.txt_message.insert(start_idx, new_word) self.highlight_misspelled_words() def run_manual_spellcheck(self): if not self.spell: messagebox.showinfo("Spellcheck Unavailable", "Install 'pyspellchecker' using:\npip install pyspellchecker") return content = self.txt_message.get("1.0", tk.END) words = list(re.finditer(r"\b[A-Za-z']+\b", content)) misspelled_matches = [m for m in words if m.group().strip("'").lower() not in self.spell] if not misspelled_matches: messagebox.showinfo("Spell Check Complete", "No spelling errors detected (US English).") return # Interactive Spellcheck Window sc_win = tk.Toplevel(self.root) sc_win.title("Spell Check (US English)") sc_win.geometry("420x350") sc_win.grab_set() current_idx = [0] lbl_header = ttk.Label(sc_win, text="Misspelled Word:", font=('Helvetica', 10, 'bold')) lbl_header.pack(pady=(10, 2)) lbl_bad_word = ttk.Label(sc_win, text="", font=('Helvetica', 12, 'bold'), foreground='red') lbl_bad_word.pack(pady=2) ttk.Label(sc_win, text="Suggestions:").pack(pady=(10, 2)) lb_suggestions = tk.Listbox(sc_win, height=6) lb_suggestions.pack(fill='x', padx=20, pady=5) def load_current_error(): if current_idx[0] >= len(misspelled_matches): sc_win.destroy() messagebox.showinfo("Spell Check Complete", "Spell check finished.") self.highlight_misspelled_words() return match = misspelled_matches[current_idx[0]] word = match.group() lbl_bad_word.config(text=word) lb_suggestions.delete(0, tk.END) candidates = self.spell.candidates(word.strip("'")) if candidates: for c in candidates: lb_suggestions.insert(tk.END, c) lb_suggestions.selection_set(0) else: lb_suggestions.insert(tk.END, "(No suggestions)") def replace_current(): sel = lb_suggestions.curselection() if not sel or lb_suggestions.get(sel[0]) == "(No suggestions)": return replacement = lb_suggestions.get(sel[0]) match = misspelled_matches[current_idx[0]] # Recalculate range in current text widget start = f"1.0 + {match.start()} chars" end = f"1.0 + {match.end()} chars" self.replace_word(start, end, replacement) current_idx[0] += 1 load_current_error() def ignore_current(): current_idx[0] += 1 load_current_error() btn_box = ttk.Frame(sc_win) btn_box.pack(pady=15) ttk.Button(btn_box, text="Replace", command=replace_current).pack(side='left', padx=5) ttk.Button(btn_box, text="Ignore", command=ignore_current).pack(side='left', padx=5) ttk.Button(btn_box, text="Close", command=sc_win.destroy).pack(side='left', padx=5) load_current_error() # --- RIGHT-CLICK CONTEXT MENU HELPER --- def add_context_menu(self, widget): menu = tk.Menu(widget, tearoff=0) def cut_text(): widget.event_generate("<>") def copy_text(): widget.event_generate("<>") def paste_text(): widget.event_generate("<>") def select_all_text(): if isinstance(widget, tk.Text): widget.tag_add("sel", "1.0", "end") elif isinstance(widget, (ttk.Entry, tk.Entry)): widget.select_range(0, tk.END) widget.icursor(tk.END) def clear_text(): if isinstance(widget, tk.Text): widget.delete("1.0", tk.END) elif isinstance(widget, (ttk.Entry, tk.Entry)): widget.delete(0, tk.END) def show_popup(event): menu.delete(0, tk.END) # If right-clicking the text box, show quick spelling suggestions at top of menu if isinstance(widget, tk.Text) and self.spell: index = widget.index(f"@{event.x},{event.y}") tags = widget.tag_names(index) if "misspelled" in tags: word_start = widget.index(f"{index} wordstart") word_end = widget.index(f"{index} wordend") word = widget.get(word_start, word_end).strip() candidates = self.spell.candidates(word.strip("'")) if candidates: for cand in list(candidates)[:4]: menu.add_command( label=f"Did you mean: '{cand}'?", command=lambda c=cand, s=word_start, e=word_end: self.replace_word(s, e, c) ) menu.add_separator() menu.add_command(label="Cut", command=cut_text) menu.add_command(label="Copy", command=copy_text) menu.add_command(label="Paste", command=paste_text) menu.add_separator() menu.add_command(label="Select All", command=select_all_text) menu.add_command(label="Clear", command=clear_text) menu.tk_popup(event.x_root, event.y_root) widget.bind("", show_popup) widget.bind("", show_popup) widget.bind("", show_popup) def load_database(self): if not os.path.exists(DB_FILE): with open(DB_FILE, 'w') as f: json.dump(DEFAULT_DB, f, indent=4) try: with open(DB_FILE, 'r') as f: self.db = json.load(f) migrated = False for c in self.db.get("contacts", []): if "group" in c and "tags" not in c: c["tags"] = f"ARES, {c['group'].strip().upper()}" if c["group"].strip().upper() != "ARES" else "ARES" del c["group"] migrated = True if migrated: self.save_database() except Exception as e: messagebox.showerror("Error", f"Failed to load database: {e}") self.db = DEFAULT_DB def save_database(self): try: with open(DB_FILE, 'w') as f: json.dump(self.db, f, indent=4) except Exception as e: messagebox.showerror("Error", f"Failed to save database: {e}") def create_widgets(self): self.notebook = ttk.Notebook(self.root) self.notebook.pack(fill='both', expand=True, padx=10, pady=10) self.tab_dispatch = ttk.Frame(self.notebook) self.notebook.add(self.tab_dispatch, text="Send Dispatch") self.tab_roster = ttk.Frame(self.notebook) self.notebook.add(self.tab_roster, text="Manage Roster") self.tab_config = ttk.Frame(self.notebook) self.notebook.add(self.tab_config, text="SMTP Setup") self.setup_dispatch_tab() self.setup_roster_tab() self.setup_config_tab() # --- TAB 1: DISPATCH --- def setup_dispatch_tab(self): top_frame = ttk.Frame(self.tab_dispatch) top_frame.pack(fill='x', padx=10, pady=10) # Targeting Frame target_frame = ttk.LabelFrame(top_frame, text=" Recipient Targeting Mode ", padding=10) target_frame.pack(side='left', fill='both', expand=True, padx=(0, 5)) self.target_mode = tk.StringVar(value="all") rb_all = ttk.Radiobutton(target_frame, text="All Roster Members", variable=self.target_mode, value="all", command=self.toggle_target_fields) rb_all.grid(row=0, column=0, sticky='w', padx=5, pady=5) rb_group = ttk.Radiobutton(target_frame, text="By Group Tag Filter:", variable=self.target_mode, value="group", command=self.toggle_target_fields) rb_group.grid(row=1, column=0, sticky='w', padx=5, pady=5) self.cb_groups = ttk.Combobox(target_frame, state="disabled", width=15) self.cb_groups.grid(row=1, column=1, sticky='w', padx=5, pady=5) rb_indiv = ttk.Radiobutton(target_frame, text="Individual Operator:", variable=self.target_mode, value="individual", command=self.toggle_target_fields) rb_indiv.grid(row=2, column=0, sticky='w', padx=5, pady=5) self.indiv_search_var = tk.StringVar() self.indiv_search_var.trace_add("write", self.on_individual_type) self.cb_individuals = ttk.Combobox(target_frame, state="disabled", width=35, textvariable=self.indiv_search_var) self.cb_individuals.grid(row=2, column=1, sticky='w', padx=5, pady=5) self.add_context_menu(self.cb_individuals) # Transmission Channel & Dynamic Sender Options channel_frame = ttk.LabelFrame(top_frame, text=" Transmission Settings ", padding=10) channel_frame.pack(side='right', fill='both', padx=(5, 0)) self.dispatch_send_email = tk.BooleanVar(value=True) self.dispatch_send_text = tk.BooleanVar(value=False) chk_send_email = ttk.Checkbutton(channel_frame, text="Send via Email", variable=self.dispatch_send_email) chk_send_email.pack(anchor='w', padx=10, pady=2) chk_send_text = ttk.Checkbutton(channel_frame, text="Send via Text (SMS)", variable=self.dispatch_send_text) chk_send_text.pack(anchor='w', padx=10, pady=2) # Custom Sender Box ttk.Label(channel_frame, text="Display Sender Email:").pack(anchor='w', padx=10, pady=(10, 2)) self.ent_custom_sender = ttk.Entry(channel_frame, width=28) self.ent_custom_sender.pack(anchor='w', padx=10, pady=2) cfg = self.db.get("smtp_settings", {}) self.ent_custom_sender.insert(0, cfg.get("email", "")) self.add_context_menu(self.ent_custom_sender) # Scheduler Panel sched_frame = ttk.LabelFrame(self.tab_dispatch, text=" Transmission Timing Scheduling ", padding=10) sched_frame.pack(fill='x', padx=10, pady=5) self.sched_choice = tk.StringVar(value="now") rb_now = ttk.Radiobutton(sched_frame, text="Send Immediately", variable=self.sched_choice, value="now", command=self.toggle_sched_fields) rb_now.grid(row=0, column=0, sticky='w', padx=5, pady=5) rb_later = ttk.Radiobutton(sched_frame, text="Schedule Deployment:", variable=self.sched_choice, value="later", command=self.toggle_sched_fields) rb_later.grid(row=0, column=1, sticky='w', padx=15, pady=5) ttk.Label(sched_frame, text="Date (MM-DD-YY):").grid(row=0, column=2, sticky='w', padx=5) self.ent_sched_date = ttk.Entry(sched_frame, width=12, state="disabled") self.ent_sched_date.insert(0, datetime.now().strftime("%m-%d-%y")) self.ent_sched_date.grid(row=0, column=3, sticky='w', padx=5) self.add_context_menu(self.ent_sched_date) ttk.Label(sched_frame, text="Time (HH:MM 24hr):").grid(row=0, column=4, sticky='w', padx=5) self.ent_sched_time = ttk.Entry(sched_frame, width=8, state="disabled") self.ent_sched_time.insert(0, (datetime.now() + timedelta(hours=1)).strftime("%H:%M")) self.ent_sched_time.grid(row=0, column=5, sticky='w', padx=5) self.add_context_menu(self.ent_sched_time) # Queue Status Trackers self.lbl_queue_status = ttk.Label(sched_frame, text="Pending Alerts: 0", font=('Helvetica', 9, 'italic'), foreground='blue') self.lbl_queue_status.grid(row=0, column=6, sticky='e', padx=(20, 5)) btn_manage_queue = ttk.Button(sched_frame, text="Manage Queue", command=self.open_queue_manager) btn_manage_queue.grid(row=0, column=7, sticky='e', padx=5) # Message Entry Layout msg_header_frame = ttk.Frame(self.tab_dispatch) msg_header_frame.pack(fill='x', padx=10, pady=(5, 0)) lbl_msg = ttk.Label(msg_header_frame, text="Emergency Alert Message Content (HTML Supported):", font=('Helvetica', 10, 'bold')) lbl_msg.pack(side='left') btn_spellcheck = ttk.Button(msg_header_frame, text="Run Spell Check (US)", command=self.run_manual_spellcheck) btn_spellcheck.pack(side='right') self.txt_message = tk.Text(self.tab_dispatch, height=8, wrap='word', undo=True) self.txt_message.pack(fill='both', expand=True, padx=10, pady=5) # Configure Misspelling Tag Style (Red Underline) self.txt_message.tag_config("misspelled", foreground="red", underline=True) # Standard Keyboard Shortcuts & Live Spell Check Trigger self.txt_message.bind("", self.queue_live_spellcheck) self.txt_message.bind("", self.handle_select_all) self.txt_message.bind("", self.handle_copy) self.txt_message.bind("", self.handle_paste) self.txt_message.bind("", self.handle_cut) # Attach Right-Click Context Menu self.add_context_menu(self.txt_message) # Action Buttons Frame btn_frame = ttk.Frame(self.tab_dispatch) btn_frame.pack(fill='x', padx=10, pady=10) btn_send = ttk.Button(btn_frame, text="TRANSMIT DISPATCH", command=self.process_dispatch_request) btn_send.pack(side='right', padx=5) btn_clear_screen = ttk.Button(btn_frame, text="Clear Screen", command=self.clear_message_screen) btn_clear_screen.pack(side='right', padx=5) def handle_select_all(self, event): self.txt_message.tag_add("sel", "1.0", "end") return "break" def handle_copy(self, event): self.txt_message.event_generate("<>") return "break" def handle_paste(self, event): self.txt_message.event_generate("<>") self.queue_live_spellcheck() return "break" def handle_cut(self, event): self.txt_message.event_generate("<>") self.queue_live_spellcheck() return "break" def clear_message_screen(self): self.txt_message.delete('1.0', tk.END) def toggle_target_fields(self): mode = self.target_mode.get() if mode == "all": self.cb_individuals.config(state="disabled") self.cb_groups.config(state="disabled") elif mode == "group": self.cb_groups.config(state="readonly") self.cb_individuals.config(state="disabled") elif mode == "individual": self.cb_groups.config(state="disabled") self.cb_individuals.config(state="normal") self.ignore_trace = True self.cb_individuals.set("") self.ignore_trace = False self.cb_individuals.focus_set() def toggle_sched_fields(self): if self.sched_choice.get() == "now": self.ent_sched_date.config(state="disabled") self.ent_sched_time.config(state="disabled") else: self.ent_sched_date.config(state="normal") self.ent_sched_time.config(state="normal") def update_dispatch_selectors(self): unique_tags = set() self.all_individuals = [] for c in self.db.get("contacts", []): if c.get("tags"): parts = [t.strip().upper() for t in c["tags"].split(",") if t.strip()] unique_tags.update(parts) display_name = f"{c.get('callsign', 'VOLUNTEER')} - {c.get('name', '')} ({c.get('email', '')})" self.all_individuals.append(display_name) self.cb_groups['values'] = sorted(list(unique_tags)) if unique_tags: if "ARES" in unique_tags: self.cb_groups.set("ARES") else: self.cb_groups.current(0) self.all_individuals = sorted(self.all_individuals) self.cb_individuals['values'] = self.all_individuals def on_individual_type(self, *args): if self.ignore_trace or self.target_mode.get() != "individual": return typed_text = self.cb_individuals.get() typed_text_lower = typed_text.strip().lower() if typed_text_lower == "": filtered_values = self.all_individuals else: filtered_values = [person for person in self.all_individuals if typed_text_lower in person.lower()] self.ignore_trace = True self.cb_individuals['values'] = filtered_values self.ignore_trace = False # --- TAB 2: ROSTER MANAGEMENT --- def setup_roster_tab(self): top_panel = ttk.Frame(self.tab_roster) top_panel.pack(fill='x', padx=10, pady=5) btn_import = ttk.Button(top_panel, text="Import Google CSV", command=self.import_google_csv) btn_import.pack(side='left', padx=5) ttk.Label(top_panel, text="Search Roster:").pack(side='left', padx=(20, 5)) self.ent_search = ttk.Entry(top_panel, width=25) self.ent_search.pack(side='left', padx=5) self.ent_search.bind("", lambda event: self.refresh_contact_list()) self.add_context_menu(self.ent_search) main_roster_frame = ttk.Frame(self.tab_roster) main_roster_frame.pack(fill='both', expand=True, padx=10, pady=5) self.tree = ttk.Treeview(main_roster_frame, columns=("Callsign", "Name", "Tags", "Email", "SMS Gateway", "Email Ok", "Text Ok"), show='headings') self.tree.heading("Callsign", text="Callsign / ID") self.tree.heading("Name", text="Name") self.tree.heading("Tags", text="Assigned Tags / Groups") self.tree.heading("Email", text="Email") self.tree.heading("SMS Gateway", text="SMS Gateway Address") self.tree.heading("Email Ok", text="Email Ok") self.tree.heading("Text Ok", text="Text Ok") self.tree.column("Email Ok", width=70, anchor='center') self.tree.column("Text Ok", width=70, anchor='center') self.tree.column("Tags", width=150) self.tree.pack(side='left', fill='both', expand=True) self.tree.bind("<>", self.on_contact_select) editor_frame = ttk.LabelFrame(main_roster_frame, text=" Operator Profiles / Details ", padding=10) editor_frame.pack(side='right', fill='y', padx=(10, 0)) ttk.Label(editor_frame, text="Callsign/ID:").grid(row=0, column=0, sticky='w', pady=2) self.form_callsign = ttk.Entry(editor_frame, width=22) self.form_callsign.grid(row=0, column=1, pady=2, sticky='w') self.add_context_menu(self.form_callsign) ttk.Label(editor_frame, text="Name:").grid(row=1, column=0, sticky='w', pady=2) self.form_name = ttk.Entry(editor_frame, width=22) self.form_name.grid(row=1, column=1, pady=2, sticky='w') self.add_context_menu(self.form_name) ttk.Label(editor_frame, text="Group Tags:").grid(row=2, column=0, sticky='w', pady=2) self.form_tags = ttk.Entry(editor_frame, width=22) self.form_tags.grid(row=2, column=1, pady=2, sticky='w') self.add_context_menu(self.form_tags) ttk.Label(editor_frame, text="(Separate with commas)", font=('Helvetica', 8, 'italic'), foreground='gray').grid(row=3, column=1, sticky='w') ttk.Label(editor_frame, text="Email:").grid(row=4, column=0, sticky='w', pady=2) self.form_email = ttk.Entry(editor_frame, width=22) self.form_email.grid(row=4, column=1, pady=2, sticky='w') self.add_context_menu(self.form_email) ttk.Label(editor_frame, text="SMS Gateway:").grid(row=5, column=0, sticky='w', pady=2) self.form_sms = ttk.Entry(editor_frame, width=22) self.form_sms.grid(row=5, column=1, pady=2, sticky='w') self.add_context_menu(self.form_sms) ttk.Label(editor_frame, text="Channels:").grid(row=6, column=0, sticky='nw', pady=5) chan_subframe = ttk.Frame(editor_frame) chan_subframe.grid(row=6, column=1, sticky='w', pady=5) self.form_receive_email = tk.BooleanVar(value=True) self.form_receive_text = tk.BooleanVar(value=True) ttk.Checkbutton(chan_subframe, text="Email Enabled", variable=self.form_receive_email).pack(anchor='w') ttk.Checkbutton(chan_subframe, text="Text Enabled", variable=self.form_receive_text).pack(anchor='w') btn_form_frame = ttk.Frame(editor_frame) btn_form_frame.grid(row=7, column=0, columnspan=2, pady=15) btn_save_user = ttk.Button(btn_form_frame, text="Save / Update", command=self.save_or_update_contact) btn_save_user.pack(side='left', padx=2) btn_del_user = ttk.Button(btn_form_frame, text="Delete Entry", command=self.delete_contact) btn_del_user.pack(side='left', padx=2) btn_clear_user = ttk.Button(btn_form_frame, text="Clear Form", command=self.clear_form) btn_clear_user.pack(side='left', padx=2) btn_wipe_all = ttk.Button(btn_form_frame, text="Wipe Roster", command=self.wipe_entire_roster) btn_wipe_all.pack(side='left', padx=2) self.selected_contact_email = None self.clear_form() def on_contact_select(self, event): selected = self.tree.selection() if not selected: return values = self.tree.item(selected[0], "values") self.clear_form() self.form_callsign.insert(0, values[0]) self.form_name.insert(0, values[1]) self.form_tags.insert(0, values[2]) self.form_email.insert(0, values[3]) self.form_sms.insert(0, values[4]) self.form_receive_email.set(True if values[5] == "YES" else False) self.form_receive_text.set(True if values[6] == "YES" else False) self.selected_contact_email = values[3] def clear_form(self): self.form_callsign.delete(0, tk.END) self.form_name.delete(0, tk.END) self.form_tags.delete(0, tk.END) self.form_email.delete(0, tk.END) self.form_sms.delete(0, tk.END) self.form_receive_email.set(True) self.form_receive_text.set(True) self.selected_contact_email = None def save_or_update_contact(self): email = self.form_email.get().strip() if not email: messagebox.showwarning("Validation Error", "An email address is required.") return raw_tags = self.form_tags.get().strip() tag_list = [t.strip().upper() for t in raw_tags.split(",") if t.strip()] sanitized_tags_str = ", ".join(tag_list) new_data = { "callsign": self.form_callsign.get().strip(), "name": self.form_name.get().strip(), "tags": sanitized_tags_str, "email": email, "phone_gateway": self.form_sms.get().strip(), "receive_email": self.form_receive_email.get(), "receive_text": self.form_receive_text.get() } updated = False for i, c in enumerate(self.db["contacts"]): if (self.selected_contact_email and c.get("email") == self.selected_contact_email) or (not self.selected_contact_email and c.get("email") == email): self.db["contacts"][i] = new_data updated = True break if not updated: self.db["contacts"].append(new_data) self.save_database() self.refresh_contact_list() self.update_dispatch_selectors() self.clear_form() messagebox.showinfo("Success", "Roster record saved successfully.") def delete_contact(self): if not self.selected_contact_email: messagebox.showwarning("Selection Required", "Select an operator entry from the roster list to delete.") return if messagebox.askyesno("Confirm Deletion", "Are you sure you want to completely remove this operator entry?"): self.db["contacts"] = [c for c in self.db["contacts"] if c.get("email") != self.selected_contact_email] self.save_database() self.refresh_contact_list() self.update_dispatch_selectors() self.clear_form() def wipe_entire_roster(self): if messagebox.askyesno("CRITICAL WARNING", "Are you sure you want to permanently delete the ENTIRE contact roster?\n\nThis cannot be undone."): if messagebox.askyesno("FINAL CONFIRMATION", "Confirming one last time: Erase all operator profiles?"): self.db["contacts"] = [] self.save_database() self.refresh_contact_list() self.update_dispatch_selectors() self.clear_form() messagebox.showinfo("Roster Purged", "The entire contact database has been wiped clean.") # --- TAB 3: CONFIGURATION --- def setup_config_tab(self): cfg = self.db.get("smtp_settings", {}) frame = ttk.Frame(self.tab_config, padding=20) frame.pack(fill='both', expand=True) ttk.Label(frame, text="SMTP Server:").grid(row=0, column=0, sticky='w', pady=5) self.ent_server = ttk.Entry(frame, width=30) self.ent_server.insert(0, cfg.get("server", "smtp.gmail.com")) self.ent_server.grid(row=0, column=1, sticky='w', pady=5) self.add_context_menu(self.ent_server) ttk.Label(frame, text="Port:").grid(row=1, column=0, sticky='w', pady=5) self.ent_port = ttk.Entry(frame, width=10) self.ent_port.insert(0, str(cfg.get("port", 587))) self.ent_port.grid(row=1, column=1, sticky='w', pady=5) self.add_context_menu(self.ent_port) ttk.Label(frame, text="Sender Email:").grid(row=2, column=0, sticky='w', pady=5) self.ent_email = ttk.Entry(frame, width=30) self.ent_email.insert(0, cfg.get("email", "")) self.ent_email.grid(row=2, column=1, sticky='w', pady=5) self.add_context_menu(self.ent_email) ttk.Label(frame, text="App Password:").grid(row=3, column=0, sticky='w', pady=5) self.ent_pass = ttk.Entry(frame, width=30, show="*") self.ent_pass.insert(0, cfg.get("password", "")) self.ent_pass.grid(row=3, column=1, sticky='w', pady=5) self.add_context_menu(self.ent_pass) btn_save_cfg = ttk.Button(frame, text="Save Settings", command=self.save_config) btn_save_cfg.grid(row=4, column=1, sticky='e', pady=15) def save_config(self): self.db["smtp_settings"] = { "server": self.ent_server.get(), "port": int(self.ent_port.get()), "email": self.ent_email.get(), "password": self.ent_pass.get() } self.save_database() self.ent_custom_sender.delete(0, tk.END) self.ent_custom_sender.insert(0, self.ent_email.get()) messagebox.showinfo("Success", "SMTP configurations saved.") def refresh_contact_list(self): for item in self.tree.get_children(): self.tree.delete(item) search_term = self.ent_search.get().strip().lower() for c in self.db.get("contacts", []): call = c.get("callsign", "").lower() name = c.get("name", "").lower() email = c.get("email", "").lower() tags = c.get("tags", "").lower() if search_term and (search_term not in call and search_term not in name and search_term not in email and search_term not in tags): continue e_ok = "YES" if c.get("receive_email", True) else "NO" t_ok = "YES" if c.get("receive_text", True) else "NO" self.tree.insert("", "end", values=(c.get("callsign", ""), c.get("name", ""), c.get("tags", ""), c.get("email", ""), c.get("phone_gateway", ""), e_ok, t_ok)) def import_google_csv(self): import csv file_path = filedialog.askopenfilename(filetypes=[("CSV Files", "*.csv")]) if not file_path: return try: with open(file_path, mode='r', encoding='utf-8-sig') as f: reader = csv.DictReader(f) headers = {k.lower(): k for k in reader.fieldnames} name_key = headers.get('name') or headers.get('given name') first_name_key = headers.get('given name') or headers.get('first name') email_key = headers.get('e-mail 1 - value') or headers.get('email 1 - value') or headers.get('email') phone_key = headers.get('phone 1 - value') or headers.get('phone') notes_key = headers.get('notes') group_key = headers.get('group membership') imported_count = 0 for row in reader: name = row.get(name_key, "").strip() if name_key else "" first_name = row.get(first_name_key, "").strip() if first_name_key else "" email = row.get(email_key, "").strip() if email_key else "" phone = row.get(phone_key, "").strip() if phone_key else "" notes = row.get(notes_key, "").strip() if notes_key else "" g_membership = row.get(group_key, "").strip() if group_key else "" if not name and not email: continue tags_detected = [] g_upper = g_membership.upper() n_upper = notes.upper() if "ARES" in g_upper or "ARES" in n_upper: tags_detected.append("ARES") if "RACES" in g_upper or "RACES" in n_upper: tags_detected.append("RACES") if "SKYWARN" in g_upper or "SKYWARN" in n_upper: tags_detected.append("SKYWARN") if "LEADER" in g_upper or "LEADER" in n_upper or "EC" in g_upper: tags_detected.append("LEADER") callsign = "" if notes and ("/" in notes or len(notes) <= 6 and notes.isalnum()): callsign = notes.upper() elif "[" in name and "]" in name: callsign = name[name.find("[")+1:name.find("]")].upper() name = name.split("[")[0].strip() if not callsign and first_name: callsign = first_name clean_phone = ''.join(c for c in phone if c.isdigit()) gateway = "" if len(clean_phone) == 10: gateway = f"{clean_phone}@vtext.com" if not any(c['email'] == email for c in self.db['contacts'] if email): self.db['contacts'].append({ "callsign": callsign if callsign else "VOLUNTEER", "name": name, "email": email, "phone_gateway": gateway, "tags": ", ".join(tags_detected), "receive_email": True, "receive_text": True if gateway else False }) imported_count += 1 self.save_database() self.refresh_contact_list() self.update_dispatch_selectors() messagebox.showinfo("Import Complete", f"Successfully imported {imported_count} contacts.") except Exception as e: messagebox.showerror("Import Error", f"Failed to parse Google Contacts CSV:\n{e}") # --- TRANSMIT LAYERS --- def process_dispatch_request(self): cfg = self.db.get("smtp_settings", {}) if not cfg.get("email") or not cfg.get("password"): messagebox.showwarning("Setup Required", "Please configure your SMTP credentials.") return raw_body = self.txt_message.get('1.0', tk.END).strip() if not raw_body: messagebox.showwarning("Empty Message", "Cannot send an empty alert dispatch text.") return plain_body = raw_body html_body = raw_body.replace("\n", "
") display_sender = self.ent_custom_sender.get().strip() if not display_sender: messagebox.showwarning("Sender Address Missing", "Please provide a valid sender verification string.") return send_email_globally = self.dispatch_send_email.get() send_text_globally = self.dispatch_send_text.get() if not send_email_globally and not send_text_globally: messagebox.showwarning("Selection Missing", "Please check at least one transmission mode to send.") return mode = self.target_mode.get() target_list = [] if mode == "all": target_list = self.db.get("contacts", []) elif mode == "group": selected_tag = self.cb_groups.get().strip().upper() for c in self.db.get("contacts", []): if c.get("tags"): c_tags = [t.strip().upper() for t in c["tags"].split(",") if t.strip()] if selected_tag in c_tags: target_list.append(c) elif mode == "individual": selected_indiv = self.cb_individuals.get() found = False for c in self.db.get("contacts", []): display_str = f"{c.get('callsign', 'VOLUNTEER')} - {c.get('name', '')} ({c.get('email', '')})" if display_str == selected_indiv: target_list = [c] found = True break if not found: messagebox.showwarning("Invalid Selection", "Please choose a valid individual entry.") return all_recipients = [] for contact in target_list: if send_email_globally and contact.get("receive_email", True) and contact.get("email"): all_recipients.append(contact["email"]) if send_text_globally and contact.get("receive_text", True) and contact.get("phone_gateway"): all_recipients.append(contact["phone_gateway"]) all_recipients = list(set(all_recipients)) if not all_recipients: messagebox.showwarning("No Recipients", "No targets available under selected permissions constraints.") return payload_tuple = (plain_body, html_body) if self.sched_choice.get() == "now": self.execute_transmission(all_recipients, payload_tuple, cfg, display_sender, None) else: date_str = self.ent_sched_date.get().strip() time_str = self.ent_sched_time.get().strip() try: target_datetime = datetime.strptime(f"{date_str} {time_str}", "%m-%d-%y %H:%M") delta_seconds = (target_datetime - datetime.now()).total_seconds() if delta_seconds <= 0: messagebox.showwarning("Invalid Time", "The scheduled time must be in the future.") return self.task_counter += 1 tid = f"TASK_{self.task_counter}" t = threading.Timer(delta_seconds, self.execute_transmission, args=[all_recipients, payload_tuple, cfg, display_sender, tid]) self.active_schedulers[tid] = { "timer": t, "time": target_datetime.strftime("%m-%d-%y %H:%M:%S"), "recipients_count": len(all_recipients) } t.start() self.lbl_queue_status.config(text=f"Pending Alerts: {len(self.active_schedulers)}") messagebox.showinfo("Alert Scheduled", "Dispatch safely added to internal system queue.") except ValueError: messagebox.showerror("Format Error", "Date syntax must follow MM-DD-YY and time HH:MM format parameters.") def execute_transmission(self, recipients, body_tuple, cfg, sender_override, task_id): try: plain_text, html_markup = body_tuple if int(cfg["port"]) == 465: server = smtplib.SMTP_SSL(cfg["server"], cfg["port"]) else: server = smtplib.SMTP(cfg["server"], cfg["port"]) server.starttls() server.login(cfg["email"], cfg["password"]) for rec in recipients: msg = MIMEMultipart('alternative') msg['From'] = sender_override msg['To'] = rec msg['Subject'] = "CCAR: MESSAGE ALERT PORTAL" part1 = MIMEText(plain_text, 'plain') part2 = MIMEText(html_markup, 'html') msg.attach(part1) msg.attach(part2) server.send_message(msg) server.quit() if task_id and task_id in self.active_schedulers: del self.active_schedulers[task_id] self.root.after(0, self.update_queue_ui_label) else: messagebox.showinfo("Success", f"Immediate dispatch completed to {len(recipients)} endpoints.") except Exception as e: if threading.current_thread() == threading.main_thread(): messagebox.showerror("Transmission Failure", f"An error occurred while sending:\n{e}") else: print(f"[BACKGROUND DETECT WORKER ERROR]: {e}") def update_queue_ui_label(self): self.lbl_queue_status.config(text=f"Pending Alerts: {len(self.active_schedulers)}") messagebox.showinfo("Queue System Update", "A scheduled group dispatch has deployed successfully.") # --- PENDING QUEUE CANCELLATION SUB-SYSTEM --- def open_queue_manager(self): q_win = tk.Toplevel(self.root) q_win.title("Active Task Queue Manager") q_win.geometry("500x350") q_win.grab_set() ttk.Label(q_win, text="Active Pending Deployments:", font=('Helvetica', 10, 'bold')).pack(anchor='w', padx=10, pady=10) lb_queue = tk.Listbox(q_win, selectmode="single", font=('Consolas', 10)) lb_queue.pack(fill='both', expand=True, padx=10, pady=5) index_to_task_id = {} for idx, (tid, details) in enumerate(self.active_schedulers.items()): display_str = f"[{tid}] Time: {details['time']} | Recipients: {details['recipients_count']}" lb_queue.insert(tk.END, display_str) index_to_task_id[idx] = tid def cancel_selected_task(): selection = lb_queue.curselection() if not selection: messagebox.showwarning("No Selection", "Please click a task inside the list viewport window first.") return chosen_idx = selection[0] target_tid = index_to_task_id[chosen_idx] task_dict = self.active_schedulers[target_tid] task_dict["timer"].cancel() del self.active_schedulers[target_tid] self.lbl_queue_status.config(text=f"Pending Alerts: {len(self.active_schedulers)}") q_win.destroy() messagebox.showinfo("Task Aborted", f"Successfully terminated alert string identifier {target_tid} safely.") btn_cancel = ttk.Button(q_win, text="Cancel Selected Alert", command=cancel_selected_task) btn_cancel.pack(pady=10) if __name__ == "__main__": root = tk.Tk() app = DispatchApp(root) root.mainloop()