openmail 0.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. openmail/__init__.py +6 -0
  2. openmail/assistants/__init__.py +35 -0
  3. openmail/assistants/classify_emails.py +83 -0
  4. openmail/assistants/compose_email.py +43 -0
  5. openmail/assistants/detect_phishing_for_email.py +61 -0
  6. openmail/assistants/evaluate_sender_trust_for_email.py +59 -0
  7. openmail/assistants/extract_tasks_from_emails.py +126 -0
  8. openmail/assistants/generate_follow_up_for_email.py +54 -0
  9. openmail/assistants/natural_language_query.py +699 -0
  10. openmail/assistants/prioritize_emails.py +89 -0
  11. openmail/assistants/reply.py +58 -0
  12. openmail/assistants/reply_suggestions.py +46 -0
  13. openmail/assistants/rewrite_email.py +50 -0
  14. openmail/assistants/summarize_attachments_for_email.py +101 -0
  15. openmail/assistants/summarize_thread_emails.py +55 -0
  16. openmail/assistants/summary.py +44 -0
  17. openmail/assistants/summary_multi.py +57 -0
  18. openmail/assistants/translate_email.py +54 -0
  19. openmail/auth/__init__.py +6 -0
  20. openmail/auth/base.py +34 -0
  21. openmail/auth/no_auth.py +19 -0
  22. openmail/auth/oauth2.py +58 -0
  23. openmail/auth/password.py +26 -0
  24. openmail/config.py +26 -0
  25. openmail/email_assistant.py +418 -0
  26. openmail/email_manager.py +777 -0
  27. openmail/email_query.py +279 -0
  28. openmail/errors.py +16 -0
  29. openmail/imap/__init__.py +5 -0
  30. openmail/imap/attachment_parts.py +55 -0
  31. openmail/imap/bodystructure.py +296 -0
  32. openmail/imap/client.py +806 -0
  33. openmail/imap/fetch_response.py +115 -0
  34. openmail/imap/inline_cid.py +106 -0
  35. openmail/imap/pagination.py +16 -0
  36. openmail/imap/parser.py +298 -0
  37. openmail/imap/query.py +233 -0
  38. openmail/llm/__init__.py +3 -0
  39. openmail/llm/claude.py +35 -0
  40. openmail/llm/costs.py +108 -0
  41. openmail/llm/gemini.py +34 -0
  42. openmail/llm/gpt.py +33 -0
  43. openmail/llm/groq.py +36 -0
  44. openmail/llm/model.py +126 -0
  45. openmail/llm/xai.py +35 -0
  46. openmail/logger.py +20 -0
  47. openmail/models/__init__.py +20 -0
  48. openmail/models/attachment.py +128 -0
  49. openmail/models/message.py +113 -0
  50. openmail/models/subscription.py +45 -0
  51. openmail/models/task.py +24 -0
  52. openmail/py.typed +0 -0
  53. openmail/smtp/__init__.py +7 -0
  54. openmail/smtp/builder.py +41 -0
  55. openmail/smtp/client.py +218 -0
  56. openmail/smtp/templates.py +16 -0
  57. openmail/subscription/__init__.py +7 -0
  58. openmail/subscription/detector.py +58 -0
  59. openmail/subscription/parser.py +32 -0
  60. openmail/subscription/service.py +237 -0
  61. openmail/types.py +30 -0
  62. openmail/utils/__init__.py +39 -0
  63. openmail/utils/utils.py +295 -0
  64. openmail-0.1.5.dist-info/METADATA +180 -0
  65. openmail-0.1.5.dist-info/RECORD +67 -0
  66. openmail-0.1.5.dist-info/WHEEL +4 -0
  67. openmail-0.1.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,699 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from openmail.email_query import EmailQuery
