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