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/** 9 * Plugin to Fill in and send email templates using a spreadsheet. 10 */ 11public class MailMerge.Folder : Geary.AbstractLocalFolder { 12 13 14 private class EmailIdentifier : Geary.EmailIdentifier { 15 16 17 private const string VARIANT_TYPE = "(y(x))"; 18 19 public int64 message_id { get; private set; } 20 21 22 public EmailIdentifier(int64 message_id) { 23 this.message_id = message_id; 24 } 25 26 internal EmailIdentifier.from_variant(GLib.Variant serialised) 27 throws Geary.EngineError.BAD_PARAMETERS { 28 if (serialised.get_type_string() != VARIANT_TYPE) { 29 throw new Geary.EngineError.BAD_PARAMETERS( 30 "Invalid serialised id type: %s", serialised.get_type_string() 31 ); 32 } 33 GLib.Variant inner = serialised.get_child_value(1); 34 GLib.Variant mid = inner.get_child_value(0); 35 this(mid.get_int64()); 36 } 37 38 /** {@inheritDoc} */ 39 public override uint hash() { 40 return GLib.int64_hash(this.message_id); 41 } 42 43 /** {@inheritDoc} */ 44 public override bool equal_to(Geary.EmailIdentifier other) { 45 return ( 46 this.get_type() == other.get_type() && 47 this.message_id == ((EmailIdentifier) other).message_id 48 ); 49 } 50 51 /** {@inheritDoc} */ 52 public override string to_string() { 53 return "%s(%lld)".printf(this.get_type().name(), this.message_id); 54 } 55 56 public override int natural_sort_comparator(Geary.EmailIdentifier o) { 57 EmailIdentifier? other = o as EmailIdentifier; 58 if (other == null) { 59 return 1; 60 } 61 return (int) (this.message_id - other.message_id).clamp(-1, 1); 62 } 63 64 public override GLib.Variant to_variant() { 65 return new GLib.Variant.tuple(new Variant[] { 66 new GLib.Variant.byte('m'), 67 new GLib.Variant.tuple(new Variant[] { 68 new GLib.Variant.int64(this.message_id), 69 }) 70 }); 71 } 72 73 } 74 75 76 private class FolderProperties : Geary.FolderProperties { 77 78 public FolderProperties() { 79 base( 80 0, 0, 81 Geary.Trillian.FALSE, Geary.Trillian.FALSE, Geary.Trillian.TRUE, 82 true, false, false 83 ); 84 } 85 86 public void set_total(int total) { 87 this.email_total = total; 88 } 89 90 } 91 92 93 /** {@inheritDoc} */ 94 public override Geary.Account account { 95 get { return this._account; } 96 } 97 private Geary.Account _account; 98 99 /** {@inheritDoc} */ 100 public override Geary.FolderProperties properties { 101 get { return _properties; } 102 } 103 private FolderProperties _properties = new FolderProperties(); 104 105 /** {@inheritDoc} */ 106 public override Geary.FolderPath path { 107 get { return _path; } 108 } 109 private Geary.FolderPath _path; 110 111 /** {@inheritDoc} */ 112 public override Geary.Folder.SpecialUse used_as { 113 get { return this._used_as; } 114 } 115 Geary.Folder.SpecialUse _used_as = NONE; 116 117 /** The source data file used the folder. */ 118 public GLib.File data_location { get; private set; } 119 120 /** The display name for {@link data_location}. */ 121 public string data_display_name { get; private set; } 122 123 /** The number of email that have been sent. */ 124 public uint email_sent { get; private set; default = 0; } 125 126 /** The number of email in total. */ 127 public uint email_total { get; private set; default = 0; } 128 129 /** Specifies if the merged mail is currently being sent. */ 130 public bool is_sending { get; private set; default = false; } 131 132 133 private Gee.List<Geary.EmailIdentifier> ids = 134 new Gee.ArrayList<Geary.EmailIdentifier>(); 135 private Gee.Map<Geary.EmailIdentifier,Geary.ComposedEmail> composed = 136 new Gee.HashMap<Geary.EmailIdentifier,Geary.ComposedEmail>(); 137 private Gee.Map<Geary.EmailIdentifier,Geary.Email> email = 138 new Gee.HashMap<Geary.EmailIdentifier,Geary.Email>(); 139 private Geary.Email template; 140 private Csv.Reader data; 141 private GLib.Cancellable loading = new GLib.Cancellable(); 142 private GLib.Cancellable sending = new GLib.Cancellable(); 143 144 145 /** Emitted when an error sending an email is reported. */ 146 public signal void send_error(GLib.Error error); 147 148 149 public async Folder(Geary.Account account, 150 Geary.FolderRoot root, 151 Geary.Email template, 152 GLib.File data_location, 153 Csv.Reader data) 154 throws GLib.Error { 155 this._account = account; 156 this._path = root.get_child("$Plugin.MailMerge$"); 157 this.template = template; 158 this.data_location = data_location; 159 this.data = data; 160 161 var info = yield data_location.query_info_async( 162 GLib.FileAttribute.STANDARD_DISPLAY_NAME, 163 NONE, 164 GLib.Priority.DEFAULT, 165 null 166 ); 167 this.data_display_name = info.get_display_name(); 168 169 // Do this in the background to avoid blocking while the whole 170 // file is processed 171 this.load_data.begin(this.loading); 172 } 173 174 175 /** Starts or stops the folder sending mail. */ 176 public void set_sending(bool is_sending) { 177 if (is_sending && !this.is_sending) { 178 this.send_loop.begin(); 179 this.is_sending = true; 180 } else if (!is_sending && this.is_sending) { 181 this.sending.cancel(); 182 this.sending = new GLib.Cancellable(); 183 } 184 } 185 186 /** {@inheritDoc} */ 187 public override async bool close_async(GLib.Cancellable? cancellable = null) 188 throws GLib.Error { 189 var is_closing = yield base.close_async(cancellable); 190 if (is_closing) { 191 this.loading.cancel(); 192 set_sending(false); 193 } 194 return is_closing; 195 } 196 197 /** {@inheritDoc} */ 198 public override async Gee.Collection<Geary.EmailIdentifier> contains_identifiers( 199 Gee.Collection<Geary.EmailIdentifier> ids, 200 GLib.Cancellable? cancellable = null) 201 throws GLib.Error { 202 return Geary.traverse( 203 ids 204 ).filter( 205 (id) => this.email.has_key(id) 206 ).to_hash_set(); 207 } 208 209 public override async Geary.Email 210 fetch_email_async(Geary.EmailIdentifier id, 211 Geary.Email.Field required_fields, 212 Geary.Folder.ListFlags flags, 213 GLib.Cancellable? cancellable = null) 214 throws GLib.Error { 215 check_open(); 216 var email = this.email.get(id); 217 if (email == null) { 218 throw new Geary.EngineError.NOT_FOUND( 219 "No email with ID %s in merge", id.to_string() 220 ); 221 } 222 return email; 223 } 224 225 public override async Gee.List<Geary.Email>? 226 list_email_by_id_async(Geary.EmailIdentifier? initial_id, 227 int count, 228 Geary.Email.Field required_fields, 229 Geary.Folder.ListFlags flags, 230 GLib.Cancellable? cancellable = null) 231 throws GLib.Error { 232 check_open(); 233 234 var initial = initial_id as EmailIdentifier; 235 if (initial_id != null && initial == null) { 236 throw new Geary.EngineError.BAD_PARAMETERS( 237 "EmailIdentifier %s not from merge", 238 initial_id.to_string() 239 ); 240 } 241 242 Gee.List<Geary.Email> list = new Gee.ArrayList<Geary.Email>(); 243 if (!this.ids.is_empty && count > 0) { 244 int incr = 1; 245 if (Geary.Folder.ListFlags.OLDEST_TO_NEWEST in flags) { 246 incr = -1; 247 } 248 int next_index = -1; 249 if (initial == null) { 250 initial = (EmailIdentifier) ( 251 incr > 0 252 ? this.ids.first() 253 : this.ids.last() 254 ); 255 next_index = (int) initial.message_id; 256 } else { 257 if (Geary.Folder.ListFlags.INCLUDING_ID in flags) { 258 list.add(this.email.get(this.ids[(int) initial.message_id])); 259 } 260 next_index = (int) initial.message_id; 261 next_index += incr; 262 } 263 264 while (list.size < count && 265 next_index >= 0 && 266 next_index < this.ids.size) { 267 list.add(this.email.get(this.ids[next_index])); 268 next_index += incr; 269 } 270 } 271 272 return (list.size > 0) ? list : null; 273 } 274 275 public override async Gee.List<Geary.Email>? 276 list_email_by_sparse_id_async(Gee.Collection<Geary.EmailIdentifier> ids, 277 Geary.Email.Field required_fields, 278 Geary.Folder.ListFlags flags, 279 GLib.Cancellable? cancellable = null) 280 throws GLib.Error { 281 check_open(); 282 Gee.List<Geary.Email> list = new Gee.ArrayList<Geary.Email>(); 283 foreach (var id in ids) { 284 var email = this.email.get(id); 285 if (email == null) { 286 throw new Geary.EngineError.NOT_FOUND( 287 "No email with ID %s in merge", id.to_string() 288 ); 289 } 290 list.add(email); 291 } 292 return (list.size > 0) ? list : null; 293 } 294 295 public override void set_used_as_custom(bool enabled) 296 throws Geary.EngineError.UNSUPPORTED { 297 this._used_as = ( 298 enabled 299 ? Geary.Folder.SpecialUse.CUSTOM 300 : Geary.Folder.SpecialUse.NONE 301 ); 302 } 303 304 305 // NB: This is called from a thread outside of the main loop 306 private async void load_data(GLib.Cancellable? cancellable) { 307 int64 next_id = 0; 308 try { 309 var template = yield load_template(cancellable); 310 Geary.Memory.Buffer? raw_rfc822 = null; 311 yield Geary.Nonblocking.Concurrent.global.schedule_async( 312 (c) => { 313 raw_rfc822 = template.get_message().get_rfc822_buffer(); 314 }, 315 cancellable 316 ); 317 318 string[] headers = yield this.data.read_record(); 319 var fields = new Gee.HashMap<string,string>(); 320 string[] record = yield this.data.read_record(); 321 while (record != null) { 322 fields.clear(); 323 for (int i = 0; i < headers.length; i++) { 324 fields.set(headers[i], record[i]); 325 } 326 var processor = new Processor(this.template); 327 var composed = processor.merge(fields); 328 var message = yield new Geary.RFC822.Message.from_composed_email( 329 composed, null, cancellable 330 ); 331 332 var id = new EmailIdentifier(next_id++); 333 var email = new Geary.Email.from_message(id, message); 334 // Don't set a date since it would be re-set on send, 335 // and we don't want to give people the wrong idea of 336 // what it will be 337 email.set_send_date(null); 338 email.set_flags(new Geary.EmailFlags()); 339 340 // Update folder state then notify about the new email 341 this.ids.add(id); 342 this.composed.set(id, composed); 343 this.email.set(id, email); 344 this._properties.set_total((int) next_id); 345 this.email_total = (uint) next_id; 346 347 notify_email_inserted(Geary.Collection.single(id)); 348 record = yield this.data.read_record(); 349 } 350 } catch (GLib.Error err) { 351 debug("Error processing email for merge: %s", err.message); 352 } 353 } 354 355 private async Geary.Email load_template(GLib.Cancellable? cancellable) 356 throws GLib.Error { 357 var template = this.template; 358 if (!template.fields.fulfills(Geary.Email.REQUIRED_FOR_MESSAGE)) { 359 template = yield this.account.local_fetch_email_async( 360 template.id, 361 Geary.Email.REQUIRED_FOR_MESSAGE, 362 cancellable 363 ); 364 } 365 return template; 366 } 367 368 private async void send_loop() { 369 var cancellable = this.sending; 370 var smtp = this._account.outgoing as Geary.Smtp.ClientService; 371 if (smtp != null) { 372 while (!this.ids.is_empty && !this.sending.is_cancelled()) { 373 var last = this.ids.size - 1; 374 var id = this.ids[last]; 375 376 try { 377 var composed = this.composed.get(id); 378 composed.set_date(new GLib.DateTime.now()); 379 yield smtp.send_email(composed, cancellable); 380 381 this.email_sent++; 382 383 this.ids.remove_at(last); 384 this.email.unset(id); 385 this.composed.unset(id); 386 this._properties.set_total(last); 387 notify_email_removed(Geary.Collection.single(id)); 388 389 // Rate limit to ~30/minute for now 390 GLib.Timeout.add_seconds(2, this.send_loop.callback); 391 yield; 392 } catch (GLib.Error err) { 393 warning("Error sending merge email: %s", err.message); 394 send_error(err); 395 break; 396 } 397 } 398 } else { 399 warning("Account has no outgoing SMTP service"); 400 } 401 this.is_sending = false; 402 } 403 404} 405