8
+ from openmail.imap import IMAPQuery
9
+ from openmail.llm import get_model
10
+
11
+ if TYPE_CHECKING:
12
+ pass
13
+
14
+
15
+ class HeaderFilter(BaseModel):
16
+ name: str = Field(
17
+ description="Exact header name to inspect, for example 'List-Unsubscribe' or 'X-Provider'."
18
+ )
19
+ value: str = Field(
20
+ description=(
21
+ "Substring that must appear in the given header's value for the message to match."
22
+ )
23
+ )
24
+
25
+
26
+ class IMAPFlagsPlan(BaseModel):
27
+ """
28
+ Boolean flags describing required or forbidden message states.
29
+ A value of True means the message must have that state.
30
+ """
31
+
32
+ seen: bool = Field(
33
+ default=False,
34
+ description="If true, match messages that are marked as read/seen.",
35
+ )
36
+ unseen: bool = Field(
37
+ default=False,
38
+ description="If true, match messages that are not marked as read/seen.",
39
+ )
40
+ answered: bool = Field(
41
+ default=False,
42
+ description="If true, match messages that have been replied to.",
43
+ )
44
+ unanswered: bool = Field(
45
+ default=False,
46
+ description="If true, match messages that have not been replied to.",
47
+ )
48
+ flagged: bool = Field(
49
+ default=False,
50
+ description="If true, match messages that are flagged/starred.",
51
+ )
52
+ unflagged: bool = Field(
53
+ default=False,
54
+ description="If true, match messages that are not flagged/starred.",
55
+ )
56
+ deleted: bool = Field(
57
+ default=False,
58
+ description="If true, match messages that are marked for deletion.",
59
+ )
60
+ undeleted: bool = Field(
61
+ default=False,
62
+ description="If true, match messages that are not marked for deletion.",
63
+ )
64
+ draft: bool = Field(
65
+ default=False,
66
+ description="If true, match messages that are drafts.",
67
+ )
68
+ undraft: bool = Field(
69
+ default=False,
70
+ description="If true, match messages that are not drafts.",
71
+ )
72
+ recent: bool = Field(
73
+ default=False,
74
+ description="If true, match messages that have newly arrived in the mailbox.",
75
+ )
76
+ new: bool = Field(
77
+ default=False,
78
+ description="If true, match messages that are both recently arrived and still unread.",
79
+ )
80
+
81
+
82
+ class IMAPExcludePlan(BaseModel):
83
+ """
84
+ Negative filters: any value here describes content that must NOT be present.
85
+ """
86
+
87
+ from_: List[str] = Field(
88
+ default_factory=list,
89
+ description="Messages whose sender matches any of these strings MUST be excluded.",
90
+ )
91
+ to: List[str] = Field(
92
+ default_factory=list,
93
+ description="Messages whose 'To' recipient matches any of these strings MUST be excluded.",
94
+ )
95
+ cc: List[str] = Field(
96
+ default_factory=list,
97
+ description="Messages whose 'Cc' recipient matches any of these strings MUST be excluded.",
98
+ )
99
+ bcc: List[str] = Field(
100
+ default_factory=list,
101
+ description="Messages whose 'Bcc' recipient matches any of these strings MUST be excluded.",
102
+ )
103
+ subject: List[str] = Field(
104
+ default_factory=list,
105
+ description="Messages whose subject contains any of these substrings MUST be excluded.",
106
+ )
107
+ header: List[HeaderFilter] = Field(
108
+ default_factory=list,
109
+ description=(
110
+ "Messages whose given header contains the specified substring MUST be excluded. "
111
+ "Each entry describes a header name and a disallowed value substring."
112
+ ),
113
+ )
114
+ text: List[str] = Field(
115
+ default_factory=list,
116
+ description=(
117
+ "Messages whose headers or body text contain any of these substrings MUST be excluded."
118
+ ),
119
+ )
120
+ body: List[str] = Field(
121
+ default_factory=list,
122
+ description="Messages whose body text contains any of these substrings MUST be excluded.",
123
+ )
124
+
125
+
126
+ class IMAPClauses(BaseModel):
127
+ """
128
+ One AND-clause in Disjunctive Normal Form.
129
+
130
+ All fields in a single clause describe conditions that must be true at
131
+ the same time for a message to match that clause.
132
+ """
133
+
134
+ from_: List[str] = Field(
135
+ default_factory=list,
136
+ description=(
137
+ "Sender filter: each entry is a substring that must appear in the message's sender "
138
+ "address or display name. All listed values are ANDed together within the clause."
139
+ ),
140
+ )
141
+ to: List[str] = Field(
142
+ default_factory=list,
143
+ description=(
144
+ "Recipient filter (To): each entry is a substring that must appear among the primary "
145
+ "recipients. All listed values are ANDed together within the clause."
146
+ ),
147
+ )
148
+ cc: List[str] = Field(
149
+ default_factory=list,
150
+ description=(
151
+ "Recipient filter (Cc): each entry is a substring that must appear among the Cc "
152
+ "recipients. All listed values are ANDed together within the clause."
153
+ ),
154
+ )
155
+ bcc: List[str] = Field(
156
+ default_factory=list,
157
+ description=(
158
+ "Recipient filter (Bcc): each entry is a substring that must appear among the Bcc "
159
+ "recipients. All listed values are ANDed together within the clause."
160
+ ),
161
+ )
162
+ subject: List[str] = Field(
163
+ default_factory=list,
164
+ description=(
165
+ "Subject filter: each entry is a substring that must appear in the message subject. "
166
+ "All listed values are ANDed together within the clause."
167
+ ),
168
+ )
169
+ text: List[str] = Field(
170
+ default_factory=list,
171
+ description=(
172
+ "Free-text filter: each entry is a substring that must appear somewhere in the "
173
+ "message headers OR body text. All listed values are ANDed together within the clause."
174
+ ),
175
+ )
176
+ body: List[str] = Field(
177
+ default_factory=list,
178
+ description=(
179
+ "Body-only filter: each entry is a substring that must appear in the message body "
180
+ "content. All listed values are ANDed together within the clause."
181
+ ),
182
+ )
183
+ header: List[HeaderFilter] = Field(
184
+ default_factory=list,
185
+ description=(
186
+ "Header-based filters: for each entry, the specified header must contain the given "
187
+ "value substring for the message to match this clause."
188
+ ),
189
+ )
190
+
191
+ since: Optional[str] = Field(
192
+ default=None,
193
+ description=(
194
+ "Earliest allowed message date (inclusive). Only messages on or after this date "
195
+ "match this clause. Format: 'YYYY-MM-DD'."
196
+ ),
197
+ )
198
+ before: Optional[str] = Field(
199
+ default=None,
200
+ description=(
201
+ "Upper bound on message date (exclusive). Only messages strictly before this date "
202
+ "match this clause. Format: 'YYYY-MM-DD'."
203
+ ),
204
+ )
205
+ on: Optional[str] = Field(
206
+ default=None,
207
+ description=(
208
+ "Exact message date filter. Only messages whose date equals this day match this clause. "
209
+ "Format: 'YYYY-MM-DD'."
210
+ ),
211
+ )
212
+ sent_since: Optional[str] = Field(
213
+ default=None,
214
+ description=(
215
+ "Earliest allowed sent date (inclusive). Only messages sent on or after this date "
216
+ "match this clause. Format: 'YYYY-MM-DD'."
217
+ ),
218
+ )
219
+ sent_before: Optional[str] = Field(
220
+ default=None,
221
+ description=(
222
+ "Upper bound on sent date (exclusive). Only messages sent before this date match "
223
+ "this clause. Format: 'YYYY-MM-DD'."
224
+ ),
225
+ )
226
+ sent_on: Optional[str] = Field(
227
+ default=None,
228
+ description=(
229
+ "Exact sent date filter. Only messages whose sent date equals this day match this clause. "
230
+ "Format: 'YYYY-MM-DD'."
231
+ ),
232
+ )
233
+
234
+ flags: IMAPFlagsPlan = Field(
235
+ default_factory=IMAPFlagsPlan,
236
+ description=(
237
+ "Message state filters (read/unread, replied, flagged, draft, etc.). Only messages "
238
+ "whose flags satisfy all enabled conditions are included in this clause."
239
+ ),
240
+ )
241
+
242
+ # size
243
+ larger: Optional[int] = Field(
244
+ default=None,
245
+ description=(
246
+ "Minimum message size in bytes. Only messages strictly larger than this value "
247
+ "match this clause."
248
+ ),
249
+ )
250
+ smaller: Optional[int] = Field(
251
+ default=None,
252
+ description=(
253
+ "Maximum message size in bytes. Only messages strictly smaller than this value "
254
+ "match this clause."
255
+ ),
256
+ )
257
+
258
+ keyword: List[str] = Field(
259
+ default_factory=list,
260
+ description=(
261
+ "Required keywords: each entry is a keyword or label that the message must have. "
262
+ "All listed values are ANDed together within the clause."
263
+ ),
264
+ )
265
+ unkeyword: List[str] = Field(
266
+ default_factory=list,
267
+ description=(
268
+ "Forbidden keywords: each entry is a keyword or label that the message must NOT have. "
269
+ "A message is excluded from this clause if it has any of these."
270
+ ),
271
+ )
272
+
273
+ uid: List[str] = Field(
274
+ default_factory=list,
275
+ description=(
276
+ "UID-based restriction. Entries are either individual numeric identifiers or ranges "
277
+ "like 'start:end'. Only messages whose UID falls within this set are included."
278
+ ),
279
+ )
280
+
281
+ excludes: IMAPExcludePlan = Field(
282
+ default_factory=IMAPExcludePlan,
283
+ description=(
284
+ "Negative content filters for this clause. Any message matching these exclusion "
285
+ "rules is removed from the result even if it matches other conditions."
286
+ ),
287
+ )
288
+
289
+ use_newsletters: bool = Field(
290
+ default=False,
291
+ description=(
292
+ "If true, restrict this clause to messages that resemble newsletters or "
293
+ "marketing/announcement mailings, typically subscription-style emails."
294
+ ),
295
+ )
296
+
297
+ use_invoices_or_receipts: bool = Field(
298
+ default=False,
299
+ description=(
300
+ "If true, restrict this clause to finance-related transactional emails such as "
301
+ "invoices, receipts, payment confirmations, or order confirmations."
302
+ ),
303
+ )
304
+
305
+ use_security_alerts: bool = Field(
306
+ default=False,
307
+ description=(
308
+ "If true, restrict this clause to security or account alerts, such as login "
309
+ "notifications, password changes, or verification-code emails."
310
+ ),
311
+ )
312
+
313
+ use_with_attachments_hint: bool = Field(
314
+ default=False,
315
+ description=(
316
+ "If true, restrict this clause to messages that likely include file attachments, "
317
+ "approximated by searching for common attachment-related markers in the content."
318
+ ),
319
+ )
320
+
321
+ raw_tokens: List[str] = Field(
322
+ default_factory=list,
323
+ description=(
324
+ "Extra IMAP search tokens that apply only inside this clause. These are appended "
325
+ "directly to the constructed search expression for this clause."
326
+ ),
327
+ )
328
+
329
+
330
+ class IMAPLowLevelPlan(BaseModel):
331
+ """
332
+ A full low-level search plan expressed in Disjunctive Normal Form (DNF).
333
+ """
334
+
335
+ clauses: List[IMAPClauses] = Field(
336
+ default_factory=list,
337
+ description=(
338
+ "List of clauses; each clause describes a set of conditions that must all hold for "
339
+ "a message to match, and the overall result is the union of messages matched by any clause."
340
+ ),
341
+ )
342
+
343
+ raw_tokens: List[str] = Field(
344
+ default_factory=list,
345
+ description=(
346
+ "Additional IMAP search tokens that are appended to the final query after all clauses "
347
+ "have been combined. These affect the entire search, not just a single clause."
348
+ ),
349
+ )
350
+
351
+ notes: Optional[str] = Field(
352
+ default=None,
353
+ description=(
354
+ "Optional free-form explanation of the intended meaning of this plan, useful for "
355
+ "debugging or logging but not used for filtering."
356
+ ),
357
+ )
358
+
359
+
360
+ EMAIL_IMAP_QUERY_PROMPT = """
361
+ You are an assistant that translates NATURAL LANGUAGE email-search requests
362
+ into a JSON object that matches the Pydantic model `IMAPLowLevelPlan`.
363
+
364
+ The resulting plan describes email filters in Disjunctive Normal Form (DNF):
365
+
366
+ - `clauses` is a list of filter clauses.
367
+ - The overall meaning is: a message matches if it satisfies ANY one clause
368
+ (clause_1 OR clause_2 OR ...).
369
+
370
+ Within a single clause:
371
+
372
+ - All specified conditions in that clause must be satisfied at the same time
373
+ (logical AND within the clause).
374
+ - IMPORTANT: For any list field in a clause (e.g. from_, to, cc, bcc, subject,
375
+ text, body, header, keyword, unkeyword, uid, raw_tokens, excludes.*), MULTIPLE
376
+ VALUES ARE INTERPRETED AS LOGICAL AND. That is, every listed value must match
377
+ for that field.
378
+ - If the user wants OR semantics between multiple values for the same kind of
379
+ condition (e.g. "from Google OR Microsoft", "subject contains 'invoice' OR
380
+ 'receipt'"), you MUST express this using MULTIPLE CLAUSES, one per alternative.
381
+ For example:
382
+ - Clause 1: from_ = ["google.com"]
383
+ - Clause 2: from_ = ["microsoft.com"]
384
+ so that the overall `clauses` acts as OR.
385
+
386
+ The clause may constrain:
387
+ - sender and recipients (from_, to, cc, bcc)
388
+ - subject and body text (subject, text, body)
389
+ - specific headers (header)
390
+ - dates and sent-dates (since, before, on, sent_since, sent_before, sent_on)
391
+ - message state (flags)
392
+ - message size (larger, smaller)
393
+ - keywords and labels (keyword, unkeyword)
394
+ - UID ranges (uid)
395
+ - explicit exclusions (excludes)
396
+ - and may optionally mark the clause as focusing on newsletters, invoices/receipts,
397
+ security alerts, or messages likely to include attachments.
398
+
399
+ Interpret the fields as follows:
400
+
401
+ - from_ / to / cc / bcc:
402
+ Each list entry is a substring used to restrict who sent or received the email.
403
+ Within a clause, all listed values for a field are required (logical AND).
404
+
405
+ - subject:
406
+ Each list entry is a substring that must appear in the subject. All listed
407
+ values are required when present (logical AND).
408
+
409
+ - text:
410
+ Each list entry is a substring that must appear somewhere in the headers OR
411
+ body of the message. All listed values are required (logical AND).
412
+
413
+ - body:
414
+ Each list entry is a substring that must appear in the message body only.
415
+ All listed values are required (logical AND).
416
+
417
+ - header:
418
+ Each entry describes a header name and a required substring in that header's value.
419
+ All header conditions within a clause must be satisfied (logical AND).
420
+
421
+ - since / before / on:
422
+ Date constraints based on the message date. Use ISO strings 'YYYY-MM-DD'.
423
+
424
+ - sent_since / sent_before / sent_on:
425
+ Date constraints based on the message's sent date. Use ISO strings 'YYYY-MM-DD'.
426
+
427
+ - flags:
428
+ A set of boolean filters describing whether the message should be read/unread,
429
+ answered/unanswered, flagged/unflagged, draft/undraft, recent/new, etc.
430
+
431
+ - larger / smaller:
432
+ Minimum and maximum message sizes in bytes (strict inequalities).
433
+
434
+ - keyword:
435
+ Required keywords or labels; every listed keyword must be present (logical AND).
436
+
437
+ - unkeyword:
438
+ Forbidden keywords or labels; messages are excluded if they have any of these.
439
+
440
+ - uid:
441
+ Restrictions based on message UID, using individual identifiers or ranges such
442
+ as '100:200'. All listed UID restrictions are combined with AND unless encoded
443
+ as separate clauses.
444
+
445
+ - excludes:
446
+ Negative filters; any message whose sender/recipient/subject/header/text/body
447
+ matches these exclusion rules is removed from the result, even if it matched
448
+ the positive filters. Multiple values in the same excludes list are combined
449
+ with AND (the message must match all those exclusion conditions to be removed).
450
+
451
+ - use_newsletters:
452
+ If true, the clause is intended to match emails that look like newsletters or
453
+ subscription-style marketing/announcement mail.
454
+
455
+ - use_invoices_or_receipts:
456
+ If true, the clause is intended to match finance-related transactional emails
457
+ such as invoices, receipts, payment confirmations, or order confirmations.
458
+
459
+ - use_security_alerts:
460
+ If true, the clause is intended to match security or account alerts, including
461
+ login notifications, password-change messages, and verification-code emails.
462
+
463
+ - use_with_attachments_hint:
464
+ If true, the clause is intended to emphasize emails that likely contain file
465
+ attachments, approximated by searching for attachment-related markers.
466
+
467
+ - raw_tokens (at the clause level):
468
+ Extra raw IMAP search tokens that apply only to that clause. Multiple tokens
469
+ in this list are combined with AND semantics.
470
+
471
+ At the top level:
472
+
473
+ - raw_tokens:
474
+ Extra IMAP search tokens that apply to the entire search, after all clauses.
475
+ Multiple tokens here are also combined with AND semantics.
476
+
477
+ General-request handling (VERY IMPORTANT):
478
+
479
+ - If the user's request is general or underspecified (e.g. "find emails about X",
480
+ "messages related to Y", "anything mentioning Z", without specifying subject-only
481
+ or sender/recipient constraints), you should prefer searching BOTH subject and
482
+ body by using `text` (headers OR body) and/or `body`, rather than `subject` alone.
483
+ - When helpful, include common synonyms or closely related terms as OR alternatives
484
+ by using MULTIPLE CLAUSES (one per synonym/alternative), since lists within a
485
+ clause are ANDed.
486
+ Example:
487
+ - User: "emails about refunds"
488
+ - Clause 1: text=["refund"]
489
+ - Clause 2: text=["reimbursement"]
490
+ - Clause 3: text=["chargeback"]
491
+ - Do NOT add synonyms if the user is already very specific (exact phrase, specific
492
+ sender, invoice number, etc.). Keep those plans minimal and precise.
493
+
494
+ Your task:
495
+
496
+ 1. Read the user's natural-language request describing which emails they want.
497
+ 2. Construct one or more clauses, using the fields above, so that the overall
498
+ result matches the described intent.
499
+ 3. Remember: multiple values in the SAME LIST FIELD inside a clause are ANDed.
500
+ If the user speaks about alternatives with OR semantics (e.g. "A or B"), you
501
+ should create separate clauses (one per alternative), so they are combined
502
+ with OR at the top-level `clauses`.
503
+ 4. Use several clauses if the user describes different categories of emails that
504
+ should be combined with OR semantics (e.g. "either invoices OR security alerts").
505
+ 5. When date or state information is unclear or not requested, simply leave those
506
+ fields at their default values or omit them.
507
+
508
+ Constraints:
509
+
510
+ - You MUST output a single JSON object that can be parsed as `IMAPLowLevelPlan`.
511
+ - Do NOT output Python code, comments, or explanations.
512
+ - Do NOT invent fields that are not defined in the Pydantic models.
513
+ - Prefer clear, minimal conditions that directly capture the user's request.
514
+
515
+ User request:
516
+ {user_request}
517
+ """
518
+
519
+
520
+ def _apply_imap_clauses(q: IMAPQuery, c: IMAPClauses) -> None:
521
+ """
522
+ Apply the low-level IMAPQuery part of one clause to an IMAPQuery instance.
523
+ """
524
+
525
+ # basic positive fields
526
+ for s in c.from_:
527
+ q.from_(s)
528
+ for s in c.to:
529
+ q.to(s)
530
+ for s in c.cc:
531
+ q.cc(s)
532
+ for s in c.bcc:
533
+ q.bcc(s)
534
+ for s in c.subject:
535
+ q.subject(s)
536
+ for s in c.text:
537
+ q.text(s)
538
+ for s in c.body:
539
+ q.body(s)
540
+ for hf in c.header:
541
+ if hf.name and hf.value:
542
+ q.header(hf.name, hf.value)
543
+
544
+ # dates
545
+ if c.since:
546
+ q.since(c.since)
547
+ if c.before:
548
+ q.before(c.before)
549
+ if c.on:
550
+ q.on(c.on)
551
+ if c.sent_since:
552
+ q.sent_since(c.sent_since)
553
+ if c.sent_before:
554
+ q.sent_before(c.sent_before)
555
+ if c.sent_on:
556
+ q.sent_on(c.sent_on)
557
+
558
+ # flags
559
+ f = c.flags
560
+ if f.seen:
561
+ q.seen()
562
+ if f.unseen:
563
+ q.unseen()
564
+ if f.answered:
565
+ q.answered()
566
+ if f.unanswered:
567
+ q.unanswered()
568
+ if f.flagged:
569
+ q.flagged()
570
+ if f.unflagged:
571
+ q.unflagged()
572
+ if f.deleted:
573
+ q.deleted()
574
+ if f.undeleted:
575
+ q.undeleted()
576
+ if f.draft:
577
+ q.draft()
578
+ if f.undraft:
579
+ q.undraft()
580
+ if f.recent:
581
+ q.recent()
582
+ if f.new:
583
+ q.new()
584
+
585
+ # size
586
+ if c.larger is not None:
587
+ q.larger(c.larger)
588
+ if c.smaller is not None:
589
+ q.smaller(c.smaller)
590
+
591
+ # keyword / unkeyword
592
+ for kw in c.keyword:
593
+ q.keyword(kw)
594
+ for kw in c.unkeyword:
595
+ q.unkeyword(kw)
596
+
597
+ # uid
598
+ if c.uid:
599
+ q.uid(*c.uid)
600
+
601
+ # excludes
602
+ ex = c.excludes
603
+ for s in ex.from_:
604
+ q.exclude_from(s)
605
+ for s in ex.to:
606
+ q.exclude_to(s)
607
+ for s in ex.cc:
608
+ q.exclude_cc(s)
609
+ for s in ex.bcc:
610
+ q.exclude_bcc(s)
611
+ for s in ex.subject:
612
+ q.exclude_subject(s)
613
+ for hf in ex.header:
614
+ if hf.name and hf.value:
615
+ q.exclude_header(hf.name, hf.value)
616
+ for s in ex.text:
617
+ q.exclude_text(s)
618
+ for s in ex.body:
619
+ q.exclude_body(s)
620
+
621
+ # clause-local raw tokens
622
+ if c.raw_tokens:
623
+ q.raw(*c.raw_tokens)
624
+
625
+
626
+ def _apply_clause_to_easy(easy: EmailQuery, c: IMAPClauses) -> None:
627
+ """
628
+ Apply ONE clause to an EmailQuery:
629
+ - First, clause-local high-level filters (newsletters, invoices/receipts, security alerts,
630
+ with-attachments hint).
631
+ - Then, low-level IMAPQuery primitives onto easy.query.
632
+ """
633
+
634
+ if c.use_newsletters:
635
+ easy.newsletters()
636
+
637
+ if c.use_invoices_or_receipts:
638
+ easy.invoices_or_receipts()
639
+
640
+ if c.use_security_alerts:
641
+ easy.security_alerts()
642
+
643
+ if c.use_with_attachments_hint:
644
+ easy.with_attachments_hint()
645
+
646
+ _apply_imap_clauses(easy.query, c)
647
+
648
+
649
+ def _apply_low_level_to_easy_query(easy: EmailQuery, low: IMAPLowLevelPlan) -> None:
650
+ """
651
+ Apply IMAPLowLevelPlan (DNF) onto an EmailQuery.
652
+
653
+ - If 0 clauses: do nothing.
654
+ - If 1 clause: AND it directly into `easy` (helpers + low-level).
655
+ - If N>=2 clauses:
656
+ * Build N sub-queries with separate EmailQuery instances.
657
+ * Combine them with OR into the main easy.query via easy.query.or_(*subqueries).
658
+ """
659
+
660
+ if not low.clauses:
661
+ pass
662
+ elif len(low.clauses) == 1:
663
+ _apply_clause_to_easy(easy, low.clauses[0])
664
+ else:
665
+ subqueries: List[IMAPQuery] = []
666
+
667
+ for clause in low.clauses:
668
+ sub_q = IMAPQuery()
669
+
670
+ clause_easy = EmailQuery(None, "INBOX")
671
+ clause_easy.query = sub_q
672
+
673
+ _apply_clause_to_easy(clause_easy, clause)
674
+ subqueries.append(sub_q)
675
+
676
+ easy.query.or_(*subqueries)
677
+
678
+ if low.raw_tokens:
679
+ easy.raw(*low.raw_tokens)
680
+
681
+
682
+ def llm_easy_imap_query_from_nl(
683
+ user_request: str,
684
+ *,
685
+ provider: str,
686
+ model_name: str,
687
+ mailbox: str = "INBOX",
688
+ ) -> Tuple[EmailQuery, Dict[str, Any]]:
689
+ """
690
+ Use an LLM to translate a natural-language request into an EmailQuery,
691
+ where the final LLM output schema is IMAPLowLevelPlan (a list of DNF clauses).
692
+ """
693
+ chain = get_model(provider, model_name, IMAPLowLevelPlan)
694
+ result, llm_call_info = chain(EMAIL_IMAP_QUERY_PROMPT.format(user_request=user_request))
695
+ plan = result
696
+ easy = EmailQuery(manager=None, mailbox=mailbox)
697
+ _apply_low_level_to_easy_query(easy, plan)
698
+
699
+ return easy, llm_call_info