1/* 2 * Copyright © 2020 Michael Gratton <mike@vee.net>. 3 * 4 * This software is licensed under the GNU Lesser General Public License 5 * (version 2.1 or later). See the COPYING file in this distribution. 6 */ 7 8[ModuleInit] 9public void peas_register_types(TypeModule module) { 10 Peas.ObjectModule obj = module as Peas.ObjectModule; 11 obj.register_extension_type( 12 typeof(Plugin.PluginBase), 13 typeof(Plugin.MailMerge) 14 ); 15} 16 17/** 18 * Plugin to fill in and send email templates using a spreadsheet. 19 */ 20public class Plugin.MailMerge : 21 PluginBase, FolderExtension, EmailExtension, TrustedExtension { 22 23 24 // Translators: Templates folder name alternatives. Separate names 25 // using a vertical bar and put the most common localized name to 26 // the front for the default. English names do not need to be 27 // included. 28 private const string LOC_NAMES = _( 29 "Templates | Template Mail | Template Email | Template E-Mail" 30 ); 31 // This must be identical to he above except without translation 32 private const string UNLOC_NAMES = ( 33 "Templates | Template Mail | Template Email | Template E-Mail" 34 ); 35 36 37 private const string ACTION_EDIT = "edit-template"; 38 private const string ACTION_INSERT_FIELD = "insert-field"; 39 private const string ACTION_MERGE = "merge-template"; 40 private const string ACTION_LOAD = "load-merge-data"; 41 private const string ACTION_START = "start-send"; 42 private const string ACTION_PAUSE = "pause-send"; 43 44 private const int INFO_BAR_PRIORITY = 10; 45 46 47 public FolderContext folders { 48 get; set construct; 49 } 50 51 public EmailContext email { 52 get; set construct; 53 } 54 55 public global::Application.Client client_application { 56 get; set construct; 57 } 58 59 public global::Application.PluginManager client_plugins { 60 get; set construct; 61 } 62 63 private FolderStore? folder_store = null; 64 private EmailStore? email_store = null; 65 66 private global::MailMerge.Folder? merge_folder = null; 67 private InfoBar merge_bar = null; 68 69 private GLib.SimpleAction? edit_action = null; 70 private GLib.SimpleAction? merge_action = null; 71 private GLib.SimpleAction? start_action = null; 72 private GLib.SimpleAction? pause_action = null; 73 74 private Actionable? start_ui = null; 75 private Actionable? pause_ui = null; 76 77 private Gee.List<string> folder_names = new Gee.ArrayList<string>(); 78 79 private GLib.Cancellable cancellable = new GLib.Cancellable(); 80 81 82 public override async void activate(bool is_startup) throws GLib.Error { 83 // Add localised first, so if we need to create a folder it 84 // will be created localised. 85 Geary.iterate_array(LOC_NAMES.split("|")).map<string>( 86 (name) => name.strip() 87 ).add_all_to(this.folder_names); 88 Geary.iterate_array(UNLOC_NAMES.split("|")).map<string>( 89 (name) => name.strip() 90 ).add_all_to(this.folder_names); 91 92 this.folder_store = yield this.folders.get_folder_store(); 93 this.folder_store.folders_available.connect(on_folders_available); 94 this.folder_store.folder_selected.connect(on_folder_selected); 95 96 this.email_store = yield this.email.get_email_store(); 97 this.email_store.email_displayed.connect(on_email_displayed); 98 99 this.edit_action = new GLib.SimpleAction( 100 ACTION_EDIT, this.email_store.email_identifier_variant_type 101 ); 102 this.edit_action.activate.connect(on_edit_activated); 103 this.plugin_application.register_action(this.edit_action); 104 105 this.merge_action = new GLib.SimpleAction( 106 ACTION_MERGE, this.email_store.email_identifier_variant_type 107 ); 108 this.merge_action.activate.connect(on_merge_activated); 109 this.plugin_application.register_action(this.merge_action); 110 111 this.start_action = new GLib.SimpleAction(ACTION_START, null); 112 this.start_action.activate.connect(on_start_activated); 113 this.plugin_application.register_action(this.start_action); 114 115 this.start_ui = new Actionable.with_icon( 116 // Translators: Info bar label for starting sending a mail 117 // merge 118 _("Start"), 119 "media-playback-start-symbolic", 120 this.start_action 121 ); 122 123 this.pause_action = new GLib.SimpleAction(ACTION_PAUSE, null); 124 this.pause_action.activate.connect(on_pause_activated); 125 this.plugin_application.register_action(this.pause_action); 126 127 this.pause_ui = new Actionable.with_icon( 128 // Translators: Info bar label for pausing sending a mail 129 // merge 130 _("Pause"), 131 "media-playback-pause-symbolic", 132 this.pause_action 133 ); 134 135 this.plugin_application.composer_registered.connect( 136 this.on_composer_registered 137 ); 138 } 139 140 public override async void deactivate(bool is_shutdown) throws GLib.Error { 141 this.cancellable.cancel(); 142 143 this.plugin_application.deregister_action(this.edit_action); 144 this.edit_action = null; 145 146 this.plugin_application.deregister_action(this.merge_action); 147 this.merge_action = null; 148 149 this.folder_store = null; 150 151 this.email_store.email_displayed.disconnect(on_email_displayed); 152 this.email_store = null; 153 154 this.folder_names.clear(); 155 } 156 157 private async void edit_email(EmailIdentifier id) { 158 try { 159 var composer = yield this.plugin_application.compose_with_context( 160 id.account, 161 Composer.ContextType.EDIT, 162 id 163 ); 164 var containing = yield this.folder_store.list_containing_folders( 165 id, this.cancellable 166 ); 167 var folder = containing.first_match( 168 (f) => f.display_name in this.folder_names 169 ); 170 171 composer.save_to_folder(folder); 172 composer.can_send = false; 173 composer.present(); 174 } catch (GLib.Error err) { 175 warning("Unable to construct composer: %s", err.message); 176 } 177 } 178 179 private async void merge_email(EmailIdentifier id, 180 GLib.File? default_csv_file) { 181 var csv_file = default_csv_file ?? show_merge_data_chooser(); 182 if (csv_file != null) { 183 try { 184 var csv_input = yield csv_file.read_async( 185 GLib.Priority.DEFAULT, 186 this.cancellable 187 ); 188 var csv = yield new global::MailMerge.Csv.Reader( 189 csv_input, this.cancellable 190 ); 191 192 Gee.Collection<Email> emails = yield this.email_store.get_email( 193 Geary.Collection.single(id), 194 this.cancellable 195 ); 196 if (!emails.is_empty) { 197 var account_context = this.client_plugins.to_client_account( 198 id.account 199 ); 200 var email = Geary.Collection.first(emails); 201 202 this.merge_folder = yield new global::MailMerge.Folder( 203 account_context.account, 204 account_context.account.local_folder_root, 205 yield load_merge_email(email), 206 csv_file, 207 csv 208 ); 209 210 this.merge_bar = new InfoBar( 211 this.merge_folder.data_display_name, "" 212 ); 213 update_merge_folder_info_bar(); 214 this.merge_bar.show_close_button = true; 215 this.merge_bar.close_activated.connect(on_merge_closed); 216 this.merge_folder.notify["email-sent"].connect( 217 () => { update_merge_folder_info_bar(); } 218 ); 219 this.merge_folder.notify["email-total"].connect( 220 () => { update_merge_folder_info_bar(); } 221 ); 222 223 account_context.account.register_local_folder( 224 this.merge_folder 225 ); 226 var main = this.client_application.get_active_main_window(); 227 yield main.select_folder(this.merge_folder, true); 228 } 229 } catch (GLib.Error err) { 230 debug("Displaying merge folder failed: %s", err.message); 231 } 232 } 233 } 234 235 private void update_merge_folder_info_bar() { 236 // Translators: Info bar description for the mail merge 237 // folder. The first string substitution the number of email 238 // already sent, the second is the total number to send. 239 this.merge_bar.description = ngettext( 240 "Sent %u of %u", 241 "Sent %u of %u", 242 this.merge_folder.email_total 243 ).printf( 244 this.merge_folder.email_sent, 245 this.merge_folder.email_total 246 ); 247 this.merge_bar.primary_button = ( 248 (this.merge_folder.is_sending) ? this.pause_ui : this.start_ui 249 ); 250 } 251 252 private async void update_email(Email target) { 253 var containing = Gee.Collection.empty<Folder>(); 254 try { 255 containing = yield this.folder_store.list_containing_folders( 256 target.identifier, this.cancellable 257 ); 258 } catch (GLib.Error err) { 259 warning("Could not load folders for email: %s", err.message); 260 } 261 if (containing.any_match((f) => f.display_name in this.folder_names)) { 262 try { 263 var email = yield load_merge_email(target); 264 if (global::MailMerge.Processor.is_mail_merge_template(email)) { 265 this.email.add_email_info_bar( 266 target.identifier, 267 new_template_email_info_bar(target.identifier), 268 INFO_BAR_PRIORITY 269 ); 270 } 271 } catch (GLib.Error err) { 272 warning("Error checking email for merge templates: %s", 273 err.message); 274 } 275 } 276 } 277 278 private async void update_composer(Composer composer) { 279 if (true) { 280 var load_action = new GLib.SimpleAction(ACTION_LOAD, null); 281 load_action.activate.connect( 282 () => { load_composer_data.begin(composer); } 283 ); 284 composer.register_action(load_action); 285 composer.append_menu_item( 286 /// Translators: Menu item label for invoking mail 287 /// merge in composer 288 new Actionable(_("Mail Merge"), load_action) 289 ); 290 } 291 } 292 293 private async void load_composer_data(Composer composer) { 294 var data = show_merge_data_chooser(); 295 if (data != null) { 296 var insert_field_action = new GLib.SimpleAction( 297 ACTION_INSERT_FIELD, 298 GLib.VariantType.STRING 299 ); 300 composer.register_action(insert_field_action); 301 insert_field_action.activate.connect( 302 (param) => { 303 insert_field(composer, (string) param); 304 } 305 ); 306 307 try { 308 composer.set_action_bar( 309 yield new_composer_action_bar( 310 data, 311 composer.action_group_name 312 ) 313 ); 314 } catch (GLib.Error err) { 315 debug("Error loading CSV: %s", err.message); 316 } 317 } 318 319 } 320 321 private InfoBar new_template_email_info_bar(EmailIdentifier target) { 322 // Translators: Infobar status label for an email mail merge 323 // template 324 var bar = new InfoBar(_("Mail merge template")); 325 bar.primary_button = new Actionable( 326 // Translators: Info bar button label for performing a 327 // mail-merge on an email template 328 _("Merge"), 329 this.merge_action, 330 target.to_variant() 331 ); 332 bar.secondary_buttons.add( 333 new Actionable( 334 // Translators: Info bar button label for editing an 335 // existing email template 336 _("Edit"), 337 this.edit_action, 338 target.to_variant() 339 ) 340 ); 341 return bar; 342 } 343 344 private async ActionBar new_composer_action_bar(GLib.File csv_file, 345 string action_group_name) 346 throws GLib.Error { 347 var info = yield csv_file.query_info_async( 348 GLib.FileAttribute.STANDARD_DISPLAY_NAME, 349 NONE, 350 GLib.Priority.DEFAULT, 351 this.cancellable 352 ); 353 var input = yield csv_file.read_async( 354 GLib.Priority.DEFAULT, 355 this.cancellable 356 ); 357 var csv = yield new global::MailMerge.Csv.Reader( 358 input, this.cancellable 359 ); 360 var record = yield csv.read_record(); 361 362 var text_fields_menu = new GLib.Menu(); 363 foreach (var field in record) { 364 text_fields_menu.append( 365 field, 366 GLib.Action.print_detailed_name( 367 action_group_name + "." + ACTION_INSERT_FIELD, 368 field 369 ) 370 ); 371 } 372 373 var action_bar = new ActionBar(); 374 action_bar.append_item( 375 /// Translators: Action bar menu button label for 376 /// mail-merge plugin 377 new ActionBar.MenuItem(_("Insert field"), text_fields_menu), START 378 ); 379 action_bar.append_item( 380 new ActionBar.LabelItem(info.get_display_name()), START 381 ); 382 return action_bar; 383 } 384 385 private GLib.File? show_merge_data_chooser() { 386 var chooser = new Gtk.FileChooserNative( 387 /// Translators: File chooser title after invoking mail 388 /// merge in composer 389 _("Mail Merge"), 390 null, OPEN, 391 _("_Open"), 392 _("_Cancel") 393 ); 394 var csv_filter = new Gtk.FileFilter(); 395 /// Translators: File chooser filer label 396 csv_filter.set_filter_name(_("Comma separated values (CSV)")); 397 csv_filter.add_mime_type("text/csv"); 398 chooser.add_filter(csv_filter); 399 400 return ( 401 chooser.run() == Gtk.ResponseType.ACCEPT 402 ? chooser.get_file() 403 : null 404 ); 405 } 406 407 private void insert_field(Composer composer, string field) { 408 composer.insert_text(global::MailMerge.Processor.to_field(field)); 409 } 410 411 private async Geary.Email load_merge_email(Email plugin) throws GLib.Error { 412 Geary.Email? engine = this.client_plugins.to_engine_email(plugin); 413 if (engine != null && 414 !engine.fields.fulfills(global::MailMerge.Processor.REQUIRED_FIELDS)) { 415 var account_context = this.client_plugins.to_client_account( 416 plugin.identifier.account 417 ); 418 engine = yield account_context.emails.fetch_email_async( 419 engine.id, 420 global::MailMerge.Processor.REQUIRED_FIELDS, 421 Geary.Folder.ListFlags.LOCAL_ONLY, 422 this.cancellable 423 ); 424 } 425 if (engine == null) { 426 throw new Geary.EngineError.NOT_FOUND("Plugin email not found"); 427 } 428 return engine; 429 } 430 431 private void on_edit_activated(GLib.Action action, GLib.Variant? target) { 432 if (this.email_store != null && target != null) { 433 EmailIdentifier? id = 434 this.email_store.get_email_identifier_for_variant(target); 435 if (id != null) { 436 this.edit_email.begin(id); 437 } 438 } 439 } 440 441 private void on_merge_activated(GLib.Action action, GLib.Variant? target) { 442 if (this.email_store != null && target != null) { 443 EmailIdentifier? id = 444 this.email_store.get_email_identifier_for_variant(target); 445 if (id != null) { 446 this.merge_email.begin(id, null); 447 } 448 } 449 } 450 451 private void on_start_activated(GLib.Action action, GLib.Variant? target) { 452 this.merge_folder.set_sending(true); 453 update_merge_folder_info_bar(); 454 } 455 456 private void on_pause_activated(GLib.Action action, GLib.Variant? target) { 457 this.merge_folder.set_sending(false); 458 update_merge_folder_info_bar(); 459 } 460 461 private void on_composer_registered(Composer registered) { 462 this.update_composer.begin(registered); 463 } 464 465 private void on_merge_closed() { 466 if (this.merge_folder != null) { 467 try { 468 this.merge_folder.account.deregister_local_folder( 469 this.merge_folder 470 ); 471 } catch (GLib.Error err) { 472 warning("Error de-registering merge folder: %s", err.message); 473 } 474 this.merge_folder = null; 475 this.merge_bar = null; 476 } 477 } 478 479 private void on_folders_available(Gee.Collection<Folder> available) { 480 foreach (var folder in available) { 481 var engine_folder = this.client_plugins.to_engine_folder(folder); 482 if (this.merge_folder == engine_folder) { 483 try { 484 this.folders.register_folder_used_as( 485 folder, 486 // Translators: The name of the folder used to 487 // display merged email 488 _("Mail Merge"), 489 "mail-outbox-symbolic" 490 ); 491 } catch (GLib.Error err) { 492 warning( 493 "Failed to register %s as merge folder: %s", 494 folder.persistent_id, 495 err.message 496 ); 497 } 498 } 499 } 500 } 501 502 private void on_folder_selected(Folder selected) { 503 var engine_folder = this.client_plugins.to_engine_folder(selected); 504 if (this.merge_folder == engine_folder) { 505 this.folders.add_folder_info_bar( 506 selected, this.merge_bar, INFO_BAR_PRIORITY 507 ); 508 } 509 } 510 511 private void on_email_displayed(Email email) { 512 this.update_email.begin(email); 513 } 514 515} 516